* 🍃 fix: Amazon DocumentDB Compatibility for Pipeline-Form Updates
- Rewrite acceptTerms without aggregation-pipeline update + $$NOW (null-guarded first-acceptance claim preserves the original timestamp under concurrent and repeat requests)
- Rewrite decrementTagCounts clamp-at-zero decrement as ordered two-op bulkWrite (clamp before guarded $inc)
- Rewrite extendFilesTTL TTL hold as projected read + per-doc guarded $set via tenantSafeBulkWrite, preserving only-widens/ceiling/cleared-stays-permanent semantics
- Log background index-build failures via Model 'index' listeners (previously swallowed silently, e.g. partialFilterExpression rejection on DocumentDB <5.0)
- Add misc/documentdb live-compatibility harness + assessment (AWS-cited)
- Fix latent file.spec helper bug: createdAt backdating was silently stripped by mongoose immutability
Closes#14488
* 🍃 fix: Harden DocumentDB-Safe Updates Against Cross-Call Races
Addresses Codex review on #14495:
- decrementTagCounts: normalize-null / $inc / clamp-negative op triple so
interleaved decrements of the same tag converge on max(0, ...) exactly as
the serialized pipeline did (clamp keys on count < 0, not count < amount)
- acceptTerms: guard the repeat-acceptance fallback with a non-null timestamp
(exact complement of the claim guard) and retry the claim when a
config/reset-terms.js reset races between the two updates, so acceptance
never resurrects a reset cycle without a fresh audit timestamp
* 👷 ci: Suppress Ignored-File Warnings in Changed-File ESLint Run
Changed files under config-ignored paths (packages/data-schemas/misc/**)
emit "File ignored" warnings that fail --max-warnings=0.
* 🛡️ fix: Guard HITL checkpoint size against MongoDB 16MB limit
A LangGraph HITL checkpoint embeds the whole serialized message history
in a single BSON document, so a large conversation (inlined base64 media,
big tool outputs, long history) can serialize past MongoDB's 16MB
document ceiling. `MongoDBSaver.put` would then throw a raw
`BSONObjectTooLarge` at pause time and the pause would be lost with no
legible error.
`LazyMongoSaver` now measures the serialized checkpoint on the persist
path (rare HITL pauses only — the clean-exit common path is untouched):
debug-logs the size, warns past a soft 8MB threshold, and throws a typed
`CheckpointTooLargeError` before the doomed write past a 15MB hard limit
(16MB minus headroom for the document's other fields). Thresholds are
overridable via the constructor for testing.
Adds integration coverage (real serde + mongodb-memory-server) for the
under-threshold, soft-warn, and hard-reject cases.
* 🧱 fix: Add explicit types for isolatedDeclarations build
The production build (tsdown + rolldown-plugin-dts) compiles with
--isolatedDeclarations, which requires explicit type annotations on
exported bindings whose initializers it can't infer syntactically.
`CHECKPOINT_HARD_LIMIT_BYTES` (arithmetic over two consts) tripped
TS9010; annotate it and `CHECKPOINT_WARN_BYTES` as `number`. Verified
with `tsc --isolatedDeclarations` over the package.
* fix: Codex review — include metadata in the size guard + flush parked bookkeeping
P1 (lost bookkeeping): `put` consumes the write anchor, then AWAITS
assertCheckpointFitsDocument (checkpoint serialization). A bookkeeping-only
putWrites dispatched in that window sees neither the anchor nor persistedIds,
so it parks — and `put` never flushed it, dropping the marker (e.g. a
completed Send-sibling's __no_writes__) so a resume re-executed the sibling.
Extract flushBufferedBookkeeping (shared with the anchoring putWrites) and call
it after super.put in the persist path.
P2 (metadata ignored by guard): MongoDBSaver.put stores the serialized
checkpoint AND metadata (plus metadata_search) in the SAME document, but the
guard measured only the checkpoint — a just-under-limit checkpoint with large
metadata fell through to a raw BSONObjectTooLarge. Measure checkpoint +
metadata; the fixed headroom now only covers metadata_search/ids/framing.
Two integration regressions added (both fail without the fix, pass with it):
metadata-pushes-over-the-ceiling, and flush-during-the-serialization-window.
* fix: count metadata_search (raw metadata copy) in the checkpoint size guard
Codex follow-up: MongoDBSaver.put stores metadata a SECOND time as
`metadata_search: metadata` — the whole raw metadata object as a queryable BSON
subdocument in the same agent_checkpoints document. Measuring only checkpoint +
serialized metadata undercounted by that raw copy, so a large metadata.writes
payload could pass the 15 MB preflight while metadata_search pushed the actual
BSON past 16 MB — the raw BSONObjectTooLarge the guard exists to prevent.
Add mongoose.mongo.BSON.calculateObjectSize(metadata) for the metadata_search
contribution (mongoose already imported; no new dep). Headroom now only covers
ids + BSON framing. New integration test sizes a case where checkpoint +
serialized metadata is under the limit but the raw metadata_search copy pushes
it over — green (24/24).
* ci: run packages/api agents integration specs (checkpointer) in CI
The checkpointer.integration.spec.ts (durable HITL checkpointer vs a real
in-process MongoDB) is a *.integration.spec.ts, which test:ci deliberately
excludes — and cache-integration-tests.yml only covers cache/cluster/mcp/
stream, not src/agents/**. So it ran nowhere and its regressions guarded
nothing. Add:
- test:agents-integration script (jest over src/agents/*.integration.spec.ts,
runInBand — mongodb-memory-server is in-process, no external service);
- a dedicated agents-integration-tests.yml (mirrors the proven build setup,
no Redis) triggered on packages/api/src/agents/** changes;
- babel-plugin-replace-ts-export-assignment as a packages/api devDep: the spec
imports @langchain/langgraph-checkpoint (whitelisted for babel transform,
uses `export =`), whose transform needs this plugin — it was only present
under client/node_modules, unresolvable from packages/api, so the suite
couldn't load. 24/24 pass locally.
DataTable.spec failed with "Too many re-renders" (35 tests). Root cause: @tanstack/react-virtual is measurement-driven, and jsdom has no real layout, so its re-render loop never converges. This went unnoticed because packages/client had no jest CI job (only the client workspace runs jest in frontend-review.yml).
- DataTable: only read the virtualizer (getVirtualItems/getTotalSize) when virtualization is active; the non-virtualized branch renders rows directly, so engaging it for small tables was wasted render-phase work.
- Spec: mock @tanstack/react-virtual, since jsdom can't exercise real virtualization layout.
- Add a test:ci script to @librechat/client and a Tests: @librechat/client CI job so packages/client specs run on every frontend PR.
The gitnexus droplet is ~8.7GB usable, not the 60GB the disk-cleanup
comment assumed. With /usr (~2.8GB), the in-use docker images (~2.1GB),
and the growing /opt/gitnexus/indexes (~1.2GB), deploys were aborting at
the `AVAIL_MB < 2048` guard ("Disk critically low").
Two fixes:
- Reclaim the previous gitnexus image after force-recreate. The pre-pull
`docker system prune -af` cannot remove it while the old container is
still running, so a stale ~700MB generation accumulated every deploy.
A post-recreate `docker image prune -f` makes the box self-cleaning.
- Lower the abort threshold 2048 -> 1536MB. The image is ~700MB and
shares most layers with the running one, so an incremental pull needs
well under 1GB; the old guard was sized for the 60GB assumption.
Also corrects the stale 60GB comment to reflect the actual disk.
* 🗜️ ci: Cache Dependencies and Builds in Cache Integration Tests
Port the node_modules and package-dist caching pattern from
backend-review.yml to cache-integration-tests.yml, which ran a full
npm ci (~72s) and rebuilt data-provider, data-schemas, and api on
every run. Cache keys are identical to backend-review.yml so the two
workflows share entries. Drops setup-node's npm tarball cache,
superseded by the node_modules restore, matching backend-review.yml.
* 🗜️ ci: Exercise Warm-Cache Path
* 🪟 ci: Shard Windows Frontend Unit Tests
Mirror the 4-way jest sharding the Ubuntu frontend test job already
uses onto the Windows job, which currently runs the whole client suite
in a single 20-minute job. Also drops the `--verbose` flag, which npm
consumed itself (it preceded `--`) and only raised npm's own log level.
* 🪟 ci: Trigger Frontend Tests on Workflow Changes
* 👷 ci: Add API runtime smoke (boot the production image) to docker-smoke
The docker-smoke workflow only built the `client-package-build` stage and
never booted the runtime, so it couldn't catch the class of regression that
recently took production down: the api tsdown bundle externalizes runtime
deps that, after `npm ci --omit=dev`, were missing from the image
(`Cannot find module 'get-stream'`).
- Add an `api-runtime-smoke` job that builds the real production image
(final `api-build` stage, `npm ci --omit=dev`), then:
1. loads the @librechat/api bundle's full require graph in the pruned
image (deterministic, no DB) — fails on any missing/ESM-incompatible
runtime dependency.
2. boots the actual entrypoint and asserts no module-load crash (the
server loads its require graph before connecting to Mongo, so this
surfaces without a database).
- Expand triggers to include `packages/api/**`, `packages/data-schemas/**`,
and `api/package.json` (previously a packages/api change only triggered
this via a root lockfile change, and even then only built the client stage).
- Add gha build cache + concurrency cancellation to bound CI cost.
* 👷 ci: Address Codex review — boot smoke against real Mongo + crash detection
- Boot the production image against a real MongoDB container with the env
the server needs, so the *entire* require graph loads. `api/db/connect.js`
throws at module scope without `MONGO_URI` and is imported before
models/services/routes, so the previous no-env boot exercised almost none
of the legacy API graph. (Codex finding 2)
- Gate on `/health` returning 200 AND the container staying alive, failing on
any container exit. A non-module startup crash (ReferenceError, SyntaxError,
bad config) now fails the smoke instead of slipping past a missing-module
grep. (Codex finding 3)
- Expand trigger from `api/package.json` to `api/**`, since the image copies
the whole `api/` tree and runs `node server/index.js`. (Codex finding 1)
* 👷 ci: Address Codex round 2 — poll /readyz + cover all image inputs
- Poll /readyz instead of /health. /health returns 200 at app.listen, but
initializeMCPs() and checkMigrations() run *after* listen and process.exit(1)
on failure; /readyz only returns 200 once serverReady is set after those
complete. So post-listen startup crashes now fail the smoke too. (finding A)
- Expand triggers to every source tree copied into the production image:
client/**, config/**, skill/** (the final stage copies client/dist, config,
and skill). (finding B)
* ⚡ refactor: Migrate @librechat/client build from Rollup to tsdown
Mirrors the data-schemas migration. Replaces Rollup (rpt2 + postcss) with
tsdown (rolldown + oxc); the package build drops from tens of seconds to ~0.3s.
- Emit isolated-declaration .d.ts via oxc (dts.oxc) and enforce
isolatedDeclarations in tsconfig for editor DX (source made clean: explicit
export type annotations added across src, no `any`).
- Extract component CSS to dist/style.css so the CJS output stays valid
CommonJS (the prior postcss runtime-injection produced an ESM import in the
CJS bundle that breaks jest/require). Imported once in the client app entry;
Vite bundles it for the app.
- Repoint package.json to dual .mjs/.cjs + .d.mts/.d.cts and add ./style.css
and ./package.json exports.
- Update CI build-cache keys to hash tsdown.config.mjs; remove rollup.config.js.
* 🔧 chore: address Codex review on client tsdown migration
- Add tsdown.config.mjs to turbo.json build `inputs` so changes to the new
bundler config invalidate the Turbo cache (the shared inputs only listed the
rollup configs). Also covers the already-migrated data-schemas.
- Name the memoized default export (ControlComboboxMemo) instead of the
codefix-generated `_default_1`, for clearer stack traces / grepping.
Replace the Rollup + `rollup-plugin-typescript2` build with a split
pipeline: tsdown (rolldown) bundles the JS in ~0.2s, and plain `tsc`
emits the declarations to `dist/types` (~2s). Full cold build drops from
~9.2s to ~2.5s (~3.6x) with zero source changes.
Unlike data-schemas, the fast oxc/isolated-declarations dts path isn't
viable here: the package's 78 exported zod schemas produce 374
`isolatedDeclarations` errors (TS9013/TS9038) and a `z.ZodType<T>`
annotation would break the 76 downstream `.extend`/`.shape`/`.pick`
usages. Plain `tsc` keeps the rich zod types intact, and since dts was
never the bottleneck (rollup-plugin-typescript2 was), the win stands.
- dts stays unbundled in `dist/types/` — identical to the prior output,
so the existing deep `dist/types` imports and the exports `types`
paths are unchanged.
- ESM output renamed `index.es.js` -> `index.mjs` (via the exports map;
no consumer hardcodes the old path). cjs/types paths unchanged.
- `./react-query` now emits a real cjs build + types — the exports map
already promised them, but Rollup only ever built the esm file.
- Kept `rollup` + the plugins used by `server-rollup.config.js`
(the `rollup:api` server-bundle smoke test in backend-review.yml);
removed only the deps used solely by the deleted `rollup.config.js`.
- Repointed CI build-cache keys from `rollup.config.js` to
`tsdown.config.mjs`.
* ⚡️ refactor: Migrate @librechat/api build to tsdown
Replace Rollup with tsdown (rolldown + oxc isolated-declarations) for the
@librechat/api package build, mirroring the merged data-schemas migration.
- Add tsdown.config.mjs (cjs output, oxc dts, externalize all bare deps,
bundle first-party `~/` + relative imports)
- Annotate exports for isolatedDeclarations (codefix-driven). Collapse the
tokens.ts model->token maps to Record<string, Record<string, number>> and
switch validation.ts's runtime `files` field from z.any() to z.unknown()
so no explicit `any` is introduced
- Repoint package.json main/types/exports to tsdown's .cjs/.d.cts output
- Add src/telemetry.ts entry shim so the two index.ts entries don't collide
in oxc's flat dts output (stable dist/telemetry.{cjs,d.cts})
- Delete rollup.config.js
Build time ~36s -> ~0.5s. No runtime behavior change: 5712 unit tests pass,
both entries load via require(), legacy /api consumes them unchanged.
* 👷 ci: Hash packages/api/tsdown.config.mjs in build-api cache keys
The build-api cache keys hashed `packages/api/server-rollup.config.js`,
which never existed (api used `rollup.config.js`, now removed) — a copy-paste
artifact from the data-provider key that matched no file. Replace it with the
new `packages/api/tsdown.config.mjs` so edits to the build config (entry,
format, externals) bust the api build cache, matching the data-schemas key.
* ⚡ perf: Migrate data-schemas Build to tsdown with isolatedDeclarations
Replace Rollup with tsdown (rolldown + oxc) for @librechat/data-schemas. With the source made isolatedDeclarations-clean, oxc emits .d.ts without tsc, dropping the package build from ~5.8s to ~0.8s (~7x).
- Annotate exported model/method factories for isolatedDeclarations (TypeScript's fixMissingTypeAnnotationOnExports codefix plus hand-authored interfaces); type the ~44 mongoose `any`s and add an explicit PromptMethods interface (previously its declaration was silently dropped by the Rollup build).
- Repoint package.json exports/main/module/types to tsdown output; drop rollup config.
- Config lives in tsdown.config.mjs (native ESM) so CI without a TS-config loader can build it; bundle `dotenv` so the package stays self-contained for its env-loading side effect.
- Fix a latent token `metadata` mismatch the accurate types surfaced: widen TokenCreate/UpdateData inputs to accept plain objects, flatten OAuthMetadata at the api boundary.
- Update mongoMeili/aclEntry specs to the precise model types; drop redundant terser minification from data-provider's library build.
All data-schemas tests pass; api builds clean against the new output.
* 🔧 chore: Hash tsdown.config.mjs in data-schemas CI build-cache keys
The data-schemas build switched from rollup to tsdown, but the build-data-schemas / build-api cache keys in backend-review, config-review, and playwright-mock still hashed the (now-deleted) rollup.config.js. Hash tsdown.config.mjs instead so a config-only change invalidates the cached dist/api builds. (Found by Codex review.)
* 🔧 chore: Replace deprecated tsdown `external` with `deps.neverBundle`
tsdown 0.22 deprecated the top-level `external` option in favor of `deps.neverBundle`. Migrate the data-schemas config and set `deps.onlyBundle: false` to silence the (intentional) dotenv bundling hint. Build output and externalization are unchanged — dotenv bundled, all peers external.
* 🩹 fix: Bump GitNexus to 1.6.5 and Fail-Soft the PR Index Job
The GitNexus Index workflow began failing on most PRs with
"Analysis failed: Maximum call stack size exceeded". Root cause is in
the pinned gitnexus@1.5.3 CLI: pipeline.js does
`deferredWorkerCalls.push(...chunkWorkerData.calls)`, and once a chunk
yields more extracted calls than V8's argument-count limit (~125k on
this repo) the spread-push throws a RangeError. It is deterministic on
repo size, not flaky — LibreChat simply grew past the threshold, so it
fails "more often" as more branches cross it. Stack-size flags don't
help; it's an arg-count limit, not stack depth.
gitnexus@1.6.5 refactored that code path (the .calls spread-pushes are
gone) and indexes this repo cleanly. Bump the indexer, the deploy image
tag/build-arg, and the Dockerfile default in lockstep (an index written
by 1.6.5 must be served by a 1.6.5 server), and move the co-pinned
@ladybugdb/core to 0.16.1 to match.
Also make the index job fail-soft on pull_request events so a future
tool-internal crash degrades gracefully instead of red-X'ing PRs. Push,
dispatch, and /gitnexus command runs still fail loudly, keeping the
deploy-gating and completion-comment logic correct.
* 🐳 fix: Unbreak the GitNexus Deploy Image for 1.6.5
Addresses two issues in the deploy image surfaced after the 1.6.5 bump:
- The image build's lbug-adapter patch grepped
dist/mcp/core/lbug-adapter.js for "LOAD EXTENSION fts", but in 1.6.5
that file is a shim re-export and the FTS load moved to
dist/core/lbug/lbug-adapter.js. The grep would fail the build on the
next image rebuild. The patch is also obsolete: 1.6.5 loads the vector
extension itself via loadVectorExtension. Removed the patch step.
- The image installed only gitnexus, letting @ladybugdb/core resolve
freely via gitnexus's ^0.16.1 range while the index workflow pins
0.16.1 exactly. Pin the native DB in the image too (nested under
gitnexus so install-extensions.js keeps resolving it), restoring the
intended indexer/server lockstep.
The `client/` workspace was never type-checked: the existing typecheck
job only covered `packages/` and `api/`, and Vite/esbuild transpiles
without type-checking, so type errors shipped through every CI gate.
- Add a `typecheck` job to frontend-review.yml running `tsc --noEmit`
over `client/` (zero tolerance), reusing the data-provider +
client-package build artifacts. Triggers on `client/**`,
`packages/client/**`, `packages/data-provider/**`.
- Fix all 168 pre-existing client type errors this surfaced (source +
tests), including genuine latent bugs:
- `getFileConfig()` was typed as merged `FileConfig`, but the server
returns the raw config that `mergeFileConfig()` consumes (`TFileConfig`).
- SidePanel/Agents `Retrieval`/`ImageVision` were bound to `AgentForm`
but use the assistants `Capabilities` enum → `AssistantForm`.
- `useSearchResultsByTurn` read a `sources` field its type lacked.
- Removed orphaned dead code: `Artifacts/Mermaid.tsx` (imported a
never-installed dep) and dead barrel re-exports (`./Plugins`, `./MCPAuth`).
- Narrow `client/tsconfig.json` to the client app (drop `../e2e` and
`../config/translations`, which reference backend/tooling modules) so
the gate's scope matches its trigger.
No `any`/`@ts-ignore`/`as unknown as`. Localized newly-surfaced strings.
* 🔧 chore: Update ESLint config, add import sorting script, Test Sharding, Bump `@librechat/agents`
* Change 'no-nested-ternary' rule from 'warn' to 'error' in ESLint config
* Add new scripts for sorting imports in the project
* Update lint-staged configuration to include import sorting
* Modify GitHub Actions workflows to support sharding for unit tests
* chore: remove nested ternary expressions
* refactor: Extract scale multiplier logic into a separate function in CircleRender component
* refactor: Simplify auto-refill rendering logic in Balance component for better readability
* refactor: Improve width style handling in DataTable components for clarity and maintainability
* chore: remove CircleRender component
* delete: Remove CircleRender component as it is no longer needed in the project
* chore: Bump @librechat/agents to version 3.2.31 and update Node.js engine requirement
* Update @librechat/agents dependency from 3.2.2 to 3.2.31 in package-lock.json, api/package.json, and packages/api/package.json
* Change Node.js engine requirement from >=20.0.0 to >=24.0.0 in @librechat/agents
* chore: Add import sorting check to ESLint CI workflow
* Implement a new job in the GitHub Actions workflow to verify import ordering on changed files.
* The job checks for changes in specific file types and reports any import order drift, providing instructions for local fixes.
* 🎭 test: Run Mock E2E Suite Through createRun With In-Process Fake Model
Replace the standalone HTTP mock LLM server with an in-process fake model
injected into the real createRun -> Run.create pipeline via
run.Graph.overrideTestModel, so the mock suite exercises the agents
integration end-to-end without a live provider or a separate server.
- Bump @librechat/agents to 3.2.2 for the FakeChatModel/createFakeStreamingLLM exports
- Add an env-gated applyTestRunHook seam in packages/api createRun (no /api changes)
- Add e2e/setup/fake-model.js to drive default replies + the skill-authoring tool-call flow
- Drop the mock-llm webServer from playwright.config.mock.ts and set LIBRECHAT_TEST_RUN_HOOK
* 🧹 test: Retire Standalone Mock LLM Server From E2E Recorder
Migrate the `--profile=mock` recorder onto the same in-process fake model
as the Playwright mock suite, then delete the now-unused HTTP mock server
so the fake-LLM logic lives in a single place.
- Point record.js mock profile at the fake model via LIBRECHAT_TEST_RUN_HOOK
- Remove the mock-llm-server spawn/wait and MOCK_LLM_PORT plumbing from record.js
- Delete e2e/setup/mock-llm-server.js (e2e/setup/fake-model.js is now the only source)
- Update e2e/README.md to describe the in-process fake LLM
* 🏷️ ci: Rename Playwright Mock E2E Check to Playwright E2E Tests
* feat: Add granular access control to shared links via ACL system
* fix(shared-links): preserve isPublic on failed migration grants
Transient ACL failures during auto-migration permanently stranded
links — $unset ran unconditionally, removing the legacy flag that
triggers retry. Now only $unset isPublic after all grants succeed.
* fix(config): skip isPublic unset for failed ACL grants
Bulk migration unconditionally removed isPublic from all links,
even those whose ACL writes failed. Failed links then lost the
legacy marker needed for auto-migration retry. Now tracks failed
link IDs per-batch and excludes them from the $unset step.
Also adds sharedLink to AccessRole resourceType schema enum —
was missing, only worked because seedDefaultRoles uses
findOneAndUpdate which bypasses validation.
* ci(config): add jest config and PR workflow for migration tests
config/__tests__/ specs depend on api/jest.config.js module
mappings but had no dedicated runner. Adds config/jest.config.js
extending api config with absolutized paths, npm test:config
script, and a GitHub Actions workflow triggered by changes to
config/, api/models/, api/db/, or packages/ ACL code.
* fix(permissions): honor boolean sharedLinks config
SHARED_LINKS has no USE permission, so boolean config produced
an empty update payload — gate conditions only matched object
form, making `sharedLinks: false` a no-op on existing perms.
* fix(share): resolve role before creating shared link
Role lookup between create and grant left an orphaned link
without ACL entries if getRoleByName threw — retry then hit "Share already exists" with no recovery path.
* fix: Restore Public ACL Access Checks
* fix: Type Public ACL Lookup
* fix: Preserve Private Legacy Shared Links
* chore: Promote Shared Link Permission Migration
* fix: Address Shared Link Review Findings
* fix: Repair Shared Link CI Follow-Up
* fix: Narrow Shared Link Mongoose Test Mock
* fix: Address Shared Link Review Follow-Ups
* fix: Close Shared Link Review Gaps
* fix: Guard Missing Shared Link Permission Backfill
* test: Add Shared Link Mock E2E
* test: Stabilize Shared Link Mock E2E
---------
Co-authored-by: Danny Avila <danny@librechat.ai>
The `Tests: @librechat/api` job in `backend-review.yml` had
`timeout-minutes: 10`. Recent runs have started getting cancelled
right at the 10-minute mark with tests still actively passing — the
log streams `PASS …` lines through the final second before
`The operation was canceled.` fires.
Looking at recent runs in the wild:
- Warm `ubuntu-latest` runners: suite finishes in ~4–5 min.
- Cold/busy runners: 10+ min. No headroom = job killed mid-suite.
The suite has grown over the last few months (OpenTelemetry, MCP
OAuth, Bedrock, the Operational Prometheus Metrics work from #13265,
plus the agents/responses harness). 10-minute ceiling that worked
before is now a tail-latency cliff.
Bump to 20 minutes. Still well below the runner's hard 6-hour cap,
generous enough to absorb runner variance, and a stuck/genuine hang
will still get killed eventually rather than billing forever.
No code changes; one-line config bump.
Adds a `Run Prettier --check on changed files` step to the existing
`eslint-ci.yml` workflow. Same path filter (api/**, client/**,
packages/**), same changed-files detection, runs after the ESLint step.
## Why
Today there is no `prettier --check` in CI — only the local
`lint-staged` pre-commit hook runs `prettier --write`. When a PR is
merged with the hook bypassed (e.g. GitHub UI edit-and-merge, or
`git commit --no-verify`), a file can land in a non-prettier-canonical
state and nobody notices. The next contributor who stages an unrelated
change in that file then ends up with a "drive-by" prettier diff in
their PR.
`packages/api/src` had 14 such files accumulated; #13281 fixes the
existing drift. This PR closes the gap so it doesn't regrow.
## What the step does
- Detects changed JS/TS files under `api/**`, `client/**`, or
`packages/**` against the PR base.
- Runs `npx prettier --check $CHANGED_FILES`.
- On failure, prints a `::error::` annotation telling the contributor
how to fix it locally (`npx prettier --write <files>`).
Same one-step shape as the existing ESLint check — no extra workflow
file, no extra `npm ci`, no extra checkout.
## Ordering note
#13281 (`chore: prettier --write packages/api/src`) should land first
so the existing drift is cleared. After both PRs merge, the
pre-commit hook + this CI check together prevent drift from
re-accumulating.
## Test plan
- [x] `npx js-yaml .github/workflows/eslint-ci.yml` validates.
- [ ] CI green on this PR itself (touches only `.github/workflows/`,
which the path filter includes, so the workflow runs on itself).
- [ ] After merge: a synthetic PR introducing prettier drift should
fail the new step with the diagnostic message.
* 🏗️ refactor: Derive App Version from Root package.json + Add buildInfo Schema
The hardcoded `Constants.VERSION` in `data-provider` is now replaced at
rollup build time via `@rollup/plugin-replace`, sourcing from the root
`package.json` so version bumps are a single-file change.
Adds the shape needed by the rest of the series:
- `interface.buildInfo` boolean flag (default `true`) — lets self-hosters
opt out of exposing commit/branch/date.
- `buildInfo` on `TStartupConfig` — commit/commitShort/branch/buildDate.
- `SettingsTabValues.ABOUT` — new settings tab enum value.
Ref: https://github.com/danny-avila/LibreChat/issues/12406
* 🛠️ feat: Add Build Metadata Resolver and Expose via /api/config
Adds `resolveBuildInfo()` in `@librechat/api` that surfaces commit SHA,
branch, and build date from (in order) `BUILD_*` env vars, then local git
metadata. Result is cached per-process.
`/api/config` includes a `buildInfo` field on both authenticated and
anonymous responses when `interface.buildInfo !== false` and at least one
resolver field is populated. Omitted entirely otherwise.
Designed so pre-built Docker images carry metadata via build-arg while
source installs pick it up from `.git` — no manual version tracking.
Ref: https://github.com/danny-avila/LibreChat/issues/12406
* ℹ️ feat: Add Settings → About Panel with Diagnostics Copy
New Settings tab that renders the running build's version, commit (short
SHA), branch, and build date in a monospaced block alongside a "Copy
diagnostics" button that emits a preformatted text blob for pasting into
support issues.
Tab is hidden when `interface.buildInfo` is set to `false`. Reads from
`startupConfig.buildInfo` provided by `/api/config`.
Ref: https://github.com/danny-avila/LibreChat/issues/12406
* 🐳 ci: Inject BUILD_COMMIT/BRANCH/DATE into Docker Images
Adds optional `BUILD_COMMIT`, `BUILD_BRANCH`, `BUILD_DATE` ARGs to both
`Dockerfile` and `Dockerfile.multi`, wired as `ENV` vars in the runtime
stage so the backend's `resolveBuildInfo` picks them up.
All image-publishing workflows (`tag`, `main`, `dev`, `dev-branch`,
`dev-staging`) now compute `${github.sha}`, `${github.ref_name}`, and a
UTC timestamp, then pass them to `docker/build-push-action` as
`build-args`.
Defaults are empty — non-CI builds (local `docker build`) still work,
and the backend falls back to local `.git` metadata if ARGs aren't set.
Ref: https://github.com/danny-avila/LibreChat/issues/12406
* 📝 docs: Direct Bug Reporters to Settings → About for Version Info
The previous instructions (`docker images | grep librechat`,
`git rev-parse HEAD`) only worked for a subset of deployments and
rarely produced a commit SHA for users pulling pre-built images.
Point users to the new in-app Settings → About panel's
"Copy diagnostics" button, which captures version, commit, branch,
build date, and user agent in a single preformatted block. Fallback
instructions preserved for older installs.
Ref: https://github.com/danny-avila/LibreChat/issues/12406
* 🐳 fix: Move BUILD_* ENV to End of Docker Stages to Preserve Layer Cache
Per-commit BUILD_COMMIT/BUILD_DATE changes were being promoted to ENV
before `npm ci` / `npm run frontend` (single-stage) and before
`npm ci --omit=dev` (multi-stage api-build), which invalidated the cache
for every subsequent layer on every CI run.
Move the ARG/ENV block below the heavy install and build steps in both
Dockerfiles. Metadata is still available in the runtime image but no
longer busts layer reuse.
Addresses codex review on #12756.
* 🔧 fix: Propagate interface.buildInfo=false to Unauthenticated /api/config
The unauthenticated branch of `/api/config` was emitting an `interface`
object only when `privacyPolicy` or `termsOfService` was set, which
meant an admin's explicit `interface.buildInfo: false` opt-out was never
visible to anonymous/guest clients. `Settings.tsx` gates the About tab
on `startupConfig?.interface?.buildInfo !== false`, so a missing field
fell through as "enabled" for those clients.
Include `interface.buildInfo: false` in the unauth payload whenever it's
explicitly disabled. Keep the implicit default (true) absent to preserve
the minimal-unauth-payload convention.
Addresses codex review on #12756.
* 🔀 ci: Trigger Dev Image Workflows on Root package.json + Dockerfile Changes
The baked `Constants.VERSION` now reads from the root `package.json` via
rollup-plugin-replace, but the `dev-images.yml` and `dev-branch-images.yml`
path filters only matched `api/**`, `client/**`, `packages/**`. A release
commit that only bumps root `package.json` would not trigger a rebuild,
leaving `latest` dev images with stale Footer/About version metadata.
Include `package.json`, `package-lock.json`, and both Dockerfiles in the
path filters so dependency changes (lockfile rebuilds) and image build
tweaks also rebuild dev images.
Addresses codex review on #12756.
* 🧽 fix: Harden About Panel Lifecycle, A11y, and Loading Gate
Review follow-ups on #12756:
- #1 timer leak: stash the copy-state `setTimeout` in a ref and clear it
from a `useEffect` cleanup so unmounting the Settings dialog mid-toast
doesn't fire `setCopied(false)` on an unmounted component.
- #3 flash of About tab: gate `aboutEnabled` on `startupConfig != null`
so the tab stays hidden until `/api/config` returns. For admins who
disabled `interface.buildInfo`, the tab no longer briefly appears and
vanishes on page load.
- #6 aria-live placement: move the live region off the interactive
button onto a dedicated `<span role="status" aria-live="polite">` so
screen readers announce the copied state, not the full button content
on every re-render.
- #2 missing coverage: add `About.spec.tsx` exercising populated/empty
buildInfo rendering, invalid-date handling, diagnostics clipboard
payload, copy-state toggling, unmount cleanup, and the live region.
* ⚡ perf: Eagerly Resolve Build Info at Module Load
Review follow-up #4 on #12756: `resolveBuildInfo()` calls `execFileSync`
with a 2s timeout on source installs without `BUILD_*` env vars. Paying
this cost on the first HTTP request blocks the event loop mid-flight.
Call `resolveBuildInfo()` once at config route module load so the
resolver's cache is warm before any request arrives. Docker images with
the BUILD_* env vars set sidestep the git path entirely, so this only
affects the edge case of source installs.
* 📝 docs: Document rollup Version Placeholder Contract
Review follow-ups #5 / #8 on #12756. The `__LIBRECHAT_VERSION__`
placeholder relies on a substring replacement rule that only works
because the token appears inside a string literal, and the substitution
only runs during `npm run build:data-provider`.
- Expand the `Constants.VERSION` JSDoc to spell out that consumers read
the placeholder through the built dist bundle; source-level test
imports would see the raw placeholder.
- Add a NOTE above the rollup `replace` config warning future
contributors not to repurpose the token as a bare identifier without
switching to a quoted replacement value.
Non-functional; prevents future contributors from stepping on a subtle
constraint.
* 🪪 fix: Only Toast "Copied" When Clipboard Copy Actually Succeeds
Codex R5 on #12756. `copy-to-clipboard` returns a boolean indicating
whether the underlying `execCommand('copy')` / fallback prompt actually
wrote to the clipboard. The previous handler flipped to the "Copied"
state unconditionally, which in hardened browsers or when the
permission prompt is dismissed would mislead users into filing bug
reports without the diagnostics blob attached.
Gate the state/timer/live-region on the boolean return; silently no-op
on failure rather than showing a false positive. Adds a test asserting
the button label stays at "Copy diagnostics" when the clipboard call
fails.
* 🐳 fix: Derive main image metadata from checkout
* 🪪 fix: Keep About enabled until disabled
* ✅ test: Avoid literal Settings mock text
* 🧱 refactor: Rename Build Info Module
* 🎨 feat: Skills UI — Create/Edit/Share/List with Conditional File Tree
First-pass UI on top of the CRUD API scaffolding (#12613). Ships the full
user-facing flow for inline, single-SKILL.md skills and leaves a clean
drop-in for phase-2 multi-file support.
- Create a skill from /skills/new with name (kebab-case, validated),
description, and SKILL.md body — wired to the real `useCreateSkillMutation`
and `TCreateSkill` payload.
- List skills in a sidebar (SkillsSidePanel) via `useListSkillsQuery` with
live search filtering.
- Edit any skill the caller has EDIT permission on — `useUpdateSkillMutation`
passes `expectedVersion` for optimistic concurrency and surfaces 409
conflicts as a warning toast + cache refetch.
- Non-blocking `TSkillWarning[]` (e.g. "description too short") are shown
inline above the form after a successful create/patch.
- Read-only mode when the current user lacks EDIT — the form still renders
but inputs are marked `readOnly` and the save/reset buttons are hidden.
- Share via ACL using the existing `GenericGrantAccessDialog` — the
`ShareSkill` button is gated on the SHARE permission.
- Delete with confirmation, driven by `useDeleteSkillMutation({ id })`.
- Conditional file tree: only rendered when `useListSkillFilesQuery`
returns > 0 files. The tree groups flat `relativePath` strings into a
nested view (no `react-arborist` dependency) and supports per-file
deletion via `useDeleteSkillFileMutation`. Upload is intentionally
deferred — the backend stubs it at 501 in phase 1.
- New routes: `/skills`, `/skills/new`, `/skills/:skillId`.
- Sidebar accordion (`SkillsAccordion` wrapping `SkillsSidePanel`) added
to `useSideNavLinks` gated on `PermissionTypes.SKILLS` USE.
The initial UI branch (#12580) shipped a lot of exploration code on top of
a now-superseded placeholder backend. Kept as complementary: the `Skills/`
component tree, translation keys, role descriptions, `PublicSharingToggle`
SKILL mapping, `resources.ts` SKILL config, `useCanSharePublic` SKILL
mapping, and `data-provider/roles.ts` `useUpdateSkillPermissionsMutation`.
Deferred out of this first pass:
- Skill favorites (`useSkillFavorites`, `getSkillFavorites` endpoint) —
the backend route doesn't exist yet; saving for a follow-up.
- AgentConfig `SkillSelectDialog` integration — the UI branch had this
gated behind `false &&`; rolled back with the config.
- `InvocationMode` / `CategorySelector` / `parseSkillMd` / tree-node
mutations — not in the Anthropic skill spec and not in the CRUD API.
- `react-arborist` dependency — replaced with a hand-rolled recursive
tree built from flat `TSkillFile[]`.
- 38 data-schemas skill model tests: pass
- 25 api skill route tests: pass
- 16 user-controller cleanup tests: pass
* 🔐 feat: Default-On Skills in Interface Config and Role Seeder
The skills accordion was registered in the side nav gated on
`PermissionTypes.SKILLS` USE, but no one was actually seeding that
permission on startup, so a fresh install had the USER role with
zero skill permissions and the accordion never rendered.
Fixes three gaps:
1. `interfaceSchema` in data-provider's `config.ts` had no `skills`
field at all. Added it alongside the existing agents/prompts shape
(boolean | { use, create, share, public }) and a default of
`{ use: true, create: true, share: false, public: false }`.
2. `loadDefaultInterface` in data-schemas passed every interface key
through to the loaded config EXCEPT `skills`. Added the one-line
passthrough so `appConfig.interfaceConfig.skills` is actually
populated on boot.
3. `updateInterfacePermissions` in packages/api/src/app/permissions.ts
seeds role permissions from the interface config on every restart.
Added:
- `SKILLS` case to `hasExplicitConfig`
- `skillsDefaultUse/Create/Share/Public` extraction (mirrors
prompts/agents)
- `PermissionTypes.SKILLS` block in `allPermissions` that falls
through config → roleDefaults → schema default, same pattern as
AGENTS and PROMPTS
- `SKILLS` entry in the share-backfill array so that pre-existing
SKILL role docs missing SHARE/SHARE_PUBLIC get them filled on the
next restart
Test expectations updated: seven `expectedPermissionsFor(User|Admin)`
blocks in `permissions.spec.ts` now include SKILLS, matching the
role-default values (USER: use+create true, share/public false;
ADMIN: all true).
Result: on a fresh install, a regular USER gets skill USE/CREATE
and the "Skills" accordion shows up in the chat side panel without
any yaml config. Admins can lock it down per role or per tenant via
`interface.skills` in librechat.yaml.
Tests:
- 34 packages/api permissions.spec.ts: pass
- 151 packages/api app tests: pass
- 38 data-schemas skill.spec.ts: pass
- 928 data-provider tests: pass
- 25 api skills.test.js: pass
* ♻️ fix: Resolve Skills UI Review Findings
Addresses the 13 findings from the PR review against the prior commit.
1. **canEdit consistency** — extracted `useSkillPermissions(skill)` as the
single source of truth for owner/admin/ACL gating. `SkillsView`,
`SkillForm`, `ShareSkill` all consume it; `SkillFileTree`'s per-file
delete button now honors admin + EDIT-bit permissions instead of just
ownership. Unit tests cover owner, admin, editor-ACL, viewer-ACL,
owner-ACL, loading, and undefined-skill cases.
2. **Disabled submit buttons** — create/edit form submit buttons now set
native `disabled` (not just `aria-disabled`) during `isLoading`.
`onSubmit` also guards with an early return when the mutation is still
in-flight so a duplicate enter-key submit can't create two skills.
3. **Wrong maxLength error message** — description/name `maxLength` rules
no longer re-use `com_ui_skill_*_required`. Added dedicated
`com_ui_skill_name_too_long` and `com_ui_skill_description_too_long`
keys with the literal limit interpolated (`{{0}}`).
4. **Search debouncing** — `SkillsSidePanel` now threads the filter input
through the existing `useDebounce` hook (250ms) so typing "skills" no
longer fires six separate list queries.
5. **Frontend test coverage** — added:
- `tree.test.ts` (9 tests) covering `buildTree` / `nodeKey` edge cases:
empty input, single root file, multiple roots, nested folders,
deeply-nested trees, lexicographic sort, empty paths, stable keys
- `useSkillPermissions.test.ts` (7 tests) covering every precedence
branch (owner / admin / EDIT / VIEW / owner-ACL / loading / undef)
Form integration tests proved flaky against react-hook-form's async
`isValid` with our jest-dom mock setup; deferred to a follow-up PR
with a proper `@librechat/client` test harness.
6. **Shared `SKILL_NAME_PATTERN`** — promoted the regex plus the four
length constants (`SKILL_NAME_MAX_LENGTH`,
`SKILL_DESCRIPTION_MAX_LENGTH`, `SKILL_DESCRIPTION_SHORT_THRESHOLD`,
`SKILL_DISPLAY_TITLE_MAX_LENGTH`, `SKILL_BODY_MAX_LENGTH`) out of
`packages/data-schemas/src/methods/skill.ts` and into
`packages/data-provider/src/types/skills.ts`. The data-schemas
module now aliases the shared exports so the backend validator and
the frontend form share one source of truth. Also fixed a latent bug:
the client regex was stricter than the backend
(`^[a-z0-9]+(?:-[a-z0-9]+)*$` vs. the real `^[a-z0-9][a-z0-9-]*$`),
which would have rejected valid names like `foo--bar` client-side.
7. **Removed hardcoded "Claude"** — replaced `com_ui_skill_description_help`
("Claude uses this to...") with a new `com_ui_skill_create_subtitle`
for the form header and `com_ui_skill_description_field_hint`
("This is what the model reads to decide...") for the inline hint.
LibreChat is LLM-agnostic; the old copy misled GPT/Gemini users.
8. **Lifted tree mutation hook** — `useDeleteSkillFileMutation` is now
instantiated once in `SkillFileTree` (not per `TreeRow`). A
`TreeContext` provides `onDeleteFile` + `isDeleting` + `canEdit` to
rows. A 60-node tree used to instantiate 60 mutation hooks; it now
instantiates one.
9. **List O(n) re-render** — `SkillListItem` no longer reads
`useParams()` directly. `SkillList` reads the active id once and
passes `isActive` as a prop, so navigation only re-renders the two
items whose `isActive` flipped (memo'd), not all N items.
10. **Deduped help text** — the field-level hint and form-level subtitle
now use different translation keys with distinct copy instead of
showing the same sentence twice on the same page.
11. **Removed ineffective `useCallback`** — `DeleteSkill.handleDelete`,
`CreateSkillForm.onSubmit` / `.handleCancel`, `SkillForm.onSubmit`,
and `SkillFileTree.handleDeleteFile` all wrapped closures around
React Query `mutation` refs, whose identities change every render.
Their dep arrays invalidated every render, making the memo a no-op
with extra overhead. `SkillFileTree` now destructures the stable
`mutate` function and inlines the arrow inside the memoized
`contextValue` — one stable reference per deps change.
12. **Import order** — fixed shortest→longest package ordering and
longest→shortest local ordering across all touched skill files per
AGENTS.md. `react` always first where imported.
13. **Memoization principle** — documented the rule with inline comments:
`memo` on components that appear in repeated contexts (`TreeRow`,
`SkillListItem`) or as children of frequently-re-rendering parents
(`ShareSkill` / `DeleteSkill` under `SkillForm`'s per-keystroke
form-state updates). Removed `memo` from `SkillFileTree` since its
parent `SkillDetailPanel` only re-renders on query-data changes.
- 38 data-schemas skill.spec.ts
- 34 packages/api permissions.spec.ts
- 25 api skills.test.js
- 16 client unit tests (9 buildTree + 7 useSkillPermissions)
- All type-checks + eslint clean on touched files
* 🧹 fix: Skills Duplication, Input Styling, Remove LLM-specific Copy
Three UI fixes from an in-chat review pass:
1. **Sidebar duplication** — `SkillsView` was rendering its own
`SkillsSidePanel` aside alongside the chat side panel's
`SkillsAccordion`, so on `/skills` the user saw the skill list
twice. Fixed by mirroring the `InlinePromptsView` pattern: the
route content is now just the detail / create panel and the
chat side panel is the sole list. Added `/skills → /skills/new`
redirect and a `/skills/new` literal route so `useParams().skillId`
is `undefined` for "new" (matches prompts).
2. **Name Input styling** — the big floating-label pattern used by
prompts/agents for the primary name field was replaced with a
conventional `<Label>` + `<Input>` above it, diverging from the
rest of the app. Restored the prompts-style `text-2xl` input with
the peer-focus animated label on both `CreateSkillForm` and
`SkillForm`. Kept the conventional pattern for description and
body since they're textareas.
3. **Remove LLM-specific copy from skill translations** — dropped
`com_ui_skill_description_help` ("Claude uses this to...") and
the transitional "This is what the model reads..." phrasing.
Field hint is now a neutral "Be specific about when this skill
should apply." and the create-page subtitle is a neutral "Author
a new skill your agents can invoke." LibreChat is LLM-agnostic;
baking product names into user-facing copy is wrong outside the
`com_endpoint_anthropic_*` keys where the setting actually only
applies to Claude models.
Side-effect: the `SkillDetailView` wrapper in `SkillsView` now only
renders the file-tree aside when the skill has > 0 files — same
conditional-tree behavior as before, just scoped to this route
instead of also trying to also render a list sidebar.
- 16 client skill tests still pass
- Type-check + eslint clean on touched files
* 🎁 feat: Restore Skills UI from PR #12580
Brings back everything the original UI PR (#12580, commit da039917c)
shipped that my earlier rebase dropped. Verbatim restores where possible;
adapts the new hooks/types where the backend contract has shifted.
**Scoped-out / gated-off (now restored as inert UI scaffolding):**
- `hooks/useSkillFavorites.ts` + `utils/favoritesError.ts` + the
`useGetSkillFavoritesQuery` / `useUpdateSkillFavoritesMutation` additions
in `data-provider/Favorites.ts`. The backend route doesn't exist yet —
the data-service functions resolve with empty arrays so the Star UI is a
visual-only no-op until phase 2.
- `dialogs/SkillSelectDialog.tsx` + the "Add Skills" section in
`SidePanel/Agents/AgentConfig.tsx` (still gated behind the original
`false &&`) + `skills?: string[]` on `AgentForm` / `Agent` /
`AgentCreateParams` / `AgentUpdateParams` + the `skills: []` entry in
`defaultAgentFormValues`.
- `TUserFavorite.skillId` reserved on the shared favorites type.
**Concept-is-gone / deleted-types (restored as UI-only types + stubs):**
- `InvocationMode` enum and `TSkillNode`, `TSkillTreeResponse`,
`TCreateSkillNodeRequest`, `TUpdateSkillNodeRequest` types in
`packages/data-provider/src/types.ts`. UI-facing only; the backend flat
`TSkillFile[]` contract is unchanged.
- `TSkill.invocationMode?: InvocationMode` as an optional field. Forms
read/write it in local state and deliberately drop it from the PATCH
payload until the backend column lands.
- `tree/SkillFileTree.tsx` (`react-arborist`-based), `SkillTreeNode.tsx`,
`TreeToolbar.tsx`, `SkillFileEditor.tsx`, `SkillFilePreview.tsx` — full
filesystem-style browser UI restored verbatim.
- `data-provider/Skills/tree-queries.ts` + `tree-mutations.ts` hooks
(`useGetSkillTreeQuery`, `useCreateSkillNodeMutation`, etc.). The
`data-service` stubs them: `getSkillTree` returns `{ nodes: [] }`,
`createSkillNode` / `updateSkillNode` / `updateSkillNodeContent` return
synthetic node shapes, `deleteSkillNode` resolves void. Hooks compile
and run; tree is empty until phase 2 wires a real backend.
- `MutationKeys.createSkillNode` / `updateSkillNode` / `deleteSkillNode` /
`updateSkillNodeContent` + `CreateSkillNodeBody` /
`UpdateSkillNodeVariables` / `DeleteSkillNodeBody` /
`UpdateSkillNodeContentVariables` types.
- `QueryKeys.skillTree` / `skillNodeContent` / `skillFavorites` /
`favorites` and the `skillTree()` endpoint helper.
**Scope-simplified (restored with minimal adaptation):**
- `display/SkillDetailHeader.tsx` + `display/SkillDetail.tsx`. Header now
falls back to `InvocationMode.auto` when `skill.invocationMode` is
undefined.
- `forms/SkillContentEditor.tsx` — click-to-edit markdown preview toggle
for the SKILL.md body field. Wired into both `CreateSkillForm` and
`SkillForm` replacing the plain `<TextareaAutosize>`.
(Needed `@ts-ignore` on `remarkPlugins` / `rehypePlugins` for the same
`PluggableList` vs `Pluggable[]` shape drift `MarkdownLite.tsx` already
works around.)
- `forms/InvocationModePicker.tsx` + `forms/CategorySelector.tsx` — the
auto/manual/both dropdown and the skill category selector. Wired into
both forms inside a `FormProvider` so the Controller-based widgets can
read `useFormContext`. `category` flows to the PATCH / POST payload as
before; `invocationMode` is UI-only per the type note above.
- `buttons/CreateSkillMenu.tsx` + `utils/parseSkillMd.ts` — dropdown with
AI / Manual / Upload SKILL.md entries + the YAML frontmatter parser for
the upload path. `CreateSkillForm.defaultValues` now accepts the parsed
shape, so the upload → redirect → pre-populated form flow works again.
- `buttons/AdminSettings.tsx` — admin permissions dialog. Uses the
existing `useUpdateSkillPermissionsMutation` which was already wired.
- `sidebar/FilterSkills.tsx` — restored filter + AdminSettings +
CreateSkillMenu wrapper. `SkillsSidePanel.tsx` is back to the original
`FilterSkills`-based layout.
- `lists/SkillList.tsx` + `lists/SkillListItem.tsx` — restored verbatim.
- `layouts/SkillsView.tsx` — restored the full tree + file editor + file
preview layout. The chat side panel keeps its own accordion list; this
view is the inline detail experience.
- `hooks/Generic/useUnsavedChangesPrompt.ts` — route-leave guard hook.
- `useGetSkillByIdQuery` is aliased to `useGetSkillQuery` so restored
components (`SkillsView`, `SkillForm`) that import the old name resolve
to the new hook.
- `SkillSelectDialog` + `AgentConfig` coerce `skillsData?.skills` instead
of `.data` (list response shape drift from the CRUD PR).
- `CreateSkillForm` / `SkillForm` wrap their JSX in `FormProvider` so the
restored `CategorySelector` and `SkillContentEditor` components —
which read `useFormContext` — work inside the existing forms without
another refactor.
- `CreateSkillForm.defaultValues` prop accepts `Partial<Values> &
{ invocationMode?: unknown }` so the upload flow's
`{ name, description, invocationMode }` shape passes through cleanly.
- `SkillsView` route map gains `/skills/:skillId/edit` and
`/skills/:skillId/file/:nodeId` so the tree-navigation URLs the original
view produces actually resolve.
- `client/package.json` gains `react-arborist@^3.4.3`.
- ~60 translation keys the restored files reference — invocation labels,
edit/create page titles, file editor chrome, tree toolbar tooltips,
favorites, admin allow-settings, unknown-file-type, sr_public_skill,
delete/rename _var variants — all added to `en/translation.json`.
- Prompts-style floating-label name input — kept from my earlier commit
so it matches the rest of the app (user reviewed and approved that
styling). Hidden skill-body textarea is replaced by `SkillContentEditor`
in both forms.
- 38 data-schemas skill.spec.ts
- 34 packages/api permissions.spec.ts
- 25 api skills.test.js
- 7 client useSkillPermissions.test.ts
- Type-check: pre-existing error count (188) dropped to 120 because my
restorations fixed some previously-broken field types.
* chore: Update package-lock.json to include react-arborist and memoize-one
* feat: Add support for react-arborist in Vite configuration
This update introduces a new condition in the Vite configuration to handle the 'react-arborist' package, ensuring it is properly recognized during the build process. This change enhances compatibility with the recently added 'react-arborist' dependency in the project.
* 🩹 fix: Hide InvocationMode, Fix SkillContentEditor Click-to-Edit
1. Hide InvocationModePicker from both CreateSkillForm and SkillForm.
Component stays on disk for when the backend lands the column.
2. Fix "Click to edit" doing nothing on SkillContentEditor. The
`onBlur={() => setIsEditing(false)}` on the TextareaAutosize was
racing with `autoFocus` — React renders the textarea, autoFocus
fires, then a layout/reconciliation blur fires immediately,
bouncing back to preview mode before the user can interact.
Removed onBlur; users toggle via the header button or Escape key.
* 🎨 feat: Reader-First Skills UI — Match Claude.ai Layout
Reworks the Skills UI from form-first to reader-first, matching
Claude.ai's skill detail pattern.
**Default view is now read-only.** Clicking a skill in the sidebar
navigates to `/skills/:id` which renders `SkillDetail` — a clean
content view with:
- Skill name as the primary heading
- Metadata row: "Added by" + "Last updated" (formatted date)
- Description block
- Rendered SKILL.md body in a bordered card with a source/rendered
toggle (eye + code icons, matching Claude.ai's segmented control)
No form fields, no save/cancel buttons. The user reads the skill
first and takes action deliberately.
**Create is now a dialog.** The `/skills/new` route is gone.
`CreateSkillMenu` (the + dropdown in the sidebar) now opens
`CreateSkillDialog` — a minimal modal with name, description, and
instructions fields. Upload-from-file still works: parse → populate
dialog → create. Matches Claude.ai's "Write skill instructions"
modal.
**Edit is behind an action.** The detail view shows an "Edit" button
(permission-gated) that navigates to `/skills/:id/edit`, rendering
the existing `SkillForm`. The edit route is preserved for direct
linking.
**Navigation goes to detail, not edit.** `SkillListItem` now
navigates to `/skills/:id` (detail) instead of `/skills/:id/edit`.
- `display/SkillMarkdownRenderer.tsx` — shared ReactMarkdown
component extracted from `SkillContentEditor`. Same remark/rehype
plugins, no form dependency.
- `display/SkillDetail.tsx` — the reader-first view (replaces the
old thin wrapper).
- `dialogs/CreateSkillDialog.tsx` — OGDialog modal for skill
creation.
- `layouts/SkillsView.tsx` — gutted and rebuilt. Three states:
no-skill (empty state), skillId (SkillDetail), skillId+edit
(SkillForm). Removed full-page CreateSkillForm, removed TreeView.
- `buttons/CreateSkillMenu.tsx` — opens dialog instead of navigating
to `/skills/new`. Upload flow: parse → set dialog defaults → open.
- `lists/SkillListItem.tsx` — navigate to detail, not edit.
- `routes/index.tsx` — removed `/skills/new` and file/nodeId routes;
`/skills` renders SkillsView directly (empty state).
- `display/index.ts`, `dialogs/index.ts` — added new exports.
- `locales/en/translation.json` — added ~10 new keys for metadata,
toggle labels, dialog title, empty state.
* 🩹 fix: SkillContentEditor click-to-edit z-index — button was z-0 behind rendered content
* 🩹 fix: Align Edit button size with Share/Delete (size-9)
* 🎨 feat: Claude.ai-Style Skill List Panel
Rewrites the skills sidebar to match Claude.ai's panel layout:
- Header: "Skills" title + search icon (toggles input) + add icon
(opens CreateSkillDialog directly, no dropdown menu)
- Collapsible "Skills" section with chevron toggle
- Skill items: 24px icon badge (rounded square with ScrollText icon)
+ name only. No description text in the list — that lives in the
detail view. Active item gets highlighted bg + bold font.
- Removed AdminSettings button from sidebar header — admin config
is accessible via the admin dashboard, not cluttering every user's
skill list.
- Removed FilterSkills wrapper (was Filter + AdminSettings +
CreateSkillMenu). The search + create are now inline in the panel
header.
Files changed:
- sidebar/SkillsSidePanel.tsx — full rewrite
- sidebar/SkillsAccordion.tsx — simplified wrapper
- lists/SkillList.tsx — collapsible section, no description
- lists/SkillListItem.tsx — icon badge + name, memo'd
* 🎨 fix: Align Skills UI Styling with Prompts Patterns
Style alignment pass based on direct comparison with claude.ai and
the existing prompts preview dialog.
SkillsSidePanel search now replaces the title in the header row when
toggled (search icon + input + X close), matching Claude.ai's pattern.
Previously it pushed a separate input below the header, wasting
vertical space. Close button clears the search term.
Replaced `text-text-tertiary` with `text-text-secondary` across
SkillDetail, SkillList, SkillForm, CreateSkillForm, CreateSkillDialog,
SkillContentEditor. Tertiary was too dark / low contrast.
SkillList section chevron label now reads "Personal skills" (matching
Claude.ai) via the existing `com_ui_my_skills` key, instead of the
generic "Skills" which duplicated the header.
Aligned with `PromptDetailHeader` styling:
- 48px round icon (ScrollText in bg-surface-secondary circle)
- Name + public badge in the icon row
- Metadata below the icon: User icon + author, Calendar icon + date
(text-xs text-text-secondary with gap-3, matching prompts exactly)
- Description uses the same label-above-text pattern as prompts
- Content card uses `bg-transparent` border (not bg-surface-primary-alt)
- Toggle buttons use size-5 icons and text-text-secondary for inactive
Changed from `max-w-lg p-0` to `max-w-5xl` with the same max-height
and padding pattern as the prompts PreviewPrompt dialog:
`max-h-[80vh] p-1 sm:p-2 gap-3 sm:gap-4`. Close button now renders
via default OGDialogContent behavior (removed showCloseButton=false).
* 🩹 fix: SkillDetail fills parent height, tighter spacing (px-6 pb-6 gap-2)
* 🩹 fix: Align Skills panel header padding (px-4) with list content below
* 🩹 fix: Reduce Skills header top padding (pt-2) to align with sidebar icon strip
* 🩹 fix: Tighten Skills header (py-2) and detail top (py-2) to align with sidebar icons and match edit view
* 🩹 fix: Offset SidePanel Nav pt-2 with -mt-2 on SkillsAccordion so Skills header aligns with icon strip
* 🛠️ fix: Increase Node memory limit for production build in package.json
* 🩹 fix: Remove top padding from SkillDetail header row (py-2 → pb-2)
* 🏗️ refactor: Move pt-2 from SidePanel/Nav wrapper to each panel
Removed the global `pt-2` from `SidePanel/Nav.tsx` and pushed it
into each panel's own top-level wrapper. This lets each panel own
its vertical alignment independently — Skills can sit flush at the
top to align with the sidebar icon strip, while other panels keep
their original spacing.
Panels updated with `pt-2`:
- PromptsAccordion (via className on PromptSidePanel)
- BookmarkPanel
- FilesPanel
- MemoryPanel
- MCPBuilderPanel
- AgentPanel (form wrapper)
- AssistantPanel (form wrapper)
- ParametersPanel (already had pt-2)
SkillsAccordion: removed the -mt-2 hack, now naturally flush.
* 🧹 fix: Align CreateSkillDialog field styling + remove 19 unused i18n keys
Dialog fields: all three inputs now use consistent `rounded-xl
border-border-medium px-3 py-2 text-sm` styling. Replaced the
`<Input>` component with a plain `<input>` to avoid the component's
built-in `rounded-lg border-border-light` overriding the dialog's
border style. Labels use `font-medium` for consistency.
Removed 19 unused translation keys from translation.json:
com_ui_skill_body, com_ui_skill_body_placeholder,
com_ui_skill_create_subtitle, com_ui_skill_file_delete_confirm,
com_ui_skill_file_delete_error, com_ui_skill_file_deleted,
com_ui_skill_files_empty, com_ui_skill_files_multi_hint,
com_ui_skill_list, com_ui_skill_load_error,
com_ui_skill_resize_file_tree, com_ui_skill_select_file,
com_ui_skill_select_file_desc, com_ui_skills_load_error,
com_ui_add_first_skill, com_ui_create_skill_page,
com_ui_edit_skill_page, com_ui_save_skill, com_ui_no_skills_title
* 🎁 feat: Upload Skill Dialog + Simplified Create Menu
New `UploadSkillDialog` matching Claude.ai's upload modal:
- Dashed drop zone with drag-and-drop support
- Accepts .md, .zip, .skill files
- Phase 1: processes .md files (parses YAML frontmatter → creates
skill with body as the full file content)
- Shows file requirements below the drop zone
- On success: navigates to the new skill's detail view
`CreateSkillMenu` now has two flat options (no sub-menu):
- "Write skill instructions" → opens `CreateSkillDialog`
- "Upload a skill" → opens `UploadSkillDialog`
Removed the disabled "Create with AI" option and the old file input
hidden-element approach. The sidebar `+` button now renders
`CreateSkillMenu` directly instead of a standalone create dialog.
- Removed 5 unused i18n keys (com_ui_skill_added_by,
com_ui_skill_last_updated, com_ui_skills_add_first,
com_ui_skills_filter_placeholder, com_ui_skills_new)
- Tightened metadata gap in SkillDetail (mt-1 → mt-0.5)
- Added 7 new upload-related i18n keys
* 🔒 feat: Zip/Skill File Upload Support with Safety Limits
Rewrites UploadSkillDialog to properly handle all three accepted
file types:
- `.md` — reads as text, parses YAML frontmatter, creates skill
- `.zip` / `.skill` — reads as ArrayBuffer, extracts with JSZip,
finds SKILL.md (at root or one level deep), parses its content,
creates skill. Shows spinner during processing.
Security guards against zip bombs:
- MAX_ZIP_SIZE: 50MB compressed file limit
- MAX_ENTRIES: 500 file limit inside the archive
- Path traversal rejection: skips entries with `..` or leading `/`
- SKILL.md search limited to depth ≤ 2 segments
Added `jszip@^3.10.1` to client dependencies (already in the
monorepo's node_modules from backend usage).
The name is inferred from the zip filename if SKILL.md frontmatter
doesn't have one (e.g. `skills-autofix.zip` → `skills-autofix`).
* 🚀 feat: Backend Skill Import + Live File Upload Endpoints
New endpoint that accepts a single multipart file (.md, .zip, .skill)
and creates a skill with all its files in one request:
- **.md**: parse YAML frontmatter → create skill with body
- **.zip / .skill**: extract with JSZip, find SKILL.md (root or one
level deep), create skill from its content, then persist every
additional file via `upsertSkillFile` + local file storage strategy.
Returns the created skill + an `_importSummary` with per-file
results.
Security:
- 50MB compressed file size limit (multer)
- 500 max entries in archive
- 10MB per individual file
- Path traversal rejection (no `..`, no absolute, validated charset)
- File type filter: only .md/.zip/.skill accepted
- Rate limited via existing `fileUploadIpLimiter` + `fileUploadUserLimiter`
Handler lives in `packages/api/src/skills/import.ts` with injectable
deps (`createSkill`, `upsertSkillFile`, `saveBuffer`) for testability.
Replaced the 501 stub with a real handler:
- Accepts multipart FormData with `file` + `relativePath`
- Saves file via local storage strategy
- Calls `upsertSkillFile` to persist the SkillFile record
- Returns the upserted document
- Rate limited, ACL-gated (EDIT permission required)
- 10MB per file limit
`UploadSkillDialog` now sends the file to `/api/skills/import` via
`dataService.importSkill(formData)` — no more client-side JSZip.
Removed `jszip` from client dependencies (only backend needs it).
Added `importSkill()` in data-service + `importSkill()` endpoint
builder in api-endpoints.
Updated the file upload test from expecting 501 stub to expecting
400 "no file provided" (live validation). All 25 skill route tests
pass.
* 🔒 fix: Complete Import Handler — Validation, Ownership, Error Surfacing
Fixes several gaps in the skill import flow:
1. **Skill validation now runs and surfaces properly.** The import
handler calls the real `createSkill(CreateSkillInput)` which runs
`validateSkillName`, `validateSkillDescription`, `validateSkillBody`.
Validation errors (SKILL_VALIDATION_FAILED) are caught and returned
as 400 with the issue messages. Duplicate-key errors return 409.
Previously all errors were swallowed into a generic 500.
2. **`authorName` is now populated.** The `CreateSkillInput` requires
`authorName` which was missing — resolved from `req.user.name ??
req.user.username ?? 'Unknown'`, matching the existing create handler.
3. **SKILL_OWNER permission is granted after import.** Calls
`grantPermission` with `AccessRoleIds.SKILL_OWNER` so the uploader
can edit/delete/share the imported skill. This was entirely missing —
imported skills would have been ownerless.
4. **`tenantId` propagated.** Both the skill and each SkillFile record
receive `req.user.tenantId` for multi-tenant deployments.
5. **SkillFile records are created in the DB.** Each non-SKILL.md file
in the zip is saved to file storage via `saveBuffer` and recorded
via `upsertSkillFile`, which validates the relativePath, infers the
category from the path prefix, and atomically bumps the skill's
`fileCount` and `version`.
Import deps now include `grantPermission` from PermissionService,
injected in `api/server/routes/skills.js`.
* 🐛 fix: Import grant uses accessRoleId (not roleId) — fixes skill not appearing in list
* 🎨 fix: Cache invalidation, file tree, frontmatter rendering
Three fixes for the skill detail view:
1. **Cache invalidation after import.** UploadSkillDialog now calls
`queryClient.invalidateQueries([QueryKeys.skills])` after a
successful import so the sidebar list picks up the new skill
without requiring a page refresh.
2. **File tree in detail view.** When a skill has `fileCount > 0`,
the detail view now queries `useListSkillFilesQuery` and renders
a file list below the body card — SKILL.md first, then folders
and root files. Icons: Folder for directories, FileText for files.
3. **Frontmatter stripped and rendered as metadata.** YAML frontmatter
(`---\nversion: 0.1.0\ntriggers: ...\n---`) is now parsed out of
the body before markdown rendering. The `name` and `description`
fields are skipped (already shown in the header). Remaining fields
(version, triggers, dependencies, etc.) are displayed in a
Claude.ai–style grid: label on the left, value on the right,
above the rendered markdown content. Source view still shows the
full raw body including frontmatter.
* 🩹 fix: Always fetch skill files — fileCount may be stale in cached skill object
* 🌳 feat: Inline File Tree in Sidebar Skill List
Moves the file tree from the bottom of SkillDetail into the sidebar
list, matching Claude.ai's pattern:
- Multi-file skills show a chevron toggle on the right side of the
skill list item
- Clicking the chevron expands an inline file tree below the skill
name: SKILL.md first, then folders (with folder icon + right
chevron) and root files
- File list is fetched lazily (only when expanded) via
useListSkillFilesQuery
- Clicking a file navigates to the skill detail view
- Files section removed from SkillDetail — the sidebar is now the
sole file tree location, keeping the detail panel clean
SkillDetail cleaned up: removed groupFiles helper, file-related
state, useListSkillFilesQuery import, FileText/Folder icon imports.
* 🌲 feat: Virtualized inline file tree with react-vtree
Replace hand-rolled recursive FolderRow/FileRow buttons with a proper
virtualized FixedSizeTree from react-vtree for the sidebar skill list.
Dynamic height tracks open folders; capped at 350px with smooth
expand/collapse transitions.
* chore: Remove no longer used SkillFileTree and SkillTreeNode components
* chore: Update Vite config to replace 'react-arborist' with 'react-vtree' for module resolution
* feat: Skill file content viewing with lazy DB caching
- Add `skills` field to `fileStrategiesSchema` so operators can
configure a dedicated storage backend for skill files. Falls back
by type (image/document) when unset.
- Fix hardcoded `FileSources.local` in skill save/import — now uses
the resolved strategy via `getFileStrategy(req.config, { context })`.
- Replace 501 download stub with real handler that streams from any
storage backend and returns JSON `{ content, mimeType, isBinary }`.
- Binary detection (null-byte + non-printable ratio on first 8 KB)
flags files on first read so they're never re-fetched.
- Text content ≤ 512 KB is cached in the SkillFile MongoDB document;
subsequent reads skip storage entirely.
- Clicking a skill row now expands inline files (not just chevron).
- Clicking a file navigates to `?file=<path>` and renders content
in a new SkillFileViewer (markdown, code, images, binary placeholder).
* chore: Remove react-window and its type definitions from package.json and package-lock.json
- Deleted `react-window` and `@types/react-window` dependencies from both `package.json` and `package-lock.json` to streamline the project and reduce unnecessary bloat.
* fix: Build errors — remove endpoints import, fix Uint8Array cast
- Replace `import { endpoints }` (not public) with inline URL in
SkillFileViewer
- Remove `as Uint8Array` cast in stream chunk handling
- Extend getSkillFileByPath return type with content/isBinary to
decouple from data-schemas build artifact resolution
* chore: Remove 8 unused i18next keys
com_ui_create_skill_ai, com_ui_create_skill_manual,
com_ui_delete_folder_confirm_var, com_ui_delete_skill,
com_ui_delete_skill_confirm_var, com_ui_delete_var,
com_ui_rename_var, com_ui_skill_files
* fix: Add configMiddleware to skills router, handle SKILL.md in viewer
- Add configMiddleware to skills router so req.config is populated
when getLocalFileStream (or any strategy) reads file paths.
- Handle SKILL.md in download handler — serves skill.body directly
from the Skill document instead of looking for a SkillFile record.
- Clicking SKILL.md in sidebar tree now opens the file viewer
(matching Claude.ai behavior: file view vs default detail view).
* ci: Run unit tests on PRs to any branch
Remove the branches filter from both test workflows so contributor
PRs targeting feature branches (not just main/dev) get CI coverage.
Path filters are kept so tests only run when relevant files change.
* fix: Update skills route tests for download handler changes
- Mock configMiddleware (sets req.config for file storage access)
- Mock getStrategyFunctions and getFileStrategy (storage strategy deps)
- Replace 501 stub test with SKILL.md content test + 404 test
* fix: Auto-expand files, frontmatter parsing, select-none, prefetch
- Auto-expand file tree when navigating directly to a skill URL
- Prefetch files for the active skill (eliminates first-expand lag)
- Fix frontmatter parser to handle multi-line YAML list values
(triggers field was missing because it uses list syntax)
- SkillFileViewer now parses frontmatter for .md files — shows
structured grid + rendered body (matching SkillDetail's display)
with source/rendered toggle
- Add select-none to all sidebar skill and file tree buttons
* refactor: Derive expanded state from isActive instead of useEffect
Replace useEffect sync with deterministic derivation:
expanded = hasFiles && (isActive || !collapsed)
Active skill is always open. collapsed is a manual toggle that
only takes effect on non-active items.
* fix: Remove empty space above body card — overlay view toggle
Move the rendered/source toggle from a dedicated row (40px of empty
space) to an absolute-positioned overlay in the card's top-right
corner, matching Claude.ai's layout.
* fix: Remove header bars from content editors — overlay action buttons
Collapse the full-width header bars ("Skill Content", "Text") in
SkillContentEditor, PromptTextCard, and PromptEditor. Action buttons
(edit/save toggle, copy, variables) are now absolute-positioned in
the card's top-right corner, reclaiming ~46px of vertical space.
* fix: Spinner visibility in file viewer — use text-text-secondary
* fix: Address review findings — security, correctness, code quality
Codex P1: Use $unset instead of undefined to clear cached content
and isBinary fields on file re-upload (Mongoose strips undefined).
Codex P2: Match skill-file validation errors by error.code instead
of error.message substring.
F1: Zip bomb defense — track cumulative decompressed bytes (500 MB
cap), check declared uncompressed size before buffering each entry.
F2: Remove misleading "atomically" from import handler JSDoc.
F3: Static import for isBinaryBuffer instead of dynamic import().
F4: Replace console.error with logger in upload handler.
F6: Add multer error handler middleware to skills router.
F7: Move React import to top of SkillDetail.tsx.
F9: Fix variable shadowing (trimmed → item) in parseFrontmatter.
F11: Replace JSON.parse(JSON.stringify()) with toJSON() for
Mongoose document serialization.
F12: Remove dead dynamic import('fs') fallback (memoryStorage
always provides file.buffer).
F13: Hoist MIME_MAP to module scope to avoid per-call allocation.
F16: Share single multer.memoryStorage() instance.
* fix: Follow-up review — close zip bomb gap, fix error handler
F1: Add post-decompression cumulative byte check with break (the
pre-decompression check relies on undocumented JSZip internals
that may be absent; this closes the gap unconditionally).
F2+F3: Multer error handler now forwards non-multer errors via
next(err) instead of swallowing them. Also catches file filter
rejections (plain Error, not MulterError) by message prefix.
F4: Move isBinaryBuffer import to local imports section per
CLAUDE.md import order rules.
F5: Simplify dead toJSON branch — createSkill returns a POJO.
* nit: Link filter error message to handler prefix check
* feat: Accordion expansion + active file highlight in sidebar
- Only one skill's file tree can be expanded at a time (accordion).
Expansion state lifted from SkillListItem to SkillList.
- Selected file gets bg-surface-active highlight in the tree.
Skill row uses subtle style (no background) when a file is active,
matching Claude.ai's pattern where the file — not the skill —
carries the selection state.
* style: Adjust margin for file tree in SkillListItem component
- Reduced left margin from 10 to 5 for improved layout consistency in the file tree display.
* fix: TS error on FileTreeNode, nested ternary, chevron collapse
- Make style prop optional to match react-vtree's NodeComponentProps
- Flatten nested ternary for skill row active styles
- Skill row click expands (but doesn't collapse) files + navigates
- Chevron click explicitly toggles collapse (matching Claude.ai
where clicking the chevron is how you collapse files)
* fix: Upload basePath, reject SKILL.md uploads, add skills permission route
- Pass basePath: 'uploads' in per-file upload handler (was defaulting
to 'images' path, inconsistent with the import flow).
- Reject uploads targeting SKILL.md (reserved path — download handler
special-cases it to return skill.body, making an uploaded file
unreachable via the API).
- Add skills entry to roles router permissionConfigs so PUT
/api/roles/:roleName/skills actually reaches a handler instead
of returning 404.
* feat: Expand content area, move controls to header, reduce padding
Default detail view:
- Remove rounded-xl bordered card wrapper — content flows directly
into the article, capitalizing on full screen width
- Move eye/code toggle inline with the divider row
- Reduce px-6/pb-6 to px-4/pb-4
File viewer:
- Move eye/code toggle from card overlay to the header bar
- Add copy-to-clipboard button for text files in the header bar
- Remove rounded-xl bordered card wrapper for markdown content
- Remove bordered pre wrapper for non-markdown text
- Reduce px-6/py-4 to px-4/py-3
Both views maximize content space over decorative chrome.
* fix: Stable header height, restore some padding
- Fix layout shift in file viewer header: use fixed h-10 so the
bar height stays constant whether the eye/code toggle renders
(markdown) or not (plain text).
- Bump content padding from px-4/py-3 back to px-5/py-4 in both
views — the previous reduction was too aggressive.
* fix: Grant rollback, path validation, error format, dead code cleanup
F2: grantOwnership now rolls back (compensating delete) on failure,
matching the create handler. Both markdown and zip import paths
check the result and return 500 on grant failure.
F4: Upload handler validates relativePath with regex + traversal
check before calling downstream upsertSkillFile.
F5: Document JSZip _data.uncompressedSize as best-effort; the
post-decompression cumulative check is the real safety net.
F10: Standardize all upload handler error responses to { error }
(was { message }, inconsistent with handlers.ts).
F13: Single-pass fileResults accumulation in import handler.
F1-5: Remove dead uploadFileStubHandler (no route references it).
Codex P2: Fix delete nav from /skills/new to /skills.
F12: Use cn() in UploadSkillDialog instead of template literals.
* perf: Stream-first binary detection + O(1) public skill check
F1: Download handler now reads only the first 8 KB for binary
detection. If binary, the stream is destroyed immediately without
buffering the remaining file. Text files continue reading for
caching. Eliminates buffering up to 10 MB per request for binary
files under concurrent load.
F7: Single-skill GET and PATCH now use hasPublicPermission (O(1)
ACL lookup) instead of getPublicSkillIdSet (queries ALL public
skill IDs). The list handler still uses the Set approach since it
serializes multiple skills. serializeSkill/serializeSkillSummary
now accept boolean | Set for flexibility.
* fix: Update test to match { error } response format
* fix: Critical stream truncation bug, grantedBy, error format
NF-1 (CRITICAL): Rewrite binary detection to single for-await loop.
Breaking out of for-await-of destroys the stream via iterator.return(),
so the previous two-loop approach silently truncated text files > 8KB.
Now: one loop collects chunks, checks binary after 8KB accumulated,
and either destroys+returns (binary) or continues reading (text).
NF-2: Add grantedBy to import handler's grantPermission call and
interface (was missing, inconsistent with create handler).
NF-3: Standardize all import handler error responses from { message }
to { error }, matching handlers.ts convention. Update client's
UploadSkillDialog to read response.data.error accordingly.
* fix: Prefer specific validation message over generic error field
* fix: YAML quote stripping, saveBuffer null guard, dot segment rejection
- Strip surrounding YAML quotes from frontmatter values so
name: "my-skill" parses as my-skill (not "my-skill" with quotes
that fails the name validator).
- Guard resolveSkillStorage against backends with saveBuffer: null
(e.g. OpenAI/vector strategies) — throws a descriptive error
caught by the handler's try/catch instead of a TypeError.
- Tighten upload path validation to reject . segments (e.g.
docs/./a.md) matching the model-layer validator, preventing
storage writes for paths the DB will reject.
* fix: Orphan cleanup, stream errors, malformed zip, cache latency
F1: Upload handler now deletes the stored blob if the subsequent
DB upsert fails, preventing orphaned files on disk/cloud.
F2: Multer error handler returns { error } (was { message }).
F3: Wrap JSZip.loadAsync in try/catch — malformed zip returns 400
instead of falling through to 500.
F4: Raw download stream gets an error handler — logs the error and
destroys the response if headers were already sent.
F8: Strip leading hyphens from inferred skill name so filenames
like _my-skill.zip don't produce -my-skill (invalid name pattern).
F9: Fire-and-forget all updateSkillFileContent cache writes so the
response is sent immediately. Cache failures are logged but don't
block or fail the read.
* fix: Import orphan cleanup + Content-Disposition sanitization
Finding A: Add deleteFile dep to ImportSkillDeps. The per-file loop
in handleZip now cleans up stored blobs when upsertSkillFile fails,
closing the second half of the F1 orphan fix (upload handler was
already fixed).
Finding B: Sanitize filename in Content-Disposition header for raw
downloads — strip quotes, backslashes, and newlines to prevent
header injection from user-uploaded filenames.
* security: Prevent stored XSS via raw file downloads
Non-image files served via ?raw=true now use Content-Disposition:
attachment (force download) instead of inline. An uploaded .html or
.svg file served inline from the LibreChat origin could execute
scripts with access to the user's session — this closes that vector.
Images stay inline (needed for <img> rendering in SkillFileViewer).
X-Content-Type-Options: nosniff added to prevent MIME sniffing.
* security: Block SVG XSS — allowlist safe raster MIME types for inline
SVG (image/svg+xml) passed the startsWith('image/') check and was
served inline, but SVG is a scriptable format — embedded <script>
tags execute in the LibreChat origin. Replace the prefix match with
a Set of safe raster-only MIME types (png, jpeg, gif, webp, avif,
bmp). SVGs and any future scriptable image/* subtypes now get
Content-Disposition: attachment (forced download).
* fix: Cap JSON text response at 1MB, consistent md name inference
F3: Text files > 1MB now return { isBinary: false } with no content
field, forcing the client to use ?raw=true for download. Prevents
buffering 10MB files into heap for JSON serialization. Frontend
shows a download fallback when content is absent.
F4: handleMarkdown now infers skill name from filename (same as
handleZip) when frontmatter has no name, instead of rejecting
with 400. Consistent behavior across import paths.
F1 (reviewer concern): upsertSkillFile is NOT affected — it uses
{ new: false } for insert-vs-replace detection but does a follow-up
findOne (lines 855-859) to return the post-upsert document.
* fix: deleteFile arg shape, raw URL base path, hoist SAFE_INLINE_MIMES
Codex P2: deleteFile expects { filepath } object, not a raw string.
Both upload handler cleanup and import handler cleanup now pass
{ filepath } to match the strategy contract (deleteLocalFile,
deleteFileFromS3 all expect a file object).
Codex P2: Raw download URL in SkillFileViewer now uses apiBaseUrl
prefix so subpath deployments (/chat, etc.) resolve correctly.
NIT: Hoist SAFE_INLINE_MIMES Set to factory scope — was re-allocated
per raw download request inside the if block.
* fix: Remove inert cache write for large text files, localize aria-label
N2: The { isBinary: false } cache write for text files > 1MB had no
effect — subsequent requests still fell through to stream read since
neither isBinary nor content provided a fast-path short-circuit.
Removed the pointless DB updateOne per request.
N4: Replace hardcoded "Back to skill" aria-label with localize().
* refactor: Extract shared parseFrontmatter, widen deleteFile type
N3: Extract parseFrontmatter into Skills/utils/frontmatter.ts —
single implementation shared by SkillDetail and SkillFileViewer.
Accepts optional skipKeys set so callers control which frontmatter
fields are excluded (SkillDetail skips name/description since
they're shown in the header; other .md files show all fields).
N5: Widen ImportSkillDeps.deleteFile file param from { filepath }
to { filepath; [key: string]: unknown } to signal extensibility
if strategies start accessing additional file properties.
* fix: Advance i past list items for skipped keys, DRY parseSkillMd
Finding A: parseFrontmatter now consumes multi-line YAML list items
before checking skipKeys — prevents list lines from leaking into
subsequent key parsing as spurious fields.
Finding B: parseSkillMd now delegates to the shared parseFrontmatter
instead of re-implementing the same frontmatter scanning loop.
Reduces client-side parseFrontmatter implementations from 3 to 1.
* fix: Call apiBaseUrl(), delete storage blob on file removal
- apiBaseUrl is a function, not a string — call it in the template
literal so raw download URLs resolve correctly.
- deleteFileHandler now looks up the file record before deleting,
then fire-and-forget deletes the storage blob via the strategy's
deleteFile. Previously only the DB record was removed, leaving
orphaned blobs in local/S3/Firebase/Azure storage.
* fix: Clean up storage blobs when deleting an entire skill
deleteHandler now lists all files for the skill before calling
deleteSkill, then fire-and-forget deletes each blob via the
storage strategy. Previously only per-file deletion cleaned up
blobs — deleting a whole skill left all associated files orphaned
in local/S3/Firebase/Azure storage.
* refactor: useImportSkillMutation hook, fix TSkill[] unsafe cast
- Create useImportSkillMutation in mutations.ts + ImportSkillOptions
type. UploadSkillDialog now uses the mutation hook instead of
calling dataService.importSkill directly with manual useState
loading management. Eliminates unmounted-component state update
risk and aligns with the React Query mutation pattern used by
every other mutation in the codebase.
- SkillSelectDialog: replace as unknown as TSkill[] with proper
TSkillSummary typing. SkillCard props updated to TSkillSummary.
The dialog only uses summary-level fields (name, description,
category, author) — the cast was hiding a type mismatch.
* fix: Use saved source for import cleanup, delete old blob on replace
Codex P2: Import cleanup now uses file.source (the backend the file
was actually saved to) instead of re-resolving from config. In mixed
strategy setups, the previous approach could target the wrong backend.
Codex P2: When re-uploading a file to an existing relativePath, the
old blob is now deleted after successful upsert. Previously only the
DB record was replaced, leaving the old storage object orphaned.
* fix: Register PUT /:roleName/skills route in roles router
* fix: Re-read skill after zip file processing for fresh metadata
The import response was built from the skill object created before
the file loop, but each upsertSkillFile bumps version and fileCount.
Clients caching the stale response would get 409 conflicts on first
edit and see incorrect file counts.
Now re-reads the skill via getSkillById after the loop so the
response reflects the current version, fileCount, and updatedAt.
* fix: Size-check SKILL.md before decompression, don't gate on fileCount
P1: SKILL.md was decompressed before any size accounting. A crafted
archive could expand SKILL.md past 10MB before validation ran. Now
checks declared size pre-decompression and actual size post, both
against MAX_SINGLE_FILE_BYTES.
P2: File list query was gated on cached fileCount which can be stale
after mutations. Now fetches files for the active skill regardless
of fileCount. hasFiles derived from fetched data with fileCount as
fallback, so newly uploaded files appear without hard refresh.
* fix: Move files declaration before hasFiles to avoid TDZ error
* security: Stream-decompress zip entries with enforced byte cap
Replace zipEntry.async('nodebuffer') (buffers entire entry before
checking limits) with zipEntry.nodeStream('nodebuffer') piped
through a byte counter that destroys the stream when the per-file
or cumulative limit is exceeded.
Previously, when JSZip's _data.uncompressedSize was absent (the
common case), a high-ratio entry could allocate hundreds of MB
before the post-decompression check caught it. Now decompression
is aborted mid-stream at the exact byte threshold — no entry can
exceed its limit regardless of compression ratio.
* refactor: Reorganize access check for prompts in useSideNavLinks hook
Moved the prompts access check to a new position in the code to improve readability and maintainability. This change ensures that the prompts link is added to the navigation only if the user has the appropriate access, without altering the existing functionality.
---------
Co-authored-by: Danny Avila <danny@librechat.ai>
* fix: add docker system prune before image pull to prevent disk exhaustion
The 60GB droplet filled up after ~40 deploys because each
docker compose pull leaves the previous image's layers as
dangling/unused. The gitnexus image is ~700MB, so ~40 stale
copies ≈ 28GB of dead layers. Combined with indexes, OS, and
Docker's build cache, the disk hits 100% and the next pull fails
with 'no space left on device'.
Add a docker system prune -af --volumes BEFORE pulling the new
image on every deploy. This removes stopped containers, unused
networks, all images not referenced by a running container, and
build cache. Running containers are never touched. Typically
frees 1-2GB per deploy (the previous image's layers).
Also add a hard 2GB free-space guard after prune so the deploy
fails with a clear error instead of letting docker pull attempt
a 700MB extract onto a near-full disk.
* fix: cap PR indexes at 3 + delete-before-sync for 10GB disk
The 10GB droplet has ~2GB free. Each index is ~130MB, so 7 PR indexes
(~900MB) plus main+dev (~260MB) plus the ~700MB Docker image leaves
almost nothing for image pulls. The deploy failed with 'no space left
on device' during docker compose pull.
Three changes:
1. Cap PR indexes at MAX_PR_INDEXES=3. The resolve step now sorts
PR artifacts by created_at descending and only keeps the 3 most
recent. Older PR indexes are logged as evicted and their droplet
folders get cleaned by the prune step.
2. Prune BEFORE sync (was after). Freeing disk space from evicted
indexes before rsyncing new data is critical on a tight disk. The
old order (sync then prune) could briefly hold both old evicted
indexes and newly-uploaded ones simultaneously.
3. Delete-before-sync for every index, including main/dev. Instead
of rsync --delete (which transfers new files then removes extras),
rm -rf the target folder before rsync so the disk never holds both
old and new copies of the same index (~260MB saved per index).
Main/dev are only deleted when a fresh artifact is about to replace
them — never evicted between deploys.
Budget on 10GB disk:
OS + Docker engine: ~4.0 GB
Docker image (running): ~0.7 GB
main + dev indexes: ~0.26 GB
3 PR indexes: ~0.39 GB
Docker prune headroom: ~0.7 GB (for image pull)
Free: ~3.9 GB
* refine: restrict automatic PR indexing to danny-avila authored PRs
With 200+ open PRs and a 10GB disk capped at 3 served PR indexes,
auto-indexing every contributor PR burns CI minutes for artifacts
that will mostly be evicted before anyone queries them.
Narrow the pull_request auto-trigger to PRs authored by danny-avila
only. Other contributors' PRs can still be indexed on demand via
/gitnexus index (contributor-gated comment command) or manual
workflow_dispatch — both arrive as workflow_dispatch events and
bypass the pull_request filter entirely.
* fix: drop --volumes from docker system prune to preserve Caddy TLS state
The deploy workflow explicitly handles a caddy-not-running state later
in the same step. If Caddy is stopped when the prune runs, --volumes
deletes the caddy-data and caddy-config volumes (TLS certs + ACME
account keys), forcing a Let's Encrypt re-issuance on next start.
LE rate-limits to 5 certs per domain per week, so repeated wipes
could brick HTTPS for days.
docker system prune -af (without --volumes) still removes stopped
containers, unused networks, all dangling/unreferenced images, and
build cache — which is where the disk savings come from. Named
volumes are left untouched.
* fix: rsync-then-swap instead of delete-before-sync
The delete-before-sync pattern removed the live index BEFORE rsync
ran. If rsync failed (SSH timeout, disk pressure, network error),
the index was already gone — production served nothing for that
repo until a later deploy succeeded.
Replace with rsync-then-swap: upload to a .new temp directory, and
only rm + mv into place after rsync succeeds. On rsync failure,
the .new temp is cleaned up and the old index stays live. The cost
is ~130MB of extra disk while both old and new coexist, but the
prune step runs first and frees evicted PR indexes, so this fits
comfortably on the 10GB disk.
* fix: fail deploy on main/dev rsync failure, soft-fail PRs only
The rsync-then-swap pattern downgraded ALL failures to a warning,
so the deploy continued even when LibreChat or LibreChat-dev failed
to sync. The job would pull the new image, restart the container,
and report success while serving stale or missing core indexes.
Split by criticality: main/dev rsync failures now exit 1 (aborting
the deploy before the container restart). PR index failures remain
soft-fail with a warning — a missing PR index is inconvenient but
shouldn't take the whole server down.