diff --git a/.do/gitnexus/Dockerfile b/.do/gitnexus/Dockerfile index 58a79e087e7..8b7e538726e 100644 --- a/.do/gitnexus/Dockerfile +++ b/.do/gitnexus/Dockerfile @@ -7,13 +7,18 @@ FROM node:24.16.0-slim -ARG GITNEXUS_VERSION=1.5.3 +ARG GITNEXUS_VERSION=1.6.7 +# Pin the native DB to match the index workflow; gitnexus's ^0.17.0 range +# would otherwise let the served image drift from the CI-produced index. +ARG LADYBUG_VERSION=0.17.1 # 1. Build native addons with Bookworm toolchain, then remove build tools. # curl stays for the docker healthcheck; Caddy lives in its own container. +# LadybugDB is pinned nested under gitnexus so step 3's require() resolves it. RUN apt-get update \ && apt-get install -y --no-install-recommends python3 make g++ curl \ && npm install -g gitnexus@${GITNEXUS_VERSION} \ + && npm install --no-save --prefix /usr/local/lib/node_modules/gitnexus "@ladybugdb/core@${LADYBUG_VERSION}" \ && apt-get purge -y --auto-remove python3 make g++ \ && rm -rf /var/lib/apt/lists/* /root/.npm @@ -26,17 +31,13 @@ RUN echo "deb http://deb.debian.org/debian trixie main" > /etc/apt/sources.list. && rm -rf /var/lib/apt/lists/* # 3. Pre-install LadybugDB FTS + vector extensions so ~/.kuzu/extension/ -# is baked into the image. Workaround for upstream GitNexus 1.5.3 bug. +# is baked into the image. gitnexus serve loads extensions with a +# load-only policy and never installs them at runtime, so the cache +# must already exist. (GitNexus loads the vector extension itself +# via loadVectorExtension — no adapter patch needed.) COPY install-extensions.js /tmp/install-extensions.js RUN node /tmp/install-extensions.js && rm -rf /tmp/install-extensions.js /tmp/lbug-ext-install -# 4. Patch lbug-adapter.js to also LOAD EXTENSION vector after FTS. -RUN LBUG_ADAPTER=/usr/local/lib/node_modules/gitnexus/dist/mcp/core/lbug-adapter.js \ - && grep -q "LOAD EXTENSION fts" "$LBUG_ADAPTER" \ - && sed -i "s|await available\[0\]\.query('LOAD EXTENSION fts');|await available[0].query('LOAD EXTENSION fts'); try { await available[0].query('LOAD EXTENSION vector'); } catch (e) { /* vector extension may not be installed */ }|g" "$LBUG_ADAPTER" \ - && grep -c "LOAD EXTENSION vector" "$LBUG_ADAPTER" \ - && echo "lbug-adapter.js patched to load vector extension" - COPY entrypoint.sh /entrypoint.sh RUN chmod +x /entrypoint.sh diff --git a/.env.example b/.env.example index 6971a47c372..430fb8179e7 100644 --- a/.env.example +++ b/.env.example @@ -108,6 +108,10 @@ NODE_MAX_OLD_SPACE_SIZE=6144 # CONFIG_PATH="/alternative/path/to/librechat.yaml" +# Deployment skills are loaded read-only at startup and exposed to all users +# with the Skills capability enabled. Defaults to project root ./skill. +# DEPLOYMENT_SKILLS_DIR=./skill + #==================# # Langfuse Tracing # #==================# @@ -134,6 +138,8 @@ NODE_MAX_OLD_SPACE_SIZE=6144 # OTEL_TRACES_SAMPLER=parentbased_always_on # OTEL_LOG_LEVEL=INFO # OTEL_SDK_DISABLED=false +# Enable Redis command-level spans. Disabled by default to keep backend traces high-level. +# OTEL_IOREDIS_TRACING_ENABLED=false #===============================# # Real User Monitoring (Browser) # @@ -175,9 +181,13 @@ NODE_MAX_OLD_SPACE_SIZE=6144 # ENDPOINTS=openAI,assistants,azureOpenAI,google,anthropic -# Optional outbound proxy for server-side requests, including remote MCP HTTP/SSE transports. -# Remote MCP transports also honor HTTP_PROXY, HTTPS_PROXY, and NO_PROXY when PROXY is unset. +# Optional outbound proxy for server-side requests. +# PROXY applies to both HTTP and HTTPS targets. When PROXY is unset, LibreChat honors +# HTTP_PROXY, HTTPS_PROXY, and NO_PROXY/no_proxy for supported server-side clients. PROXY= +# HTTP_PROXY= +# HTTPS_PROXY= +# NO_PROXY= #===================================# # Known Endpoints - librechat.yaml # @@ -205,7 +215,7 @@ PROXY= #============# ANTHROPIC_API_KEY=user_provided -# ANTHROPIC_MODELS=claude-opus-4-8,claude-opus-4-7,claude-sonnet-4-6,claude-opus-4-6,claude-opus-4-20250514,claude-3-7-sonnet-20250219,claude-3-5-sonnet-20241022,claude-3-5-haiku-20241022,claude-3-opus-20240229,claude-3-sonnet-20240229,claude-3-haiku-20240307 +# ANTHROPIC_MODELS=claude-fable-5,claude-opus-4-8,claude-opus-4-7,claude-sonnet-4-6,claude-opus-4-6,claude-opus-4-20250514,claude-3-7-sonnet-20250219,claude-3-5-sonnet-20241022,claude-3-5-haiku-20241022,claude-3-opus-20240229,claude-3-sonnet-20240229,claude-3-haiku-20240307 # ANTHROPIC_REVERSE_PROXY= # Set to true to use Anthropic models through Google Vertex AI instead of direct API @@ -271,8 +281,8 @@ ANTHROPIC_API_KEY=user_provided # BEDROCK_AWS_BEARER_TOKEN=yourBedrockApiKey # Note: This example list is not meant to be exhaustive. If omitted, all known, supported model IDs will be included for you. -# BEDROCK_AWS_MODELS=anthropic.claude-opus-4-8,anthropic.claude-opus-4-7,anthropic.claude-sonnet-4-6,anthropic.claude-opus-4-6-v1,anthropic.claude-3-5-sonnet-20240620-v1:0,meta.llama3-1-8b-instruct-v1:0 -# Cross-region inference model IDs: us.anthropic.claude-opus-4-8,us.anthropic.claude-opus-4-7,us.anthropic.claude-sonnet-4-6,us.anthropic.claude-opus-4-6-v1,global.anthropic.claude-opus-4-6-v1 +# BEDROCK_AWS_MODELS=anthropic.claude-fable-5,anthropic.claude-opus-4-8,anthropic.claude-opus-4-7,anthropic.claude-sonnet-4-6,anthropic.claude-opus-4-6-v1,anthropic.claude-3-5-sonnet-20240620-v1:0,meta.llama3-1-8b-instruct-v1:0 +# Cross-region inference model IDs: us.anthropic.claude-fable-5,us.anthropic.claude-opus-4-8,us.anthropic.claude-opus-4-7,us.anthropic.claude-sonnet-4-6,us.anthropic.claude-opus-4-6-v1,global.anthropic.claude-opus-4-6-v1 # See all Bedrock model IDs here: https://docs.aws.amazon.com/bedrock/latest/userguide/model-ids.html#model-ids-arns @@ -283,6 +293,10 @@ ANTHROPIC_API_KEY=user_provided # The following models are not support due to not supporting conversation history: # ai21.j2-ultra-v1, cohere.command-text-v14, cohere.command-light-text-v14 +# Claude Mythos-class models (anthropic.claude-fable-5, anthropic.claude-mythos-5) are inference-profile +# only on Bedrock — use a profile ID (e.g. us.anthropic.claude-fable-5) — and require opting into Anthropic +# data sharing via the Bedrock Data Retention API/console before they can be invoked. + #============# # Google # #============# @@ -637,7 +651,9 @@ OPENID_NAME_CLAIM= # Set to determine which user info claim to use as the email/identifier for user matching (e.g., "upn" for Entra ID) # When not set, defaults to: email -> preferred_username -> upn OPENID_EMAIL_CLAIM= -# Optional audience parameter for OpenID authorization requests +# Optional audience parameter for OpenID authorization requests and JWT validation. +# If comma-separated values are provided, JWT validation accepts all values and +# authorization requests use the first non-empty value. OPENID_AUDIENCE= # Optional audience parameter for OpenID refresh token requests. # Some providers, such as Auth0 custom APIs, require this to preserve @@ -654,6 +670,12 @@ OPENID_AUTO_REDIRECT=false OPENID_USE_PKCE=false #Set to true to reuse openid tokens for authentication management instead of using the mongodb session and the custom refresh token. OPENID_REUSE_TOKENS= +#Max age a reused OpenID session token is served before LibreChat forces an IdP refresh. Default 900000 ms (15 min). +#Accepts an arithmetic expression like SESSION_EXPIRY (e.g. 60 * 60 * 24 * 1000 for 24h). +#Raise toward the IdP access-token lifetime when the IdP revokes the previous access token on refresh, so a still-valid token +#is not rotated/revoked out from under downstream consumers (e.g. MCP servers that introspect the bearer). +#When OPENID_REUSE_TOKENS=true, the OpenID session cookie maxAge is extended to at least this value. +OPENID_REUSE_MAX_SESSION_AGE_MS= #By default, signing key verification results are cached in order to prevent excessive HTTP requests to the JWKS endpoint. #If a signing key matching the kid is found, this will be cached and the next time this kid is requested the signing key will be served from the cache. #Default is true. @@ -824,6 +846,9 @@ ALLOW_SHARED_LINKS_PUBLIC=false # If you have another service in front of your LibreChat doing compression, disable express based compression here # DISABLE_COMPRESSION=true +# Serve precompressed Brotli versions of static app assets when available. +# ENABLE_STATIC_ASSET_BROTLI=true + # If you have gzipped version of uploaded image images in the same folder, this will enable gzip scan and serving of these images # Note: The images folder will be scanned on startup and a ma kept in memory. Be careful for large number of images. # ENABLE_IMAGE_OUTPUT_GZIP_SCAN=true @@ -978,6 +1003,12 @@ OPENWEATHER_API_KEY= # Timeout for OAuth detection requests in milliseconds # MCP_OAUTH_DETECTION_TIMEOUT=5000 +# How long to wait (ms) for a user to complete the OAuth flow before timing out (default: 10 minutes) +# MCP_OAUTH_HANDLING_TIMEOUT=600000 + +# TTL (ms) for OAuth flow state; must outlive MCP_OAUTH_HANDLING_TIMEOUT (default: 15 minutes) +# MCP_OAUTH_FLOW_TTL=900000 + # Cache connection status checks for this many milliseconds to avoid expensive verification # MCP_CONNECTION_CHECK_TTL=60000 diff --git a/.github/playwright.yml b/.github/playwright.yml index 28eca14d581..27f026a525b 100644 --- a/.github/playwright.yml +++ b/.github/playwright.yml @@ -39,7 +39,7 @@ # - uses: actions/checkout@v4 # - uses: actions/setup-node@v4 # with: -# node-version: 18 +# node-version: 24.16.0 # cache: 'npm' # - name: Install global dependencies diff --git a/.github/workflows/backend-review.yml b/.github/workflows/backend-review.yml index e25e884feeb..46a698cd5ac 100644 --- a/.github/workflows/backend-review.yml +++ b/.github/workflows/backend-review.yml @@ -46,7 +46,7 @@ jobs: uses: actions/cache@v4 with: path: packages/data-provider/dist - key: build-data-provider-${{ runner.os }}-${{ hashFiles('packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/rollup.config.js', 'packages/data-provider/package.json') }} + key: build-data-provider-${{ runner.os }}-${{ hashFiles('packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }} - name: Build data-provider if: steps.cache-data-provider.outputs.cache-hit != 'true' @@ -57,7 +57,7 @@ jobs: uses: actions/cache@v4 with: path: packages/data-schemas/dist - key: build-data-schemas-${{ runner.os }}-${{ hashFiles('packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/rollup.config.js', 'packages/data-schemas/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/rollup.config.js', 'packages/data-provider/package.json') }} + key: build-data-schemas-${{ runner.os }}-${{ hashFiles('packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }} - name: Build data-schemas if: steps.cache-data-schemas.outputs.cache-hit != 'true' @@ -68,7 +68,7 @@ jobs: uses: actions/cache@v4 with: path: packages/api/dist - key: build-api-${{ runner.os }}-${{ hashFiles('packages/api/src/**', 'packages/api/tsconfig*.json', 'packages/api/server-rollup.config.js', 'packages/api/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/rollup.config.js', 'packages/data-provider/package.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/rollup.config.js', 'packages/data-schemas/package.json') }} + key: build-api-${{ runner.os }}-${{ hashFiles('packages/api/src/**', 'packages/api/tsconfig*.json', 'packages/api/tsdown.config.mjs', 'packages/api/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json') }} - name: Build api if: steps.cache-api.outputs.cache-hit != 'true' @@ -215,10 +215,14 @@ jobs: fi test-api: - name: 'Tests: api' + name: 'Tests: api (shard ${{ matrix.shard }}/3)' needs: build runs-on: ubuntu-latest timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3] env: MONGO_URI: ${{ secrets.MONGO_URI }} OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }} @@ -278,8 +282,8 @@ jobs: - name: Prepare .env.test file run: cp api/test/.env.test.example api/test/.env.test - - name: Run unit tests - run: cd api && npm run test:ci + - name: Run unit tests (shard ${{ matrix.shard }}/3) + run: cd api && npm run test:ci -- --shard=${{ matrix.shard }}/3 test-data-provider: name: 'Tests: data-provider' @@ -364,14 +368,18 @@ jobs: run: cd packages/data-schemas && npm run test:ci test-packages-api: - name: 'Tests: @librechat/api' + name: 'Tests: @librechat/api (shard ${{ matrix.shard }}/4)' needs: build runs-on: ubuntu-latest # Suite typically completes in ~5 min on a warm runner, but tail-latency # cancellations have started showing up: tests are actively passing right - # up to the timeout, then the job is killed mid-suite. Bump headroom to - # absorb GitHub Actions runner variance. + # up to the timeout, then the job is killed mid-suite. Sharding splits the + # suite across runners; per-shard headroom still absorbs runner variance. timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v4 @@ -414,5 +422,5 @@ jobs: name: build-api path: packages/api/dist - - name: Run unit tests - run: cd packages/api && npm run test:ci + - name: Run unit tests (shard ${{ matrix.shard }}/4) + run: cd packages/api && npm run test:ci -- --shard=${{ matrix.shard }}/4 diff --git a/.github/workflows/cache-integration-tests.yml b/.github/workflows/cache-integration-tests.yml index 3e4c5418ae7..1a70e4b6b0e 100644 --- a/.github/workflows/cache-integration-tests.yml +++ b/.github/workflows/cache-integration-tests.yml @@ -32,7 +32,6 @@ jobs: uses: actions/setup-node@v4 with: node-version: '24.16.0' - cache: 'npm' - name: Install Redis tools run: | @@ -57,14 +56,54 @@ jobs: redis-cli -p 7002 cluster info || exit 1 redis-cli -p 7003 cluster info || exit 1 + - name: Restore node_modules cache + id: cache-node-modules + uses: actions/cache@v4 + with: + path: | + node_modules + api/node_modules + packages/api/node_modules + packages/data-provider/node_modules + packages/data-schemas/node_modules + key: node-modules-backend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }} + - name: Install dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' run: npm ci - - name: Build packages - run: | - npm run build:data-provider - npm run build:data-schemas - npm run build:api + - name: Restore data-provider build cache + id: cache-data-provider + uses: actions/cache@v4 + with: + path: packages/data-provider/dist + key: build-data-provider-${{ runner.os }}-${{ hashFiles('packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }} + + - name: Build data-provider + if: steps.cache-data-provider.outputs.cache-hit != 'true' + run: npm run build:data-provider + + - name: Restore data-schemas build cache + id: cache-data-schemas + uses: actions/cache@v4 + with: + path: packages/data-schemas/dist + key: build-data-schemas-${{ runner.os }}-${{ hashFiles('packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }} + + - name: Build data-schemas + if: steps.cache-data-schemas.outputs.cache-hit != 'true' + run: npm run build:data-schemas + + - name: Restore api build cache + id: cache-api + uses: actions/cache@v4 + with: + path: packages/api/dist + key: build-api-${{ runner.os }}-${{ hashFiles('packages/api/src/**', 'packages/api/tsconfig*.json', 'packages/api/tsdown.config.mjs', 'packages/api/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json') }} + + - name: Build api + if: steps.cache-api.outputs.cache-hit != 'true' + run: npm run build:api - name: Run all cache integration tests (Single Redis Node) working-directory: packages/api diff --git a/.github/workflows/config-review.yml b/.github/workflows/config-review.yml new file mode 100644 index 00000000000..fc25989aa87 --- /dev/null +++ b/.github/workflows/config-review.yml @@ -0,0 +1,88 @@ +name: Config Migration Tests +on: + pull_request: + paths: + - 'config/**' + - 'api/models/**' + - 'api/db/**' + - 'packages/data-schemas/src/**' + - 'packages/data-provider/src/**' + - 'packages/api/src/acl/**' + - 'packages/api/src/shared-links/**' + +env: + NODE_ENV: CI + NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}' + +jobs: + test-config: + name: 'Tests: config migrations' + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 24.16.0 + uses: actions/setup-node@v4 + with: + node-version: '24.16.0' + + - name: Restore node_modules cache + id: cache-node-modules + uses: actions/cache@v4 + with: + path: | + node_modules + api/node_modules + packages/api/node_modules + packages/data-provider/node_modules + packages/data-schemas/node_modules + key: node-modules-backend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }} + + - name: Install dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: npm ci + + - name: Restore data-provider build cache + id: cache-data-provider + uses: actions/cache@v4 + with: + path: packages/data-provider/dist + key: build-data-provider-${{ runner.os }}-${{ hashFiles('packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }} + + - name: Build data-provider + if: steps.cache-data-provider.outputs.cache-hit != 'true' + run: npm run build:data-provider + + - name: Restore data-schemas build cache + id: cache-data-schemas + uses: actions/cache@v4 + with: + path: packages/data-schemas/dist + key: build-data-schemas-${{ runner.os }}-${{ hashFiles('packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }} + + - name: Build data-schemas + if: steps.cache-data-schemas.outputs.cache-hit != 'true' + run: npm run build:data-schemas + + - name: Restore api build cache + id: cache-api + uses: actions/cache@v4 + with: + path: packages/api/dist + key: build-api-${{ runner.os }}-${{ hashFiles('packages/api/src/**', 'packages/api/tsconfig*.json', 'packages/api/tsdown.config.mjs', 'packages/api/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json') }} + + - name: Build api + if: steps.cache-api.outputs.cache-hit != 'true' + run: npm run build:api + + - name: Create empty auth.json file + run: | + mkdir -p api/data + echo '{}' > api/data/auth.json + + - name: Prepare .env.test file + run: cp api/test/.env.test.example api/test/.env.test + + - name: Run config migration tests + run: npm run test:config diff --git a/.github/workflows/docker-smoke.yml b/.github/workflows/docker-smoke.yml index d3f313b5716..3780959d8bf 100644 --- a/.github/workflows/docker-smoke.yml +++ b/.github/workflows/docker-smoke.yml @@ -9,12 +9,22 @@ on: - 'Dockerfile.multi' - 'package.json' - 'package-lock.json' + - 'api/**' + - 'client/**' + - 'config/**' + - 'skill/**' + - 'packages/api/**' - 'packages/client/**' - 'packages/data-provider/**' + - 'packages/data-schemas/**' permissions: contents: read +concurrency: + group: docker-smoke-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: client-package-target: name: Build Docker client package target @@ -34,3 +44,85 @@ jobs: platforms: linux/amd64 push: false target: client-package-build + + api-runtime-smoke: + name: API runtime smoke (production image boots) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + # Build the real production image (final `api-build` stage), which installs + # with `npm ci --omit=dev` — the same prune that, in prod, exposed runtime + # dependencies the tsdown bundle externalizes but were never declared. + - name: Build production image + uses: docker/build-push-action@v5 + with: + context: . + file: Dockerfile.multi + platforms: linux/amd64 + push: false + load: true + tags: librechat-api-smoke:ci + cache-from: type=gha,scope=docker-smoke-api + cache-to: type=gha,mode=max,scope=docker-smoke-api + + # Loads the entire externalized require graph of the built @librechat/api + # bundle inside the pruned production image. A missing or ESM-incompatible + # runtime dependency (e.g. the `get-stream` regression) fails here with a + # non-zero exit — deterministically, with no database required. + - name: Verify production image resolves all runtime modules + run: | + docker run --rm librechat-api-smoke:ci \ + node -e "require('@librechat/api'); require('@librechat/api/telemetry'); console.log('module resolution OK')" + + # Boot the real entrypoint against a real MongoDB so the *entire* server + # require graph loads (api/db throws at module scope without MONGO_URI, and + # is imported before models/services/routes), then gate on /readyz AND the + # container staying alive. /readyz only returns 200 after the post-listen + # startup (initializeMCPs + checkMigrations) sets serverReady, and those + # steps process.exit(1) on failure — so ANY startup crash (missing module, + # ReferenceError, bad config, post-listen failure) fails the smoke. + - name: Boot production image against MongoDB and poll /readyz + run: | + set -u + docker network create lc-smoke + docker run -d --name lc-mongo --network lc-smoke mongo:8.0.20 + docker run -d --name lc-api --network lc-smoke -p 3080:3080 \ + -e HOST=0.0.0.0 -e PORT=3080 \ + -e NODE_ENV=production \ + -e MONGO_URI=mongodb://lc-mongo:27017/LibreChat \ + -e CREDS_KEY=0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef \ + -e CREDS_IV=0123456789abcdef0123456789abcdef \ + -e JWT_SECRET=docker-smoke-jwt-secret \ + -e JWT_REFRESH_SECRET=docker-smoke-jwt-refresh-secret \ + -e SEARCH=false \ + librechat-api-smoke:ci + + healthy="" + for i in $(seq 1 60); do + if [ "$(docker inspect -f '{{.State.Running}}' lc-api 2>/dev/null)" != "true" ]; then + echo "::error::API container exited during startup (exit code $(docker inspect -f '{{.State.ExitCode}}' lc-api 2>/dev/null))" + break + fi + if [ "$(curl -sS -o /dev/null -w '%{http_code}' http://localhost:3080/readyz 2>/dev/null || true)" = "200" ]; then + healthy="yes" + echo "/readyz returned 200 — server fully booted (post-listen startup complete)." + break + fi + sleep 2 + done + + echo "----- last 100 lines of api container logs -----" + docker logs lc-api 2>&1 | tail -100 || true + echo "------------------------------------------------" + docker rm -f lc-api lc-mongo >/dev/null 2>&1 || true + docker network rm lc-smoke >/dev/null 2>&1 || true + + if [ -z "$healthy" ]; then + echo "::error::Production image failed to reach a ready /readyz within timeout" + exit 1 + fi diff --git a/.github/workflows/eslint-ci.yml b/.github/workflows/eslint-ci.yml index 3710f8a02ee..3ab8528b042 100644 --- a/.github/workflows/eslint-ci.yml +++ b/.github/workflows/eslint-ci.yml @@ -94,3 +94,35 @@ jobs: echo "::error::Or rely on the lint-staged pre-commit hook (do not bypass with --no-verify)." exit 1 fi + + # Verify import ordering on the same set of changed files. The script + # only sorts files under known source roots, so unrelated changed files + # (configs, etc.) are ignored. Matches the lint-staged pre-commit hook. + - name: Check import sorting on changed files + run: | + BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH") + mapfile -d '' -t CHANGED_FILES < <( + git diff -z --name-only --diff-filter=ACMRTUXB "$BASE_SHA" HEAD | + grep -zE '^(api|client|packages)/.*\.(js|jsx|ts|tsx)$' || true + ) + + if [[ ${#CHANGED_FILES[@]} -eq 0 ]]; then + echo "No matching files changed. Skipping import-sort check." + exit 0 + fi + + echo "Files to check:" + printf '%s\n' "${CHANGED_FILES[@]}" + + # `--check` lists offending files and exits non-zero without writing. + if ! node scripts/sort-imports.mts --check "${CHANGED_FILES[@]}"; then + echo "" + echo "::error::Import order drift detected. Fix locally with:" + echo "::error:: npm run sort-imports" + echo "::error::For specific files:" + echo "::error:: npm run sort-imports -- packages/api/src/app/metrics.ts packages/api/src/rum/proxy.ts" + echo "::error::To check without writing files:" + echo "::error:: npm run sort-imports:check" + echo "::error::Or rely on the lint-staged pre-commit hook (do not bypass with --no-verify)." + exit 1 + fi diff --git a/.github/workflows/frontend-review.yml b/.github/workflows/frontend-review.yml index b84d145bee2..a3f31efba63 100644 --- a/.github/workflows/frontend-review.yml +++ b/.github/workflows/frontend-review.yml @@ -4,7 +4,9 @@ on: pull_request: paths: - 'client/**' + - 'packages/client/**' - 'packages/data-provider/**' + - '.github/workflows/frontend-review.yml' permissions: contents: read @@ -45,7 +47,7 @@ jobs: uses: actions/cache@v4 with: path: packages/data-provider/dist - key: build-data-provider-${{ runner.os }}-${{ hashFiles('packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/rollup.config.js', 'packages/data-provider/package.json') }} + key: build-data-provider-${{ runner.os }}-${{ hashFiles('packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }} - name: Build data-provider if: steps.cache-data-provider.outputs.cache-hit != 'true' @@ -56,7 +58,7 @@ jobs: uses: actions/cache@v4 with: path: packages/client/dist - key: build-client-package-${{ runner.os }}-${{ hashFiles('packages/client/src/**', 'packages/client/tsconfig*.json', 'packages/client/rollup.config.js', 'packages/client/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/rollup.config.js', 'packages/data-provider/package.json') }} + key: build-client-package-${{ runner.os }}-${{ hashFiles('packages/client/src/**', 'packages/client/tsconfig*.json', 'packages/client/tsdown.config.mjs', 'packages/client/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }} - name: Build client-package if: steps.cache-client-package.outputs.cache-hit != 'true' @@ -76,11 +78,59 @@ jobs: path: packages/client/dist retention-days: 2 + typecheck: + name: TypeScript type checks (client) + needs: build + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v4 + + - name: Use Node.js 24.16.0 + uses: actions/setup-node@v4 + with: + node-version: '24.16.0' + + - name: Restore node_modules cache + id: cache-node-modules + uses: actions/cache@v4 + with: + path: | + node_modules + client/node_modules + packages/client/node_modules + packages/data-provider/node_modules + key: node-modules-frontend-${{ runner.os }}-24.16.0-${{ hashFiles('package-lock.json') }} + + - name: Install dependencies + if: steps.cache-node-modules.outputs.cache-hit != 'true' + run: npm ci + + - name: Download data-provider build + uses: actions/download-artifact@v4 + with: + name: build-data-provider + path: packages/data-provider/dist + + - name: Download client-package build + uses: actions/download-artifact@v4 + with: + name: build-client-package + path: packages/client/dist + + - name: Type check client + run: npm run typecheck + working-directory: client + test-ubuntu: - name: 'Tests: Ubuntu' + name: 'Tests: Ubuntu (shard ${{ matrix.shard }}/4)' needs: build runs-on: ubuntu-latest timeout-minutes: 15 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v4 @@ -116,15 +166,19 @@ jobs: name: build-client-package path: packages/client/dist - - name: Run unit tests - run: npm run test:ci --verbose + - name: Run unit tests (shard ${{ matrix.shard }}/4) + run: npm run test:ci -- --shard=${{ matrix.shard }}/4 working-directory: client test-windows: - name: 'Tests: Windows' + name: 'Tests: Windows (shard ${{ matrix.shard }}/4)' needs: build runs-on: windows-latest timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + shard: [1, 2, 3, 4] steps: - uses: actions/checkout@v4 @@ -160,8 +214,8 @@ jobs: name: build-client-package path: packages/client/dist - - name: Run unit tests - run: npm run test:ci --verbose + - name: Run unit tests (shard ${{ matrix.shard }}/4) + run: npm run test:ci -- --shard=${{ matrix.shard }}/4 working-directory: client build-verify: diff --git a/.github/workflows/gitnexus-deploy.yml b/.github/workflows/gitnexus-deploy.yml index 203138c706a..dc62068a6ca 100644 --- a/.github/workflows/gitnexus-deploy.yml +++ b/.github/workflows/gitnexus-deploy.yml @@ -2,14 +2,10 @@ # # Architecture: # GitHub Actions (deploy) -# 1. Resolves latest successful index runs for main, dev, and every -# open PR that already has an index artifact (contributor-gated -# upstream by the index workflow's author_association check) +# 1. Resolves latest successful index runs for main and dev # 2. Downloads each matching .gitnexus/ artifact # 3. Rsyncs them into /opt/gitnexus/indexes// on the droplet -# 4. Removes any stale folders on the droplet for PRs that closed -# (even though gitnexus-cleanup-pr.yml also handles that path, -# this is a safety net in case the close event was missed) +# 4. Removes any stale folders on the droplet that are not main/dev # 5. Pulls latest image, force-recreates gitnexus, reloads Caddy, # and polls docker health until the container reports healthy # The caddy container is untouched — no TLS churn. @@ -58,14 +54,14 @@ on: workflow_dispatch: inputs: pr_number: - description: 'Optional PR number to post completion comment on (set by bot-triggered dispatches from gitnexus-index.yml)' + description: 'Optional PR number for status comments from bot-triggered dispatches' type: string default: '' permissions: actions: read contents: read - pull-requests: write # post completion comments on served PR indexes + pull-requests: write # post status comments on PR command dispatches # Global serialization. Earlier versions used per-ref concurrency with # cancel-in-progress so rapid pushes to the same ref coalesced but deploys @@ -84,7 +80,7 @@ concurrency: cancel-in-progress: false env: - GITNEXUS_VERSION: '1.5.3' + GITNEXUS_VERSION: '1.6.7' IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/librechat-gitnexus jobs: @@ -93,7 +89,12 @@ jobs: build-image: if: | github.event_name == 'workflow_dispatch' || - github.event.workflow_run.conclusion == 'success' + ( + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + (github.event.workflow_run.head_branch == 'main' || + github.event.workflow_run.head_branch == 'dev') + ) runs-on: ubuntu-latest timeout-minutes: 20 permissions: @@ -158,7 +159,7 @@ jobs: permissions: actions: read contents: read - pull-requests: write # post deploy-complete comments on served PR indexes + pull-requests: write # post deploy-complete comments on PR command dispatches steps: - name: Checkout deploy config uses: actions/checkout@v4 @@ -217,62 +218,7 @@ jobs: core.info(`${branch}: run ${fresh.workflow_run.id} -> ${name}`); } - // --- open PRs with at least one successful index run --- - // github.paginate handles the 100-per-page ceiling automatically - // so the resolution works on repos with 200+ concurrent open PRs. - const openPrs = await github.paginate(github.rest.pulls.list, { - owner: context.repo.owner, - repo: context.repo.repo, - state: 'open', - per_page: 100, - }); - core.info(`Found ${openPrs.length} open PRs`); - - // Parallelize artifact lookups in fixed-size batches so the - // resolve step runs in seconds instead of minutes on big repos, - // without burning the GitHub API rate limit all at once. - const BATCH_SIZE = 10; - const prMatches = []; - for (let i = 0; i < openPrs.length; i += BATCH_SIZE) { - const batch = openPrs.slice(i, i + BATCH_SIZE); - const results = await Promise.all( - batch.map(async (pr) => { - const artifactName = `gitnexus-index-pr-${pr.number}`; - const fresh = await latestArtifact(artifactName); - return fresh ? { pr, artifactName, fresh } : null; - }), - ); - for (const hit of results) { - if (hit) prMatches.push(hit); - } - } - - // Cap to the N most recent PR indexes by artifact creation time. - // On a 10GB droplet each index is ~130MB; 3 PRs + main + dev ≈ - // 650MB of index data, leaving headroom for the ~700MB Docker image - // and OS. Older PR indexes are evicted by the prune step. - const MAX_PR_INDEXES = 3; - prMatches.sort( - (a, b) => new Date(b.fresh.created_at) - new Date(a.fresh.created_at), - ); - const keptPrs = prMatches.slice(0, MAX_PR_INDEXES); - const evictedPrs = prMatches.slice(MAX_PR_INDEXES); - - for (const { pr, artifactName, fresh } of keptPrs) { - serve.push({ - name: `LibreChat-pr-${pr.number}`, - artifactName, - runId: fresh.workflow_run.id, - }); - core.info(`PR #${pr.number}: run ${fresh.workflow_run.id} -> LibreChat-pr-${pr.number}`); - } - if (evictedPrs.length) { - core.info( - `Evicted ${evictedPrs.length} older PR indexes (cap=${MAX_PR_INDEXES}): ` + - evictedPrs.map((e) => `#${e.pr.number}`).join(', '), - ); - } - core.info(`Serving ${keptPrs.length} PR indexes out of ${prMatches.length} with artifacts (${openPrs.length} open PRs total)`); + core.info('PR index deploys are paused; serving main and dev only.'); if (!serve.length) { core.setFailed('No indexes to serve'); @@ -387,7 +333,7 @@ jobs: # ── Step 1: prune FIRST ──────────────────────────────── # Remove any folders on the droplet that aren't in the active set. # This frees disk BEFORE rsyncing new data, which matters on a - # 10GB disk where each index is ~130MB. + # 10GB disk where each current index is ~400MB. echo "Pruning stale indexes (keeping: $ACTIVE_NAMES)" ssh -i ~/.ssh/deploy_key "$SSH_USER@$SSH_HOST" \ ACTIVE_NAMES="$ACTIVE_NAMES" bash <<'REMOTE' @@ -415,8 +361,8 @@ jobs: # it into place. If rsync fails, the old index survives intact # and the partial temp dir is cleaned up — no production data # is lost. The brief period where both old + new exist costs - # ~130MB of extra disk, but the prune step already freed - # space from evicted PR indexes so this fits on a 10GB disk. + # ~400MB of extra disk, but the prune step already freed + # space from evicted indexes so this fits on a 10GB disk. for dir in staging/*/; do [ -d "$dir" ] || continue name=$(basename "$dir") @@ -460,10 +406,11 @@ jobs: # ── Disk cleanup ────────────────────────────────────── # Docker accumulates old image layers, dangling images, and - # build cache across deploys. On a 60GB droplet with a 700MB+ - # gitnexus image, this fills the disk after ~40 deploys. - # Prune everything not used by currently-running containers - # BEFORE pulling the new image so the extract has room. + # build cache across deploys. This droplet is only ~8.7GB + # usable with a 700MB+ gitnexus image, so disk pressure is + # constant. Prune everything not used by currently-running + # containers BEFORE pulling the new image so the extract has + # room; the post-recreate prune below reclaims the old image. echo "Disk before cleanup:" df -h / | tail -1 # Omit --volumes: Caddy's caddy-data and caddy-config volumes @@ -475,9 +422,13 @@ jobs: echo "Disk after cleanup:" df -h / | tail -1 - # Fail fast if disk is critically low even after prune + # Fail fast if disk is critically low even after prune. The + # gitnexus image is ~700MB and shares most layers with the + # running one, so an incremental pull needs well under 1GB. + # 1536MB leaves headroom on this small droplet without the + # over-conservative 2GB guard aborting on a healthy box. AVAIL_MB=$(df --output=avail -m / | tail -1 | tr -d ' ') - if [ "$AVAIL_MB" -lt 2048 ]; then + if [ "$AVAIL_MB" -lt 1536 ]; then echo "::error::Disk critically low (${AVAIL_MB}MB free). Aborting deploy." exit 1 fi @@ -485,6 +436,13 @@ jobs: docker compose pull gitnexus docker compose up -d --force-recreate gitnexus + # The previous gitnexus image is now dangling (the running + # container was recreated onto the freshly pulled image). The + # pre-pull prune above couldn't touch it because it was still + # in use at that point. Reclaim it now so the old generation + # doesn't accumulate — critical on this 10GB droplet. + docker image prune -f 2>/dev/null || true + # Reload Caddy in-place so a changed Caddyfile takes effect # without losing TLS certs or restarting connections. If caddy # isn't running yet (first-time bootstrap), bring it up. @@ -555,15 +513,41 @@ jobs: DEPLOY_STATUS: ${{ job.status }} with: script: | + const deployUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; + const matrix = JSON.parse(process.env.MATRIX || '[]'); let prNum = null; // Case 1: dispatched directly with pr_number (bot-fallback path) if (process.env.DISPATCH_PR_NUMBER && process.env.DISPATCH_PR_NUMBER !== '') { - prNum = parseInt(process.env.DISPATCH_PR_NUMBER, 10); + const dispatchPrRaw = process.env.DISPATCH_PR_NUMBER; + if (!/^\d+$/.test(dispatchPrRaw)) { + core.setFailed(`Invalid PR number: ${dispatchPrRaw}`); + return; + } + + const dispatchPrNum = Number(dispatchPrRaw); + const servedPr = matrix.some((m) => m.name === `LibreChat-pr-${dispatchPrNum}`); + + if (!servedPr) { + const body = [ + '### GitNexus: PR deploy skipped', + '', + 'PR-specific deploys are paused; only `LibreChat` and `LibreChat-dev` are currently served.', + `[Deploy run](${deployUrl})`, + ].join('\n'); + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: dispatchPrNum, + body, + }); + return; + } + + prNum = dispatchPrNum; } // Case 2: workflow_run trigger from a PR index run else if (context.eventName === 'workflow_run') { - const matrix = JSON.parse(process.env.MATRIX || '[]'); const triggerRunId = Number(process.env.TRIGGER_RUN_ID); const match = matrix.find( (m) => m.runId === triggerRunId && m.name.startsWith('LibreChat-pr-'), @@ -578,7 +562,6 @@ jobs: return; } - const deployUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; const ok = process.env.DEPLOY_STATUS === 'success'; const body = [ `### GitNexus: ${ok ? '🚀 deployed' : '❌ deploy failed'}`, diff --git a/.github/workflows/gitnexus-index.yml b/.github/workflows/gitnexus-index.yml index 21c3f693213..ef368261bd9 100644 --- a/.github/workflows/gitnexus-index.yml +++ b/.github/workflows/gitnexus-index.yml @@ -43,7 +43,7 @@ concurrency: cancel-in-progress: true env: - GITNEXUS_VERSION: '1.5.3' + GITNEXUS_VERSION: '1.6.7' jobs: index: @@ -63,7 +63,12 @@ jobs: github.event_name != 'pull_request' || github.event.pull_request.user.login == 'danny-avila' runs-on: ubuntu-latest - timeout-minutes: 25 + # Embedding generation dominates the budget: ~45 min worst case on + # standard runners since the 1.6.x graph (~23k nodes) doubled vs 1.5.x. + timeout-minutes: 60 + # Best-effort index: a tool-internal crash must not block PRs. Fail soft on + # PR events; push/dispatch runs still fail loudly so regressions stay visible. + continue-on-error: ${{ github.event_name == 'pull_request' }} steps: - name: Validate dispatch inputs if: github.event_name == 'workflow_dispatch' @@ -162,7 +167,7 @@ jobs: --no-save \ --no-package-lock \ "gitnexus@${{ env.GITNEXUS_VERSION }}" \ - "@ladybugdb/core@0.15.2" + "@ladybugdb/core@0.17.1" test -x "$RUNNER_TEMP/gitnexus-cli/node_modules/.bin/gitnexus" - name: Checkout repository @@ -177,12 +182,29 @@ jobs: fetch-depth: 1 persist-credentials: false + # HuggingFace throttles anonymous model downloads from shared GHA + # runner IPs (429s or stalled transfers). Cache the embedding model + # across runs so warm runs never touch HF at all. + - name: Cache HuggingFace embedding model + if: steps.flags.outputs.enable_embeddings == 'true' + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/hf-cache + key: hf-model-snowflake-arctic-embed-xs-v1 + - name: Run GitNexus Analyze working-directory: ${{ runner.temp }} env: ENABLE_EMBEDDINGS: ${{ steps.flags.outputs.enable_embeddings }} FORCE: ${{ inputs.force }} GITNEXUS_BIN: ${{ runner.temp }}/gitnexus-cli/node_modules/.bin/gitnexus + # Fail soft in ~2 min on stalled downloads instead of eating the + # 25-min job budget; HF_TOKEN lifts the anonymous rate limit on + # cold-cache runs (empty when the secret is unset — safe no-op). + HF_DOWNLOAD_TIMEOUT_MS: '60000' + HF_HOME: ${{ runner.temp }}/hf-cache + HF_MAX_ATTEMPTS: '2' + HF_TOKEN: ${{ secrets.HF_TOKEN }} NPM_CONFIG_AUDIT: false NPM_CONFIG_CACHE: ${{ runner.temp }}/gitnexus-npm-cache NPM_CONFIG_FUND: false @@ -245,35 +267,27 @@ jobs: pull-requests: write # post completion comments for /gitnexus command runs steps: # GitHub suppresses workflow_run events for workflow runs triggered - # by GITHUB_TOKEN (to prevent recursive chaining). Command-triggered - # index runs opt into a deploy by setting deploy_after=true. - - name: Trigger deploy workflow after command-triggered runs - if: inputs.deploy_after && needs.index.result == 'success' + # by GITHUB_TOKEN (to prevent recursive chaining). Dispatches without + # a PR number can still opt into a deploy by setting deploy_after=true. + - name: Trigger deploy workflow after non-PR dispatches + if: inputs.deploy_after && inputs.pr_number == '' && needs.index.result == 'success' uses: actions/github-script@v7 - env: - PR_NUMBER: ${{ inputs.pr_number }} with: script: | core.info('deploy_after=true; dispatching gitnexus-deploy.yml manually.'); - // Pass pr_number through so the deploy workflow knows which - // PR to post its completion comment on (for /gitnexus - // command runs this will be set; for other bot dispatches - // it's empty and the deploy step falls back to matrix match). await github.rest.actions.createWorkflowDispatch({ owner: context.repo.owner, repo: context.repo.repo, workflow_id: 'gitnexus-deploy.yml', ref: 'main', inputs: { - pr_number: process.env.PR_NUMBER || '', + pr_number: '', }, }); # Reply on the PR when the /gitnexus command path runs so the # requester knows the index step finished. This fires when - # inputs.pr_number is set and reports the index job result. A - # separate comment posts from the deploy workflow when the live - # server has the fresh index. + # inputs.pr_number is set and reports the index job result. - name: Comment on PR — index complete if: inputs.pr_number != '' uses: actions/github-script@v7 @@ -299,7 +313,7 @@ jobs: `[Index run](${runUrl})`, '', indexSucceeded - ? '⏳ Waiting for deploy to serve the fresh index…' + ? 'PR-specific deploys are paused; only `LibreChat` and `LibreChat-dev` are currently served.' : '_Index run failed — the previous index (if any) continues to be served._', ].join('\n'); await github.rest.issues.createComment({ diff --git a/.github/workflows/playwright-mock.yml b/.github/workflows/playwright-mock.yml index 91a2f9910bf..fc94dc02d64 100644 --- a/.github/workflows/playwright-mock.yml +++ b/.github/workflows/playwright-mock.yml @@ -1,4 +1,4 @@ -name: Playwright E2E (Mock LLM) +name: Playwright E2E Tests on: pull_request: @@ -22,7 +22,6 @@ env: jobs: e2e: - name: Tier-1 smoke (headless Chrome) runs-on: ubuntu-latest timeout-minutes: 30 env: @@ -58,7 +57,7 @@ jobs: uses: actions/cache@v4 with: path: packages/data-provider/dist - key: build-data-provider-${{ runner.os }}-${{ hashFiles('package-lock.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/rollup.config.js', 'packages/data-provider/package.json') }} + key: build-data-provider-${{ runner.os }}-${{ hashFiles('package-lock.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }} - name: Build data-provider if: steps.cache-data-provider.outputs.cache-hit != 'true' @@ -69,7 +68,7 @@ jobs: uses: actions/cache@v4 with: path: packages/data-schemas/dist - key: build-data-schemas-${{ runner.os }}-${{ hashFiles('package-lock.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/rollup.config.js', 'packages/data-schemas/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/rollup.config.js', 'packages/data-provider/package.json') }} + key: build-data-schemas-${{ runner.os }}-${{ hashFiles('package-lock.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }} - name: Build data-schemas if: steps.cache-data-schemas.outputs.cache-hit != 'true' @@ -80,7 +79,7 @@ jobs: uses: actions/cache@v4 with: path: packages/api/dist - key: build-api-${{ runner.os }}-${{ hashFiles('package-lock.json', 'packages/api/src/**', 'packages/api/tsconfig*.json', 'packages/api/server-rollup.config.js', 'packages/api/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/rollup.config.js', 'packages/data-provider/package.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/rollup.config.js', 'packages/data-schemas/package.json') }} + key: build-api-${{ runner.os }}-${{ hashFiles('package-lock.json', 'packages/api/src/**', 'packages/api/tsconfig*.json', 'packages/api/tsdown.config.mjs', 'packages/api/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json', 'packages/data-schemas/src/**', 'packages/data-schemas/tsconfig*.json', 'packages/data-schemas/tsdown.config.mjs', 'packages/data-schemas/package.json') }} - name: Build api if: steps.cache-api.outputs.cache-hit != 'true' @@ -91,7 +90,7 @@ jobs: uses: actions/cache@v4 with: path: packages/client/dist - key: build-client-package-${{ runner.os }}-${{ hashFiles('package-lock.json', 'packages/client/src/**', 'packages/client/tsconfig*.json', 'packages/client/rollup.config.js', 'packages/client/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/rollup.config.js', 'packages/data-provider/package.json') }} + key: build-client-package-${{ runner.os }}-${{ hashFiles('package-lock.json', 'packages/client/src/**', 'packages/client/tsconfig*.json', 'packages/client/tsdown.config.mjs', 'packages/client/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }} - name: Build client-package if: steps.cache-client-package.outputs.cache-hit != 'true' @@ -102,7 +101,7 @@ jobs: uses: actions/cache@v4 with: path: client/dist - key: build-client-app-e2e-${{ runner.os }}-${{ hashFiles('package-lock.json', 'client/src/**', 'client/public/**', 'client/scripts/post-build.cjs', 'client/index.html', 'client/package.json', 'client/vite.config.*', 'client/tsconfig*.json', 'client/tailwind.config.*', 'client/postcss.config.*', 'packages/client/src/**', 'packages/client/tsconfig*.json', 'packages/client/rollup.config.js', 'packages/client/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/rollup.config.js', 'packages/data-provider/package.json') }} + key: build-client-app-e2e-${{ runner.os }}-${{ hashFiles('package-lock.json', 'client/src/**', 'client/public/**', 'client/scripts/post-build.cjs', 'client/index.html', 'client/package.json', 'client/vite.config.*', 'client/tsconfig*.json', 'client/tailwind.config.*', 'client/postcss.config.*', 'packages/client/src/**', 'packages/client/tsconfig*.json', 'packages/client/tsdown.config.mjs', 'packages/client/package.json', 'packages/data-provider/src/**', 'packages/data-provider/tsconfig*.json', 'packages/data-provider/tsdown.config.mjs', 'packages/data-provider/package.json') }} - name: Build client app if: steps.cache-client-app.outputs.cache-hit != 'true' diff --git a/.husky/lint-staged.config.js b/.husky/lint-staged.config.js index 482e1f050e0..8aee5fba819 100644 --- a/.husky/lint-staged.config.js +++ b/.husky/lint-staged.config.js @@ -1,4 +1,9 @@ module.exports = { - '*.{js,jsx,ts,tsx}': ['prettier --write', 'eslint --fix', 'eslint'], + '*.{js,jsx,ts,tsx}': [ + 'node scripts/sort-imports.mts', + 'prettier --write', + 'eslint --fix', + 'eslint', + ], '*.json': ['prettier --write'], }; diff --git a/Dockerfile b/Dockerfile index 6253913262a..8fb58216130 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# v0.8.6 +# v0.8.7-rc1 # Base node image FROM node:24.16.0-alpine AS node @@ -35,7 +35,7 @@ RUN \ # Allow mounting of these files, which have no default touch .env ; \ # Create directories for the volumes to inherit the correct permissions - mkdir -p /app/client/public/images /app/logs /app/uploads ; \ + mkdir -p /app/client/public/images /app/logs /app/uploads /app/skill ; \ npm config set fetch-retry-maxtimeout 600000 ; \ npm config set fetch-retries 5 ; \ npm config set fetch-retry-mintimeout 15000 ; \ diff --git a/Dockerfile.multi b/Dockerfile.multi index 745e54b8a4c..ecff4370e53 100644 --- a/Dockerfile.multi +++ b/Dockerfile.multi @@ -1,5 +1,5 @@ # Dockerfile.multi -# v0.8.6 +# v0.8.7-rc1 # Set configurable max-old-space-size with default ARG NODE_MAX_OLD_SPACE_SIZE=6144 @@ -109,6 +109,7 @@ RUN attempt=1; \ done COPY api ./api COPY config ./config +COPY skill ./skill COPY --from=data-provider-build /app/packages/data-provider/dist ./packages/data-provider/dist COPY --from=data-schemas-build /app/packages/data-schemas/dist ./packages/data-schemas/dist COPY --from=api-package-build /app/packages/api/dist ./packages/api/dist diff --git a/README.zh.md b/README.zh.md index 7f74057413c..61c6d589fa5 100644 --- a/README.zh.md +++ b/README.zh.md @@ -1,4 +1,4 @@ - +

@@ -76,6 +76,8 @@ - 智能体市场:发现并部署社区构建的智能体。 - 协作共享:与特定用户和群组共享智能体。 - 灵活且可扩展:支持 MCP 服务器、工具、文件搜索、代码执行等。 + - [Skills](https://www.librechat.ai/docs/features/skills):创建可复用的 `SKILL.md` 指令包,用于手动、自动或始终启用的智能体工作流。 + - [Subagents](https://www.librechat.ai/docs/features/subagents):将专门任务委派给拥有独立上下文窗口的隔离子智能体运行。 - 兼容自定义端点、OpenAI, Azure, Anthropic, AWS Bedrock, Google, Vertex AI, Responses API 等。 - [支持模型上下文协议 (MCP)](https://modelcontextprotocol.io/clients#librechat) 用于工具调用。 @@ -139,6 +141,7 @@ - ⚙️ **配置与部署**: - 支持代理、反向代理、Docker 及多种部署选项。 + - 使用 [S3 与 CloudFront](https://www.librechat.ai/docs/configuration/cdn/cloudfront) 获得稳定的媒体链接、边缘分发、签名 Cookie 和安全下载。 - 可完全本地运行或部署在云端。 - 📖 **开源与社区**: diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index d36e54f0157..03653333b4a 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -700,7 +700,6 @@ class BaseClient { user, ); this.savedMessageIds.add(responseMessage.messageId); - delete responseMessage.tokenCount; return responseMessage; } @@ -1217,8 +1216,8 @@ class BaseClient { const provider = this.options.agent?.provider ?? this.options.endpoint; const isBedrock = provider === EModelEndpoint.bedrock; - if (!this._mergedFileConfig && this.options.req?.config?.fileConfig) { - this._mergedFileConfig = mergeFileConfig(this.options.req.config.fileConfig); + if (!this._mergedFileConfig) { + this._mergedFileConfig = mergeFileConfig(this.options.req?.config?.fileConfig); const endpoint = this.options.agent?.endpoint ?? this.options.endpoint; this._endpointFileConfig = getEndpointFileConfig({ fileConfig: this._mergedFileConfig, diff --git a/api/app/clients/tools/structured/DALLE3.js b/api/app/clients/tools/structured/DALLE3.js index c36587b0b56..365039485bf 100644 --- a/api/app/clients/tools/structured/DALLE3.js +++ b/api/app/clients/tools/structured/DALLE3.js @@ -1,12 +1,14 @@ const path = require('path'); const OpenAI = require('openai'); const { v4: uuidv4 } = require('uuid'); -const { ProxyAgent, fetch } = require('undici'); +const { fetch } = require('undici'); const { logger } = require('@librechat/data-schemas'); const { Tool } = require('@librechat/agents/langchain/tools'); const { getImageBasename, extractBaseURL, + getProxyDispatcher, + getEnvProxyDispatcher, enforceImageSizeLimit, createMinimalRetentionRequest, } = require('@librechat/api'); @@ -83,10 +85,10 @@ class DALLE3 extends Tool { config.apiKey = process.env.DALLE3_API_KEY; } - if (process.env.PROXY) { - const proxyAgent = new ProxyAgent(process.env.PROXY); + const proxyDispatcher = getProxyDispatcher(); + if (proxyDispatcher) { config.fetchOptions = { - dispatcher: proxyAgent, + dispatcher: proxyDispatcher, }; } @@ -187,9 +189,9 @@ Error Message: ${error.message}`); if (this.isAgent) { let fetchOptions = {}; - if (process.env.PROXY) { - const proxyAgent = new ProxyAgent(process.env.PROXY); - fetchOptions.dispatcher = proxyAgent; + const dispatcher = getEnvProxyDispatcher(); + if (dispatcher) { + fetchOptions.dispatcher = dispatcher; } const imageResponse = await fetch(theImageUrl, fetchOptions); const arrayBuffer = await imageResponse.arrayBuffer(); diff --git a/api/app/clients/tools/structured/FluxAPI.js b/api/app/clients/tools/structured/FluxAPI.js index 44cec3277d7..66782c9f44b 100644 --- a/api/app/clients/tools/structured/FluxAPI.js +++ b/api/app/clients/tools/structured/FluxAPI.js @@ -2,10 +2,13 @@ const axios = require('axios'); const fetch = require('node-fetch'); const { v4: uuidv4 } = require('uuid'); const { logger } = require('@librechat/data-schemas'); -const { HttpsProxyAgent } = require('https-proxy-agent'); -const { enforceImageSizeLimit } = require('@librechat/api'); const { Tool } = require('@librechat/agents/langchain/tools'); -const { createMinimalRetentionRequest } = require('@librechat/api'); +const { + applyAxiosProxyConfig, + createMinimalRetentionRequest, + getHttpsProxyAgent, + enforceImageSizeLimit, +} = require('@librechat/api'); const { FileContext, ContentTypes } = require('librechat-data-provider'); const fluxApiJsonSchema = { @@ -151,10 +154,7 @@ class FluxAPI extends Tool { getAxiosConfig() { const config = {}; - if (process.env.PROXY) { - config.httpsAgent = new HttpsProxyAgent(process.env.PROXY); - } - return config; + return applyAxiosProxyConfig(config, this.baseUrl); } /** @param {Object|string} value */ @@ -308,8 +308,9 @@ class FluxAPI extends Tool { try { // Fetch the image and convert to base64 const fetchOptions = {}; - if (process.env.PROXY) { - fetchOptions.agent = new HttpsProxyAgent(process.env.PROXY); + const agent = getHttpsProxyAgent(imageUrl); + if (agent) { + fetchOptions.agent = agent; } const imageResponse = await fetch(imageUrl, fetchOptions); const arrayBuffer = await imageResponse.arrayBuffer(); @@ -546,8 +547,9 @@ class FluxAPI extends Tool { if (this.isAgent) { try { const fetchOptions = {}; - if (process.env.PROXY) { - fetchOptions.agent = new HttpsProxyAgent(process.env.PROXY); + const agent = getHttpsProxyAgent(imageUrl); + if (agent) { + fetchOptions.agent = agent; } const imageResponse = await fetch(imageUrl, fetchOptions); const arrayBuffer = await imageResponse.arrayBuffer(); diff --git a/api/app/clients/tools/structured/GeminiImageGen.js b/api/app/clients/tools/structured/GeminiImageGen.js index 1a2b88de0d5..72d5371a1eb 100644 --- a/api/app/clients/tools/structured/GeminiImageGen.js +++ b/api/app/clients/tools/structured/GeminiImageGen.js @@ -1,7 +1,6 @@ const path = require('path'); const sharp = require('sharp'); const { v4 } = require('uuid'); -const { ProxyAgent } = require('undici'); const { GoogleGenAI } = require('@google/genai'); const { logger } = require('@librechat/data-schemas'); const { tool } = require('@librechat/agents/langchain/tools'); @@ -10,6 +9,7 @@ const { geminiToolkit, loadServiceKey, getBalanceConfig, + getEnvProxyDispatcher, enforceImageSizeLimit, getTransactionsConfig, } = require('@librechat/api'); @@ -21,14 +21,14 @@ const { spendTokens, getFiles } = require('~/models'); * This wraps globalThis.fetch to add a proxy dispatcher only for googleapis.com URLs * This is necessary because @google/genai SDK doesn't support custom fetch or httpOptions.dispatcher */ -if (process.env.PROXY) { +const googleApiProxyDispatcher = getEnvProxyDispatcher(); +if (googleApiProxyDispatcher) { const originalFetch = globalThis.fetch; - const proxyAgent = new ProxyAgent(process.env.PROXY); globalThis.fetch = function (url, options = {}) { const urlString = url.toString(); if (urlString.includes('googleapis.com')) { - options = { ...options, dispatcher: proxyAgent }; + options = { ...options, dispatcher: googleApiProxyDispatcher }; } return originalFetch.call(this, url, options); }; @@ -120,7 +120,7 @@ async function initializeGeminiClient(options = {}) { return new GoogleGenAI({ vertexai: true, project: serviceKey.project_id, - location: process.env.GOOGLE_LOC || process.env.GOOGLE_CLOUD_LOCATION || 'global', + location: process.env.GOOGLE_CLOUD_LOCATION || process.env.GOOGLE_LOC || 'global', googleAuthOptions: { credentials: serviceKey }, }); } diff --git a/api/app/clients/tools/structured/OpenAIImageTools.js b/api/app/clients/tools/structured/OpenAIImageTools.js index 378de56be41..7109043c820 100644 --- a/api/app/clients/tools/structured/OpenAIImageTools.js +++ b/api/app/clients/tools/structured/OpenAIImageTools.js @@ -2,15 +2,15 @@ const axios = require('axios'); const { v4 } = require('uuid'); const OpenAI = require('openai'); const FormData = require('form-data'); -const { ProxyAgent } = require('undici'); const { logger } = require('@librechat/data-schemas'); -const { HttpsProxyAgent } = require('https-proxy-agent'); const { tool } = require('@librechat/agents/langchain/tools'); const { ContentTypes, EImageOutputType } = require('librechat-data-provider'); const { logAxiosError, oaiToolkit, extractBaseURL, + getProxyDispatcher, + applyAxiosProxyConfig, enforceImageSizeLimit, resolveImageGenOaiDefaults, } = require('@librechat/api'); @@ -128,10 +128,10 @@ function createOpenAIImageTools(fields = {}) { throw new Error('Missing required field: prompt'); } const clientConfig = { ...closureConfig }; - if (process.env.PROXY) { - const proxyAgent = new ProxyAgent(process.env.PROXY); + const proxyDispatcher = getProxyDispatcher(); + if (proxyDispatcher) { clientConfig.fetchOptions = { - dispatcher: proxyAgent, + dispatcher: proxyDispatcher, }; } @@ -246,10 +246,10 @@ Error Message: ${error.message}`); } const clientConfig = { ...closureConfig }; - if (process.env.PROXY) { - const proxyAgent = new ProxyAgent(process.env.PROXY); + const proxyDispatcher = getProxyDispatcher(); + if (proxyDispatcher) { clientConfig.fetchOptions = { - dispatcher: proxyAgent, + dispatcher: proxyDispatcher, }; } @@ -372,9 +372,7 @@ Error Message: ${error.message}`); baseURL, }; - if (process.env.PROXY) { - axiosConfig.httpsAgent = new HttpsProxyAgent(process.env.PROXY); - } + applyAxiosProxyConfig(axiosConfig, baseURL); if (process.env.IMAGE_GEN_OAI_AZURE_API_VERSION && process.env.IMAGE_GEN_OAI_BASEURL) { axiosConfig.params = { diff --git a/api/app/clients/tools/structured/TavilySearch.js b/api/app/clients/tools/structured/TavilySearch.js index e45f6d2bf89..a90b75b9f8e 100644 --- a/api/app/clients/tools/structured/TavilySearch.js +++ b/api/app/clients/tools/structured/TavilySearch.js @@ -1,6 +1,7 @@ const { z } = require('zod'); -const { ProxyAgent, fetch } = require('undici'); +const { fetch } = require('undici'); const { tool } = require('@librechat/agents/langchain/tools'); +const { getEnvProxyDispatcher } = require('@librechat/api'); const { getApiKey } = require('./credentials'); function createTavilySearchTool(fields = {}) { @@ -28,8 +29,9 @@ function createTavilySearchTool(fields = {}) { body: JSON.stringify(requestBody), }; - if (process.env.PROXY) { - fetchOptions.dispatcher = new ProxyAgent(process.env.PROXY); + const dispatcher = getEnvProxyDispatcher(); + if (dispatcher) { + fetchOptions.dispatcher = dispatcher; } const response = await fetch('https://api.tavily.com/search', fetchOptions); diff --git a/api/app/clients/tools/structured/TavilySearchResults.js b/api/app/clients/tools/structured/TavilySearchResults.js index 4d46402c992..9e9aa3d34c8 100644 --- a/api/app/clients/tools/structured/TavilySearchResults.js +++ b/api/app/clients/tools/structured/TavilySearchResults.js @@ -1,6 +1,7 @@ -const { ProxyAgent, fetch } = require('undici'); +const { fetch } = require('undici'); const { Tool } = require('@librechat/agents/langchain/tools'); const { getEnvironmentVariable } = require('@librechat/agents/langchain/utils/env'); +const { getEnvProxyDispatcher } = require('@librechat/api'); const tavilySearchJsonSchema = { type: 'object', @@ -120,8 +121,9 @@ class TavilySearchResults extends Tool { body: JSON.stringify(requestBody), }; - if (process.env.PROXY) { - fetchOptions.dispatcher = new ProxyAgent(process.env.PROXY); + const dispatcher = getEnvProxyDispatcher(); + if (dispatcher) { + fetchOptions.dispatcher = dispatcher; } const response = await fetch('https://api.tavily.com/search', fetchOptions); diff --git a/api/app/clients/tools/structured/specs/DALLE3-proxy.spec.js b/api/app/clients/tools/structured/specs/DALLE3-proxy.spec.js index 262842b3c24..b958ed7b5b8 100644 --- a/api/app/clients/tools/structured/specs/DALLE3-proxy.spec.js +++ b/api/app/clients/tools/structured/specs/DALLE3-proxy.spec.js @@ -1,7 +1,20 @@ const DALLE3 = require('../DALLE3'); -const { ProxyAgent } = require('undici'); const processFileURL = jest.fn(); +const proxyEnvKeys = [ + 'PROXY', + 'proxy', + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'NO_PROXY', + 'http_proxy', + 'https_proxy', + 'no_proxy', +]; + +function clearProxyEnv() { + proxyEnvKeys.forEach((key) => delete process.env[key]); +} describe('DALLE3 Proxy Configuration', () => { let originalEnv; @@ -13,13 +26,14 @@ describe('DALLE3 Proxy Configuration', () => { beforeEach(() => { jest.resetModules(); process.env = { ...originalEnv }; + clearProxyEnv(); }); afterEach(() => { process.env = originalEnv; }); - it('should configure ProxyAgent in fetchOptions.dispatcher when PROXY env is set', () => { + it('should configure fetchOptions.dispatcher when proxy env is set', () => { // Set proxy environment variable process.env.PROXY = 'http://proxy.example.com:8080'; process.env.DALLE_API_KEY = 'test-api-key'; @@ -34,12 +48,10 @@ describe('DALLE3 Proxy Configuration', () => { expect(dalleWithProxy.openai._options).toBeDefined(); expect(dalleWithProxy.openai._options.fetchOptions).toBeDefined(); expect(dalleWithProxy.openai._options.fetchOptions.dispatcher).toBeDefined(); - expect(dalleWithProxy.openai._options.fetchOptions.dispatcher).toBeInstanceOf(ProxyAgent); + expect(dalleWithProxy.openai._options.fetchOptions.dispatcher).toBeDefined(); }); - it('should not configure ProxyAgent when PROXY env is not set', () => { - // Ensure PROXY is not set - delete process.env.PROXY; + it('should not configure a dispatcher when proxy env is not set', () => { process.env.DALLE_API_KEY = 'test-api-key'; // Create instance diff --git a/api/app/clients/tools/structured/specs/GeminiImageGen-proxy.spec.js b/api/app/clients/tools/structured/specs/GeminiImageGen-proxy.spec.js index 027d2659d68..dbdda6e454c 100644 --- a/api/app/clients/tools/structured/specs/GeminiImageGen-proxy.spec.js +++ b/api/app/clients/tools/structured/specs/GeminiImageGen-proxy.spec.js @@ -1,5 +1,3 @@ -const { ProxyAgent } = require('undici'); - /** * These tests verify the proxy wrapper behavior for GeminiImageGen. * Instead of loading the full module (which has many dependencies), @@ -29,14 +27,14 @@ describe('GeminiImageGen Proxy Configuration', () => { * This is the same logic from GeminiImageGen.js lines 30-42. */ function applyProxyWrapper() { - if (process.env.PROXY) { + const proxyDispatcher = process.env.PROXY ? { type: 'proxy-dispatcher' } : undefined; + if (proxyDispatcher) { const _originalFetch = globalThis.fetch; - const proxyAgent = new ProxyAgent(process.env.PROXY); globalThis.fetch = function (url, options = {}) { const urlString = url.toString(); if (urlString.includes('googleapis.com')) { - options = { ...options, dispatcher: proxyAgent }; + options = { ...options, dispatcher: proxyDispatcher }; } return _originalFetch.call(this, url, options); }; @@ -78,7 +76,7 @@ describe('GeminiImageGen Proxy Configuration', () => { await globalThis.fetch('https://generativelanguage.googleapis.com/v1/models', {}); expect(capturedOptions).toBeDefined(); - expect(capturedOptions.dispatcher).toBeInstanceOf(ProxyAgent); + expect(capturedOptions.dispatcher).toEqual({ type: 'proxy-dispatcher' }); }); it('should not add dispatcher to non-googleapis.com URLs', async () => { @@ -118,7 +116,7 @@ describe('GeminiImageGen Proxy Configuration', () => { }); expect(capturedOptions).toBeDefined(); - expect(capturedOptions.dispatcher).toBeInstanceOf(ProxyAgent); + expect(capturedOptions.dispatcher).toEqual({ type: 'proxy-dispatcher' }); expect(capturedOptions.headers).toEqual(customHeaders); expect(capturedOptions.method).toBe('POST'); }); diff --git a/api/app/clients/tools/structured/specs/TavilySearchResults.spec.js b/api/app/clients/tools/structured/specs/TavilySearchResults.spec.js index 891a8cdc192..7184e082041 100644 --- a/api/app/clients/tools/structured/specs/TavilySearchResults.spec.js +++ b/api/app/clients/tools/structured/specs/TavilySearchResults.spec.js @@ -1,7 +1,11 @@ -const { fetch, ProxyAgent } = require('undici'); +const { fetch } = require('undici'); const TavilySearchResults = require('../TavilySearchResults'); +const { getEnvProxyDispatcher } = require('@librechat/api'); jest.mock('undici'); +jest.mock('@librechat/api', () => ({ + getEnvProxyDispatcher: jest.fn(), +})); describe('TavilySearchResults', () => { let originalEnv; @@ -46,32 +50,29 @@ describe('TavilySearchResults', () => { fetch.mockResolvedValue(mockResponse); }); - it('should use ProxyAgent when PROXY env var is set', async () => { - const proxyUrl = 'http://proxy.example.com:8080'; - process.env.PROXY = proxyUrl; - - const mockProxyAgent = { type: 'proxy-agent' }; - ProxyAgent.mockImplementation(() => mockProxyAgent); + it('should use a shared proxy dispatcher when configured', async () => { + const mockProxyDispatcher = { type: 'proxy-dispatcher' }; + getEnvProxyDispatcher.mockReturnValue(mockProxyDispatcher); const instance = new TavilySearchResults({ TAVILY_API_KEY: mockApiKey }); await instance._call({ query: 'test query' }); - expect(ProxyAgent).toHaveBeenCalledWith(proxyUrl); + expect(getEnvProxyDispatcher).toHaveBeenCalled(); expect(fetch).toHaveBeenCalledWith( 'https://api.tavily.com/search', expect.objectContaining({ - dispatcher: mockProxyAgent, + dispatcher: mockProxyDispatcher, }), ); }); - it('should not use ProxyAgent when PROXY env var is not set', async () => { - delete process.env.PROXY; + it('should not attach a dispatcher when no proxy is configured', async () => { + getEnvProxyDispatcher.mockReturnValue(undefined); const instance = new TavilySearchResults({ TAVILY_API_KEY: mockApiKey }); await instance._call({ query: 'test query' }); - expect(ProxyAgent).not.toHaveBeenCalled(); + expect(getEnvProxyDispatcher).toHaveBeenCalled(); expect(fetch).toHaveBeenCalledWith( 'https://api.tavily.com/search', expect.not.objectContaining({ diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index 45623f9a9e6..adeb9f7ca99 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -41,6 +41,7 @@ const { createMCPPermissionContext, resolveConfigServers, } = require('~/server/services/MCP'); +const { getMCPRequestContext } = require('~/server/services/MCPRequestContext'); const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSearch'); const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process'); const { getUserPluginAuthValue } = require('~/server/services/PluginService'); @@ -451,11 +452,13 @@ const loadTools = async ({ let index = -1; const failedMCPServers = new Set(); const safeUser = createSafeUser(options.req?.user); + const requestScopedConnections = + options.requestScopedConnections ?? getMCPRequestContext(options.req, options.res); for (const [serverName, toolConfigs] of Object.entries(requestedMCPTools)) { index++; /** @type {LCAvailableTools} */ - let availableTools; + let availableTools = options.mcpAvailableTools?.[serverName]; for (const config of toolConfigs) { try { if (failedMCPServers.has(serverName)) { @@ -468,6 +471,8 @@ const loadTools = async ({ user: safeUser, userMCPAuthMap, configServers, + requestBody: options.req?.body, + requestScopedConnections, res: options.res, streamId: options.req?._resumableStreamId || null, model: agent?.model ?? model, @@ -488,7 +493,7 @@ const loadTools = async ({ } if (!availableTools) { try { - availableTools = await getMCPServerTools(safeUser.id, serverName); + availableTools = await getMCPServerTools(safeUser.id, serverName, config.config); } catch (error) { logger.error(`Error fetching available tools for MCP server ${serverName}:`, error); } @@ -502,6 +507,9 @@ const loadTools = async ({ ...mcpParams, availableTools, toolKey: config.toolKey, + onAvailableTools: (tools) => { + availableTools = tools; + }, }); if (Array.isArray(mcpTool)) { diff --git a/api/app/clients/tools/util/handleTools.test.js b/api/app/clients/tools/util/handleTools.test.js index 1adda45c35e..697649e3bde 100644 --- a/api/app/clients/tools/util/handleTools.test.js +++ b/api/app/clients/tools/util/handleTools.test.js @@ -6,6 +6,10 @@ const mockPluginService = { deleteUserPluginAuth: jest.fn(), getUserPluginAuthValue: jest.fn(), }; +const mockGetMCPServerTools = jest.fn(); +const mockCreateMCPTool = jest.fn(); +const mockCreateMCPTools = jest.fn(); +const mockGetServerConfig = jest.fn(); jest.mock('~/server/services/PluginService', () => mockPluginService); @@ -28,9 +32,26 @@ jest.mock('~/server/services/Config', () => ({ }, }, }), + getMCPServerTools: (...args) => mockGetMCPServerTools(...args), +})); + +jest.mock('~/server/services/MCP', () => ({ + createMCPTool: (...args) => mockCreateMCPTool(...args), + createMCPTools: (...args) => mockCreateMCPTools(...args), + createMCPPermissionContext: jest.fn(() => ({ + canUseServers: jest.fn().mockResolvedValue(true), + })), + resolveConfigServers: jest.fn().mockResolvedValue({}), +})); + +jest.mock('~/config', () => ({ + getMCPServersRegistry: jest.fn(() => ({ + getServerConfig: (...args) => mockGetServerConfig(...args), + })), })); const { Calculator } = require('@librechat/agents'); +const { Constants } = require('librechat-data-provider'); const { User } = require('~/db/models'); const PluginService = require('~/server/services/PluginService'); @@ -282,5 +303,152 @@ describe('Tool Handlers', () => { expect(structuredTool).toBeInstanceOf(StructuredSD); delete process.env.SD_WEBUI_URL; }); + + it('passes request body to chat MCP tool creation and skips stale cache for BODY-scoped servers', async () => { + const serverName = 'body-scoped'; + const toolKey = `search${Constants.mcp_delimiter}${serverName}`; + const requestBody = { conversationId: 'conv-123', messageId: 'msg-123' }; + const serverConfig = { + type: 'streamable-http', + url: 'https://api.example.com/messages/{{LIBRECHAT_BODY_MESSAGEID}}/mcp', + source: 'yaml', + }; + + mockGetServerConfig.mockResolvedValue(serverConfig); + mockCreateMCPTool.mockResolvedValue({ name: 'loaded-mcp-tool' }); + + const result = await loadTools({ + user: fakeUser._id.toString(), + tools: [toolKey], + options: { + req: { + user: { id: fakeUser._id.toString(), role: 'USER' }, + body: requestBody, + }, + }, + }); + + expect(result.loadedTools).toEqual([{ name: 'loaded-mcp-tool' }]); + expect(mockGetMCPServerTools).toHaveBeenCalledWith( + fakeUser._id.toString(), + serverName, + serverConfig, + ); + expect(mockCreateMCPTool).toHaveBeenCalledWith( + expect.objectContaining({ + requestBody, + toolKey, + config: serverConfig, + }), + ); + }); + + it('uses run-scoped MCP tool definitions before cache lookup', async () => { + const serverName = 'body-scoped'; + const toolKey = `search${Constants.mcp_delimiter}${serverName}`; + const requestBody = { conversationId: 'conv-123', messageId: 'msg-123' }; + const serverConfig = { + type: 'streamable-http', + url: 'https://api.example.com/messages/{{LIBRECHAT_BODY_MESSAGEID}}/mcp', + source: 'yaml', + }; + const runScopedTools = { + [toolKey]: { + function: { + name: toolKey, + description: 'Run-scoped search', + parameters: { type: 'object', properties: {} }, + }, + }, + }; + + mockGetServerConfig.mockResolvedValue(serverConfig); + mockCreateMCPTool.mockResolvedValue({ name: 'loaded-mcp-tool' }); + + const result = await loadTools({ + user: fakeUser._id.toString(), + tools: [toolKey], + options: { + mcpAvailableTools: { + [serverName]: runScopedTools, + }, + req: { + user: { id: fakeUser._id.toString(), role: 'USER' }, + body: requestBody, + }, + }, + }); + + expect(result.loadedTools).toEqual([{ name: 'loaded-mcp-tool' }]); + expect(mockGetMCPServerTools).not.toHaveBeenCalled(); + expect(mockCreateMCPTool).toHaveBeenCalledWith( + expect.objectContaining({ + availableTools: runScopedTools, + requestBody, + toolKey, + config: serverConfig, + }), + ); + }); + + it('reuses discovered request-scoped MCP tool definitions within a server loop', async () => { + const serverName = 'body-scoped'; + const firstToolKey = `search${Constants.mcp_delimiter}${serverName}`; + const secondToolKey = `lookup${Constants.mcp_delimiter}${serverName}`; + const requestBody = { conversationId: 'conv-123', messageId: 'msg-123' }; + const serverConfig = { + type: 'streamable-http', + url: 'https://api.example.com/messages/{{LIBRECHAT_BODY_MESSAGEID}}/mcp', + source: 'yaml', + }; + const discoveredTools = { + [firstToolKey]: { + function: { + description: 'Search', + parameters: { type: 'object', properties: {} }, + }, + }, + [secondToolKey]: { + function: { + description: 'Lookup', + parameters: { type: 'object', properties: {} }, + }, + }, + }; + + mockGetServerConfig.mockResolvedValue(serverConfig); + mockCreateMCPTool + .mockImplementationOnce(async ({ onAvailableTools }) => { + onAvailableTools(discoveredTools); + return { name: 'search-tool' }; + }) + .mockImplementationOnce(async ({ availableTools }) => { + expect(availableTools).toBe(discoveredTools); + return { name: 'lookup-tool' }; + }); + + const result = await loadTools({ + user: fakeUser._id.toString(), + tools: [firstToolKey, secondToolKey], + options: { + req: { + user: { id: fakeUser._id.toString(), role: 'USER' }, + body: requestBody, + }, + }, + }); + + expect(result.loadedTools).toEqual([{ name: 'search-tool' }, { name: 'lookup-tool' }]); + expect(mockGetMCPServerTools).toHaveBeenCalledTimes(1); + expect(mockCreateMCPTool).toHaveBeenCalledTimes(2); + expect(mockCreateMCPTool).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + availableTools: discoveredTools, + requestBody, + toolKey: secondToolKey, + }), + ); + }); }); }); diff --git a/api/config/__tests__/logToFile.spec.js b/api/config/__tests__/logToFile.spec.js deleted file mode 100644 index 4b3170f95ac..00000000000 --- a/api/config/__tests__/logToFile.spec.js +++ /dev/null @@ -1,72 +0,0 @@ -const fs = require('fs'); - -const ORIGINAL_ENV = process.env; - -const mockDataSchemas = () => { - jest.doMock('@librechat/data-schemas', () => ({ - getTenantId: jest.fn(), - getUserId: jest.fn(), - getRequestId: jest.fn(), - SYSTEM_TENANT_ID: 'system', - })); -}; - -const mockReadOnlyDockerLogDir = () => { - const originalExistsSync = fs.existsSync; - const originalMkdirSync = fs.mkdirSync; - - jest.spyOn(process, 'cwd').mockReturnValue('/app'); - jest - .spyOn(fs, 'existsSync') - .mockImplementation((target) => - target === '/app/logs' ? false : originalExistsSync.call(fs, target), - ); - - return jest.spyOn(fs, 'mkdirSync').mockImplementation((target, options) => { - if (target === '/app/logs') { - throw new Error('Attempted to create Docker log directory'); - } - return originalMkdirSync.call(fs, target, options); - }); -}; - -const prepareLoggerWithoutFileLogging = () => { - jest.resetModules(); - jest.clearAllMocks(); - mockDataSchemas(); - - process.env = { - ...ORIGINAL_ENV, - DEBUG_LOGGING: 'true', - LOG_TO_FILE: 'false', - }; - - return mockReadOnlyDockerLogDir(); -}; - -describe('LOG_TO_FILE', () => { - afterEach(() => { - process.env = ORIGINAL_ENV; - jest.restoreAllMocks(); - }); - - it('does not create the API log directory when winston file logging is disabled', () => { - const mkdirSyncSpy = prepareLoggerWithoutFileLogging(); - - expect(() => require('../winston')).not.toThrow(); - - const winston = require('winston'); - expect(winston.transports.DailyRotateFile).not.toHaveBeenCalled(); - expect(mkdirSyncSpy).not.toHaveBeenCalledWith('/app/logs', expect.anything()); - }); - - it('does not create the API log directory when Meili file logging is disabled', () => { - const mkdirSyncSpy = prepareLoggerWithoutFileLogging(); - - expect(() => require('../meiliLogger')).not.toThrow(); - - const winston = require('winston'); - expect(winston.transports.DailyRotateFile).not.toHaveBeenCalled(); - expect(mkdirSyncSpy).not.toHaveBeenCalledWith('/app/logs', expect.anything()); - }); -}); diff --git a/api/config/__tests__/parsers.spec.js b/api/config/__tests__/parsers.spec.js deleted file mode 100644 index 4c783609531..00000000000 --- a/api/config/__tests__/parsers.spec.js +++ /dev/null @@ -1,430 +0,0 @@ -jest.unmock('winston'); - -const { formatConsoleMeta, redactMessage, redactFormat, debugTraverse } = - jest.requireActual('../parsers'); -const SPLAT_SYMBOL = Symbol.for('splat'); - -describe('formatConsoleMeta', () => { - it('returns empty string when there is no user metadata', () => { - expect( - formatConsoleMeta({ - level: 'error', - message: 'oops', - timestamp: '2026-04-18 02:25:22', - }), - ).toBe(''); - }); - - it('serializes user-supplied metadata keys', () => { - const meta = formatConsoleMeta({ - level: 'error', - message: '[agents:summarize] Summarization LLM call failed', - timestamp: '2026-04-18 02:25:22', - provider: 'azureOpenAI', - model: 'gpt-5.4-mini', - messagesToRefineCount: 42, - }); - - expect(meta).toContain('"provider":"azureOpenAI"'); - expect(meta).toContain('"model":"gpt-5.4-mini"'); - expect(meta).toContain('"messagesToRefineCount":42'); - }); - - it('omits the system tenant sentinel from metadata trailers', () => { - const meta = formatConsoleMeta({ - level: 'warn', - message: 'system task', - timestamp: 'ts', - tenantId: '__SYSTEM__', - userId: 'user-1', - }); - - expect(meta).toBe('{"userId":"user-1"}'); - }); - - it('ignores reserved winston keys but preserves legitimate fields like _id', () => { - const meta = formatConsoleMeta({ - level: 'error', - message: 'boom', - timestamp: 'ts', - splat: [1, 2], - _id: '507f191e810c19729de860ea', - userField: 'keep', - }); - - expect(meta).toContain('"_id":"507f191e810c19729de860ea"'); - expect(meta).toContain('"userField":"keep"'); - expect(meta).not.toContain('"splat"'); - }); - - it('drops numeric-index-like keys (splat artifacts from primitive args)', () => { - const meta = formatConsoleMeta({ - level: 'warn', - message: 'Unhandled step:', - timestamp: 'ts', - 0: 'f', - 1: 'o', - 2: 'o', - realField: 'real', - }); - - expect(meta).toBe('{"realField":"real"}'); - }); - - it('drops empty, null, undefined, function, and symbol values', () => { - const meta = formatConsoleMeta({ - level: 'warn', - message: 'noise', - timestamp: 'ts', - empty: '', - nullish: null, - undef: undefined, - fn: () => 1, - sym: Symbol('x'), - kept: 'yes', - }); - - expect(meta).toBe('{"kept":"yes"}'); - }); - - it('truncates very long string values to avoid console spam', () => { - const longString = 'x'.repeat(5000); - const meta = formatConsoleMeta({ - level: 'error', - message: 'long', - timestamp: 'ts', - errorStack: longString, - }); - - expect(meta.length).toBeLessThan(longString.length); - expect(meta).toContain('...'); - }); - - it('preserves non-circular fields when one value is circular', () => { - const circular = {}; - circular.self = circular; - const meta = formatConsoleMeta({ - level: 'error', - message: 'circular', - timestamp: 'ts', - provider: 'openai', - model: 'gpt-5.4-mini', - circular, - }); - - expect(meta).toContain('"provider":"openai"'); - expect(meta).toContain('"model":"gpt-5.4-mini"'); - expect(meta).toContain('[Circular]'); - }); - - it('falls back to per-field serialization when a value toJSON throws', () => { - const meta = formatConsoleMeta({ - level: 'error', - message: 'crash', - timestamp: 'ts', - provider: 'azure', - model: 'gpt-5.4-mini', - broken: { - toJSON() { - throw new Error('nope'); - }, - }, - }); - - expect(meta).toContain('"provider":"azure"'); - expect(meta).toContain('"model":"gpt-5.4-mini"'); - expect(meta).toContain('[Unserializable]'); - }); - - it('redacts sensitive strings nested inside metadata objects', () => { - const meta = formatConsoleMeta({ - level: 'error', - message: 'nested leak', - timestamp: 'ts', - config: { - headers: { - authorization: 'Bearer eyJhbGciOi.nestedTokenValue', - }, - query: 'https://example.com/?key=AIzaNested', - }, - openaiKey: 'sk-outerKey123', - }); - - expect(meta).not.toContain('eyJhbGciOi.nestedTokenValue'); - expect(meta).not.toContain('AIzaNested'); - expect(meta).not.toContain('sk-outerKey123'); - expect(meta).toContain('Bearer [REDACTED]'); - expect(meta).toContain('key=[REDACTED]'); - expect(meta).toContain('sk-[REDACTED]'); - }); - - it('redacts the Azure-style mixed-case Api-Key header', () => { - const meta = formatConsoleMeta({ - level: 'error', - message: 'azure call', - timestamp: 'ts', - headers: 'Api-Key: 0123456789abcdef', - }); - - expect(meta).not.toContain('0123456789abcdef'); - expect(meta).toContain('Api-Key: [REDACTED]'); - }); - - it('redacts sensitive patterns inside string metadata values', () => { - const meta = formatConsoleMeta({ - level: 'error', - message: 'leak test', - timestamp: 'ts', - openaiKey: 'sk-abc123def456', - auth: 'Bearer eyJhbGciOi...tokenvalue', - google: 'https://example.com/?key=AIzaSyXX', - }); - - expect(meta).not.toContain('sk-abc123def456'); - expect(meta).not.toContain('eyJhbGciOi...tokenvalue'); - expect(meta).not.toContain('AIzaSyXX'); - expect(meta).toContain('sk-[REDACTED]'); - expect(meta).toContain('Bearer [REDACTED]'); - expect(meta).toContain('key=[REDACTED]'); - }); - - it('redacts multiple occurrences of the same pattern in one value', () => { - const meta = formatConsoleMeta({ - level: 'error', - message: 'two keys', - timestamp: 'ts', - combined: 'first sk-aaa and then sk-bbb', - }); - - expect(meta).not.toContain('sk-aaa'); - expect(meta).not.toContain('sk-bbb'); - expect(meta.match(/sk-\[REDACTED\]/g)?.length).toBe(2); - }); -}); - -describe('redactMessage', () => { - it('redacts sk- keys that are not at line start (inside JSON-like text)', () => { - const input = '{"apiKey":"sk-abc123"}'; - expect(redactMessage(input)).toBe('{"apiKey":"sk-[REDACTED]"}'); - }); - - it('redacts all sk- occurrences in a single pass', () => { - const input = 'sk-one sk-two sk-three'; - expect(redactMessage(input)).toBe('sk-[REDACTED] sk-[REDACTED] sk-[REDACTED]'); - }); - - it('trims redacted output when trimLength is provided', () => { - const input = 'Bearer supersecretvalue'; - expect(redactMessage(input, 10)).toBe('Bearer [RE...'); - }); - - it('returns empty string for falsy input', () => { - expect(redactMessage('')).toBe(''); - expect(redactMessage(undefined)).toBe(''); - }); - - it('does not redact ordinary words that contain "sk-" inside them', () => { - expect(redactMessage('task-runner failed')).toBe('task-runner failed'); - expect(redactMessage('mask-value computed')).toBe('mask-value computed'); - expect(redactMessage('desk-lamp is on')).toBe('desk-lamp is on'); - }); - - it('does not redact words that contain "key=" inside them', () => { - expect(redactMessage('monkey=10 bananas')).toBe('monkey=10 bananas'); - }); - - it('still redacts standalone sk- keys at word boundaries', () => { - expect(redactMessage('token: sk-abc123def')).toBe('token: sk-[REDACTED]'); - expect(redactMessage('"sk-abc123def"')).toBe('"sk-[REDACTED]"'); - }); -}); - -describe('redactFormat', () => { - const runFormat = (info) => redactFormat().transform(info) || info; - - it('redacts info.message for error level before any colorize step runs', () => { - const info = runFormat({ level: 'error', message: 'Bearer secretvalue' }); - expect(info.message).toBe('Bearer [REDACTED]'); - }); - - it('redacts info.message for warn level too (avoids ANSI boundary issues later)', () => { - const info = runFormat({ level: 'warn', message: 'apiKey=sk-abc123def' }); - expect(info.message).toContain('sk-[REDACTED]'); - }); - - it('leaves info.message untouched for info and debug levels', () => { - const infoInfo = runFormat({ level: 'info', message: 'Bearer looksSensitive' }); - expect(infoInfo.message).toBe('Bearer looksSensitive'); - - const infoDebug = runFormat({ level: 'debug', message: 'Bearer looksSensitive' }); - expect(infoDebug.message).toBe('Bearer looksSensitive'); - }); -}); - -describe('debugTraverse', () => { - const runFormatter = (info) => { - const transformed = debugTraverse.transform(info); - const MESSAGE = Symbol.for('message'); - if (transformed && typeof transformed === 'object') { - return transformed[MESSAGE] ?? String(transformed); - } - return String(transformed); - }; - - const buildInfo = (level, meta) => { - const info = { - level, - message: 'test', - timestamp: 'ts', - ...meta, - }; - info[SPLAT_SYMBOL] = [meta]; - return info; - }; - - it('redacts sensitive strings in metadata for error level', () => { - const out = runFormatter(buildInfo('error', { auth: 'Bearer eyJabc123', openai: 'sk-abc123' })); - expect(out).not.toContain('eyJabc123'); - expect(out).not.toContain('sk-abc123'); - expect(out).toContain('Bearer [REDACTED]'); - expect(out).toContain('sk-[REDACTED]'); - }); - - it('redacts sensitive strings in metadata for warn level', () => { - const out = runFormatter(buildInfo('warn', { header: 'Bearer supersecrettoken' })); - expect(out).not.toContain('supersecrettoken'); - expect(out).toContain('Bearer [REDACTED]'); - }); - - it('preserves debug-level metadata unmodified (existing behavior)', () => { - const out = runFormatter(buildInfo('debug', { someField: 'not-sensitive' })); - expect(out).toContain('not-sensitive'); - }); - - it('prefers structured metadata over a consumed printf arg in SPLAT[0]', () => { - const info = { - level: 'warn', - message: 'failed for tenant-7', - timestamp: 'ts', - provider: 'openai', - [SPLAT_SYMBOL]: ['tenant-7', { provider: 'openai' }], - }; - const out = runFormatter(info); - expect(out).toContain('openai'); - const tenantMatches = out.match(/tenant-7/g) ?? []; - expect(tenantMatches.length).toBeLessThanOrEqual(1); - }); - - it('does not duplicate a consumed %s arg when there is no structured metadata', () => { - const info = { - level: 'warn', - message: 'failed for tenant-7', - timestamp: 'ts', - [SPLAT_SYMBOL]: ['tenant-7'], - }; - const out = runFormatter(info); - const tenantMatches = out.match(/tenant-7/g) ?? []; - expect(tenantMatches.length).toBe(1); - }); - - it('appends request context metadata for non-debug lines', () => { - const out = runFormatter( - buildInfo('info', { - tenantId: 'tenant-1', - userId: 'user-1', - requestId: 'req-1', - }), - ); - - expect(out).toContain('"tenantId":"tenant-1"'); - expect(out).toContain('"userId":"user-1"'); - expect(out).toContain('"requestId":"req-1"'); - }); - - it('does not append the system tenant sentinel as tenantId', () => { - const out = runFormatter( - buildInfo('info', { - tenantId: '__SYSTEM__', - userId: 'user-1', - requestId: 'req-1', - }), - ); - - expect(out).not.toContain('__SYSTEM__'); - expect(out).not.toContain('"tenantId"'); - expect(out).toContain('"userId":"user-1"'); - expect(out).toContain('"requestId":"req-1"'); - }); - - it('omits the system tenant sentinel from debug object metadata', () => { - const out = runFormatter( - buildInfo('debug', { - tenantId: '__SYSTEM__', - userId: 'user-1', - }), - ); - - expect(out).not.toContain('__SYSTEM__'); - expect(out).not.toMatch(/tenantId:/); - expect(out).toContain('userId'); - }); - - it('appends request context metadata for debug lines without object metadata', () => { - const info = { - level: 'debug', - message: 'prefix:', - timestamp: 'ts', - tenantId: 'tenant-1', - userId: 'user-1', - requestId: 'req-1', - [SPLAT_SYMBOL]: ['detailValueXYZ'], - }; - const out = runFormatter(info); - - expect(out).toContain('detailValueXYZ'); - expect(out).toContain('"tenantId":"tenant-1"'); - expect(out).toContain('"userId":"user-1"'); - expect(out).toContain('"requestId":"req-1"'); - }); - - it('omits numeric splat-artifact keys from the traversed output', () => { - const info = { - level: 'error', - message: 'boom', - timestamp: 'ts', - 0: 'x', - 1: 'y', - realField: 'keep', - [SPLAT_SYMBOL]: [{ realField: 'keep' }], - }; - const out = runFormatter(info); - expect(out).toContain('realField'); - expect(out).toContain('keep'); - expect(out).not.toMatch(/^\s*0:/m); - expect(out).not.toMatch(/^\s*1:/m); - }); - - it('surfaces unconsumed primitive SPLAT[0] (no %s in message) for debug level', () => { - const info = { - level: 'debug', - message: 'prefix:', - timestamp: 'ts', - [SPLAT_SYMBOL]: ['detailValueXYZ'], - }; - const out = runFormatter(info); - expect(out).toContain('detailValueXYZ'); - }); - - it('still surfaces array metadata in SPLAT[0] when no object is extracted', () => { - const info = { - level: 'debug', - message: 'list', - timestamp: 'ts', - [SPLAT_SYMBOL]: [['alpha', 'beta', 'gamma']], - }; - const out = runFormatter(info); - expect(out).toContain('alpha'); - expect(out).toContain('beta'); - expect(out).toContain('gamma'); - }); -}); diff --git a/api/config/index.js b/api/config/index.js index 3b6d869332b..6d9f70ecbbe 100644 --- a/api/config/index.js +++ b/api/config/index.js @@ -1,38 +1,57 @@ const { EventSource } = require('eventsource'); const { Time } = require('librechat-data-provider'); const { + mcpConfig, MCPManager, FlowStateManager, MCPServersRegistry, OAuthReconnectionManager, } = require('@librechat/api'); -const logger = require('./winston'); global.EventSource = EventSource; -/** @type {MCPManager} */ +/** @type {FlowStateManager} */ let flowManager = null; +/** @type {FlowStateManager} */ +let actionFlowManager = null; /** + * Flow manager for MCP OAuth flows. Uses the longer MCP OAuth TTL so the auth + * button and flow state outlive the user-completion window. * @param {Keyv} flowsCache * @returns {FlowStateManager} */ function getFlowStateManager(flowsCache) { if (!flowManager) { flowManager = new FlowStateManager(flowsCache, { - ttl: Time.ONE_MINUTE * 3, + ttl: mcpConfig.OAUTH_FLOW_TTL, }); } return flowManager; } +/** + * Flow manager for Action (custom tool) OAuth flows. Kept on the shorter TTL so an + * unclicked action login does not leave the tool call waiting for the MCP OAuth window. + * @param {Keyv} flowsCache + * @returns {FlowStateManager} + */ +function getActionFlowStateManager(flowsCache) { + if (!actionFlowManager) { + actionFlowManager = new FlowStateManager(flowsCache, { + ttl: Time.ONE_MINUTE * 3, + }); + } + return actionFlowManager; +} + module.exports = { - logger, createMCPServersRegistry: MCPServersRegistry.createInstance, getMCPServersRegistry: MCPServersRegistry.getInstance, createMCPManager: MCPManager.createInstance, getMCPManager: MCPManager.getInstance, getFlowStateManager, + getActionFlowStateManager, createOAuthReconnectionManager: OAuthReconnectionManager.createInstance, getOAuthReconnectionManager: OAuthReconnectionManager.getInstance, }; diff --git a/api/config/parsers.js b/api/config/parsers.js deleted file mode 100644 index 477e3712535..00000000000 --- a/api/config/parsers.js +++ /dev/null @@ -1,388 +0,0 @@ -const { klona } = require('klona'); -const winston = require('winston'); -const traverse = require('traverse'); - -const SPLAT_SYMBOL = Symbol.for('splat'); -const MESSAGE_SYMBOL = Symbol.for('message'); -const CONSOLE_JSON_STRING_LENGTH = parseInt(process.env.CONSOLE_JSON_STRING_LENGTH) || 255; -const DEBUG_MESSAGE_LENGTH = parseInt(process.env.DEBUG_MESSAGE_LENGTH) || 150; - -const sensitiveKeys = [ - // OpenAI API key: `sk-` at a word boundary, followed by the documented - // charset for keys. `\b` keeps `task-runner`, `mask-value`, etc. from - // being mis-redacted. - /\b(sk-)[a-zA-Z0-9_-]+/g, - /\b(Bearer )[^\s"']+/g, // Header: Bearer token pattern - /\b(api-key:? )[^\s"']+/gi, // Header: API key pattern (case-insensitive; covers `Api-Key:`, `API-KEY:`) - /\b(key=)[^\s"'&]+/g, // URL query param: sensitive key pattern (Google) -]; - -const NUMERIC_KEY_RE = /^\d+$/; -const LOG_CONTEXT_KEYS = ['tenantId', 'userId', 'requestId']; -const SYSTEM_TENANT_ID = '__SYSTEM__'; - -/** - * Redacts sensitive information from a console message and trims it to a specified length if provided. - * @param {string} str - The console message to be redacted. - * @param {number} [trimLength] - The optional length at which to trim the redacted message. - * @returns {string} - The redacted and optionally trimmed console message. - */ -function redactMessage(str, trimLength) { - if (!str) { - return ''; - } - - let redacted = str; - for (const pattern of sensitiveKeys) { - redacted = redacted.replace(pattern, '$1[REDACTED]'); - } - - if (trimLength !== undefined && redacted.length > trimLength) { - return `${redacted.substring(0, trimLength)}...`; - } - - return redacted; -} - -/** - * Redacts sensitive information from log messages when the log level is - * `error` or `warn`. Runs on the raw `info.message` before any colorize / - * splat transforms so the sensitive-token regexes don't have to contend - * with ANSI escape sequences (whose trailing `m` would otherwise defeat - * `\b` anchors). - * - * Note: Intentionally mutates the object. - * @param {Object} info - The log information object. - * @returns {Object} - The modified log information object. - */ -const redactFormat = winston.format((info) => { - if (info.level === 'error' || info.level === 'warn') { - if (typeof info.message === 'string') { - info.message = redactMessage(info.message); - } - if (typeof info[MESSAGE_SYMBOL] === 'string') { - info[MESSAGE_SYMBOL] = redactMessage(info[MESSAGE_SYMBOL]); - } - } - return info; -}); - -/** - * Truncates long strings, especially base64 image data, within log messages. - * - * @param {any} value - The value to be inspected and potentially truncated. - * @param {number} [length] - The length at which to truncate the value. Default: 100. - * @returns {any} - The truncated or original value. - */ -const truncateLongStrings = (value, length = 100) => { - if (typeof value === 'string') { - return value.length > length ? value.substring(0, length) + '... [truncated]' : value; - } - - return value; -}; - -/** - * An array mapping function that truncates long strings (objects converted to JSON strings). - * @param {any} item - The item to be condensed. - * @returns {any} - The condensed item. - */ -const condenseArray = (item) => { - if (typeof item === 'string') { - return truncateLongStrings(JSON.stringify(item)); - } else if (typeof item === 'object') { - return truncateLongStrings(JSON.stringify(item)); - } - return item; -}; - -const RESERVED_LOG_KEYS = new Set(['level', 'message', 'timestamp', 'splat']); - -/** - * Extracts user-supplied metadata from a winston info object. Filters out: - * - Reserved winston keys (`level`, `message`, `timestamp`, `splat`). - * - Numeric-string keys (`"0"`, `"1"`, ...) that `format.splat()` can - * synthesize when a primitive is passed as an extra log argument. - * - Values that are undefined, null, empty strings, functions, or symbols. - * - * Underscore-prefixed keys are intentionally preserved so legitimate - * fields like MongoDB `_id` survive. - * - * @param {Record} source - The object to extract metadata from. - * @returns {Record | undefined} - The extracted metadata, or undefined if empty. - */ -function extractMetaObject(source) { - if (source == null || typeof source !== 'object') { - return undefined; - } - const meta = {}; - for (const key of Object.keys(source)) { - if (RESERVED_LOG_KEYS.has(key)) { - continue; - } - if (NUMERIC_KEY_RE.test(key)) { - continue; - } - const value = source[key]; - if (key === 'tenantId' && value === SYSTEM_TENANT_ID) { - continue; - } - if (value === undefined || value === null || value === '') { - continue; - } - if (typeof value === 'function' || typeof value === 'symbol') { - continue; - } - meta[key] = value; - } - return Object.keys(meta).length > 0 ? meta : undefined; -} - -/** - * Formats the metadata portion of a winston info object as a compact - * single-line JSON trailer, suitable for appending to the console message. - * Returns an empty string when there is no meaningful metadata. - * - * @param {Record} info - The winston info object. - * @returns {string} - The serialized metadata, or an empty string. - */ -function formatConsoleMeta(info) { - const meta = extractMetaObject(info); - if (!meta) { - return ''; - } - const seen = new WeakSet(); - const replacer = (_key, value) => { - if (typeof value === 'string') { - const safe = redactMessage(value); - return safe.length > CONSOLE_JSON_STRING_LENGTH - ? `${safe.substring(0, CONSOLE_JSON_STRING_LENGTH)}...` - : safe; - } - if (value !== null && typeof value === 'object') { - if (seen.has(value)) { - return '[Circular]'; - } - seen.add(value); - } - return value; - }; - - try { - return JSON.stringify(meta, replacer); - } catch { - /* - * Fall back to per-field serialization: a single unserializable field - * shouldn't drop every other scalar in the trailer. Scalars are emitted - * as-is; values that still fail serialization are replaced with a - * placeholder so `provider`, `model`, etc. continue to surface. - */ - const parts = []; - for (const key of Object.keys(meta)) { - const perFieldSeen = new WeakSet(); - const perFieldReplacer = (k, value) => { - if (typeof value === 'string') { - return replacer(k, value); - } - if (value !== null && typeof value === 'object') { - if (perFieldSeen.has(value)) { - return '[Circular]'; - } - perFieldSeen.add(value); - } - return value; - }; - try { - parts.push(`${JSON.stringify(key)}:${JSON.stringify(meta[key], perFieldReplacer)}`); - } catch { - parts.push(`${JSON.stringify(key)}:"[Unserializable]"`); - } - } - return parts.length > 0 ? `{${parts.join(',')}}` : ''; - } -} - -function formatRequestContext(info) { - if (info == null || typeof info !== 'object') { - return ''; - } - const context = {}; - for (const key of LOG_CONTEXT_KEYS) { - const value = info[key]; - if (key === 'tenantId' && value === SYSTEM_TENANT_ID) { - continue; - } - if (typeof value === 'string' && value) { - context[key] = value; - } - } - return Object.keys(context).length > 0 ? JSON.stringify(context) : ''; -} - -/** - * Formats log messages for file and debug-console transports. Three paths: - * - `warn` / `error`: append a compact single-line JSON metadata trailer - * (via `formatConsoleMeta`) and pass the full line through `redactMessage` - * so sensitive patterns are scrubbed. - * - `debug`: perform the detailed multi-line object traversal of - * `SPLAT_SYMBOL[0]`, with long-string truncation and array condensation. - * Redaction on this path is not applied here (debug-file consumers - * historically accept raw detail). - * - Other levels: return the truncated `" : "` - * line with request context metadata when present. - * - * @param {Object} options - The options for formatting log messages. - * @param {string} options.level - The log level. - * @param {string} options.message - The log message. - * @param {string} options.timestamp - The timestamp of the log message. - * @param {Object} options.metadata - Additional metadata associated with the log message. - * @returns {string} - The formatted log message. - */ -const debugTraverse = winston.format.printf(({ level, message, timestamp, ...metadata }) => { - if (!message) { - return `${timestamp} ${level}`; - } - - if (!message?.trim || typeof message !== 'string') { - return `${timestamp} ${level}: ${JSON.stringify(message)}`; - } - - let msg = `${timestamp} ${level}: ${truncateLongStrings(message?.trim(), DEBUG_MESSAGE_LENGTH)}`; - const levelStr = typeof level === 'string' ? level : String(level); - const isErrorOrWarn = levelStr.includes('error') || levelStr.includes('warn'); - - /* - * Warn/error follow a simpler code path: append a single-line JSON - * metadata trailer (same shape as the console formatter) and pass the - * result through `redactMessage`. The complex object-traversal below is - * kept for debug level only, where detailed multi-line output is the - * intended behavior and its splat/interpolation interactions were - * already tolerated. - */ - if (isErrorOrWarn) { - const trailer = formatConsoleMeta(metadata); - const line = trailer ? `${msg} ${trailer}` : msg; - return redactMessage(line); - } - - try { - if (level !== 'debug') { - const trailer = formatRequestContext(metadata); - return trailer ? `${msg} ${trailer}` : msg; - } - - if (!metadata) { - return msg; - } - - const appendMetadataTrailer = (line) => { - const trailer = formatRequestContext(metadata); - return trailer ? `${line} ${trailer}` : line; - }; - - const debugValue = metadata[SPLAT_SYMBOL]?.[0]; - - if (!debugValue) { - return appendMetadataTrailer(msg); - } - - if (debugValue && Array.isArray(debugValue)) { - msg += `\n${JSON.stringify(debugValue.map(condenseArray))}`; - return appendMetadataTrailer(msg); - } - - if (typeof debugValue !== 'object') { - msg += ` ${debugValue}`; - return appendMetadataTrailer(msg); - } - - msg += '\n{'; - - const copy = klona(metadata); - if (copy.tenantId === SYSTEM_TENANT_ID) { - delete copy.tenantId; - } - traverse(copy).forEach(function (value) { - if (typeof this?.key === 'symbol') { - return; - } - - let _parentKey = ''; - const parent = this.parent; - - if (typeof parent?.key !== 'symbol' && parent?.key) { - _parentKey = parent.key; - } - - const parentKey = `${parent && parent.notRoot ? _parentKey + '.' : ''}`; - - const tabs = `${parent && parent.notRoot ? ' ' : ' '}`; - - const currentKey = this?.key ?? 'unknown'; - - if (this.isLeaf && typeof value === 'string') { - const truncatedText = truncateLongStrings(value); - msg += `\n${tabs}${parentKey}${currentKey}: ${JSON.stringify(truncatedText)},`; - } else if (this.notLeaf && Array.isArray(value) && value.length > 0) { - const currentMessage = `\n${tabs}// ${value.length} ${currentKey.replace(/s$/, '')}(s)`; - this.update(currentMessage, true); - msg += currentMessage; - const stringifiedArray = value.map(condenseArray); - msg += `\n${tabs}${parentKey}${currentKey}: [${stringifiedArray}],`; - } else if (this.isLeaf && typeof value === 'function') { - msg += `\n${tabs}${parentKey}${currentKey}: function,`; - } else if (this.isLeaf) { - msg += `\n${tabs}${parentKey}${currentKey}: ${value},`; - } - }); - - msg += '\n}'; - return msg; - } catch (e) { - return (msg += `\n[LOGGER PARSING ERROR] ${e.message}`); - } -}); - -const jsonTruncateFormat = winston.format((info) => { - const truncateLongStrings = (str, maxLength) => { - return str.length > maxLength ? str.substring(0, maxLength) + '...' : str; - }; - - const seen = new WeakSet(); - - const truncateObject = (obj) => { - if (typeof obj !== 'object' || obj === null) { - return obj; - } - - // Handle circular references - if (seen.has(obj)) { - return '[Circular]'; - } - seen.add(obj); - - if (Array.isArray(obj)) { - return obj.map((item) => truncateObject(item)); - } - - const newObj = {}; - Object.entries(obj).forEach(([key, value]) => { - if (typeof value === 'string') { - newObj[key] = truncateLongStrings(value, CONSOLE_JSON_STRING_LENGTH); - } else { - newObj[key] = truncateObject(value); - } - }); - return newObj; - }; - - return truncateObject(info); -}); - -module.exports = { - redactFormat, - redactMessage, - debugTraverse, - jsonTruncateFormat, - formatConsoleMeta, -}; diff --git a/api/config/winston.js b/api/config/winston.js deleted file mode 100644 index 983205fc709..00000000000 --- a/api/config/winston.js +++ /dev/null @@ -1,224 +0,0 @@ -const path = require('path'); -const fs = require('fs'); -const winston = require('winston'); -require('winston-daily-rotate-file'); -const { - getTenantId, - getUserId, - getRequestId, - SYSTEM_TENANT_ID, -} = require('@librechat/data-schemas'); -const { - redactFormat, - redactMessage, - debugTraverse, - jsonTruncateFormat, - formatConsoleMeta, -} = require('./parsers'); - -/** - * Determine the log directory. - * Priority: - * 1. LIBRECHAT_LOG_DIR environment variable (allows user override) - * 2. /app/logs if running in Docker (bind-mounted with correct permissions) - * 3. api/logs relative to this file (local development) - */ -const getLogDir = () => { - if (process.env.LIBRECHAT_LOG_DIR) { - return process.env.LIBRECHAT_LOG_DIR; - } - - // Check if running in Docker container (cwd is /app) - if (process.cwd() === '/app') { - const dockerLogDir = '/app/logs'; - // Ensure the directory exists - if (!fs.existsSync(dockerLogDir)) { - fs.mkdirSync(dockerLogDir, { recursive: true }); - } - return dockerLogDir; - } - - // Local development: use api/logs relative to this file - return path.join(__dirname, '..', 'logs'); -}; - -const { - NODE_ENV, - DEBUG_LOGGING = true, - CONSOLE_JSON = false, - DEBUG_CONSOLE = false, - LOG_TO_FILE = true, -} = process.env; - -const useConsoleJson = - (typeof CONSOLE_JSON === 'string' && CONSOLE_JSON?.toLowerCase() === 'true') || - CONSOLE_JSON === true; - -const useDebugConsole = - (typeof DEBUG_CONSOLE === 'string' && DEBUG_CONSOLE?.toLowerCase() === 'true') || - DEBUG_CONSOLE === true; - -const useDebugLogging = - (typeof DEBUG_LOGGING === 'string' && DEBUG_LOGGING?.toLowerCase() === 'true') || - DEBUG_LOGGING === true; - -const useFileLogging = - (typeof LOG_TO_FILE === 'string' && LOG_TO_FILE?.toLowerCase() !== 'false') || - LOG_TO_FILE === true; - -const levels = { - error: 0, - warn: 1, - info: 2, - http: 3, - verbose: 4, - debug: 5, - activity: 6, - silly: 7, -}; - -const LOG_CONTEXT_KEYS = ['tenantId', 'userId', 'requestId']; - -const getLogTenantId = () => { - const tenantId = getTenantId(); - return tenantId === SYSTEM_TENANT_ID ? undefined : tenantId; -}; - -const requestContextFormat = winston.format((info) => { - if (info.tenantId === SYSTEM_TENANT_ID) { - delete info.tenantId; - } - const context = { - tenantId: getLogTenantId(), - userId: getUserId(), - requestId: getRequestId(), - }; - LOG_CONTEXT_KEYS.forEach((key) => { - if (context[key] && info[key] == null) { - info[key] = context[key]; - } - }); - return info; -}); - -const formatRequestContext = (info) => { - const context = {}; - LOG_CONTEXT_KEYS.forEach((key) => { - const value = info[key]; - if (key === 'tenantId' && value === SYSTEM_TENANT_ID) { - return; - } - if (typeof value === 'string' && value) { - context[key] = value; - } - }); - return Object.keys(context).length > 0 ? JSON.stringify(context) : ''; -}; - -winston.addColors({ - info: 'green', // fontStyle color - warn: 'italic yellow', - error: 'red', - debug: 'blue', -}); - -const level = () => { - const env = NODE_ENV || 'development'; - const isDevelopment = env === 'development'; - return isDevelopment ? 'debug' : 'warn'; -}; - -const fileFormat = winston.format.combine( - redactFormat(), - winston.format.timestamp({ format: () => new Date().toISOString() }), - winston.format.errors({ stack: true }), - winston.format.splat(), - requestContextFormat(), - // redactErrors(), -); - -const transports = []; - -if (useFileLogging) { - const logDir = getLogDir(); - - transports.push( - new winston.transports.DailyRotateFile({ - level: 'error', - filename: `${logDir}/error-%DATE%.log`, - datePattern: 'YYYY-MM-DD', - zippedArchive: true, - maxSize: '20m', - maxFiles: '14d', - format: fileFormat, - }), - ); - - if (useDebugLogging) { - transports.push( - new winston.transports.DailyRotateFile({ - level: 'debug', - filename: `${logDir}/debug-%DATE%.log`, - datePattern: 'YYYY-MM-DD', - zippedArchive: true, - maxSize: '20m', - maxFiles: '14d', - format: winston.format.combine(fileFormat, debugTraverse), - }), - ); - } -} - -const consoleFormat = winston.format.combine( - redactFormat(), - requestContextFormat(), - winston.format.colorize({ all: true }), - winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), - // redactErrors(), - winston.format.printf((info) => { - const base = `${info.timestamp} ${info.level}: ${info.message}`; - const isErrorOrWarn = info.level.includes('error') || info.level.includes('warn'); - const metaTrailer = isErrorOrWarn ? formatConsoleMeta(info) : formatRequestContext(info); - const line = metaTrailer ? `${base} ${metaTrailer}` : base; - return isErrorOrWarn ? redactMessage(line) : line; - }), -); - -// Determine console log level -let consoleLogLevel = 'info'; -if (useDebugConsole) { - consoleLogLevel = 'debug'; -} - -if (useDebugConsole) { - transports.push( - new winston.transports.Console({ - level: consoleLogLevel, - format: useConsoleJson - ? winston.format.combine(fileFormat, jsonTruncateFormat(), winston.format.json()) - : winston.format.combine(fileFormat, debugTraverse), - }), - ); -} else if (useConsoleJson) { - transports.push( - new winston.transports.Console({ - level: consoleLogLevel, - format: winston.format.combine(fileFormat, jsonTruncateFormat(), winston.format.json()), - }), - ); -} else { - transports.push( - new winston.transports.Console({ - level: consoleLogLevel, - format: consoleFormat, - }), - ); -} - -const logger = winston.createLogger({ - level: level(), - levels, - transports, -}); - -module.exports = logger; diff --git a/api/jest.config.js b/api/jest.config.js index 47f8b7287bf..07588ea4554 100644 --- a/api/jest.config.js +++ b/api/jest.config.js @@ -1,3 +1,13 @@ +const esModules = [ + 'openid-client', + 'oauth4webapi', + 'jose', + '@langchain/langgraph', + '@langchain/langgraph-checkpoint', + '@langchain/langgraph-sdk', + 'uuid', +].join('|'); + module.exports = { testEnvironment: 'node', clearMocks: true, @@ -12,5 +22,13 @@ module.exports = { '^openid-client/passport$': '/test/__mocks__/openid-client-passport.js', '^openid-client$': '/test/__mocks__/openid-client.js', }, - transformIgnorePatterns: ['/node_modules/(?!(openid-client|oauth4webapi|jose)/).*/'], + transform: { + '\\.[jt]sx?$': [ + 'babel-jest', + { + presets: [['@babel/preset-env', { targets: { node: 'current' } }]], + }, + ], + }, + transformIgnorePatterns: [`/node_modules/(?!(${esModules})/).*/`], }; diff --git a/api/package.json b/api/package.json index 9c4da2df2ea..74a73add47d 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/backend", - "version": "v0.8.6", + "version": "v0.8.7-rc1", "description": "", "scripts": { "start": "echo 'please run this from the root directory'", @@ -44,9 +44,9 @@ "@azure/identity": "^4.13.1", "@azure/search-documents": "^12.0.0", "@azure/storage-blob": "^12.30.0", - "@google/genai": "^2.0.1", + "@google/genai": "^2.8.0", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.2.1", + "@librechat/agents": "^3.2.36", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -82,11 +82,13 @@ "file-type": "^21.3.2", "firebase": "^11.0.2", "form-data": "^4.0.4", + "get-stream": "^6.0.1", "handlebars": "^4.7.9", "https-proxy-agent": "^7.0.6", "ioredis": "^5.3.2", "js-yaml": "^4.1.1", "jsonwebtoken": "^9.0.0", + "jszip": "^3.10.1", "jwks-rsa": "^3.2.0", "keyv": "^5.3.2", "keyv-file": "^5.1.2", @@ -99,6 +101,7 @@ "memorystore": "^1.6.7", "mime": "^3.0.0", "module-alias": "^2.2.3", + "mongodb": "^6.14.2", "mongoose": "^8.23.1", "multer": "^2.1.1", "nanoid": "^3.3.7", @@ -121,7 +124,6 @@ "rate-limit-redis": "^4.2.0", "sanitize-html": "^2.13.0", "sharp": "^0.33.5", - "traverse": "^0.6.7", "ua-parser-js": "^1.0.36", "undici": "^7.24.1", "winston": "^3.11.0", @@ -131,6 +133,7 @@ "zod": "^3.22.4" }, "devDependencies": { + "@babel/preset-env": "^7.29.5", "@types/sanitize-html": "^2.13.0", "jest": "^30.2.0", "mongodb-memory-server": "^11.0.1", diff --git a/api/server/controllers/AuthController.js b/api/server/controllers/AuthController.js index 527728e98c0..b3743df8280 100644 --- a/api/server/controllers/AuthController.js +++ b/api/server/controllers/AuthController.js @@ -3,6 +3,7 @@ const jwt = require('jsonwebtoken'); const openIdClient = require('openid-client'); const { logger } = require('@librechat/data-schemas'); const { + math, isEnabled, findOpenIDUser, getOpenIdIssuer, @@ -28,8 +29,18 @@ const { getOpenIdConfig, getOpenIdEmail } = require('~/strategies'); const AUTH_REFRESH_USER_PROJECTION = '-password -__v -totpSecret -backupCodes -federatedTokens'; const OPENID_REUSE_EXPIRY_BUFFER_SECONDS = 30; -/** Mirrors the default SESSION_EXPIRY to bound IdP revocation lag for session-token reuse. */ -const OPENID_REUSE_MAX_SESSION_AGE_MS = 15 * 60 * 1000; +/** + * Max age (ms) LibreChat reuses a cached OpenID session token before forcing an IdP refresh. + * Env-overridable (accepts an arithmetic expression, e.g. `60 * 60 * 24 * 1000`, like + * `SESSION_EXPIRY`): deployments whose IdP revokes the previous access token on refresh can + * widen this to the access-token lifetime so a still-valid token is not rotated/revoked out + * from under downstream consumers (e.g. MCP servers that introspect the bearer). Defaults to + * 15 minutes. + */ +const OPENID_REUSE_MAX_SESSION_AGE_MS = math( + process.env.OPENID_REUSE_MAX_SESSION_AGE_MS, + 15 * 60 * 1000, +); const registrationController = async (req, res) => { try { diff --git a/api/server/controllers/AuthController.spec.js b/api/server/controllers/AuthController.spec.js index 7bed1da33bd..40c20bbbe18 100644 --- a/api/server/controllers/AuthController.spec.js +++ b/api/server/controllers/AuthController.spec.js @@ -22,6 +22,7 @@ jest.mock('~/models', () => ({ findUser: jest.fn(), })); jest.mock('@librechat/api', () => ({ + math: jest.fn((value, fallback) => fallback), isEnabled: jest.fn(), findOpenIDUser: jest.fn(), getOpenIdIssuer: jest.fn(() => 'https://issuer.example.com'), diff --git a/api/server/controllers/ContextProjectionController.js b/api/server/controllers/ContextProjectionController.js new file mode 100644 index 00000000000..eaf9592e73c --- /dev/null +++ b/api/server/controllers/ContextProjectionController.js @@ -0,0 +1,31 @@ +const { logger } = require('@librechat/data-schemas'); +const { resolveContextProjection } = require('@librechat/api'); +const db = require('~/models'); + +/** + * Returns a server-side context-usage projection for the viewed branch + config + * (agents SDK, no model call) — powers the gauge for snapshot-less branches and + * after a model/window switch. Resolution lives in `@librechat/api`; this + * controller only injects request-scoped model accessors. + * @param {ServerRequest} req + * @param {ServerResponse} res + */ +async function contextProjectionController(req, res) { + try { + const params = req.body ?? {}; + if (!params.conversationId || !params.messageId) { + res.json(null); + return; + } + const projection = await resolveContextProjection( + { userId: req.user?.id, getMessages: db.getMessages }, + params, + ); + res.json(projection ?? null); + } catch (error) { + logger.error('[contextProjectionController]', error); + res.status(500).json({ error: 'Failed to resolve context projection' }); + } +} + +module.exports = contextProjectionController; diff --git a/api/server/controllers/PermissionsController.js b/api/server/controllers/PermissionsController.js index ffe159a82cc..076de31cf33 100644 --- a/api/server/controllers/PermissionsController.js +++ b/api/server/controllers/PermissionsController.js @@ -3,7 +3,7 @@ */ const mongoose = require('mongoose'); -const { logger } = require('@librechat/data-schemas'); +const { logger, getTenantId, SYSTEM_TENANT_ID } = require('@librechat/data-schemas'); const { ResourceType, PrincipalType, PermissionBits } = require('librechat-data-provider'); const { enrichRemoteAgentPrincipals, backfillRemoteAgentPermissions } = require('@librechat/api'); const { @@ -21,6 +21,13 @@ const { } = require('~/server/services/GraphApiService'); const db = require('~/models'); +const matchesCurrentTenant = (principal, tenantId) => { + if (!tenantId || tenantId === SYSTEM_TENANT_ID) { + return true; + } + return principal?.tenantId === tenantId; +}; + /** * Generic controller for resource permission endpoints * Delegates validation and logic to PermissionService @@ -134,8 +141,8 @@ const updateResourcePermissions = async (req, res) => { revokedPrincipals.push(...removed); } - // If public is disabled, add public to revoked list - if (!isPublic) { + // If public is explicitly disabled, add public to revoked list + if (isPublic === false) { revokedPrincipals.push({ type: PrincipalType.PUBLIC, id: null, @@ -167,7 +174,7 @@ const updateResourcePermissions = async (req, res) => { message: 'Permissions updated successfully', results: { principals: results.granted, - public: isPublic || false, + ...(isPublic !== undefined ? { public: isPublic } : {}), publicAccessRoleId: isPublic ? publicAccessRoleId : undefined, }, }; @@ -191,6 +198,7 @@ const getResourcePermissions = async (req, res) => { try { const { resourceType, resourceId } = req.params; validateResourceType(resourceType); + const tenantId = getTenantId(); const results = await db.aggregateAclEntries([ // Match ACL entries for this resource @@ -244,14 +252,17 @@ const getResourcePermissions = async (req, res) => { let principals = []; let publicPermission = null; - // Process aggregation results for (const result of results) { if (result.principalType === PrincipalType.PUBLIC) { publicPermission = { public: true, publicAccessRoleId: result.accessRoleId, }; - } else if (result.principalType === PrincipalType.USER && result.userInfo) { + } else if ( + result.principalType === PrincipalType.USER && + result.userInfo && + matchesCurrentTenant(result.userInfo, tenantId) + ) { principals.push({ type: PrincipalType.USER, id: result.userInfo._id.toString(), @@ -262,7 +273,11 @@ const getResourcePermissions = async (req, res) => { idOnTheSource: result.userInfo.idOnTheSource || result.userInfo._id.toString(), accessRoleId: result.accessRoleId, }); - } else if (result.principalType === PrincipalType.GROUP && result.groupInfo) { + } else if ( + result.principalType === PrincipalType.GROUP && + result.groupInfo && + matchesCurrentTenant(result.groupInfo, tenantId) + ) { principals.push({ type: PrincipalType.GROUP, id: result.groupInfo._id.toString(), diff --git a/api/server/controllers/SkillStatesController.js b/api/server/controllers/SkillStatesController.js index 35679d2ac6d..1afeb271413 100644 --- a/api/server/controllers/SkillStatesController.js +++ b/api/server/controllers/SkillStatesController.js @@ -5,6 +5,8 @@ const { toSkillStatesRecord, validateSkillStatesPayload, pruneOrphanSkillStates, + getDeploymentSkillIds, + mergeDeploymentSkillIds, } = require('@librechat/api'); const { ResourceType, PermissionBits } = require('librechat-data-provider'); const { findAccessibleResources } = require('~/server/services/PermissionService'); @@ -21,15 +23,20 @@ function buildPruneDeps(user) { const existing = await Skill.find({ _id: { $in: validIds } }) .select('_id') .lean(); - return existing.map((doc) => doc._id.toString()); + const deploymentIds = getDeploymentSkillIds() + .map((id) => id.toString()) + .filter((id) => validIds.includes(id)); + return [...existing.map((doc) => doc._id.toString()), ...deploymentIds]; }, - findAccessibleSkillIds: () => - findAccessibleResources({ - userId: user.id, - role: user.role, - resourceType: ResourceType.SKILL, - requiredPermissions: PermissionBits.VIEW, - }), + findAccessibleSkillIds: async () => + mergeDeploymentSkillIds( + await findAccessibleResources({ + userId: user.id, + role: user.role, + resourceType: ResourceType.SKILL, + requiredPermissions: PermissionBits.VIEW, + }), + ), }; } diff --git a/api/server/controllers/TokenConfigController.js b/api/server/controllers/TokenConfigController.js new file mode 100644 index 00000000000..059f1447f28 --- /dev/null +++ b/api/server/controllers/TokenConfigController.js @@ -0,0 +1,32 @@ +const { logger } = require('@librechat/data-schemas'); +const { resolveTokenConfigMap } = require('@librechat/api'); +const { getModelsConfig } = require('~/server/controllers/ModelController'); +const { getValueKey, getMultiplier, getCacheMultiplier } = require('~/models'); + +/** + * Returns server-resolved context windows (and pricing when + * `interface.contextCost` is enabled) for every configured model. Resolution + * lives in `@librechat/api`; this controller only supplies request-scoped deps. + * @param {ServerRequest} req + * @param {ServerResponse} res + */ +async function tokenConfigController(req, res) { + try { + const modelsConfig = await getModelsConfig(req); + const tokenConfigMap = await resolveTokenConfigMap( + { + appConfig: req.config, + modelsConfig, + userId: req.user.id, + tenantId: req.user.tenantId, + }, + { getValueKey, getMultiplier, getCacheMultiplier }, + ); + res.json(tokenConfigMap); + } catch (error) { + logger.error('[tokenConfigController]', error); + res.status(500).json({ error: 'Failed to resolve token config' }); + } +} + +module.exports = tokenConfigController; diff --git a/api/server/controllers/UserController.js b/api/server/controllers/UserController.js index ca560389e6a..5fd43b66d1e 100644 --- a/api/server/controllers/UserController.js +++ b/api/server/controllers/UserController.js @@ -1,5 +1,5 @@ const mongoose = require('mongoose'); -const { logger, webSearchKeys } = require('@librechat/data-schemas'); +const { logger, getTenantId, webSearchKeys } = require('@librechat/data-schemas'); const { getNewS3URL, needsRefresh, @@ -7,6 +7,7 @@ const { MCPTokenStorage, normalizeHttpError, extractWebSearchEnvVars, + deleteAllSharedLinksWithCleanup, } = require('@librechat/api'); const { Tools, @@ -359,7 +360,7 @@ const deleteUserController = async (req, res) => { } await deleteUserPluginAuth(user.id, null, true); await db.deleteUserById(user.id); - await db.deleteAllSharedLinks(user.id); + await deleteAllSharedLinksWithCleanup(user.id); await deleteUserFiles(req); await db.deleteFiles(null, user.id); await db.deleteToolCalls(user.id); @@ -387,7 +388,7 @@ const verifyEmailController = async (req, res) => { try { const verifyEmailService = await verifyEmail(req); if (verifyEmailService instanceof Error) { - return res.status(400).json(verifyEmailService); + return res.status(400).json({ message: verifyEmailService.message }); } else { return res.status(200).json(verifyEmailService); } @@ -401,9 +402,9 @@ const resendVerificationController = async (req, res) => { try { const result = await resendVerificationEmail(req); if (result instanceof Error) { - return res.status(400).json(result); + return res.status(400).json({ message: result.message }); } else { - return res.status(200).json(result); + return res.status(result.status ?? 200).json({ message: result.message }); } } catch (e) { logger.error('[verifyEmailController]', e); @@ -431,11 +432,24 @@ const clearStoredMCPOAuthState = async (userId, serverName) => { try { const flowsCache = getLogStores(CacheKeys.FLOWS); const flowManager = getFlowStateManager(flowsCache); - const flowId = MCPOAuthHandler.generateFlowId(userId, serverName); - const results = await Promise.allSettled([ - flowManager.deleteFlow(flowId, 'mcp_get_tokens'), - flowManager.deleteFlow(flowId, 'mcp_oauth'), - ]); + const baseFlowId = MCPOAuthHandler.generateFlowId(userId, serverName); + const tenantId = getTenantId(); + const tokenFlowId = MCPOAuthHandler.generateTokenFlowId(userId, serverName, tenantId); + const oauthFlowId = MCPOAuthHandler.generateFlowId(userId, serverName, tenantId); + const flowDeletes = [ + [tokenFlowId, 'mcp_get_tokens'], + [oauthFlowId, 'mcp_oauth'], + [baseFlowId, 'mcp_get_tokens'], + [baseFlowId, 'mcp_oauth'], + ].filter( + ([flowId, type], index, deletes) => + deletes.findIndex(([candidateId, candidateType]) => { + return candidateId === flowId && candidateType === type; + }) === index, + ); + const results = await Promise.allSettled( + flowDeletes.map(([flowId, type]) => flowManager.deleteFlow(flowId, type)), + ); for (const result of results) { if (result.status === 'rejected') { logger.warn( @@ -516,9 +530,10 @@ const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => { serverConfig.oauth?.revocation_endpoint_auth_methods_supported ?? clientMetadata.revocation_endpoint_auth_methods_supported; const oauthHeaders = serverConfig.oauth_headers ?? {}; - const registry = getMCPServersRegistry(); - const allowedDomains = registry.getAllowedDomains(); - const allowedAddresses = registry.getAllowedAddresses(); + // Use the request's merged (tenant/principal-scoped) allowlists so admin-panel mcpSettings + // overrides are honored for OAuth revocation, consistent with inspection/connection. + const allowedDomains = appConfig?.mcpSettings?.allowedDomains; + const allowedAddresses = appConfig?.mcpSettings?.allowedAddresses; if (tokens?.access_token) { try { diff --git a/api/server/controllers/UserController.spec.js b/api/server/controllers/UserController.spec.js index 4dd2efaa7ba..6a165fe7182 100644 --- a/api/server/controllers/UserController.spec.js +++ b/api/server/controllers/UserController.spec.js @@ -108,9 +108,49 @@ afterEach(async () => { } }); -const { deleteUserController, getUserController } = require('./UserController'); +const { + deleteUserController, + getUserController, + resendVerificationController, + verifyEmailController, +} = require('./UserController'); const { Group } = require('~/db/models'); const { deleteConvos } = require('~/models'); +const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService'); + +describe('verifyEmailController', () => { + const mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns the generic verification error message from service failures', async () => { + verifyEmail.mockResolvedValue(new Error('Invalid or expired email verification token')); + + await verifyEmailController( + { body: { email: 'user%40example.com', token: 'not-the-token' } }, + mockRes, + ); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(mockRes.json).toHaveBeenCalledWith({ + message: 'Invalid or expired email verification token', + }); + }); + + it('uses the service status for resend verification responses', async () => { + resendVerificationEmail.mockResolvedValue({ status: 500, message: 'Something went wrong.' }); + + await resendVerificationController({ body: { email: 'user@example.com' } }, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(500); + expect(mockRes.json).toHaveBeenCalledWith({ message: 'Something went wrong.' }); + }); +}); describe('getUserController', () => { const mockRes = { diff --git a/api/server/controllers/__tests__/PermissionsController.spec.js b/api/server/controllers/__tests__/PermissionsController.spec.js index 6c42ccc59b5..5976f9b29a5 100644 --- a/api/server/controllers/__tests__/PermissionsController.spec.js +++ b/api/server/controllers/__tests__/PermissionsController.spec.js @@ -1,12 +1,16 @@ const mongoose = require('mongoose'); const mockLogger = { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() }; +const mockGetTenantId = jest.fn(); jest.mock('@librechat/data-schemas', () => ({ logger: mockLogger, + getTenantId: mockGetTenantId, + SYSTEM_TENANT_ID: '__SYSTEM__', })); -const { ResourceType, PrincipalType } = jest.requireActual('librechat-data-provider'); +const { AccessRoleIds, ResourceType, PrincipalType } = + jest.requireActual('librechat-data-provider'); jest.mock('librechat-data-provider', () => ({ ...jest.requireActual('librechat-data-provider'), @@ -32,6 +36,7 @@ jest.mock('~/server/services/PermissionService', () => ({ const mockRemoveAgentFromUserFavorites = jest.fn(); jest.mock('~/models', () => ({ + aggregateAclEntries: jest.fn(), searchPrincipals: jest.fn(), sortPrincipalsByRelevance: jest.fn(), calculateRelevanceScore: jest.fn(), @@ -44,7 +49,11 @@ jest.mock('~/server/services/GraphApiService', () => ({ })); const db = require('~/models'); -const { updateResourcePermissions, searchPrincipals } = require('../PermissionsController'); +const { + updateResourcePermissions, + searchPrincipals, + getResourcePermissions, +} = require('../PermissionsController'); const createMockReq = (overrides = {}) => ({ params: { resourceType: ResourceType.AGENT, resourceId: '507f1f77bcf86cd799439011' }, @@ -66,6 +75,7 @@ const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); describe('PermissionsController', () => { beforeEach(() => { jest.clearAllMocks(); + mockGetTenantId.mockReturnValue(undefined); }); describe('searchPrincipals', () => { @@ -139,6 +149,108 @@ describe('PermissionsController', () => { }); }); + describe('getResourcePermissions — principal details', () => { + const currentTenantId = 'tenant-a'; + const otherTenantId = 'tenant-b'; + const userId = new mongoose.Types.ObjectId(); + const groupId = new mongoose.Types.ObjectId(); + + it('omits joined user and group details outside the current request context', async () => { + mockGetTenantId.mockReturnValue(currentTenantId); + db.aggregateAclEntries.mockResolvedValue([ + { + principalType: PrincipalType.USER, + accessRoleId: AccessRoleIds.AGENT_VIEWER, + userInfo: { + _id: userId, + tenantId: otherTenantId, + name: 'Outside User', + email: 'outside-user@example.com', + avatar: 'outside-user.png', + }, + }, + { + principalType: PrincipalType.GROUP, + accessRoleId: AccessRoleIds.AGENT_VIEWER, + groupInfo: { + _id: groupId, + tenantId: otherTenantId, + name: 'Outside Group', + email: 'outside-group@example.com', + avatar: 'outside-group.png', + }, + }, + { + principalType: PrincipalType.PUBLIC, + accessRoleId: AccessRoleIds.AGENT_VIEWER, + }, + ]); + + const req = createMockReq(); + const res = createMockRes(); + + await getResourcePermissions(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + resourceType: ResourceType.AGENT, + resourceId: req.params.resourceId, + principals: [], + public: true, + publicAccessRoleId: AccessRoleIds.AGENT_VIEWER, + }); + expect(JSON.stringify(res.json.mock.calls[0][0])).not.toContain('outside-user@example.com'); + expect(JSON.stringify(res.json.mock.calls[0][0])).not.toContain('outside-group@example.com'); + }); + + it('includes joined user and group details in the current request context', async () => { + mockGetTenantId.mockReturnValue(currentTenantId); + db.aggregateAclEntries.mockResolvedValue([ + { + principalType: PrincipalType.USER, + accessRoleId: AccessRoleIds.AGENT_VIEWER, + userInfo: { + _id: userId, + tenantId: currentTenantId, + name: 'Current User', + email: 'current-user@example.com', + avatar: 'current-user.png', + }, + }, + { + principalType: PrincipalType.GROUP, + accessRoleId: AccessRoleIds.AGENT_VIEWER, + groupInfo: { + _id: groupId, + tenantId: currentTenantId, + name: 'Current Group', + email: 'current-group@example.com', + avatar: 'current-group.png', + }, + }, + ]); + + const req = createMockReq(); + const res = createMockRes(); + + await getResourcePermissions(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json.mock.calls[0][0].principals).toEqual([ + expect.objectContaining({ + type: PrincipalType.USER, + id: userId.toString(), + email: 'current-user@example.com', + }), + expect.objectContaining({ + type: PrincipalType.GROUP, + id: groupId.toString(), + email: 'current-group@example.com', + }), + ]); + }); + }); + describe('updateResourcePermissions — favorites cleanup', () => { const agentObjectId = new mongoose.Types.ObjectId().toString(); const revokedUserId = new mongoose.Types.ObjectId().toString(); diff --git a/api/server/controllers/__tests__/UserController.mcpOAuth.spec.js b/api/server/controllers/__tests__/UserController.mcpOAuth.spec.js index ef605beaab7..c5457d468c7 100644 --- a/api/server/controllers/__tests__/UserController.mcpOAuth.spec.js +++ b/api/server/controllers/__tests__/UserController.mcpOAuth.spec.js @@ -10,6 +10,7 @@ const mockGetMCPServersRegistry = jest.fn(); jest.mock('@librechat/data-schemas', () => ({ logger: { error: jest.fn(), info: jest.fn(), warn: jest.fn() }, + getTenantId: jest.fn(), webSearchKeys: [], })); @@ -22,7 +23,14 @@ jest.mock('librechat-data-provider', () => ({ jest.mock('@librechat/api', () => ({ MCPOAuthHandler: { - generateFlowId: jest.fn(() => 'user-1:test-server'), + generateFlowId: jest.fn((userId, serverName, tenantId) => { + const flowId = `${userId}:${serverName}`; + return tenantId ? `tenant:${encodeURIComponent(tenantId)}:${flowId}` : flowId; + }), + generateTokenFlowId: jest.fn((userId, serverName, tenantId) => { + const flowId = `${userId}:${serverName}`; + return tenantId ? `tenant:${encodeURIComponent(tenantId)}:${flowId}` : flowId; + }), revokeOAuthToken: jest.fn(), }, MCPTokenStorage: { @@ -78,7 +86,7 @@ jest.mock('~/cache', () => ({ getLogStores: (...args) => mockGetLogStores(...args), })); -const { logger } = require('@librechat/data-schemas'); +const { logger, getTenantId } = require('@librechat/data-schemas'); const { MCPTokenStorage, MCPOAuthHandler } = require('@librechat/api'); const { updateUserPluginsController } = require('~/server/controllers/UserController'); @@ -124,7 +132,10 @@ function setupMCPMocks() { getAllowedAddresses: jest.fn().mockReturnValue(null), }; - mockGetAppConfig.mockResolvedValue({}); + // Revocation reads the merged config's mcpSettings allowlists (not the registry getters). + mockGetAppConfig.mockResolvedValue({ + mcpSettings: { allowedDomains: [], allowedAddresses: null }, + }); mockUpdateUserPlugins.mockResolvedValue(); mockDeleteUserPluginAuth.mockResolvedValue(); mockInvalidateCachedTools.mockResolvedValue(); @@ -138,6 +149,7 @@ function setupMCPMocks() { beforeEach(() => { jest.clearAllMocks(); + getTenantId.mockReturnValue(undefined); }); describe('updateUserPluginsController MCP OAuth cleanup', () => { @@ -231,6 +243,27 @@ describe('updateUserPluginsController MCP OAuth cleanup', () => { expect(MCPOAuthHandler.revokeOAuthToken).not.toHaveBeenCalled(); }); + it('clears tenant-scoped and legacy OAuth flow state when tenant context exists', async () => { + const { flowManager } = setupMCPMocks(); + getTenantId.mockReturnValue('tenant-a'); + MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue(null); + + const res = createResponse(); + await updateUserPluginsController(createRequest(), res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(flowManager.deleteFlow).toHaveBeenCalledWith( + 'tenant:tenant-a:user-1:test-server', + 'mcp_get_tokens', + ); + expect(flowManager.deleteFlow).toHaveBeenCalledWith( + 'tenant:tenant-a:user-1:test-server', + 'mcp_oauth', + ); + expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens'); + expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_oauth'); + }); + it('clears stored OAuth token state when server config is missing', async () => { const { flowManager, registry } = setupMCPMocks(); registry.getServerConfig.mockResolvedValue(undefined); diff --git a/api/server/controllers/__tests__/deleteUser.spec.js b/api/server/controllers/__tests__/deleteUser.spec.js index 1d7c8521531..6198122bd05 100644 --- a/api/server/controllers/__tests__/deleteUser.spec.js +++ b/api/server/controllers/__tests__/deleteUser.spec.js @@ -3,6 +3,7 @@ const mockDeleteMessages = jest.fn(); const mockDeleteAllUserSessions = jest.fn(); const mockDeleteUserById = jest.fn(); const mockDeleteAllSharedLinks = jest.fn(); +const mockDeleteAllSharedLinksWithCleanup = jest.fn(); const mockDeletePresets = jest.fn(); const mockDeleteUserKey = jest.fn(); const mockDeleteConvos = jest.fn(); @@ -38,6 +39,7 @@ jest.mock('@librechat/api', () => ({ extractWebSearchEnvVars: jest.fn(), needsRefresh: jest.fn(), getNewS3URL: jest.fn(), + deleteAllSharedLinksWithCleanup: (...args) => mockDeleteAllSharedLinksWithCleanup(...args), })); jest.mock('~/models', () => ({ @@ -126,6 +128,7 @@ function stubDeletionMocks() { mockDeleteUserPluginAuth.mockResolvedValue(); mockDeleteUserById.mockResolvedValue(); mockDeleteAllSharedLinks.mockResolvedValue(); + mockDeleteAllSharedLinksWithCleanup.mockResolvedValue({ deletedCount: 0 }); mockGetFiles.mockResolvedValue([]); mockProcessDeleteRequest.mockResolvedValue({ deletedFileIds: [], failedFileIds: [] }); mockDeleteFiles.mockResolvedValue(); diff --git a/api/server/controllers/__tests__/deleteUserResourceCoverage.spec.js b/api/server/controllers/__tests__/deleteUserResourceCoverage.spec.js index 78fcfa16b0b..1bd5b2efaa5 100644 --- a/api/server/controllers/__tests__/deleteUserResourceCoverage.spec.js +++ b/api/server/controllers/__tests__/deleteUserResourceCoverage.spec.js @@ -16,6 +16,7 @@ const HANDLED_RESOURCE_TYPES = { [ResourceType.PROMPTGROUP]: 'deleteUserPrompts', [ResourceType.MCPSERVER]: 'deleteUserMcpServers', [ResourceType.SKILL]: 'deleteUserSkills', + [ResourceType.SHARED_LINK]: 'deleteAllSharedLinksWithCleanup', }; /** diff --git a/api/server/controllers/__tests__/maybeUninstallOAuthMCP.spec.js b/api/server/controllers/__tests__/maybeUninstallOAuthMCP.spec.js index fd591aa440f..1b8436233a5 100644 --- a/api/server/controllers/__tests__/maybeUninstallOAuthMCP.spec.js +++ b/api/server/controllers/__tests__/maybeUninstallOAuthMCP.spec.js @@ -13,9 +13,11 @@ const mockDeleteTokens = jest.fn(); const mockLoggerInfo = jest.fn(); const mockLoggerWarn = jest.fn(); const mockLoggerError = jest.fn(); +const mockGetTenantId = jest.fn(); jest.mock('@librechat/data-schemas', () => ({ logger: { info: mockLoggerInfo, warn: mockLoggerWarn, error: mockLoggerError }, + getTenantId: (...args) => mockGetTenantId(...args), webSearchKeys: [], })); @@ -23,7 +25,14 @@ jest.mock('@librechat/api', () => { return { MCPOAuthHandler: { revokeOAuthToken: (...args) => mockRevokeOAuthToken(...args), - generateFlowId: (userId, serverName) => `${userId}:${serverName}`, + generateFlowId: (userId, serverName, tenantId) => { + const flowId = `${userId}:${serverName}`; + return tenantId ? `tenant:${encodeURIComponent(tenantId)}:${flowId}` : flowId; + }, + generateTokenFlowId: (userId, serverName, tenantId) => { + const flowId = `${userId}:${serverName}`; + return tenantId ? `tenant:${encodeURIComponent(tenantId)}:${flowId}` : flowId; + }, }, MCPTokenStorage: { getTokens: (...args) => mockGetTokens(...args), @@ -151,6 +160,7 @@ function setupOAuthServerFound() { describe('maybeUninstallOAuthMCP', () => { beforeEach(() => { jest.clearAllMocks(); + mockGetTenantId.mockReturnValue(undefined); }); test('is a no-op when pluginKey is not an MCP key', async () => { @@ -205,6 +215,20 @@ describe('maybeUninstallOAuthMCP', () => { ); }); + test('clears tenant-scoped and legacy flow state when tenant context exists', async () => { + setupOAuthServerFound(); + mockGetTenantId.mockReturnValue('tenant-a'); + mockGetClientInfoAndMetadata.mockResolvedValue(null); + + await maybeUninstallOAuthMCP(userId, pluginKey, appConfig); + + expect(mockDeleteFlow).toHaveBeenCalledTimes(4); + expect(mockDeleteFlow).toHaveBeenCalledWith('tenant:tenant-a:user-123:acme', 'mcp_get_tokens'); + expect(mockDeleteFlow).toHaveBeenCalledWith('tenant:tenant-a:user-123:acme', 'mcp_oauth'); + expect(mockDeleteFlow).toHaveBeenCalledWith('user-123:acme', 'mcp_get_tokens'); + expect(mockDeleteFlow).toHaveBeenCalledWith('user-123:acme', 'mcp_oauth'); + }); + test('revokes both tokens and runs cleanup on happy path', async () => { setupOAuthServerFound(); mockGetTokens.mockResolvedValue({ diff --git a/api/server/controllers/agents/__tests__/callbacks.spec.js b/api/server/controllers/agents/__tests__/callbacks.spec.js index 0ba20d409cd..20fcf54a6cd 100644 --- a/api/server/controllers/agents/__tests__/callbacks.spec.js +++ b/api/server/controllers/agents/__tests__/callbacks.spec.js @@ -7,6 +7,10 @@ jest.mock('nanoid', () => ({ jest.mock('@librechat/api', () => ({ sendEvent: jest.fn(), + HOST_FILE_AUTHORING_ARTIFACT_KEY: '__librechat_file_authoring', + isCodeSessionToolName: jest.fn((name) => + ['execute_code', 'bash_tool', 'read_file'].includes(name), + ), })); jest.mock('@librechat/data-schemas', () => ({ @@ -364,12 +368,21 @@ describe('createToolEndCallback', () => { const { processCodeOutput } = require('~/server/services/Files/Code/process'); - function makeCodeExecutionEvent({ runId, threadId, toolCallId, fileId, name }) { + function makeCodeExecutionEvent({ + runId, + threadId, + toolCallId, + fileId, + name, + toolName = 'execute_code', + hostFileAuthoring = false, + }) { return { output: { - name: 'execute_code', + name: toolName, tool_call_id: toolCallId, artifact: { + ...(hostFileAuthoring ? { __librechat_file_authoring: true } : {}), session_id: 'sess-1', files: [{ id: fileId, name, session_id: 'sess-1' }], }, @@ -573,6 +586,65 @@ describe('createToolEndCallback', () => { expect(res.write).toHaveBeenCalledTimes(1); }); + + it('processes create_file sandbox artifacts like code execution outputs', async () => { + res.headersSent = true; + processCodeOutput.mockResolvedValue({ + file: { + file_id: 'fid-created', + filename: 'created.txt', + filepath: '/uploads/created.txt', + type: 'text/plain', + conversationId: 'thread789', + messageId: 'run-create', + toolCallId: 'tool-create', + status: 'ready', + }, + }); + + const toolEndCallback = createToolEndCallback({ req, res, artifactPromises }); + const event = makeCodeExecutionEvent({ + runId: 'run-create', + threadId: 'thread789', + toolCallId: 'tool-create', + fileId: 'fid-created', + name: 'created.txt', + toolName: 'create_file', + hostFileAuthoring: true, + }); + await toolEndCallback({ output: event.output }, event.metadata); + await Promise.all(artifactPromises); + + expect(processCodeOutput).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'fid-created', + name: 'created.txt', + messageId: 'run-create', + toolCallId: 'tool-create', + conversationId: 'thread789', + }), + ); + expect(res.write).toHaveBeenCalledTimes(1); + }); + + it('does not process arbitrary user tool artifacts named create_file as code outputs', async () => { + res.headersSent = true; + const toolEndCallback = createToolEndCallback({ req, res, artifactPromises }); + const event = makeCodeExecutionEvent({ + runId: 'run-user-create', + threadId: 'thread789', + toolCallId: 'tool-user-create', + fileId: 'fid-user-created', + name: 'created.txt', + toolName: 'create_file', + }); + + await toolEndCallback({ output: event.output }, event.metadata); + await Promise.all(artifactPromises); + + expect(processCodeOutput).not.toHaveBeenCalled(); + expect(res.write).not.toHaveBeenCalled(); + }); }); }); diff --git a/api/server/controllers/agents/__tests__/client.contextMetadata.spec.js b/api/server/controllers/agents/__tests__/client.contextMetadata.spec.js new file mode 100644 index 00000000000..6df38efb6c5 --- /dev/null +++ b/api/server/controllers/agents/__tests__/client.contextMetadata.spec.js @@ -0,0 +1,139 @@ +const AgentClient = require('../client'); + +/** Minimal post-(maybe-)summary snapshot. baseUsed = maxContextTokens(1000) - + * remainingContextTokens(700) = 300, so the marker (summaryUsedTokens) is 300. */ +const snapshot = (summaryTokens) => ({ + runId: 'run-1', + agentId: 'agent-1', + breakdown: { + maxContextTokens: 1000, + instructionTokens: 50, + systemMessageTokens: 50, + dynamicInstructionTokens: 0, + toolSchemaTokens: 0, + summaryTokens, + toolCount: 0, + messageCount: 1, + messageTokens: 20, + availableForMessages: 900, + }, + contextBudget: 1000, + remainingContextTokens: 700, + prePruneContextTokens: 300, + effectiveInstructionTokens: 50, + calibrationRatio: 1, +}); + +const primary = { input_tokens: 10, output_tokens: 5, total_tokens: 15 }; +const summarizationUsage = { ...primary, usage_type: 'summarization' }; +const primaryFor = (runId, output_tokens) => ({ + input_tokens: 10, + output_tokens, + total_tokens: 10 + output_tokens, + provider: 'openAI', + runId, +}); + +function buildMeta({ snap, latestUsageIndex, usageEvents }) { + const self = { + collectedThoughtSignatures: null, + usageEmitSink: usageEvents, + contextUsageSink: snap + ? { latest: snap, count: 1, latestUsageIndex } + : { latest: null, count: 0 }, + }; + return AgentClient.prototype.buildResponseMetadata.call(self); +} + +describe('AgentClient.buildResponseMetadata — snapshot persistence + summary marker', () => { + it('persists the snapshot when a primary usage follows it (normal turn)', () => { + const meta = buildMeta({ snap: snapshot(0), latestUsageIndex: 0, usageEvents: [primary] }); + expect(meta.contextUsage).toBeDefined(); + expect(meta.summaryUsedTokens).toBeUndefined(); + }); + + it('persists the post-summary snapshot when the only pre-primary usage is the summarization', () => { + /** A summarized turn: the summarization usage precedes the post-summary + * snapshot (index 1), then the model's primary usage follows it. The old + * count guard miscounted and dropped this; the new guard keeps it. The + * marker subtracts the summarization output (5): the generated summary is in + * the snapshot baseline (summaryTokens) AND the response tokenCount, so + * 300 − 5 = 295 keeps the client estimate from counting it twice. */ + const meta = buildMeta({ + snap: snapshot(80), + latestUsageIndex: 1, + usageEvents: [summarizationUsage, primary], + }); + expect(meta.contextUsage).toBeDefined(); + expect(meta.summaryUsedTokens).toBe(295); + }); + + it('still emits the summary marker when the final call emitted no usage', () => { + /** Interrupted summarized turn: no primary usage follows the latest snapshot, + * so the snapshot is (correctly) not persisted — but the coarse marker + * survives so the client estimate still caps the discarded history. The + * summarization output (5) is subtracted (300 − 5 = 295). */ + const meta = buildMeta({ + snap: snapshot(80), + latestUsageIndex: 1, + usageEvents: [summarizationUsage], + }); + expect(meta.contextUsage).toBeUndefined(); + expect(meta.summaryUsedTokens).toBe(295); + }); + + it('drops the snapshot and emits no marker when the final call had no usage and no summary', () => { + const meta = buildMeta({ snap: snapshot(0), latestUsageIndex: 1, usageEvents: [primary] }); + expect(meta.contextUsage).toBeUndefined(); + expect(meta.summaryUsedTokens).toBeUndefined(); + }); + + it('does not persist the snapshot when only a parallel run produced post-snapshot usage', () => { + /** A snapshot (run-1) → B snapshot (run-1 is latest) but the only following + * usage belongs to a sibling run (run-2). The guard must NOT persist run-1's + * snapshot with run-2's output — it falls back to the per-message estimate. */ + const meta = buildMeta({ + snap: snapshot(0), + latestUsageIndex: 0, + usageEvents: [primaryFor('run-2', 99)], + }); + expect(meta.contextUsage).toBeUndefined(); + }); + + it('persists with the snapshot run output when its own primary usage follows', () => { + const meta = buildMeta({ + snap: snapshot(0), + latestUsageIndex: 0, + usageEvents: [primaryFor('run-2', 99), primaryFor('run-1', 7)], + }); + expect(meta.contextUsage).toBeDefined(); + expect(meta.contextUsage.completedOutputTokens).toBe(7); + }); + + it('subtracts earlier tool-loop output from the summary marker (interrupted turn)', () => { + /** Multi-call summarized turn stopped before the final usage: the earlier + * call (output 40) is baked into baseUsed (300), so the marker is 300 − 40 = + * 260. No primary follows the snapshot, so the full snapshot is not persisted + * and the client uses this marker — which must not double-count the 40 that + * the response tokenCount also carries. */ + const meta = buildMeta({ + snap: snapshot(80), + latestUsageIndex: 1, + usageEvents: [primaryFor('run-1', 40)], + }); + expect(meta.contextUsage).toBeUndefined(); + expect(meta.summaryUsedTokens).toBe(260); + }); + + it('subtracts only this run’s earlier output, not a parallel run’s', () => { + const meta = buildMeta({ + snap: snapshot(80), + latestUsageIndex: 2, + usageEvents: [primaryFor('run-2', 999), primaryFor('run-1', 40), primaryFor('run-1', 5)], + }); + /** baseUsed 300 − run-1's earlier 40 = 260; run-2's 999 is ignored. */ + expect(meta.summaryUsedTokens).toBe(260); + /** run-1's own primary follows the snapshot → snapshot persisted with output 5. */ + expect(meta.contextUsage.completedOutputTokens).toBe(5); + }); +}); diff --git a/api/server/controllers/agents/__tests__/modelEndHandler.spec.js b/api/server/controllers/agents/__tests__/modelEndHandler.spec.js index fdd2c88b6c8..07c55e7a547 100644 --- a/api/server/controllers/agents/__tests__/modelEndHandler.spec.js +++ b/api/server/controllers/agents/__tests__/modelEndHandler.spec.js @@ -150,6 +150,45 @@ describe('ModelEndHandler — Vertex thoughtSignature capture (issue #13006 foll expect(collectedThoughtSignatures).toEqual({}); }); + it('tags the producing agent on collected + emitted usage for per-endpoint pricing', async () => { + const collectedUsage = []; + const emitUsage = jest.fn(); + const handler = new ModelEndHandler(collectedUsage, null, emitUsage); + const graph = { + getAgentContext: () => ({ + provider: 'openai', + agentId: 'agent_sub', + clientOptions: { model: 'gpt-4' }, + }), + }; + + await handler.handle( + 'on_chat_model_end', + { output: { usage_metadata: { input_tokens: 10, output_tokens: 5, total_tokens: 15 } } }, + { ls_model_name: 'gpt-4', run_id: 'r1', user_id: 'u1' }, + graph, + ); + + expect(collectedUsage[0].agentId).toBe('agent_sub'); + expect(emitUsage).toHaveBeenCalledWith(expect.objectContaining({ agentId: 'agent_sub' })); + }); + + it('leaves usage untagged when the graph context has no agentId (single-endpoint)', async () => { + const collectedUsage = []; + const emitUsage = jest.fn(); + const handler = new ModelEndHandler(collectedUsage, null, emitUsage); + + await handler.handle( + 'on_chat_model_end', + { output: { usage_metadata: { input_tokens: 10, output_tokens: 5, total_tokens: 15 } } }, + { ls_model_name: 'gemini-3.1-flash-lite-preview', run_id: 'r1', user_id: 'u1' }, + buildGraph(), + ); + + expect(collectedUsage[0].agentId).toBeUndefined(); + expect(emitUsage).toHaveBeenCalledWith(expect.objectContaining({ agentId: undefined })); + }); + it('throws when collectedUsage is not an array (existing contract)', () => { expect(() => new ModelEndHandler(null)).toThrow('collectedUsage must be an array'); }); diff --git a/api/server/controllers/agents/__tests__/openai.spec.js b/api/server/controllers/agents/__tests__/openai.spec.js index 7638fc2e35d..8b6910fe35b 100644 --- a/api/server/controllers/agents/__tests__/openai.spec.js +++ b/api/server/controllers/agents/__tests__/openai.spec.js @@ -11,6 +11,50 @@ const mockRecordCollectedUsage = jest .mockResolvedValue({ input_tokens: 100, output_tokens: 50 }); const mockGetBalanceConfig = jest.fn().mockReturnValue({ enabled: true }); const mockGetTransactionsConfig = jest.fn().mockReturnValue({ enabled: true }); +const mockBuildSkillPrimedIdsByName = jest.fn((manualSkillPrimes, alwaysApplySkillPrimes) => { + const primed = {}; + for (const skill of alwaysApplySkillPrimes ?? []) { + primed[skill.name] = skill._id.toString(); + } + for (const skill of manualSkillPrimes ?? []) { + primed[skill.name] = skill._id.toString(); + } + return Object.keys(primed).length > 0 ? primed : undefined; +}); +const mockEnrichWithSkillConfigurable = jest.fn((result) => result); +const mockBuildAgentToolContext = jest.fn(({ agent, config }) => ({ + agent, + toolRegistry: config.toolRegistry, + userMCPAuthMap: config.userMCPAuthMap, + tool_resources: config.tool_resources, + actionsEnabled: config.actionsEnabled, + accessibleSkillIds: config.accessibleSkillIds, + activeSkillNames: config.activeSkillNames, + codeEnvAvailable: config.codeEnvAvailable, + skillAuthoringAvailable: config.skillAuthoringAvailable, + fileAuthoringToolNames: config.fileAuthoringToolNames, + skillPrimedIdsByName: + mockBuildSkillPrimedIdsByName(config.manualSkillPrimes, config.alwaysApplySkillPrimes) ?? {}, +})); +const mockEnrichLoadedToolsWithAgentContext = jest.fn(({ result, req, ctx }) => + mockEnrichWithSkillConfigurable({ + result, + context: { + req, + accessibleSkillIds: ctx.accessibleSkillIds, + codeEnvAvailable: ctx.codeEnvAvailable === true, + skillPrimedIdsByName: ctx.skillPrimedIdsByName, + activeSkillNames: ctx.activeSkillNames, + skillAuthoringAvailable: ctx.skillAuthoringAvailable === true, + fileAuthoringToolNames: ctx.fileAuthoringToolNames, + }, + }), +); +const mockCanAuthorSkillFiles = jest.fn( + ({ scopedEditableSkillIds = [], skillCreateAllowed }) => + scopedEditableSkillIds.length > 0 || skillCreateAllowed === true, +); +const mockGetSkillToolDeps = jest.fn(() => ({})); jest.mock('nanoid', () => ({ nanoid: jest.fn(() => 'mock-nanoid-123'), @@ -61,6 +105,7 @@ jest.mock('@librechat/api', () => ({ createErrorResponse: jest.fn(), getTransactionsConfig: mockGetTransactionsConfig, recordCollectedUsage: mockRecordCollectedUsage, + createSubagentUsageSink: jest.fn().mockReturnValue(jest.fn()), extractManualSkills: jest.fn().mockReturnValue(undefined), injectSkillPrimes: jest.fn().mockReturnValue({ initialMessages: [], @@ -88,6 +133,7 @@ jest.mock('@librechat/api', () => ({ resolveRecursionLimit: jest.fn().mockReturnValue(50), createToolExecuteHandler: jest.fn().mockReturnValue({ handle: jest.fn() }), isChatCompletionValidationFailure: jest.fn().mockReturnValue(false), + findPiiMatchInMessages: jest.fn().mockReturnValue(null), discoverConnectedAgents: jest.fn().mockResolvedValue({ agentConfigs: new Map(), edges: [], @@ -104,6 +150,17 @@ jest.mock('~/server/services/Files/permissions', () => ({ filterFilesByAgentAccess: jest.fn(), })); +jest.mock('~/server/services/Endpoints/agents/skillDeps', () => ({ + getSkillToolDeps: mockGetSkillToolDeps, + getSkillDbMethods: jest.fn(() => ({})), + canAuthorSkillFiles: mockCanAuthorSkillFiles, + withDeploymentSkillIds: jest.fn((ids = []) => ids), + enrichWithSkillConfigurable: mockEnrichWithSkillConfigurable, + buildSkillPrimedIdsByName: mockBuildSkillPrimedIdsByName, + buildAgentToolContext: mockBuildAgentToolContext, + enrichLoadedToolsWithAgentContext: mockEnrichLoadedToolsWithAgentContext, +})); + jest.mock('~/cache', () => ({ logViolation: jest.fn(), })); @@ -368,4 +425,87 @@ describe('OpenAIChatCompletionController', () => { expect(resolveRecursionLimit).toHaveBeenCalledWith(req.config.endpoints.agents, mockAgent); }); }); + + describe('sub-agent skill priming', () => { + it('passes the sub-agent primed skill IDs into tool execution', async () => { + const { + initializeAgent, + discoverConnectedAgents, + createToolExecuteHandler, + } = require('@librechat/api'); + const { loadToolsForExecution } = require('~/server/services/ToolService'); + const subAgent = { id: 'agent-sub', name: 'Sub Agent' }; + const subConfig = { + id: 'agent-sub', + model: 'gpt-4', + model_parameters: {}, + toolRegistry: new Map(), + userMCPAuthMap: { sub: { token: 'sub-token' } }, + tool_resources: { code_interpreter: { file_ids: ['sub-file'] } }, + actionsEnabled: true, + accessibleSkillIds: ['sub-skill-id'], + activeSkillNames: ['sub-hidden-skill'], + codeEnvAvailable: true, + skillAuthoringAvailable: true, + fileAuthoringToolNames: ['create_file', 'edit_file'], + manualSkillPrimes: [{ name: 'sub-hidden-skill', _id: { toString: () => 'sub-manual-id' } }], + alwaysApplySkillPrimes: [ + { name: 'sub-always-skill', _id: { toString: () => 'sub-always-id' } }, + ], + }; + + initializeAgent.mockResolvedValueOnce({ + id: 'agent-123', + model: 'gpt-4', + model_parameters: {}, + toolRegistry: new Map(), + edges: [{ source: 'agent-123', target: 'agent-sub' }], + accessibleSkillIds: ['primary-skill-id'], + activeSkillNames: ['primary-skill'], + codeEnvAvailable: false, + skillAuthoringAvailable: false, + fileAuthoringToolNames: [], + manualSkillPrimes: [{ name: 'primary-skill', _id: { toString: () => 'primary-skill-id' } }], + }); + discoverConnectedAgents.mockImplementationOnce(async (_params, deps) => { + deps.onAgentInitialized('agent-sub', subAgent, subConfig); + return { + agentConfigs: new Map([['agent-sub', subConfig]]), + edges: [], + skippedAgentIds: new Set(), + userMCPAuthMap: undefined, + }; + }); + + await OpenAIChatCompletionController(req, res); + + const toolExecuteOptions = createToolExecuteHandler.mock.calls.at(-1)[0]; + await toolExecuteOptions.loadTools(['read_file'], 'agent-sub'); + + expect(loadToolsForExecution).toHaveBeenLastCalledWith( + expect.objectContaining({ + agent: subAgent, + toolRegistry: subConfig.toolRegistry, + userMCPAuthMap: subConfig.userMCPAuthMap, + tool_resources: subConfig.tool_resources, + actionsEnabled: true, + }), + ); + expect(mockEnrichWithSkillConfigurable).toHaveBeenLastCalledWith({ + result: expect.anything(), + context: { + req, + accessibleSkillIds: ['sub-skill-id'], + codeEnvAvailable: true, + skillPrimedIdsByName: { + 'sub-always-skill': 'sub-always-id', + 'sub-hidden-skill': 'sub-manual-id', + }, + activeSkillNames: ['sub-hidden-skill'], + skillAuthoringAvailable: true, + fileAuthoringToolNames: ['create_file', 'edit_file'], + }, + }); + }); + }); }); diff --git a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js new file mode 100644 index 00000000000..de07b4ab542 --- /dev/null +++ b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js @@ -0,0 +1,612 @@ +const { EventEmitter } = require('events'); + +const mockLogger = { + debug: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + info: jest.fn(), +}; + +const mockGenerationJobManager = { + createJob: jest.fn(), + emitError: jest.fn(), + completeJob: jest.fn(), + getResumeState: jest.fn(), + updateMetadata: jest.fn(), +}; + +const mockCheckAndIncrementPendingRequest = jest.fn(); +const mockDecrementPendingRequest = jest.fn(); +const mockFilterPersistableAbortContent = jest.fn((content) => + content.filter((part) => part?.type !== 'tool_call'), +); +const mockGetConvo = jest.fn(); +const mockGetMessages = jest.fn(); +const mockSaveMessage = jest.fn(); +let mockMCPContexts = new WeakMap(); + +const mockCreateMCPRequestContext = jest.fn(() => ({ + connections: new Map(), + pending: new Map(), + cleanupStarted: false, + cleanupOnResponse: false, + responseCleanupAttached: false, +})); +const mockGetMCPRequestContext = jest.fn((req) => { + if (!req) { + return undefined; + } + + let context = mockMCPContexts.get(req); + if (!context) { + context = mockCreateMCPRequestContext(); + mockMCPContexts.set(req, context); + } + + return context.cleanupStarted ? undefined : context; +}); +const mockCleanupMCPRequestContext = jest.fn(async (context) => { + if (!context || context.cleanupStarted) { + return; + } + + context.cleanupStarted = true; + const connections = new Set(context.connections.values()); + const settled = await Promise.allSettled(context.pending.values()); + for (const result of settled) { + if (result.status === 'fulfilled' && result.value) { + connections.add(result.value); + } + } + + await Promise.allSettled(Array.from(connections).map((connection) => connection.disconnect?.())); + context.connections.clear(); + context.pending.clear(); +}); +const mockCleanupMCPRequestContextForReq = jest.fn(async (req) => { + const context = mockMCPContexts.get(req); + if (!context) { + return; + } + + try { + await mockCleanupMCPRequestContext(context); + } finally { + mockMCPContexts.delete(req); + } +}); + +jest.mock('@librechat/data-schemas', () => ({ + logger: mockLogger, +})); + +jest.mock('@librechat/api', () => ({ + sendEvent: jest.fn(), + getViolationInfo: jest.fn(), + buildMessageFiles: jest.fn(() => []), + resolveTitleTiming: jest.fn(() => 'immediate'), + GenerationJobManager: mockGenerationJobManager, + cleanupMCPRequestContext: (...args) => mockCleanupMCPRequestContext(...args), + createMCPRequestContext: (...args) => mockCreateMCPRequestContext(...args), + getMCPRequestContext: (...args) => mockGetMCPRequestContext(...args), + filterPersistableAbortContent: (...args) => mockFilterPersistableAbortContent(...args), + cleanupMCPRequestContextForReq: (...args) => mockCleanupMCPRequestContextForReq(...args), + decrementPendingRequest: (...args) => mockDecrementPendingRequest(...args), + sanitizeMessageForTransmit: jest.fn((message) => message), + checkAndIncrementPendingRequest: (...args) => mockCheckAndIncrementPendingRequest(...args), + isUnpersistedPreliminaryParent: async ({ + userId, + conversationId, + parentMessageId, + getMessages, + }) => { + if (typeof parentMessageId !== 'string' || !parentMessageId.endsWith('_')) { + return false; + } + + const filter = { user: userId, messageId: parentMessageId }; + if (conversationId && conversationId !== 'new') { + filter.conversationId = conversationId; + } + + const messages = await getMessages(filter, '_id'); + return messages.length === 0; + }, +})); + +jest.mock('~/server/cleanup', () => ({ + disposeClient: jest.fn(), + clientRegistry: null, + requestDataMap: { + set: jest.fn(), + }, +})); + +jest.mock('~/server/middleware', () => ({ + handleAbortError: jest.fn(() => Promise.resolve()), +})); + +jest.mock('~/cache', () => ({ + logViolation: jest.fn(), +})); + +jest.mock('~/models', () => ({ + saveMessage: (...args) => mockSaveMessage(...args), + getMessages: (...args) => mockGetMessages(...args), + getConvo: (...args) => mockGetConvo(...args), +})); + +const AgentController = require('../request'); +const { getMCPRequestContext } = require('~/server/services/MCPRequestContext'); + +function createResumableResponse() { + const res = new EventEmitter(); + res.headersSent = false; + res.writableEnded = false; + res.finished = false; + res.destroyed = false; + res.json = jest.fn(() => { + res.headersSent = true; + res.writableEnded = true; + res.finished = true; + res.emit('finish'); + return res; + }); + res.status = jest.fn(() => res); + return res; +} + +function nextTick() { + return new Promise((resolve) => setImmediate(resolve)); +} + +describe('ResumableAgentController resume metadata', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockMCPContexts = new WeakMap(); + mockCheckAndIncrementPendingRequest.mockResolvedValue({ allowed: true }); + mockDecrementPendingRequest.mockResolvedValue(undefined); + mockGetConvo.mockResolvedValue({ createdAt: '2026-06-07T00:00:00.000Z' }); + mockGetMessages.mockResolvedValue([]); + mockGenerationJobManager.createJob.mockResolvedValue({ + createdAt: 1000, + readyPromise: Promise.resolve(), + abortController: new AbortController(), + emitter: { on: jest.fn() }, + }); + mockGenerationJobManager.getResumeState.mockResolvedValue(null); + mockGenerationJobManager.updateMetadata.mockResolvedValue(undefined); + mockGenerationJobManager.emitError.mockResolvedValue(undefined); + mockSaveMessage.mockResolvedValue({}); + }); + + it('rejects an underscore-suffixed parent that is not persisted', async () => { + const conversationId = 'conversation-123'; + const initializeClient = jest.fn(); + const req = { + user: { id: 'user-123' }, + body: { + text: 'Follow up too early.', + messageId: 'follow-up-user', + parentMessageId: 'pending-response_', + conversationId, + endpointOption: { + endpoint: 'agents', + modelOptions: { model: 'gpt-3.5-turbo' }, + }, + }, + config: {}, + }; + const res = { + json: jest.fn(), + status: jest.fn(() => res), + }; + + await AgentController(req, res, jest.fn(), initializeClient, null); + + expect(mockGetMessages).toHaveBeenCalledWith( + { user: 'user-123', messageId: 'pending-response_', conversationId }, + '_id', + ); + expect(res.status).toHaveBeenCalledWith(409); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + error: expect.stringContaining('selected parent response is still being saved'), + }), + ); + expect(mockCheckAndIncrementPendingRequest).not.toHaveBeenCalled(); + expect(mockGenerationJobManager.createJob).not.toHaveBeenCalled(); + expect(initializeClient).not.toHaveBeenCalled(); + }); + + it('allows an underscore-suffixed parent when it is already persisted', async () => { + const conversationId = 'conversation-123'; + mockGetMessages.mockResolvedValue([{ _id: 'persisted-parent' }]); + const initializeClient = jest.fn().mockRejectedValue(new Error('stop before tool loading')); + const req = { + user: { id: 'user-123' }, + body: { + text: 'Follow up to persisted underscore id.', + messageId: 'follow-up-user', + parentMessageId: 'persisted-response_', + conversationId, + endpointOption: { + endpoint: 'agents', + modelOptions: { model: 'gpt-3.5-turbo' }, + }, + }, + config: {}, + }; + const res = { + headersSent: true, + json: jest.fn(() => { + res.headersSent = true; + }), + status: jest.fn(() => res), + }; + + await AgentController(req, res, jest.fn(), initializeClient, null); + + expect(mockGetMessages).toHaveBeenCalledWith( + { user: 'user-123', messageId: 'persisted-response_', conversationId }, + '_id', + ); + expect(res.status).not.toHaveBeenCalledWith(409); + expect(mockCheckAndIncrementPendingRequest).toHaveBeenCalledWith('user-123'); + expect(mockGenerationJobManager.createJob).toHaveBeenCalledWith( + conversationId, + 'user-123', + conversationId, + ); + }); + + it('stores the in-flight turn before MCP initialization can emit OAuth', async () => { + const conversationId = 'conversation-123'; + const initializeClient = jest.fn().mockRejectedValue(new Error('stop before tool loading')); + const req = { + user: { id: 'user-123' }, + body: { + text: 'Check Google Workspace availability.', + messageId: 'follow-up-user', + parentMessageId: 'original-response', + conversationId, + endpointOption: { + endpoint: 'agents', + iconURL: 'https://example.com/spec-icon.png', + modelOptions: { model: 'gpt-3.5-turbo' }, + }, + }, + config: {}, + }; + const res = { + headersSent: true, + json: jest.fn(() => { + res.headersSent = true; + }), + status: jest.fn(() => res), + }; + + await AgentController(req, res, jest.fn(), initializeClient, null); + + expect(mockGenerationJobManager.updateMetadata).toHaveBeenCalledWith( + conversationId, + expect.objectContaining({ + conversationId, + endpoint: 'agents', + iconURL: 'https://example.com/spec-icon.png', + model: 'gpt-3.5-turbo', + responseMessageId: 'follow-up-user_', + userMessage: { + messageId: 'follow-up-user', + parentMessageId: 'original-response', + conversationId, + text: 'Check Google Workspace availability.', + }, + }), + ); + expect(mockGenerationJobManager.updateMetadata.mock.invocationCallOrder[0]).toBeLessThan( + initializeClient.mock.invocationCallOrder[0], + ); + }); + + it('keeps request-scoped MCP connections until resumable initialization finishes', async () => { + const conversationId = 'conversation-123'; + const disconnect = jest.fn().mockResolvedValue(undefined); + const initializeClient = jest.fn(async ({ req, res }) => { + const context = getMCPRequestContext(req, res); + context.connections.set('mcp-server', { disconnect }); + + await nextTick(); + expect(disconnect).not.toHaveBeenCalled(); + + throw new Error('stop after request-scoped MCP connection'); + }); + const req = { + user: { id: 'user-123' }, + body: { + text: 'Use a BODY-scoped MCP server.', + messageId: 'user-message', + parentMessageId: 'parent-message', + conversationId, + endpointOption: { + endpoint: 'agents', + modelOptions: { model: 'gpt-4.1' }, + }, + }, + config: {}, + }; + const res = createResumableResponse(); + + await AgentController(req, res, jest.fn(), initializeClient, null); + + expect(res.json).toHaveBeenCalledWith({ + streamId: conversationId, + conversationId, + status: 'started', + }); + expect(disconnect).toHaveBeenCalledTimes(1); + expect(disconnect.mock.invocationCallOrder[0]).toBeLessThan( + mockDecrementPendingRequest.mock.invocationCallOrder[0], + ); + }); + + it('stores model spec icon fallbacks and agent ids in early resume metadata', async () => { + const conversationId = 'conversation-123'; + const initializeClient = jest.fn().mockRejectedValue(new Error('stop before tool loading')); + const req = { + user: { id: 'user-123' }, + body: { + text: 'Use the resume spec.', + messageId: 'follow-up-user', + parentMessageId: 'original-response', + conversationId, + endpointOption: { + endpoint: 'agents', + spec: 'agent-spec', + agent_id: 'agent_resume_spec', + model_parameters: { model: 'gpt-4.1' }, + }, + }, + config: { + modelSpecs: { + list: [ + { + name: 'agent-spec', + preset: { + endpoint: 'openAI', + iconURL: 'https://example.com/preset-icon.png', + }, + }, + ], + }, + }, + }; + const res = { + headersSent: true, + json: jest.fn(() => { + res.headersSent = true; + }), + status: jest.fn(() => res), + }; + + await AgentController(req, res, jest.fn(), initializeClient, null); + + expect(mockGenerationJobManager.updateMetadata).toHaveBeenCalledWith( + conversationId, + expect.objectContaining({ + iconURL: 'https://example.com/preset-icon.png', + model: 'agent_resume_spec', + }), + ); + }); + + it('falls back to the model spec preset endpoint when no icon URL is configured', async () => { + const conversationId = 'conversation-123'; + const initializeClient = jest.fn().mockRejectedValue(new Error('stop before tool loading')); + const req = { + user: { id: 'user-123' }, + body: { + text: 'Use the endpoint icon.', + messageId: 'follow-up-user', + parentMessageId: 'original-response', + conversationId, + endpointOption: { + endpoint: 'agents', + spec: 'endpoint-icon-spec', + model_parameters: { model: 'gpt-4.1' }, + }, + }, + config: { + modelSpecs: { + list: [ + { + name: 'endpoint-icon-spec', + preset: { + endpoint: 'anthropic', + }, + }, + ], + }, + }, + }; + const res = { + headersSent: true, + json: jest.fn(() => { + res.headersSent = true; + }), + status: jest.fn(() => res), + }; + + await AgentController(req, res, jest.fn(), initializeClient, null); + + expect(mockGenerationJobManager.updateMetadata).toHaveBeenCalledWith( + conversationId, + expect.objectContaining({ + iconURL: 'anthropic', + model: 'gpt-4.1', + }), + ); + }); + + it('filters OAuth prompts before saving partial responses on disconnect', async () => { + const conversationId = 'conversation-123'; + let allSubscribersLeftHandler; + mockGenerationJobManager.createJob.mockResolvedValue({ + createdAt: 1000, + readyPromise: Promise.resolve(), + abortController: new AbortController(), + emitter: { + on: jest.fn((event, handler) => { + if (event === 'allSubscribersLeft') { + allSubscribersLeftHandler = handler; + } + }), + }, + }); + mockGenerationJobManager.getResumeState.mockResolvedValue({ + conversationId, + responseMessageId: 'response-message', + iconURL: 'https://example.com/spec-icon.png', + model: 'gpt-4.1', + userMessage: { + messageId: 'user-message', + parentMessageId: 'parent-message', + conversationId, + text: 'Use Google Workspace', + }, + }); + + const initializeClient = jest.fn().mockRejectedValue(new Error('stop after setup')); + const req = { + user: { id: 'user-123' }, + body: { + text: 'Use Google Workspace', + messageId: 'user-message', + parentMessageId: 'parent-message', + conversationId, + endpointOption: { + endpoint: 'agents', + iconURL: 'https://example.com/fallback-icon.png', + modelOptions: { model: 'gpt-3.5-turbo' }, + }, + }, + config: {}, + }; + const res = { + headersSent: true, + json: jest.fn(() => { + res.headersSent = true; + }), + status: jest.fn(() => res), + }; + + await AgentController(req, res, jest.fn(), initializeClient, null); + expect(allSubscribersLeftHandler).toEqual(expect.any(Function)); + + const oauthPart = { + type: 'tool_call', + tool_call: { + name: 'oauth_mcp_Google-Workspace', + auth: 'https://auth.example.com/oauth', + }, + }; + const textPart = { type: 'text', text: 'Partial response...' }; + + await allSubscribersLeftHandler([oauthPart, textPart]); + + expect(mockFilterPersistableAbortContent).toHaveBeenCalledWith([oauthPart, textPart]); + expect(mockSaveMessage).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-123' }), + expect.objectContaining({ + content: [textPart], + iconURL: 'https://example.com/spec-icon.png', + model: 'gpt-4.1', + messageId: 'response-message', + parentMessageId: 'user-message', + }), + expect.any(Object), + ); + }); + + it('uses model spec and agent fallbacks when saving partial responses on disconnect', async () => { + const conversationId = 'conversation-123'; + let allSubscribersLeftHandler; + mockGenerationJobManager.createJob.mockResolvedValue({ + createdAt: 1000, + readyPromise: Promise.resolve(), + abortController: new AbortController(), + emitter: { + on: jest.fn((event, handler) => { + if (event === 'allSubscribersLeft') { + allSubscribersLeftHandler = handler; + } + }), + }, + }); + mockGenerationJobManager.getResumeState.mockResolvedValue({ + conversationId, + responseMessageId: 'response-message', + userMessage: { + messageId: 'user-message', + parentMessageId: 'parent-message', + conversationId, + text: 'Use fallback metadata', + }, + }); + + const initializeClient = jest.fn().mockRejectedValue(new Error('stop after setup')); + const req = { + user: { id: 'user-123' }, + body: { + text: 'Use fallback metadata', + messageId: 'user-message', + parentMessageId: 'parent-message', + conversationId, + endpointOption: { + endpoint: 'agents', + spec: 'agent-spec', + agent_id: 'agent_resume_spec', + model_parameters: { model: 'gpt-4.1' }, + }, + }, + config: { + modelSpecs: { + list: [ + { + name: 'agent-spec', + preset: { + endpoint: 'openAI', + iconURL: 'https://example.com/preset-icon.png', + }, + }, + ], + }, + }, + }; + const res = { + headersSent: true, + json: jest.fn(() => { + res.headersSent = true; + }), + status: jest.fn(() => res), + }; + + await AgentController(req, res, jest.fn(), initializeClient, null); + expect(allSubscribersLeftHandler).toEqual(expect.any(Function)); + + const textPart = { type: 'text', text: 'Partial response...' }; + await allSubscribersLeftHandler([textPart]); + + expect(mockSaveMessage).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-123' }), + expect.objectContaining({ + content: [textPart], + iconURL: 'https://example.com/preset-icon.png', + model: 'agent_resume_spec', + messageId: 'response-message', + parentMessageId: 'user-message', + }), + expect.any(Object), + ); + }); +}); diff --git a/api/server/controllers/agents/__tests__/responses.unit.spec.js b/api/server/controllers/agents/__tests__/responses.unit.spec.js index e5569fbbf57..3e980a057e1 100644 --- a/api/server/controllers/agents/__tests__/responses.unit.spec.js +++ b/api/server/controllers/agents/__tests__/responses.unit.spec.js @@ -10,6 +10,50 @@ const mockRecordCollectedUsage = jest .mockResolvedValue({ input_tokens: 100, output_tokens: 50 }); const mockGetBalanceConfig = jest.fn().mockReturnValue({ enabled: true }); const mockGetTransactionsConfig = jest.fn().mockReturnValue({ enabled: true }); +const mockBuildSkillPrimedIdsByName = jest.fn((manualSkillPrimes, alwaysApplySkillPrimes) => { + const primed = {}; + for (const skill of alwaysApplySkillPrimes ?? []) { + primed[skill.name] = skill._id.toString(); + } + for (const skill of manualSkillPrimes ?? []) { + primed[skill.name] = skill._id.toString(); + } + return Object.keys(primed).length > 0 ? primed : undefined; +}); +const mockEnrichWithSkillConfigurable = jest.fn((result) => result); +const mockBuildAgentToolContext = jest.fn(({ agent, config }) => ({ + agent, + toolRegistry: config.toolRegistry, + userMCPAuthMap: config.userMCPAuthMap, + tool_resources: config.tool_resources, + actionsEnabled: config.actionsEnabled, + accessibleSkillIds: config.accessibleSkillIds, + activeSkillNames: config.activeSkillNames, + codeEnvAvailable: config.codeEnvAvailable, + skillAuthoringAvailable: config.skillAuthoringAvailable, + fileAuthoringToolNames: config.fileAuthoringToolNames, + skillPrimedIdsByName: + mockBuildSkillPrimedIdsByName(config.manualSkillPrimes, config.alwaysApplySkillPrimes) ?? {}, +})); +const mockEnrichLoadedToolsWithAgentContext = jest.fn(({ result, req, ctx }) => + mockEnrichWithSkillConfigurable({ + result, + context: { + req, + accessibleSkillIds: ctx.accessibleSkillIds, + codeEnvAvailable: ctx.codeEnvAvailable === true, + skillPrimedIdsByName: ctx.skillPrimedIdsByName, + activeSkillNames: ctx.activeSkillNames, + skillAuthoringAvailable: ctx.skillAuthoringAvailable === true, + fileAuthoringToolNames: ctx.fileAuthoringToolNames, + }, + }), +); +const mockCanAuthorSkillFiles = jest.fn( + ({ scopedEditableSkillIds = [], skillCreateAllowed }) => + scopedEditableSkillIds.length > 0 || skillCreateAllowed === true, +); +const mockGetSkillToolDeps = jest.fn(() => ({})); jest.mock('nanoid', () => ({ nanoid: jest.fn(() => 'mock-nanoid-123'), @@ -63,6 +107,7 @@ jest.mock('@librechat/api', () => ({ getBalanceConfig: mockGetBalanceConfig, getTransactionsConfig: mockGetTransactionsConfig, recordCollectedUsage: mockRecordCollectedUsage, + createSubagentUsageSink: jest.fn().mockReturnValue(jest.fn()), extractManualSkills: jest.fn().mockReturnValue(undefined), injectSkillPrimes: jest.fn().mockReturnValue({ initialMessages: [], @@ -78,6 +123,7 @@ jest.mock('@librechat/api', () => ({ buildResponse: jest.fn().mockReturnValue({ id: 'resp_123', output: [] }), generateResponseId: jest.fn().mockReturnValue('resp_mock-123'), isValidationFailure: jest.fn().mockReturnValue(false), + findPiiMatchInMessages: jest.fn().mockReturnValue(null), emitResponseCreated: jest.fn(), createResponseContext: jest.fn().mockReturnValue({ responseId: 'resp_123' }), createResponseTracker: jest.fn().mockReturnValue({ @@ -154,6 +200,17 @@ jest.mock('~/server/services/Files/permissions', () => ({ filterFilesByAgentAccess: jest.fn(), })); +jest.mock('~/server/services/Endpoints/agents/skillDeps', () => ({ + getSkillToolDeps: mockGetSkillToolDeps, + getSkillDbMethods: jest.fn(() => ({})), + canAuthorSkillFiles: mockCanAuthorSkillFiles, + withDeploymentSkillIds: jest.fn((ids = []) => ids), + enrichWithSkillConfigurable: mockEnrichWithSkillConfigurable, + buildSkillPrimedIdsByName: mockBuildSkillPrimedIdsByName, + buildAgentToolContext: mockBuildAgentToolContext, + enrichLoadedToolsWithAgentContext: mockEnrichLoadedToolsWithAgentContext, +})); + jest.mock('~/cache', () => ({ logViolation: jest.fn(), })); @@ -458,4 +515,87 @@ describe('createResponse controller', () => { ); }); }); + + describe('sub-agent skill priming', () => { + it('passes the sub-agent primed skill IDs into non-streaming tool execution', async () => { + const { + initializeAgent, + discoverConnectedAgents, + createToolExecuteHandler, + } = require('@librechat/api'); + const { loadToolsForExecution } = require('~/server/services/ToolService'); + const subAgent = { id: 'agent-sub', name: 'Sub Agent' }; + const subConfig = { + id: 'agent-sub', + model: 'claude-3', + model_parameters: {}, + toolRegistry: new Map(), + userMCPAuthMap: { sub: { token: 'sub-token' } }, + tool_resources: { code_interpreter: { file_ids: ['sub-file'] } }, + actionsEnabled: true, + accessibleSkillIds: ['sub-skill-id'], + activeSkillNames: ['sub-hidden-skill'], + codeEnvAvailable: true, + skillAuthoringAvailable: true, + fileAuthoringToolNames: ['create_file', 'edit_file'], + manualSkillPrimes: [{ name: 'sub-hidden-skill', _id: { toString: () => 'sub-manual-id' } }], + alwaysApplySkillPrimes: [ + { name: 'sub-always-skill', _id: { toString: () => 'sub-always-id' } }, + ], + }; + + initializeAgent.mockResolvedValueOnce({ + id: 'agent-123', + model: 'claude-3', + model_parameters: {}, + toolRegistry: new Map(), + edges: [{ source: 'agent-123', target: 'agent-sub' }], + accessibleSkillIds: ['primary-skill-id'], + activeSkillNames: ['primary-skill'], + codeEnvAvailable: false, + skillAuthoringAvailable: false, + fileAuthoringToolNames: [], + manualSkillPrimes: [{ name: 'primary-skill', _id: { toString: () => 'primary-skill-id' } }], + }); + discoverConnectedAgents.mockImplementationOnce(async (_params, deps) => { + deps.onAgentInitialized('agent-sub', subAgent, subConfig); + return { + agentConfigs: new Map([['agent-sub', subConfig]]), + edges: [], + skippedAgentIds: new Set(), + userMCPAuthMap: undefined, + }; + }); + + await createResponse(req, res); + + const toolExecuteOptions = createToolExecuteHandler.mock.calls.at(-1)[0]; + await toolExecuteOptions.loadTools(['read_file'], 'agent-sub'); + + expect(loadToolsForExecution).toHaveBeenLastCalledWith( + expect.objectContaining({ + agent: subAgent, + toolRegistry: subConfig.toolRegistry, + userMCPAuthMap: subConfig.userMCPAuthMap, + tool_resources: subConfig.tool_resources, + actionsEnabled: true, + }), + ); + expect(mockEnrichWithSkillConfigurable).toHaveBeenLastCalledWith({ + result: expect.anything(), + context: { + req, + accessibleSkillIds: ['sub-skill-id'], + codeEnvAvailable: true, + skillPrimedIdsByName: { + 'sub-always-skill': 'sub-always-id', + 'sub-hidden-skill': 'sub-manual-id', + }, + activeSkillNames: ['sub-hidden-skill'], + skillAuthoringAvailable: true, + fileAuthoringToolNames: ['create_file', 'edit_file'], + }, + }); + }); + }); }); diff --git a/api/server/controllers/agents/__tests__/usageEvents.integration.spec.js b/api/server/controllers/agents/__tests__/usageEvents.integration.spec.js new file mode 100644 index 00000000000..caa14f6ea70 --- /dev/null +++ b/api/server/controllers/agents/__tests__/usageEvents.integration.spec.js @@ -0,0 +1,477 @@ +const { z } = require('zod'); +const { tool } = require('@langchain/core/tools'); +const { ChatGenerationChunk } = require('@langchain/core/outputs'); +const { HumanMessage, AIMessage, AIMessageChunk } = require('@langchain/core/messages'); +const { + Run, + Providers, + GraphEvents, + FakeChatModel, + createContentAggregator, +} = require('@librechat/agents'); +const { + GenerationJobManager, + aggregateEmittedUsage, + resolveAgentTokenConfig, + buildPersistedContextUsage, +} = require('@librechat/api'); +const { getDefaultHandlers } = require('~/server/controllers/agents/callbacks'); + +jest.mock('nanoid', () => ({ + nanoid: jest.fn(() => 'mock-nanoid'), +})); + +jest.mock('~/server/services/Files/Citations', () => ({ + processFileCitations: jest.fn(), +})); + +jest.mock('~/server/services/Files/Code/process', () => ({ + processCodeOutput: jest.fn(), + runPreviewFinalize: jest.fn(), +})); + +jest.mock('~/server/services/Files/process', () => ({ + saveBase64Image: jest.fn(), +})); + +/** Real pipeline guard: published lib versions without the event skip its assertions */ +const hasContextUsageEvent = GraphEvents.ON_CONTEXT_USAGE != null; + +/** + * FakeChatModel that attaches provider-style usage_metadata on a final + * empty chunk (the OpenAI streaming pattern), so CHAT_MODEL_END carries + * aggregated usage through the real @librechat/agents pipeline. + */ +class UsageFakeModel extends FakeChatModel { + constructor(options, usagePerCall) { + super(options); + this.usagePerCall = usagePerCall; + this.usageCallIndex = 0; + } + + async *_streamResponseChunks(messages, options, runManager) { + yield* super._streamResponseChunks(messages, options, runManager); + const index = Math.min(this.usageCallIndex, this.usagePerCall.length - 1); + this.usageCallIndex += 1; + yield new ChatGenerationChunk({ + text: '', + message: new AIMessageChunk({ content: '', usage_metadata: this.usagePerCall[index] }), + }); + } +} + +const addTool = tool(async ({ a, b }) => String(a + b), { + name: 'add', + description: 'Add two numbers', + schema: z.object({ a: z.number(), b: z.number() }), +}); + +const charCounter = (msg) => { + const content = msg.content; + if (typeof content === 'string') { + return content.length + 3; + } + if (Array.isArray(content)) { + let length = 3; + for (const part of content) { + if (typeof part === 'string') { + length += part.length; + } else if (typeof part?.text === 'string') { + length += part.text.length; + } + } + return length; + } + return 3; +}; + +function createMockRes() { + const events = []; + return { + events, + headersSent: true, + writableEnded: false, + write(payload) { + for (const line of String(payload).split('\n')) { + if (line.startsWith('data: ')) { + events.push(JSON.parse(line.slice(6))); + } + } + return true; + }, + }; +} + +const FIRST_CALL_USAGE = { + input_tokens: 100, + output_tokens: 20, + total_tokens: 120, +}; + +const SECOND_CALL_USAGE = { + input_tokens: 150, + output_tokens: 10, + total_tokens: 160, + input_token_details: { cache_creation: 30, cache_read: 50 }, +}; + +const MAX_CONTEXT_TOKENS = 8000; + +async function runToolLoop({ + res, + streamId = null, + collectedUsage, + contextUsageSink = null, + usageEmitSink = null, + usageCost = null, +}) { + const { contentParts, aggregateContent } = createContentAggregator(); + const handlers = getDefaultHandlers({ + res, + aggregateContent, + toolEndCallback: () => {}, + collectedUsage, + streamId, + contextUsageSink, + usageEmitSink, + usageCost, + }); + + const run = await Run.create({ + runId: 'usage-e2e-response', + graphConfig: { + type: 'standard', + llmConfig: { + provider: Providers.OPENAI, + model: 'gpt-4o-mini', + streaming: true, + streamUsage: false, + }, + instructions: 'You are a helpful assistant.', + maxContextTokens: MAX_CONTEXT_TOKENS, + tools: [addTool], + }, + returnContent: true, + customHandlers: handlers, + tokenCounter: charCounter, + indexTokenCountMap: {}, + }); + + run.Graph.overrideModel = new UsageFakeModel( + { + responses: ['Let me calculate that.', 'The answer is 4.'], + toolCalls: [{ name: 'add', args: { a: 2, b: 2 }, id: 'tc_1', type: 'tool_call' }], + }, + [FIRST_CALL_USAGE, SECOND_CALL_USAGE], + ); + + await run.processStream( + { messages: [new HumanMessage('What is 2+2?')] }, + { + configurable: { thread_id: 'usage-e2e-thread', user_id: 'user-1' }, + streamMode: 'values', + version: 'v2', + }, + ); + + return { run, contentParts }; +} + +describe('usage events through the real agents pipeline', () => { + jest.setTimeout(30000); + + afterAll(async () => { + await GenerationJobManager.destroy(); + }); + + test('emits on_token_usage per model call with collectedUsage parity', async () => { + const res = createMockRes(); + const collectedUsage = []; + const { contentParts } = await runToolLoop({ res, collectedUsage }); + + const usageEvents = res.events.filter((e) => e.event === 'on_token_usage'); + expect(usageEvents).toHaveLength(2); + + expect(usageEvents[0].data).toMatchObject(FIRST_CALL_USAGE); + expect(usageEvents[1].data).toMatchObject(SECOND_CALL_USAGE); + expect(usageEvents[0].data.provider).toBe(Providers.OPENAI); + expect(usageEvents[0].data.model).toBeTruthy(); + expect(usageEvents[0].data.usage_type).toBeUndefined(); + + expect(collectedUsage).toHaveLength(2); + expect(collectedUsage[0]).toMatchObject(FIRST_CALL_USAGE); + expect(collectedUsage[1]).toMatchObject(SECOND_CALL_USAGE); + + const text = contentParts + .filter((part) => part?.type === 'text') + .map((part) => part.text) + .join(''); + expect(text).toContain('The answer is 4.'); + }); + + test('emits a context snapshot before each model call', async () => { + if (!hasContextUsageEvent) { + console.warn('Skipping: installed @librechat/agents predates ON_CONTEXT_USAGE'); + return; + } + const res = createMockRes(); + const { run } = await runToolLoop({ res, collectedUsage: [] }); + expect(run).toBeDefined(); + + const contextEvents = res.events.filter((e) => e.event === 'on_context_usage'); + expect(contextEvents).toHaveLength(2); + + for (const event of contextEvents) { + const { breakdown, contextBudget, remainingContextTokens, effectiveInstructionTokens } = + event.data; + expect(breakdown.maxContextTokens).toBe(MAX_CONTEXT_TOKENS); + expect(contextBudget).toBeGreaterThan(0); + expect(contextBudget).toBeLessThanOrEqual(MAX_CONTEXT_TOKENS); + expect(effectiveInstructionTokens).toBeGreaterThan(0); + expect(remainingContextTokens).toBeGreaterThan(0); + expect(remainingContextTokens).toBeLessThan(contextBudget); + expect(breakdown.toolTokenCounts.add).toBeGreaterThan(0); + } + + /** Tool loop grows the context between calls */ + expect(contextEvents[1].data.prePruneContextTokens).toBeGreaterThan( + contextEvents[0].data.prePruneContextTokens, + ); + + /** Snapshot precedes the call's usage event */ + const firstContextIndex = res.events.findIndex((e) => e.event === 'on_context_usage'); + const firstUsageIndex = res.events.findIndex((e) => e.event === 'on_token_usage'); + expect(firstContextIndex).toBeGreaterThanOrEqual(0); + expect(firstContextIndex).toBeLessThan(firstUsageIndex); + }); + + test('captures the usage rollup + latest context snapshot for message persistence', () => { + const res = createMockRes(); + const contextUsageSink = { latest: null }; + const usageEmitSink = []; + return runToolLoop({ res, collectedUsage: [], contextUsageSink, usageEmitSink }).then(() => { + /** Both model calls' emitted payloads are captured for the rollup */ + expect(usageEmitSink).toHaveLength(2); + + const usage = aggregateEmittedUsage(usageEmitSink); + /** Display units: openAI is cache-subset, so input excludes cache + * (150−30−50=70); output is repaired completion */ + expect(usage).toEqual({ + input: + FIRST_CALL_USAGE.input_tokens + + (SECOND_CALL_USAGE.input_tokens - + SECOND_CALL_USAGE.input_token_details.cache_creation - + SECOND_CALL_USAGE.input_token_details.cache_read), + output: FIRST_CALL_USAGE.output_tokens + SECOND_CALL_USAGE.output_tokens, + cacheWrite: SECOND_CALL_USAGE.input_token_details.cache_creation, + cacheRead: SECOND_CALL_USAGE.input_token_details.cache_read, + }); + /** contextCost off → no cost folded into the rollup */ + expect(usage.cost).toBeUndefined(); + + if (hasContextUsageEvent) { + expect(contextUsageSink.latest).not.toBeNull(); + const persisted = buildPersistedContextUsage(contextUsageSink.latest); + expect(persisted.breakdown.maxContextTokens).toBe(MAX_CONTEXT_TOKENS); + /** Zero-valued tool counts are trimmed from the persisted blob */ + for (const count of Object.values(persisted.breakdown.toolTokenCounts ?? {})) { + expect(count).toBeGreaterThan(0); + } + } + }); + }); + + test('folds authoritative per-event cost into the rollup when contextCost is on', async () => { + const res = createMockRes(); + const usageEmitSink = []; + /** Stub pricing mirroring getMultiplier/getCacheMultiplier shape */ + const usageCost = { + enabled: true, + pricing: { + getMultiplier: ({ tokenType }) => (tokenType === 'completion' ? 15 : 3), + getCacheMultiplier: ({ cacheType }) => (cacheType === 'write' ? 3.75 : 0.3), + }, + }; + await runToolLoop({ res, collectedUsage: [], usageEmitSink, usageCost }); + + for (const event of usageEmitSink) { + expect(typeof event.cost).toBe('number'); + } + const usage = aggregateEmittedUsage(usageEmitSink); + expect(usage.cost).toBeGreaterThan(0); + expect(usage.cost).toBeCloseTo(usageEmitSink.reduce((sum, e) => sum + e.cost, 0)); + }); + + test('emit path prices each call by its producing agent and strips the agentId tag', () => { + const res = createMockRes(); + const usageEmitSink = []; + /** Two endpoints share a model id but bill at different rates. */ + const primaryConfig = { 'gpt-4': { prompt: 0.01, completion: 0.03, context: 8192 } }; + const subagentConfig = { 'gpt-4': { prompt: 0.05, completion: 0.15, context: 8192 } }; + const byAgentId = new Map([ + ['primary', primaryConfig], + ['sub', subagentConfig], + ]); + const usageCost = { + enabled: true, + endpointTokenConfig: primaryConfig, + pricing: { + getMultiplier: ({ tokenType, model, endpointTokenConfig }) => + endpointTokenConfig?.[model]?.[tokenType] ?? 0, + getCacheMultiplier: () => 0, + }, + resolveEndpointTokenConfig: (usage) => + resolveAgentTokenConfig({ agentId: usage?.agentId, byAgentId, fallback: primaryConfig }), + }; + + const { aggregateContent } = createContentAggregator(); + const handlers = getDefaultHandlers({ + res, + aggregateContent, + toolEndCallback: () => {}, + collectedUsage: [], + usageEmitSink, + usageCost, + }); + /** The CHAT_MODEL_END handler's emitUsage IS the real emitTokenUsage closure. */ + const emitUsage = handlers[GraphEvents.CHAT_MODEL_END].emitUsage; + const call = { model: 'gpt-4', input_tokens: 100, output_tokens: 50, total_tokens: 150 }; + emitUsage({ ...call, agentId: 'sub' }); + emitUsage({ ...call, agentId: 'primary' }); + + const events = res.events.filter((e) => e.event === 'on_token_usage'); + expect(events).toHaveLength(2); + /** agentId is an internal pricing tag — never streamed to the client nor + * folded into the persisted rollup. */ + for (const e of events) { + expect(e.data.agentId).toBeUndefined(); + } + for (const entry of usageEmitSink) { + expect(entry.agentId).toBeUndefined(); + } + /** Same tokens + model id, but the subagent endpoint's higher rates price + * its call above the primary — proving per-agent emit pricing. The 5× ratio + * ((100·0.05+50·0.15)/(100·0.01+50·0.03)) is scale-independent of credit units. */ + expect(events[1].data.cost).toBeGreaterThan(0); + expect(events[0].data.cost).toBeGreaterThan(events[1].data.cost); + expect(events[0].data.cost / events[1].data.cost).toBeCloseTo(5); + }); + + test('persists usage and context snapshot for resume via GenerationJobManager', async () => { + const streamId = `usage-e2e-stream-${Date.now()}`; + await GenerationJobManager.createJob(streamId, 'user-1', 'convo-1'); + + const res = createMockRes(); + await runToolLoop({ res, streamId, collectedUsage: [] }); + + const resumeState = await GenerationJobManager.getResumeState(streamId); + expect(resumeState).not.toBeNull(); + + expect(resumeState.collectedUsage).toHaveLength(2); + expect(resumeState.collectedUsage[0]).toMatchObject(FIRST_CALL_USAGE); + expect(resumeState.collectedUsage[1]).toMatchObject(SECOND_CALL_USAGE); + + if (hasContextUsageEvent) { + expect(resumeState.contextUsage.breakdown.maxContextTokens).toBe(MAX_CONTEXT_TOKENS); + /** Latest-wins: the persisted snapshot is the second call's */ + expect(resumeState.contextUsage.prePruneContextTokens).toBeGreaterThan(0); + /** Reconciled to the final primary call's actual prompt: openAI folds cache + * into input_tokens (150), so the resume snapshot's used = 150 — the real + * context, not the calibrated estimate. */ + const used = + resumeState.contextUsage.contextBudget - resumeState.contextUsage.remainingContextTokens; + expect(used).toBe(SECOND_CALL_USAGE.input_tokens); + } + }); + + /** Drives a real summarization (tight context + padded history); self-summarize + * reuses the overridden fake model so no API key is needed. */ + async function runSummarizationLoop({ res, collectedUsage, contextUsageSink, usageEmitSink }) { + const { aggregateContent } = createContentAggregator(); + const handlers = getDefaultHandlers({ + res, + aggregateContent, + toolEndCallback: () => {}, + collectedUsage, + contextUsageSink, + usageEmitSink, + summarizationOptions: { enabled: true }, + }); + + const pad = 'context detail to overflow the tiny budget. '.repeat(40); + const history = [ + new HumanMessage(`Turn 1 question. ${pad}`), + new AIMessage(`Turn 1 answer. ${pad}`), + new HumanMessage(`Turn 2 question. ${pad}`), + new AIMessage(`Turn 2 answer. ${pad}`), + new HumanMessage(`Final question after a lot of prior history. ${pad}`), + ]; + const indexTokenCountMap = {}; + history.forEach((message, i) => { + indexTokenCountMap[i] = charCounter(message); + }); + + const run = await Run.create({ + runId: `summ-e2e-${Date.now()}`, + graphConfig: { + type: 'standard', + llmConfig: { + provider: Providers.OPENAI, + model: 'gpt-4o-mini', + streaming: true, + streamUsage: false, + }, + instructions: 'You are a helpful assistant.', + maxContextTokens: 700, + summarizationEnabled: true, + summarizationConfig: { provider: Providers.OPENAI, model: 'gpt-4o-mini' }, + }, + returnContent: true, + customHandlers: handlers, + tokenCounter: charCounter, + indexTokenCountMap, + }); + + run.Graph.overrideModel = new UsageFakeModel( + { responses: ['## Summary\nPrior turns compacted.', 'Here is the final answer.'] }, + [{ input_tokens: 40, output_tokens: 8, total_tokens: 48 }], + ); + + await run.processStream( + { messages: history }, + { + configurable: { thread_id: 'summ-e2e-thread', user_id: 'user-1' }, + streamMode: 'values', + version: 'v2', + }, + ); + return run; + } + + /** A summarized turn compacts the context (summary tokens replace the older + * turns) and the reduced snapshot is persisted — the latest snapshot is + * followed by a primary usage, so the save guard keeps it and the client + * uses the snapshot (not the inflated whole-history estimate). */ + test('persists the reduced (compacted) snapshot after summarization', async () => { + if (!hasContextUsageEvent) { + return; + } + const res = createMockRes(); + const contextUsageSink = { latest: null, count: 0 }; + const usageEmitSink = []; + await runSummarizationLoop({ res, collectedUsage: [], contextUsageSink, usageEmitSink }); + + const snapshot = contextUsageSink.latest; + /** Summarization fired: a summary exists and the kept message tokens are + * small (the compacted context, not the full history). */ + expect(snapshot?.breakdown?.summaryTokens).toBeGreaterThan(0); + expect(snapshot?.breakdown?.messageTokens).toBeLessThan(snapshot?.breakdown?.summaryTokens); + + /** The save guard keeps it: a primary usage follows the latest snapshot. */ + const afterLatest = usageEmitSink.slice(contextUsageSink.latestUsageIndex ?? 0); + expect(afterLatest.some((e) => e.usage_type == null)).toBe(true); + expect( + buildPersistedContextUsage(snapshot, usageEmitSink).breakdown.summaryTokens, + ).toBeGreaterThan(0); + }); +}); diff --git a/api/server/controllers/agents/__tests__/usageEvents.live.spec.js b/api/server/controllers/agents/__tests__/usageEvents.live.spec.js new file mode 100644 index 00000000000..ff02c9325f6 --- /dev/null +++ b/api/server/controllers/agents/__tests__/usageEvents.live.spec.js @@ -0,0 +1,161 @@ +/** + * Live host-layer verification: real Anthropic run through the actual + * getDefaultHandlers pipeline, asserting the SSE usage/context events the + * client consumes and their resume persistence. + * + * Run with: + * RUN_USAGE_LIVE_TESTS=1 ANTHROPIC_API_KEY=... npx jest usageEvents.live --runInBand + */ +const { HumanMessage } = require('@langchain/core/messages'); +const { Run, Providers, GraphEvents } = require('@librechat/agents'); +const { GenerationJobManager } = require('@librechat/api'); +const { getDefaultHandlers } = require('~/server/controllers/agents/callbacks'); + +jest.mock('nanoid', () => ({ + nanoid: jest.fn(() => 'mock-nanoid'), +})); + +jest.mock('~/server/services/Files/Citations', () => ({ + processFileCitations: jest.fn(), +})); + +jest.mock('~/server/services/Files/Code/process', () => ({ + processCodeOutput: jest.fn(), + runPreviewFinalize: jest.fn(), +})); + +jest.mock('~/server/services/Files/process', () => ({ + saveBase64Image: jest.fn(), +})); + +const shouldRunLive = + process.env.RUN_USAGE_LIVE_TESTS === '1' && + process.env.ANTHROPIC_API_KEY != null && + process.env.ANTHROPIC_API_KEY !== ''; + +const describeIfLive = shouldRunLive ? describe : describe.skip; +const modelName = process.env.ANTHROPIC_USAGE_LIVE_MODEL ?? 'claude-haiku-4-5'; +const hasContextUsageEvent = GraphEvents.ON_CONTEXT_USAGE != null; + +const charCounter = (msg) => { + const content = msg.content; + if (typeof content === 'string') { + return Math.ceil(content.length / 4) + 3; + } + if (Array.isArray(content)) { + let length = 3; + for (const part of content) { + if (typeof part === 'string') { + length += Math.ceil(part.length / 4); + } else if (typeof part?.text === 'string') { + length += Math.ceil(part.text.length / 4); + } + } + return length; + } + return 3; +}; + +function createMockRes() { + const events = []; + return { + events, + headersSent: true, + writableEnded: false, + write(payload) { + for (const line of String(payload).split('\n')) { + if (line.startsWith('data: ')) { + events.push(JSON.parse(line.slice(6))); + } + } + return true; + }, + }; +} + +describeIfLive('live usage events through the host pipeline', () => { + jest.setTimeout(120000); + + afterAll(async () => { + await GenerationJobManager.destroy(); + }); + + test('streams real provider usage and persists it for resume', async () => { + const streamId = `usage-live-${Date.now()}`; + await GenerationJobManager.createJob(streamId, 'user-live', 'convo-live'); + + /** streamId mode routes events through the job emitter — capture them + * as a subscribed resumable client would, not via res.write */ + const res = createMockRes(); + await GenerationJobManager.subscribe(streamId, (event) => { + res.events.push(event); + }); + const collectedUsage = []; + const handlers = getDefaultHandlers({ + res, + aggregateContent: () => {}, + toolEndCallback: () => {}, + collectedUsage, + streamId, + }); + + const run = await Run.create({ + runId: 'usage-live-response', + graphConfig: { + type: 'standard', + llmConfig: { + provider: Providers.ANTHROPIC, + model: modelName, + apiKey: process.env.ANTHROPIC_API_KEY, + temperature: 0, + maxTokens: 64, + streaming: true, + streamUsage: true, + }, + instructions: 'You are concise. Reply with one short sentence.', + maxContextTokens: 8000, + }, + returnContent: true, + customHandlers: handlers, + tokenCounter: charCounter, + indexTokenCountMap: {}, + }); + + await run.processStream( + { messages: [new HumanMessage('Say hello in five words or fewer.')] }, + { + configurable: { thread_id: 'usage-live-thread', user_id: 'user-live' }, + streamMode: 'values', + version: 'v2', + }, + ); + + const usageEvents = res.events.filter((e) => e.event === 'on_token_usage'); + expect(usageEvents).toHaveLength(1); + const usage = usageEvents[0].data; + expect(usage.input_tokens).toBeGreaterThan(0); + expect(usage.output_tokens).toBeGreaterThan(0); + expect(usage.provider).toBe(Providers.ANTHROPIC); + expect(usage.model).toBe(modelName); + expect(collectedUsage).toHaveLength(1); + expect(usage.input_tokens).toBe(collectedUsage[0].input_tokens); + + if (hasContextUsageEvent) { + const contextEvents = res.events.filter((e) => e.event === 'on_context_usage'); + expect(contextEvents).toHaveLength(1); + const snapshot = contextEvents[0].data; + expect(snapshot.breakdown.maxContextTokens).toBe(8000); + const estimatedUsed = snapshot.contextBudget - snapshot.remainingContextTokens; + expect(estimatedUsed).toBeGreaterThan(0); + expect(estimatedUsed / usage.input_tokens).toBeGreaterThan(0.2); + expect(estimatedUsed / usage.input_tokens).toBeLessThan(5); + } + + const resumeState = await GenerationJobManager.getResumeState(streamId); + expect(resumeState.collectedUsage).toHaveLength(1); + expect(resumeState.collectedUsage[0].input_tokens).toBe(usage.input_tokens); + if (hasContextUsageEvent) { + expect(resumeState.contextUsage.breakdown.maxContextTokens).toBe(8000); + } + }); +}); diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js index 314ee481a6c..d0b2ea4aaab 100644 --- a/api/server/controllers/agents/callbacks.js +++ b/api/server/controllers/agents/callbacks.js @@ -1,23 +1,39 @@ const { nanoid } = require('nanoid'); const { logger } = require('@librechat/data-schemas'); -const { Tools, StepTypes, FileContext, ErrorTypes } = require('librechat-data-provider'); +const { + Tools, + StepTypes, + FileContext, + ErrorTypes, + UsageEvents, +} = require('librechat-data-provider'); const { GraphEvents, GraphNodeKeys, ToolEndHandler, - CODE_EXECUTION_TOOLS, createContentAggregator, } = require('@librechat/agents'); const { sendEvent, + computeUsageCostUSD, GenerationJobManager, writeAttachmentEvent, createToolExecuteHandler, + HOST_FILE_AUTHORING_ARTIFACT_KEY, + isCodeSessionToolName, } = require('@librechat/api'); const { processFileCitations } = require('~/server/services/Files/Citations'); const { processCodeOutput, runPreviewFinalize } = require('~/server/services/Files/Code/process'); const { saveBase64Image } = require('~/server/services/Files/process'); +function isHostFileAuthoringArtifact(artifact) { + return artifact?.[HOST_FILE_AUTHORING_ARTIFACT_KEY] === true; +} + +function isCodeArtifactToolOutput(output) { + return isCodeSessionToolName(output.name) || isHostFileAuthoringArtifact(output.artifact); +} + class ModelEndHandler { /** * @param {Array} collectedUsage @@ -32,13 +48,16 @@ class ModelEndHandler { * Optional; when `null`, the handler is a no-op for signatures. Non-Vertex * providers don't emit `additional_kwargs.signatures`, so capture is also * a no-op for them even when the map is provided. + * @param {(data: Record) => Promise | void} [emitUsage] Optional + * callback to stream per-call token usage to the client. */ - constructor(collectedUsage, collectedThoughtSignatures = null) { + constructor(collectedUsage, collectedThoughtSignatures = null, emitUsage = null) { if (!Array.isArray(collectedUsage)) { throw new Error('collectedUsage must be an array'); } this.collectedUsage = collectedUsage; this.collectedThoughtSignatures = collectedThoughtSignatures; + this.emitUsage = emitUsage; } finalize(errorMessage) { @@ -90,11 +109,62 @@ class ModelEndHandler { if (agentContext.provider) { usage.provider = agentContext.provider; } + /** Tag the producing agent so multi-endpoint graphs can price each call + * with its own endpoint token config (recordCollectedUsage resolver). */ + if (agentContext.agentId) { + usage.agentId = agentContext.agentId; + } - const taggedUsage = markSummarizationUsage(usage, metadata); + let taggedUsage = markSummarizationUsage(usage, metadata); + /** Hidden intermediate sequential-agent calls are billed but never shown. + * Tag them non-primary on the COLLECTED usage too (not just the emit) so + * recordCollectedUsage excludes their output from the parent's tokenCount + * and the client folds them into cost/totals only — not the live gauge. */ + if ( + taggedUsage.usage_type == null && + !checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node) && + metadata?.hide_sequential_outputs === true + ) { + taggedUsage = { ...taggedUsage, usage_type: 'sequential' }; + } this.collectedUsage.push(taggedUsage); + if (this.emitUsage) { + /** Normalize Anthropic/Bedrock-style top-level cache fields into details */ + const cache_creation = + taggedUsage.input_token_details?.cache_creation ?? + taggedUsage.cache_creation_input_tokens; + const cache_read = + taggedUsage.input_token_details?.cache_read ?? taggedUsage.cache_read_input_tokens; + try { + await this.emitUsage({ + input_tokens: taggedUsage.input_tokens, + output_tokens: taggedUsage.output_tokens, + total_tokens: taggedUsage.total_tokens, + input_token_details: + cache_creation != null || cache_read != null + ? { cache_creation, cache_read } + : undefined, + model: taggedUsage.model, + provider: taggedUsage.provider, + usage_type: taggedUsage.usage_type, + /** Producing agent for per-endpoint pricing; consumed by the emit + * cost resolver and not included in the emitted/persisted payload. */ + agentId: taggedUsage.agentId, + runId: metadata?.run_id, + /** Per-run sequence so identical payloads from distinct calls + * stay distinguishable during resume dedupe */ + seq: this.collectedUsage.length, + }); + } catch (err) { + /** Best-effort telemetry: a failed emit (closed SSE, Redis publish + * error) must not abort the handler before the thought-signature + * capture below, or resumed tool-call requests lose that metadata */ + logger.warn('[ModelEndHandler] Failed to emit token usage', err); + } + } + /** * `additional_kwargs.signatures` is a flat array indexed by response * part position (text + functionCall interleaved). `tool_calls` is @@ -211,6 +281,12 @@ function feedSubagentAggregator(aggregator, event) { * @param {Array} options.collectedUsage - The list of collected usage metadata. * @param {string | null} [options.streamId] - The stream ID for resumable mode, or null for standard mode. * @param {ToolExecuteOptions} [options.toolExecuteOptions] - Options for event-driven tool execution. + * @param {UsageCostDeps} [options.usageCost] - Pricing context for authoritative per-event cost. + * @param {{ latest: TContextUsageEvent | null, count: number }} [options.contextUsageSink] - Mutable + * holder for the latest visible context snapshot + a count of visible snapshots (model calls), + * used to persist the breakdown only when the final call emitted usage. + * @param {Array} [options.usageEmitSink] - Array collecting each emitted + * `on_token_usage` payload (incl. cost) so the response's usage rollup can be persisted. * @returns {Record} The default handlers. * @throws {Error} If the request is not found. */ @@ -224,14 +300,53 @@ function getDefaultHandlers({ toolExecuteOptions = null, summarizationOptions = null, subagentAggregatorsByToolCallId = null, + usageCost = null, + contextUsageSink = null, + usageEmitSink = null, }) { if (!res || !aggregateContent) { throw new Error( `[getDefaultHandlers] Missing required options: res: ${!res}, aggregateContent: ${!aggregateContent}`, ); } + /** + * Emit a token-usage event, attaching the authoritative per-event USD cost + * when cost display is enabled. The backend is the single source of truth + * for pricing (premium tiers, cache rates) — the client sums these instead + * of re-deriving from base rates. + * @param {Record} data + */ + const emitTokenUsage = ({ agentId, ...data }) => { + let payload = data; + if (usageCost?.enabled === true && usageCost.pricing) { + try { + /** Price with the producing agent's config (multi-endpoint graphs) so + * the streamed/persisted cost matches the per-agent balance transaction; + * `agentId` is resolved here, not forwarded to the client or rollup. */ + const endpointTokenConfig = usageCost.resolveEndpointTokenConfig + ? usageCost.resolveEndpointTokenConfig({ agentId }) + : usageCost.endpointTokenConfig; + payload = { + ...data, + cost: computeUsageCostUSD(data, usageCost.pricing, endpointTokenConfig), + }; + } catch (err) { + logger.warn('[getDefaultHandlers] Failed to compute usage cost', err); + } + } + /** Collect the same payload the client folds so the response's usage rollup + * persisted on `metadata.usage` reproduces the live branch/total + cost. */ + if (usageEmitSink) { + usageEmitSink.push(payload); + } + return emitEvent(res, streamId, { event: UsageEvents.ON_TOKEN_USAGE, data: payload }); + }; const handlers = { - [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler(collectedUsage, collectedThoughtSignatures), + [GraphEvents.CHAT_MODEL_END]: new ModelEndHandler( + collectedUsage, + collectedThoughtSignatures, + emitTokenUsage, + ), [GraphEvents.TOOL_END]: new ToolEndHandler(toolEndCallback, logger), [GraphEvents.ON_RUN_STEP]: { /** @@ -416,6 +531,43 @@ function getDefaultHandlers({ handlers[GraphEvents.ON_AGENT_LOG] = { handle: agentLogHandler }; + /** Guarded: no-op when the installed @librechat/agents predates the event */ + if (GraphEvents.ON_CONTEXT_USAGE) { + handlers[GraphEvents.ON_CONTEXT_USAGE] = { + /** + * Forward per-model-call context usage snapshots to the client, + * honoring the same sequential-agent visibility gate as deltas. + * @param {string} event - The event name. + * @param {StreamEventData} data - The event data. + * @param {GraphRunnableConfig['configurable']} [metadata] The runnable metadata. + */ + handle: async (event, data, metadata) => { + if ( + checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node) || + !metadata?.hide_sequential_outputs + ) { + /** Capture the latest visible snapshot (last-wins) and how many usage + * events preceded it BEFORE awaiting the emit. `emitEvent` can yield + * (resumable SSE / Redis publish); with parallel runs active this + * call's own primary usage could land in `usageEmitSink` during that + * yield, pushing `latestUsageIndex` past the very event that proves the + * snapshot completed — the save path would then slice it away and drop + * a valid breakdown. The recorded index lets the save path persist only + * when a PRIMARY usage follows this snapshot (the snapshot's call + * actually invoked the model); a summarization detour emits a snapshot + * whose only following usage is tagged `summarization`, which a plain + * snapshot-count would over-count and wrongly drop. */ + if (contextUsageSink) { + contextUsageSink.latest = data; + contextUsageSink.count = (contextUsageSink.count ?? 0) + 1; + contextUsageSink.latestUsageIndex = usageEmitSink?.length ?? 0; + } + await emitEvent(res, streamId, { event, data }); + } + }, + }; + } + return handlers; } @@ -623,7 +775,7 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null }) return; } - if (!CODE_EXECUTION_TOOLS.has(output.name)) { + if (!isCodeArtifactToolOutput(output)) { return; } @@ -890,7 +1042,7 @@ function createResponsesToolEndCallback({ req, res, tracker, artifactPromises }) return; } - if (!CODE_EXECUTION_TOOLS.has(output.name)) { + if (!isCodeArtifactToolOutput(output)) { return; } diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 9c85560a668..94d17fa3f51 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -9,9 +9,9 @@ const { logToolError, sanitizeTitle, payloadParser, - resolveHeaders, createSafeUser, initializeAgent, + resolveConfigHeaders, countTokens, getBalanceConfig, omitTitleOptions, @@ -21,6 +21,14 @@ const { applyContextToAgent, isMemoryAgentEnabled, recordCollectedUsage, + sendEvent, + computeUsageCostUSD, + aggregateEmittedUsage, + resolveAgentTokenConfig, + buildPersistedContextUsage, + computeSummaryUsedTokens, + priorRunOutputTokens, + createSubagentUsageSink, isDeepSeekReasoningProvider, GenerationJobManager, getTransactionsConfig, @@ -30,10 +38,13 @@ const { createMultiAgentMapper, filterMalformedContentParts, countFormattedMessageTokens, + prependFileContext, hydrateMissingIndexTokenCounts, injectSkillPrimes, + collectFreshSkillPrimeNames, isSkillPrimeMessage, collectFileIds, + processTextWithTokenLimit, buildAgentScopedContext, buildSkillPrimeContentParts, buildInitialToolSessions, @@ -48,6 +59,7 @@ const { } = require('@librechat/agents'); const { Constants, + UsageEvents, Permissions, VisionModes, ContentTypes, @@ -57,6 +69,7 @@ const { isAgentsEndpoint, isEphemeralAgentId, removeNullishValues, + DEFAULT_MEMORY_MAX_INPUT_TOKENS, } = require('librechat-data-provider'); const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions'); const { encodeAndFormat } = require('~/server/services/Files/images/encode'); @@ -69,6 +82,8 @@ const db = require('~/models'); const loadAgent = (params) => loadAgentFn(params, { getAgent: db.getAgent, getMCPServerTools }); +const MEMORY_INPUT_CHARS_PER_TOKEN = 8; + class AgentClient extends BaseClient { constructor(options = {}) { super(null, options); @@ -98,11 +113,22 @@ class AgentClient extends BaseClient { artifactPromises, maxContextTokens, subagentAggregatorsByToolCallId, + contextUsageSink, + usageEmitSink, ...clientOptions } = options; this.agentConfigs = agentConfigs; this.maxContextTokens = maxContextTokens; + /** Latest visible context snapshot for this response, captured live by the + * ON_CONTEXT_USAGE handler; persisted on `metadata.contextUsage`. + * @type {{ latest: import('librechat-data-provider').TContextUsageEvent | null } | undefined} */ + this.contextUsageSink = contextUsageSink; + /** Every emitted `on_token_usage` payload for this response (primary, + * summarization, sequential, and subagent); aggregated into the rollup + * persisted on `metadata.usage`. + * @type {Array | undefined} */ + this.usageEmitSink = usageEmitSink; /** @type {MessageContentComplex[]} */ this.contentParts = contentParts; /** @type {Array} */ @@ -121,6 +147,11 @@ class AgentClient extends BaseClient { * harvests `contentParts` onto the matching `subagent` tool_call * so the child's full activity survives a page refresh. */ this.subagentAggregatorsByToolCallId = subagentAggregatorsByToolCallId ?? new Map(); + /** In-flight `on_token_usage` emits from subagent child runs. The sink + * fires the emitter without awaiting, so chatCompletion's finally flushes + * these before returning — otherwise job cleanup can race the persist. + * @type {Promise[]} */ + this.pendingSubagentEmits = []; /** @type {AgentClientOptions} */ this.options = Object.assign({ endpoint: options.endpoint }, clientOptions); /** @type {string} */ @@ -135,6 +166,8 @@ class AgentClient extends BaseClient { this.usage; /** @type {Record} */ this.indexTokenCountMap = {}; + /** @type {Array> | null} */ + this.memoryPayload = null; /** @type {(messages: BaseMessage[]) => Promise} */ this.processMemory; } @@ -209,6 +242,7 @@ class AgentClient extends BaseClient { { spec: this.options.spec, iconURL: this.options.iconURL, + chatProjectId: this.options.chatProjectId, endpoint: this.options.endpoint, agent_id: this.options.agent.id, modelLabel: this.options.modelLabel, @@ -314,39 +348,51 @@ class AgentClient extends BaseClient { } /** @type {Record} */ - const canonicalTokenCountMap = {}; + const indexTokenCountMap = {}; /** @type {Record} */ const tokenCountMap = {}; + const memoryPayload = []; + let hasFileContext = false; let promptTokenTotal = 0; + const encoding = this.getEncoding(); const formattedMessages = orderedMessages.map((message, i) => { const formattedMessage = formatMessage({ message, userName: this.options?.name, assistantName: this.options?.modelLabel, }); + const memoryFormattedMessage = formatMessage({ + message, + userName: this.options?.name, + assistantName: this.options?.modelLabel, + }); - /** For non-latest messages, prepend file context directly to message content */ - if (message.fileContext && i !== orderedMessages.length - 1) { - if (typeof formattedMessage.content === 'string') { - formattedMessage.content = message.fileContext + '\n' + formattedMessage.content; - } else { - const textPart = formattedMessage.content.find((part) => part.type === 'text'); - textPart - ? (textPart.text = message.fileContext + '\n' + textPart.text) - : formattedMessage.content.unshift({ type: 'text', text: message.fileContext }); - } + /** + * Bind file context to the message it belongs to. Historical attachments + * are resent inline, so the current turn's text attachment must be inline + * too instead of living only in the dynamic system tail. + */ + if (message.fileContext) { + hasFileContext = true; + prependFileContext(formattedMessage, message.fileContext); } - const dbTokenCount = orderedMessages[i].tokenCount; - const needsTokenCount = !dbTokenCount || message.fileContext; + memoryPayload.push(memoryFormattedMessage); - if (needsTokenCount || (this.isVisionModel && (message.image_urls || message.files))) { - orderedMessages[i].tokenCount = countFormattedMessageTokens( - formattedMessage, - this.getEncoding(), - ); + const dbTokenCount = Number(orderedMessages[i].tokenCount); + const hasDbTokenCount = Number.isFinite(dbTokenCount) && dbTokenCount > 0; + const needsCanonicalTokenCount = + !hasDbTokenCount || (this.isVisionModel && (message.image_urls || message.files)); + + let canonicalTokenCount = hasDbTokenCount ? dbTokenCount : 0; + if (needsCanonicalTokenCount) { + canonicalTokenCount = countFormattedMessageTokens(memoryFormattedMessage, encoding); } + const promptMessageTokenCount = message.fileContext + ? countFormattedMessageTokens(formattedMessage, encoding) + : canonicalTokenCount; + /* If message has files, calculate image token cost */ if (this.message_file_map && this.message_file_map[message.messageId]) { const attachments = this.message_file_map[message.messageId]; @@ -361,13 +407,19 @@ class AgentClient extends BaseClient { } } - const tokenCount = Number(orderedMessages[i].tokenCount); - const normalizedTokenCount = Number.isFinite(tokenCount) && tokenCount > 0 ? tokenCount : 0; - canonicalTokenCountMap[i] = normalizedTokenCount; - promptTokenTotal += normalizedTokenCount; + const normalizedCanonicalTokenCount = + Number.isFinite(canonicalTokenCount) && canonicalTokenCount > 0 ? canonicalTokenCount : 0; + const normalizedPromptTokenCount = + Number.isFinite(promptMessageTokenCount) && promptMessageTokenCount > 0 + ? promptMessageTokenCount + : 0; + + orderedMessages[i].tokenCount = normalizedCanonicalTokenCount; + indexTokenCountMap[i] = normalizedPromptTokenCount; + promptTokenTotal += normalizedPromptTokenCount; if (message.messageId) { - tokenCountMap[message.messageId] = normalizedTokenCount; + tokenCountMap[message.messageId] = normalizedCanonicalTokenCount; } if (isEnabled(process.env.AGENT_DEBUG_LOGGING)) { @@ -376,9 +428,10 @@ class AgentClient extends BaseClient { Array.isArray(message.content) && message.content.some((p) => p && p.type === 'summary'); const suffix = hasSummary ? '[S]' : ''; const id = (message.messageId ?? message.id ?? '').slice(-8); - const recalced = needsTokenCount ? orderedMessages[i].tokenCount : null; + const recalced = needsCanonicalTokenCount ? normalizedCanonicalTokenCount : null; + const promptRecalced = message.fileContext ? normalizedPromptTokenCount : null; logger.debug( - `[AgentClient] msg[${i}] ${role}${suffix} id=…${id} db=${dbTokenCount} needsRecount=${needsTokenCount} recalced=${recalced} tokens=${normalizedTokenCount}`, + `[AgentClient] msg[${i}] ${role}${suffix} id=…${id} db=${dbTokenCount} needsRecount=${needsCanonicalTokenCount} recalced=${recalced} promptRecalced=${promptRecalced} tokens=${normalizedPromptTokenCount}`, ); } @@ -386,22 +439,18 @@ class AgentClient extends BaseClient { }); payload = formattedMessages; + this.memoryPayload = hasFileContext ? memoryPayload : null; messages = orderedMessages; promptTokens = promptTokenTotal; /** * Build shared run context - applies to ALL agents in the run. - * This includes file context from the latest message and augmented prompt (RAG). + * Request attachment file context is already bound inline to the latest + * user message above; only side-channel context belongs here. * Memory context is handled separately and applied per-agent based on config. */ const sharedRunContextParts = []; - /** File context from the latest message (attachments) */ - const latestMessage = orderedMessages[orderedMessages.length - 1]; - if (latestMessage?.fileContext) { - sharedRunContextParts.push(latestMessage.fileContext); - } - /** Augmented prompt from RAG/context handlers */ if (this.contextHandlers) { this.augmentedPrompt = await this.contextHandlers.createContext(); @@ -427,8 +476,8 @@ class AgentClient extends BaseClient { tokenCountFn: (text) => countTokens(text), }); - /** Preserve canonical pre-format token counts for all history entering graph formatting */ - this.indexTokenCountMap = canonicalTokenCountMap; + /** Preserve prompt token counts for graph formatting and pruning. */ + this.indexTokenCountMap = indexTokenCountMap; /** Extract contextMeta from the parent response (second-to-last in ordered chain; * last is the current user message). Seeds the pruner's calibration EMA for this run. */ @@ -741,7 +790,44 @@ class AgentClient extends BaseClient { const filteredMessages = messagesToProcess.map((msg) => this.filterImageUrls(msg)); const bufferString = getBufferString(filteredMessages); - const bufferMessage = new HumanMessage(`# Current Chat:\n\n${bufferString}`); + const configuredMaxInputTokens = Number.isFinite(memoryConfig?.maxInputTokens) + ? Math.floor(memoryConfig.maxInputTokens) + : undefined; + const maxInputTokens = + configuredMaxInputTokens != null && configuredMaxInputTokens > 0 + ? configuredMaxInputTokens + : DEFAULT_MEMORY_MAX_INPUT_TOKENS; + const maxInputChars = maxInputTokens * MEMORY_INPUT_CHARS_PER_TOKEN; + const isCharTruncated = bufferString.length > maxInputChars; + const memoryInput = `# Current Chat:\n\n${ + isCharTruncated + ? `[Earlier chat content omitted due to memory input limit]\n\n${bufferString.slice( + -maxInputChars, + )}` + : bufferString + }`; + const { + text: limitedMemoryInput, + tokenCount, + wasTruncated, + } = await processTextWithTokenLimit({ + text: memoryInput, + tokenLimit: maxInputTokens, + tokenCountFn: (text) => countTokens(text), + preserve: 'end', + }); + if (isCharTruncated || wasTruncated) { + logger.warn('[MemoryAgent] Memory input truncated before processing', { + tokenCount, + messageId: this.responseMessageId, + conversationId: this.conversationId, + maxInputTokens, + wasTruncated, + maxInputChars, + originalLength: bufferString.length, + }); + } + const bufferMessage = new HumanMessage(limitedMemoryInput); return await this.processMemory([bufferMessage]); } catch (error) { logger.error('Memory Agent failed to process memory', error); @@ -758,11 +844,100 @@ class AgentClient extends BaseClient { }); const completion = filterMalformedContentParts(this.contentParts); + const metadata = this.buildResponseMetadata(); + return metadata ? { completion, metadata } : { completion }; + } + + /** + * Assembles the response message `metadata`: Vertex thought signatures plus + * the persisted context breakdown (Part A) and the usage/cost rollup (Part B), + * which rebuild the gauge breakdown and branch/total cost across reloads. + * Returns undefined when nothing was captured. + * @returns {{ + * thoughtSignatures?: Record, + * contextUsage?: import('librechat-data-provider').TContextUsageEvent, + * usage?: import('librechat-data-provider').TResponseUsage, + * } | undefined} + */ + buildResponseMetadata() { + /** @type {{ + * thoughtSignatures?: Record, + * contextUsage?: import('librechat-data-provider').TContextUsageEvent, + * usage?: import('librechat-data-provider').TResponseUsage, + * }} */ + const metadata = {}; const signatures = this.collectedThoughtSignatures; - if (!signatures || Object.keys(signatures).length === 0) { - return { completion }; + if (signatures && Object.keys(signatures).length > 0) { + metadata.thoughtSignatures = signatures; + } + const usageEvents = this.usageEmitSink ?? []; + /** Persist the breakdown only when the latest snapshot's OWN run completed — + * i.e. a PRIMARY usage event (usage_type == null) from that run's id arrived + * AFTER the snapshot. Matching by run id keeps `completedOutputTokens` a real + * post-snapshot delta even when parallel/direct runs interleave (A snapshot → + * B snapshot → A usage must NOT persist B's snapshot with A's output); an + * interrupted final call that emits no usage falls back to the per-message + * estimate. It still keeps the post-summary snapshot: the summarization detour + * emits an extra snapshot whose following primary usage shares that run's id, + * which the old snapshot-count guard miscounted and wrongly dropped. Events + * without a run id (older lib / resume) match any snapshot for back-compat. */ + const latestSnapshot = this.contextUsageSink?.latest; + const latestSnapshotUsageIndex = this.contextUsageSink?.latestUsageIndex ?? 0; + const latestSnapshotRunId = latestSnapshot?.runId; + const hasPrimaryAfterSnapshot = usageEvents + .slice(latestSnapshotUsageIndex) + .some( + (event) => + event.usage_type == null && + (latestSnapshotRunId == null || + event.runId == null || + event.runId === latestSnapshotRunId), + ); + if (latestSnapshot && hasPrimaryAfterSnapshot) { + metadata.contextUsage = buildPersistedContextUsage(latestSnapshot, usageEvents); } - return { completion, metadata: { thoughtSignatures: signatures } }; + /** Lightweight summarization marker — persisted whenever this turn compacted + * the context, INDEPENDENT of the snapshot guard above. When the client has + * no usable snapshot on the branch and falls back to the per-message + * estimate, it caps the discarded pre-summary history at this baseline + * instead of re-summing it (the gauge otherwise reads 100% forever). Shared + * with the abort save path via `computeSummaryUsedTokens`. Subtract the + * response's earlier tool-loop outputs (the primaries that preceded the + * latest snapshot, same run): those tokens are inside the snapshot baseline + * AND in the response `tokenCount` the client estimate adds on top, so + * leaving them in the marker double-counts them on a multi-call turn. */ + const priorOutputTokens = priorRunOutputTokens( + usageEvents, + latestSnapshotUsageIndex, + latestSnapshotRunId, + ); + const summaryUsedTokens = computeSummaryUsedTokens(latestSnapshot, priorOutputTokens); + if (summaryUsedTokens != null) { + metadata.summaryUsedTokens = summaryUsedTokens; + } + const usage = aggregateEmittedUsage(usageEvents); + if (usage) { + metadata.usage = usage; + } + return Object.keys(metadata).length > 0 ? metadata : undefined; + } + + /** + * Resolves the endpoint token config for a usage item by its producing agent + * (multi-endpoint graphs: connected agents + subagents). A known agent's + * config is authoritative — including `undefined`, which prices with built-in + * rates (e.g. a non-custom agent in a custom-primary graph). Only an + * untagged/unknown agent falls back to the primary config, so single-endpoint + * graphs are unchanged. + * @param {UsageMetadata} usage + * @returns {import('@librechat/api').EndpointTokenConfig | undefined} + */ + resolveAgentEndpointTokenConfig(usage) { + return resolveAgentTokenConfig({ + agentId: usage?.agentId, + byAgentId: this.options.endpointTokenConfigByAgentId, + fallback: this.options.endpointTokenConfig, + }); } /** @@ -797,6 +972,7 @@ class AgentClient extends BaseClient { balance, transactions, endpointTokenConfig: this.options.endpointTokenConfig, + resolveEndpointTokenConfig: (usage) => this.resolveAgentEndpointTokenConfig(usage), }, ); @@ -813,6 +989,84 @@ class AgentClient extends BaseClient { return this.usage; } + /** + * Builds the subagent usage emitter for {@link createSubagentUsageSink}. + * Streams each billed child-run usage to the client as an `on_token_usage` + * event tagged `subagent` (folds into session cost/totals, not the live + * gauge), with the authoritative cost when `interface.contextCost` is on. + * Returns undefined when there's no stream to write to. + * @param {AppConfig} [appConfig] + * @returns {((usage: UsageMetadata) => void) | undefined} + */ + buildSubagentUsageEmitter(appConfig) { + const res = this.options.res; + const streamId = this.options.req?._resumableStreamId || null; + if (!res && !streamId) { + return undefined; + } + const includeCost = appConfig?.interfaceConfig?.contextCost === true; + return (usage) => { + const data = { + input_tokens: usage.input_tokens, + output_tokens: usage.output_tokens, + total_tokens: usage.total_tokens, + input_token_details: this.subagentCacheDetails(usage), + model: usage.model, + provider: usage.provider, + usage_type: 'subagent', + runId: this.responseMessageId, + /** Unique per collected entry (post-push length) for resume dedupe */ + seq: this.collectedUsage.length, + /** Price with the SUBAGENT's own endpoint token config (its endpoint may + * differ from the parent's); `usage.agentId` is tagged by the sink. */ + cost: includeCost + ? computeUsageCostUSD( + usage, + { getMultiplier: db.getMultiplier, getCacheMultiplier: db.getCacheMultiplier }, + this.resolveAgentEndpointTokenConfig(usage), + ) + : undefined, + }; + /** Fold into the response's usage rollup (synchronously, regardless of + * emit success) so the persisted total matches the live session, which + * also folds subagent usage into its cost/totals. */ + if (this.usageEmitSink) { + this.usageEmitSink.push(data); + } + /** The sink fires this without awaiting, so retain the promise and flush + * it in chatCompletion's finally — emitChunk persists (HSET) before + * publishing, and job cleanup must not race that persist or resumed + * clients miss billed subagent usage. */ + const emit = (async () => { + try { + if (streamId) { + await GenerationJobManager.emitChunk(streamId, { + event: UsageEvents.ON_TOKEN_USAGE, + data, + }); + } else { + sendEvent(res, { event: UsageEvents.ON_TOKEN_USAGE, data }); + } + } catch (err) { + logger.warn('[AgentClient] Failed to emit subagent usage', err); + } + })(); + this.pendingSubagentEmits.push(emit); + return emit; + }; + } + + /** Normalizes a subagent usage event's cache token details for emission. */ + subagentCacheDetails(usage) { + const cache_creation = + usage.input_token_details?.cache_creation ?? usage.cache_creation_input_tokens; + const cache_read = usage.input_token_details?.cache_read ?? usage.cache_read_input_tokens; + if (cache_creation == null && cache_read == null) { + return undefined; + } + return { cache_creation, cache_read }; + } + /** * @param {TMessage} responseMessage * @returns {number} @@ -893,7 +1147,30 @@ class AgentClient extends BaseClient { hasDeepSeekAgent(this.options.agent) || (this.agentConfigs != null && Array.from(this.agentConfigs.values()).some(hasDeepSeekAgent)); - const formatOptions = needsDeepSeekFormat ? { provider: Providers.DEEPSEEK } : undefined; + /** + * Skills primed fresh this turn — manual ($ popover) and always-apply + * (frontmatter). `injectSkillPrimes` (below) splices their SKILL.md + * bodies in, so `formatAgentMessages` must NOT also reconstruct the + * same names from a historical `skill` tool_call — otherwise the body + * lands twice and a prompt-cache marker can pin to the duplicated + * synthetic prefix. Names NOT primed this turn still reconstruct from + * history, preserving sticky manual re-priming across turns. + */ + const manualSkillPrimes = this.options.agent?.manualSkillPrimes; + const alwaysApplySkillPrimes = this.options.agent?.alwaysApplySkillPrimes; + const freshSkillPrimeNames = collectFreshSkillPrimeNames({ + manualSkillPrimes, + alwaysApplySkillPrimes, + }); + const formatOptions = + needsDeepSeekFormat || freshSkillPrimeNames.size > 0 + ? { + ...(needsDeepSeekFormat ? { provider: Providers.DEEPSEEK } : {}), + ...(freshSkillPrimeNames.size > 0 + ? { skipSkillBodyNames: freshSkillPrimeNames } + : {}), + } + : undefined; let { messages: initialMessages, indexTokenCountMap, @@ -924,9 +1201,11 @@ class AgentClient extends BaseClient { * agent and multi-agent runs; how primes interact with handoff / * added-convo agents' per-agent state is an agents-SDK concern, * not this layer's to gate. + * + * `manualSkillPrimes` / `alwaysApplySkillPrimes` are resolved above + * (used to build `freshSkillPrimeNames` for dedupe against historical + * skill reconstruction). */ - const manualSkillPrimes = this.options.agent?.manualSkillPrimes; - const alwaysApplySkillPrimes = this.options.agent?.alwaysApplySkillPrimes; if ( (manualSkillPrimes && manualSkillPrimes.length > 0) || (alwaysApplySkillPrimes && alwaysApplySkillPrimes.length > 0) @@ -969,6 +1248,17 @@ class AgentClient extends BaseClient { tokenCounter, }); + const memoryMessages = + this.processMemory && this.memoryPayload + ? formatAgentMessages( + this.memoryPayload, + undefined, + toolSet, + skillPrimeResult?.skills, + formatOptions, + ).messages + : initialMessages; + /** * @param {BaseMessage[]} messages */ @@ -1009,7 +1299,7 @@ class AgentClient extends BaseClient { // } if (this.processMemory) { - memoryPromise = this.runMemory(messages); + memoryPromise = this.runMemory(memoryMessages); } /** Seed calibration state from previous run if encoding matches */ @@ -1040,6 +1330,17 @@ class AgentClient extends BaseClient { summarizationConfig: appConfig?.summarization, appConfig, tokenCounter, + /** Bills subagent child-run model calls — child graphs execute + * outside the streamEvents loop, so ModelEndHandler never sees + * them. Entries land in collectedUsage tagged + * `usage_type: 'subagent'` and are spent by recordCollectedUsage. + * The sink also streams each as an `on_token_usage` event so the + * gauge's session cost/totals include billed subagent usage (the + * `subagent` tag keeps it out of the live context meter). */ + subagentUsageSink: createSubagentUsageSink( + this.collectedUsage, + this.buildSubagentUsageEmitter(appConfig), + ), }); if (!run) { @@ -1156,6 +1457,14 @@ class AgentClient extends BaseClient { this.finalizeSubagentContent(); + /** Flush subagent usage emits the sink fired without awaiting, so their + * persist/publish completes before we return and the job is cleaned up + * (resumed clients read this persisted usage). */ + if (this.pendingSubagentEmits.length > 0) { + await Promise.allSettled(this.pendingSubagentEmits); + this.pendingSubagentEmits = []; + } + try { const attachments = await this.awaitMemoryWithTimeout(memoryPromise); if (attachments && attachments.length > 0) { @@ -1350,12 +1659,25 @@ class AgentClient extends BaseClient { delete clientOptions.modelKwargs.max_output_tokens; } + /** `omitTitleOptions` drops the Anthropic `clientOptions` carrier (thinking, + * streaming, etc.), which would also drop its `defaultHeaders` — preserve the + * original `clientOptions` object so gateway/reverse-proxy metadata still + * reaches title requests (the proxy may require it for auth/routing). Restore + * the SAME object reference, not a copy: the Vertex `createClient` closure from + * `getLLMConfig` closes over this object, so `resolveConfigHeaders` must mutate + * the very object the client is built from. */ + const anthropicClientOptions = clientOptions?.clientOptions; + clientOptions = Object.assign( Object.fromEntries( Object.entries(clientOptions).filter(([key]) => !omitTitleOptions.has(key)), ), ); + if (anthropicClientOptions?.defaultHeaders != null && clientOptions.clientOptions == null) { + clientOptions.clientOptions = anthropicClientOptions; + } + if ( provider === Providers.GOOGLE && (endpointConfig?.titleMethod === TitleMethod.FUNCTIONS || @@ -1364,20 +1686,19 @@ class AgentClient extends BaseClient { clientOptions.json = true; } - /** Resolve request-based headers for Custom Endpoints. Note: if this is added to - * non-custom endpoints, needs consideration of varying provider header configs. + /** Resolve request-based headers across provider-specific header locations: + * OpenAI `configuration.defaultHeaders`, Anthropic `clientOptions.defaultHeaders` + * (preserved above), and Google `customHeaders`. */ - if (clientOptions?.configuration?.defaultHeaders != null) { - clientOptions.configuration.defaultHeaders = resolveHeaders({ - headers: clientOptions.configuration.defaultHeaders, - user: createSafeUser(this.options.req?.user), - body: { - messageId: this.responseMessageId, - conversationId: this.conversationId, - parentMessageId: this.parentMessageId, - }, - }); - } + resolveConfigHeaders({ + llmConfig: clientOptions, + user: createSafeUser(this.options.req?.user), + body: { + messageId: this.responseMessageId, + conversationId: this.conversationId, + parentMessageId: this.parentMessageId, + }, + }); try { const titleResult = await this.run.generateTitle({ diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index 1c656c125ca..c71ede7b237 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -1,5 +1,5 @@ const { Providers } = require('@librechat/agents'); -const { Constants, EModelEndpoint } = require('librechat-data-provider'); +const { Constants, ContentTypes, EModelEndpoint } = require('librechat-data-provider'); const AgentClient = require('./client'); jest.mock('@librechat/agents', () => ({ @@ -225,6 +225,51 @@ describe('AgentClient - titleConvo', () => { expect(generateTitleCall.clientOptions.model).toBe('gpt-3.5-turbo'); }); + it('preserves Anthropic custom headers on title requests despite omitTitleOptions', async () => { + const prevKey = process.env.ANTHROPIC_API_KEY; + process.env.ANTHROPIC_API_KEY = 'sk-ant-test'; + try { + const req = { + user: { id: 'user-123' }, + body: { model: 'claude-sonnet-4-5', endpoint: EModelEndpoint.anthropic, key: null }, + config: { + endpoints: { + [EModelEndpoint.anthropic]: { + headers: { 'X-Conversation-Id': '{{LIBRECHAT_BODY_CONVERSATIONID}}' }, + }, + }, + }, + }; + const agent = { + id: 'agent-anthropic', + endpoint: EModelEndpoint.anthropic, + provider: EModelEndpoint.anthropic, + model_parameters: { model: 'claude-sonnet-4-5' }, + }; + const anthropicClient = new AgentClient({ req, res: {}, agent, endpointTokenConfig: {} }); + anthropicClient.run = mockRun; + anthropicClient.responseMessageId = 'response-123'; + anthropicClient.conversationId = 'convo-123'; + anthropicClient.contentParts = [{ type: 'text', text: 'Test content' }]; + anthropicClient.recordCollectedUsage = jest.fn().mockResolvedValue(); + + await anthropicClient.titleConvo({ text: 'Hello', abortController: new AbortController() }); + + const defaultHeaders = + mockRun.generateTitle.mock.calls[0][0].clientOptions?.clientOptions?.defaultHeaders; + // Custom header survives the `omitTitleOptions` strip and resolves the conversationId + expect(defaultHeaders?.['X-Conversation-Id']).toBe('convo-123'); + // Provider-managed beta header is preserved alongside it + expect(defaultHeaders?.['anthropic-beta']).toBeDefined(); + } finally { + if (prevKey === undefined) { + delete process.env.ANTHROPIC_API_KEY; + } else { + process.env.ANTHROPIC_API_KEY = prevKey; + } + } + }); + it('should handle missing endpoint config gracefully', async () => { // Remove endpoint config mockReq.config = { endpoints: {} }; @@ -1497,9 +1542,23 @@ describe('AgentClient - titleConvo', () => { text, }); + const makeUploadedFile = (file_id, filename, type) => ({ + user: 'user-123', + file_id, + filename, + filepath: `/uploads/${filename}`, + object: 'file', + type, + bytes: 128, + embedded: false, + usage: 0, + source: 'local', + }); + beforeEach(() => { jest.clearAllMocks(); mockFormatInstructions.mockResolvedValue(''); + require('@librechat/api').countFormattedMessageTokens.mockImplementation(() => 42); mockAgent = { id: 'primary-agent', @@ -1544,7 +1603,49 @@ describe('AgentClient - titleConvo', () => { client.useMemory = jest.fn().mockResolvedValue(undefined); }); - it("applies shared request context plus each agent's own context docs only", async () => { + it.each([ + ['CSV', 'csv-file', 'sample.csv', 'text/csv'], + [ + 'XLSX', + 'xlsx-file', + 'sample.xlsx', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ], + ])( + 'routes default-supported provider uploads like %s as request documents without custom file config', + async (_label, file_id, filename, type) => { + const currentFile = makeUploadedFile(file_id, filename, type); + const message = { + messageId: 'msg-1', + parentMessageId: null, + sender: 'User', + text: `Read this ${filename}.`, + isCreatedByUser: true, + }; + + client.addDocuments = jest.fn(async (targetMessage, attachments) => { + targetMessage.documents = attachments.map((file) => ({ + type: 'input_file', + filename: file.filename, + file_data: `data:${file.type};base64,Y29sMQox`, + })); + return attachments; + }); + + const files = await client.processAttachments(message, [currentFile]); + + expect(client.addDocuments).toHaveBeenCalledWith(message, [currentFile]); + expect(message.documents).toEqual([ + expect.objectContaining({ + type: 'input_file', + filename, + }), + ]); + expect(files).toEqual([currentFile]); + }, + ); + + it('places request context inline and applies each agent context doc only once', async () => { const requestFile = makeTextFile('request-file', 'request.txt', 'Shared request context'); const primaryContext = makeTextFile( 'primary-context', @@ -1574,7 +1675,7 @@ describe('AgentClient - titleConvo', () => { ]); client.agentConfigs = new Map([['handoff-agent', handoffAgent]]); - await client.buildMessages( + const result = await client.buildMessages( [ { messageId: 'msg-1', @@ -1588,15 +1689,99 @@ describe('AgentClient - titleConvo', () => { {}, ); - expect(mockAgent.additional_instructions).toContain('Shared request context'); + expect(result.prompt[0].content).toContain('Shared request context'); + expect(mockAgent.additional_instructions).toContain('Primary private context'); + expect(mockAgent.additional_instructions).not.toContain('Shared request context'); expect(mockAgent.additional_instructions).not.toContain('Handoff private context'); - expect(handoffAgent.additional_instructions).toContain('Shared request context'); expect(handoffAgent.additional_instructions).toContain('Handoff private context'); + expect(handoffAgent.additional_instructions).not.toContain('Shared request context'); expect(handoffAgent.additional_instructions).not.toContain('Primary private context'); }); + it('places current request file context on the latest user message', async () => { + const currentFile = makeTextFile('current-file', 'current.txt', 'Current turn file body'); + const previousFileContext = + 'Attached document(s):\n```md\n# "previous.txt"\nPrevious turn file body\n```'; + + client.options.attachments = [currentFile]; + + const result = await client.buildMessages( + [ + { + messageId: 'msg-1', + parentMessageId: null, + sender: 'User', + text: 'What is written here?', + isCreatedByUser: true, + fileContext: previousFileContext, + }, + { + messageId: 'msg-2', + parentMessageId: 'msg-1', + sender: 'Assistant', + text: 'It describes the previous file.', + isCreatedByUser: false, + }, + { + messageId: 'msg-3', + parentMessageId: 'msg-2', + sender: 'User', + text: 'What is written here?', + isCreatedByUser: true, + }, + ], + 'msg-3', + {}, + ); + + expect(result.prompt[0].content).toContain('Previous turn file body'); + expect(result.prompt[2].content).toContain('Current turn file body'); + expect(result.prompt[2].content).toContain('What is written here?'); + expect(result.prompt[2].content).not.toContain('Previous turn file body'); + expect(client.memoryPayload[2].content).toContain('What is written here?'); + expect(client.memoryPayload[2].content).not.toContain('Current turn file body'); + expect(mockAgent.additional_instructions ?? '').not.toContain('Current turn file body'); + expect(result.prompt[2].content.indexOf('Current turn file body')).toBeLessThan( + result.prompt[2].content.indexOf('What is written here?'), + ); + }); + + it('persists canonical token counts while counting request file context for the prompt', async () => { + const { countFormattedMessageTokens } = require('@librechat/api'); + const currentFile = makeTextFile('current-file', 'current.txt', 'Current turn file body'); + + countFormattedMessageTokens.mockImplementation(({ content }) => { + const text = Array.isArray(content) + ? content.map((part) => part.text ?? part[ContentTypes.TEXT] ?? '').join('\n') + : String(content ?? ''); + return text.includes('Current turn file body') ? 200 : 20; + }); + + client.options.attachments = [currentFile]; + + const result = await client.buildMessages( + [ + { + messageId: 'msg-1', + parentMessageId: null, + sender: 'User', + text: 'What is written here?', + isCreatedByUser: true, + }, + ], + 'msg-1', + {}, + ); + + expect(result.prompt[0].content).toContain('Current turn file body'); + expect(result.tokenCountMap['msg-1']).toBe(20); + expect(result.promptTokens).toBe(200); + expect(client.indexTokenCountMap[0]).toBe(200); + expect(client.memoryPayload[0].content).toBe('What is written here?'); + }); + it('does not duplicate a file that is both request context and scoped context', async () => { const sharedFile = makeTextFile('shared-file', 'shared.txt', 'Shared duplicate context'); @@ -1604,7 +1789,7 @@ describe('AgentClient - titleConvo', () => { client.options.agentContextAttachmentsByAgentId = new Map([['primary-agent', [sharedFile]]]); client.agentConfigs = new Map(); - await client.buildMessages( + const result = await client.buildMessages( [ { messageId: 'msg-1', @@ -1618,10 +1803,10 @@ describe('AgentClient - titleConvo', () => { {}, ); - const occurrences = ( - mockAgent.additional_instructions.match(/Shared duplicate context/g) ?? [] - ).length; - expect(occurrences).toBe(1); + const inlineOccurrences = (result.prompt[0].content.match(/Shared duplicate context/g) ?? []) + .length; + expect(inlineOccurrences).toBe(1); + expect(mockAgent.additional_instructions ?? '').not.toContain('Shared duplicate context'); }); it('keeps direct chats with context-doc agents working without request attachments', async () => { @@ -1870,6 +2055,25 @@ describe('AgentClient - titleConvo', () => { expect(processedMessage.content).not.toContain('Response 1'); }); + it('should cap memory input tokens and preserve recent content', async () => { + const { HumanMessage, AIMessage } = require('@librechat/agents/langchain/messages'); + mockReq.config.memory.maxInputTokens = 12; + const messages = [ + new HumanMessage(`OLDER_CONTENT ${'a'.repeat(600)}`), + new AIMessage('Intermediate response'), + new HumanMessage('Please remember LATEST_MEMORY_MARKER'), + ]; + + await client.runMemory(messages); + + expect(mockProcessMemory).toHaveBeenCalledTimes(1); + const processedMessage = mockProcessMemory.mock.calls[0][0][0]; + + expect(processedMessage.content).toContain('LATEST_MEMORY_MARKER'); + expect(processedMessage.content).not.toContain('OLDER_CONTENT'); + expect(Math.ceil(processedMessage.content.length / 4)).toBeLessThanOrEqual(12); + }); + it('should return early if processMemory is not set', async () => { const { HumanMessage } = require('@librechat/agents/langchain/messages'); client.processMemory = null; diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index 7d2395b09f1..3c00b1fce2e 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -23,8 +23,10 @@ const { extractManualSkills, createErrorResponse, recordCollectedUsage, + createSubagentUsageSink, getTransactionsConfig, resolveRecursionLimit, + findPiiMatchInMessages, discoverConnectedAgents, getRemoteAgentPermissions, createToolExecuteHandler, @@ -47,8 +49,11 @@ const { } = require('~/server/services/PermissionService'); const { getSkillToolDeps, - enrichWithSkillConfigurable, - buildSkillPrimedIdsByName, + getSkillDbMethods, + canAuthorSkillFiles, + withDeploymentSkillIds, + buildAgentToolContext, + enrichLoadedToolsWithAgentContext, } = require('~/server/services/Endpoints/agents/skillDeps'); const { getModelsConfig } = require('~/server/controllers/ModelController'); const { logViolation } = require('~/cache'); @@ -174,6 +179,17 @@ const OpenAIChatCompletionController = async (req, res) => { ); } + const piiHit = findPiiMatchInMessages(request.messages, appConfig?.messageFilter?.pii); + if (piiHit != null) { + return sendErrorResponse( + res, + 400, + `Message contains a ${piiHit.label}. Remove it and try again.`, + 'invalid_request_error', + 'message_filter_pii_block', + ); + } + const responseId = `chatcmpl-${nanoid()}`; const created = Math.floor(Date.now() / 1000); @@ -228,6 +244,7 @@ const OpenAIChatCompletionController = async (req, res) => { endpoint: agent.provider, model_parameters: agent.model_parameters ?? {}, }; + const skillDbMethods = getSkillDbMethods(); // `filterFilesByAgentAccess` is intentionally omitted: it calls // `checkPermission` with `resourceType: AGENT`, but this route @@ -245,22 +262,35 @@ const OpenAIChatCompletionController = async (req, res) => { getUserCodeFiles: db.getUserCodeFiles, getToolFilesByIds: db.getToolFilesByIds, getCodeGeneratedFiles: db.getCodeGeneratedFiles, - listSkillsByAccess: db.listSkillsByAccess, - listAlwaysApplySkills: db.listAlwaysApplySkills, - getSkillByName: db.getSkillByName, + listSkillsByAccess: skillDbMethods.listSkillsByAccess, + listAlwaysApplySkills: skillDbMethods.listAlwaysApplySkills, + getSkillByName: skillDbMethods.getSkillByName, }; const enabledCapabilities = new Set(agentsEConfig?.capabilities); const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills); const ephemeralSkillsToggle = req.body?.ephemeralAgent?.skills === true; const accessibleSkillIds = skillsCapabilityEnabled + ? withDeploymentSkillIds( + await findAccessibleResources({ + userId: req.user.id, + role: req.user.role, + resourceType: ResourceType.SKILL, + requiredPermissions: PermissionBits.VIEW, + }), + ) + : []; + const editableSkillIds = skillsCapabilityEnabled ? await findAccessibleResources({ userId: req.user.id, role: req.user.role, resourceType: ResourceType.SKILL, - requiredPermissions: PermissionBits.VIEW, + requiredPermissions: PermissionBits.EDIT, }) : []; + const skillCreateAllowed = skillsCapabilityEnabled + ? await getSkillToolDeps().canCreateSkill({ req }) + : false; const { skillStates, defaultActiveOnShare } = await loadSkillStates({ userId: req.user.id, @@ -271,6 +301,19 @@ const OpenAIChatCompletionController = async (req, res) => { const manualSkills = extractManualSkills(req.body); + const primaryScopedSkillIds = resolveAgentScopedSkillIds({ + agent, + accessibleSkillIds, + skillsCapabilityEnabled, + ephemeralSkillsToggle, + }); + const primaryScopedEditableSkillIds = resolveAgentScopedSkillIds({ + agent, + accessibleSkillIds: editableSkillIds, + skillsCapabilityEnabled, + ephemeralSkillsToggle, + }); + const primaryConfig = await initializeAgent( { req, @@ -283,9 +326,11 @@ const OpenAIChatCompletionController = async (req, res) => { endpointOption, allowedProviders, isInitialAgent: true, - accessibleSkillIds: resolveAgentScopedSkillIds({ + accessibleSkillIds: primaryScopedSkillIds, + skillAuthoringAvailable: canAuthorSkillFiles({ agent, - accessibleSkillIds, + scopedEditableSkillIds: primaryScopedEditableSkillIds, + skillCreateAllowed, skillsCapabilityEnabled, ephemeralSkillsToggle, }), @@ -304,20 +349,17 @@ const OpenAIChatCompletionController = async (req, res) => { * @type {Map>, * tool_resources?: object, * actionsEnabled?: boolean, * }>} */ const agentToolContexts = new Map(); - agentToolContexts.set(primaryConfig.id, { - agent, - toolRegistry: primaryConfig.toolRegistry, - userMCPAuthMap: primaryConfig.userMCPAuthMap, - tool_resources: primaryConfig.tool_resources, - actionsEnabled: primaryConfig.actionsEnabled, - codeEnvAvailable: primaryConfig.codeEnvAvailable, - }); + agentToolContexts.set( + primaryConfig.id, + buildAgentToolContext({ agent, config: primaryConfig }), + ); // Only run BFS discovery (and pay `getModelsConfig` upfront) when the // primary has edges to follow — the common API case is single-agent. @@ -346,6 +388,28 @@ const OpenAIChatCompletionController = async (req, res) => { // sub-agent must clear the same sharing boundary, not the looser // in-app AGENT one. resourceType: ResourceType.REMOTE_AGENT, + computeAccessibleSkillIds: (handoffAgent) => + resolveAgentScopedSkillIds({ + agent: handoffAgent, + accessibleSkillIds, + skillsCapabilityEnabled, + ephemeralSkillsToggle, + }), + computeSkillAuthoringAvailable: (handoffAgent) => + canAuthorSkillFiles({ + agent: handoffAgent, + scopedEditableSkillIds: resolveAgentScopedSkillIds({ + agent: handoffAgent, + accessibleSkillIds: editableSkillIds, + skillsCapabilityEnabled, + ephemeralSkillsToggle, + }), + skillCreateAllowed, + skillsCapabilityEnabled, + ephemeralSkillsToggle, + }), + skillStates, + defaultActiveOnShare, /** @see DiscoverConnectedAgentsParams.codeEnvAvailable */ codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code), }, @@ -368,14 +432,7 @@ const OpenAIChatCompletionController = async (req, res) => { logViolation, db: dbMethods, onAgentInitialized: (agentId, handoffAgent, config) => { - agentToolContexts.set(agentId, { - agent: handoffAgent, - toolRegistry: config.toolRegistry, - userMCPAuthMap: config.userMCPAuthMap, - tool_resources: config.tool_resources, - actionsEnabled: config.actionsEnabled, - codeEnvAvailable: config.codeEnvAvailable, - }); + agentToolContexts.set(agentId, buildAgentToolContext({ agent: handoffAgent, config })); }, initializeAgent, }, @@ -420,18 +477,13 @@ const OpenAIChatCompletionController = async (req, res) => { const toolEndCallback = createToolEndCallback({ req, res, artifactPromises, streamId: null }); - /* Stable for the turn: the prime lists are fixed once - `initializeAgent` resolves. Hoisted out of `loadTools` so tool - execution doesn't recompute them. `codeEnvAvailable` is read + /* Stable for the turn: the primary prime list is fixed once + `initializeAgent` resolves and is used as the fallback when a + specific agent context is unavailable. `codeEnvAvailable` is read per-agent from the stored tool context (admin cap AND that agent's `tools` list includes `execute_code`) — a skills-only agent never gains sandbox access even if the admin enabled the capability globally. */ - const skillPrimedIdsByName = buildSkillPrimedIdsByName( - primaryConfig.manualSkillPrimes, - primaryConfig.alwaysApplySkillPrimes, - ); - const toolExecuteOptions = { loadTools: async (toolNames, agentId) => { const ctx = agentToolContexts.get(agentId) ?? agentToolContexts.get(primaryConfig.id) ?? {}; @@ -442,17 +494,17 @@ const OpenAIChatCompletionController = async (req, res) => { agent: ctx.agent ?? agent, signal: abortController.signal, toolRegistry: ctx.toolRegistry, + mcpAvailableTools: ctx.mcpAvailableTools, + requestScopedConnections: ctx.requestScopedConnections, userMCPAuthMap: ctx.userMCPAuthMap, tool_resources: ctx.tool_resources, actionsEnabled: ctx.actionsEnabled, }); - return enrichWithSkillConfigurable( + return enrichLoadedToolsWithAgentContext({ result, req, - primaryConfig.accessibleSkillIds, - ctx.codeEnvAvailable === true, - skillPrimedIdsByName, - ); + ctx, + }); }, toolEndCallback, ...getSkillToolDeps(), @@ -691,6 +743,9 @@ const OpenAIChatCompletionController = async (req, res) => { conversationId, }, user: { id: userId }, + /** Bills subagent child-run model calls (reported outside the + * streamEvents loop) into the same collectedUsage array. */ + subagentUsageSink: createSubagentUsageSink(collectedUsage), }); if (!run) { diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js index 39fc215b896..c2bcd5c12e1 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -1,19 +1,25 @@ const { logger } = require('@librechat/data-schemas'); -const { Constants, ViolationTypes } = require('librechat-data-provider'); +const { Constants, ViolationTypes, isEphemeralAgentId } = require('librechat-data-provider'); const { sendEvent, getViolationInfo, buildMessageFiles, resolveTitleTiming, GenerationJobManager, + filterPersistableAbortContent, decrementPendingRequest, sanitizeMessageForTransmit, checkAndIncrementPendingRequest, + isUnpersistedPreliminaryParent, } = require('@librechat/api'); const { disposeClient, clientRegistry, requestDataMap } = require('~/server/cleanup'); +const { + getMCPRequestContext, + cleanupMCPRequestContextForReq, +} = require('~/server/services/MCPRequestContext'); const { handleAbortError } = require('~/server/middleware'); const { logViolation } = require('~/cache'); -const { saveMessage, getConvo } = require('~/models'); +const { saveMessage, getMessages, getConvo } = require('~/models'); function createCloseHandler(abortController) { return function (manual) { @@ -75,6 +81,83 @@ async function attachConversationCreatedAt(req, { userId, conversationId, isNewC } } +function getPreliminaryResponseMessageId({ messageId, responseMessageId }) { + if (typeof responseMessageId === 'string' && responseMessageId.length > 0) { + return responseMessageId; + } + + if (typeof messageId !== 'string' || messageId.length === 0) { + return null; + } + + return `${messageId.replace(/_+$/, '')}_`; +} + +function getPreliminaryUserMessage({ messageId, parentMessageId, text }, conversationId) { + if (typeof messageId !== 'string' || messageId.length === 0) { + return null; + } + + return { + messageId, + parentMessageId, + conversationId, + text, + }; +} + +function getRequestModelSpec(req, endpointOption) { + const spec = endpointOption?.spec ?? req.body?.spec; + if (typeof spec !== 'string' || spec.length === 0) { + return; + } + + const list = req.config?.modelSpecs?.list; + if (!Array.isArray(list)) { + return; + } + + return list.find((modelSpec) => modelSpec?.name === spec); +} + +function getModelSpecIconURL(modelSpec) { + return modelSpec?.iconURL ?? modelSpec?.preset?.iconURL ?? modelSpec?.preset?.endpoint ?? ''; +} + +function getEndpointIconURL(req, endpointOption) { + const iconURL = + endpointOption?.iconURL ?? getModelSpecIconURL(getRequestModelSpec(req, endpointOption)); + return iconURL || undefined; +} + +function getEndpointResponseModel(endpointOption) { + return endpointOption?.modelOptions?.model || endpointOption?.model_parameters?.model; +} + +function getAgentResponseModel(req, endpointOption) { + const agentId = endpointOption?.agent_id || req.body?.agent_id; + if (typeof agentId === 'string' && agentId.length > 0 && !isEphemeralAgentId(agentId)) { + return agentId; + } + + return getEndpointResponseModel(endpointOption); +} + +async function finishResumableRequest(req, userId) { + try { + await cleanupMCPRequestContextForReq(req); + } finally { + await decrementPendingRequest(userId); + } +} + +function rejectPreliminaryParentMessageId(res) { + return res.status(409).json({ + error: + 'Cannot submit a follow-up while the selected parent response is still being saved. Please wait and try again.', + }); +} + /** * Resumable Agent Controller - Generation runs independently of HTTP connection. * Returns streamId immediately, client subscribes separately via SSE. @@ -94,6 +177,17 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit const userId = req.user.id; + if ( + await isUnpersistedPreliminaryParent({ + userId, + conversationId: reqConversationId, + parentMessageId, + getMessages, + }) + ) { + return rejectPreliminaryParentMessageId(res); + } + /** When to generate the conversation title. `immediate` (default) fires title * generation in parallel with the response, from the user's first message; * `final` defers it until the full response completes (legacy behavior). @@ -127,6 +221,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit const job = await GenerationJobManager.createJob(streamId, userId, conversationId); const jobCreatedAt = job.createdAt; // Capture creation time to detect job replacement req._resumableStreamId = streamId; + getMCPRequestContext(req, undefined, { cleanupOnResponse: false }); // Send JSON response IMMEDIATELY so client can connect to SSE stream // This is critical: tool loading (MCP OAuth) may emit events that the client needs to receive @@ -134,6 +229,19 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit await attachConversationCreatedAt(req, { userId, conversationId, isNewConvo }); + const endpointIconURL = getEndpointIconURL(req, endpointOption); + const responseModel = getAgentResponseModel(req, endpointOption); + const preliminaryUserMessage = getPreliminaryUserMessage(req.body, conversationId); + const preliminaryResponseMessageId = getPreliminaryResponseMessageId(req.body); + await GenerationJobManager.updateMetadata(streamId, { + conversationId, + endpoint: endpointOption.endpoint, + iconURL: endpointIconURL, + model: responseModel, + responseMessageId: preliminaryResponseMessageId, + userMessage: preliminaryUserMessage, + }); + // Note: We no longer use res.on('close') to abort since we send JSON immediately. // The response closes normally after res.json(), which is not an abort condition. // Abort handling is done through GenerationJobManager via the SSE stream connection. @@ -155,6 +263,12 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit return; } + const persistableContent = filterPersistableAbortContent(aggregatedContent); + if (persistableContent.length === 0) { + logger.debug('[ResumableAgentController] No persistable content to save partial response'); + return; + } + const resumeState = await GenerationJobManager.getResumeState(streamId); if (!resumeState?.userMessage) { logger.debug('[ResumableAgentController] No user message to save partial response for'); @@ -170,13 +284,14 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit conversationId: responseConversationId, parentMessageId: resumeState.userMessage.messageId, sender: client?.sender ?? 'AI', - content: aggregatedContent, + content: persistableContent, unfinished: true, error: false, isCreatedByUser: false, user: userId, endpoint: endpointOption.endpoint, - model: endpointOption.modelOptions?.model || endpointOption.model_parameters?.model, + iconURL: resumeState.iconURL || endpointIconURL, + model: resumeState.model || responseModel, }; if (req.body?.agent_id) { @@ -194,7 +309,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit ); logger.debug( - `[ResumableAgentController] Saved partial response for ${streamId}, content parts: ${aggregatedContent.length}`, + `[ResumableAgentController] Saved partial response for ${streamId}, content parts: ${persistableContent.length}`, ); } catch (error) { logger.error('[ResumableAgentController] Error saving partial response:', error); @@ -214,7 +329,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit if (job.abortController.signal.aborted) { GenerationJobManager.completeJob(streamId, 'Request aborted during initialization'); - await decrementPendingRequest(userId); + await finishResumableRequest(req, userId); return; } @@ -274,6 +389,11 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit * `completeJob` also aborts on *successful* completion and would otherwise * cancel a title that is merely slower than a short response. */ const titleAbortController = new AbortController(); + /** Separate from `titleAbortController`: a user Stop cancels the in-flight + * title model call but keeps a title that already finished generating. + * Only a superseded/failed stream aborts this to discard such a title so it + * cannot clobber the conversation now owned by the newer run. */ + const titleDiscardController = new AbortController(); const abortTitleOnJobAbort = () => titleAbortController.abort(); if (job.abortController.signal.aborted) { titleAbortController.abort(); @@ -363,6 +483,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit immediate: true, convoReady, signal: titleAbortController.signal, + discardSignal: titleDiscardController.signal, onTitleGenerated: emitTitleEvent, }).catch((err) => { logger.error('[ResumableAgentController] Error in immediate title generation', err); @@ -439,11 +560,12 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit // unblock its persistence wait without letting it save (the newer job // owns the conversation now). titleAbortController.abort(); + titleDiscardController.abort(); job.abortController.signal.removeEventListener('abort', abortTitleOnJobAbort); acceptsTitleEvents = false; resolveConvoReady(); // Still decrement pending request since we incremented at start - await decrementPendingRequest(userId); + await finishResumableRequest(req, userId); if (immediateTitlePromise) { immediateTitlePromise.finally(() => { if (client) { @@ -493,7 +615,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit await GenerationJobManager.emitDone(streamId, finalEvent); GenerationJobManager.completeJob(streamId); - await decrementPendingRequest(userId); + await finishResumableRequest(req, userId); } else { const finalEvent = { final: true, @@ -513,7 +635,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit await GenerationJobManager.emitDone(streamId, finalEvent); GenerationJobManager.completeJob(streamId, 'Request aborted'); - await decrementPendingRequest(userId); + await finishResumableRequest(req, userId); } if (titleTiming === 'immediate') { @@ -554,6 +676,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit // `_waitForRun` would otherwise never resolve, deferring client disposal // until the 45s title timeout, and no title should persist for a failed turn. titleAbortController.abort(); + titleDiscardController.abort(); job.abortController.signal.removeEventListener('abort', abortTitleOnJobAbort); acceptsTitleEvents = false; resolveConvoReady(); @@ -570,7 +693,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit GenerationJobManager.completeJob(streamId, error.message); } - await decrementPendingRequest(userId); + await finishResumableRequest(req, userId); // Defer disposal until any immediate title settles (it holds the run/req). if (immediateTitlePromise) { @@ -594,7 +717,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit `[ResumableAgentController] Unhandled error in background generation: ${err.message}`, ); GenerationJobManager.completeJob(streamId, err.message); - await decrementPendingRequest(userId); + await finishResumableRequest(req, userId); }); } catch (error) { logger.error('[ResumableAgentController] Initialization error:', error); @@ -605,7 +728,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit await GenerationJobManager.emitError(streamId, error.message || 'Failed to start generation'); } GenerationJobManager.completeJob(streamId, error.message); - await decrementPendingRequest(userId); + await finishResumableRequest(req, userId); if (client) { disposeClient(client); } @@ -653,6 +776,17 @@ const _LegacyAgentController = async (req, res, next, initializeClient, addTitle // Match the same logic used for conversationId generation above const userId = req.user.id; + if ( + await isUnpersistedPreliminaryParent({ + userId, + conversationId: reqConversationId, + parentMessageId, + getMessages, + }) + ) { + return rejectPreliminaryParentMessageId(res); + } + await attachConversationCreatedAt(req, { userId, conversationId, isNewConvo }); // Create handler to avoid capturing the entire parent scope @@ -759,8 +893,8 @@ const _LegacyAgentController = async (req, res, next, initializeClient, addTitle // Store endpoint metadata for abort handling GenerationJobManager.updateMetadata(streamId, { endpoint: endpointOption.endpoint, - iconURL: endpointOption.iconURL, - model: endpointOption.modelOptions?.model || endpointOption.model_parameters?.model, + iconURL: getEndpointIconURL(req, endpointOption), + model: getAgentResponseModel(req, endpointOption), sender: client?.sender, }); diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index b2805fc19fc..abd292fd559 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -12,18 +12,20 @@ const { const { createRun, buildToolSet, - loadSkillStates, - resolveAgentScopedSkillIds, createSafeUser, initializeAgent, + loadSkillStates, getBalanceConfig, + injectSkillPrimes, + extractManualSkills, recordCollectedUsage, + createSubagentUsageSink, getTransactionsConfig, - extractManualSkills, - injectSkillPrimes, - createToolExecuteHandler, + findPiiMatchInMessages, discoverConnectedAgents, + createToolExecuteHandler, getRemoteAgentPermissions, + resolveAgentScopedSkillIds, // Responses API writeDone, buildResponse, @@ -56,8 +58,11 @@ const { } = require('~/server/services/PermissionService'); const { getSkillToolDeps, - enrichWithSkillConfigurable, - buildSkillPrimedIdsByName, + getSkillDbMethods, + canAuthorSkillFiles, + withDeploymentSkillIds, + buildAgentToolContext, + enrichLoadedToolsWithAgentContext, } = require('~/server/services/Endpoints/agents/skillDeps'); const { getModelsConfig } = require('~/server/controllers/ModelController'); const { logViolation } = require('~/cache'); @@ -350,6 +355,7 @@ const createResponse = async (req, res) => { // Create tool loader const loadTools = createToolLoader(abortController.signal); + const skillDbMethods = getSkillDbMethods(); // Initialize the agent first to check for disableStreaming const endpointOption = { @@ -373,9 +379,9 @@ const createResponse = async (req, res) => { getUserCodeFiles: db.getUserCodeFiles, getToolFilesByIds: db.getToolFilesByIds, getCodeGeneratedFiles: db.getCodeGeneratedFiles, - listSkillsByAccess: db.listSkillsByAccess, - listAlwaysApplySkills: db.listAlwaysApplySkills, - getSkillByName: db.getSkillByName, + listSkillsByAccess: skillDbMethods.listSkillsByAccess, + listAlwaysApplySkills: skillDbMethods.listAlwaysApplySkills, + getSkillByName: skillDbMethods.getSkillByName, }; const enabledCapabilities = new Set( @@ -384,13 +390,26 @@ const createResponse = async (req, res) => { const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills); const ephemeralSkillsToggle = req.body?.ephemeralAgent?.skills === true; const accessibleSkillIds = skillsCapabilityEnabled + ? withDeploymentSkillIds( + await findAccessibleResources({ + userId: req.user.id, + role: req.user.role, + resourceType: ResourceType.SKILL, + requiredPermissions: PermissionBits.VIEW, + }), + ) + : []; + const editableSkillIds = skillsCapabilityEnabled ? await findAccessibleResources({ userId: req.user.id, role: req.user.role, resourceType: ResourceType.SKILL, - requiredPermissions: PermissionBits.VIEW, + requiredPermissions: PermissionBits.EDIT, }) : []; + const skillCreateAllowed = skillsCapabilityEnabled + ? await getSkillToolDeps().canCreateSkill({ req }) + : false; const { skillStates, defaultActiveOnShare } = await loadSkillStates({ userId: req.user.id, @@ -401,6 +420,19 @@ const createResponse = async (req, res) => { const manualSkills = extractManualSkills(req.body); + const primaryScopedSkillIds = resolveAgentScopedSkillIds({ + agent, + accessibleSkillIds, + skillsCapabilityEnabled, + ephemeralSkillsToggle, + }); + const primaryScopedEditableSkillIds = resolveAgentScopedSkillIds({ + agent, + accessibleSkillIds: editableSkillIds, + skillsCapabilityEnabled, + ephemeralSkillsToggle, + }); + const primaryConfig = await initializeAgent( { req, @@ -413,9 +445,11 @@ const createResponse = async (req, res) => { endpointOption, allowedProviders, isInitialAgent: true, - accessibleSkillIds: resolveAgentScopedSkillIds({ + accessibleSkillIds: primaryScopedSkillIds, + skillAuthoringAvailable: canAuthorSkillFiles({ agent, - accessibleSkillIds, + scopedEditableSkillIds: primaryScopedEditableSkillIds, + skillCreateAllowed, skillsCapabilityEnabled, ephemeralSkillsToggle, }), @@ -434,20 +468,17 @@ const createResponse = async (req, res) => { * @type {Map>, * tool_resources?: object, * actionsEnabled?: boolean, * }>} */ const agentToolContexts = new Map(); - agentToolContexts.set(primaryConfig.id, { - agent, - toolRegistry: primaryConfig.toolRegistry, - userMCPAuthMap: primaryConfig.userMCPAuthMap, - tool_resources: primaryConfig.tool_resources, - actionsEnabled: primaryConfig.actionsEnabled, - codeEnvAvailable: primaryConfig.codeEnvAvailable, - }); + agentToolContexts.set( + primaryConfig.id, + buildAgentToolContext({ agent, config: primaryConfig }), + ); // Only run BFS discovery (and pay `getModelsConfig` upfront) when the // primary has edges to follow — the common API case is single-agent. @@ -476,6 +507,28 @@ const createResponse = async (req, res) => { // sub-agent must clear the same sharing boundary, not the looser // in-app AGENT one. resourceType: ResourceType.REMOTE_AGENT, + computeAccessibleSkillIds: (handoffAgent) => + resolveAgentScopedSkillIds({ + agent: handoffAgent, + accessibleSkillIds, + skillsCapabilityEnabled, + ephemeralSkillsToggle, + }), + computeSkillAuthoringAvailable: (handoffAgent) => + canAuthorSkillFiles({ + agent: handoffAgent, + scopedEditableSkillIds: resolveAgentScopedSkillIds({ + agent: handoffAgent, + accessibleSkillIds: editableSkillIds, + skillsCapabilityEnabled, + ephemeralSkillsToggle, + }), + skillCreateAllowed, + skillsCapabilityEnabled, + ephemeralSkillsToggle, + }), + skillStates, + defaultActiveOnShare, /** @see DiscoverConnectedAgentsParams.codeEnvAvailable */ codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code), }, @@ -498,14 +551,7 @@ const createResponse = async (req, res) => { logViolation, db: dbMethods, onAgentInitialized: (agentId, handoffAgent, config) => { - agentToolContexts.set(agentId, { - agent: handoffAgent, - toolRegistry: config.toolRegistry, - userMCPAuthMap: config.userMCPAuthMap, - tool_resources: config.tool_resources, - actionsEnabled: config.actionsEnabled, - codeEnvAvailable: config.codeEnvAvailable, - }); + agentToolContexts.set(agentId, buildAgentToolContext({ agent: handoffAgent, config })); }, initializeAgent, }, @@ -532,6 +578,17 @@ const createResponse = async (req, res) => { typeof request.input === 'string' ? request.input : request.input, ); + const piiHit = findPiiMatchInMessages(inputMessages, appConfig?.messageFilter?.pii); + if (piiHit != null) { + return sendResponsesErrorResponse( + res, + 400, + `Message contains a ${piiHit.label}. Remove it and try again.`, + 'invalid_request', + 'message_filter_pii_block', + ); + } + // Merge previous messages with new input const allMessages = [...previousMessages, ...inputMessages]; @@ -573,19 +630,13 @@ const createResponse = async (req, res) => { } } - /* Stable for the turn: the prime lists are fixed once - `initializeAgent` resolves. Hoisted here so both the streaming - and non-streaming `loadTools` closures below reuse it without - recomputing per tool execution. `codeEnvAvailable` is read + /* Stable for the turn: the primary prime list is fixed once + `initializeAgent` resolves and is used as the fallback when a + specific agent context is unavailable. `codeEnvAvailable` is read per-agent from the stored tool context (admin cap AND that agent's `tools` list includes `execute_code`) — a skills-only agent never gains sandbox access even if the admin enabled the capability globally. */ - const skillPrimedIdsByName = buildSkillPrimedIdsByName( - manualSkillPrimes, - alwaysApplySkillPrimes, - ); - // Create tracker for streaming or aggregator for non-streaming const tracker = actuallyStreaming ? createResponseTracker() : null; const aggregator = actuallyStreaming ? null : createResponseAggregator(); @@ -635,17 +686,17 @@ const createResponse = async (req, res) => { agent: ctx.agent ?? agent, signal: abortController.signal, toolRegistry: ctx.toolRegistry, + mcpAvailableTools: ctx.mcpAvailableTools, + requestScopedConnections: ctx.requestScopedConnections, userMCPAuthMap: ctx.userMCPAuthMap, tool_resources: ctx.tool_resources, actionsEnabled: ctx.actionsEnabled, }); - return enrichWithSkillConfigurable( + return enrichLoadedToolsWithAgentContext({ result, req, - primaryConfig.accessibleSkillIds, - ctx.codeEnvAvailable === true, - skillPrimedIdsByName, - ); + ctx, + }); }, toolEndCallback, ...getSkillToolDeps(), @@ -699,6 +750,9 @@ const createResponse = async (req, res) => { conversationId, }, user: { id: userId }, + /** Bills subagent child-run model calls (reported outside the + * streamEvents loop) into the same collectedUsage array. */ + subagentUsageSink: createSubagentUsageSink(collectedUsage), }); if (!run) { @@ -811,17 +865,17 @@ const createResponse = async (req, res) => { agent: ctx.agent ?? agent, signal: abortController.signal, toolRegistry: ctx.toolRegistry, + mcpAvailableTools: ctx.mcpAvailableTools, + requestScopedConnections: ctx.requestScopedConnections, userMCPAuthMap: ctx.userMCPAuthMap, tool_resources: ctx.tool_resources, actionsEnabled: ctx.actionsEnabled, }); - return enrichWithSkillConfigurable( + return enrichLoadedToolsWithAgentContext({ result, req, - primaryConfig.accessibleSkillIds, - ctx.codeEnvAvailable === true, - skillPrimedIdsByName, - ); + ctx, + }); }, toolEndCallback, ...getSkillToolDeps(), @@ -873,6 +927,9 @@ const createResponse = async (req, res) => { conversationId, }, user: { id: userId }, + /** Bills subagent child-run model calls (reported outside the + * streamEvents loop) into the same collectedUsage array. */ + subagentUsageSink: createSubagentUsageSink(collectedUsage), }); if (!run) { diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index adb78d1b566..b7f7a9bd29b 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -77,10 +77,9 @@ const sanitizeViewerSkillScope = (agent, accessibleSkillSet) => { const configuredSkills = Array.isArray(agent.skills) ? agent.skills : []; if (configuredSkills.length === 0) { + // Empty allowlist means the viewer's full accessible catalog. delete agent.skills; - if (accessibleSkillSet.size > 0) { - agent.skills_enabled = true; - } + agent.skills_enabled = true; return agent; } diff --git a/api/server/controllers/agents/v1.spec.js b/api/server/controllers/agents/v1.spec.js index f36152abec6..fda2bdd6167 100644 --- a/api/server/controllers/agents/v1.spec.js +++ b/api/server/controllers/agents/v1.spec.js @@ -1548,6 +1548,33 @@ describe('Agent Controllers - Mass Assignment Protection', () => { expect(response.data[0].skills_enabled).toBeUndefined(); }); + test('should preserve enabled skill scope for VIEW list callers with an empty allowlist', async () => { + await Agent.findByIdAndUpdate(agentA1._id, { + skills_enabled: true, + skills: [], + }); + + mockReq.user.id = userB.toString(); + mockReq.query.requiredPermission = String(PermissionBits.VIEW); + findAccessibleResources.mockImplementation(({ resourceType }) => { + if (resourceType === ResourceType.AGENT) { + return Promise.resolve([agentA1._id]); + } + if (resourceType === ResourceType.SKILL) { + return Promise.resolve([]); + } + return Promise.resolve([]); + }); + findPubliclyAccessibleResources.mockResolvedValue([]); + + await getListAgentsHandler(mockReq, mockRes); + + const response = mockRes.json.mock.calls[0][0]; + expect(response.data).toHaveLength(1); + expect(response.data[0].skills).toBeUndefined(); + expect(response.data[0].skills_enabled).toBe(true); + }); + test('should return raw skill configuration for EDIT list callers', async () => { const visibleSkillId = new mongoose.Types.ObjectId(); const hiddenSkillId = new mongoose.Types.ObjectId(); diff --git a/api/server/controllers/mcp.js b/api/server/controllers/mcp.js index 02e29d75963..85b840891dc 100644 --- a/api/server/controllers/mcp.js +++ b/api/server/controllers/mcp.js @@ -5,9 +5,10 @@ * @import { MCPServerRegistry } from '@librechat/api' * @import { MCPServerDocument } from 'librechat-data-provider' */ -const { logger } = require('@librechat/data-schemas'); +const { logger, SystemCapabilities } = require('@librechat/data-schemas'); const { checkAccess, + isUserSourced, MCPErrorCodes, redactServerSecrets, redactAllServerSecrets, @@ -17,9 +18,11 @@ const { const { Constants, Permissions, + ResourceType, + PermissionBits, PermissionTypes, - MCPServerUserInputSchema, MCP_USER_INPUT_FIELDS, + MCPServerUserInputSchema, } = require('librechat-data-provider'); const { resolveConfigServers, @@ -27,6 +30,8 @@ const { resolveAllMcpConfigs, } = require('~/server/services/MCP'); const { cacheMCPServerTools, getMCPServerTools } = require('~/server/services/Config'); +const { getResourcePermissionsMap } = require('~/server/services/PermissionService'); +const { hasCapability } = require('~/server/middleware/roles/capabilities'); const { getMCPManager, getMCPServersRegistry } = require('~/config'); const db = require('~/models'); @@ -96,7 +101,7 @@ const getMCPTools = async (req, res) => { try { return { serverName, - tools: await getMCPServerTools(userId, serverName), + tools: await getMCPServerTools(userId, serverName, mcpConfig[serverName]), }; } catch (error) { logger.error(`[getMCPTools] Error fetching cached tools for ${serverName}:`, error); @@ -125,7 +130,12 @@ const getMCPTools = async (req, res) => { if (Object.keys(serverTools).length > 0) { // Cache asynchronously without blocking - cacheMCPServerTools({ userId, serverName, serverTools }).catch((err) => + cacheMCPServerTools({ + userId, + serverName, + serverTools, + serverConfig: mcpConfig[serverName], + }).catch((err) => logger.error(`[getMCPTools] Failed to cache tools for ${serverName}:`, err), ); } @@ -191,6 +201,55 @@ const getMCPTools = async (req, res) => { res.status(500).json({ message: error.message }); } }; +/** Mirrors canAccessResource's capability bypass plus per-resource ACL EDIT check. */ +async function computeCanEditByServer(req, serverConfigs) { + const canEditByServer = new Map(); + let bypass = false; + try { + bypass = await hasCapability(req.user, SystemCapabilities.MANAGE_MCP_SERVERS); + } catch (err) { + logger.warn(`[computeCanEditByServer] Capability bypass check failed: ${err.message}`); + } + if (bypass) { + for (const name of Object.keys(serverConfigs)) { + canEditByServer.set(name, true); + } + return canEditByServer; + } + const dbIdsToCheck = []; + const dbIdToServerName = new Map(); + for (const [name, config] of Object.entries(serverConfigs)) { + if (config.dbId) { + dbIdsToCheck.push(config.dbId); + dbIdToServerName.set(String(config.dbId), name); + continue; + } + canEditByServer.set(name, isUserSourced(config)); + } + if (dbIdsToCheck.length > 0) { + try { + const permsMap = await getResourcePermissionsMap({ + userId: req.user.id, + role: req.user.role, + resourceType: ResourceType.MCPSERVER, + resourceIds: dbIdsToCheck, + }); + for (const [dbIdStr, name] of dbIdToServerName) { + const bits = permsMap.get(dbIdStr) ?? 0; + canEditByServer.set(name, (bits & PermissionBits.EDIT) !== 0); + } + } catch (err) { + logger.warn( + `[computeCanEditByServer] ACL lookup failed, defaulting to no edit: ${err.message}`, + ); + for (const name of dbIdToServerName.values()) { + canEditByServer.set(name, false); + } + } + } + return canEditByServer; +} + /** * Get all MCP servers with permissions * @route GET /api/mcp/servers @@ -203,7 +262,8 @@ const getMCPServersList = async (req, res) => { } const serverConfigs = await resolveAllMcpConfigs(userId, req.user); - return res.json(redactAllServerSecrets(serverConfigs)); + const canEditByServer = await computeCanEditByServer(req, serverConfigs); + return res.json(redactAllServerSecrets(serverConfigs, { canEditByServer })); } catch (error) { logger.error('[getMCPServersList]', error); res.status(500).json({ error: error.message }); @@ -301,7 +361,7 @@ const createMCPServerController = async (req, res) => { ); res.status(201).json({ serverName: result.serverName, - ...redactServerSecrets(result.config), + ...redactServerSecrets(result.config, { canEdit: true }), }); } catch (error) { logger.error('[createMCPServer]', error); @@ -334,7 +394,9 @@ const getMCPServerById = async (req, res) => { return res.status(404).json({ message: 'MCP server not found' }); } - res.status(200).json(redactServerSecrets(parsedConfig)); + const canEditMap = await computeCanEditByServer(req, { [serverName]: parsedConfig }); + const canEdit = canEditMap.get(serverName) ?? false; + res.status(200).json(redactServerSecrets(parsedConfig, { canEdit })); } catch (error) { logger.error('[getMCPServerById]', error); res.status(500).json({ message: error.message }); @@ -395,7 +457,7 @@ const updateMCPServerController = async (req, res) => { userId, ); - res.status(200).json(redactServerSecrets(parsedConfig)); + res.status(200).json(redactServerSecrets(parsedConfig, { canEdit: true })); } catch (error) { logger.error('[updateMCPServer]', error); const mcpErrorResponse = handleMCPError(error, res); diff --git a/api/server/experimental.js b/api/server/experimental.js index 8fd94c67232..ac289615a35 100644 --- a/api/server/experimental.js +++ b/api/server/experimental.js @@ -16,15 +16,19 @@ const { isEnabled, apiNotFound, ErrorController, + QUERY_DEVTOOLS_HEADER, performStartupChecks, handleJsonParseError, initializeFileStorage, + maybeInjectQueryDevtoolsBootstrap, preAuthTenantMiddleware, } = require('@librechat/api'); const { connectDb, indexSync } = require('~/db'); const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager'); +const { capabilityContextMiddleware } = require('./middleware/roles/capabilities'); const createValidateImageRequest = require('./middleware/validateImageRequest'); const { startExpiredFileSweep } = require('./services/Files/process'); +const { initializeGitHubSkillSync } = require('./services/Skills/sync'); const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies'); const { updateInterfacePermissions: updateInterfacePerms } = require('@librechat/api'); const { @@ -36,6 +40,7 @@ const { const { checkMigrations } = require('./services/start/migration'); const initializeMCPs = require('./services/initializeMCPs'); const configureSocialLogins = require('./socialLogins'); +const createSpaFallback = require('./utils/fallback'); const { getAppConfig } = require('./services/Config'); const staticCache = require('./utils/staticCache'); const optionalJwtAuth = require('./middleware/optionalJwtAuth'); @@ -294,6 +299,7 @@ if (cluster.isMaster) { /** Initialize app configuration */ const appConfig = await getAppConfig(); initializeFileStorage(appConfig); + initializeGitHubSkillSync(appConfig); expiredFileSweepOptions = { appConfig, loadAppConfig: getAppConfig }; startExpiredFileSweepOnce(); await performStartupChecks(appConfig); @@ -315,6 +321,23 @@ if (cluster.isMaster) { } } + const sendIndexHtml = (req, res) => { + res.set({ + 'Cache-Control': process.env.INDEX_CACHE_CONTROL || 'no-cache, no-store, must-revalidate', + Pragma: process.env.INDEX_PRAGMA || 'no-cache', + Expires: process.env.INDEX_EXPIRES || '0', + }); + res.vary(QUERY_DEVTOOLS_HEADER); + + const lang = req.cookies.lang || req.headers['accept-language']?.split(',')[0] || 'en-US'; + const saneLang = lang.replace(/"/g, '"'); + let updatedIndexHtml = indexHTML.replace(/lang="en-US"/g, `lang="${saneLang}"`); + updatedIndexHtml = maybeInjectQueryDevtoolsBootstrap(updatedIndexHtml, req); + + res.type('html'); + res.send(updatedIndexHtml); + }; + /** Health check endpoint */ app.get('/health', (_req, res) => res.status(200).send('OK')); @@ -348,6 +371,7 @@ if (cluster.isMaster) { logger.warn('Response compression has been disabled via DISABLE_COMPRESSION.'); } + app.get('/index.html', sendIndexHtml); app.use(staticCache(appConfig.paths.dist)); app.use(staticCache(appConfig.paths.fonts)); app.use(staticCache(appConfig.paths.assets)); @@ -370,10 +394,13 @@ if (cluster.isMaster) { await configureSocialLogins(app); } + app.use(capabilityContextMiddleware); + /** Routes */ app.use('/oauth', routes.oauth); app.use('/api/auth', routes.auth); app.use('/api/admin', routes.adminAuth); + app.use('/api/admin/skills', routes.adminSkills); app.use('/api/actions', routes.actions); app.use('/api/keys', routes.keys); app.use('/api/api-keys', routes.apiKeys); @@ -382,6 +409,7 @@ if (cluster.isMaster) { app.use('/api/messages', routes.messages); app.use('/api/convos', routes.convos); app.use('/api/presets', routes.presets); + app.use('/api/projects', routes.projects); app.use('/api/prompts', routes.prompts); app.use('/api/skills', routes.skills); app.use('/api/categories', routes.categories); @@ -405,20 +433,7 @@ if (cluster.isMaster) { app.use('/api', apiNotFound); /** SPA fallback - serve index.html for all unmatched routes */ - app.use((req, res) => { - res.set({ - 'Cache-Control': process.env.INDEX_CACHE_CONTROL || 'no-cache, no-store, must-revalidate', - Pragma: process.env.INDEX_PRAGMA || 'no-cache', - Expires: process.env.INDEX_EXPIRES || '0', - }); - - const lang = req.cookies.lang || req.headers['accept-language']?.split(',')[0] || 'en-US'; - const saneLang = lang.replace(/"/g, '"'); - let updatedIndexHtml = indexHTML.replace(/lang="en-US"/g, `lang="${saneLang}"`); - - res.type('html'); - res.send(updatedIndexHtml); - }); + app.use(createSpaFallback(sendIndexHtml)); /** Error handler (must be last - Express identifies error middleware by its 4-arg signature) */ app.use(ErrorController); diff --git a/api/server/index.js b/api/server/index.js index bd2d260e762..501946a4136 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -19,8 +19,11 @@ const { performStartupChecks, handleJsonParseError, GenerationJobManager, + QUERY_DEVTOOLS_HEADER, createStreamServices, initializeFileStorage, + initializeDeploymentSkills, + maybeInjectQueryDevtoolsBootstrap, preAuthTenantMiddleware, setupGracefulShutdown, updateInterfacePermissions, @@ -36,11 +39,13 @@ const initializeOAuthReconnectManager = require('./services/initializeOAuthRecon const { capabilityContextMiddleware } = require('./middleware/roles/capabilities'); const createValidateImageRequest = require('./middleware/validateImageRequest'); const { startExpiredFileSweep } = require('./services/Files/process'); +const { initializeGitHubSkillSync } = require('./services/Skills/sync'); const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies'); const { checkMigrations } = require('./services/start/migration'); const optionalJwtAuth = require('./middleware/optionalJwtAuth'); const initializeMCPs = require('./services/initializeMCPs'); const configureSocialLogins = require('./socialLogins'); +const createSpaFallback = require('./utils/fallback'); const { getAppConfig } = require('./services/Config'); const staticCache = require('./utils/staticCache'); const noIndex = require('./middleware/noIndex'); @@ -56,6 +61,30 @@ const trusted_proxy = Number(TRUST_PROXY) || 1; /* trust first proxy by default const app = express(); let serverReady = false; +const SERVER_NOT_READY_CODE = 'SERVER_NOT_READY'; +const CHAT_START_RETRY_AFTER_SECONDS = '1'; + +const rejectChatStartsUntilReady = (req, res, next) => { + if (serverReady || req.method !== 'POST' || req.path === '/abort') { + return next(); + } + + res.set('Retry-After', CHAT_START_RETRY_AFTER_SECONDS); + return res.status(503).json({ + code: SERVER_NOT_READY_CODE, + error: 'Server is still starting. Please retry shortly.', + }); +}; + +const configureGenerationStreams = () => { + const streamServices = createStreamServices(); + GenerationJobManager.configure({ + ...streamServices, + cleanupOnComplete: !isEnabled(process.env.STREAM_KEEP_COMPLETED_JOBS), + }); + GenerationJobManager.initialize(); +}; + const startServer = async () => { const { metricsMiddleware, metricsRouter } = createMetrics(); if (!process.env.METRICS_SECRET) { @@ -92,6 +121,8 @@ const startServer = async () => { }); const appConfig = await getAppConfig({ baseOnly: true }); initializeFileStorage(appConfig); + await initializeDeploymentSkills({ projectRoot: path.resolve(__dirname, '../..') }); + initializeGitHubSkillSync(appConfig); startExpiredFileSweep({ appConfig, loadAppConfig: getAppConfig }); await runAsSystem(async () => { await performStartupChecks(appConfig); @@ -114,6 +145,23 @@ const startServer = async () => { } } + const sendIndexHtml = (req, res) => { + res.set({ + 'Cache-Control': process.env.INDEX_CACHE_CONTROL || 'no-cache, no-store, must-revalidate', + Pragma: process.env.INDEX_PRAGMA || 'no-cache', + Expires: process.env.INDEX_EXPIRES || '0', + }); + res.vary(QUERY_DEVTOOLS_HEADER); + + const lang = req.cookies.lang || req.headers['accept-language']?.split(',')[0] || 'en-US'; + const saneLang = lang.replace(/"/g, '"'); + let updatedIndexHtml = indexHTML.replace(/lang="en-US"/g, `lang="${saneLang}"`); + updatedIndexHtml = maybeInjectQueryDevtoolsBootstrap(updatedIndexHtml, req); + + res.type('html'); + res.send(updatedIndexHtml); + }; + app.get('/health', (_req, res) => res.status(200).send('OK')); app.get('/livez', (_req, res) => res.status(200).send('OK')); app.get('/readyz', (_req, res) => { @@ -153,6 +201,7 @@ const startServer = async () => { console.warn('Response compression has been disabled via DISABLE_COMPRESSION.'); } + app.get('/index.html', sendIndexHtml); app.use(staticCache(appConfig.paths.dist)); app.use(staticCache(appConfig.paths.fonts)); app.use(staticCache(appConfig.paths.assets)); @@ -192,6 +241,7 @@ const startServer = async () => { app.use('/api/admin/grants', routes.adminGrants); app.use('/api/admin/groups', routes.adminGroups); app.use('/api/admin/roles', routes.adminRoles); + app.use('/api/admin/skills', routes.adminSkills); app.use('/api/admin/users', routes.adminUsers); app.use('/api/actions', routes.actions); app.use('/api/keys', routes.keys); @@ -201,6 +251,7 @@ const startServer = async () => { app.use('/api/messages', routes.messages); app.use('/api/convos', routes.convos); app.use('/api/presets', routes.presets); + app.use('/api/projects', routes.projects); app.use('/api/prompts', routes.prompts); app.use('/api/skills', routes.skills); app.use('/api/categories', routes.categories); @@ -213,6 +264,7 @@ const startServer = async () => { app.use('/images/', createValidateImageRequest(appConfig.secureImageLinks), routes.staticRoute); app.use('/api/share', preAuthTenantMiddleware, routes.share); app.use('/api/roles', routes.roles); + app.use('/api/agents/chat', rejectChatStartsUntilReady); app.use('/api/agents', routes.agents); app.use('/api/banner', routes.banner); app.use('/api/memories', routes.memories); @@ -228,20 +280,7 @@ const startServer = async () => { app.use('/api', apiNotFound); /** SPA fallback - serve index.html for all unmatched routes */ - app.use((req, res) => { - res.set({ - 'Cache-Control': process.env.INDEX_CACHE_CONTROL || 'no-cache, no-store, must-revalidate', - Pragma: process.env.INDEX_PRAGMA || 'no-cache', - Expires: process.env.INDEX_EXPIRES || '0', - }); - - const lang = req.cookies.lang || req.headers['accept-language']?.split(',')[0] || 'en-US'; - const saneLang = lang.replace(/"/g, '"'); - let updatedIndexHtml = indexHTML.replace(/lang="en-US"/g, `lang="${saneLang}"`); - - res.type('html'); - res.send(updatedIndexHtml); - }); + app.use(createSpaFallback(sendIndexHtml)); /** Record trace errors before the final error controller. */ if (telemetry.enabled) { @@ -250,6 +289,8 @@ const startServer = async () => { /** Error handler (must be last - Express identifies error middleware by its 4-arg signature) */ app.use(ErrorController); + configureGenerationStreams(); + const server = app.listen(port, host, async (err) => { if (err) { logger.error('Failed to start server:', err); @@ -279,14 +320,6 @@ const startServer = async () => { }); await checkMigrations(); - // Configure stream services (auto-detects Redis from USE_REDIS env var) - const streamServices = createStreamServices(); - GenerationJobManager.configure({ - ...streamServices, - cleanupOnComplete: !isEnabled(process.env.STREAM_KEEP_COMPLETED_JOBS), - }); - GenerationJobManager.initialize(); - const inspectFlags = process.execArgv.some((arg) => arg.startsWith('--inspect')); if (inspectFlags || isEnabled(process.env.MEM_DIAG)) { memoryDiagnostics.start(); diff --git a/api/server/index.spec.js b/api/server/index.spec.js index 573770e2828..3e0fc07127d 100644 --- a/api/server/index.spec.js +++ b/api/server/index.spec.js @@ -83,6 +83,33 @@ describe('Telemetry wiring', () => { }); }); +describe('Startup readiness wiring', () => { + const source = fs.readFileSync(path.join(__dirname, 'index.js'), 'utf8'); + + it('configures generation streams before the server accepts requests', () => { + const streamConfigIndex = source.indexOf('configureGenerationStreams();'); + const listenIndex = source.indexOf('const server = app.listen'); + const postListenMcpIndex = source.indexOf('await initializeMCPs();'); + + expect(streamConfigIndex).toBeGreaterThan(-1); + expect(listenIndex).toBeGreaterThan(-1); + expect(postListenMcpIndex).toBeGreaterThan(-1); + expect(streamConfigIndex).toBeLessThan(listenIndex); + expect(streamConfigIndex).toBeLessThan(postListenMcpIndex); + }); + + it('mounts the chat-start readiness gate before agent routes', () => { + const readinessGateIndex = source.indexOf( + "app.use('/api/agents/chat', rejectChatStartsUntilReady);", + ); + const agentsRouteIndex = source.indexOf("app.use('/api/agents', routes.agents);"); + + expect(readinessGateIndex).toBeGreaterThan(-1); + expect(agentsRouteIndex).toBeGreaterThan(-1); + expect(readinessGateIndex).toBeLessThan(agentsRouteIndex); + }); +}); + describe('Server Configuration', () => { // Increase the default timeout to allow for Mongo cleanup jest.setTimeout(30_000); @@ -185,6 +212,32 @@ describe('Server Configuration', () => { expect(response.headers['content-type']).toMatch(/html/); }); + it('should gate React Query Devtools config in SPA HTML by debug header', async () => { + const defaultResponse = await request(app).get('/this/does/not/exist'); + const debugResponse = await request(app) + .get('/this/does/not/exist') + .set('x-librechat-enable-query-devtools', '1'); + const directIndexResponse = await request(app) + .get('/index.html') + .set('x-librechat-enable-query-devtools', '1'); + + expect(defaultResponse.status).toBe(200); + expect(defaultResponse.headers.vary).toContain('x-librechat-enable-query-devtools'); + expect(defaultResponse.text).not.toContain('enableQueryDevtools'); + + expect(debugResponse.status).toBe(200); + expect(debugResponse.headers.vary).toContain('x-librechat-enable-query-devtools'); + expect(debugResponse.text).toContain('window.__LIBRECHAT_CONFIG__'); + expect(debugResponse.text).toContain('data-librechat-query-devtools="true"'); + expect(debugResponse.text).toContain('"enableQueryDevtools":true'); + + expect(directIndexResponse.status).toBe(200); + expect(directIndexResponse.headers.vary).toContain('x-librechat-enable-query-devtools'); + expect(directIndexResponse.text).toContain('window.__LIBRECHAT_CONFIG__'); + expect(directIndexResponse.text).toContain('data-librechat-query-devtools="true"'); + expect(directIndexResponse.text).toContain('"enableQueryDevtools":true'); + }); + it('should return 500 for unknown errors via ErrorController', async () => { // Testing the error handling here on top of unit tests to ensure the middleware is correctly integrated diff --git a/api/server/middleware/__tests__/requireJwtAuth.spec.js b/api/server/middleware/__tests__/requireJwtAuth.spec.js index 4059be24098..b70f371a941 100644 --- a/api/server/middleware/__tests__/requireJwtAuth.spec.js +++ b/api/server/middleware/__tests__/requireJwtAuth.spec.js @@ -43,6 +43,7 @@ jest.mock('@librechat/data-schemas', () => { getTenantId: () => tenantStorage.getStore()?.tenantId, getUserId: () => tenantStorage.getStore()?.userId, getRequestId: () => tenantStorage.getStore()?.requestId, + logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() }, tenantStorage, }; }); @@ -53,6 +54,152 @@ jest.mock('@librechat/data-schemas', () => { // primitives. The real implementation is covered by packages/api tenant.spec.ts. jest.mock('@librechat/api', () => { const { tenantStorage } = require('@librechat/data-schemas'); + const normalizeAuthLogValue = (value) => { + if (value == null) { + return undefined; + } + if (Array.isArray(value)) { + for (const entry of value) { + const normalized = normalizeAuthLogValue(entry); + if (normalized) { + return normalized; + } + } + return undefined; + } + if (typeof value === 'string') { + const trimmed = value.trim(); + return trimmed || undefined; + } + if (typeof value === 'number' || typeof value === 'boolean') { + return String(value); + } + return undefined; + }; + const normalizeAuthLogContextValue = (value) => { + if (value == null) { + return undefined; + } + if (Array.isArray(value)) { + const values = value + .map((entry) => normalizeAuthLogValue(entry)) + .filter((entry) => entry !== undefined); + return values.length > 0 ? values : undefined; + } + if (typeof value === 'string') { + return normalizeAuthLogValue(value); + } + if (typeof value === 'number') { + return Number.isFinite(value) ? value : undefined; + } + if (typeof value === 'boolean') { + return value; + } + return undefined; + }; + const getAuthFailureField = (source, field) => { + if (!source) { + return undefined; + } + if (typeof source === 'string') { + return field === 'message' ? source : undefined; + } + if (typeof source === 'object') { + try { + return source[field]; + } catch { + return undefined; + } + } + return undefined; + }; + const getAuthFailureReason = (err, info, fallback = 'Unauthorized') => + normalizeAuthLogValue(getAuthFailureField(info, 'message')) ?? + normalizeAuthLogValue(getAuthFailureField(err, 'message')) ?? + fallback; + const getAuthFailureErrorName = (err, info) => + normalizeAuthLogValue(getAuthFailureField(info, 'name')) ?? + normalizeAuthLogValue(getAuthFailureField(err, 'name')); + const getSafeTokenProvider = (tokenProvider) => { + const normalized = normalizeAuthLogValue(tokenProvider); + if (!normalized) { + return undefined; + } + return normalized === 'openid' || normalized === 'librechat' ? normalized : 'other'; + }; + const normalizeRoutePath = (path) => { + if (typeof path === 'string') { + return normalizeAuthLogValue(path); + } + if (Array.isArray(path)) { + for (const entry of path) { + const normalized = normalizeRoutePath(entry); + if (normalized) { + return normalized; + } + } + } + return undefined; + }; + const joinRoutePath = (baseUrl, routePath) => { + const normalizedRoute = routePath === '/' ? '' : routePath; + if (!baseUrl) { + return normalizedRoute || '/'; + } + if (!normalizedRoute) { + return baseUrl; + } + return `${baseUrl.replace(/\/$/, '')}/${normalizedRoute.replace(/^\//, '')}`; + }; + const bucketConcretePath = (path) => { + const queryless = path?.split('?')[0]; + if (!queryless) { + return undefined; + } + const segments = queryless.split('/').filter(Boolean); + if (segments.length === 0) { + return '/'; + } + if (segments[0] === 'api' && segments[1]) { + return `/${segments.slice(0, 2).join('/')}`; + } + return `/${segments[0]}`; + }; + const getRequestPath = (req) => { + const baseUrl = normalizeAuthLogValue(req.baseUrl); + const routePath = normalizeRoutePath(req.route?.path); + if (routePath) { + return joinRoutePath(baseUrl, routePath); + } + if (baseUrl) { + return baseUrl; + } + const path = + normalizeAuthLogValue(req.path) ?? normalizeAuthLogValue(req.originalUrl ?? req.url); + return bucketConcretePath(path); + }; + const compactAuthLogContext = (log) => + Object.fromEntries( + Object.entries(log) + .map(([key, value]) => [key, normalizeAuthLogContextValue(value)]) + .filter(([, value]) => value !== undefined), + ); + const buildSafeAuthLogContext = (req, authState, extra = {}) => + compactAuthLogContext({ + ...extra, + request_id: + normalizeAuthLogValue(req.requestId) ?? + normalizeAuthLogValue(req.id) ?? + normalizeAuthLogValue(req.headers?.['x-request-id']) ?? + normalizeAuthLogValue(req.headers?.['x-correlation-id']), + method: normalizeAuthLogValue(req.method), + path: getRequestPath(req), + token_provider: getSafeTokenProvider(authState.tokenProvider), + openid_reuse_enabled: authState.openidReuseEnabled, + openid_jwt_available: authState.openidJwtAvailable, + has_openid_reuse_user_id: authState.hasOpenIdReuseUserId, + }); + const formatAuthLogMessage = (message, context) => `${message} ${JSON.stringify(context)}`; const normalizeContextValue = (value) => { const trimmed = value?.trim?.(); return trimmed || undefined; @@ -66,6 +213,11 @@ jest.mock('@librechat/api', () => { normalizeContextValue(req.headers?.['x-correlation-id']); return { isEnabled: jest.fn(() => false), + recordRumProxyRequest: jest.fn(), + getAuthFailureReason, + getAuthFailureErrorName, + buildSafeAuthLogContext, + formatAuthLogMessage, maybeRefreshCloudFrontAuthCookiesMiddleware: jest.fn((req, res, next) => next()), tenantContextMiddleware: (req, res, next) => { const context = { @@ -84,8 +236,13 @@ jest.mock('@librechat/api', () => { // ── Helpers ───────────────────────────────────────────────────────────── const requireJwtAuth = require('../requireJwtAuth'); -const { getTenantId, getUserId } = require('@librechat/data-schemas'); -const { isEnabled, maybeRefreshCloudFrontAuthCookiesMiddleware } = require('@librechat/api'); +const { requireRumProxyAuth } = requireJwtAuth; +const { getTenantId, getUserId, logger } = require('@librechat/data-schemas'); +const { + isEnabled, + maybeRefreshCloudFrontAuthCookiesMiddleware, + recordRumProxyRequest, +} = require('@librechat/api'); const passport = require('passport'); const jwtSecret = 'test-refresh-secret'; @@ -99,7 +256,11 @@ function signedOpenIdUserCookie(userId = 'user-openid') { } function mockRes() { - return { status: jest.fn().mockReturnThis(), json: jest.fn().mockReturnThis() }; + return { + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + end: jest.fn().mockReturnThis(), + }; } /** Runs requireJwtAuth and returns the tenantId observed inside next(). */ @@ -127,6 +288,11 @@ describe('requireJwtAuth tenant context chaining', () => { mockRegisteredStrategies = new Set(['jwt']); isEnabled.mockReturnValue(false); maybeRefreshCloudFrontAuthCookiesMiddleware.mockClear(); + logger.debug.mockClear(); + logger.info.mockClear(); + logger.warn.mockClear(); + logger.error.mockClear(); + recordRumProxyRequest.mockClear(); passport.authenticate.mockClear(); passport._strategy.mockClear(); if (originalJwtSecret === undefined) { @@ -204,6 +370,207 @@ describe('requireJwtAuth tenant context chaining', () => { expect(next).not.toHaveBeenCalled(); expect(res.status).toHaveBeenCalledWith(401); expect(getTenantId()).toBeUndefined(); + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('[requireJwtAuth] Authentication failed after all strategies'), + expect.objectContaining({ + primary_strategy: 'jwt', + fallback_attempted: false, + fallback_succeeded: false, + attempted_strategies: ['jwt'], + final_strategy: 'jwt', + reason: 'Unauthorized', + status: 401, + }), + ); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('logs OpenID JWT expiry when JWT fallback succeeds', () => { + isEnabled.mockReturnValue(true); + mockRegisteredStrategies.add('openidJwt'); + const req = mockReq(undefined, { + requestId: 'req-expired-success', + method: 'GET', + path: '/api/messages', + headers: { + cookie: `token_provider=openid; openid_user_id=${signedOpenIdUserCookie('user-jwt')}`, + }, + _mockStrategies: { + openidJwt: { + user: false, + info: { message: 'jwt expired', name: 'TokenExpiredError' }, + status: 401, + }, + jwt: { user: { id: 'user-jwt', tenantId: 'tenant-jwt', role: 'user' } }, + }, + }); + const res = mockRes(); + const next = jest.fn(); + + requireJwtAuth(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(req.authStrategy).toBe('jwt'); + expect(res.status).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('[requireJwtAuth] OpenID JWT auth failed; trying fallback'), + expect.objectContaining({ + request_id: 'req-expired-success', + method: 'GET', + path: '/api/messages', + token_provider: 'openid', + openid_reuse_enabled: true, + openid_jwt_available: true, + has_openid_reuse_user_id: true, + primary_strategy: 'openidJwt', + fallback_strategy: 'jwt', + fallback_attempted: true, + reason: 'jwt expired', + error_name: 'TokenExpiredError', + status: 401, + }), + ); + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('[requireJwtAuth] JWT fallback succeeded after OpenID JWT failure'), + expect.objectContaining({ + request_id: 'req-expired-success', + auth_strategy: 'jwt', + primary_strategy: 'openidJwt', + fallback_strategy: 'jwt', + fallback_attempted: true, + fallback_succeeded: true, + primary_failure_reason: 'jwt expired', + reason: 'jwt expired', + error_name: 'TokenExpiredError', + }), + ); + expect(logger.debug.mock.calls[0][0]).toContain('"reason":"jwt expired"'); + expect(logger.debug.mock.calls[0][0]).toContain('"fallback_attempted":true'); + expect(logger.debug.mock.calls[1][0]).toContain('"fallback_succeeded":true'); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('does not let malformed Passport info break JWT fallback logging', () => { + isEnabled.mockReturnValue(true); + mockRegisteredStrategies.add('openidJwt'); + const info = {}; + Object.defineProperties(info, { + message: { + get() { + throw new TypeError('message getter failed'); + }, + }, + name: { + get() { + throw new TypeError('name getter failed'); + }, + }, + }); + const req = mockReq(undefined, { + requestId: 'req-malformed-info', + method: 'GET', + path: '/api/messages', + headers: { + cookie: `token_provider=openid; openid_user_id=${signedOpenIdUserCookie('user-jwt')}`, + }, + _mockStrategies: { + openidJwt: { + user: false, + info, + status: 401, + }, + jwt: { user: { id: 'user-jwt', tenantId: 'tenant-jwt', role: 'user' } }, + }, + }); + const res = mockRes(); + const next = jest.fn(); + + expect(() => requireJwtAuth(req, res, next)).not.toThrow(); + + expect(next).toHaveBeenCalled(); + expect(req.authStrategy).toBe('jwt'); + expect(res.status).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('[requireJwtAuth] OpenID JWT auth failed; trying fallback'), + expect.objectContaining({ + request_id: 'req-malformed-info', + fallback_attempted: true, + reason: 'Unauthorized', + status: 401, + }), + ); + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('[requireJwtAuth] JWT fallback succeeded after OpenID JWT failure'), + expect.objectContaining({ + request_id: 'req-malformed-info', + fallback_succeeded: true, + primary_failure_reason: 'Unauthorized', + }), + ); + }); + + it('logs OpenID JWT expiry when JWT fallback fails', () => { + isEnabled.mockReturnValue(true); + mockRegisteredStrategies.add('openidJwt'); + const req = mockReq(undefined, { + id: 'req-expired-fail', + method: 'POST', + originalUrl: '/api/ask?access_token=hidden', + headers: { + cookie: `token_provider=openid; openid_user_id=${signedOpenIdUserCookie('user-jwt')}`, + }, + _mockStrategies: { + openidJwt: { + user: false, + info: { message: 'jwt expired', name: 'TokenExpiredError' }, + status: 401, + }, + jwt: { + user: false, + info: { message: 'invalid signature', name: 'JsonWebTokenError' }, + status: 401, + }, + }, + }); + const res = mockRes(); + const next = jest.fn(); + + requireJwtAuth(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(401); + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('[requireJwtAuth] OpenID JWT auth failed; trying fallback'), + expect.objectContaining({ + request_id: 'req-expired-fail', + method: 'POST', + path: '/api/ask', + fallback_attempted: true, + reason: 'jwt expired', + error_name: 'TokenExpiredError', + status: 401, + }), + ); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('[requireJwtAuth] Authentication failed after all strategies'), + expect.objectContaining({ + request_id: 'req-expired-fail', + method: 'POST', + path: '/api/ask', + token_provider: 'openid', + attempted_strategies: ['openidJwt', 'jwt'], + final_strategy: 'jwt', + primary_strategy: 'openidJwt', + fallback_strategy: 'jwt', + fallback_attempted: true, + fallback_succeeded: false, + reason: 'invalid signature', + error_name: 'JsonWebTokenError', + status: 401, + }), + ); + expect(logger.warn.mock.calls[0][0]).toContain('"reason":"invalid signature"'); + expect(logger.warn.mock.calls[0][0]).toContain('"path":"/api/ask"'); }); it('does not fall back to OpenID JWT for bearer-only reuse requests', () => { @@ -263,6 +630,98 @@ describe('requireJwtAuth tenant context chaining', () => { ); }); + it('logs OpenID user-id mismatch when JWT fallback succeeds', () => { + isEnabled.mockReturnValue(true); + mockRegisteredStrategies.add('openidJwt'); + const req = mockReq(undefined, { + requestId: 'req-mismatch-success', + method: 'GET', + path: '/api/auth/me', + headers: { + cookie: `token_provider=openid; openid_user_id=${signedOpenIdUserCookie('user-a')}`, + }, + _mockStrategies: { + openidJwt: { user: { id: 'user-b', tenantId: 'tenant-openid', role: 'user' } }, + jwt: { user: { id: 'user-a', tenantId: 'tenant-jwt', role: 'user' } }, + }, + }); + const res = mockRes(); + const next = jest.fn(); + + requireJwtAuth(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(req.authStrategy).toBe('jwt'); + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('[requireJwtAuth] OpenID JWT auth failed; trying fallback'), + expect.objectContaining({ + request_id: 'req-mismatch-success', + primary_strategy: 'openidJwt', + fallback_strategy: 'jwt', + fallback_attempted: true, + reason: 'openid user-id mismatch', + status: 401, + }), + ); + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('[requireJwtAuth] JWT fallback succeeded after OpenID JWT failure'), + expect.objectContaining({ + request_id: 'req-mismatch-success', + auth_strategy: 'jwt', + fallback_attempted: true, + fallback_succeeded: true, + primary_failure_reason: 'openid user-id mismatch', + reason: 'openid user-id mismatch', + }), + ); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it('logs OpenID user-id mismatch when JWT fallback fails', () => { + isEnabled.mockReturnValue(true); + mockRegisteredStrategies.add('openidJwt'); + const req = mockReq(undefined, { + requestId: 'req-mismatch-fail', + method: 'GET', + path: '/api/auth/me', + headers: { + cookie: `token_provider=openid; openid_user_id=${signedOpenIdUserCookie('user-a')}`, + }, + _mockStrategies: { + openidJwt: { user: { id: 'user-b', tenantId: 'tenant-openid', role: 'user' } }, + jwt: { user: false, info: { message: 'Unauthorized' }, status: 401 }, + }, + }); + const res = mockRes(); + const next = jest.fn(); + + requireJwtAuth(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(401); + expect(logger.debug).toHaveBeenCalledWith( + expect.stringContaining('[requireJwtAuth] OpenID JWT auth failed; trying fallback'), + expect.objectContaining({ + request_id: 'req-mismatch-fail', + fallback_attempted: true, + reason: 'openid user-id mismatch', + status: 401, + }), + ); + expect(logger.warn).toHaveBeenCalledWith( + expect.stringContaining('[requireJwtAuth] Authentication failed after all strategies'), + expect.objectContaining({ + request_id: 'req-mismatch-fail', + attempted_strategies: ['openidJwt', 'jwt'], + final_strategy: 'jwt', + fallback_attempted: true, + fallback_succeeded: false, + reason: 'Unauthorized', + status: 401, + }), + ); + }); + it('does not authenticate OpenID JWT when the reuse cookie belongs to another user', () => { isEnabled.mockReturnValue(true); mockRegisteredStrategies.add('openidJwt'); @@ -388,3 +847,141 @@ describe('requireJwtAuth tenant context chaining', () => { expect(getTenantId()).toBeUndefined(); }); }); + +describe('requireRumProxyAuth', () => { + const originalJwtSecret = process.env.JWT_REFRESH_SECRET; + + beforeEach(() => { + process.env.JWT_REFRESH_SECRET = jwtSecret; + }); + + afterEach(() => { + mockPassportError = null; + mockRegisteredStrategies = new Set(['jwt']); + isEnabled.mockReturnValue(false); + maybeRefreshCloudFrontAuthCookiesMiddleware.mockClear(); + logger.debug.mockClear(); + logger.info.mockClear(); + logger.warn.mockClear(); + logger.error.mockClear(); + recordRumProxyRequest.mockClear(); + passport.authenticate.mockClear(); + passport._strategy.mockClear(); + if (originalJwtSecret === undefined) { + delete process.env.JWT_REFRESH_SECRET; + } else { + process.env.JWT_REFRESH_SECRET = originalJwtSecret; + } + }); + + it('authenticates telemetry with the LibreChat JWT strategy without tenant or cookie refresh middleware', () => { + const req = mockReq({ id: 'user-jwt', tenantId: 'tenant-jwt', role: 'user' }); + const res = mockRes(); + const next = jest.fn(); + + requireRumProxyAuth(req, res, next); + + expect(passport.authenticate).toHaveBeenCalledWith( + 'jwt', + { session: false }, + expect.any(Function), + ); + expect(req.authStrategy).toBe('jwt'); + expect(maybeRefreshCloudFrontAuthCookiesMiddleware).not.toHaveBeenCalled(); + // Success is recorded by the proxy. + expect(recordRumProxyRequest).not.toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalled(); + }); + + it('authenticates telemetry with OpenID JWT reuse when the reuse cookie is present', () => { + isEnabled.mockReturnValue(true); + mockRegisteredStrategies.add('openidJwt'); + const req = mockReq(undefined, { + headers: { cookie: `token_provider=openid; openid_user_id=${signedOpenIdUserCookie()}` }, + _mockStrategies: { + openidJwt: { user: { id: 'user-openid', tenantId: 'tenant-openid', role: 'user' } }, + jwt: { user: false, info: { message: 'invalid signature' }, status: 401 }, + }, + }); + const res = mockRes(); + const next = jest.fn(); + + requireRumProxyAuth(req, res, next); + + expect(passport.authenticate).toHaveBeenCalledWith( + 'openidJwt', + { session: false }, + expect.any(Function), + ); + expect(req.authStrategy).toBe('openidJwt'); + expect(maybeRefreshCloudFrontAuthCookiesMiddleware).not.toHaveBeenCalled(); + expect(recordRumProxyRequest).not.toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalled(); + }); + + it('falls back to LibreChat JWT when OpenID JWT telemetry auth fails', () => { + isEnabled.mockReturnValue(true); + mockRegisteredStrategies.add('openidJwt'); + const req = mockReq(undefined, { + headers: { cookie: `token_provider=openid; openid_user_id=${signedOpenIdUserCookie()}` }, + _mockStrategies: { + openidJwt: { + user: false, + info: { message: 'jwt expired', name: 'TokenExpiredError' }, + status: 401, + }, + jwt: { user: { id: 'user-openid', tenantId: 'tenant-jwt', role: 'user' } }, + }, + }); + const res = mockRes(); + const next = jest.fn(); + + requireRumProxyAuth(req, res, next); + + expect(passport.authenticate).toHaveBeenCalledTimes(2); + expect(req.authStrategy).toBe('jwt'); + expect(recordRumProxyRequest).not.toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + expect(next).toHaveBeenCalled(); + }); + + it('drops invalid telemetry auth with 204 instead of returning an app auth error', () => { + const req = mockReq(undefined, { + path: '/v1/traces', + _mockStrategies: { + jwt: { + user: false, + info: { message: 'invalid signature', name: 'JsonWebTokenError' }, + status: 401, + }, + }, + }); + const res = mockRes(); + const next = jest.fn(); + + requireRumProxyAuth(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(maybeRefreshCloudFrontAuthCookiesMiddleware).not.toHaveBeenCalled(); + expect(recordRumProxyRequest).toHaveBeenCalledWith('traces', 'auth_drop'); + expect(res.status).toHaveBeenCalledWith(204); + expect(res.end).toHaveBeenCalled(); + }); + + it('records passport errors separately from ordinary telemetry auth drops', () => { + mockPassportError = new Error('passport unavailable'); + const req = mockReq(undefined, { path: '/v1/logs' }); + const res = mockRes(); + const next = jest.fn(); + + requireRumProxyAuth(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(logger.warn).not.toHaveBeenCalled(); + expect(recordRumProxyRequest).toHaveBeenCalledWith('logs', 'auth_error'); + expect(res.status).toHaveBeenCalledWith(204); + expect(res.end).toHaveBeenCalled(); + }); +}); diff --git a/api/server/middleware/abortMiddleware.js b/api/server/middleware/abortMiddleware.js index e0c5ae0ff09..8b339b05ba4 100644 --- a/api/server/middleware/abortMiddleware.js +++ b/api/server/middleware/abortMiddleware.js @@ -7,6 +7,7 @@ const { GenerationJobManager, recordCollectedUsage, sanitizeMessageForTransmit, + buildAbortedResponseMetadata, } = require('@librechat/api'); const { truncateText, smartTruncateText } = require('~/app/clients/prompts'); const clearPendingReq = require('~/cache/clearPendingReq'); @@ -110,6 +111,14 @@ async function abortMessage(req, res) { tokenCount: completionTokens, }; + /** Persist the usage/cost rollup + context breakdown for the stopped response + * so its branch/total cost and granular rows survive a reload, matching the + * normal completion path. */ + const abortMetadata = buildAbortedResponseMetadata(jobData); + if (abortMetadata) { + responseMessage.metadata = abortMetadata; + } + // Spend tokens for ALL models from collectedUsage (handles parallel agents/addedConvo) if (collectedUsage && collectedUsage.length > 0) { await spendCollectedUsage({ diff --git a/api/server/middleware/accessResources/canAccessSkillResource.js b/api/server/middleware/accessResources/canAccessSkillResource.js index 1010ca89980..d68612df277 100644 --- a/api/server/middleware/accessResources/canAccessSkillResource.js +++ b/api/server/middleware/accessResources/canAccessSkillResource.js @@ -1,6 +1,7 @@ -const { ResourceType } = require('librechat-data-provider'); +const { ResourceType, PermissionBits } = require('librechat-data-provider'); const { canAccessResource } = require('./canAccessResource'); const { getSkillById } = require('~/models'); +const { getDeploymentSkillById } = require('@librechat/api'); /** * Skill-specific middleware factory that checks skill access permissions. @@ -19,12 +20,35 @@ const canAccessSkillResource = (options) => { throw new Error('canAccessSkillResource: requiredPermission is required and must be a number'); } - return canAccessResource({ + const aclMiddleware = canAccessResource({ resourceType: ResourceType.SKILL, requiredPermission, resourceIdParam, idResolver: getSkillById, }); + + return (req, res, next) => { + const rawResourceId = req.params[resourceIdParam]; + const deploymentSkill = rawResourceId ? getDeploymentSkillById(rawResourceId) : null; + if (!deploymentSkill) { + return aclMiddleware(req, res, next); + } + if (requiredPermission !== PermissionBits.VIEW) { + return res.status(403).json({ + error: 'Forbidden', + message: 'Deployment skills are read-only', + }); + } + req.resourceAccess = { + resourceType: ResourceType.SKILL, + resourceId: deploymentSkill._id, + customResourceId: rawResourceId, + permission: requiredPermission, + userId: req.user?.id, + resourceInfo: deploymentSkill, + }; + return next(); + }; }; module.exports = { diff --git a/api/server/middleware/buildEndpointOption.js b/api/server/middleware/buildEndpointOption.js index 1eaa1ef8d92..a762c153eac 100644 --- a/api/server/middleware/buildEndpointOption.js +++ b/api/server/middleware/buildEndpointOption.js @@ -60,7 +60,14 @@ async function buildEndpointOption(req, res, next) { if (appConfig.modelSpecs?.list?.length && appConfig.modelSpecs?.enforce) { /** @type {{ list: TModelSpec[] }}*/ const { list } = appConfig.modelSpecs; - const { spec } = parsedBody; + const rawSpec = req.body.spec; + const spec = parsedBody.spec ?? (typeof rawSpec === 'string' ? rawSpec : undefined); + const rawChatProjectId = req.body.chatProjectId; + const parsedBodyForModelSpec = + parsedBody.chatProjectId === undefined && + (typeof rawChatProjectId === 'string' || rawChatProjectId === null) + ? { ...parsedBody, chatProjectId: rawChatProjectId } + : parsedBody; if (!spec) { return handleError(res, { text: 'No model spec selected' }); @@ -78,7 +85,7 @@ async function buildEndpointOption(req, res, next) { try { const result = applyModelSpecPreset({ modelSpec: currentModelSpec, - parsedBody: currentModelSpec.preset, + parsedBody: parsedBodyForModelSpec, endpoint, endpointType, defaultParamsEndpoint, @@ -132,7 +139,9 @@ async function buildEndpointOption(req, res, next) { req.body.endpointOption = await builder(endpoint, parsedBody, endpointType); if (req.body.files && !isAgents) { - req.body.endpointOption.attachments = updateFilesUsage(req.body.files); + req.body.endpointOption.attachments = updateFilesUsage(req.body.files, undefined, { + user: req.user.id, + }); } next(); diff --git a/api/server/middleware/buildEndpointOption.spec.js b/api/server/middleware/buildEndpointOption.spec.js index 9c353b498ab..c4da3a726ed 100644 --- a/api/server/middleware/buildEndpointOption.spec.js +++ b/api/server/middleware/buildEndpointOption.spec.js @@ -35,6 +35,7 @@ jest.mock('~/server/services/Endpoints/agents', () => ({ jest.mock('~/models', () => ({ updateFilesUsage: jest.fn(), })); +const { updateFilesUsage } = require('~/models'); const mockGetEndpointsConfig = jest.fn(); jest.mock('~/server/services/Config', () => ({ @@ -188,6 +189,9 @@ describe('buildEndpointOption - defaultParamsEndpoint parsing', () => { endpointType: EModelEndpoint.custom, spec: 'claude-opus-4.5', model: 'anthropic/claude-opus-4.5', + temperature: 0.1, + topP: 0.2, + chatProjectId: 'project-1', }, { modelSpecs: { @@ -196,6 +200,7 @@ describe('buildEndpointOption - defaultParamsEndpoint parsing', () => { }, }, ); + req.baseUrl = '/api/agents/chat'; await buildEndpointOption(req, createRes(), jest.fn()); @@ -209,7 +214,50 @@ describe('buildEndpointOption - defaultParamsEndpoint parsing', () => { const enforcedResult = parseCompactConvo.mock.results[1].value; expect(enforcedResult.maxOutputTokens).toBe(8192); expect(enforcedResult.temperature).toBe(0.7); + expect(enforcedResult.topP).toBeUndefined(); expect(enforcedResult.maxContextTokens).toBe(50000); + expect(enforcedResult.chatProjectId).toBe('project-1'); + expect(req.body.endpointOption.chatProjectId).toBe('project-1'); + }); + + it('should rebuild enforced custom specs from the backend preset when compact parsing drops raw fields', async () => { + mockGetEndpointsConfig.mockResolvedValue({}); + + const modelSpec = { + name: 'approved-custom', + preset: { + endpoint: 'Mock Provider A', + endpointType: EModelEndpoint.custom, + model: 'mock-model-a', + promptPrefix: 'Use the approved custom model spec.', + }, + }; + + const req = createReq( + { + endpoint: 'Mock Provider A', + endpointType: EModelEndpoint.custom, + spec: 'approved-custom', + model: { stale: 'cached-client-value' }, + agent_id: 'agent_from_cached_client_state', + chatProjectId: 'project-1', + }, + { + modelSpecs: { + enforce: true, + list: [modelSpec], + }, + }, + ); + req.baseUrl = '/api/agents/chat'; + + await buildEndpointOption(req, createRes(), jest.fn()); + + expect(parseCompactConvo.mock.results[0].value).toEqual({}); + expect(req.body.endpointOption.spec).toBe('approved-custom'); + expect(req.body.endpointOption.model).toBe('mock-model-a'); + expect(req.body.endpointOption.promptPrefix).toBe('Use the approved custom model spec.'); + expect(req.body.endpointOption.chatProjectId).toBe('project-1'); }); it('should restore private model spec preset fields in non-enforced mode', async () => { @@ -417,6 +465,29 @@ describe('buildEndpointOption - defaultParamsEndpoint parsing', () => { expect(parsedResult.max_tokens).toBe(4096); }); + it('should scope non-agent chat attachment usage updates to the authenticated user', async () => { + const attachments = Promise.resolve([]); + updateFilesUsage.mockReturnValueOnce(attachments); + mockGetEndpointsConfig.mockResolvedValue({}); + + const req = createReq( + { + endpoint: EModelEndpoint.assistants, + assistant_id: 'asst_123', + files: [{ file_id: 'forged-file-id' }], + }, + { modelSpecs: null }, + ); + req.user = { id: 'user-1' }; + + await buildEndpointOption(req, createRes(), jest.fn()); + + expect(updateFilesUsage).toHaveBeenCalledWith(req.body.files, undefined, { + user: 'user-1', + }); + expect(req.body.endpointOption.attachments).toBe(attachments); + }); + it('should not enter the enforce branch when modelSpecs.list is empty', async () => { mockGetEndpointsConfig.mockResolvedValue({}); diff --git a/api/server/middleware/canAccessSharedLink.js b/api/server/middleware/canAccessSharedLink.js new file mode 100644 index 00000000000..79fd93e486c --- /dev/null +++ b/api/server/middleware/canAccessSharedLink.js @@ -0,0 +1,6 @@ +const mongoose = require('mongoose'); +const { createSharedLinkAccessMiddleware } = require('@librechat/api'); + +const canAccessSharedLink = createSharedLinkAccessMiddleware({ mongoose }); + +module.exports = canAccessSharedLink; diff --git a/api/server/middleware/index.js b/api/server/middleware/index.js index 64b9fb16185..bc523ff166c 100644 --- a/api/server/middleware/index.js +++ b/api/server/middleware/index.js @@ -1,4 +1,5 @@ const validatePasswordReset = require('./validatePasswordReset'); +const setTwoFactorTempUser = require('./setTwoFactorTempUser'); const validateRegistration = require('./validateRegistration'); const buildEndpointOption = require('./buildEndpointOption'); const validateMessageReq = require('./validateMessageReq'); @@ -10,6 +11,7 @@ const requireLdapAuth = require('./requireLdapAuth'); const abortMiddleware = require('./abortMiddleware'); const checkInviteUser = require('./checkInviteUser'); const requireJwtAuth = require('./requireJwtAuth'); +const { requireRumProxyAuth } = require('./requireJwtAuth'); const configMiddleware = require('./config/app'); const validateModel = require('./validateModel'); const moderateText = require('./moderateText'); @@ -36,6 +38,8 @@ module.exports = { moderateText, validateModel, requireJwtAuth, + requireRumProxyAuth, + setTwoFactorTempUser, checkInviteUser, requireLdapAuth, requireLocalAuth, diff --git a/api/server/middleware/limiters/index.js b/api/server/middleware/limiters/index.js index a38188d2a6b..4a569e2698e 100644 --- a/api/server/middleware/limiters/index.js +++ b/api/server/middleware/limiters/index.js @@ -11,6 +11,7 @@ const messageLimiters = require('./messageLimiters'); const promptUsageLimiter = require('./promptUsageLimiter'); const verifyEmailLimiter = require('./verifyEmailLimiter'); const resetPasswordLimiter = require('./resetPasswordLimiter'); +const twoFactorTempLimiter = require('./twoFactorTempLimiter'); module.exports = { ...uploadLimiters, @@ -25,4 +26,5 @@ module.exports = { createSTTLimiters, verifyEmailLimiter, resetPasswordLimiter, + twoFactorTempLimiter, }; diff --git a/api/server/middleware/limiters/twoFactorTempLimiter.js b/api/server/middleware/limiters/twoFactorTempLimiter.js new file mode 100644 index 00000000000..97d0861af62 --- /dev/null +++ b/api/server/middleware/limiters/twoFactorTempLimiter.js @@ -0,0 +1,101 @@ +const jwt = require('jsonwebtoken'); +const { createHash } = require('crypto'); +const rateLimit = require('express-rate-limit'); +const { ViolationTypes } = require('librechat-data-provider'); +const { limiterCache, removePorts } = require('@librechat/api'); +const { logViolation } = require('~/cache'); + +const { + LOGIN_WINDOW = 5, + LOGIN_MAX = 7, + LOGIN_VIOLATION_SCORE, + TWO_FACTOR_TEMP_WINDOW = LOGIN_WINDOW, + TWO_FACTOR_TEMP_MAX = LOGIN_MAX, + TWO_FACTOR_TEMP_VIOLATION_SCORE, +} = process.env; +const windowMs = TWO_FACTOR_TEMP_WINDOW * 60 * 1000; +const max = TWO_FACTOR_TEMP_MAX; +const score = TWO_FACTOR_TEMP_VIOLATION_SCORE ?? LOGIN_VIOLATION_SCORE; +const windowInMinutes = windowMs / 60000; +const message = `Too many verification attempts, please try again after ${windowInMinutes} minutes.`; + +const hashLimiterKey = (value) => createHash('sha256').update(value).digest('hex'); + +const getUserLimiterKey = (req) => { + const userId = req.user?.id ?? req.user?._id; + if (userId) { + return `user:${userId.toString()}`; + } + + const tempToken = req.body?.tempToken; + if (typeof tempToken === 'string' && tempToken) { + return `temp:${hashLimiterKey(tempToken)}`; + } + + const ip = removePorts(req); + return ip ? `ip:${ip}` : 'ip:unknown'; +}; + +const getTempTokenUserId = (tempToken) => { + if (!tempToken) { + return null; + } + + try { + const payload = jwt.verify(tempToken, process.env.JWT_SECRET); + return payload?.userId ?? null; + } catch { + return null; + } +}; + +const createHandler = (limiter) => async (req, res) => { + const type = ViolationTypes.LOGINS; + const errorMessage = { + type, + max, + limiter, + windowInMinutes, + }; + + const userId = getTempTokenUserId(req.body?.tempToken); + if (userId && !req.user) { + req.user = { id: userId }; + } else if (userId && !req.user.id && !req.user._id) { + req.user.id = userId; + } + + await logViolation(req, res, type, errorMessage, score); + return res.status(429).json({ message }); +}; + +const ipLimiterOptions = { + windowMs, + max, + handler: createHandler('ip'), + keyGenerator: removePorts, + store: limiterCache('two_factor_temp_limiter'), +}; + +const userLimiterOptions = { + windowMs, + max, + handler: createHandler('user'), + keyGenerator: getUserLimiterKey, + store: limiterCache('two_factor_temp_user_limiter'), +}; + +const twoFactorTempIpLimiter = rateLimit(ipLimiterOptions); +const twoFactorTempUserLimiter = rateLimit(userLimiterOptions); + +const twoFactorTempLimiter = (req, res, next) => { + twoFactorTempIpLimiter(req, res, (err) => { + if (err) { + return next(err); + } + + return twoFactorTempUserLimiter(req, res, next); + }); +}; + +module.exports = twoFactorTempLimiter; diff --git a/api/server/middleware/limiters/twoFactorTempLimiter.test.js b/api/server/middleware/limiters/twoFactorTempLimiter.test.js new file mode 100644 index 00000000000..37b06c7fdb6 --- /dev/null +++ b/api/server/middleware/limiters/twoFactorTempLimiter.test.js @@ -0,0 +1,111 @@ +const jwt = require('jsonwebtoken'); +const express = require('express'); +const request = require('supertest'); + +const originalEnv = process.env; +const jwtSecret = 'test-two-factor-secret'; + +const createToken = (userId) => + jwt.sign({ userId, twoFAPending: true }, jwtSecret, { expiresIn: '5m' }); + +const createApp = () => { + jest.resetModules(); + process.env = { + ...originalEnv, + JWT_SECRET: jwtSecret, + LOGIN_MAX: '2', + LOGIN_WINDOW: '5', + TWO_FACTOR_TEMP_MAX: '2', + TWO_FACTOR_TEMP_WINDOW: '5', + }; + + jest.doMock('@librechat/api', () => ({ + limiterCache: jest.fn(() => undefined), + removePorts: (req) => req?.['ip'], + })); + jest.doMock('~/cache', () => ({ + logViolation: jest.fn().mockResolvedValue(undefined), + })); + + const setTwoFactorTempUser = require('../setTwoFactorTempUser'); + const twoFactorTempLimiter = require('./twoFactorTempLimiter'); + const { logViolation } = require('~/cache'); + + const app = express(); + app.set('trust proxy', 1); + app.use(express.json()); + app.post('/verify', setTwoFactorTempUser, twoFactorTempLimiter, (req, res) => + res.status(204).end(), + ); + + return { app, logViolation }; +}; + +describe('twoFactorTempLimiter', () => { + afterEach(() => { + jest.dontMock('@librechat/api'); + jest.dontMock('~/cache'); + process.env = originalEnv; + }); + + it('limits a valid temp-token user across rotating source IPs', async () => { + const { app, logViolation } = createApp(); + const tempToken = createToken('user-1'); + + await request(app) + .post('/verify') + .set('X-Forwarded-For', '203.0.113.1') + .send({ tempToken, token: '000000' }) + .expect(204); + await request(app) + .post('/verify') + .set('X-Forwarded-For', '203.0.113.2') + .send({ tempToken, token: '000001' }) + .expect(204); + + const response = await request(app) + .post('/verify') + .set('X-Forwarded-For', '203.0.113.3') + .send({ tempToken, token: '000002' }) + .expect(429); + + expect(response.body).toEqual({ + message: 'Too many verification attempts, please try again after 5 minutes.', + }); + expect(logViolation).toHaveBeenCalledTimes(1); + expect(logViolation.mock.calls[0][0].user).toEqual({ id: 'user-1' }); + expect(logViolation.mock.calls[0][3]).toMatchObject({ + limiter: 'user', + max: '2', + windowInMinutes: 5, + }); + }); + + it('keeps the existing source IP limit before the user limit', async () => { + const { app, logViolation } = createApp(); + + await request(app) + .post('/verify') + .set('X-Forwarded-For', '198.51.100.1') + .send({ tempToken: createToken('user-a'), token: '000000' }) + .expect(204); + await request(app) + .post('/verify') + .set('X-Forwarded-For', '198.51.100.1') + .send({ tempToken: createToken('user-b'), token: '000001' }) + .expect(204); + + await request(app) + .post('/verify') + .set('X-Forwarded-For', '198.51.100.1') + .send({ tempToken: createToken('user-c'), token: '000002' }) + .expect(429); + + expect(logViolation).toHaveBeenCalledTimes(1); + expect(logViolation.mock.calls[0][3]).toMatchObject({ + limiter: 'ip', + max: '2', + windowInMinutes: 5, + }); + }); +}); diff --git a/api/server/middleware/requireJwtAuth.js b/api/server/middleware/requireJwtAuth.js index 935957e913d..9c4d1ca47c9 100644 --- a/api/server/middleware/requireJwtAuth.js +++ b/api/server/middleware/requireJwtAuth.js @@ -1,10 +1,16 @@ const cookies = require('cookie'); const jwt = require('jsonwebtoken'); const passport = require('passport'); +const { logger } = require('@librechat/data-schemas'); const { isEnabled, tenantContextMiddleware, + getAuthFailureReason, + getAuthFailureErrorName, + buildSafeAuthLogContext, + formatAuthLogMessage, maybeRefreshCloudFrontAuthCookiesMiddleware, + recordRumProxyRequest, } = require('@librechat/api'); const hasPassportStrategy = (strategy) => @@ -30,6 +36,45 @@ const getAuthenticatedUserId = (user) => user?.id?.toString?.() ?? user?._id?.to const refreshCloudFrontCookies = maybeRefreshCloudFrontAuthCookiesMiddleware ?? ((_req, _res, next) => next()); +const getAuthStrategies = (req) => { + const cookieHeader = req.headers.cookie; + const parsedCookies = cookieHeader ? cookies.parse(cookieHeader) : {}; + const tokenProvider = parsedCookies.token_provider; + const openidReuseEnabled = isEnabled(process.env.OPENID_REUSE_TOKENS); + const openidJwtAvailable = openidReuseEnabled && hasPassportStrategy('openidJwt'); + const openIdReuseUserId = getValidOpenIdReuseUserId(parsedCookies); + const useOpenIdJwt = + tokenProvider === 'openid' && openidJwtAvailable && openIdReuseUserId != null; + + return { + tokenProvider, + openidReuseEnabled, + openidJwtAvailable, + openIdReuseUserId, + strategies: useOpenIdJwt ? ['openidJwt', 'jwt'] : ['jwt'], + }; +}; + +const dropRumTelemetry = (res) => { + if (!res.headersSent) { + res.status(204).end(); + } +}; + +// Keep in sync with packages/api/src/rum/proxy.ts; auth drops are recorded before proxy code runs. +const getRumProxyEndpoint = (req) => { + if (req.path === '/v1/traces') { + return 'traces'; + } + if (req.path === '/v1/logs') { + return 'logs'; + } + return 'unknown'; +}; + +const isOpenIdReuseUser = (strategy, user, openIdReuseUserId) => + strategy !== 'openidJwt' || getAuthenticatedUserId(user) === openIdReuseUserId; + /** * Custom Middleware to handle JWT authentication, with support for OpenID token reuse. * Switches between JWT and OpenID authentication based on cookies and environment settings. @@ -39,15 +84,68 @@ const refreshCloudFrontCookies = * for downstream Mongoose tenant isolation and structured logging. */ const requireJwtAuth = (req, res, next) => { - const cookieHeader = req.headers.cookie; - const parsedCookies = cookieHeader ? cookies.parse(cookieHeader) : {}; - const tokenProvider = parsedCookies.token_provider; - const openidReuseEnabled = isEnabled(process.env.OPENID_REUSE_TOKENS); - const openidJwtAvailable = openidReuseEnabled && hasPassportStrategy('openidJwt'); - const openIdReuseUserId = getValidOpenIdReuseUserId(parsedCookies); - const useOpenIdJwt = - tokenProvider === 'openid' && openidJwtAvailable && openIdReuseUserId != null; - const strategies = useOpenIdJwt ? ['openidJwt', 'jwt'] : ['jwt']; + const { tokenProvider, openidReuseEnabled, openidJwtAvailable, openIdReuseUserId, strategies } = + getAuthStrategies(req); + const authLogState = { + tokenProvider, + openidReuseEnabled, + openidJwtAvailable, + hasOpenIdReuseUserId: openIdReuseUserId != null, + }; + let primaryFailureReason; + let primaryFailureErrorName; + let fallbackAttempted = false; + + const logOpenIdFallbackAttempt = ({ fallbackStrategy, reason, errorName, status }) => { + primaryFailureReason = reason; + primaryFailureErrorName = errorName; + fallbackAttempted = true; + const message = '[requireJwtAuth] OpenID JWT auth failed; trying fallback'; + const context = buildSafeAuthLogContext(req, authLogState, { + primary_strategy: 'openidJwt', + fallback_strategy: fallbackStrategy, + fallback_attempted: true, + reason, + error_name: errorName, + status, + }); + logger.debug(formatAuthLogMessage(message, context), context); + }; + + const logAuthenticationFailure = ({ strategy, info, status, err }) => { + const message = '[requireJwtAuth] Authentication failed after all strategies'; + const context = buildSafeAuthLogContext(req, authLogState, { + primary_strategy: strategies[0], + fallback_strategy: strategies[1], + fallback_attempted: fallbackAttempted, + fallback_succeeded: false, + attempted_strategies: strategies, + final_strategy: strategy, + reason: getAuthFailureReason(err, info), + error_name: getAuthFailureErrorName(err, info), + status: status || 401, + }); + const log = fallbackAttempted ? logger.warn : logger.debug; + log.call(logger, formatAuthLogMessage(message, context), context); + }; + + const logFallbackSuccess = (strategy) => { + if (!fallbackAttempted || strategy !== 'jwt') { + return; + } + const message = '[requireJwtAuth] JWT fallback succeeded after OpenID JWT failure'; + const context = buildSafeAuthLogContext(req, authLogState, { + auth_strategy: 'jwt', + primary_strategy: 'openidJwt', + fallback_strategy: 'jwt', + fallback_attempted: true, + fallback_succeeded: true, + primary_failure_reason: primaryFailureReason, + reason: primaryFailureReason, + error_name: primaryFailureErrorName, + }); + logger.debug(formatAuthLogMessage(message, context), context); + }; const authenticateWithStrategy = (index) => { const strategy = strategies[index]; @@ -57,20 +155,34 @@ const requireJwtAuth = (req, res, next) => { } if (!user) { if (index + 1 < strategies.length) { + logOpenIdFallbackAttempt({ + fallbackStrategy: strategies[index + 1], + reason: getAuthFailureReason(err, info), + errorName: getAuthFailureErrorName(err, info), + status: status || 401, + }); return authenticateWithStrategy(index + 1); } + logAuthenticationFailure({ strategy, info, status, err }); return res.status(status || 401).json({ message: info?.message || 'Unauthorized', }); } if (strategy === 'openidJwt' && getAuthenticatedUserId(user) !== openIdReuseUserId) { if (index + 1 < strategies.length) { + logOpenIdFallbackAttempt({ + fallbackStrategy: strategies[index + 1], + reason: 'openid user-id mismatch', + status: 401, + }); return authenticateWithStrategy(index + 1); } + logAuthenticationFailure({ strategy, info, status: 401, err }); return res.status(401).json({ message: 'Unauthorized' }); } req.user = user; req.authStrategy = strategy; + logFallbackSuccess(strategy); tenantContextMiddleware(req, res, (tenantErr) => { if (tenantErr) { return next(tenantErr); @@ -83,4 +195,45 @@ const requireJwtAuth = (req, res, next) => { authenticateWithStrategy(0); }; +const requireRumProxyAuth = (req, res, next) => { + const { openIdReuseUserId, strategies } = getAuthStrategies(req); + const endpoint = getRumProxyEndpoint(req); + let authErrorSeen = false; + + const dropTelemetry = () => { + recordRumProxyRequest(endpoint, authErrorSeen ? 'auth_error' : 'auth_drop'); + dropRumTelemetry(res); + }; + + const finishAuthentication = (strategy, user) => { + req.user = user; + req.authStrategy = strategy; + next(); + }; + + let nextStrategyIndex = 0; + const tryNextStrategy = () => { + const strategy = strategies[nextStrategyIndex]; + nextStrategyIndex += 1; + + if (!strategy) { + dropTelemetry(); + return; + } + + passport.authenticate(strategy, { session: false }, (err, user) => { + authErrorSeen = authErrorSeen || err != null; + if (err || !user || !isOpenIdReuseUser(strategy, user, openIdReuseUserId)) { + tryNextStrategy(); + return; + } + + finishAuthentication(strategy, user); + })(req, res, next); + }; + + tryNextStrategy(); +}; + module.exports = requireJwtAuth; +module.exports.requireRumProxyAuth = requireRumProxyAuth; diff --git a/api/server/middleware/setTwoFactorTempUser.js b/api/server/middleware/setTwoFactorTempUser.js new file mode 100644 index 00000000000..facbbcba9a2 --- /dev/null +++ b/api/server/middleware/setTwoFactorTempUser.js @@ -0,0 +1,25 @@ +const jwt = require('jsonwebtoken'); + +const setTwoFactorTempUser = (req, _res, next) => { + if (req.user?.id || req.user?._id) { + return next(); + } + + const { tempToken } = req.body ?? {}; + if (!tempToken) { + return next(); + } + + try { + const payload = jwt.verify(tempToken, process.env.JWT_SECRET); + if (payload?.userId) { + req.user = { id: payload.userId }; + } + } catch { + return next(); + } + + return next(); +}; + +module.exports = setTwoFactorTempUser; diff --git a/api/server/routes/__test-utils__/convos-route-mocks.js b/api/server/routes/__test-utils__/convos-route-mocks.js index a3718addff0..a0eb6fe3128 100644 --- a/api/server/routes/__test-utils__/convos-route-mocks.js +++ b/api/server/routes/__test-utils__/convos-route-mocks.js @@ -12,6 +12,8 @@ module.exports = { })), logAxiosError: jest.fn(), restoreTenantContextFromReq: jest.fn((req, res, next) => next()), + deleteConvoSharedLinksWithCleanup: jest.fn(), + deleteAllSharedLinksWithCleanup: jest.fn(), ...overrides, }), diff --git a/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js index 606b4ae8a1a..e0fc200486a 100644 --- a/api/server/routes/__tests__/config.spec.js +++ b/api/server/routes/__tests__/config.spec.js @@ -327,6 +327,7 @@ describe('GET /api/config', () => { { name: 'guarded-spec', label: 'Guarded Spec', + skills: ['private-skill'], preset: { endpoint: 'openAI', model: 'gpt-4o', @@ -352,6 +353,7 @@ describe('GET /api/config', () => { model: 'gpt-4o', greeting: 'Hello', }); + expect(response.body.modelSpecs.list[0]).not.toHaveProperty('skills'); }); it('should include full interface config', async () => { diff --git a/api/server/routes/__tests__/convos.spec.js b/api/server/routes/__tests__/convos.spec.js index 23978f28e9b..2f766694606 100644 --- a/api/server/routes/__tests__/convos.spec.js +++ b/api/server/routes/__tests__/convos.spec.js @@ -21,13 +21,11 @@ jest.mock('~/server/services/Endpoints/assistants', () => require(MOCKS).assista describe('Convos Routes', () => { let app; let convosRouter; + const { deleteToolCalls, deleteConvos, saveConvo } = require('~/models'); const { - deleteAllSharedLinks, - deleteConvoSharedLink, - deleteToolCalls, - deleteConvos, - saveConvo, - } = require('~/models'); + deleteAllSharedLinksWithCleanup, + deleteConvoSharedLinksWithCleanup, + } = require('@librechat/api'); beforeAll(() => { convosRouter = require('../convos'); @@ -57,7 +55,7 @@ describe('Convos Routes', () => { deleteConvos.mockResolvedValue(mockDbResponse); deleteToolCalls.mockResolvedValue({ deletedCount: 10 }); - deleteAllSharedLinks.mockResolvedValue({ + deleteAllSharedLinksWithCleanup.mockResolvedValue({ message: 'All shared links deleted successfully', deletedCount: 3, }); @@ -75,12 +73,12 @@ describe('Convos Routes', () => { expect(deleteToolCalls).toHaveBeenCalledWith('test-user-123'); expect(deleteToolCalls).toHaveBeenCalledTimes(1); - /** Verify deleteAllSharedLinks was called with correct userId */ - expect(deleteAllSharedLinks).toHaveBeenCalledWith('test-user-123'); - expect(deleteAllSharedLinks).toHaveBeenCalledTimes(1); + /** Verify deleteAllSharedLinksWithCleanup was called with correct userId */ + expect(deleteAllSharedLinksWithCleanup).toHaveBeenCalledWith('test-user-123'); + expect(deleteAllSharedLinksWithCleanup).toHaveBeenCalledTimes(1); }); - it('should call deleteAllSharedLinks even when no conversations exist', async () => { + it('should call deleteAllSharedLinksWithCleanup even when no conversations exist', async () => { const mockDbResponse = { deletedCount: 0, message: 'No conversations to delete', @@ -88,7 +86,7 @@ describe('Convos Routes', () => { deleteConvos.mockResolvedValue(mockDbResponse); deleteToolCalls.mockResolvedValue({ deletedCount: 0 }); - deleteAllSharedLinks.mockResolvedValue({ + deleteAllSharedLinksWithCleanup.mockResolvedValue({ message: 'All shared links deleted successfully', deletedCount: 0, }); @@ -96,7 +94,7 @@ describe('Convos Routes', () => { const response = await request(app).delete('/api/convos/all'); expect(response.status).toBe(201); - expect(deleteAllSharedLinks).toHaveBeenCalledWith('test-user-123'); + expect(deleteAllSharedLinksWithCleanup).toHaveBeenCalledWith('test-user-123'); }); it('should return 500 if deleteConvos fails', async () => { @@ -123,10 +121,10 @@ describe('Convos Routes', () => { expect(response.text).toBe('Error clearing conversations'); }); - it('should return 500 if deleteAllSharedLinks fails', async () => { + it('should return 500 if deleteAllSharedLinksWithCleanup fails', async () => { deleteConvos.mockResolvedValue({ deletedCount: 5 }); deleteToolCalls.mockResolvedValue({ deletedCount: 10 }); - deleteAllSharedLinks.mockRejectedValue(new Error('Shared links deletion failed')); + deleteAllSharedLinksWithCleanup.mockRejectedValue(new Error('Shared links deletion failed')); const response = await request(app).delete('/api/convos/all'); @@ -138,12 +136,12 @@ describe('Convos Routes', () => { /** First user */ deleteConvos.mockResolvedValue({ deletedCount: 3 }); deleteToolCalls.mockResolvedValue({ deletedCount: 5 }); - deleteAllSharedLinks.mockResolvedValue({ deletedCount: 2 }); + deleteAllSharedLinksWithCleanup.mockResolvedValue({ deletedCount: 2 }); let response = await request(app).delete('/api/convos/all'); expect(response.status).toBe(201); - expect(deleteAllSharedLinks).toHaveBeenCalledWith('test-user-123'); + expect(deleteAllSharedLinksWithCleanup).toHaveBeenCalledWith('test-user-123'); jest.clearAllMocks(); @@ -158,12 +156,12 @@ describe('Convos Routes', () => { deleteConvos.mockResolvedValue({ deletedCount: 7 }); deleteToolCalls.mockResolvedValue({ deletedCount: 12 }); - deleteAllSharedLinks.mockResolvedValue({ deletedCount: 4 }); + deleteAllSharedLinksWithCleanup.mockResolvedValue({ deletedCount: 4 }); response = await request(app2).delete('/api/convos/all'); expect(response.status).toBe(201); - expect(deleteAllSharedLinks).toHaveBeenCalledWith('test-user-456'); + expect(deleteAllSharedLinksWithCleanup).toHaveBeenCalledWith('test-user-456'); }); it('should execute deletions in correct sequence', async () => { @@ -179,15 +177,19 @@ describe('Convos Routes', () => { return Promise.resolve({ deletedCount: 10 }); }); - deleteAllSharedLinks.mockImplementation(() => { - executionOrder.push('deleteAllSharedLinks'); + deleteAllSharedLinksWithCleanup.mockImplementation(() => { + executionOrder.push('deleteAllSharedLinksWithCleanup'); return Promise.resolve({ deletedCount: 3 }); }); await request(app).delete('/api/convos/all'); /** Verify all three functions were called */ - expect(executionOrder).toEqual(['deleteConvos', 'deleteToolCalls', 'deleteAllSharedLinks']); + expect(executionOrder).toEqual([ + 'deleteConvos', + 'deleteToolCalls', + 'deleteAllSharedLinksWithCleanup', + ]); }); it('should maintain data integrity by cleaning up shared links when conversations are deleted', async () => { @@ -201,17 +203,17 @@ describe('Convos Routes', () => { deleteConvos.mockResolvedValue(mockConvosDeleted); deleteToolCalls.mockResolvedValue(mockToolCallsDeleted); - deleteAllSharedLinks.mockResolvedValue(mockSharedLinksDeleted); + deleteAllSharedLinksWithCleanup.mockResolvedValue(mockSharedLinksDeleted); const response = await request(app).delete('/api/convos/all'); expect(response.status).toBe(201); /** Verify that shared links cleanup was called for the same user */ - expect(deleteAllSharedLinks).toHaveBeenCalledWith('test-user-123'); + expect(deleteAllSharedLinksWithCleanup).toHaveBeenCalledWith('test-user-123'); /** Verify no shared links remain for deleted conversations */ - expect(deleteAllSharedLinks).toHaveBeenCalledAfter(deleteConvos); + expect(deleteAllSharedLinksWithCleanup).toHaveBeenCalledAfter(deleteConvos); }); }); @@ -225,7 +227,7 @@ describe('Convos Routes', () => { deleteConvos.mockResolvedValue(mockDbResponse); deleteToolCalls.mockResolvedValue({ deletedCount: 3 }); - deleteConvoSharedLink.mockResolvedValue({ + deleteConvoSharedLinksWithCleanup.mockResolvedValue({ message: 'Shared links deleted successfully', deletedCount: 1, }); @@ -249,11 +251,14 @@ describe('Convos Routes', () => { /** Verify deleteToolCalls was called */ expect(deleteToolCalls).toHaveBeenCalledWith('test-user-123', mockConversationId); - /** Verify deleteConvoSharedLink was called */ - expect(deleteConvoSharedLink).toHaveBeenCalledWith('test-user-123', mockConversationId); + /** Verify deleteConvoSharedLinksWithCleanup was called */ + expect(deleteConvoSharedLinksWithCleanup).toHaveBeenCalledWith( + 'test-user-123', + mockConversationId, + ); }); - it('should not call deleteConvoSharedLink when no conversationId provided', async () => { + it('should not call deleteConvoSharedLinksWithCleanup when no conversationId provided', async () => { deleteConvos.mockResolvedValue({ deletedCount: 0 }); deleteToolCalls.mockResolvedValue({ deletedCount: 0 }); @@ -266,7 +271,7 @@ describe('Convos Routes', () => { }); expect(response.status).toBe(200); - expect(deleteConvoSharedLink).not.toHaveBeenCalled(); + expect(deleteConvoSharedLinksWithCleanup).not.toHaveBeenCalled(); }); it('should handle deletion of conversation without shared links', async () => { @@ -274,7 +279,7 @@ describe('Convos Routes', () => { deleteConvos.mockResolvedValue({ deletedCount: 1 }); deleteToolCalls.mockResolvedValue({ deletedCount: 0 }); - deleteConvoSharedLink.mockResolvedValue({ + deleteConvoSharedLinksWithCleanup.mockResolvedValue({ message: 'Shared links deleted successfully', deletedCount: 0, }); @@ -288,7 +293,10 @@ describe('Convos Routes', () => { }); expect(response.status).toBe(201); - expect(deleteConvoSharedLink).toHaveBeenCalledWith('test-user-123', mockConversationId); + expect(deleteConvoSharedLinksWithCleanup).toHaveBeenCalledWith( + 'test-user-123', + mockConversationId, + ); }); it('should return 400 when no parameters provided', async () => { @@ -299,7 +307,7 @@ describe('Convos Routes', () => { expect(response.status).toBe(400); expect(response.body).toEqual({ error: 'no parameters provided' }); expect(deleteConvos).not.toHaveBeenCalled(); - expect(deleteConvoSharedLink).not.toHaveBeenCalled(); + expect(deleteConvoSharedLinksWithCleanup).not.toHaveBeenCalled(); }); it('should return 400 when request body is empty (DoS prevention)', async () => { @@ -336,12 +344,14 @@ describe('Convos Routes', () => { expect(deleteConvos).not.toHaveBeenCalled(); }); - it('should return 500 if deleteConvoSharedLink fails', async () => { + it('should return 500 if deleteConvoSharedLinksWithCleanup fails', async () => { const mockConversationId = 'conv-error'; deleteConvos.mockResolvedValue({ deletedCount: 1 }); deleteToolCalls.mockResolvedValue({ deletedCount: 2 }); - deleteConvoSharedLink.mockRejectedValue(new Error('Failed to delete shared links')); + deleteConvoSharedLinksWithCleanup.mockRejectedValue( + new Error('Failed to delete shared links'), + ); const response = await request(app) .delete('/api/convos') @@ -369,8 +379,8 @@ describe('Convos Routes', () => { return Promise.resolve({ deletedCount: 2 }); }); - deleteConvoSharedLink.mockImplementation(() => { - executionOrder.push('deleteConvoSharedLink'); + deleteConvoSharedLinksWithCleanup.mockImplementation(() => { + executionOrder.push('deleteConvoSharedLinksWithCleanup'); return Promise.resolve({ deletedCount: 1 }); }); @@ -382,7 +392,11 @@ describe('Convos Routes', () => { }, }); - expect(executionOrder).toEqual(['deleteConvos', 'deleteToolCalls', 'deleteConvoSharedLink']); + expect(executionOrder).toEqual([ + 'deleteConvos', + 'deleteToolCalls', + 'deleteConvoSharedLinksWithCleanup', + ]); }); it('should prevent orphaned shared links when deleting single conversation', async () => { @@ -390,7 +404,7 @@ describe('Convos Routes', () => { deleteConvos.mockResolvedValue({ deletedCount: 1 }); deleteToolCalls.mockResolvedValue({ deletedCount: 4 }); - deleteConvoSharedLink.mockResolvedValue({ + deleteConvoSharedLinksWithCleanup.mockResolvedValue({ message: 'Shared links deleted successfully', deletedCount: 2, }); @@ -406,10 +420,13 @@ describe('Convos Routes', () => { expect(response.status).toBe(201); /** Verify shared links were deleted for the specific conversation */ - expect(deleteConvoSharedLink).toHaveBeenCalledWith('test-user-123', mockConversationId); + expect(deleteConvoSharedLinksWithCleanup).toHaveBeenCalledWith( + 'test-user-123', + mockConversationId, + ); /** Verify it was called after the conversation was deleted */ - expect(deleteConvoSharedLink).toHaveBeenCalledAfter(deleteConvos); + expect(deleteConvoSharedLinksWithCleanup).toHaveBeenCalledAfter(deleteConvos); }); }); diff --git a/api/server/routes/__tests__/mcp.spec.js b/api/server/routes/__tests__/mcp.spec.js index 96e8ff9d9e6..5323fa0d0e7 100644 --- a/api/server/routes/__tests__/mcp.spec.js +++ b/api/server/routes/__tests__/mcp.spec.js @@ -3,7 +3,7 @@ const express = require('express'); const request = require('supertest'); const mongoose = require('mongoose'); const cookieParser = require('cookie-parser'); -const { getBasePath } = require('@librechat/api'); +const { getBasePath, PENDING_STALE_MS } = require('@librechat/api'); const { MongoMemoryServer } = require('mongodb-memory-server'); function generateTestCsrfToken(flowId) { @@ -24,6 +24,11 @@ const mockRegistryInstance = { removeServer: jest.fn(), getAllowedDomains: jest.fn().mockReturnValue(null), getAllowedAddresses: jest.fn().mockReturnValue(null), + resolveAllowlists: jest.fn().mockResolvedValue({ + allowedDomains: null, + allowedAddresses: null, + useSSRFProtection: true, + }), }; let mockMCPUseAllowed = true; @@ -36,6 +41,16 @@ jest.mock('@librechat/api', () => { getFlowState: jest.fn(), completeOAuthFlow: jest.fn(), generateFlowId: jest.fn(), + generateTokenFlowId: jest.fn(), + parseFlowId: jest.fn(), + buildStoredClientMetadata: jest.fn((metadata, resourceMetadata) => + metadata + ? { + ...metadata, + ...(resourceMetadata?.resource && { resource: resourceMetadata.resource }), + } + : undefined, + ), resolveStateToFlowId: jest.fn(async (state) => state), storeStateMapping: jest.fn(), deleteStateMapping: jest.fn(), @@ -186,6 +201,51 @@ describe('MCP Routes', () => { currentUser = undefined; mockResolveAllMcpConfigs.mockResolvedValue({}); mockResolveMcpConfigNames.mockResolvedValue([]); + const { MCPOAuthHandler } = require('@librechat/api'); + const { getTenantId } = require('@librechat/data-schemas'); + getTenantId.mockReturnValue(undefined); + MCPOAuthHandler.generateFlowId.mockImplementation((userId, serverName, tenantId) => { + const flowId = `${userId}:${serverName}`; + return tenantId ? `tenant:${encodeURIComponent(tenantId)}:${flowId}` : flowId; + }); + MCPOAuthHandler.generateTokenFlowId.mockImplementation((userId, serverName, tenantId) => { + const flowId = `${userId}:${serverName}`; + return tenantId ? `tenant:${encodeURIComponent(tenantId)}:${flowId}` : flowId; + }); + MCPOAuthHandler.parseFlowId.mockImplementation((flowId) => { + const parts = flowId.split(':'); + if (parts[0] === 'tenant') { + if (parts.length < 4 || !parts[1] || !parts[2]) { + return null; + } + let tenantId; + try { + tenantId = decodeURIComponent(parts[1]); + } catch { + return null; + } + return { + tenantId, + userId: parts[2], + serverName: parts.slice(3).join(':'), + }; + } + if (parts.length < 2 || !parts[0]) { + return null; + } + return { + userId: parts[0], + serverName: parts.slice(1).join(':'), + }; + }); + MCPOAuthHandler.buildStoredClientMetadata.mockImplementation((metadata, resourceMetadata) => + metadata + ? { + ...metadata, + ...(resourceMetadata?.resource && { resource: resourceMetadata.resource }), + } + : undefined, + ); mockMCPUseAllowed = true; /** * Reset registry method implementations every test. `clearAllMocks` resets @@ -203,11 +263,117 @@ describe('MCP Routes', () => { const { MCPOAuthHandler } = require('@librechat/api'); const { getLogStores } = require('~/cache'); - it('should initiate OAuth flow successfully', async () => { + it('should reuse stored authorization URL without starting a new OAuth flow', async () => { + const mockFlowManager = { + getFlowState: jest.fn().mockResolvedValue({ + status: 'PENDING', + createdAt: Date.now(), + metadata: { + serverName: 'test-server', + userId: 'test-user-id', + authorizationUrl: 'https://oauth.example.com/auth?state=stored-state', + }, + }), + }; + + getLogStores.mockReturnValue({}); + require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager); + + const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({ + userId: 'test-user-id', + flowId: 'test-user-id:test-server', + }); + + expect(response.status).toBe(302); + expect(response.headers.location).toBe('https://oauth.example.com/auth?state=stored-state'); + expect(response.headers['set-cookie']?.join('')).toContain('oauth_csrf='); + expect(MCPOAuthHandler.initiateOAuthFlow).not.toHaveBeenCalled(); + expect(MCPOAuthHandler.storeStateMapping).not.toHaveBeenCalled(); + expect(mockRegistryInstance.getServerConfig).not.toHaveBeenCalled(); + }); + + it('should accept tenant-scoped flow IDs when a tenant is active', async () => { + const { getTenantId } = require('@librechat/data-schemas'); + getTenantId.mockReturnValue('tenant-a'); + const tenantFlowId = 'tenant:tenant-a:test-user-id:test-server'; + const mockFlowManager = { + getFlowState: jest.fn().mockResolvedValue({ + status: 'PENDING', + createdAt: Date.now(), + metadata: { + serverName: 'test-server', + userId: 'test-user-id', + authorizationUrl: 'https://oauth.example.com/auth?state=stored-state', + }, + }), + }; + + getLogStores.mockReturnValue({}); + require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager); + + const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({ + userId: 'test-user-id', + flowId: tenantFlowId, + }); + + expect(response.status).toBe(302); + expect(response.headers.location).toBe('https://oauth.example.com/auth?state=stored-state'); + expect(mockFlowManager.getFlowState).toHaveBeenCalledWith(tenantFlowId, 'mcp_oauth'); + }); + + it('should reject non-tenant flow IDs when a tenant is active', async () => { + const { getTenantId } = require('@librechat/data-schemas'); + getTenantId.mockReturnValue('tenant-a'); + const mockFlowManager = { getFlowState: jest.fn() }; + + getLogStores.mockReturnValue({}); + require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager); + + const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({ + userId: 'test-user-id', + flowId: 'test-user-id:test-server', + }); + + expect(response.status).toBe(403); + expect(response.body).toEqual({ error: 'Flow mismatch' }); + expect(mockFlowManager.getFlowState).not.toHaveBeenCalled(); + }); + + it('should reject stored authorization URL when flow is no longer pending', async () => { const mockFlowManager = { getFlowState: jest.fn().mockResolvedValue({ + status: 'COMPLETED', + createdAt: Date.now(), + metadata: { + serverName: 'test-server', + userId: 'test-user-id', + authorizationUrl: 'https://oauth.example.com/auth?state=stored-state', + }, + }), + }; + + getLogStores.mockReturnValue({}); + require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager); + + const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({ + userId: 'test-user-id', + flowId: 'test-user-id:test-server', + }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'Invalid flow state' }); + expect(MCPOAuthHandler.initiateOAuthFlow).not.toHaveBeenCalled(); + expect(MCPOAuthHandler.storeStateMapping).not.toHaveBeenCalled(); + }); + + it('should initiate OAuth flow when stored authorization URL is missing', async () => { + const mockFlowManager = { + getFlowState: jest.fn().mockResolvedValue({ + status: 'PENDING', + createdAt: Date.now(), metadata: { serverUrl: 'https://test-server.com', + state: 'old-state-value', oauth: { clientId: 'test-client-id' }, }, }), @@ -241,6 +407,24 @@ describe('MCP Routes', () => { null, undefined, null, + undefined, + ); + expect(MCPOAuthHandler.deleteStateMapping).toHaveBeenCalledWith( + 'old-state-value', + mockFlowManager, + ); + expect(mockFlowManager.initFlow).toHaveBeenCalledWith( + 'test-user-id:test-server', + 'mcp_oauth', + expect.objectContaining({ + state: 'random-state-value', + authorizationUrl: 'https://oauth.example.com/auth', + }), + ); + expect(MCPOAuthHandler.storeStateMapping).toHaveBeenCalledWith( + 'random-state-value', + 'test-user-id:test-server', + mockFlowManager, ); }); @@ -254,6 +438,27 @@ describe('MCP Routes', () => { expect(response.body).toEqual({ error: 'User mismatch' }); }); + it('should return 403 when flowId does not match authenticated user and server', async () => { + const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({ + userId: 'test-user-id', + flowId: 'other-user-id:test-server', + }); + + expect(response.status).toBe(403); + expect(response.body).toEqual({ error: 'Flow mismatch' }); + expect(getLogStores).not.toHaveBeenCalled(); + }); + + it('should return 403 when flowId query value is not a string', async () => { + const response = await request(app) + .get('/api/mcp/test-server/oauth/initiate') + .query('userId=test-user-id&flowId=test-user-id:test-server&flowId=other-flow'); + + expect(response.status).toBe(403); + expect(response.body).toEqual({ error: 'Flow mismatch' }); + expect(getLogStores).not.toHaveBeenCalled(); + }); + it('should return 404 when flow state is not found', async () => { const mockFlowManager = { getFlowState: jest.fn().mockResolvedValue(null), @@ -264,7 +469,7 @@ describe('MCP Routes', () => { const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({ userId: 'test-user-id', - flowId: 'non-existent-flow-id', + flowId: 'test-user-id:test-server', }); expect(response.status).toBe(404); @@ -424,6 +629,26 @@ describe('MCP Routes', () => { expect(response.headers.location).toBe(`${basePath}/oauth/error?error=invalid_client`); expect(mockFlowManager.failFlow).not.toHaveBeenCalled(); }); + + it('should redirect instead of hanging when OAuth error flow ID is malformed', async () => { + const mockFlowManager = { + failFlow: jest.fn(), + }; + + getLogStores.mockReturnValueOnce({}); + require('~/config').getFlowStateManager.mockReturnValueOnce(mockFlowManager); + MCPOAuthHandler.resolveStateToFlowId.mockResolvedValueOnce('malformed-flow-id'); + + const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({ + error: 'invalid_client', + state: 'opaque-state', + }); + const basePath = getBasePath(); + + expect(response.status).toBe(302); + expect(response.headers.location).toBe(`${basePath}/oauth/error?error=invalid_client`); + expect(mockFlowManager.failFlow).not.toHaveBeenCalled(); + }); }); it('should redirect to error page when code is missing', async () => { @@ -542,6 +767,69 @@ describe('MCP Routes', () => { expect(response.headers.location).toContain(`${basePath}/oauth/success`); }); + it('should forward the merged server config so the tool cache gate sees request-scoped servers', async () => { + const flowId = 'test-user-id:test-server'; + const mockFlowManager = { + getFlowState: jest.fn().mockResolvedValue({ + status: 'PENDING', + createdAt: Date.now(), + }), + completeFlow: jest.fn().mockResolvedValue(true), + deleteFlow: jest.fn().mockResolvedValue(true), + }; + const mockFlowState = { + serverName: 'test-server', + userId: 'test-user-id', + metadata: {}, + clientInfo: {}, + codeVerifier: 'test-verifier', + }; + const mergedServerConfig = { + type: 'streamable-http', + url: 'https://override.example.com/{{LIBRECHAT_BODY_CONVERSATIONID}}/mcp', + source: 'config', + }; + const fetchedTools = [{ name: 'search', inputSchema: { type: 'object' } }]; + + getLogStores.mockReturnValue({}); + require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager); + MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState); + MCPOAuthHandler.completeOAuthFlow.mockResolvedValue({ + access_token: 'test-token', + }); + MCPTokenStorage.storeTokens.mockResolvedValue(); + mockRegistryInstance.getServerConfig.mockResolvedValue({}); + mockResolveAllMcpConfigs.mockResolvedValueOnce({ 'test-server': mergedServerConfig }); + + const mockMcpManager = { + getUserConnection: jest.fn().mockResolvedValue({ + fetchTools: jest.fn().mockResolvedValue(fetchedTools), + }), + }; + require('~/config').getMCPManager.mockReturnValue(mockMcpManager); + require('~/config').getOAuthReconnectionManager.mockReturnValue({ + clearReconnection: jest.fn(), + }); + const { updateMCPServerTools } = require('~/server/services/Config/mcp'); + updateMCPServerTools.mockResolvedValue(); + + const response = await request(app) + .get('/api/mcp/test-server/oauth/callback') + .query({ code: 'test-code', state: flowId }); + + expect(response.status).toBe(302); + expect(mockResolveAllMcpConfigs).toHaveBeenCalledWith('test-user-id'); + expect(mockMcpManager.getUserConnection).toHaveBeenCalledWith( + expect.objectContaining({ serverConfig: mergedServerConfig }), + ); + expect(updateMCPServerTools).toHaveBeenCalledWith({ + userId: 'test-user-id', + serverName: 'test-server', + tools: fetchedTools, + serverConfig: mergedServerConfig, + }); + }); + it('should reject when no PENDING flow exists and no cookies are present', async () => { const flowId = 'test-user-id:test-server'; const mockFlowManager = { @@ -590,7 +878,7 @@ describe('MCP Routes', () => { const mockFlowManager = { getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING', - createdAt: Date.now() - 3 * 60 * 1000, + createdAt: Date.now() - PENDING_STALE_MS - 60 * 1000, }), }; @@ -700,6 +988,140 @@ describe('MCP Routes', () => { ); }); + it('should clear tenant-scoped token flow state after storing callback tokens', async () => { + const mockFlowManager = { + getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }), + completeFlow: jest.fn().mockResolvedValue(), + deleteFlow: jest.fn().mockResolvedValue(true), + }; + const mockFlowState = { + serverName: 'test-server', + userId: 'test-user-id', + metadata: { + toolFlowId: 'tool-flow-123', + token_endpoint: 'https://auth.example.com/token', + }, + resourceMetadata: { resource: 'https://api.example.com/' }, + clientInfo: {}, + codeVerifier: 'test-verifier', + tenantId: 'tenant-a', + }; + const mockTokens = { + access_token: 'test-access-token', + refresh_token: 'test-refresh-token', + }; + + MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState); + MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens); + MCPTokenStorage.storeTokens.mockResolvedValue(); + getLogStores.mockReturnValue({}); + require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager); + require('~/config').getOAuthReconnectionManager.mockReturnValue({ + clearReconnection: jest.fn(), + }); + require('~/config').getMCPManager.mockReturnValue({ + getUserConnection: jest.fn().mockResolvedValue({ + fetchTools: jest.fn().mockResolvedValue([]), + }), + }); + const { getCachedTools, setCachedTools } = require('~/server/services/Config'); + getCachedTools.mockResolvedValue({}); + setCachedTools.mockResolvedValue(); + + const flowId = 'test-user-id:test-server'; + const csrfToken = generateTestCsrfToken(flowId); + + const response = await request(app) + .get('/api/mcp/test-server/oauth/callback') + .set('Cookie', [`oauth_csrf=${csrfToken}`]) + .query({ + code: 'test-auth-code', + state: flowId, + }); + + expect(response.status).toBe(302); + expect(MCPTokenStorage.storeTokens).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + resource: 'https://api.example.com/', + }), + }), + ); + expect(mockFlowManager.deleteFlow).toHaveBeenCalledWith( + 'tenant:tenant-a:test-user-id:test-server', + 'mcp_get_tokens', + ); + expect(mockFlowManager.deleteFlow).toHaveBeenCalledWith(flowId, 'mcp_get_tokens'); + }); + + it('should complete pending token flow waiters after storing callback tokens', async () => { + const mockFlowManager = { + getFlowState: jest.fn().mockImplementation((id, type) => { + if (type === 'mcp_get_tokens' && id === 'tenant:tenant-a:test-user-id:test-server') { + return Promise.resolve({ + type: 'mcp_get_tokens', + status: 'PENDING', + }); + } + return Promise.resolve({ status: 'PENDING' }); + }), + completeFlow: jest.fn().mockResolvedValue(true), + deleteFlow: jest.fn().mockResolvedValue(true), + }; + const mockFlowState = { + serverName: 'test-server', + userId: 'test-user-id', + metadata: {}, + clientInfo: {}, + codeVerifier: 'test-verifier', + tenantId: 'tenant-a', + }; + const mockTokens = { + access_token: 'fresh-access-token', + refresh_token: 'fresh-refresh-token', + }; + + MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState); + MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens); + MCPTokenStorage.storeTokens.mockResolvedValue(); + getLogStores.mockReturnValue({}); + require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager); + require('~/config').getOAuthReconnectionManager.mockReturnValue({ + clearReconnection: jest.fn(), + }); + require('~/config').getMCPManager.mockReturnValue({ + getUserConnection: jest.fn().mockResolvedValue({ + fetchTools: jest.fn().mockResolvedValue([]), + }), + }); + const { getCachedTools, setCachedTools } = require('~/server/services/Config'); + getCachedTools.mockResolvedValue({}); + setCachedTools.mockResolvedValue(); + + const flowId = 'test-user-id:test-server'; + const csrfToken = generateTestCsrfToken(flowId); + + const response = await request(app) + .get('/api/mcp/test-server/oauth/callback') + .set('Cookie', [`oauth_csrf=${csrfToken}`]) + .query({ + code: 'test-auth-code', + state: flowId, + }); + + expect(response.status).toBe(302); + expect(mockFlowManager.completeFlow).toHaveBeenCalledWith( + 'tenant:tenant-a:test-user-id:test-server', + 'mcp_get_tokens', + mockTokens, + ); + expect(mockFlowManager.deleteFlow).not.toHaveBeenCalledWith( + 'tenant:tenant-a:test-user-id:test-server', + 'mcp_get_tokens', + ); + expect(mockFlowManager.deleteFlow).toHaveBeenCalledWith(flowId, 'mcp_get_tokens'); + }); + it('should use oauthHeaders from flow state when present', async () => { const mockFlowManager = { getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }), @@ -1110,6 +1532,49 @@ describe('MCP Routes', () => { }); }); + it('should return tokens for a tenant-prefixed flow owned by the user', async () => { + const { getTenantId } = require('@librechat/data-schemas'); + const mockFlowManager = { + getFlowState: jest.fn().mockResolvedValue({ + status: 'COMPLETED', + result: { + access_token: 'tenant-access-token', + }, + }), + }; + + getTenantId.mockReturnValue('tenant-a'); + getLogStores.mockReturnValue({}); + require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager); + + const response = await request(app).get( + '/api/mcp/oauth/tokens/tenant:tenant-a:test-user-id:test-server', + ); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + tokens: { + access_token: 'tenant-access-token', + }, + }); + expect(mockFlowManager.getFlowState).toHaveBeenCalledWith( + 'tenant:tenant-a:test-user-id:test-server', + 'mcp_oauth', + ); + }); + + it('should reject tenant-prefixed token flow access from another tenant', async () => { + const { getTenantId } = require('@librechat/data-schemas'); + getTenantId.mockReturnValue('tenant-b'); + + const response = await request(app).get( + '/api/mcp/oauth/tokens/tenant:tenant-a:test-user-id:test-server', + ); + + expect(response.status).toBe(403); + expect(response.body).toEqual({ error: 'Access denied' }); + }); + it('should return 401 when user is not authenticated', async () => { const unauthApp = express(); unauthApp.use(express.json()); @@ -1202,6 +1667,48 @@ describe('MCP Routes', () => { }); }); + it('should return flow status for a tenant-prefixed flow owned by the user', async () => { + const { getTenantId } = require('@librechat/data-schemas'); + const mockFlowManager = { + getFlowState: jest.fn().mockResolvedValue({ + status: 'PENDING', + error: null, + }), + }; + + getTenantId.mockReturnValue('tenant-a'); + getLogStores.mockReturnValue({}); + require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager); + + const response = await request(app).get( + '/api/mcp/oauth/status/tenant:tenant-a:test-user-id:test-server', + ); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + status: 'PENDING', + completed: false, + failed: false, + error: null, + }); + expect(mockFlowManager.getFlowState).toHaveBeenCalledWith( + 'tenant:tenant-a:test-user-id:test-server', + 'mcp_oauth', + ); + }); + + it('should reject tenant-prefixed status access from another tenant', async () => { + const { getTenantId } = require('@librechat/data-schemas'); + getTenantId.mockReturnValue('tenant-b'); + + const response = await request(app).get( + '/api/mcp/oauth/status/tenant:tenant-a:test-user-id:test-server', + ); + + expect(response.status).toBe(403); + expect(response.body).toEqual({ error: 'Access denied' }); + }); + it('should return 403 when flowId does not match authenticated user', async () => { const response = await request(app).get('/api/mcp/oauth/status/other-user-id:test-server'); @@ -1565,6 +2072,7 @@ describe('MCP Routes', () => { expect(response.status).toBe(200); expect(response.body).toEqual({ success: true, + oauthTimeout: expect.any(Number), connectionStatus: { server1: { connectionState: 'connected', @@ -2097,11 +2605,13 @@ describe('MCP Routes', () => { type: 'sse', url: 'http://server1.com/sse', title: 'Server 1', + source: 'user', }, 'server-2': { type: 'sse', url: 'http://server2.com/sse', title: 'Server 2', + source: 'user', }, }; @@ -2173,7 +2683,7 @@ describe('MCP Routes', () => { mockRegistryInstance.addServer.mockResolvedValue({ serverName: 'test-sse-server', - config: validConfig, + config: { ...validConfig, source: 'user' }, }); const response = await request(app).post('/api/mcp/servers').send({ config: validConfig }); @@ -2593,6 +3103,7 @@ describe('MCP Routes', () => { type: 'sse', url: 'https://mcp-server.example.com/sse', title: 'Test Server', + source: 'user', }; mockRegistryInstance.getServerConfig.mockResolvedValue(mockConfig); @@ -2661,7 +3172,7 @@ describe('MCP Routes', () => { description: 'Updated description', }; - mockRegistryInstance.updateServer.mockResolvedValue(updatedConfig); + mockRegistryInstance.updateServer.mockResolvedValue({ ...updatedConfig, source: 'user' }); const response = await request(app) .patch('/api/mcp/servers/test-server') diff --git a/api/server/routes/__tests__/rum.spec.js b/api/server/routes/__tests__/rum.spec.js index ad161a75eb7..cdd0ec3e761 100644 --- a/api/server/routes/__tests__/rum.spec.js +++ b/api/server/routes/__tests__/rum.spec.js @@ -1,12 +1,12 @@ const express = require('express'); const request = require('supertest'); -const mockRequireJwtAuth = jest.fn((_req, _res, next) => next()); +const mockRequireRumProxyAuth = jest.fn((_req, _res, next) => next()); const mockIsRumProxyEnabled = jest.fn(); const mockProxyRumRequest = jest.fn((_req, res) => res.status(202).send()); jest.mock('~/server/middleware', () => ({ - requireJwtAuth: (...args) => mockRequireJwtAuth(...args), + requireRumProxyAuth: (...args) => mockRequireRumProxyAuth(...args), })); jest.mock('@librechat/api', () => ({ @@ -26,7 +26,7 @@ describe('RUM proxy routes', () => { }); beforeEach(() => { - mockRequireJwtAuth.mockClear(); + mockRequireRumProxyAuth.mockClear(); mockIsRumProxyEnabled.mockReset(); mockProxyRumRequest.mockClear(); }); @@ -41,7 +41,7 @@ describe('RUM proxy routes', () => { expect(response.status).toBe(404); expect(response.body).toEqual({ message: 'RUM proxy is not configured' }); - expect(mockRequireJwtAuth).not.toHaveBeenCalled(); + expect(mockRequireRumProxyAuth).not.toHaveBeenCalled(); expect(mockProxyRumRequest).not.toHaveBeenCalled(); }); @@ -54,7 +54,20 @@ describe('RUM proxy routes', () => { .send(Buffer.from('payload')); expect(response.status).toBe(202); - expect(mockRequireJwtAuth).toHaveBeenCalledTimes(1); + expect(mockRequireRumProxyAuth).toHaveBeenCalledTimes(1); + expect(mockProxyRumRequest).toHaveBeenCalledTimes(1); + }); + + it('uses RUM-specific auth for logs as well as traces', async () => { + mockIsRumProxyEnabled.mockReturnValue(true); + + const response = await request(app) + .post('/api/rum/v1/logs') + .set('Content-Type', 'application/x-protobuf') + .send(Buffer.from('payload')); + + expect(response.status).toBe(202); + expect(mockRequireRumProxyAuth).toHaveBeenCalledTimes(1); expect(mockProxyRumRequest).toHaveBeenCalledTimes(1); }); }); diff --git a/api/server/routes/__tests__/share.spec.js b/api/server/routes/__tests__/share.spec.js index 541ae451c64..e26dd9be6c3 100644 --- a/api/server/routes/__tests__/share.spec.js +++ b/api/server/routes/__tests__/share.spec.js @@ -3,9 +3,18 @@ const request = require('supertest'); const mongoose = require('mongoose'); const mockGetSharedLinkExpiration = jest.fn(); +const mockGrantCreationPermissions = jest.fn(); +const mockUpdateSharedLinkPermissionsExpiration = jest.fn(); +const mockSharedLinksAccess = jest.fn((_req, _res, next) => next()); jest.mock('@librechat/api', () => ({ isEnabled: jest.fn(() => true), + generateCheckAccess: jest.fn(() => mockSharedLinksAccess), + grantCreationPermissions: (...args) => mockGrantCreationPermissions(...args), + updateSharedLinkPermissionsExpiration: (...args) => + mockUpdateSharedLinkPermissionsExpiration(...args), + ensureLinkPermissions: jest.fn(), + deleteSharedLinkWithCleanup: jest.fn(), getSharedLinkExpiration: (...args) => mockGetSharedLinkExpiration(...args), isActiveExpirationDate: jest.fn((expiredAt) => expiredAt > new Date()), })); @@ -16,6 +25,13 @@ jest.mock('@librechat/data-schemas', () => ({ })); jest.mock('librechat-data-provider', () => ({ + PermissionTypes: { + SHARED_LINKS: 'SHARED_LINKS', + }, + Permissions: { + CREATE: 'CREATE', + SHARE_PUBLIC: 'SHARE_PUBLIC', + }, RetentionMode: { ALL: 'all', TEMPORARY: 'temporary', @@ -40,13 +56,22 @@ jest.mock('~/models', () => ({ deleteSharedLink: jest.fn(), getSharedLinks: jest.fn(), getSharedLink: jest.fn(), + getRoleByName: jest.fn(), })); +jest.mock('~/server/middleware/canAccessSharedLink', () => (_req, _res, next) => next()); +jest.mock('~/server/middleware/optionalJwtAuth', () => (req, _res, next) => next()); jest.mock('~/server/middleware/requireJwtAuth', () => (req, res, next) => next()); const { RetentionMode } = require('librechat-data-provider'); const { createTempChatExpirationDate, logger } = require('@librechat/data-schemas'); -const { createSharedLink, updateSharedLink } = require('~/models'); +const { deleteSharedLinkWithCleanup } = require('@librechat/api'); +const { + getSharedMessages, + createSharedLink, + updateSharedLink, + getRoleByName, +} = require('~/models'); const shareRouter = require('../share'); const activeExpiration = new Date('2030-01-01T00:00:00.000Z'); @@ -71,11 +96,28 @@ const buildApp = ({ retentionMode = RetentionMode.TEMPORARY } = {}) => { describe('share routes retention', () => { beforeEach(() => { jest.clearAllMocks(); + getRoleByName.mockResolvedValue({ + permissions: { + SHARED_LINKS: { + SHARE_PUBLIC: true, + }, + }, + }); + mockGrantCreationPermissions.mockResolvedValue(undefined); + }); + + it('prevents successful shared message responses from being cached', async () => { + getSharedMessages.mockResolvedValue({ shareId: 'share-123', messages: [] }); + + const response = await request(buildApp()).get('/api/share/share-123'); + + expect(response.status).toBe(200); + expect(response.headers['cache-control']).toBe('private, no-store'); }); it('expires new shares for retained non-temporary conversations', async () => { mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration); - createSharedLink.mockResolvedValue({ shareId: 'share-123' }); + createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' }); const response = await request(buildApp()) .post('/api/share/convo-123') @@ -106,11 +148,18 @@ describe('share routes retention', () => { 'msg-123', new Date('2030-01-01T00:00:00.000Z'), ); + expect(mockGrantCreationPermissions).toHaveBeenCalledWith( + 'link-123', + 'user-123', + true, + new Date('2030-01-01T00:00:00.000Z'), + ); + expect(mockSharedLinksAccess).toHaveBeenCalled(); }); it('rejects new shares when the retained conversation expired', async () => { mockGetSharedLinkExpiration.mockResolvedValue(expiredExpiration); - createSharedLink.mockResolvedValue({ shareId: 'share-123' }); + createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' }); const response = await request(buildApp()) .post('/api/share/convo-123') @@ -122,7 +171,7 @@ describe('share routes retention', () => { it('rejects new shares for expired conversations in all retention mode', async () => { mockGetSharedLinkExpiration.mockResolvedValue(expiredExpiration); - createSharedLink.mockResolvedValue({ shareId: 'share-123' }); + createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' }); const response = await request(buildApp({ retentionMode: RetentionMode.ALL })) .post('/api/share/convo-123') @@ -135,7 +184,7 @@ describe('share routes retention', () => { it('expires updated shares for retained non-temporary conversations', async () => { mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' })); mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration); - updateSharedLink.mockResolvedValue({ shareId: 'share-456' }); + updateSharedLink.mockResolvedValue({ _id: 'link-456', shareId: 'share-456' }); const response = await request(buildApp()).patch('/api/share/share-123'); @@ -162,6 +211,10 @@ describe('share routes retention', () => { undefined, new Date('2030-01-01T00:00:00.000Z'), ); + expect(mockUpdateSharedLinkPermissionsExpiration).toHaveBeenCalledWith( + 'link-456', + new Date('2030-01-01T00:00:00.000Z'), + ); }); it('rejects updated shares when the retained conversation expired', async () => { @@ -195,12 +248,14 @@ describe('share routes retention', () => { it('clears updated share expiration when the conversation is no longer retained', async () => { mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' })); mockGetSharedLinkExpiration.mockResolvedValue(null); - updateSharedLink.mockResolvedValue({ shareId: 'share-456' }); + updateSharedLink.mockResolvedValue({ _id: 'link-456', shareId: 'share-456' }); const response = await request(buildApp()).patch('/api/share/share-123'); expect(response.status).toBe(200); expect(updateSharedLink).toHaveBeenCalledWith('user-123', 'share-123', undefined, null); + expect(mockUpdateSharedLinkPermissionsExpiration).toHaveBeenCalledWith('link-456', null); + expect(mockSharedLinksAccess).not.toHaveBeenCalled(); }); it('preserves updated share expiration when the conversation cannot be found', async () => { @@ -212,6 +267,7 @@ describe('share routes retention', () => { expect(response.status).toBe(200); expect(updateSharedLink).toHaveBeenCalledWith('user-123', 'share-123', undefined, undefined); + expect(mockUpdateSharedLinkPermissionsExpiration).not.toHaveBeenCalled(); }); it('clears updated share expiration when creating a new expiration throws', async () => { @@ -221,7 +277,7 @@ describe('share routes retention', () => { dependencies.logger.error('[getSharedLinkExpiration] Error creating expiration date:', error); return null; }); - updateSharedLink.mockResolvedValue({ shareId: 'share-456' }); + updateSharedLink.mockResolvedValue({ _id: 'link-456', shareId: 'share-456' }); const response = await request(buildApp()).patch('/api/share/share-123'); @@ -231,6 +287,7 @@ describe('share routes retention', () => { error, ); expect(updateSharedLink).toHaveBeenCalledWith('user-123', 'share-123', undefined, null); + expect(mockUpdateSharedLinkPermissionsExpiration).toHaveBeenCalledWith('link-456', null); }); it('updates share target message while applying retention expiration', async () => { @@ -259,4 +316,14 @@ describe('share routes retention', () => { expect(response.status).toBe(400); expect(updateSharedLink).not.toHaveBeenCalled(); }); + + it('allows deleting existing shares without CREATE permission gate', async () => { + deleteSharedLinkWithCleanup.mockResolvedValue({ shareId: 'share-123' }); + + const response = await request(buildApp()).delete('/api/share/share-123'); + + expect(response.status).toBe(200); + expect(mockSharedLinksAccess).not.toHaveBeenCalled(); + expect(deleteSharedLinkWithCleanup).toHaveBeenCalledWith('user-123', 'share-123'); + }); }); diff --git a/api/server/routes/accessPermissions.js b/api/server/routes/accessPermissions.js index e53d0ef1a77..6ef731daba7 100644 --- a/api/server/routes/accessPermissions.js +++ b/api/server/routes/accessPermissions.js @@ -1,5 +1,11 @@ +const mongoose = require('mongoose'); const express = require('express'); -const { ResourceType, PermissionBits } = require('librechat-data-provider'); +const { + AccessRoleIds, + PrincipalType, + ResourceType, + PermissionBits, +} = require('librechat-data-provider'); const { getUserEffectivePermissions, getAllEffectivePermissions, @@ -82,6 +88,12 @@ const checkResourcePermissionAccess = (requiredPermission) => (req, res, next) = resourceIdParam: 'resourceId', idResolver: getSkillById, }); + } else if (resourceType === ResourceType.SHARED_LINK) { + middleware = canAccessResource({ + resourceType: ResourceType.SHARED_LINK, + requiredPermission, + resourceIdParam: 'resourceId', + }); } else { return res.status(400).json({ error: 'Bad Request', @@ -93,6 +105,57 @@ const checkResourcePermissionAccess = (requiredPermission) => (req, res, next) = middleware(req, res, next); }; +const rejectSharedLinkOwnerPermissionChanges = async (req, res, next) => { + if (req.params.resourceType !== ResourceType.SHARED_LINK) { + return next(); + } + + const updated = Array.isArray(req.body?.updated) ? req.body.updated : []; + const removed = Array.isArray(req.body?.removed) ? req.body.removed : []; + const grantsOwner = updated.some( + (principal) => principal?.accessRoleId === AccessRoleIds.SHARED_LINK_OWNER, + ); + const grantsPublicOwner = req.body?.publicAccessRoleId === AccessRoleIds.SHARED_LINK_OWNER; + + if (grantsOwner || grantsPublicOwner) { + return res.status(400).json({ + error: 'Bad Request', + message: 'Shared link owner permissions cannot be changed', + }); + } + + const userMutations = [...updated, ...removed].filter( + (principal) => principal?.type === PrincipalType.USER && principal?.id, + ); + + if (userMutations.length === 0) { + return next(); + } + + try { + const SharedLink = mongoose.models.SharedLink; + const link = await SharedLink.findById(req.params.resourceId, 'user').lean(); + const ownerId = link?.user?.toString(); + const touchesOwner = ownerId + ? userMutations.some((principal) => principal.id?.toString() === ownerId) + : false; + + if (touchesOwner) { + return res.status(400).json({ + error: 'Bad Request', + message: 'Shared link owner permissions cannot be changed', + }); + } + } catch (_error) { + return res.status(500).json({ + error: 'Internal Server Error', + message: 'Failed to validate shared link owner permissions', + }); + } + + return next(); +}; + /** * GET /api/permissions/{resourceType}/{resourceId} * Get all permissions for a specific resource @@ -115,6 +178,7 @@ router.put( checkResourcePermissionAccess(PermissionBits.SHARE), checkShareAccess, checkSharePublicAccess, + rejectSharedLinkOwnerPermissionChanges, updateResourcePermissions, ); diff --git a/api/server/routes/accessPermissions.sharePolicy.test.js b/api/server/routes/accessPermissions.sharePolicy.test.js index 0fc7a90deac..ed17a044529 100644 --- a/api/server/routes/accessPermissions.sharePolicy.test.js +++ b/api/server/routes/accessPermissions.sharePolicy.test.js @@ -30,6 +30,7 @@ jest.mock('~/server/controllers/PermissionsController', () => ({ const express = require('express'); const request = require('supertest'); +const mongoose = require('mongoose'); const { SystemRoles, ResourceType, @@ -48,6 +49,8 @@ const { getRoleByName } = require('~/models'); describe('Access permissions share policy', () => { let app; + const mockSharedLinkFindById = jest.fn(); + const originalSharedLinkModel = mongoose.models.SharedLink; const resourceId = '507f1f77bcf86cd799439011'; const sharePolicyCases = [ @@ -116,8 +119,30 @@ describe('Access permissions share policy', () => { accessRoleId, }); + const allowSharedLinkSharing = () => { + getRoleByName.mockResolvedValue({ + permissions: { + [PermissionTypes.SHARED_LINKS]: { + [Permissions.SHARE]: true, + [Permissions.SHARE_PUBLIC]: true, + }, + }, + }); + }; + + const mockSharedLinkOwner = (ownerId = 'owner-user') => { + mockSharedLinkFindById.mockReturnValue({ + lean: jest.fn().mockResolvedValue({ user: ownerId }), + }); + }; + beforeEach(() => { jest.clearAllMocks(); + if (mongoose.models.SharedLink) { + mongoose.models.SharedLink.findById = mockSharedLinkFindById; + } else { + mongoose.models.SharedLink = { findById: mockSharedLinkFindById }; + } hasCapability.mockResolvedValue(false); app = express(); @@ -129,6 +154,14 @@ describe('Access permissions share policy', () => { app.use('/api/permissions', accessPermissionsRouter); }); + afterAll(() => { + if (originalSharedLinkModel) { + mongoose.models.SharedLink = originalSharedLinkModel; + } else { + delete mongoose.models.SharedLink; + } + }); + it.each(sharePolicyCases)( 'blocks non-public $label sharing when ACL SHARE passes but role SHARE is disabled', async ({ resourceType, permissionType, accessRoleId, middlewareOptions }) => { @@ -208,4 +241,80 @@ describe('Access permissions share policy', () => { }); expect(updateResourcePermissions).not.toHaveBeenCalled(); }); + + it('blocks granting shared-link owner through generic permission updates', async () => { + allowSharedLinkSharing(); + mockSharedLinkOwner(); + + const response = await request(app) + .put(`/api/permissions/${ResourceType.SHARED_LINK}/${resourceId}`) + .send({ + updated: [ + { + type: PrincipalType.USER, + id: 'target-user', + accessRoleId: AccessRoleIds.SHARED_LINK_OWNER, + }, + ], + public: false, + }); + + expect(response.status).toBe(400); + expect(response.body.message).toBe('Shared link owner permissions cannot be changed'); + expect(updateResourcePermissions).not.toHaveBeenCalled(); + }); + + it('blocks granting shared-link owner to the public principal', async () => { + allowSharedLinkSharing(); + + const response = await request(app) + .put(`/api/permissions/${ResourceType.SHARED_LINK}/${resourceId}`) + .send({ + public: true, + publicAccessRoleId: AccessRoleIds.SHARED_LINK_OWNER, + }); + + expect(response.status).toBe(400); + expect(response.body.message).toBe('Shared link owner permissions cannot be changed'); + expect(updateResourcePermissions).not.toHaveBeenCalled(); + expect(mockSharedLinkFindById).not.toHaveBeenCalled(); + }); + + it('blocks removing the canonical shared-link owner', async () => { + allowSharedLinkSharing(); + mockSharedLinkOwner('owner-user'); + + const response = await request(app) + .put(`/api/permissions/${ResourceType.SHARED_LINK}/${resourceId}`) + .send({ + updated: [], + removed: [{ type: PrincipalType.USER, id: 'owner-user' }], + public: false, + }); + + expect(response.status).toBe(400); + expect(response.body.message).toBe('Shared link owner permissions cannot be changed'); + expect(updateResourcePermissions).not.toHaveBeenCalled(); + }); + + it('allows viewer grants for non-owner shared-link users', async () => { + allowSharedLinkSharing(); + mockSharedLinkOwner('owner-user'); + + const response = await request(app) + .put(`/api/permissions/${ResourceType.SHARED_LINK}/${resourceId}`) + .send({ + updated: [ + { + type: PrincipalType.USER, + id: 'target-user', + accessRoleId: AccessRoleIds.SHARED_LINK_VIEWER, + }, + ], + public: false, + }); + + expect(response.status).toBe(200); + expect(updateResourcePermissions).toHaveBeenCalledTimes(1); + }); }); diff --git a/api/server/routes/actions.js b/api/server/routes/actions.js index 38d0dc8c949..d9a2f2f7fad 100644 --- a/api/server/routes/actions.js +++ b/api/server/routes/actions.js @@ -14,7 +14,7 @@ const { } = require('@librechat/api'); const { findToken, updateToken, createToken } = require('~/models'); const { requireJwtAuth } = require('~/server/middleware'); -const { getFlowStateManager } = require('~/config'); +const { getActionFlowStateManager } = require('~/config'); const { getLogStores } = require('~/cache'); const router = express.Router(); @@ -56,7 +56,7 @@ router.get('/:action_id/oauth/callback', async (req, res) => { const { action_id } = req.params; const { code, state } = req.query; const flowsCache = getLogStores(CacheKeys.FLOWS); - const flowManager = getFlowStateManager(flowsCache); + const flowManager = getActionFlowStateManager(flowsCache); const basePath = getBasePath(); let identifier = action_id; try { diff --git a/api/server/routes/admin/config.js b/api/server/routes/admin/config.js index 0632077ea97..d934645f186 100644 --- a/api/server/routes/admin/config.js +++ b/api/server/routes/admin/config.js @@ -18,6 +18,7 @@ const handlers = createAdminConfigHandlers({ findConfigByPrincipal: db.findConfigByPrincipal, upsertConfig: db.upsertConfig, patchConfigFields: db.patchConfigFields, + tombstoneConfigField: db.tombstoneConfigField, unsetConfigField: db.unsetConfigField, deleteConfig: db.deleteConfig, toggleConfigActive: db.toggleConfigActive, @@ -33,6 +34,7 @@ router.get('/base', handlers.getBaseConfig); router.get('/:principalType/:principalId', handlers.getConfig); router.put('/:principalType/:principalId', handlers.upsertConfigOverrides); router.patch('/:principalType/:principalId/fields', handlers.patchConfigField); +router.post('/:principalType/:principalId/fields/tombstone', handlers.tombstoneConfigField); router.delete('/:principalType/:principalId/fields', handlers.deleteConfigField); router.delete('/:principalType/:principalId', handlers.deleteConfigOverrides); router.patch('/:principalType/:principalId/active', handlers.toggleConfig); diff --git a/api/server/routes/admin/skills.js b/api/server/routes/admin/skills.js new file mode 100644 index 00000000000..54e54004ff2 --- /dev/null +++ b/api/server/routes/admin/skills.js @@ -0,0 +1,50 @@ +const express = require('express'); +const { createAdminSkillsSyncAccess, createAdminSkillsSyncHandlers } = require('@librechat/api'); +const { SystemCapabilities } = require('@librechat/data-schemas'); +const { hasCapability, requireCapability } = require('~/server/middleware/roles/capabilities'); +const { requireJwtAuth } = require('~/server/middleware'); +const { upsertSkillSyncCredential, deleteSkillSyncCredential } = require('~/models'); +const { getGitHubSkillSyncRunnerForRequest } = require('~/server/services/Skills/sync'); +const { getAppConfig } = require('~/server/services/Config'); +const configMiddleware = require('~/server/middleware/config/app'); + +const router = express.Router(); +const requireAdminAccess = requireCapability(SystemCapabilities.ACCESS_ADMIN); + +const syncAccess = createAdminSkillsSyncAccess({ + getAppConfig, + hasCapability, +}); + +const handlers = createAdminSkillsSyncHandlers({ + getRunner: getGitHubSkillSyncRunnerForRequest, + upsertCredential: upsertSkillSyncCredential, + deleteCredential: deleteSkillSyncCredential, +}); + +router.use( + requireJwtAuth, + requireAdminAccess, + configMiddleware, + syncAccess.attachBaseSkillSyncConfig, +); + +router.get( + '/sync/status', + syncAccess.requireReadSkills, + syncAccess.attachCredentialReadAccess, + handlers.getSyncStatus, +); +router.post('/sync/run', syncAccess.requireSyncRunCapability, handlers.runSync); +router.put( + '/sync/credentials/:credentialKey', + syncAccess.requirePlatformManageSkills, + handlers.setCredential, +); +router.delete( + '/sync/credentials/:credentialKey', + syncAccess.requirePlatformManageSkills, + handlers.deleteCredential, +); + +module.exports = router; diff --git a/api/server/routes/admin/skills.test.js b/api/server/routes/admin/skills.test.js new file mode 100644 index 00000000000..f452d6ea212 --- /dev/null +++ b/api/server/routes/admin/skills.test.js @@ -0,0 +1,119 @@ +const express = require('express'); +const request = require('supertest'); + +const mockRequireJwtAuth = jest.fn((req, res, next) => { + req.user = { id: 'user-1', role: 'ADMIN', tenantId: 'tenant-a' }; + next(); +}); +const mockCapabilityMiddleware = jest.fn((req, res, next) => next()); +const mockRequireCapability = jest.fn(() => mockCapabilityMiddleware); +const mockHasCapability = jest.fn().mockResolvedValue(true); +const mockConfigMiddleware = jest.fn((req, res, next) => { + req.config = { skillSync: { github: { enabled: false, sources: [] } } }; + next(); +}); +const mockGetAppConfig = jest.fn(); +const mockGetGitHubSkillSyncRunnerForRequest = jest.fn(); +const mockHandlers = { + getSyncStatus: jest.fn((req, res) => res.status(200).json({ ok: true })), + runSync: jest.fn((req, res) => res.status(200).json({ ok: true })), + setCredential: jest.fn((req, res) => res.status(200).json({ ok: true })), + deleteCredential: jest.fn((req, res) => res.status(200).json({ ok: true })), +}; +const mockSyncAccess = { + attachBaseSkillSyncConfig: jest.fn((req, res, next) => next()), + requireReadSkills: jest.fn((req, res, next) => next()), + attachCredentialReadAccess: jest.fn((req, res, next) => next()), + requireSyncRunCapability: jest.fn((req, res, next) => next()), + requirePlatformManageSkills: jest.fn((req, res, next) => next()), +}; + +jest.mock('@librechat/data-schemas', () => ({ + SystemCapabilities: { + ACCESS_ADMIN: 'access:admin', + }, +})); + +jest.mock('@librechat/api', () => ({ + createAdminSkillsSyncAccess: jest.fn(() => mockSyncAccess), + createAdminSkillsSyncHandlers: jest.fn(() => mockHandlers), +})); + +jest.mock('~/server/middleware/roles/capabilities', () => ({ + hasCapability: mockHasCapability, + requireCapability: mockRequireCapability, +})); + +jest.mock('~/server/middleware', () => ({ + requireJwtAuth: mockRequireJwtAuth, +})); + +jest.mock('~/server/middleware/config/app', () => mockConfigMiddleware); + +jest.mock('~/server/services/Config', () => ({ + getAppConfig: mockGetAppConfig, +})); + +jest.mock('~/models', () => ({ + upsertSkillSyncCredential: jest.fn(), + deleteSkillSyncCredential: jest.fn(), +})); + +jest.mock('~/server/services/Skills/sync', () => ({ + getGitHubSkillSyncRunnerForRequest: mockGetGitHubSkillSyncRunnerForRequest, +})); + +describe('admin skills sync routes', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + function createApp() { + delete require.cache[require.resolve('./skills')]; + const router = require('./skills'); + const app = express(); + app.use(express.json()); + app.use('/api/admin/skills', router); + return app; + } + + it('delegates skill sync access policy to the API package', async () => { + const app = createApp(); + + await request(app).get('/api/admin/skills/sync/status').expect(200); + + const { + createAdminSkillsSyncAccess, + createAdminSkillsSyncHandlers, + } = require('@librechat/api'); + expect(mockRequireCapability).toHaveBeenCalledWith('access:admin'); + expect(createAdminSkillsSyncAccess).toHaveBeenCalledWith({ + getAppConfig: mockGetAppConfig, + hasCapability: mockHasCapability, + }); + expect(createAdminSkillsSyncHandlers).toHaveBeenCalledWith( + expect.objectContaining({ getRunner: mockGetGitHubSkillSyncRunnerForRequest }), + ); + expect(mockRequireJwtAuth).toHaveBeenCalled(); + expect(mockCapabilityMiddleware).toHaveBeenCalled(); + expect(mockConfigMiddleware).toHaveBeenCalled(); + expect(mockSyncAccess.attachBaseSkillSyncConfig).toHaveBeenCalled(); + expect(mockSyncAccess.requireReadSkills).toHaveBeenCalled(); + expect(mockSyncAccess.attachCredentialReadAccess).toHaveBeenCalled(); + expect(mockHandlers.getSyncStatus).toHaveBeenCalled(); + }); + + it('mounts package access middlewares before each sync endpoint handler', async () => { + const app = createApp(); + + await request(app).post('/api/admin/skills/sync/run').expect(200); + await request(app).put('/api/admin/skills/sync/credentials/default').send({}).expect(200); + await request(app).delete('/api/admin/skills/sync/credentials/default').expect(200); + + expect(mockSyncAccess.requireSyncRunCapability).toHaveBeenCalled(); + expect(mockHandlers.runSync).toHaveBeenCalled(); + expect(mockSyncAccess.requirePlatformManageSkills).toHaveBeenCalledTimes(2); + expect(mockHandlers.setCredential).toHaveBeenCalled(); + expect(mockHandlers.deleteCredential).toHaveBeenCalled(); + }); +}); diff --git a/api/server/routes/agents/__tests__/abort.spec.js b/api/server/routes/agents/__tests__/abort.spec.js index 442665d9737..418c5f42549 100644 --- a/api/server/routes/agents/__tests__/abort.spec.js +++ b/api/server/routes/agents/__tests__/abort.spec.js @@ -195,6 +195,43 @@ describe('Agent Abort Endpoint', () => { expect(response.status).toBe(200); expect(mockSaveMessage).not.toHaveBeenCalled(); }); + + it('should skip message saving when abort content is only an OAuth prompt', async () => { + const jobStreamId = 'test-stream-123'; + + mockGenerationJobManager.getJob.mockResolvedValue({ + metadata: { userId: 'test-user-123' }, + }); + + mockGenerationJobManager.abortJob.mockResolvedValue({ + success: true, + jobData: { + userMessage: { messageId: 'user-msg-123' }, + responseMessageId: 'response-msg-456', + conversationId: jobStreamId, + }, + content: [ + { + type: 'tool_call', + tool_call: { + type: 'tool_call', + id: 'oauth-call-1', + name: 'oauth_mcp_Google-Workspace', + args: '', + auth: 'https://auth.example.com/oauth', + }, + }, + ], + text: '', + }); + + const response = await request(app) + .post('/api/agents/chat/abort') + .send({ conversationId: jobStreamId }); + + expect(response.status).toBe(200); + expect(mockSaveMessage).not.toHaveBeenCalled(); + }); }); describe('Partial Response Saving', () => { @@ -215,6 +252,7 @@ describe('Agent Abort Endpoint', () => { conversationId: jobStreamId, sender: 'TestAgent', endpoint: 'anthropic', + iconURL: 'https://example.com/spec-icon.png', model: 'claude-3', }, content: [{ type: 'text', text: 'Partial response...' }], @@ -238,6 +276,7 @@ describe('Agent Abort Endpoint', () => { text: 'Partial response...', sender: 'TestAgent', endpoint: 'anthropic', + iconURL: 'https://example.com/spec-icon.png', model: 'claude-3', unfinished: true, error: false, @@ -264,8 +303,8 @@ describe('Agent Abort Endpoint', () => { responseMessageId: 'response-msg-456', conversationId: jobStreamId, }, - content: [], - text: '', + content: [{ type: 'text', text: 'Partial response...' }], + text: 'Partial response...', }); mockSaveMessage.mockRejectedValue(new Error('Database error')); diff --git a/api/server/routes/agents/__tests__/streamTenant.spec.js b/api/server/routes/agents/__tests__/streamTenant.spec.js index 1f89953186e..708a0712284 100644 --- a/api/server/routes/agents/__tests__/streamTenant.spec.js +++ b/api/server/routes/agents/__tests__/streamTenant.spec.js @@ -45,9 +45,11 @@ jest.mock('~/server/middleware', () => ({ })); jest.mock('~/server/routes/agents/chat', () => require('express').Router()); -jest.mock('~/server/routes/agents/v1', () => ({ - v1: require('express').Router(), -})); +jest.mock('~/server/routes/agents/v1', () => { + const router = require('express').Router(); + router.use((req, res) => res.status(418).json({ error: 'v1 caught stream route' })); + return { v1: router }; +}); jest.mock('~/server/routes/agents/openai', () => require('express').Router()); jest.mock('~/server/routes/agents/responses', () => require('express').Router()); diff --git a/api/server/routes/agents/actions.js b/api/server/routes/agents/actions.js index 6133ee24e1d..eab7c908aaf 100644 --- a/api/server/routes/agents/actions.js +++ b/api/server/routes/agents/actions.js @@ -3,8 +3,12 @@ const { nanoid } = require('nanoid'); const { logger } = require('@librechat/data-schemas'); const { generateCheckAccess, + planAgentActionUpdate, isActionDomainAllowed, + legacyActionDomainEncode, validateActionOAuthMetadata, + ACTION_CREDENTIAL_REFRESH_MESSAGE, + buildActionOAuthTokenDeleteQueries, } = require('@librechat/api'); const { Permissions, @@ -16,17 +20,19 @@ const { validateActionDomain, validateAndParseOpenAPISpec, } = require('librechat-data-provider'); -const { - legacyDomainEncode, - encryptMetadata, - domainParser, -} = require('~/server/services/ActionService'); +const { encryptMetadata, domainParser } = require('~/server/services/ActionService'); const { findAccessibleResources } = require('~/server/services/PermissionService'); const db = require('~/models'); const { canAccessAgentResource } = require('~/server/middleware'); const router = express.Router(); +async function deleteActionOAuthTokens(action_id) { + await Promise.all( + buildActionOAuthTokenDeleteQueries(action_id).map((query) => db.deleteTokens(query)), + ); +} + const checkAgentCreate = generateCheckAccess({ permissionType: PermissionTypes.AGENTS, permissions: [Permissions.USE, Permissions.CREATE], @@ -92,7 +98,7 @@ router.post( return res.status(400).json({ message: 'No functions provided' }); } - let metadata = await encryptMetadata(removeNullishValues(_metadata, true)); + const metadata = await encryptMetadata(removeNullishValues(_metadata, true)); const appConfig = req.config; // SECURITY: Validate the OpenAPI spec and extract the server URL @@ -135,15 +141,16 @@ router.post( return res.status(400).json({ message: 'No domain provided' }); } - const legacyDomain = legacyDomainEncode(metadata.domain); + const legacyDomain = legacyActionDomainEncode(metadata.domain); - const action_id = _action_id ?? nanoid(); + const requestedActionId = _action_id; + const action_id = requestedActionId ?? nanoid(); const initialPromises = []; // Permissions already validated by middleware - load agent directly initialPromises.push(db.getAgent({ id: agent_id })); - if (_action_id) { - initialPromises.push(db.getActions({ action_id }, true)); + if (requestedActionId) { + initialPromises.push(db.getActions({ action_id: requestedActionId }, true)); } /** @type {[Agent, [Action|undefined]]} */ @@ -152,53 +159,51 @@ router.post( return res.status(404).json({ message: 'Agent not found for adding action' }); } - if (actions_result && actions_result.length) { - const action = actions_result[0]; - if (action.agent_id !== agent_id) { + const storedAction = actions_result?.[0]; + if (storedAction) { + if (storedAction.agent_id !== agent_id) { return res.status(403).json({ message: 'Action does not belong to this agent' }); } - metadata = { ...action.metadata, ...metadata }; + } + + const { actions: agentActions = [], tools: agentTools = [], author: agent_author } = agent; + const plannedUpdate = planAgentActionUpdate({ + agentActions, + agentTools, + incomingFunctions: functions, + incomingMetadata: metadata, + actionId: action_id, + requestedActionId, + encodedDomain, + legacyDomain, + previousLegacyDomain: legacyActionDomainEncode(storedAction?.metadata?.domain), + storedAction, + }); + + if (plannedUpdate.requiresCredentialRefresh) { + return res.status(400).json({ + message: ACTION_CREDENTIAL_REFRESH_MESSAGE, + }); } try { - await validateActionOAuthMetadata(metadata.auth, appConfig?.actions?.allowedAddresses); + await validateActionOAuthMetadata( + plannedUpdate.metadata.auth, + appConfig?.actions?.allowedAddresses, + ); } catch (error) { return res.status(400).json({ message: error.message }); } - const { actions: _actions = [], author: agent_author } = agent ?? {}; - const actions = []; - for (const action of _actions) { - const [_action_domain, current_action_id] = action.split(actionDelimiter); - if (current_action_id === action_id) { - continue; - } - - actions.push(action); + if (plannedUpdate.deleteOAuthTokens && requestedActionId) { + // Keep the callback URL stable while preventing old OAuth tokens from following a new target. + await deleteActionOAuthTokens(requestedActionId); } - actions.push(`${encodedDomain}${actionDelimiter}${action_id}`); - - /** @type {string[]}} */ - const { tools: _tools = [] } = agent; - - const shouldRemoveAgentTool = (tool) => { - if (!tool) { - return false; - } - return ( - tool.includes(encodedDomain) || tool.includes(legacyDomain) || tool.includes(action_id) - ); - }; - - const tools = _tools - .filter((tool) => !shouldRemoveAgentTool(tool)) - .concat(functions.map((tool) => `${tool.function.name}${actionDelimiter}${encodedDomain}`)); - // Force version update since actions are changing const updatedAgent = await db.updateAgent( { id: agent_id }, - { tools, actions }, + { tools: plannedUpdate.tools, actions: plannedUpdate.actions }, { updatingUserId: req.user.id, forceVersion: true, @@ -206,14 +211,21 @@ router.post( ); // Only update user field for new actions - const actionUpdateData = { metadata, agent_id }; + const actionUpdateData = { + action_id: plannedUpdate.actionId, + metadata: plannedUpdate.metadata, + agent_id, + }; if (!actions_result || !actions_result.length) { // For new actions, use the agent owner's user ID actionUpdateData.user = agent_author || req.user.id; } - /** @type {[Action]} */ - const updatedAction = await db.updateAction({ action_id, agent_id }, actionUpdateData); + /** @type {Action} */ + const updatedAction = await db.updateAction( + { action_id: requestedActionId ?? action_id, agent_id }, + actionUpdateData, + ); const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret']; for (let field of sensitiveFields) { diff --git a/api/server/routes/agents/chat.js b/api/server/routes/agents/chat.js index 0543b0b1aa6..8ffbde6552b 100644 --- a/api/server/routes/agents/chat.js +++ b/api/server/routes/agents/chat.js @@ -1,5 +1,5 @@ const express = require('express'); -const { generateCheckAccess, skipAgentCheck } = require('@librechat/api'); +const { createMessageFilterPii, generateCheckAccess, skipAgentCheck } = require('@librechat/api'); const { PermissionTypes, Permissions, PermissionBits } = require('librechat-data-provider'); const { moderateText, @@ -25,6 +25,7 @@ const checkAgentResourceAccess = canAccessAgentFromBody({ requiredPermission: PermissionBits.VIEW, }); +router.use(createMessageFilterPii({ getConfig: (req) => req.config?.messageFilter?.pii })); router.use(moderateText); router.use(checkAgentAccess); router.use(checkAgentResourceAccess); diff --git a/api/server/routes/agents/index.js b/api/server/routes/agents/index.js index f7076ce153b..145a6c03161 100644 --- a/api/server/routes/agents/index.js +++ b/api/server/routes/agents/index.js @@ -1,5 +1,10 @@ const express = require('express'); -const { isEnabled, GenerationJobManager } = require('@librechat/api'); +const { + isEnabled, + GenerationJobManager, + hasPersistableAbortContent, + buildAbortedResponseMetadata, +} = require('@librechat/api'); const { createSseStreamTelemetry } = require('@librechat/api/telemetry'); const { logger } = require('@librechat/data-schemas'); const { @@ -43,8 +48,6 @@ router.use(requireJwtAuth); router.use(checkBan); router.use(uaParser); -router.use('/', v1); - /** * Stream endpoints - mounted before chatRouter to bypass rate limiters * These are GET requests and don't need message body validation or rate limiting @@ -273,7 +276,8 @@ router.post('/chat/abort', async (req, res) => { if ( abortResult.success && abortResult.jobData?.userMessage?.messageId && - abortResult.jobData?.responseMessageId + abortResult.jobData?.responseMessageId && + hasPersistableAbortContent(abortResult.content) ) { const { jobData, content, text } = abortResult; const responseMessage = { @@ -284,6 +288,7 @@ router.post('/chat/abort', async (req, res) => { text: text || '', sender: jobData.sender || 'AI', endpoint: jobData.endpoint, + iconURL: jobData.iconURL, model: jobData.model, unfinished: true, error: false, @@ -291,6 +296,15 @@ router.post('/chat/abort', async (req, res) => { user: userId, }; + /** Persist the usage/cost rollup + context breakdown for the stopped + * response (from the job's tracked tokenUsage/contextUsage) so its + * branch/total cost and granular rows survive a reload — parity with the + * normal completion path. */ + const abortMetadata = buildAbortedResponseMetadata(jobData); + if (abortMetadata) { + responseMessage.metadata = abortMetadata; + } + try { await saveMessage( { @@ -314,6 +328,8 @@ router.post('/chat/abort', async (req, res) => { return res.status(404).json({ error: 'Job not found', streamId: jobStreamId }); }); +router.use('/', v1); + const chatRouter = express.Router(); chatRouter.use(configMiddleware); diff --git a/api/server/routes/auth.2fa-ratelimit.test.js b/api/server/routes/auth.2fa-ratelimit.test.js new file mode 100644 index 00000000000..35cc0e8840a --- /dev/null +++ b/api/server/routes/auth.2fa-ratelimit.test.js @@ -0,0 +1,120 @@ +const express = require('express'); +const request = require('supertest'); + +const mockSetTwoFactorTempUser = jest.fn((req, res, next) => next()); +const mockTwoFactorTempLimiter = jest.fn((req, res, next) => next()); +const mockCheckBan = jest.fn((req, res, next) => next()); +const mockVerify2FAWithTempToken = jest.fn((req, res) => res.status(204).end()); + +jest.mock('@librechat/api', () => ({ + createSetBalanceConfig: jest.fn(() => (req, res, next) => next()), + forceRefreshCloudFrontAuthCookies: jest.fn(), +})); + +jest.mock('~/server/controllers/AuthController', () => ({ + refreshController: jest.fn((req, res) => res.status(204).end()), + registrationController: jest.fn((req, res) => res.status(204).end()), + resetPasswordController: jest.fn((req, res) => res.status(204).end()), + resetPasswordRequestController: jest.fn((req, res) => res.status(204).end()), + graphTokenController: jest.fn((req, res) => res.status(204).end()), +})); + +jest.mock('~/server/controllers/TwoFactorController', () => ({ + enable2FA: jest.fn((req, res) => res.status(204).end()), + verify2FA: jest.fn((req, res) => res.status(204).end()), + confirm2FA: jest.fn((req, res) => res.status(204).end()), + disable2FA: jest.fn((req, res) => res.status(204).end()), + regenerateBackupCodes: jest.fn((req, res) => res.status(204).end()), +})); + +jest.mock('~/server/controllers/auth/TwoFactorAuthController', () => ({ + verify2FAWithTempToken: (...args) => mockVerify2FAWithTempToken(...args), +})); + +jest.mock('~/server/controllers/auth/LogoutController', () => ({ + logoutController: jest.fn((req, res) => res.status(204).end()), +})); + +jest.mock('~/server/controllers/auth/LoginController', () => ({ + loginController: jest.fn((req, res) => res.status(204).end()), +})); + +jest.mock('~/models', () => ({ + findBalanceByUser: jest.fn(), + upsertBalanceFields: jest.fn(), +})); + +jest.mock('~/server/services/Config', () => ({ + getAppConfig: jest.fn(), +})); + +jest.mock('~/server/middleware', () => { + const pass = (req, res, next) => next(); + return { + logHeaders: pass, + loginLimiter: pass, + setTwoFactorTempUser: (...args) => mockSetTwoFactorTempUser(...args), + twoFactorTempLimiter: (...args) => mockTwoFactorTempLimiter(...args), + checkBan: (...args) => mockCheckBan(...args), + requireLocalAuth: pass, + requireLdapAuth: pass, + registerLimiter: pass, + checkInviteUser: pass, + validateRegistration: pass, + resetPasswordLimiter: pass, + validatePasswordReset: pass, + requireJwtAuth: pass, + }; +}); + +const authRouter = require('./auth'); + +describe('POST /api/auth/2fa/verify-temp rate limiting', () => { + let app; + + beforeEach(() => { + jest.clearAllMocks(); + mockSetTwoFactorTempUser.mockImplementation((req, res, next) => next()); + mockTwoFactorTempLimiter.mockImplementation((req, res, next) => next()); + mockCheckBan.mockImplementation((req, res, next) => next()); + mockVerify2FAWithTempToken.mockImplementation((req, res) => res.status(204).end()); + + app = express(); + app.use(express.json()); + app.use('/api/auth', authRouter); + }); + + it('sets the temp user before limiting, checking bans, and verifying temp 2FA tokens', async () => { + await request(app).post('/api/auth/2fa/verify-temp').send({ token: '123456' }).expect(204); + + expect(mockSetTwoFactorTempUser).toHaveBeenCalledTimes(1); + expect(mockTwoFactorTempLimiter).toHaveBeenCalledTimes(1); + expect(mockCheckBan).toHaveBeenCalledTimes(1); + expect(mockVerify2FAWithTempToken).toHaveBeenCalledTimes(1); + expect(mockSetTwoFactorTempUser.mock.invocationCallOrder[0]).toBeLessThan( + mockTwoFactorTempLimiter.mock.invocationCallOrder[0], + ); + expect(mockTwoFactorTempLimiter.mock.invocationCallOrder[0]).toBeLessThan( + mockCheckBan.mock.invocationCallOrder[0], + ); + expect(mockCheckBan.mock.invocationCallOrder[0]).toBeLessThan( + mockVerify2FAWithTempToken.mock.invocationCallOrder[0], + ); + }); + + it('does not verify the temp 2FA token after the limiter rejects the request', async () => { + mockTwoFactorTempLimiter.mockImplementation((req, res) => + res.status(429).json({ message: 'Too many verification attempts' }), + ); + + const response = await request(app) + .post('/api/auth/2fa/verify-temp') + .send({ token: '123456' }) + .expect(429); + + expect(response.body).toEqual({ message: 'Too many verification attempts' }); + expect(mockSetTwoFactorTempUser).toHaveBeenCalledTimes(1); + expect(mockCheckBan).not.toHaveBeenCalled(); + expect(mockVerify2FAWithTempToken).not.toHaveBeenCalled(); + }); +}); diff --git a/api/server/routes/auth.cloudfront.test.js b/api/server/routes/auth.cloudfront.test.js index 9d50ac97a71..ae786291046 100644 --- a/api/server/routes/auth.cloudfront.test.js +++ b/api/server/routes/auth.cloudfront.test.js @@ -50,6 +50,8 @@ jest.mock('~/server/middleware', () => { return { logHeaders: pass, loginLimiter: pass, + setTwoFactorTempUser: pass, + twoFactorTempLimiter: pass, checkBan: pass, requireLocalAuth: pass, requireLdapAuth: pass, diff --git a/api/server/routes/auth.js b/api/server/routes/auth.js index e2fc08187da..14c4e863897 100644 --- a/api/server/routes/auth.js +++ b/api/server/routes/auth.js @@ -87,7 +87,13 @@ router.post( router.post('/2fa/enable', middleware.requireJwtAuth, enable2FA); router.post('/2fa/verify', middleware.requireJwtAuth, verify2FA); -router.post('/2fa/verify-temp', middleware.checkBan, verify2FAWithTempToken); +router.post( + '/2fa/verify-temp', + middleware.setTwoFactorTempUser, + middleware.twoFactorTempLimiter, + middleware.checkBan, + verify2FAWithTempToken, +); router.post('/2fa/confirm', middleware.requireJwtAuth, confirm2FA); router.post('/2fa/disable', middleware.requireJwtAuth, disable2FA); router.post('/2fa/backup/regenerate', middleware.requireJwtAuth, regenerateBackupCodes); diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index dc59482afa7..c9d77189cdd 100644 --- a/api/server/routes/convos.js +++ b/api/server/routes/convos.js @@ -5,6 +5,8 @@ const { isEnabled, resolveImportMaxFileSize, restoreTenantContextFromReq, + deleteAllSharedLinksWithCleanup, + deleteConvoSharedLinksWithCleanup, } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); const { CacheKeys, EModelEndpoint } = require('librechat-data-provider'); @@ -29,6 +31,9 @@ const assistantClients = { const router = express.Router(); router.use(requireJwtAuth); +const isValidProjectFilter = (projectId) => + !projectId || projectId === 'unassigned' || /^[a-f\d]{24}$/i.test(projectId); + router.get('/', async (req, res) => { const limit = parseInt(req.query.limit, 10) || 25; const cursor = req.query.cursor; @@ -36,6 +41,13 @@ router.get('/', async (req, res) => { const search = req.query.search ? decodeURIComponent(req.query.search) : undefined; const sortBy = req.query.sortBy || 'updatedAt'; const sortDirection = req.query.sortDirection || 'desc'; + const projectId = Array.isArray(req.query.projectId) + ? req.query.projectId[0] + : req.query.projectId; + + if (!isValidProjectFilter(projectId)) { + return res.status(400).json({ error: 'projectId must be a valid project id or unassigned' }); + } let tags; if (req.query.tags) { @@ -51,6 +63,7 @@ router.get('/', async (req, res) => { search, sortBy, sortDirection, + projectId, }); res.status(200).json(result); } catch (error) { @@ -133,7 +146,7 @@ router.delete('/', async (req, res) => { const dbResponse = await db.deleteConvos(req.user.id, filter); if (filter.conversationId) { await db.deleteToolCalls(req.user.id, filter.conversationId); - await db.deleteConvoSharedLink(req.user.id, filter.conversationId); + await deleteConvoSharedLinksWithCleanup(req.user.id, filter.conversationId); } res.status(201).json(dbResponse); } catch (error) { @@ -146,7 +159,7 @@ router.delete('/all', async (req, res) => { try { const dbResponse = await db.deleteConvos(req.user.id, {}); await db.deleteToolCalls(req.user.id); - await db.deleteAllSharedLinks(req.user.id); + await deleteAllSharedLinksWithCleanup(req.user.id); res.status(201).json(dbResponse); } catch (error) { logger.error('Error clearing conversations', error); diff --git a/api/server/routes/endpoints.js b/api/server/routes/endpoints.js index e7ff1c70000..b11de153df6 100644 --- a/api/server/routes/endpoints.js +++ b/api/server/routes/endpoints.js @@ -1,9 +1,14 @@ const express = require('express'); const requireJwtAuth = require('~/server/middleware/requireJwtAuth'); +const configMiddleware = require('~/server/middleware/config/app'); const endpointController = require('~/server/controllers/EndpointController'); +const tokenConfigController = require('~/server/controllers/TokenConfigController'); +const contextProjectionController = require('~/server/controllers/ContextProjectionController'); const router = express.Router(); /** Auth required for role/tenant-scoped endpoint config resolution. */ router.get('/', requireJwtAuth, endpointController); +router.get('/token-config', requireJwtAuth, configMiddleware, tokenConfigController); +router.post('/context-projection', requireJwtAuth, configMiddleware, contextProjectionController); module.exports = router; diff --git a/api/server/routes/files/multer.js b/api/server/routes/files/multer.js index cb155e2ac27..da17ce8008a 100644 --- a/api/server/routes/files/multer.js +++ b/api/server/routes/files/multer.js @@ -5,6 +5,7 @@ const multer = require('multer'); const { sanitizeFilename } = require('@librechat/api'); const { mergeFileConfig, + inferMimeType, getEndpointFileConfig, fileConfig: defaultFileConfig, } = require('librechat-data-provider'); @@ -37,6 +38,14 @@ const importFileFilter = (req, file, cb) => { } }; +const normalizeUploadMimeType = (file) => { + const mimeType = inferMimeType(file.originalname || '', file.mimetype || ''); + if (mimeType && file.mimetype !== mimeType) { + file.mimetype = mimeType; + } + return mimeType; +}; + /** * * @param {import('librechat-data-provider').FileConfig | undefined} customFileConfig @@ -52,7 +61,9 @@ const createFileFilter = (customFileConfig) => { return cb(new Error('No file provided'), false); } - if (req.originalUrl.endsWith('/speech/stt') && file.mimetype.startsWith('audio/')) { + const mimeType = normalizeUploadMimeType(file); + + if (req.originalUrl.endsWith('/speech/stt') && mimeType.startsWith('audio/')) { return cb(null, true); } @@ -64,8 +75,8 @@ const createFileFilter = (customFileConfig) => { endpointType, }); - if (!defaultFileConfig.checkType(file.mimetype, endpointFileConfig.supportedMimeTypes)) { - return cb(new Error('Unsupported file type: ' + file.mimetype), false); + if (!defaultFileConfig.checkType(mimeType, endpointFileConfig.supportedMimeTypes)) { + return cb(new Error('Unsupported file type: ' + (file.mimetype || mimeType)), false); } cb(null, true); @@ -85,4 +96,4 @@ const createMulterInstance = async () => { }); }; -module.exports = { createMulterInstance, storage, importFileFilter }; +module.exports = { createMulterInstance, storage, importFileFilter, createFileFilter }; diff --git a/api/server/routes/files/multer.spec.js b/api/server/routes/files/multer.spec.js index 84b97fe7896..23b7a2458a3 100644 --- a/api/server/routes/files/multer.spec.js +++ b/api/server/routes/files/multer.spec.js @@ -4,7 +4,7 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); const crypto = require('crypto'); -const { createMulterInstance, storage, importFileFilter } = require('./multer'); +const { createMulterInstance, storage, importFileFilter, createFileFilter } = require('./multer'); // Mock only the config service that requires external dependencies jest.mock('~/server/services/Config', () => ({ @@ -281,6 +281,25 @@ describe('Multer Configuration', () => { } }); + it('should infer ZIP MIME type when multipart upload omits it', (done) => { + const { mergeFileConfig } = require('librechat-data-provider'); + const fileFilter = createFileFilter(mergeFileConfig()); + const zipFile = { + ...mockFile, + originalname: 'archive.zip', + mimetype: '', + }; + + const cb = jest.fn((err, result) => { + expect(err).toBeNull(); + expect(result).toBe(true); + expect(zipFile.mimetype).toBe('application/zip'); + done(); + }); + + fileFilter(mockReq, zipFile, cb); + }); + it('should use real mergeFileConfig function', async () => { const { mergeFileConfig, mbToBytes } = require('librechat-data-provider'); diff --git a/api/server/routes/index.js b/api/server/routes/index.js index 6694e47f23e..59955e937e6 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -6,12 +6,14 @@ const adminConfig = require('./admin/config'); const adminGrants = require('./admin/grants'); const adminGroups = require('./admin/groups'); const adminRoles = require('./admin/roles'); +const adminSkills = require('./admin/skills'); const adminUsers = require('./admin/users'); const endpoints = require('./endpoints'); const staticRoute = require('./static'); const messages = require('./messages'); const memories = require('./memories'); const presets = require('./presets'); +const projects = require('./projects'); const prompts = require('./prompts'); const skills = require('./skills'); const balance = require('./balance'); @@ -43,6 +45,7 @@ module.exports = { adminGrants, adminGroups, adminRoles, + adminSkills, adminUsers, keys, apiKeys, @@ -59,6 +62,7 @@ module.exports = { config, models, prompts, + projects, skills, actions, presets, diff --git a/api/server/routes/mcp.js b/api/server/routes/mcp.js index 09d6b7d49ad..1637b8f7e19 100644 --- a/api/server/routes/mcp.js +++ b/api/server/routes/mcp.js @@ -14,6 +14,7 @@ const { MCPTokenStorage, setOAuthSession, PENDING_STALE_MS, + mcpConfig: mcpSettings, getUserMCPAuthMap, validateOAuthCsrf, OAUTH_CSRF_COOKIE, @@ -38,6 +39,7 @@ const { } = require('~/config'); const { getServerConnectionStatus, + resolveAllMcpConfigs, resolveConfigServers, getMCPSetupData, } = require('~/server/services/MCP'); @@ -52,6 +54,29 @@ const router = Router(); const OAUTH_CSRF_COOKIE_PATH = '/api/mcp'; +const getOAuthFlowId = (userId, serverName) => + MCPOAuthHandler.generateFlowId(userId, serverName, getTenantId()); + +const canAccessOAuthFlow = (flowId, userId) => { + const parsed = MCPOAuthHandler.parseFlowId(flowId); + if (!parsed) { + return false; + } + if (parsed.tenantId && parsed.tenantId !== getTenantId()) { + return false; + } + return parsed.userId === userId || parsed.userId === 'system'; +}; + +const clearGetTokensFlow = async ({ flowManager, flowId, tokens }) => { + const state = await flowManager.getFlowState(flowId, 'mcp_get_tokens'); + if (state?.type === 'mcp_get_tokens' && state.status === 'PENDING') { + await flowManager.completeFlow(flowId, 'mcp_get_tokens', tokens); + return; + } + await flowManager.deleteFlow(flowId, 'mcp_get_tokens'); +}; + const checkMCPUsePermissions = generateCheckAccess({ permissionType: PermissionTypes.MCP_SERVERS, permissions: [Permissions.USE], @@ -83,10 +108,21 @@ router.get('/:serverName/oauth/initiate', requireJwtAuth, setOAuthSession, async const user = req.user; // Verify the userId matches the authenticated user - if (userId !== user.id) { + if (typeof userId !== 'string' || userId !== user.id) { return res.status(403).json({ error: 'User mismatch' }); } + const expectedFlowId = getOAuthFlowId(user.id, serverName); + if (typeof flowId !== 'string' || flowId !== expectedFlowId) { + logger.error('[MCP OAuth] Invalid flow ID for initiate request', { + serverName, + userId, + flowId, + expectedFlowId, + }); + return res.status(403).json({ error: 'Flow mismatch' }); + } + logger.debug('[MCP OAuth] Initiate request', { serverName, userId, flowId }); const flowsCache = getLogStores(CacheKeys.FLOWS); @@ -99,7 +135,45 @@ router.get('/:serverName/oauth/initiate', requireJwtAuth, setOAuthSession, async return res.status(404).json({ error: 'Flow not found' }); } - const { serverUrl, oauth: oauthConfig } = flowState.metadata || {}; + const { + authorizationUrl: storedAuthorizationUrl, + serverName: flowServerName, + userId: flowUserId, + serverUrl, + oauth: oauthConfig, + } = flowState.metadata || {}; + + if (flowUserId && flowUserId !== user.id) { + logger.error('[MCP OAuth] Flow user mismatch', { flowId, userId, flowUserId }); + return res.status(403).json({ error: 'User mismatch' }); + } + + if (flowServerName && flowServerName !== serverName) { + logger.error('[MCP OAuth] Flow server mismatch', { flowId, serverName, flowServerName }); + return res.status(400).json({ error: 'Invalid flow state' }); + } + + const pendingAge = flowState.createdAt ? Date.now() - flowState.createdAt : Infinity; + const isFreshPendingFlow = flowState.status === 'PENDING' && pendingAge < PENDING_STALE_MS; + if (!isFreshPendingFlow) { + logger.error('[MCP OAuth] Flow is not active for initiation', { + flowId, + status: flowState.status, + pendingAge, + }); + return res.status(400).json({ error: 'Invalid flow state' }); + } + + if (typeof storedAuthorizationUrl === 'string' && storedAuthorizationUrl.length > 0) { + logger.debug('[MCP OAuth] Reusing stored authorization URL', { + serverName, + userId, + flowId, + }); + setOAuthCsrfCookie(res, flowId, OAUTH_CSRF_COOKIE_PATH); + return res.redirect(storedAuthorizationUrl); + } + if (!serverUrl || !oauthConfig) { logger.error('[MCP OAuth] Missing server URL or OAuth config in flow state'); return res.status(400).json({ error: 'Invalid flow state' }); @@ -108,8 +182,10 @@ router.get('/:serverName/oauth/initiate', requireJwtAuth, setOAuthSession, async const configServers = await resolveConfigServers(req); const oauthHeaders = await getOAuthHeaders(serverName, userId, configServers); const registry = getMCPServersRegistry(); - const allowedDomains = registry.getAllowedDomains(); - const allowedAddresses = registry.getAllowedAddresses(); + const { allowedDomains, allowedAddresses } = await registry.resolveAllowlists({ + userId, + role: req.user?.role, + }); const { authorizationUrl, flowId: oauthFlowId, @@ -123,10 +199,17 @@ router.get('/:serverName/oauth/initiate', requireJwtAuth, setOAuthSession, async allowedDomains, undefined, allowedAddresses, + getTenantId(), ); logger.debug('[MCP OAuth] OAuth flow initiated', { oauthFlowId, authorizationUrl }); + const oldState = flowState.metadata?.state; + if (typeof oldState === 'string') { + await MCPOAuthHandler.deleteStateMapping(oldState, flowManager); + } + const metadataWithUrl = { ...flowMetadata, authorizationUrl, tenantId: getTenantId() }; + await flowManager.initFlow(oauthFlowId, 'mcp_oauth', metadataWithUrl); await MCPOAuthHandler.storeStateMapping(flowMetadata.state, oauthFlowId, flowManager); setOAuthCsrfCookie(res, oauthFlowId, OAUTH_CSRF_COOKIE_PATH); res.redirect(authorizationUrl); @@ -162,16 +245,21 @@ router.get('/:serverName/oauth/callback', async (req, res) => { const flowManager = getFlowStateManager(flowsCache); const flowId = await MCPOAuthHandler.resolveStateToFlowId(state, flowManager); if (flowId) { - const flowParts = flowId.split(':'); - const [flowUserId] = flowParts; - const hasCsrf = validateOAuthCsrf(req, res, flowId, OAUTH_CSRF_COOKIE_PATH); - const hasSession = !hasCsrf && validateOAuthSession(req, flowUserId); - if (hasCsrf || hasSession) { - await flowManager.failFlow(flowId, 'mcp_oauth', String(oauthError)); - logger.debug('[MCP OAuth] Marked flow as FAILED with OAuth error', { + const parsed = MCPOAuthHandler.parseFlowId(flowId); + if (!parsed) { + logger.warn('[MCP OAuth] Invalid flow ID format for OAuth error callback', { flowId, - error: oauthError, }); + } else { + const hasCsrf = validateOAuthCsrf(req, res, flowId, OAUTH_CSRF_COOKIE_PATH); + const hasSession = !hasCsrf && validateOAuthSession(req, parsed.userId); + if (hasCsrf || hasSession) { + await flowManager.failFlow(flowId, 'mcp_oauth', String(oauthError)); + logger.debug('[MCP OAuth] Marked flow as FAILED with OAuth error', { + flowId, + error: oauthError, + }); + } } } } catch (err) { @@ -203,16 +291,14 @@ router.get('/:serverName/oauth/callback', async (req, res) => { } logger.debug('[MCP OAuth] Resolved flow ID from state', { flowId }); - const flowParts = flowId.split(':'); - if (flowParts.length < 2 || !flowParts[0] || !flowParts[1]) { + const parsedFlowId = MCPOAuthHandler.parseFlowId(flowId); + if (!parsedFlowId) { logger.error('[MCP OAuth] Invalid flow ID format', { flowId }); return res.redirect(`${basePath}/oauth/error?error=invalid_state`); } - const [flowUserId] = flowParts; - const hasCsrf = validateOAuthCsrf(req, res, flowId, OAUTH_CSRF_COOKIE_PATH); - const hasSession = !hasCsrf && validateOAuthSession(req, flowUserId); + const hasSession = !hasCsrf && validateOAuthSession(req, parsedFlowId.userId); let hasActiveFlow = false; if (!hasCsrf && !hasSession) { const pendingFlow = await flowManager.getFlowState(flowId, 'mcp_oauth'); @@ -309,7 +395,10 @@ router.get('/:serverName/oauth/callback', async (req, res) => { updateToken: db.updateToken, findToken: db.findToken, clientInfo: flowState.clientInfo, - metadata: flowState.metadata, + metadata: MCPOAuthHandler.buildStoredClientMetadata( + flowState.metadata, + flowState.resourceMetadata, + ), }); logger.debug('[MCP OAuth] Stored OAuth tokens prior to reconnection', { serverName, @@ -326,7 +415,23 @@ router.get('/:serverName/oauth/callback', async (req, res) => { */ if (typeof flowManager?.deleteFlow === 'function') { try { - await flowManager.deleteFlow(flowId, 'mcp_get_tokens'); + const tokenFlowId = MCPOAuthHandler.generateTokenFlowId( + flowState.userId, + serverName, + flowState.tenantId, + ); + await clearGetTokensFlow({ + flowManager, + flowId: tokenFlowId, + tokens, + }); + if (tokenFlowId !== flowId) { + await clearGetTokensFlow({ + flowManager, + flowId, + tokens, + }); + } } catch (error) { logger.warn('[MCP OAuth] Failed to clear cached token flow state', error); } @@ -340,10 +445,25 @@ router.get('/:serverName/oauth/callback', async (req, res) => { if (flowState.userId !== 'system') { const user = { id: flowState.userId }; + /** Merged config (incl. Config-tier overlays) so the reconnection and + * the cache gate both see request-scoped servers the base registry + * lookup misses */ + let serverConfig; + try { + const allConfigs = await resolveAllMcpConfigs(flowState.userId); + serverConfig = allConfigs?.[serverName]; + } catch (error) { + logger.warn( + `[MCP OAuth] Could not resolve server config for ${serverName} before reconnecting:`, + error, + ); + } + const userConnection = await mcpManager.getUserConnection({ user, serverName, flowManager, + serverConfig, tokenMethods: { findToken: db.findToken, updateToken: db.updateToken, @@ -364,6 +484,7 @@ router.get('/:serverName/oauth/callback', async (req, res) => { userId: flowState.userId, serverName, tools, + serverConfig, }); } else { logger.debug(`[MCP OAuth] System-level OAuth completed for ${serverName}`); @@ -411,7 +532,7 @@ router.get('/oauth/tokens/:flowId', requireJwtAuth, async (req, res) => { return res.status(401).json({ error: 'User not authenticated' }); } - if (!flowId.startsWith(`${user.id}:`) && !flowId.startsWith('system:')) { + if (!canAccessOAuthFlow(flowId, user.id)) { return res.status(403).json({ error: 'Access denied' }); } @@ -448,7 +569,7 @@ router.post('/:serverName/oauth/bind', requireJwtAuth, setOAuthSession, async (r return res.status(401).json({ error: 'User not authenticated' }); } - const flowId = MCPOAuthHandler.generateFlowId(user.id, serverName); + const flowId = getOAuthFlowId(user.id, serverName); setOAuthCsrfCookie(res, flowId, OAUTH_CSRF_COOKIE_PATH); res.json({ success: true }); @@ -471,7 +592,7 @@ router.get('/oauth/status/:flowId', requireJwtAuth, async (req, res) => { return res.status(401).json({ error: 'User not authenticated' }); } - if (!flowId.startsWith(`${user.id}:`) && !flowId.startsWith('system:')) { + if (!canAccessOAuthFlow(flowId, user.id)) { return res.status(403).json({ error: 'Access denied' }); } @@ -512,7 +633,7 @@ router.post('/oauth/cancel/:serverName', requireJwtAuth, async (req, res) => { const flowsCache = getLogStores(CacheKeys.FLOWS); const flowManager = getFlowStateManager(flowsCache); - const flowId = MCPOAuthHandler.generateFlowId(user.id, serverName); + const flowId = getOAuthFlowId(user.id, serverName); const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth'); if (!flowState) { @@ -600,7 +721,7 @@ router.post( const { success, message, oauthRequired, oauthUrl } = result; if (oauthRequired) { - const flowId = MCPOAuthHandler.generateFlowId(user.id, serverName); + const flowId = getOAuthFlowId(user.id, serverName); setOAuthCsrfCookie(res, flowId, OAUTH_CSRF_COOKIE_PATH); } @@ -660,6 +781,7 @@ router.get('/connection/status', requireJwtAuth, async (req, res) => { res.json({ success: true, connectionStatus, + oauthTimeout: mcpSettings.OAUTH_HANDLING_TIMEOUT, }); } catch (error) { logger.error('[MCP Connection Status] Failed to get connection status', error); diff --git a/api/server/routes/messages.js b/api/server/routes/messages.js index a07293c0e2c..17e740c5151 100644 --- a/api/server/routes/messages.js +++ b/api/server/routes/messages.js @@ -1,8 +1,13 @@ const express = require('express'); const { v4: uuidv4 } = require('uuid'); const { logger } = require('@librechat/data-schemas'); -const { ContentTypes } = require('librechat-data-provider'); -const { unescapeLaTeX, countTokens } = require('@librechat/api'); +const { ContentTypes, isAssistantsEndpoint } = require('librechat-data-provider'); +const { + unescapeLaTeX, + countTokens, + sendFeedbackScore, + traceIdForMessage, +} = require('@librechat/api'); const { findAllArtifacts, replaceArtifactContent } = require('~/server/services/Artifacts/update'); const { requireJwtAuth, validateMessageReq } = require('~/server/middleware'); const db = require('~/models'); @@ -391,6 +396,25 @@ router.put('/:conversationId/:messageId/feedback', validateMessageReq, async (re { context: 'updateFeedback' }, ); + // Best-effort: Assistants messages do not have deterministic AgentRun traces. + if (!isAssistantsEndpoint(updatedMessage.endpoint)) { + sendFeedbackScore({ + traceId: traceIdForMessage(messageId), + feedback: updatedMessage.feedback, + metadata: { + messageId: updatedMessage.messageId ?? messageId, + parentMessageId: updatedMessage.parentMessageId, + conversationId: updatedMessage.conversationId ?? conversationId, + sessionId: updatedMessage.conversationId ?? conversationId, + userId: req?.user?.id, + endpoint: updatedMessage.endpoint, + sender: updatedMessage.sender, + isCreatedByUser: updatedMessage.isCreatedByUser, + tokenCount: updatedMessage.tokenCount, + }, + }).catch((err) => logger.error('[langfuse] feedback score failed:', err)); + } + res.json({ messageId, conversationId, diff --git a/api/server/routes/projects.js b/api/server/routes/projects.js new file mode 100644 index 00000000000..fd782a1ba5d --- /dev/null +++ b/api/server/routes/projects.js @@ -0,0 +1,25 @@ +const express = require('express'); +const { createProjectHandlers } = require('@librechat/api'); +const requireJwtAuth = require('~/server/middleware/requireJwtAuth'); +const db = require('~/models'); + +const router = express.Router(); +const handlers = createProjectHandlers({ + listChatProjects: db.listChatProjects, + createChatProject: db.createChatProject, + getChatProject: db.getChatProject, + updateChatProject: db.updateChatProject, + deleteChatProject: db.deleteChatProject, + assignConversationToProject: db.assignConversationToProject, +}); + +router.use(requireJwtAuth); + +router.get('/', handlers.listProjects); +router.post('/', handlers.createProject); +router.put('/conversations/:conversationId', handlers.assignConversationToProject); +router.get('/:projectId', handlers.getProject); +router.patch('/:projectId', handlers.updateProject); +router.delete('/:projectId', handlers.deleteProject); + +module.exports = router; diff --git a/api/server/routes/rum.js b/api/server/routes/rum.js index b407992e1e8..cc5c2f281a4 100644 --- a/api/server/routes/rum.js +++ b/api/server/routes/rum.js @@ -1,6 +1,6 @@ const express = require('express'); const { getRumProxyBodyLimit, isRumProxyEnabled, proxyRumRequest } = require('@librechat/api'); -const { requireJwtAuth } = require('~/server/middleware'); +const { requireRumProxyAuth } = require('~/server/middleware'); const router = express.Router(); const rawOtlpBody = express.raw({ @@ -16,7 +16,13 @@ function requireRumProxyEnabled(_req, res, next) { return next(); } -router.post('/v1/traces', requireRumProxyEnabled, requireJwtAuth, rawOtlpBody, proxyRumRequest); -router.post('/v1/logs', requireRumProxyEnabled, requireJwtAuth, rawOtlpBody, proxyRumRequest); +router.post( + '/v1/traces', + requireRumProxyEnabled, + requireRumProxyAuth, + rawOtlpBody, + proxyRumRequest, +); +router.post('/v1/logs', requireRumProxyEnabled, requireRumProxyAuth, rawOtlpBody, proxyRumRequest); module.exports = router; diff --git a/api/server/routes/share.js b/api/server/routes/share.js index ce4dee1a1f4..03cc00fbc2c 100644 --- a/api/server/routes/share.js +++ b/api/server/routes/share.js @@ -1,18 +1,36 @@ const mongoose = require('mongoose'); const express = require('express'); -const { isEnabled, isActiveExpirationDate, getSharedLinkExpiration } = require('@librechat/api'); +const { + isEnabled, + generateCheckAccess, + grantCreationPermissions, + ensureLinkPermissions, + deleteSharedLinkWithCleanup, + updateSharedLinkPermissionsExpiration, + isActiveExpirationDate, + getSharedLinkExpiration, +} = require('@librechat/api'); const { logger, createTempChatExpirationDate } = require('@librechat/data-schemas'); +const { PermissionTypes, Permissions } = require('librechat-data-provider'); const { getSharedMessages, createSharedLink, updateSharedLink, - deleteSharedLink, getSharedLinks, getSharedLink, + getRoleByName, } = require('~/models'); +const canAccessSharedLink = require('~/server/middleware/canAccessSharedLink'); +const optionalJwtAuth = require('~/server/middleware/optionalJwtAuth'); const requireJwtAuth = require('~/server/middleware/requireJwtAuth'); const router = express.Router(); +const checkSharedLinksAccess = generateCheckAccess({ + permissionType: PermissionTypes.SHARED_LINKS, + permissions: [Permissions.CREATE], + getRoleByName, +}); + const resolveSharedLinkExpiration = (req, conversationId) => getSharedLinkExpiration( { req, conversationId }, @@ -36,25 +54,20 @@ const allowSharedLinks = process.env.ALLOW_SHARED_LINKS === undefined || isEnabled(process.env.ALLOW_SHARED_LINKS); if (allowSharedLinks) { - const allowSharedLinksPublic = isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC); - router.get( - '/:shareId', - allowSharedLinksPublic ? (req, res, next) => next() : requireJwtAuth, - async (req, res) => { - try { - const share = await getSharedMessages(req.params.shareId); - - if (share) { - res.status(200).json(share); - } else { - res.status(404).end(); - } - } catch (error) { - logger.error('Error getting shared messages:', error); - res.status(500).json({ message: 'Error getting shared messages' }); + router.get('/:shareId', optionalJwtAuth, canAccessSharedLink, async (req, res) => { + try { + const share = await getSharedMessages(req.params.shareId, req.shareResourceId); + if (share) { + res.set('Cache-Control', 'private, no-store'); + res.status(200).json(share); + } else { + res.status(404).end(); } - }, - ); + } catch (error) { + logger.error('Error getting shared messages:', error); + res.status(500).json({ message: 'Error getting shared messages' }); + } + }); } /** @@ -65,7 +78,6 @@ router.get('/', requireJwtAuth, async (req, res) => { const params = { pageParam: req.query.cursor, pageSize: Math.max(1, parseInt(req.query.pageSize) || 10), - isPublic: isEnabled(req.query.isPublic), sortBy: ['createdAt', 'title'].includes(req.query.sortBy) ? req.query.sortBy : 'createdAt', sortDirection: ['asc', 'desc'].includes(req.query.sortDirection) ? req.query.sortDirection @@ -77,7 +89,6 @@ router.get('/', requireJwtAuth, async (req, res) => { req.user.id, params.pageParam, params.pageSize, - params.isPublic, params.sortBy, params.sortDirection, params.search, @@ -101,7 +112,12 @@ router.get('/link/:conversationId', requireJwtAuth, async (req, res) => { try { const share = await getSharedLink(req.user.id, req.params.conversationId); + if (share._id && share.success) { + await ensureLinkPermissions(share._id, req.user.id); + } + return res.status(200).json({ + _id: share._id, success: share.success, shareId: share.shareId, targetMessageId: share.targetMessageId, @@ -113,7 +129,7 @@ router.get('/link/:conversationId', requireJwtAuth, async (req, res) => { } }); -router.post('/:conversationId', requireJwtAuth, async (req, res) => { +router.post('/:conversationId', requireJwtAuth, checkSharedLinksAccess, async (req, res) => { try { const { targetMessageId } = req.body; const expiredAt = await resolveSharedLinkExpiration(req, req.params.conversationId); @@ -121,6 +137,10 @@ router.post('/:conversationId', requireJwtAuth, async (req, res) => { return res.status(404).end(); } + const role = await getRoleByName(req.user.role); + const sharedLinksPerms = role?.permissions?.[PermissionTypes.SHARED_LINKS] || {}; + const grantPublic = sharedLinksPerms[Permissions.SHARE_PUBLIC] === true; + const created = await createSharedLink( req.user.id, req.params.conversationId, @@ -128,6 +148,7 @@ router.post('/:conversationId', requireJwtAuth, async (req, res) => { expiredAt, ); if (created) { + await grantCreationPermissions(created._id, req.user.id, grantPublic, expiredAt); res.status(200).json(created); } else { res.status(404).end(); @@ -165,6 +186,9 @@ router.patch('/:shareId', requireJwtAuth, async (req, res) => { expiredAt, ); if (updatedShare) { + if (updatedShare._id && expiredAt !== undefined) { + await updateSharedLinkPermissionsExpiration(updatedShare._id, expiredAt); + } res.status(200).json(updatedShare); } else { res.status(404).end(); @@ -177,7 +201,7 @@ router.patch('/:shareId', requireJwtAuth, async (req, res) => { router.delete('/:shareId', requireJwtAuth, async (req, res) => { try { - const result = await deleteSharedLink(req.user.id, req.params.shareId); + const result = await deleteSharedLinkWithCleanup(req.user.id, req.params.shareId); if (!result) { return res.status(404).json({ message: 'Share not found' }); diff --git a/api/server/routes/skills.js b/api/server/routes/skills.js index 694009a480b..99339d2a297 100644 --- a/api/server/routes/skills.js +++ b/api/server/routes/skills.js @@ -21,14 +21,11 @@ const { const { createSkill, getSkillById, - listSkillsByAccess, updateSkill, deleteSkill, - listSkillFiles, upsertSkillFile, deleteSkillFile, getSkillFileByPath, - updateSkillFileContent, getRoleByName, } = require('~/models'); const { requireJwtAuth, canAccessSkillResource } = require('~/server/middleware'); @@ -40,8 +37,14 @@ const { } = require('~/server/services/PermissionService'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { createFileLimiters } = require('~/server/middleware/limiters/uploadLimiters'); +const { maybeRunGitHubSkillSyncForRequest } = require('~/server/services/Skills/sync'); const configMiddleware = require('~/server/middleware/config/app'); const { getFileStrategy } = require('~/server/utils/getFileStrategy'); +const { + getSkillDbMethods, + withDeploymentSkillIds, + getSkillStrategyFunctions, +} = require('~/server/services/Endpoints/agents/skillDeps'); const router = express.Router(); @@ -100,6 +103,7 @@ const checkSkillCreate = generateCheckAccess({ // Rate limiters (reuse existing file upload limiters) // --------------------------------------------------------------------------- const { fileUploadIpLimiter, fileUploadUserLimiter } = createFileLimiters(); +const skillDbMethods = getSkillDbMethods(); router.use(requireJwtAuth); router.use(configMiddleware); @@ -110,18 +114,28 @@ router.use(checkSkillAccess); // --------------------------------------------------------------------------- const handlers = createSkillsHandlers({ createSkill, - getSkillById, - listSkillsByAccess, + getSkillById: skillDbMethods.getSkillById, + listSkillsByAccess: skillDbMethods.listSkillsByAccess, updateSkill, deleteSkill, - listSkillFiles, + listSkillFiles: skillDbMethods.listSkillFiles, deleteSkillFile, - getSkillFileByPath, - updateSkillFileContent, - getStrategyFunctions, - findAccessibleResources, - findPubliclyAccessibleResources, - hasPublicPermission, + getSkillFileByPath: skillDbMethods.getSkillFileByPath, + updateSkillFileContent: skillDbMethods.updateSkillFileContent, + getStrategyFunctions: getSkillStrategyFunctions, + findAccessibleResources: async (params) => + params.resourceType === 'skill' && params.requiredPermissions === PermissionBits.VIEW + ? withDeploymentSkillIds(await findAccessibleResources(params)) + : findAccessibleResources(params), + findPubliclyAccessibleResources: async (params) => + params.resourceType === 'skill' && params.requiredPermissions === PermissionBits.VIEW + ? withDeploymentSkillIds(await findPubliclyAccessibleResources(params)) + : findPubliclyAccessibleResources(params), + hasPublicPermission: async (params) => + params.resourceType === 'skill' && params.requiredPermissions === PermissionBits.VIEW + ? withDeploymentSkillIds([]).some((id) => id.toString() === params.resourceId.toString()) || + hasPublicPermission(params) + : hasPublicPermission(params), grantPermission, isValidObjectIdString, }); @@ -272,6 +286,14 @@ async function uploadFileHandler(req, res) { // --------------------------------------------------------------------------- // Routes // --------------------------------------------------------------------------- +async function maybeStartRequestSkillSync(req, _res, next) { + try { + await maybeRunGitHubSkillSyncForRequest(req); + } catch (error) { + logger.error('[GET /skills] Failed to start request-scoped skill sync:', error); + } + next(); +} // Import: accepts .md / .zip / .skill via multipart router.post( @@ -284,7 +306,7 @@ router.post( importHandler, ); -router.get('/', handlers.list); +router.get('/', maybeStartRequestSkillSync, handlers.list); router.post('/', checkSkillCreate, handlers.create); router.get( diff --git a/api/server/routes/skills.test.js b/api/server/routes/skills.test.js index af99e4bc5e5..c48c0ff70bf 100644 --- a/api/server/routes/skills.test.js +++ b/api/server/routes/skills.test.js @@ -33,6 +33,7 @@ const { } = require('librechat-data-provider'); let mockFileConfig; +const mockMaybeRunGitHubSkillSyncForRequest = jest.fn(async () => false); jest.mock('~/server/services/Config', () => ({ getCachedTools: jest.fn().mockResolvedValue({}), @@ -68,6 +69,10 @@ jest.mock('~/server/utils/getFileStrategy', () => ({ getFileStrategy: jest.fn().mockReturnValue('local'), })); +jest.mock('~/server/services/Skills/sync', () => ({ + maybeRunGitHubSkillSyncForRequest: mockMaybeRunGitHubSkillSyncForRequest, +})); + jest.mock('~/models', () => { const mongoose = require('mongoose'); const { createMethods } = require('@librechat/data-schemas'); @@ -152,6 +157,7 @@ afterEach(async () => { await AclEntry.deleteMany({}); currentTestUser = testUsers.owner; mockFileConfig = undefined; + mockMaybeRunGitHubSkillSyncForRequest.mockClear(); }); afterAll(async () => { @@ -409,6 +415,12 @@ describe('Skill routes', () => { setTestUser(testUsers.owner); const res = await request(app).get('/api/skills'); expect(res.status).toBe(200); + expect(mockMaybeRunGitHubSkillSyncForRequest).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ fileStrategy: 'local' }), + user: expect.objectContaining({ id: testUsers.owner._id.toString() }), + }), + ); expect(res.body.skills.length).toBe(1); expect(res.body.skills[0].name).toBe('mine-skill'); }); diff --git a/api/server/services/ActionService.js b/api/server/services/ActionService.js index 2e324b53124..db8a536d8e5 100644 --- a/api/server/services/ActionService.js +++ b/api/server/services/ActionService.js @@ -29,7 +29,7 @@ const { deleteActions, deleteAssistant, } = require('~/models'); -const { getFlowStateManager } = require('~/config'); +const { getActionFlowStateManager } = require('~/config'); const { getLogStores } = require('~/cache'); const JWT_SECRET = process.env.JWT_SECRET; @@ -243,7 +243,7 @@ async function createActionTool({ }, }; const flowsCache = getLogStores(CacheKeys.FLOWS); - const flowManager = getFlowStateManager(flowsCache); + const flowManager = getActionFlowStateManager(flowsCache); await flowManager.createFlowWithHandler( `${identifier}:oauth_login:${config.metadata.thread_id}:${config.metadata.run_id}`, 'oauth_login', @@ -341,7 +341,7 @@ async function createActionTool({ }, ); const flowsCache = getLogStores(CacheKeys.FLOWS); - const flowManager = getFlowStateManager(flowsCache); + const flowManager = getActionFlowStateManager(flowsCache); const refreshData = await flowManager.createFlowWithHandler( `${identifier}:refresh`, 'oauth_refresh', diff --git a/api/server/services/AuthService.js b/api/server/services/AuthService.js index c3aca089d44..8f6c281ee1d 100644 --- a/api/server/services/AuthService.js +++ b/api/server/services/AuthService.js @@ -45,9 +45,113 @@ const domains = { server: process.env.DOMAIN_SERVER, }; +const AuthTokenTypes = Object.freeze({ + EMAIL_VERIFICATION: 'email_verification', + PASSWORD_RESET: 'password_reset', +}); + +const latestAuthTokenOptions = Object.freeze({ sort: { createdAt: -1 } }); const genericVerificationMessage = 'Please check your email to verify your email address.'; +const invalidEmailVerificationMessage = 'Invalid or expired email verification token'; const OPENID_SESSION_ID_TOKEN_EXPIRY_BUFFER_SECONDS = 30; +const findPasswordResetToken = async (userId) => { + const typedToken = await findToken( + { + userId, + type: AuthTokenTypes.PASSWORD_RESET, + }, + latestAuthTokenOptions, + ); + + if (typedToken) { + return typedToken; + } + + return await findToken( + { + userId, + email: null, + identifier: null, + type: null, + }, + latestAuthTokenOptions, + ); +}; + +const findEmailVerificationToken = async (user) => { + const typedToken = await findToken( + { + userId: user._id, + email: user.email, + type: AuthTokenTypes.EMAIL_VERIFICATION, + }, + latestAuthTokenOptions, + ); + + if (typedToken) { + return typedToken; + } + + return await findToken( + { + userId: user._id, + email: user.email, + identifier: null, + type: null, + }, + latestAuthTokenOptions, + ); +}; + +const deleteEmailVerificationTokens = (user) => + Promise.all([ + deleteTokens({ + userId: user._id, + email: user.email, + type: AuthTokenTypes.EMAIL_VERIFICATION, + }), + deleteTokens({ + userId: user._id, + email: user.email, + identifier: null, + type: null, + }), + ]); + +const getEmailVerificationTokenDeleteQuery = (emailVerificationToken) => { + if (!emailVerificationToken.identifier && !emailVerificationToken.type) { + return { + token: emailVerificationToken.token, + userId: emailVerificationToken.userId, + email: emailVerificationToken.email, + identifier: null, + type: null, + }; + } + + return { + token: emailVerificationToken.token, + type: AuthTokenTypes.EMAIL_VERIFICATION, + }; +}; + +const getPasswordResetTokenDeleteQuery = (passwordResetToken) => { + if (!passwordResetToken.email && !passwordResetToken.type) { + return { + token: passwordResetToken.token, + email: null, + identifier: null, + type: null, + }; + } + + return { + token: passwordResetToken.token, + type: AuthTokenTypes.PASSWORD_RESET, + }; +}; + const getUnexpiredOpenIDSessionIdToken = (idToken) => { if (!idToken) { return; @@ -133,6 +237,7 @@ const sendVerificationEmail = async (user) => { await createToken({ userId: user._id, email: user.email, + type: AuthTokenTypes.EMAIL_VERIFICATION, token: hash, createdAt: Date.now(), expiresIn: 900, @@ -147,25 +252,46 @@ const sendVerificationEmail = async (user) => { */ const verifyEmail = async (req) => { const { email, token } = req.body; - const decodedEmail = decodeURIComponent(email); + + if (typeof email !== 'string' || typeof token !== 'string' || !email || !token) { + logger.warn('[verifyEmail] [Invalid email verification request]'); + return new Error(invalidEmailVerificationMessage); + } + + let decodedEmail; + try { + decodedEmail = decodeURIComponent(email); + } catch { + logger.warn(`[verifyEmail] [Invalid email encoding] [Email: ${email}]`); + return new Error(invalidEmailVerificationMessage); + } const user = await findUser({ email: decodedEmail }, 'email _id emailVerified'); if (!user) { logger.warn(`[verifyEmail] [User not found] [Email: ${decodedEmail}]`); - return new Error('User not found'); + return new Error(invalidEmailVerificationMessage); } - if (user.emailVerified) { - logger.info(`[verifyEmail] Email already verified [Email: ${decodedEmail}]`); - return { message: 'Email already verified', status: 'success' }; - } - - let emailVerificationData = await findToken({ email: decodedEmail }, { sort: { createdAt: -1 } }); + const emailVerificationData = await findEmailVerificationToken(user); if (!emailVerificationData) { logger.warn(`[verifyEmail] [No email verification data found] [Email: ${decodedEmail}]`); - return new Error('Invalid or expired password reset token'); + return new Error(invalidEmailVerificationMessage); + } + + if (!emailVerificationData.token) { + logger.warn( + `[verifyEmail] [Email verification token data is invalid] [Email: ${decodedEmail}]`, + ); + return new Error(invalidEmailVerificationMessage); + } + + const tokenUserId = emailVerificationData.userId?.toString(); + const userId = user._id?.toString(); + if (!tokenUserId || tokenUserId !== userId) { + logger.warn(`[verifyEmail] [Email verification token user mismatch] [Email: ${decodedEmail}]`); + return new Error(invalidEmailVerificationMessage); } const isValid = bcrypt.compareSync(token, emailVerificationData.token); @@ -174,17 +300,23 @@ const verifyEmail = async (req) => { logger.warn( `[verifyEmail] [Invalid or expired email verification token] [Email: ${decodedEmail}]`, ); - return new Error('Invalid or expired email verification token'); + return new Error(invalidEmailVerificationMessage); + } + + if (user.emailVerified) { + await deleteTokens(getEmailVerificationTokenDeleteQuery(emailVerificationData)); + logger.info(`[verifyEmail] Email already verified [Email: ${decodedEmail}]`); + return { message: 'Email verification was successful', status: 'success' }; } const updatedUser = await updateUser(emailVerificationData.userId, { emailVerified: true }); if (!updatedUser) { logger.warn(`[verifyEmail] [User update failed] [Email: ${decodedEmail}]`); - return new Error('Failed to update user verification status'); + return new Error(invalidEmailVerificationMessage); } - await deleteTokens({ token: emailVerificationData.token }); + await deleteTokens(getEmailVerificationTokenDeleteQuery(emailVerificationData)); logger.info(`[verifyEmail] Email verification successful [Email: ${decodedEmail}]`); return { message: 'Email verification was successful', status: 'success' }; }; @@ -337,12 +469,16 @@ const requestPasswordReset = async (req) => { }; } - await deleteTokens({ userId: user._id }); + await Promise.all([ + deleteTokens({ userId: user._id, type: AuthTokenTypes.PASSWORD_RESET }), + deleteTokens({ userId: user._id, email: null, identifier: null, type: null }), + ]); const [resetToken, hash] = createTokenHash(); await createToken({ userId: user._id, + type: AuthTokenTypes.PASSWORD_RESET, token: hash, createdAt: Date.now(), expiresIn: 900, @@ -386,12 +522,7 @@ const requestPasswordReset = async (req) => { * @returns */ const resetPassword = async (userId, token, password) => { - let passwordResetToken = await findToken( - { - userId, - }, - { sort: { createdAt: -1 } }, - ); + const passwordResetToken = await findPasswordResetToken(userId); if (!passwordResetToken) { return new Error('Invalid or expired password reset token'); @@ -419,7 +550,7 @@ const resetPassword = async (userId, token, password) => { }); } - await deleteTokens({ token: passwordResetToken.token }); + await deleteTokens(getPasswordResetTokenDeleteQuery(passwordResetToken)); logger.info(`[resetPassword] Password reset successful. [Email: ${user.email}]`); return { message: 'Password reset was successful' }; }; @@ -724,7 +855,6 @@ const setOpenIDAuthTokens = ( const resendVerificationEmail = async (req) => { try { const { email } = req.body; - await deleteTokens({ email }); const user = await findUser({ email }, 'email _id name'); if (!user) { @@ -732,6 +862,8 @@ const resendVerificationEmail = async (req) => { return { status: 200, message: genericVerificationMessage }; } + await deleteEmailVerificationTokens(user); + const [verifyToken, hash] = createTokenHash(); const verificationLink = `${ @@ -753,6 +885,7 @@ const resendVerificationEmail = async (req) => { await createToken({ userId: user._id, email: user.email, + type: AuthTokenTypes.EMAIL_VERIFICATION, token: hash, createdAt: Date.now(), expiresIn: 900, diff --git a/api/server/services/AuthService.spec.js b/api/server/services/AuthService.spec.js index 3fce12cc200..03579e7278f 100644 --- a/api/server/services/AuthService.spec.js +++ b/api/server/services/AuthService.spec.js @@ -1,32 +1,44 @@ -jest.mock('@librechat/data-schemas', () => ({ - logger: { info: jest.fn(), warn: jest.fn(), debug: jest.fn(), error: jest.fn() }, - getTenantId: jest.fn(() => undefined), - DEFAULT_SESSION_EXPIRY: 900000, - DEFAULT_REFRESH_TOKEN_EXPIRY: 604800000, -})); -jest.mock('librechat-data-provider', () => ({ - ErrorTypes: {}, - SystemRoles: { USER: 'USER', ADMIN: 'ADMIN' }, - errorsToString: jest.fn(), -})); -jest.mock('@librechat/api', () => ({ - isEnabled: jest.fn((val) => val === 'true' || val === true), - checkEmailConfig: jest.fn(), - isEmailDomainAllowed: jest.fn(), - math: jest.fn((val, fallback) => (val ? Number(val) : fallback)), - shouldUseSecureCookie: jest.fn(() => false), - resolveAppConfigForUser: jest.fn(async (_getAppConfig, _user) => ({})), - setCloudFrontCookies: jest.fn(() => true), - getCloudFrontConfig: jest.fn(() => ({ - domain: 'https://cdn.example.com', - imageSigning: 'cookies', - cookieDomain: '.example.com', - privateKey: 'test-private-key', - keyPairId: 'K123ABC', - })), - parseCloudFrontCookieScope: jest.fn(() => null), - CLOUDFRONT_SCOPE_COOKIE: 'LibreChat-CloudFront-Scope', -})); +jest.mock( + '@librechat/data-schemas', + () => ({ + logger: { info: jest.fn(), warn: jest.fn(), debug: jest.fn(), error: jest.fn() }, + getTenantId: jest.fn(() => undefined), + DEFAULT_SESSION_EXPIRY: 900000, + DEFAULT_REFRESH_TOKEN_EXPIRY: 604800000, + }), + { virtual: true }, +); +jest.mock( + 'librechat-data-provider', + () => ({ + ErrorTypes: {}, + SystemRoles: { USER: 'USER', ADMIN: 'ADMIN' }, + errorsToString: jest.fn(), + }), + { virtual: true }, +); +jest.mock( + '@librechat/api', + () => ({ + isEnabled: jest.fn((val) => val === 'true' || val === true), + checkEmailConfig: jest.fn(), + isEmailDomainAllowed: jest.fn(), + math: jest.fn((val, fallback) => (val ? Number(val) : fallback)), + shouldUseSecureCookie: jest.fn(() => false), + resolveAppConfigForUser: jest.fn(async (_getAppConfig, _user) => ({})), + setCloudFrontCookies: jest.fn(() => true), + getCloudFrontConfig: jest.fn(() => ({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + cookieDomain: '.example.com', + privateKey: 'test-private-key', + keyPairId: 'K123ABC', + })), + parseCloudFrontCookieScope: jest.fn(() => null), + CLOUDFRONT_SCOPE_COOKIE: 'LibreChat-CloudFront-Scope', + }), + { virtual: true }, +); jest.mock('~/models', () => ({ findUser: jest.fn(), findToken: jest.fn(), @@ -73,6 +85,7 @@ const jwt = require('jsonwebtoken'); const { logger, getTenantId } = require('@librechat/data-schemas'); const { findUser, + findToken, createUser, updateUser, countUsers, @@ -80,14 +93,21 @@ const { generateToken, generateRefreshToken, createSession, + createToken, + deleteTokens, } = require('~/models'); const { getAppConfig } = require('~/server/services/Config'); +const { sendEmail } = require('~/server/utils'); +const bcrypt = require('bcryptjs'); const { setOpenIDAuthTokens, requestPasswordReset, registerUser, + resetPassword, + resendVerificationEmail, setAuthTokens, setCloudFrontAuthCookies, + verifyEmail, } = require('./AuthService'); /** Helper to build a mock Express response */ @@ -453,6 +473,108 @@ describe('registerUser', () => { }); }); +describe('verifyEmail public response handling', () => { + const email = 'user@example.com'; + const encodedEmail = encodeURIComponent(email); + const invalidEmailVerificationMessage = 'Invalid or expired email verification token'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('does not reveal that an account is already verified without a valid token', async () => { + findUser.mockResolvedValue({ _id: 'user-id', email, emailVerified: true }); + findToken.mockResolvedValue(null); + + const result = await verifyEmail({ body: { email: encodedEmail, token: 'not-the-token' } }); + + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe(invalidEmailVerificationMessage); + expect(updateUser).not.toHaveBeenCalled(); + expect(deleteTokens).not.toHaveBeenCalled(); + }); + + it('returns the same generic error for missing users and invalid tokens', async () => { + findUser.mockResolvedValueOnce(null); + + const missingUserResult = await verifyEmail({ + body: { email: encodedEmail, token: 'not-the-token' }, + }); + + findUser.mockResolvedValueOnce({ _id: 'user-id', email, emailVerified: false }); + findToken.mockResolvedValueOnce({ + userId: 'user-id', + email, + token: bcrypt.hashSync('real-token', 10), + }); + + const invalidTokenResult = await verifyEmail({ + body: { email: encodedEmail, token: 'not-the-token' }, + }); + + expect(missingUserResult).toBeInstanceOf(Error); + expect(invalidTokenResult).toBeInstanceOf(Error); + expect(missingUserResult.message).toBe(invalidEmailVerificationMessage); + expect(invalidTokenResult.message).toBe(invalidEmailVerificationMessage); + }); + + it('verifies an unverified account when the token is valid', async () => { + const hashedToken = bcrypt.hashSync('real-token', 10); + findUser.mockResolvedValue({ _id: 'user-id', email, emailVerified: false }); + findToken.mockResolvedValue({ userId: 'user-id', email, token: hashedToken }); + updateUser.mockResolvedValue({ _id: 'user-id', emailVerified: true }); + + const result = await verifyEmail({ body: { email: encodedEmail, token: 'real-token' } }); + + expect(result).toEqual({ + message: 'Email verification was successful', + status: 'success', + }); + expect(updateUser).toHaveBeenCalledWith('user-id', { emailVerified: true }); + expect(deleteTokens).toHaveBeenCalledWith({ + token: hashedToken, + userId: 'user-id', + email, + identifier: null, + type: null, + }); + }); + + it('returns the generic error when a valid verification update fails', async () => { + const hashedToken = bcrypt.hashSync('real-token', 10); + findUser.mockResolvedValue({ _id: 'user-id', email, emailVerified: false }); + findToken.mockResolvedValue({ userId: 'user-id', email, token: hashedToken }); + updateUser.mockResolvedValue(null); + + const result = await verifyEmail({ body: { email: encodedEmail, token: 'real-token' } }); + + expect(result).toBeInstanceOf(Error); + expect(result.message).toBe(invalidEmailVerificationMessage); + expect(deleteTokens).not.toHaveBeenCalled(); + }); + + it('allows idempotent success only when an already verified account presents a valid token', async () => { + const hashedToken = bcrypt.hashSync('real-token', 10); + findUser.mockResolvedValue({ _id: 'user-id', email, emailVerified: true }); + findToken.mockResolvedValue({ userId: 'user-id', email, token: hashedToken }); + + const result = await verifyEmail({ body: { email: encodedEmail, token: 'real-token' } }); + + expect(result).toEqual({ + message: 'Email verification was successful', + status: 'success', + }); + expect(updateUser).not.toHaveBeenCalled(); + expect(deleteTokens).toHaveBeenCalledWith({ + token: hashedToken, + userId: 'user-id', + email, + identifier: null, + type: null, + }); + }); +}); + describe('requestPasswordReset', () => { beforeEach(() => { jest.clearAllMocks(); @@ -516,6 +638,305 @@ describe('requestPasswordReset', () => { expect(result).not.toBeInstanceOf(Error); expect(result.message).toContain('If an account with that email exists'); }); + + it('should only delete existing password reset tokens when issuing a new reset link', async () => { + const user = { _id: 'user-reset', email: 'user@example.com' }; + findUser.mockResolvedValue(user); + + const req = { body: { email: 'user@example.com' }, ip: '127.0.0.1' }; + await requestPasswordReset(req); + + expect(deleteTokens).toHaveBeenCalledWith({ + userId: user._id, + type: 'password_reset', + }); + expect(deleteTokens).toHaveBeenCalledWith({ + userId: user._id, + email: null, + identifier: null, + type: null, + }); + expect(createToken).toHaveBeenCalledWith( + expect.objectContaining({ + userId: user._id, + type: 'password_reset', + }), + ); + }); +}); + +describe('resetPassword', () => { + beforeEach(() => { + jest.clearAllMocks(); + checkEmailConfig.mockReturnValue(false); + }); + + it('should only accept password reset tokens for password reset', async () => { + const verificationHash = bcrypt.hashSync('verification-token', 10); + findToken.mockImplementation(async (query) => { + if (query.type === 'password_reset') { + return null; + } + if (query.type === null && query.email === null && query.identifier === null) { + return null; + } + return { token: verificationHash, userId: 'user-reset', email: 'user@example.com' }; + }); + updateUser.mockResolvedValue({ email: 'user@example.com' }); + + const result = await resetPassword('user-reset', 'verification-token', 'new-password'); + + expect(result).toBeInstanceOf(Error); + expect(findToken).toHaveBeenCalledWith( + { + userId: 'user-reset', + type: 'password_reset', + }, + { sort: { createdAt: -1 } }, + ); + expect(findToken).toHaveBeenCalledWith( + { + userId: 'user-reset', + email: null, + identifier: null, + type: null, + }, + { sort: { createdAt: -1 } }, + ); + expect(updateUser).not.toHaveBeenCalled(); + expect(deleteTokens).not.toHaveBeenCalled(); + }); + + it('should delete only the used password reset token after a successful reset', async () => { + const resetHash = bcrypt.hashSync('reset-token', 10); + findToken.mockResolvedValue({ + token: resetHash, + userId: 'user-reset', + type: 'password_reset', + }); + updateUser.mockResolvedValue({ email: 'user@example.com' }); + + const result = await resetPassword('user-reset', 'reset-token', 'new-password'); + + expect(result).toEqual({ message: 'Password reset was successful' }); + expect(findToken).toHaveBeenCalledWith( + { + userId: 'user-reset', + type: 'password_reset', + }, + { sort: { createdAt: -1 } }, + ); + expect(deleteTokens).toHaveBeenCalledWith({ + token: resetHash, + type: 'password_reset', + }); + }); + + it('should accept legacy reset tokens without affecting verification-shaped tokens', async () => { + const legacyResetHash = bcrypt.hashSync('legacy-reset-token', 10); + findToken.mockImplementation(async (query) => { + if (query.type === 'password_reset') { + return null; + } + if (query.type === null && query.email === null && query.identifier === null) { + return { + token: legacyResetHash, + userId: 'user-reset', + }; + } + return null; + }); + updateUser.mockResolvedValue({ email: 'user@example.com' }); + + const result = await resetPassword('user-reset', 'legacy-reset-token', 'new-password'); + + expect(result).toEqual({ message: 'Password reset was successful' }); + expect(findToken).toHaveBeenCalledWith( + { + userId: 'user-reset', + type: 'password_reset', + }, + { sort: { createdAt: -1 } }, + ); + expect(findToken).toHaveBeenCalledWith( + { + userId: 'user-reset', + email: null, + identifier: null, + type: null, + }, + { sort: { createdAt: -1 } }, + ); + expect(deleteTokens).toHaveBeenCalledWith({ + token: legacyResetHash, + email: null, + identifier: null, + type: null, + }); + }); +}); + +describe('verifyEmail', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should scope verification token lookup to the user and token category', async () => { + const verificationHash = bcrypt.hashSync('verification-token', 10); + const user = { + _id: 'user-verify', + email: 'user@example.com', + emailVerified: false, + }; + findUser.mockResolvedValue(user); + findToken.mockImplementation(async (query) => { + if (query.type === 'email_verification') { + return { + userId: user._id, + email: user.email, + token: verificationHash, + type: 'email_verification', + }; + } + return null; + }); + updateUser.mockResolvedValue({ ...user, emailVerified: true }); + + const result = await verifyEmail({ + body: { + email: encodeURIComponent(user.email), + token: 'verification-token', + }, + }); + + expect(result).toEqual({ + message: 'Email verification was successful', + status: 'success', + }); + expect(findToken).toHaveBeenCalledWith( + { + userId: user._id, + email: user.email, + type: 'email_verification', + }, + { sort: { createdAt: -1 } }, + ); + expect(deleteTokens).toHaveBeenCalledWith({ + token: verificationHash, + type: 'email_verification', + }); + }); + + it('should fall back only to legacy verification tokens for the same user', async () => { + const verificationHash = bcrypt.hashSync('legacy-verification-token', 10); + const user = { + _id: 'user-verify', + email: 'user@example.com', + emailVerified: false, + }; + findUser.mockResolvedValue(user); + findToken.mockImplementation(async (query) => { + if (query.type === 'email_verification') { + return null; + } + if (query.type === null && query.identifier === null && query.userId === user._id) { + return { + userId: user._id, + email: user.email, + token: verificationHash, + }; + } + return null; + }); + updateUser.mockResolvedValue({ ...user, emailVerified: true }); + + const result = await verifyEmail({ + body: { + email: encodeURIComponent(user.email), + token: 'legacy-verification-token', + }, + }); + + expect(result).toEqual({ + message: 'Email verification was successful', + status: 'success', + }); + expect(findToken).toHaveBeenCalledWith( + { + userId: user._id, + email: user.email, + identifier: null, + type: null, + }, + { sort: { createdAt: -1 } }, + ); + expect(deleteTokens).toHaveBeenCalledWith({ + token: verificationHash, + userId: user._id, + email: user.email, + identifier: null, + type: null, + }); + }); +}); + +describe('resendVerificationEmail', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should not delete tokens when no user exists for the email', async () => { + findUser.mockResolvedValue(null); + + const result = await resendVerificationEmail({ + body: { email: 'missing@example.com' }, + }); + + expect(result).toEqual({ + status: 200, + message: 'Please check your email to verify your email address.', + }); + expect(deleteTokens).not.toHaveBeenCalled(); + expect(sendEmail).not.toHaveBeenCalled(); + expect(createToken).not.toHaveBeenCalled(); + }); + + it('should delete only verification tokens scoped to the resolved user', async () => { + const user = { + _id: 'user-verify', + email: 'user@example.com', + name: 'User Verify', + }; + findUser.mockResolvedValue(user); + + const result = await resendVerificationEmail({ + body: { email: user.email }, + }); + + expect(result).toEqual({ + status: 200, + message: 'Please check your email to verify your email address.', + }); + expect(deleteTokens).toHaveBeenCalledWith({ + userId: user._id, + email: user.email, + type: 'email_verification', + }); + expect(deleteTokens).toHaveBeenCalledWith({ + userId: user._id, + email: user.email, + identifier: null, + type: null, + }); + expect(deleteTokens).not.toHaveBeenCalledWith({ email: user.email }); + expect(createToken).toHaveBeenCalledWith( + expect.objectContaining({ + userId: user._id, + email: user.email, + type: 'email_verification', + }), + ); + }); }); describe('CloudFront cookie integration', () => { diff --git a/api/server/services/Config/__tests__/getCachedTools.spec.js b/api/server/services/Config/__tests__/getCachedTools.spec.js index 71ae8b5a573..3f85a018f0a 100644 --- a/api/server/services/Config/__tests__/getCachedTools.spec.js +++ b/api/server/services/Config/__tests__/getCachedTools.spec.js @@ -1,12 +1,6 @@ const { CacheKeys } = require('librechat-data-provider'); -jest.mock('@librechat/data-schemas', () => ({ - logger: { - error: jest.fn(), - }, -})); jest.mock('~/cache/getLogStores'); -const { logger } = require('@librechat/data-schemas'); const getLogStores = require('~/cache/getLogStores'); const mockCache = { get: jest.fn(), set: jest.fn(), delete: jest.fn() }; @@ -16,7 +10,6 @@ const { ToolCacheKeys, getCachedTools, setCachedTools, - getMCPServerTools, invalidateCachedTools, } = require('../getCachedTools'); @@ -74,41 +67,10 @@ describe('getCachedTools', () => { expect(mockCache.delete).toHaveBeenCalledWith(ToolCacheKeys.GLOBAL); }); - it('getMCPServerTools should use TOOL_CACHE namespace', async () => { - mockCache.get.mockResolvedValue(null); - await getMCPServerTools('user1', 'github'); - expect(getLogStores).toHaveBeenCalledWith(CacheKeys.TOOL_CACHE); - expect(mockCache.get).toHaveBeenCalledWith(ToolCacheKeys.MCP_SERVER('user1', 'github')); - }); - - it('getMCPServerTools should return null when the cache lookup fails', async () => { - const error = new Error('cache unavailable'); - mockCache.get.mockRejectedValue(error); - - await expect(getMCPServerTools('user1', 'github')).resolves.toBeNull(); - expect(logger.error).toHaveBeenCalledWith( - '[getMCPServerTools] Error fetching cached tools for github:', - error, - ); - }); - - it('getMCPServerTools should return null when the cache store is unavailable', async () => { - const error = new Error('cache store unavailable'); - getLogStores.mockImplementationOnce(() => { - throw error; - }); - - await expect(getMCPServerTools('user1', 'github')).resolves.toBeNull(); - expect(logger.error).toHaveBeenCalledWith( - '[getMCPServerTools] Error fetching cached tools for github:', - error, - ); - }); - it('should NOT use CONFIG_STORE namespace', async () => { mockCache.get.mockResolvedValue(null); await getCachedTools(); - await getMCPServerTools('user1', 'github'); + await getCachedTools({ userId: 'user1', serverName: 'github' }); mockCache.set.mockResolvedValue(true); await setCachedTools({ tool1: {} }); mockCache.delete.mockResolvedValue(true); diff --git a/api/server/services/Config/getCachedTools.js b/api/server/services/Config/getCachedTools.js index 083cfae6bad..2877234b582 100644 --- a/api/server/services/Config/getCachedTools.js +++ b/api/server/services/Config/getCachedTools.js @@ -1,5 +1,4 @@ const { CacheKeys, Time } = require('librechat-data-provider'); -const { logger } = require('@librechat/data-schemas'); const getLogStores = require('~/cache/getLogStores'); /** @@ -82,27 +81,9 @@ async function invalidateCachedTools(options = {}) { await Promise.all(keysToDelete.map((key) => cache.delete(key))); } -/** - * Gets MCP tools for a specific server from cache - * @function getMCPServerTools - * @param {string} userId - The user ID - * @param {string} serverName - The MCP server name - * @returns {Promise} The available tools for the server - */ -async function getMCPServerTools(userId, serverName) { - try { - const cache = getLogStores(CacheKeys.TOOL_CACHE); - return (await cache.get(ToolCacheKeys.MCP_SERVER(userId, serverName))) || null; - } catch (error) { - logger.error(`[getMCPServerTools] Error fetching cached tools for ${serverName}:`, error); - return null; - } -} - module.exports = { ToolCacheKeys, getCachedTools, setCachedTools, - getMCPServerTools, invalidateCachedTools, }; diff --git a/api/server/services/Config/loadDefaultModels.js b/api/server/services/Config/loadDefaultModels.js index cc8da0bbc05..f7ea0daf719 100644 --- a/api/server/services/Config/loadDefaultModels.js +++ b/api/server/services/Config/loadDefaultModels.js @@ -1,6 +1,7 @@ const { logger } = require('@librechat/data-schemas'); const { EModelEndpoint } = require('librechat-data-provider'); const { + mergeHeaders, getAnthropicModels, getBedrockModels, getOpenAIModels, @@ -25,18 +26,35 @@ async function loadDefaultModels(req) { })); const vertexConfig = appConfig?.endpoints?.[EModelEndpoint.anthropic]?.vertexConfig; + /** Forward configured custom headers (endpoint over global `all`) so model + * fetches reach a gateway-fronted provider the same as chat requests. */ + const allHeaders = appConfig?.endpoints?.all?.headers; + const openAIHeaders = mergeHeaders( + allHeaders, + appConfig?.endpoints?.[EModelEndpoint.openAI]?.headers, + ); + const anthropicHeaders = mergeHeaders( + allHeaders, + appConfig?.endpoints?.[EModelEndpoint.anthropic]?.headers, + ); + const [openAI, anthropic, azureOpenAI, assistants, azureAssistants, google, bedrock] = await Promise.all([ - getOpenAIModels({ user: req.user.id }).catch((error) => { - logger.error('Error fetching OpenAI models:', error); - return []; - }), - getAnthropicModels({ user: req.user.id, vertexModels: vertexConfig?.modelNames }).catch( + getOpenAIModels({ user: req.user.id, headers: openAIHeaders, userObject: req.user }).catch( (error) => { - logger.error('Error fetching Anthropic models:', error); + logger.error('Error fetching OpenAI models:', error); return []; }, ), + getAnthropicModels({ + user: req.user.id, + vertexModels: vertexConfig?.modelNames, + headers: anthropicHeaders, + userObject: req.user, + }).catch((error) => { + logger.error('Error fetching Anthropic models:', error); + return []; + }), getOpenAIModels({ user: req.user.id, azure: true }).catch((error) => { logger.error('Error fetching Azure OpenAI models:', error); return []; diff --git a/api/server/services/Config/mcp.js b/api/server/services/Config/mcp.js index fa37e223f52..2bd64cc31b8 100644 --- a/api/server/services/Config/mcp.js +++ b/api/server/services/Config/mcp.js @@ -1,13 +1,17 @@ -const { createMCPToolCacheService } = require('@librechat/api'); +const { createMCPToolCacheService, MCPServersRegistry } = require('@librechat/api'); const { getCachedTools, setCachedTools } = require('./getCachedTools'); -const { mergeAppTools, cacheMCPServerTools, updateMCPServerTools } = createMCPToolCacheService({ - getCachedTools, - setCachedTools, -}); +const { mergeAppTools, cacheMCPServerTools, updateMCPServerTools, getMCPServerTools } = + createMCPToolCacheService({ + getCachedTools, + setCachedTools, + getServerConfig: (serverName, userId) => + MCPServersRegistry.getInstance().getServerConfig(serverName, userId), + }); module.exports = { mergeAppTools, + getMCPServerTools, cacheMCPServerTools, updateMCPServerTools, }; diff --git a/api/server/services/Endpoints/agents/addedConvo.js b/api/server/services/Endpoints/agents/addedConvo.js index 2a2cd9ca30b..847c6c01af6 100644 --- a/api/server/services/Endpoints/agents/addedConvo.js +++ b/api/server/services/Endpoints/agents/addedConvo.js @@ -3,10 +3,14 @@ const { ADDED_AGENT_ID, initializeAgent, validateAgentModel, + resolveAgentScopedSkillIds, + resolveModelSpecSkillIds, loadAddedAgent: loadAddedAgentFn, } = require('@librechat/api'); +const { isEphemeralAgentId } = require('librechat-data-provider'); const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions'); const { getMCPServerTools } = require('~/server/services/Config'); +const { canAuthorSkillFiles } = require('./skillDeps'); const db = require('~/models'); const loadAddedAgent = (params) => @@ -40,6 +44,13 @@ const loadAddedAgent = (params) => * @param {Map} params.agentConfigs - Map of agent configs to add to * @param {string} params.primaryAgentId - The primary agent ID * @param {Object|undefined} params.userMCPAuthMap - User MCP auth map to merge into + * @param {Array} [params.accessibleSkillIds] - Full VIEW-accessible skill IDs for the user + * @param {Array} [params.editableSkillIds] - Full EDIT-accessible skill IDs for the user + * @param {boolean} [params.skillsCapabilityEnabled] - Whether endpoint Skills are enabled + * @param {boolean} [params.ephemeralSkillsToggle] - Per-request ephemeral Skills badge state + * @param {boolean} [params.skillCreateAllowed] - Whether the user can create Skills + * @param {Record} [params.skillStates] - Per-user Skill active overrides + * @param {boolean} [params.defaultActiveOnShare] - Default active state for shared Skills * @param {boolean} [params.codeEnvAvailable] - `execute_code` capability flag; * forwarded verbatim to the added agent's `initializeAgent`. @see * InitializeAgentParams.codeEnvAvailable for full semantics. @@ -60,6 +71,13 @@ const processAddedConvo = async ({ primaryAgentId, primaryAgent, userMCPAuthMap, + accessibleSkillIds = [], + editableSkillIds = [], + skillsCapabilityEnabled = false, + ephemeralSkillsToggle = false, + skillCreateAllowed = false, + skillStates, + defaultActiveOnShare, codeEnvAvailable, }) => { const addedConvo = endpointOption.addedConvo; @@ -94,6 +112,47 @@ const processAddedConvo = async ({ return { userMCPAuthMap }; } + const selectedModelSpec = + addedConvo.spec && Array.isArray(req.config?.modelSpecs?.list) + ? req.config.modelSpecs.list.find((modelSpec) => modelSpec.name === addedConvo.spec) + : null; + + if ( + addedAgent && + isEphemeralAgentId(addedAgent.id) && + selectedModelSpec && + Object.hasOwn(selectedModelSpec, 'skills') + ) { + if (selectedModelSpec.skills === true) { + addedAgent.skills_enabled = true; + delete addedAgent.skills; + } else if (selectedModelSpec.skills === false) { + addedAgent.skills_enabled = false; + addedAgent.skills = []; + } else if (Array.isArray(selectedModelSpec.skills)) { + const resolvedSkillIds = await resolveModelSpecSkillIds({ + names: selectedModelSpec.skills, + accessibleSkillIds, + getSkillByName: db.getSkillByName, + }); + addedAgent.skills_enabled = true; + addedAgent.skills = resolvedSkillIds.map((id) => id.toString()); + } + } + + const scopedSkillIds = resolveAgentScopedSkillIds({ + agent: addedAgent, + accessibleSkillIds, + skillsCapabilityEnabled, + ephemeralSkillsToggle, + }); + const scopedEditableSkillIds = resolveAgentScopedSkillIds({ + agent: addedAgent, + accessibleSkillIds: editableSkillIds, + skillsCapabilityEnabled, + ephemeralSkillsToggle, + }); + const addedConfig = await initializeAgent( { req, @@ -105,7 +164,17 @@ const processAddedConvo = async ({ agent: addedAgent, endpointOption, allowedProviders, + accessibleSkillIds: scopedSkillIds, + skillAuthoringAvailable: canAuthorSkillFiles({ + agent: addedAgent, + scopedEditableSkillIds, + skillCreateAllowed, + skillsCapabilityEnabled, + ephemeralSkillsToggle, + }), codeEnvAvailable, + skillStates, + defaultActiveOnShare, }, { getFiles: db.getFiles, @@ -118,6 +187,9 @@ const processAddedConvo = async ({ getToolFilesByIds: db.getToolFilesByIds, getCodeGeneratedFiles: db.getCodeGeneratedFiles, filterFilesByAgentAccess, + listSkillsByAccess: db.listSkillsByAccess, + listAlwaysApplySkills: db.listAlwaysApplySkills, + getSkillByName: db.getSkillByName, }, ); diff --git a/api/server/services/Endpoints/agents/addedConvo.spec.js b/api/server/services/Endpoints/agents/addedConvo.spec.js index b5c9427690e..cca3372bbd2 100644 --- a/api/server/services/Endpoints/agents/addedConvo.spec.js +++ b/api/server/services/Endpoints/agents/addedConvo.spec.js @@ -1,6 +1,9 @@ const mockInitializeAgent = jest.fn(); const mockValidateAgentModel = jest.fn(); const mockLoadAddedAgent = jest.fn(); +const mockResolveAgentScopedSkillIds = jest.fn(); +const mockResolveModelSpecSkillIds = jest.fn(); +const mockCanAuthorSkillFiles = jest.fn(); const mockGetAgent = jest.fn(); const mockGetMCPServerTools = jest.fn(); @@ -18,6 +21,8 @@ jest.mock('@librechat/api', () => ({ initializeAgent: (...args) => mockInitializeAgent(...args), validateAgentModel: (...args) => mockValidateAgentModel(...args), loadAddedAgent: (params) => mockLoadAddedAgent(params), + resolveAgentScopedSkillIds: (...args) => mockResolveAgentScopedSkillIds(...args), + resolveModelSpecSkillIds: (...args) => mockResolveModelSpecSkillIds(...args), })); jest.mock('~/server/services/Files/permissions', () => ({ @@ -28,11 +33,20 @@ jest.mock('~/server/services/Config', () => ({ getMCPServerTools: (...args) => mockGetMCPServerTools(...args), })); +jest.mock('./skillDeps', () => ({ + canAuthorSkillFiles: (...args) => mockCanAuthorSkillFiles(...args), +})); + jest.mock('~/models', () => ({ getAgent: (...args) => mockGetAgent(...args), + getSkillByName: jest.fn(), + listSkillsByAccess: jest.fn(), + listAlwaysApplySkills: jest.fn(), })); const { processAddedConvo } = require('./addedConvo'); +const db = require('~/models'); +const { Constants } = require('librechat-data-provider'); const makeReq = () => ({ user: { id: 'u1', role: 'USER' } }); @@ -44,7 +58,7 @@ const makeReq = () => ({ user: { id: 'u1', role: 'USER' } }); * `CodeExecutionToolDefinition` landed in their `toolDefinitions` via the * registry regardless of any explicit flag. */ -describe('processAddedConvo — codeEnvAvailable passthrough', () => { +describe('processAddedConvo', () => { beforeEach(() => { jest.clearAllMocks(); mockValidateAgentModel.mockResolvedValue({ isValid: true }); @@ -53,6 +67,11 @@ describe('processAddedConvo — codeEnvAvailable passthrough', () => { userMCPAuthMap: undefined, }); mockLoadAddedAgent.mockResolvedValue({ id: 'added-agent', provider: 'openai' }); + mockResolveAgentScopedSkillIds.mockImplementation( + ({ accessibleSkillIds }) => accessibleSkillIds, + ); + mockResolveModelSpecSkillIds.mockResolvedValue([]); + mockCanAuthorSkillFiles.mockReturnValue(false); }); const baseParams = (overrides = {}) => ({ @@ -105,4 +124,108 @@ describe('processAddedConvo — codeEnvAvailable passthrough', () => { expect.anything(), ); }); + + it('resolves and forwards model-spec skill scope for added ephemeral agents', async () => { + const accessibleSkillId = { toString: () => 'accessible-skill' }; + const editableSkillId = { toString: () => 'editable-skill' }; + const resolvedSkillId = { toString: () => 'resolved-skill' }; + const scopedSkillId = { toString: () => 'scoped-skill' }; + const scopedEditableSkillId = { toString: () => 'scoped-editable-skill' }; + const skillStates = { 'scoped-skill': true }; + + mockLoadAddedAgent.mockResolvedValue({ + id: Constants.EPHEMERAL_AGENT_ID, + provider: 'openai', + skills_enabled: true, + skills: [], + }); + mockResolveModelSpecSkillIds.mockResolvedValue([resolvedSkillId]); + mockResolveAgentScopedSkillIds + .mockReturnValueOnce([scopedSkillId]) + .mockReturnValueOnce([scopedEditableSkillId]); + mockCanAuthorSkillFiles.mockReturnValue(true); + + await processAddedConvo( + baseParams({ + req: { + user: { id: 'u1', role: 'USER' }, + config: { + modelSpecs: { + list: [ + { + name: 'added-spec', + skills: ['finance-analyst'], + }, + ], + }, + }, + }, + endpointOption: { + spec: 'primary-spec', + addedConvo: { + endpoint: 'openai', + model: 'gpt-4o', + spec: 'added-spec', + }, + }, + accessibleSkillIds: [accessibleSkillId], + editableSkillIds: [editableSkillId], + skillsCapabilityEnabled: true, + ephemeralSkillsToggle: false, + skillCreateAllowed: true, + skillStates, + defaultActiveOnShare: true, + }), + ); + + expect(mockResolveModelSpecSkillIds).toHaveBeenCalledWith({ + names: ['finance-analyst'], + accessibleSkillIds: [accessibleSkillId], + getSkillByName: db.getSkillByName, + }); + expect(mockResolveAgentScopedSkillIds).toHaveBeenNthCalledWith(1, { + agent: expect.objectContaining({ + id: Constants.EPHEMERAL_AGENT_ID, + skills_enabled: true, + skills: ['resolved-skill'], + }), + accessibleSkillIds: [accessibleSkillId], + skillsCapabilityEnabled: true, + ephemeralSkillsToggle: false, + }); + expect(mockResolveAgentScopedSkillIds).toHaveBeenNthCalledWith(2, { + agent: expect.objectContaining({ + id: Constants.EPHEMERAL_AGENT_ID, + skills_enabled: true, + skills: ['resolved-skill'], + }), + accessibleSkillIds: [editableSkillId], + skillsCapabilityEnabled: true, + ephemeralSkillsToggle: false, + }); + expect(mockCanAuthorSkillFiles).toHaveBeenCalledWith({ + agent: expect.objectContaining({ + id: Constants.EPHEMERAL_AGENT_ID, + skills_enabled: true, + skills: ['resolved-skill'], + }), + scopedEditableSkillIds: [scopedEditableSkillId], + skillCreateAllowed: true, + skillsCapabilityEnabled: true, + ephemeralSkillsToggle: false, + }); + expect(mockInitializeAgent).toHaveBeenCalledWith( + expect.objectContaining({ + accessibleSkillIds: [scopedSkillId], + skillAuthoringAvailable: true, + skillStates, + defaultActiveOnShare: true, + }), + expect.objectContaining({ + listSkillsByAccess: db.listSkillsByAccess, + listAlwaysApplySkills: db.listAlwaysApplySkills, + getSkillByName: db.getSkillByName, + }), + ); + }); }); diff --git a/api/server/services/Endpoints/agents/build.js b/api/server/services/Endpoints/agents/build.js index 19ae3ab7e83..efd7130091b 100644 --- a/api/server/services/Endpoints/agents/build.js +++ b/api/server/services/Endpoints/agents/build.js @@ -7,7 +7,7 @@ const db = require('~/models'); const loadAgent = (params) => loadAgentFn(params, { getAgent: db.getAgent, getMCPServerTools }); const buildOptions = (req, endpoint, parsedBody, endpointType) => { - const { spec, iconURL, agent_id, ...model_parameters } = parsedBody; + const { spec, iconURL, agent_id, chatProjectId, ...model_parameters } = parsedBody; const agentPromise = loadAgent({ req, spec, @@ -28,6 +28,7 @@ const buildOptions = (req, endpoint, parsedBody, endpointType) => { endpoint, agent_id, endpointType, + chatProjectId, model_parameters, agent: agentPromise, addedConvo, diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index bbbcd535ce9..9caee651c41 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -9,7 +9,9 @@ const { GenerationJobManager, getCustomEndpointConfig, discoverConnectedAgents, + resolveAgentTokenConfig, resolveAgentScopedSkillIds, + resolveModelSpecSkillIds, buildAgentContextAttachmentsByAgentId, } = require('@librechat/api'); const { @@ -31,8 +33,11 @@ const { loadAgentTools, loadToolsForExecution } = require('~/server/services/Too const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions'); const { getSkillToolDeps, - enrichWithSkillConfigurable, - buildSkillPrimedIdsByName, + getSkillDbMethods, + canAuthorSkillFiles, + withDeploymentSkillIds, + buildAgentToolContext, + enrichLoadedToolsWithAgentContext, } = require('./skillDeps'); const { getModelsConfig } = require('~/server/controllers/ModelController'); const { checkPermission, findAccessibleResources } = require('~/server/services/PermissionService'); @@ -132,7 +137,8 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { /** Query accessible skill IDs once per run (shared across all agents). * Skills activate under strict opt-in semantics — see * `resolveAgentScopedSkillIds` for the per-agent activation predicate: - * - Ephemeral agent → per-conversation skills badge toggle (full catalog). + * - Ephemeral agent → model-spec `skills` config first, otherwise the + * per-conversation skills badge toggle (full catalog). * - Persisted agent → `agent.skills_enabled === true`. Optional * `agent.skills` allowlist narrows the catalog; empty/undefined * allowlist with the toggle on = full accessible catalog. */ @@ -140,15 +146,29 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills); const codeEnvAvailable = enabledCapabilities.has(AgentCapabilities.execute_code); const ephemeralSkillsToggle = req.body?.ephemeralAgent?.skills === true; + const skillDbMethods = getSkillDbMethods(); const accessibleSkillIds = skillsCapabilityEnabled + ? withDeploymentSkillIds( + await findAccessibleResources({ + userId: req.user.id, + role: req.user.role, + resourceType: ResourceType.SKILL, + requiredPermissions: PermissionBits.VIEW, + }), + ) + : []; + const editableSkillIds = skillsCapabilityEnabled ? await findAccessibleResources({ userId: req.user.id, role: req.user.role, resourceType: ResourceType.SKILL, - requiredPermissions: PermissionBits.VIEW, + requiredPermissions: PermissionBits.EDIT, }) : []; + const skillCreateAllowed = skillsCapabilityEnabled + ? await getSkillToolDeps().canCreateSkill({ req }) + : false; const { skillStates, defaultActiveOnShare } = await loadSkillStates({ userId: req.user.id, @@ -165,6 +185,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { * agent?: object, * tool_resources?: object, * toolRegistry?: import('@librechat/agents').LCToolRegistry, + * requestScopedConnections?: import('@librechat/api').RequestScopedMCPConnectionStore, * openAIApiKey?: string * }>} */ @@ -184,6 +205,8 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { toolNames, agent: ctx.agent, toolRegistry: ctx.toolRegistry, + mcpAvailableTools: ctx.mcpAvailableTools, + requestScopedConnections: ctx.requestScopedConnections, userMCPAuthMap: ctx.userMCPAuthMap, tool_resources: ctx.tool_resources, actionsEnabled: ctx.actionsEnabled, @@ -195,14 +218,11 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { * the agent initialized. Falls back to `false` on any stray * ctx miss so a skills-only agent never gains sandbox access * even if capability lookup somehow skips. */ - return enrichWithSkillConfigurable( + return enrichLoadedToolsWithAgentContext({ result, req, - ctx.accessibleSkillIds, - ctx.codeEnvAvailable === true, - ctx.skillPrimedIdsByName, - ctx.activeSkillNames, - ); + ctx, + }); }, toolEndCallback, ...getSkillToolDeps(), @@ -223,6 +243,24 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { */ const subagentAggregatorsByToolCallId = new Map(); + /** Backend prices each model call authoritatively (premium tiers, cache + * rates) and emits the cost on on_token_usage when contextCost is on, so + * the gauge sums real costs instead of re-deriving from base rates. + * `endpointTokenConfig` is filled in once `primaryConfig` resolves below so + * custom-endpoint agents price with their configured rates, not defaults. */ + const usageCost = { + enabled: appConfig?.interfaceConfig?.contextCost === true, + pricing: { getMultiplier: db.getMultiplier, getCacheMultiplier: db.getCacheMultiplier }, + }; + + /** Latest visible context snapshot + every emitted usage payload for this + * response, captured by the handlers and persisted on the response message's + * metadata so the breakdown and branch/total cost survive a reload. + * @type {{ latest: import('librechat-data-provider').TContextUsageEvent | null, count: number }} */ + const contextUsageSink = { latest: null, count: 0 }; + /** @type {Array} */ + const usageEmitSink = []; + const eventHandlers = getDefaultHandlers({ res, toolExecuteOptions, @@ -233,6 +271,9 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { collectedThoughtSignatures, streamId, subagentAggregatorsByToolCallId, + usageCost, + contextUsageSink, + usageEmitSink, }); if (!endpointOption.agent) { @@ -279,12 +320,53 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { */ const manualSkills = extractManualSkills(req.body); + const selectedModelSpec = + endpointOption.spec && Array.isArray(appConfig?.modelSpecs?.list) + ? appConfig.modelSpecs.list.find((modelSpec) => modelSpec.name === endpointOption.spec) + : null; + + if ( + primaryAgent && + isEphemeralAgentId(primaryAgent.id) && + selectedModelSpec && + Object.hasOwn(selectedModelSpec, 'skills') + ) { + if (selectedModelSpec.skills === true) { + primaryAgent.skills_enabled = true; + delete primaryAgent.skills; + } else if (selectedModelSpec.skills === false) { + primaryAgent.skills_enabled = false; + primaryAgent.skills = []; + } else if (Array.isArray(selectedModelSpec.skills)) { + const resolvedSkillIds = await resolveModelSpecSkillIds({ + names: selectedModelSpec.skills, + accessibleSkillIds, + getSkillByName: db.getSkillByName, + }); + primaryAgent.skills_enabled = true; + primaryAgent.skills = resolvedSkillIds.map((id) => id.toString()); + } + } + const primaryScopedSkillIds = resolveAgentScopedSkillIds({ agent: primaryAgent, accessibleSkillIds, skillsCapabilityEnabled, ephemeralSkillsToggle, }); + const primaryScopedEditableSkillIds = resolveAgentScopedSkillIds({ + agent: primaryAgent, + accessibleSkillIds: editableSkillIds, + skillsCapabilityEnabled, + ephemeralSkillsToggle, + }); + const primarySkillAuthoringAvailable = canAuthorSkillFiles({ + agent: primaryAgent, + scopedEditableSkillIds: primaryScopedEditableSkillIds, + skillCreateAllowed, + skillsCapabilityEnabled, + ephemeralSkillsToggle, + }); const primaryConfig = await initializeAgent( { @@ -299,6 +381,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { allowedProviders, isInitialAgent: true, accessibleSkillIds: primaryScopedSkillIds, + skillAuthoringAvailable: primarySkillAuthoringAvailable, codeEnvAvailable, skillStates, defaultActiveOnShare, @@ -315,36 +398,24 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { getToolFilesByIds: db.getToolFilesByIds, getCodeGeneratedFiles: db.getCodeGeneratedFiles, filterFilesByAgentAccess, - listSkillsByAccess: db.listSkillsByAccess, - listAlwaysApplySkills: db.listAlwaysApplySkills, - getSkillByName: db.getSkillByName, + listSkillsByAccess: skillDbMethods.listSkillsByAccess, + listAlwaysApplySkills: skillDbMethods.listAlwaysApplySkills, + getSkillByName: skillDbMethods.getSkillByName, }, ); + /** Price emitted usage with the primary agent's resolved endpoint config so + * custom-endpoint agents reflect configured rates (mirrors the AgentClient + * spending path, which reads the same config). */ + usageCost.endpointTokenConfig = primaryConfig.endpointTokenConfig; + logger.debug( `[initializeClient] Storing tool context for ${primaryConfig.id}: ${primaryConfig.toolDefinitions?.length ?? 0} tools, registry size: ${primaryConfig.toolRegistry?.size ?? '0'}`, ); - /** Maps each primed skill name (manual `$` or always-apply) to the - * `_id` of the exact doc that was primed. Plumbed to - * `enrichWithSkillConfigurable` so the read_file handler can pin - * same-name collision lookups to the resolver's chosen doc AND relax - * the disable-model-invocation gate for skills whose body is already - * in this turn's context. */ - const skillPrimedIdsByName = buildSkillPrimedIdsByName( - primaryConfig.manualSkillPrimes, - primaryConfig.alwaysApplySkillPrimes, + agentToolContexts.set( + primaryConfig.id, + buildAgentToolContext({ agent: primaryAgent, config: primaryConfig }), ); - agentToolContexts.set(primaryConfig.id, { - agent: primaryAgent, - toolRegistry: primaryConfig.toolRegistry, - userMCPAuthMap: primaryConfig.userMCPAuthMap, - tool_resources: primaryConfig.tool_resources, - actionsEnabled: primaryConfig.actionsEnabled, - accessibleSkillIds: primaryConfig.accessibleSkillIds, - activeSkillNames: primaryConfig.activeSkillNames, - codeEnvAvailable: primaryConfig.codeEnvAvailable, - skillPrimedIdsByName, - }); const { agentConfigs: discoveredConfigs, @@ -371,6 +442,19 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { skillsCapabilityEnabled, ephemeralSkillsToggle, }), + computeSkillAuthoringAvailable: (agent) => + canAuthorSkillFiles({ + agent, + scopedEditableSkillIds: resolveAgentScopedSkillIds({ + agent, + accessibleSkillIds: editableSkillIds, + skillsCapabilityEnabled, + ephemeralSkillsToggle, + }), + skillCreateAllowed, + skillsCapabilityEnabled, + ephemeralSkillsToggle, + }), skillStates, defaultActiveOnShare, codeEnvAvailable, @@ -390,9 +474,9 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { getToolFilesByIds: db.getToolFilesByIds, getCodeGeneratedFiles: db.getCodeGeneratedFiles, filterFilesByAgentAccess, - listSkillsByAccess: db.listSkillsByAccess, - listAlwaysApplySkills: db.listAlwaysApplySkills, - getSkillByName: db.getSkillByName, + listSkillsByAccess: skillDbMethods.listSkillsByAccess, + listAlwaysApplySkills: skillDbMethods.listAlwaysApplySkills, + getSkillByName: skillDbMethods.getSkillByName, }, // The callback fires during BFS, before the helper prunes agents // whose edges end up filtered. Don't populate `agentConfigs` here — @@ -400,28 +484,8 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { // set. The per-agent tool context map is OK to keep populated even // for pruned ids: it's only read by closure in ON_TOOL_EXECUTE, // stale entries are unreachable at runtime. - // - // Handoff agents get the same `skillPrimedIdsByName` plumbing as the - // primary so `read_file` can pin same-name collisions to the exact - // primed doc AND relax the `disable-model-invocation: true` gate for - // skills whose body is already in this turn's context — matters for - // handoff agents that have their own always-apply skills bound or - // that the user `$`-invokes within the handoff flow. onAgentInitialized: (agentId, agent, config) => { - agentToolContexts.set(agentId, { - agent, - toolRegistry: config.toolRegistry, - userMCPAuthMap: config.userMCPAuthMap, - tool_resources: config.tool_resources, - actionsEnabled: config.actionsEnabled, - accessibleSkillIds: config.accessibleSkillIds, - activeSkillNames: config.activeSkillNames, - codeEnvAvailable: config.codeEnvAvailable, - skillPrimedIdsByName: buildSkillPrimedIdsByName( - config.manualSkillPrimes, - config.alwaysApplySkillPrimes, - ), - }); + agentToolContexts.set(agentId, buildAgentToolContext({ agent, config })); }, // Pass through the `@librechat/api` exports so that tests which // `jest.mock('@librechat/api')` can override the initializer/validator. @@ -457,6 +521,13 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { parentMessageId, allowedProviders, primaryAgentId: primaryConfig.id, + accessibleSkillIds, + editableSkillIds, + skillsCapabilityEnabled, + ephemeralSkillsToggle, + skillCreateAllowed, + skillStates, + defaultActiveOnShare, codeEnvAvailable, }); @@ -468,16 +539,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { if (agentToolContexts.has(agentId)) { continue; } - agentToolContexts.set(agentId, { - agent: config, - toolRegistry: config.toolRegistry, - userMCPAuthMap: config.userMCPAuthMap, - tool_resources: config.tool_resources, - actionsEnabled: config.actionsEnabled, - accessibleSkillIds: config.accessibleSkillIds, - activeSkillNames: config.activeSkillNames, - codeEnvAvailable: config.codeEnvAvailable, - }); + agentToolContexts.set(agentId, buildAgentToolContext({ agent: config, config })); } // `discoverConnectedAgents` always returns a concrete array, so no @@ -565,6 +627,18 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { skippedAgentIds.add(agentId); return null; } + const scopedSkillIds = resolveAgentScopedSkillIds({ + agent, + accessibleSkillIds, + skillsCapabilityEnabled, + ephemeralSkillsToggle, + }); + const scopedEditableSkillIds = resolveAgentScopedSkillIds({ + agent, + accessibleSkillIds: editableSkillIds, + skillsCapabilityEnabled, + ephemeralSkillsToggle, + }); const config = await initializeAgent( { req, @@ -576,9 +650,11 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { parentMessageId, endpointOption: { ...endpointOption, endpoint: EModelEndpoint.agents }, allowedProviders, - accessibleSkillIds: resolveAgentScopedSkillIds({ + accessibleSkillIds: scopedSkillIds, + skillAuthoringAvailable: canAuthorSkillFiles({ agent, - accessibleSkillIds, + scopedEditableSkillIds, + skillCreateAllowed, skillsCapabilityEnabled, ephemeralSkillsToggle, }), @@ -605,26 +681,13 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { getToolFilesByIds: db.getToolFilesByIds, getCodeGeneratedFiles: db.getCodeGeneratedFiles, filterFilesByAgentAccess, - listSkillsByAccess: db.listSkillsByAccess, - listAlwaysApplySkills: db.listAlwaysApplySkills, - getSkillByName: db.getSkillByName, + listSkillsByAccess: skillDbMethods.listSkillsByAccess, + listAlwaysApplySkills: skillDbMethods.listAlwaysApplySkills, + getSkillByName: skillDbMethods.getSkillByName, }, ); agentConfigs.set(agentId, config); - agentToolContexts.set(agentId, { - agent, - toolRegistry: config.toolRegistry, - userMCPAuthMap: config.userMCPAuthMap, - tool_resources: config.tool_resources, - actionsEnabled: config.actionsEnabled, - accessibleSkillIds: config.accessibleSkillIds, - activeSkillNames: config.activeSkillNames, - codeEnvAvailable: config.codeEnvAvailable, - skillPrimedIdsByName: buildSkillPrimedIdsByName( - config.manualSkillPrimes, - config.alwaysApplySkillPrimes, - ), - }); + agentToolContexts.set(agentId, buildAgentToolContext({ agent, config })); return config; } catch (err) { logger.error(`[processAgent] Error processing subagent ${agentId}:`, err); @@ -842,6 +905,28 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { }) : undefined; + /** Per-agent resolved endpoint token config, keyed by agent id. Built from + * `agentToolContexts` (the one map holding every agent, including pure + * subagents pruned from `agentConfigs`) so usage billed/emitted for a + * connected or subagent on a different custom endpoint is priced with THAT + * agent's configured rates instead of the primary's. Every known agent is + * recorded — even with an `undefined` config — so the resolver can tell a + * known non-custom agent (built-in pricing) from an untagged/unknown one + * (primary fallback). + * @type {Map} */ + const endpointTokenConfigByAgentId = new Map(); + for (const [agentId, ctx] of agentToolContexts) { + endpointTokenConfigByAgentId.set(agentId, ctx?.endpointTokenConfig); + } + /** Price emitted usage per producing agent too, so the streamed/persisted + * `metadata.usage.cost` matches the per-agent balance transaction. */ + usageCost.resolveEndpointTokenConfig = (usage) => + resolveAgentTokenConfig({ + agentId: usage?.agentId, + byAgentId: endpointTokenConfigByAgentId, + fallback: usageCost.endpointTokenConfig, + }); + const client = new AgentClient({ req, res, @@ -857,6 +942,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { agent: primaryConfig, spec: endpointOption.spec, iconURL: endpointOption.iconURL, + chatProjectId: endpointOption.chatProjectId, attachments: primaryConfig.requestAttachments ?? primaryConfig.attachments, agentContextAttachmentsByAgentId, endpointType: endpointOption.endpointType, @@ -864,6 +950,17 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { maxContextTokens: primaryConfig.maxContextTokens, endpoint: isEphemeralAgentId(primaryConfig.id) ? primaryConfig.endpoint : EModelEndpoint.agents, subagentAggregatorsByToolCallId, + /** Resolved endpoint token/pricing config so spending and cost reflect + * configured rates for custom-endpoint agents instead of defaults. */ + endpointTokenConfig: primaryConfig.endpointTokenConfig, + /** Per-agent override of the above for multi-endpoint graphs (connected + * agents + subagents); falls back to the primary config when an agent + * isn't present or has no configured rates. */ + endpointTokenConfigByAgentId, + /** Capture sinks the handlers fill during the run; `sendCompletion` reads + * them to persist the breakdown + usage rollup on the response message. */ + contextUsageSink, + usageEmitSink, }); if (streamId) { diff --git a/api/server/services/Endpoints/agents/initialize.spec.js b/api/server/services/Endpoints/agents/initialize.spec.js index fd1eb7c897a..1e331fc40de 100644 --- a/api/server/services/Endpoints/agents/initialize.spec.js +++ b/api/server/services/Endpoints/agents/initialize.spec.js @@ -6,6 +6,7 @@ const { PrincipalModel, MAX_SUBAGENT_DEPTH, MAX_SUBAGENT_GRAPH_NODES, + Constants, } = require('librechat-data-provider'); const { MongoMemoryServer } = require('mongodb-memory-server'); @@ -68,9 +69,10 @@ jest.mock('~/cache', () => ({ })); const { initializeClient } = require('./initialize'); +const { getSkillToolDeps } = require('./skillDeps'); const { logger } = require('@librechat/data-schemas'); const { User, AclEntry } = require('~/db/models'); -const { createAgent } = require('~/models'); +const { createAgent, createSkill } = require('~/models'); jest.spyOn(logger, 'warn').mockImplementation(() => {}); @@ -226,6 +228,92 @@ describe('initializeClient — processAgent ACL gate', () => { handoffContextAttachment, ]); }); + + it('does not enable skill authoring for VIEW-only shared skills', async () => { + const { skill } = await createSkill({ + name: 'shared-view-only', + description: 'Use for read-only sharing.', + body: '# Shared view-only skill\n', + author: new mongoose.Types.ObjectId(), + authorName: 'Skill Owner', + }); + await AclEntry.create({ + principalType: PrincipalType.USER, + principalId: testUser._id, + principalModel: PrincipalModel.USER, + resourceType: ResourceType.SKILL, + resourceId: skill._id, + permBits: PermissionBits.VIEW, + grantedBy: testUser._id, + }); + + const endpointOption = makeEndpointOption(); + endpointOption.agent = Promise.resolve({ + id: PRIMARY_ID, + name: 'Primary', + provider: 'openai', + model: 'gpt-4', + tools: [], + skills_enabled: true, + }); + mockInitializeAgent.mockResolvedValue(makePrimaryConfig([])); + const req = makeReq(); + req.config.endpoints.agents = { capabilities: ['skills'] }; + const canCreateSkillSpy = jest + .spyOn(getSkillToolDeps(), 'canCreateSkill') + .mockResolvedValue(false); + + try { + await initializeClient({ + req, + res: {}, + signal: new AbortController().signal, + endpointOption, + }); + } finally { + canCreateSkillSpy.mockRestore(); + } + + const initializeParams = mockInitializeAgent.mock.calls[0][0]; + expect(initializeParams.accessibleSkillIds.map(String)).toContain(skill._id.toString()); + expect(initializeParams.skillAuthoringAvailable).toBe(false); + }); + + it('enables skill authoring when model specs enable skills for an ephemeral agent', async () => { + const endpointOption = makeEndpointOption(); + endpointOption.spec = 'spec-skills'; + endpointOption.agent = Promise.resolve({ + id: Constants.EPHEMERAL_AGENT_ID, + name: 'Ephemeral Primary', + provider: 'openai', + model: 'gpt-4', + tools: [], + }); + mockInitializeAgent.mockResolvedValue(makePrimaryConfig([])); + const req = makeReq(); + req.config.endpoints.agents = { capabilities: ['skills'] }; + req.config.modelSpecs = { + list: [{ name: 'spec-skills', skills: true }], + }; + const canCreateSkillSpy = jest + .spyOn(getSkillToolDeps(), 'canCreateSkill') + .mockResolvedValue(true); + + try { + await initializeClient({ + req, + res: {}, + signal: new AbortController().signal, + endpointOption, + }); + } finally { + canCreateSkillSpy.mockRestore(); + } + + const initializeParams = mockInitializeAgent.mock.calls[0][0]; + expect(initializeParams.agent.skills_enabled).toBe(true); + expect(initializeParams.skillAuthoringAvailable).toBe(true); + }); }); describe('initializeClient — subagent loading', () => { @@ -451,6 +539,48 @@ describe('initializeClient — subagent loading', () => { expect(arg.actionsEnabled).toBe(true); }); + it('threads run-scoped MCP tool definitions into ON_TOOL_EXECUTE loading', async () => { + /** Regression guard for the request-scoped MCP/PTC handoff: the + * `mcpAvailableTools` discovered at run start must survive + * `buildAgentToolContext` and reach `loadToolsForExecution`, otherwise + * request-scoped servers reinitialize on every programmatic tool call + * and can trip the MCP circuit breaker under parallel calls. */ + const mcpTool = 'list_tables_mcp_ClickHouse'; + const mcpAvailableTools = { + ClickHouse: { + [mcpTool]: { + type: 'function', + function: { + name: mcpTool, + description: 'List tables', + parameters: { type: 'object', properties: {} }, + }, + }, + }, + }; + const primaryConfig = { + ...makePrimaryConfig({}), + toolRegistry: new Map([[mcpTool, { name: mcpTool }]]), + mcpAvailableTools, + }; + mockInitializeAgent.mockResolvedValue(primaryConfig); + + await initializeClient({ + req: makeSubagentReq(), + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + + expect(capturedToolExecuteOptions?.loadTools).toBeInstanceOf(Function); + await capturedToolExecuteOptions.loadTools([mcpTool], PRIMARY_ID); + + expect(mockLoadToolsForExecution).toHaveBeenCalledTimes(1); + expect(mockLoadToolsForExecution).toHaveBeenCalledWith( + expect.objectContaining({ mcpAvailableTools }), + ); + }); + it('deduplicates repeated ids in subagents.agent_ids', async () => { const subAgent = await createAgent({ id: DUPLICATE_SUBAGENT_ID, diff --git a/api/server/services/Endpoints/agents/skillDeps.js b/api/server/services/Endpoints/agents/skillDeps.js index ee2c6841da1..7154c1d52b3 100644 --- a/api/server/services/Endpoints/agents/skillDeps.js +++ b/api/server/services/Endpoints/agents/skillDeps.js @@ -1,18 +1,216 @@ +const crypto = require('crypto'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { batchUploadCodeEnvFiles } = require('~/server/services/Files/Code/crud'); const { getSessionInfo, checkIfActive, readSandboxFile, + writeSandboxFile, } = require('~/server/services/Files/Code/process'); -const { enrichWithSkillConfigurable } = require('@librechat/api'); +const { + checkAccess, + getStorageMetadata, + resolveRequestTenantId, + enrichWithSkillConfigurable, + mergeDeploymentSkillIds, + createDeploymentSkillMethods, + isDeploymentSkillFileSource, + getDeploymentSkillDownloadStream, +} = require('@librechat/api'); +const { + Permissions, + FileContext, + ResourceType, + PermissionBits, + AccessRoleIds, + PrincipalType, + PermissionTypes, + isEphemeralAgentId, +} = require('librechat-data-provider'); +const { checkPermission, grantPermission } = require('~/server/services/PermissionService'); +const { getFileStrategy } = require('~/server/utils/getFileStrategy'); const db = require('~/models'); +const deploymentSkillMethods = createDeploymentSkillMethods({ + getSkillById: db.getSkillById, + getSkillByName: db.getSkillByName, + listSkillsByAccess: db.listSkillsByAccess, + listAlwaysApplySkills: db.listAlwaysApplySkills, + listSkillFiles: db.listSkillFiles, + getSkillFileByPath: db.getSkillFileByPath, + updateSkillFileContent: db.updateSkillFileContent, + updateSkillFileCodeEnvIds: db.updateSkillFileCodeEnvIds, +}); + +function getSkillDbMethods() { + return deploymentSkillMethods; +} + +function withDeploymentSkillIds(ids = []) { + return mergeDeploymentSkillIds(ids); +} + +function getSkillStrategyFunctions(source) { + if (isDeploymentSkillFileSource(source)) { + return { + getDownloadStream: (_req, filepath) => getDeploymentSkillDownloadStream(filepath), + }; + } + return getStrategyFunctions(source); +} + +function resolveSkillStorage(req, { isImage = false } = {}) { + const source = getFileStrategy(req.config, { context: FileContext.skill_file, isImage }); + const strategy = getStrategyFunctions(source); + if (!strategy.saveBuffer) { + throw new Error(`Storage backend "${source}" does not support file writes`); + } + return { saveBuffer: strategy.saveBuffer, source }; +} + +function basename(relativePath) { + const slash = relativePath.lastIndexOf('/'); + return slash === -1 ? relativePath : relativePath.slice(slash + 1); +} + +async function saveSkillFileContent({ req, skillId, relativePath, content, mimeType }) { + const existingFile = await db.getSkillFileByPath(skillId, relativePath); + const tenantId = resolveRequestTenantId(req); + const fileId = crypto.randomUUID(); + const filename = basename(relativePath); + const storageFileName = `${fileId}__${filename}`; + const buffer = Buffer.from(content, 'utf8'); + const storage = resolveSkillStorage(req, { isImage: mimeType.startsWith('image/') }); + const filepath = await storage.saveBuffer({ + userId: req.user.id, + buffer, + fileName: storageFileName, + basePath: 'uploads', + tenantId, + }); + const storageMetadata = getStorageMetadata({ filepath, source: storage.source }); + + let result; + try { + result = await db.upsertSkillFile({ + skillId, + relativePath, + file_id: fileId, + filename, + filepath, + ...storageMetadata, + source: storage.source, + mimeType, + bytes: buffer.length, + isExecutable: false, + author: req.user._id ?? req.user.id, + tenantId, + }); + if (!result) { + const error = new Error('Skill file save failed to persist metadata'); + error.code = 'SKILL_FILE_UPSERT_NOT_FOUND'; + throw error; + } + } catch (error) { + const { deleteFile } = getStrategyFunctions(storage.source); + if (deleteFile) { + await deleteFile(req, { filepath, user: req.user.id, tenantId }).catch(() => undefined); + } + throw error; + } + + if (existingFile && existingFile.filepath !== filepath) { + const { deleteFile } = getStrategyFunctions(existingFile.source); + if (deleteFile) { + deleteFile(req, { + filepath: existingFile.filepath, + storageKey: existingFile.storageKey, + storageRegion: existingFile.storageRegion, + user: existingFile.author ?? req.user.id, + tenantId: existingFile.tenantId ?? tenantId, + }).catch(() => undefined); + } + } + + return { bytes: result.bytes, relativePath: result.relativePath }; +} + +function canCreateSkill({ req }) { + return checkAccess({ + req, + user: req.user, + permissionType: PermissionTypes.SKILLS, + permissions: [Permissions.USE, Permissions.CREATE], + getRoleByName: db.getRoleByName, + }); +} + +function canEditSkill({ req, skillId }) { + return checkPermission({ + userId: req.user.id, + role: req.user.role, + resourceType: ResourceType.SKILL, + resourceId: skillId, + requiredPermission: PermissionBits.EDIT, + }); +} + +function isAgentSkillsEnabledForRun({ agent, skillsCapabilityEnabled, ephemeralSkillsToggle }) { + if (!skillsCapabilityEnabled) { + return false; + } + if (isEphemeralAgentId(agent.id)) { + if (agent.skills_enabled === false) { + return false; + } + if (agent.skills_enabled === true) { + return true; + } + return ephemeralSkillsToggle === true; + } + return agent.skills_enabled === true; +} + +function canAuthorSkillFiles({ + agent, + scopedEditableSkillIds = [], + skillCreateAllowed, + skillsCapabilityEnabled, + ephemeralSkillsToggle, +}) { + return ( + isAgentSkillsEnabledForRun({ agent, skillsCapabilityEnabled, ephemeralSkillsToggle }) && + (scopedEditableSkillIds.length > 0 || skillCreateAllowed === true) + ); +} + +function grantSkillOwner({ req, skillId }) { + return grantPermission({ + principalType: PrincipalType.USER, + principalId: req.user.id, + resourceType: ResourceType.SKILL, + resourceId: skillId, + accessRoleId: AccessRoleIds.SKILL_OWNER, + grantedBy: req.user.id, + }); +} + +function getAuthorSkillByName({ req, name }) { + const author = req.user?._id ?? req.user?.id; + if (!author) { + return null; + } + return db.getAuthorSkillByName({ + name, + author, + tenantId: resolveRequestTenantId(req), + }); +} + /** - * Builds the `skillPrimedIdsByName` map passed through to - * `enrichWithSkillConfigurable`. Centralized here so the four CJS call - * sites (`initialize.js`, `responses.js` x2, `openai.js`) share one - * source of truth — if `ResolvedManualSkill` ever renames `_id` or + * Builds the `skillPrimedIdsByName` map threaded through + * `buildAgentToolContext`. Centralized here so every runtime route shares + * one source of truth — if `ResolvedManualSkill` ever renames `_id` or * gains new identifying fields, only this helper changes. * * Combines both manual (`$`-popover) primes AND always-apply primes so @@ -59,17 +257,97 @@ function buildSkillPrimedIdsByName(manualSkillPrimes, alwaysApplySkillPrimes) { return out; } +/** + * Builds the per-agent context consumed by ON_TOOL_EXECUTE. Keeping this + * shape in one Adapter gives every runtime path the same configurable + * fields and the same primed-skill pinning behavior. + * + * @param {object} params + * @param {object} params.agent + * @param {object} params.config + * @param {Record} [params.config.mcpAvailableTools] + * @param {import('@librechat/api').RequestScopedMCPConnectionStore} [params.config.requestScopedConnections] + * @returns {object} + */ +function buildAgentToolContext({ agent, config }) { + return { + agent, + /** Per-agent resolved endpoint token/pricing config. Retained here because + * `agentToolContexts` is the one map that holds every agent — including + * pure subagents pruned from `agentConfigs` — so usage can be priced with + * the producing agent's config in multi-endpoint graphs. */ + endpointTokenConfig: config.endpointTokenConfig, + toolRegistry: config.toolRegistry, + mcpAvailableTools: config.mcpAvailableTools, + requestScopedConnections: config.requestScopedConnections, + userMCPAuthMap: config.userMCPAuthMap, + tool_resources: config.tool_resources, + actionsEnabled: config.actionsEnabled, + accessibleSkillIds: config.accessibleSkillIds, + activeSkillNames: config.activeSkillNames, + codeEnvAvailable: config.codeEnvAvailable, + skillAuthoringAvailable: config.skillAuthoringAvailable, + fileAuthoringToolNames: config.fileAuthoringToolNames, + skillPrimedIdsByName: + buildSkillPrimedIdsByName(config.manualSkillPrimes, config.alwaysApplySkillPrimes) ?? {}, + }; +} + +function hasOwn(value, key) { + return Object.prototype.hasOwnProperty.call(value ?? {}, key); +} + +/** + * Applies per-agent runtime context to a loadToolsForExecution result. + * + * @param {object} params + * @param {{ loadedTools: unknown[], configurable?: Record }} params.result + * @param {object} params.req + * @param {object | undefined} params.ctx + * @param {object | undefined} [params.fallback] + * @returns {{ loadedTools: unknown[], configurable: Record }} + */ +function enrichLoadedToolsWithAgentContext({ result, req, ctx = {}, fallback = {} }) { + const codeEnvAvailable = hasOwn(ctx, 'codeEnvAvailable') + ? ctx.codeEnvAvailable === true + : fallback.codeEnvAvailable === true; + const skillAuthoringAvailable = hasOwn(ctx, 'skillAuthoringAvailable') + ? ctx.skillAuthoringAvailable === true + : fallback.skillAuthoringAvailable === true; + + return enrichWithSkillConfigurable({ + result, + context: { + req, + codeEnvAvailable, + accessibleSkillIds: ctx.accessibleSkillIds ?? fallback.accessibleSkillIds, + skillPrimedIdsByName: ctx.skillPrimedIdsByName ?? fallback.skillPrimedIdsByName, + activeSkillNames: ctx.activeSkillNames ?? fallback.activeSkillNames, + skillAuthoringAvailable, + fileAuthoringToolNames: ctx.fileAuthoringToolNames ?? fallback.fileAuthoringToolNames, + }, + }); +} + /** Skill-related properties for ToolExecuteOptions (stable references, allocated once). */ const skillToolDeps = { - getSkillByName: db.getSkillByName, - listSkillFiles: db.listSkillFiles, - getStrategyFunctions, + getSkillByName: deploymentSkillMethods.getSkillByName, + getAuthorSkillByName, + createSkill: db.createSkill, + updateSkill: db.updateSkill, + deleteSkill: db.deleteSkill, + canCreateSkill, + canEditSkill, + grantSkillOwner, + saveSkillFileContent, + listSkillFiles: deploymentSkillMethods.listSkillFiles, + getStrategyFunctions: getSkillStrategyFunctions, batchUploadCodeEnvFiles, getSessionInfo, checkIfActive, - updateSkillFileCodeEnvIds: db.updateSkillFileCodeEnvIds, - getSkillFileByPath: db.getSkillFileByPath, - updateSkillFileContent: db.updateSkillFileContent, + updateSkillFileCodeEnvIds: deploymentSkillMethods.updateSkillFileCodeEnvIds, + getSkillFileByPath: deploymentSkillMethods.getSkillFileByPath, + updateSkillFileContent: deploymentSkillMethods.updateSkillFileContent, /** * `read_file` falls back to a sandbox `cat` for `/mnt/data/...` paths * and for `{firstSegment}/...` paths whose first segment isn't a known @@ -79,6 +357,7 @@ const skillToolDeps = { * the agents-side `ToolNode` via `tc.codeSessionContext`. */ readSandboxFile, + writeSandboxFile, }; function getSkillToolDeps() { @@ -87,6 +366,13 @@ function getSkillToolDeps() { module.exports = { getSkillToolDeps, + canAuthorSkillFiles, + isAgentSkillsEnabledForRun, + getSkillDbMethods, + withDeploymentSkillIds, + getSkillStrategyFunctions, enrichWithSkillConfigurable, buildSkillPrimedIdsByName, + buildAgentToolContext, + enrichLoadedToolsWithAgentContext, }; diff --git a/api/server/services/Endpoints/agents/skillDeps.spec.js b/api/server/services/Endpoints/agents/skillDeps.spec.js new file mode 100644 index 00000000000..3782a4664a0 --- /dev/null +++ b/api/server/services/Endpoints/agents/skillDeps.spec.js @@ -0,0 +1,107 @@ +const mockSaveBuffer = jest.fn(); +const mockDeleteFile = jest.fn(); +const mockGetStrategyFunctions = jest.fn(); +const mockGetFileStrategy = jest.fn(); +const mockGetStorageMetadata = jest.fn(); +const mockResolveRequestTenantId = jest.fn(); +const mockCreateDeploymentSkillMethods = jest.fn((methods) => methods); + +jest.mock('~/server/services/Files/strategies', () => ({ + getStrategyFunctions: (...args) => mockGetStrategyFunctions(...args), +})); + +jest.mock('~/server/services/Files/Code/crud', () => ({ + batchUploadCodeEnvFiles: jest.fn(), +})); + +jest.mock('~/server/services/Files/Code/process', () => ({ + getSessionInfo: jest.fn(), + checkIfActive: jest.fn(), + readSandboxFile: jest.fn(), + writeSandboxFile: jest.fn(), +})); + +jest.mock('@librechat/api', () => ({ + checkAccess: jest.fn(), + createDeploymentSkillMethods: (...args) => mockCreateDeploymentSkillMethods(...args), + enrichWithSkillConfigurable: jest.fn(), + getDeploymentSkillDownloadStream: jest.fn(), + getStorageMetadata: (...args) => mockGetStorageMetadata(...args), + isDeploymentSkillFileSource: jest.fn(() => false), + mergeDeploymentSkillIds: jest.fn((ids = []) => ids), + resolveRequestTenantId: (...args) => mockResolveRequestTenantId(...args), +})); + +jest.mock('librechat-data-provider', () => ({ + AccessRoleIds: { SKILL_OWNER: 'SKILL_OWNER' }, + FileContext: { skill_file: 'skill_file' }, + PermissionBits: { EDIT: 2 }, + Permissions: { USE: 'USE', CREATE: 'CREATE' }, + PermissionTypes: { SKILLS: 'SKILLS' }, + PrincipalType: { USER: 'USER' }, + ResourceType: { SKILL: 'SKILL' }, + isEphemeralAgentId: jest.fn(() => false), +})); + +jest.mock('~/server/services/PermissionService', () => ({ + checkPermission: jest.fn(), + grantPermission: jest.fn(), +})); + +jest.mock('~/server/utils/getFileStrategy', () => ({ + getFileStrategy: (...args) => mockGetFileStrategy(...args), +})); + +const mockDb = { + getSkillFileByPath: jest.fn(), + upsertSkillFile: jest.fn(), +}; + +jest.mock('~/models', () => mockDb); + +const { getSkillToolDeps } = require('./skillDeps'); + +describe('skillDeps saveSkillFileContent', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetFileStrategy.mockReturnValue('s3'); + mockGetStrategyFunctions.mockReturnValue({ + saveBuffer: mockSaveBuffer, + deleteFile: mockDeleteFile, + }); + mockSaveBuffer.mockResolvedValue('https://files.example.test/uploads/file.txt'); + mockDeleteFile.mockResolvedValue(undefined); + mockGetStorageMetadata.mockReturnValue({ + storageKey: 'uploads/file.txt', + storageRegion: 'us-east-2', + }); + mockResolveRequestTenantId.mockReturnValue('tenant-1'); + mockDb.getSkillFileByPath.mockResolvedValue(null); + }); + + it('cleans up the uploaded object when metadata upsert returns no row', async () => { + mockDb.upsertSkillFile.mockResolvedValue(null); + + await expect( + getSkillToolDeps().saveSkillFileContent({ + req: { + user: { id: 'user-1', _id: 'user-1' }, + config: {}, + }, + skillId: 'skill-1', + relativePath: 'references/template.html', + content: '', + mimeType: 'text/html', + }), + ).rejects.toMatchObject({ code: 'SKILL_FILE_UPSERT_NOT_FOUND' }); + + expect(mockDeleteFile).toHaveBeenCalledWith( + expect.objectContaining({ user: expect.objectContaining({ id: 'user-1' }) }), + { + filepath: 'https://files.example.test/uploads/file.txt', + user: 'user-1', + tenantId: 'tenant-1', + }, + ); + }); +}); diff --git a/api/server/services/Endpoints/agents/title.js b/api/server/services/Endpoints/agents/title.js index 350b00142eb..0aa955055e8 100644 --- a/api/server/services/Endpoints/agents/title.js +++ b/api/server/services/Endpoints/agents/title.js @@ -21,7 +21,13 @@ const { saveConvo } = require('~/models'); * persisted; awaited before saving the title in `immediate` mode. * @param {AbortSignal} [params.signal] - When aborted (e.g. the user stops an * immediate-mode generation), cancels the in-flight title model call so a - * cancelled turn neither consumes the title model nor surfaces a title. + * turn stopped before the title finished does not consume the title model. A + * title that already finished generating is still persisted and surfaced. + * @param {AbortSignal} [params.discardSignal] - When aborted, discards an + * already-generated title instead of persisting it. Used only when this stream + * is superseded by a newer run (or the turn failed), so a stale title does not + * clobber the conversation now owned by the newer run. A plain user Stop does + * NOT abort this — its generated title is kept. * @param {(params: { conversationId: string, title: string }) => Promise|void} [params.onTitleGenerated] * Called after the title is cached and before persistence waits for the * conversation row. Used by live streams to push the title immediately. @@ -36,6 +42,7 @@ const addTitle = async ( immediate = false, convoReady, signal, + discardSignal, onTitleGenerated, }, ) => { @@ -130,12 +137,14 @@ const addTitle = async ( await convoReady; } - if (signal?.aborted) { - // The turn was stopped, or this stream was replaced, after the title had - // already been generated — discard it instead of persisting a title for a - // cancelled/discarded response. Only clear the cache if it still holds THIS - // task's title: a replacement stream shares the `userId-conversationId` key - // and may have already cached its own (valid) title that we must not remove. + if (discardSignal?.aborted) { + // This stream was superseded by a newer run (or the turn failed) after the + // title had already been generated — discard it so a stale title does not + // clobber the conversation now owned by the newer run. A plain user Stop is + // not a discard: its generated title falls through and is persisted below. + // Only clear the cache if it still holds THIS task's title: a replacement + // stream shares the `userId-conversationId` key and may have already cached + // its own (valid) title that we must not remove. const cached = await titleCache.get(key); if (cached === title) { await titleCache.delete(key); diff --git a/api/server/services/Endpoints/agents/title.test.js b/api/server/services/Endpoints/agents/title.test.js index cf6fe250d87..41537619900 100644 --- a/api/server/services/Endpoints/agents/title.test.js +++ b/api/server/services/Endpoints/agents/title.test.js @@ -212,10 +212,9 @@ describe('agents addTitle', () => { expect(mockSaveConvo).not.toHaveBeenCalled(); }); - it('propagates an aborted request signal and discards the title without persisting', async () => { + it('propagates the abort signal to the title model call', async () => { const client = makeClient(); const ac = new AbortController(); - const onTitleGenerated = jest.fn(); ac.abort(); await addTitle(makeReq(), { @@ -225,17 +224,35 @@ describe('agents addTitle', () => { immediate: true, convoReady: Promise.resolve(), signal: ac.signal, - onTitleGenerated, }); const { abortController } = client.titleConvo.mock.calls[0][0]; expect(abortController.signal.aborted).toBe(true); + }); + + it('discards the title without persisting when the stream is superseded', async () => { + const client = makeClient(); + const ac = new AbortController(); + const onTitleGenerated = jest.fn(); + ac.abort(); + + await addTitle(makeReq(), { + text: 'hi', + client, + conversationId: 'cid', + immediate: true, + convoReady: Promise.resolve(), + signal: ac.signal, + discardSignal: ac.signal, + onTitleGenerated, + }); + expect(onTitleGenerated).not.toHaveBeenCalled(); expect(mockSaveConvo).not.toHaveBeenCalled(); expect(mockCache.delete).toHaveBeenCalledWith('user-1-cid'); }); - it("does not delete a replacement stream's cached title when aborted", async () => { + it("does not delete a replacement stream's cached title when superseded", async () => { const client = makeClient('Stale Title'); const ac = new AbortController(); ac.abort(); @@ -250,9 +267,47 @@ describe('agents addTitle', () => { immediate: true, convoReady: Promise.resolve(), signal: ac.signal, + discardSignal: ac.signal, }); expect(mockCache.delete).not.toHaveBeenCalled(); expect(mockSaveConvo).not.toHaveBeenCalled(); }); + + it('persists a title generated before a user Stop (signal aborted, not superseded)', async () => { + const client = makeClient('Kept Title'); + // `signal` represents a user Stop; no `discardSignal` since the stream is not + // superseded. The title finishes generating and is emitted before the Stop. + const ac = new AbortController(); + const onTitleGenerated = jest.fn(); + let resolveConvo; + const convoReady = new Promise((resolve) => { + resolveConvo = resolve; + }); + + const pending = addTitle(makeReq(), { + text: 'hi', + client, + conversationId: 'cid', + immediate: true, + convoReady, + signal: ac.signal, + onTitleGenerated, + }); + + await flush(); + expect(onTitleGenerated).toHaveBeenCalledWith({ conversationId: 'cid', title: 'Kept Title' }); + + // User stops mid-response, then the conversation row is persisted. + ac.abort(); + resolveConvo(); + await pending; + + expect(mockSaveConvo).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ conversationId: 'cid', title: 'Kept Title' }), + expect.objectContaining({ noUpsert: true }), + ); + expect(mockCache.delete).not.toHaveBeenCalled(); + }); }); diff --git a/api/server/services/Endpoints/assistants/initalize.js b/api/server/services/Endpoints/assistants/initalize.js index d5a246dff7b..4b31f63fddb 100644 --- a/api/server/services/Endpoints/assistants/initalize.js +++ b/api/server/services/Endpoints/assistants/initalize.js @@ -1,6 +1,5 @@ const OpenAI = require('openai'); -const { ProxyAgent } = require('undici'); -const { isUserProvided, checkUserKeyExpiry } = require('@librechat/api'); +const { isUserProvided, checkUserKeyExpiry, getProxyDispatcher } = require('@librechat/api'); const { ErrorTypes, EModelEndpoint } = require('librechat-data-provider'); const { getUserKeyValues, getUserKeyExpiry } = require('~/models'); @@ -45,10 +44,10 @@ const initializeClient = async ({ req, res, version }) => { opts.baseURL = baseURL; } - if (PROXY) { - const proxyAgent = new ProxyAgent(PROXY); + const proxyDispatcher = getProxyDispatcher(PROXY); + if (proxyDispatcher) { opts.fetchOptions = { - dispatcher: proxyAgent, + dispatcher: proxyDispatcher, }; } diff --git a/api/server/services/Endpoints/azureAssistants/initialize.js b/api/server/services/Endpoints/azureAssistants/initialize.js index e81f0bcd8ad..fde02b15892 100644 --- a/api/server/services/Endpoints/azureAssistants/initialize.js +++ b/api/server/services/Endpoints/azureAssistants/initialize.js @@ -1,10 +1,10 @@ const OpenAI = require('openai'); -const { ProxyAgent } = require('undici'); const { isUserProvided, resolveHeaders, constructAzureURL, checkUserKeyExpiry, + getProxyDispatcher, } = require('@librechat/api'); const { ErrorTypes, EModelEndpoint, mapModelToAzureConfig } = require('librechat-data-provider'); const { getUserKeyValues, getUserKeyExpiry } = require('~/models'); @@ -157,10 +157,10 @@ const initializeClient = async ({ req, res, version, endpointOption, initAppClie opts.baseURL = baseURL; } - if (PROXY) { - const proxyAgent = new ProxyAgent(PROXY); + const proxyDispatcher = getProxyDispatcher(PROXY); + if (proxyDispatcher) { opts.fetchOptions = { - dispatcher: proxyAgent, + dispatcher: proxyDispatcher, }; } diff --git a/api/server/services/Files/Audio/STTService.js b/api/server/services/Files/Audio/STTService.js index 2caea1ffe0b..af46b9cc799 100644 --- a/api/server/services/Files/Audio/STTService.js +++ b/api/server/services/Files/Audio/STTService.js @@ -3,8 +3,7 @@ const fs = require('fs').promises; const FormData = require('form-data'); const { Readable } = require('stream'); const { logger } = require('@librechat/data-schemas'); -const { HttpsProxyAgent } = require('https-proxy-agent'); -const { genAzureEndpoint, logAxiosError } = require('@librechat/api'); +const { genAzureEndpoint, logAxiosError, applyAxiosProxyConfig } = require('@librechat/api'); const { extractEnvVariable, STTProviders } = require('librechat-data-provider'); const { getAppConfig } = require('~/server/services/Config'); @@ -303,9 +302,7 @@ class STTService { const options = { headers }; - if (process.env.PROXY) { - options.httpsAgent = new HttpsProxyAgent(process.env.PROXY); - } + applyAxiosProxyConfig(options, url); try { const response = await axios.post(url, data, options); diff --git a/api/server/services/Files/Audio/TTSService.js b/api/server/services/Files/Audio/TTSService.js index 80f4239cc64..301bbe90f84 100644 --- a/api/server/services/Files/Audio/TTSService.js +++ b/api/server/services/Files/Audio/TTSService.js @@ -1,7 +1,6 @@ const axios = require('axios'); const { logger } = require('@librechat/data-schemas'); -const { HttpsProxyAgent } = require('https-proxy-agent'); -const { genAzureEndpoint, logAxiosError } = require('@librechat/api'); +const { genAzureEndpoint, logAxiosError, applyAxiosProxyConfig } = require('@librechat/api'); const { extractEnvVariable, TTSProviders } = require('librechat-data-provider'); const { getRandomVoiceId, createChunkProcessor, splitTextIntoChunks } = require('./streamAudio'); const { getAppConfig } = require('~/server/services/Config'); @@ -267,9 +266,7 @@ class TTSService { const options = { headers, responseType: stream ? 'stream' : 'arraybuffer' }; - if (process.env.PROXY) { - options.httpsAgent = new HttpsProxyAgent(process.env.PROXY); - } + applyAxiosProxyConfig(options, url); try { return await axios.post(url, data, options); diff --git a/api/server/services/Files/Code/process.js b/api/server/services/Files/Code/process.js index d9940da05b0..10ba254a19c 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -1042,11 +1042,101 @@ async function readSandboxFile({ file_path, session_id, files, req }) { } } +/** + * Writes a UTF-8 text file into the code-execution sandbox by running a + * small Python writer through the sandbox `/exec` endpoint. The payload is + * base64-encoded JSON so neither the file path nor the content is + * interpolated into shell syntax. + * + * @param {Object} params + * @param {string} params.file_path - Path inside the sandbox (prefer `/mnt/data/...`). + * @param {string} params.content - Complete UTF-8 text content to write. + * @param {string} [params.session_id] - Sandbox session id from the seeded context. + * @param {Array<{id: string, name: string, session_id?: string}>} [params.files] - File refs to mount. + * @param {ServerRequest} [params.req] - Current authenticated request, used to mint Code API auth. + * @returns {Promise<{stdout?: string, stderr?: string, session_id?: string, files?: Array} | null>} + */ +async function writeSandboxFile({ file_path, content, session_id, files, req }) { + const baseURL = getCodeBaseURL(); + if (!baseURL) { + return null; + } + + const payload = Buffer.from( + JSON.stringify({ + file_path, + content_b64: Buffer.from(content, 'utf8').toString('base64'), + }), + 'utf8', + ).toString('base64'); + const code = [ + "python3 - <<'PY'", + 'import base64, json, os', + `payload = ${JSON.stringify(payload)}`, + "data = json.loads(base64.b64decode(payload).decode('utf-8'))", + "path = data['file_path']", + "content = base64.b64decode(data['content_b64'])", + 'parent = os.path.dirname(path)', + 'if parent:', + ' os.makedirs(parent, exist_ok=True)', + "with open(path, 'wb') as f:", + ' f.write(content)', + 'print(f"WROTE {len(content)} bytes to {path}")', + 'PY', + ].join('\n'); + + /** @type {Record} */ + const postData = { lang: 'bash', code }; + if (session_id) { + postData.session_id = session_id; + } + if (files && files.length > 0) { + postData.files = files; + } + + try { + const authHeaders = await getCodeApiAuthHeaders(req); + const response = await axios({ + method: 'post', + url: `${baseURL}/exec`, + data: postData, + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'LibreChat/1.0', + ...authHeaders, + }, + httpAgent: codeServerHttpAgent, + httpsAgent: codeServerHttpsAgent, + timeout: 15000, + }); + const result = response?.data ?? {}; + if (result.stderr && (result.stdout == null || result.stdout === '')) { + throw new Error(String(result.stderr).trim()); + } + if (result.stdout == null && result.session_id == null) { + return null; + } + return { + stdout: result.stdout == null ? undefined : String(result.stdout), + stderr: result.stderr == null ? undefined : String(result.stderr), + session_id: result.session_id, + files: result.files, + }; + } catch (error) { + logAxiosError({ + message: `Error writing sandbox file "${file_path}"`, + error, + }); + throw error; + } +} + module.exports = { primeFiles, checkIfActive, getSessionInfo, processCodeOutput, readSandboxFile, + writeSandboxFile, runPreviewFinalize, }; diff --git a/api/server/services/Files/Code/process.spec.js b/api/server/services/Files/Code/process.spec.js index b1a11a44a4e..0bff06adf32 100644 --- a/api/server/services/Files/Code/process.spec.js +++ b/api/server/services/Files/Code/process.spec.js @@ -161,7 +161,13 @@ const { getStorageMetadata, } = require('@librechat/api'); -const { processCodeOutput, getSessionInfo, readSandboxFile, primeFiles } = require('./process'); +const { + processCodeOutput, + getSessionInfo, + readSandboxFile, + writeSandboxFile, + primeFiles, +} = require('./process'); describe('Code Process', () => { const mockReq = { @@ -1626,6 +1632,95 @@ describe('Code Process', () => { }); }); + describe('writeSandboxFile', () => { + function extractWritePayload() { + const code = mockAxios.mock.calls[0][0].data.code; + const match = /payload = "([^"]+)"/.exec(code); + expect(match).not.toBeNull(); + const payload = JSON.parse(Buffer.from(match[1], 'base64').toString('utf8')); + return { + file_path: payload.file_path, + content: Buffer.from(payload.content_b64, 'base64').toString('utf8'), + code, + }; + } + + it('POSTs a bash python writer to /exec and forwards session context', async () => { + mockAxios.mockResolvedValueOnce({ + data: { + stdout: 'WROTE 11 bytes to /mnt/data/new.txt\n', + stderr: '', + session_id: 'sess-new', + files: [{ id: 'file-new', name: 'new.txt', storage_session_id: 'sess-new' }], + }, + }); + const files = [{ id: 'f1', name: 'input.csv', session_id: 'sess-prev' }]; + + const result = await writeSandboxFile({ + file_path: '/mnt/data/new.txt', + content: 'hello world', + session_id: 'sess-prev', + files, + req: mockReq, + }); + + const call = mockAxios.mock.calls[0][0]; + expect(call.method).toBe('post'); + expect(call.url).toBe('https://code-api.example.com/exec'); + expect(call.data.lang).toBe('bash'); + expect(call.data.session_id).toBe('sess-prev'); + expect(call.data.files).toEqual(files); + expect(call.timeout).toBe(15000); + expect(call.httpAgent).toBe(codeServerHttpAgent); + expect(call.httpsAgent).toBe(codeServerHttpsAgent); + expect(result).toMatchObject({ + stdout: 'WROTE 11 bytes to /mnt/data/new.txt\n', + session_id: 'sess-new', + files: [{ id: 'file-new', name: 'new.txt' }], + }); + }); + + it('encodes path and content in a base64 JSON payload instead of shell-interpolating them', async () => { + mockAxios.mockResolvedValueOnce({ data: { stdout: 'ok', stderr: '', session_id: 'sess' } }); + const trickyPath = `/mnt/data/quote'$(whoami).txt`; + const trickyContent = "hello ' $(rm -rf /)\nsecond line"; + + await writeSandboxFile({ + file_path: trickyPath, + content: trickyContent, + }); + + const { file_path, content, code } = extractWritePayload(); + expect(file_path).toBe(trickyPath); + expect(content).toBe(trickyContent); + expect(code).not.toContain(trickyPath); + expect(code).not.toContain(trickyContent); + }); + + it('returns null when getCodeBaseURL is not configured', async () => { + const { getCodeBaseURL } = require('@librechat/agents'); + getCodeBaseURL.mockReturnValueOnce(''); + + const result = await writeSandboxFile({ + file_path: '/mnt/data/x.txt', + content: 'x', + }); + + expect(result).toBeNull(); + expect(mockAxios).not.toHaveBeenCalled(); + }); + + it('throws when the writer reports stderr without stdout', async () => { + mockAxios.mockResolvedValueOnce({ + data: { stdout: '', stderr: 'Permission denied\n' }, + }); + + await expect(writeSandboxFile({ file_path: '/root/nope.txt', content: 'x' })).rejects.toThrow( + 'Permission denied', + ); + }); + }); + describe('primeFiles reupload pushes FRESH sandbox ids (Pass-N review P2)', () => { /** * Regression: when a primed code file is missing/expired in the diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js index 8035bcc3df6..46971a416e2 100644 --- a/api/server/services/MCP.js +++ b/api/server/services/MCP.js @@ -1,21 +1,26 @@ const { tool } = require('@librechat/agents/langchain/tools'); const { logger, getTenantId } = require('@librechat/data-schemas'); -const { - Providers, - StepTypes, - GraphEvents, - Constants: AgentConstants, -} = require('@librechat/agents'); +const { Providers, Constants: AgentConstants } = require('@librechat/agents'); const { sendEvent, + PENDING_STALE_MS, MCPOAuthHandler, isMCPDomainAllowed, normalizeServerName, normalizeJsonSchema, GenerationJobManager, resolveJsonSchemaRefs, - buildOAuthToolCallName, + sanitizeGeminiSchema, + buildMCPAuthStepId, + buildMCPAuthToolCall, + processMCPEnv, + buildMCPAuthRunStepEvent, + buildMCPAuthRunStepDeltaEvent, + buildMCPAuthRunStepEndDeltaEvent, + isUserSourced, checkAccessWithRequestCache, + requiresEphemeralUserConnection, + containsGraphTokenPlaceholder, } = require('@librechat/api'); const { Time, @@ -90,6 +95,13 @@ function evictStale(map, ttl) { const unavailableMsg = "This tool's MCP server is temporarily unavailable. Please try again shortly."; +function getOAuthFlowId(userId, serverName, tenantId = getTenantId()) { + if (!tenantId) { + return MCPOAuthHandler.generateFlowId(userId, serverName); + } + return MCPOAuthHandler.generateFlowId(userId, serverName, tenantId); +} + async function getAppConfigForRequest(req) { const user = req?.user; return await getAppConfigForUser(user?.id, user); @@ -157,6 +169,41 @@ async function resolveAllMcpConfigs(userId, user) { return await registry.getAllServerConfigs(userId, configServers); } +function getServerCustomUserVars(userMCPAuthMap, serverName) { + return userMCPAuthMap?.[`${Constants.mcp_prefix}${serverName}`]; +} + +/** + * Best-effort early gate; the authoritative check is + * `assertResolvedRuntimeConfigAllowed` in `@librechat/api`, whose resolution + * this must mirror. Graph placeholders resolve later (async), so a URL still + * carrying one defers to the authoritative check instead of rejecting here. + */ +async function isEarlyDomainAllowed({ + serverConfig, + user, + requestBody, + userMCPAuthMap, + serverName, + allowedDomains, + allowedAddresses, +}) { + const validationConfig = processMCPEnv({ + user, + body: requestBody, + dbSourced: isUserSourced(serverConfig), + options: serverConfig, + customUserVars: getServerCustomUserVars(userMCPAuthMap, serverName), + }); + if ( + typeof validationConfig?.url === 'string' && + containsGraphTokenPlaceholder(validationConfig.url) + ) { + return true; + } + return await isMCPDomainAllowed(validationConfig, allowedDomains, allowedAddresses); +} + /** * @param {string} toolName * @param {string} serverName @@ -201,20 +248,11 @@ function isEmptyObjectSchema(jsonSchema) { function createRunStepDeltaEmitter({ res, stepId, toolCall, streamId = null }) { /** * @param {string} authURL - The URL to redirect the user for OAuth authentication. + * @param {{ expiresAt?: number }} [options] * @returns {Promise} */ - return async function (authURL) { - /** @type {{ id: string; delta: AgentToolCallDelta }} */ - const data = { - id: stepId, - delta: { - type: StepTypes.TOOL_CALLS, - tool_calls: [{ ...toolCall, args: '' }], - auth: authURL, - expires_at: Date.now() + Time.TWO_MINUTES, - }, - }; - const eventData = { event: GraphEvents.ON_RUN_STEP_DELTA, data }; + return async function (authURL, options) { + const eventData = buildMCPAuthRunStepDeltaEvent({ authURL, stepId, toolCall, options }); if (streamId) { await GenerationJobManager.emitChunk(streamId, eventData); } else { @@ -235,18 +273,7 @@ function createRunStepDeltaEmitter({ res, stepId, toolCall, streamId = null }) { */ function createRunStepEmitter({ res, runId, stepId, toolCall, index, streamId = null }) { return async function () { - /** @type {import('@librechat/agents').RunStep} */ - const data = { - runId: runId ?? Constants.USE_PRELIM_RESPONSE_MESSAGE_ID, - id: stepId, - type: StepTypes.TOOL_CALLS, - index: index ?? 0, - stepDetails: { - type: StepTypes.TOOL_CALLS, - tool_calls: [toolCall], - }, - }; - const eventData = { event: GraphEvents.ON_RUN_STEP, data }; + const eventData = buildMCPAuthRunStepEvent({ runId, stepId, toolCall, index }); if (streamId) { await GenerationJobManager.emitChunk(streamId, eventData); } else { @@ -260,20 +287,43 @@ function createRunStepEmitter({ res, runId, stepId, toolCall, index, streamId = * @param {object} params * @param {string} params.flowId - The ID of the login flow. * @param {FlowStateManager} params.flowManager - The flow manager instance. - * @param {(authURL: string) => void} [params.callback] + * @param {(authURL: string, options?: { expiresAt?: number }) => void | Promise} [params.callback] */ function createOAuthStart({ flowId, flowManager, callback }) { /** * Creates a function to handle OAuth login requests. * @param {string} authURL - The URL to redirect the user for OAuth authentication. + * @param {{ expiresAt?: number }} [options] * @returns {Promise} Returns true to indicate the event was sent successfully. */ - return async function (authURL) { + return async function (authURL, options) { + let emitted = false; + const emitOAuthStart = async (message) => { + if (options) { + await callback?.(authURL, options); + } else { + await callback?.(authURL); + } + emitted = true; + logger.debug(message); + }; + + const existingFlow = await flowManager.getFlowState(flowId, 'oauth_login'); + if (existingFlow) { + await emitOAuthStart('Re-sent OAuth login request to client'); + return true; + } + await flowManager.createFlowWithHandler(flowId, 'oauth_login', async () => { - callback?.(authURL); - logger.debug('Sent OAuth login request to client'); + await emitOAuthStart('Sent OAuth login request to client'); return true; }); + + if (!emitted) { + await emitOAuthStart('Re-sent OAuth login request to client'); + } + + return true; }; } @@ -286,15 +336,7 @@ function createOAuthStart({ flowId, flowManager, callback }) { */ function createOAuthEnd({ res, stepId, toolCall, streamId = null }) { return async function () { - /** @type {{ id: string; delta: AgentToolCallDelta }} */ - const data = { - id: stepId, - delta: { - type: StepTypes.TOOL_CALLS, - tool_calls: [{ ...toolCall }], - }, - }; - const eventData = { event: GraphEvents.ON_RUN_STEP_DELTA, data }; + const eventData = buildMCPAuthRunStepEndDeltaEvent({ stepId, toolCall }); if (streamId) { await GenerationJobManager.emitChunk(streamId, eventData); } else { @@ -309,12 +351,13 @@ function createOAuthEnd({ res, stepId, toolCall, streamId = null }) { * @param {string} params.userId - The ID of the user. * @param {string} params.serverName - The name of the server. * @param {string} params.toolName - The name of the tool. + * @param {string} [params.tenantId] - The tenant ID for the current request. * @param {FlowStateManager} params.flowManager - The flow manager instance. */ -function createAbortHandler({ userId, serverName, toolName, flowManager }) { +function createAbortHandler({ userId, serverName, toolName, tenantId, flowManager }) { return function () { logger.info(`[MCP][User: ${userId}][${serverName}][${toolName}] Tool call aborted`); - const flowId = MCPOAuthHandler.generateFlowId(userId, serverName); + const flowId = getOAuthFlowId(userId, serverName, tenantId); // Clean up both mcp_oauth and mcp_get_tokens flows flowManager.failFlow(flowId, 'mcp_oauth', new Error('Tool call aborted')); flowManager.failFlow(flowId, 'mcp_get_tokens', new Error('Tool call aborted')); @@ -323,14 +366,14 @@ function createAbortHandler({ userId, serverName, toolName, flowManager }) { /** * @param {Object} params - * @param {() => void} params.runStepEmitter - * @param {(authURL: string) => void} params.runStepDeltaEmitter - * @returns {(authURL: string) => void} + * @param {() => Promise} params.runStepEmitter + * @param {(authURL: string, options?: { expiresAt?: number }) => Promise} params.runStepDeltaEmitter + * @returns {(authURL: string, options?: { expiresAt?: number }) => Promise} */ function createOAuthCallback({ runStepEmitter, runStepDeltaEmitter }) { - return function (authURL) { - runStepEmitter(); - runStepDeltaEmitter(authURL); + return async function (authURL, options) { + await runStepEmitter(); + await runStepDeltaEmitter(authURL, options); }; } @@ -344,6 +387,8 @@ function createOAuthCallback({ runStepEmitter, runStepDeltaEmitter }) { * @param {number} [params.index] * @param {string | null} [params.streamId] - The stream ID for resumable mode. * @param {Record>} [params.userMCPAuthMap] + * @param {import('@librechat/api').RequestScopedMCPConnectionStore} [params.requestScopedConnections] + * @param {import('@librechat/api').ParsedServerConfig} [params.serverConfig] - Used to bypass reconnect throttling for request-scoped servers. * @returns { Promise unknown}>> } An object with `_call` method to execute the tool input. */ async function reconnectServer({ @@ -352,36 +397,44 @@ async function reconnectServer({ index, signal, serverName, + serverConfig, configServers, userMCPAuthMap, + requestBody, + requestScopedConnections, streamId = null, }) { logger.debug( `[MCP][reconnectServer] serverName: ${serverName}, user: ${user?.id}, hasUserMCPAuthMap: ${!!userMCPAuthMap}`, ); - const throttleKey = `${user.id}:${serverName}`; - const now = Date.now(); - const lastAttempt = lastReconnectAttempts.get(throttleKey) ?? 0; - if (now - lastAttempt < RECONNECT_THROTTLE_MS) { - logger.debug(`[MCP][reconnectServer] Throttled reconnect for ${serverName}`); - return null; + // Request-scoped servers reconnect on every message by design; throttling them + // would stub out healthy tools for messages sent within the throttle window. + const requestScoped = serverConfig ? requiresEphemeralUserConnection(serverConfig) : false; + if (!requestScoped) { + const throttleKey = `${user.id}:${serverName}`; + const now = Date.now(); + const lastAttempt = lastReconnectAttempts.get(throttleKey) ?? 0; + if (now - lastAttempt < RECONNECT_THROTTLE_MS) { + logger.debug(`[MCP][reconnectServer] Throttled reconnect for ${serverName}`); + return null; + } + lastReconnectAttempts.set(throttleKey, now); + evictStale(lastReconnectAttempts, RECONNECT_THROTTLE_MS); } - lastReconnectAttempts.set(throttleKey, now); - evictStale(lastReconnectAttempts, RECONNECT_THROTTLE_MS); const runId = Constants.USE_PRELIM_RESPONSE_MESSAGE_ID; const flowId = `${user.id}:${serverName}:${Date.now()}`; const flowManager = getFlowStateManager(getLogStores(CacheKeys.FLOWS)); - const stepId = 'step_oauth_login_' + serverName; - const toolCall = { + const stepId = buildMCPAuthStepId(serverName); + const toolCall = buildMCPAuthToolCall({ id: flowId, - name: buildOAuthToolCallName(serverName), - type: 'tool_call_chunk', - }; + serverName, + }); // Set up abort handler to clean up OAuth flows if request is aborted - const oauthFlowId = MCPOAuthHandler.generateFlowId(user.id, serverName); + const tenantId = user?.tenantId ?? getTenantId(); + const oauthFlowId = getOAuthFlowId(user.id, serverName, tenantId); const abortHandler = () => { logger.info( `[MCP][User: ${user.id}][${serverName}] Tool loading aborted, cleaning up OAuth flows`, @@ -425,6 +478,8 @@ async function reconnectServer({ oauthStart, flowManager, userMCPAuthMap, + requestBody, + requestScopedConnections, forceNew: true, returnOnOAuth: false, connectionTimeout: Time.THIRTY_SECONDS, @@ -454,6 +509,8 @@ async function reconnectServer({ * @param {AbortSignal} [params.signal] * @param {string | null} [params.streamId] - The stream ID for resumable mode. * @param {import('@librechat/api').ParsedServerConfig} [params.config] + * @param {import('@librechat/api').RequestBody} [params.requestBody] + * @param {import('@librechat/api').RequestScopedMCPConnectionStore} [params.requestScopedConnections] * @param {Record>} [params.userMCPAuthMap] * @returns { Promise unknown}>> } An object with `_call` method to execute the tool input. */ @@ -468,10 +525,13 @@ async function createMCPTools({ serverName, configServers, userMCPAuthMap, + requestBody, + requestScopedConnections, streamId = null, }) { const serverConfig = config ?? (await getMCPServersRegistry().getServerConfig(serverName, user?.id, configServers)); + if (serverConfig?.url) { const appConfig = await getAppConfig({ role: user?.role, @@ -480,11 +540,15 @@ async function createMCPTools({ }); const allowedDomains = appConfig?.mcpSettings?.allowedDomains; const allowedAddresses = appConfig?.mcpSettings?.allowedAddresses; - const isDomainAllowed = await isMCPDomainAllowed( + const isDomainAllowed = await isEarlyDomainAllowed({ serverConfig, + user, + requestBody, + userMCPAuthMap, + serverName, allowedDomains, allowedAddresses, - ); + }); if (!isDomainAllowed) { logger.warn(`[MCP][${serverName}] Domain not allowed, skipping all tools`); return []; @@ -497,8 +561,11 @@ async function createMCPTools({ index, signal, serverName, + serverConfig, configServers, userMCPAuthMap, + requestBody, + requestScopedConnections, streamId, }); if (result === null) { @@ -522,6 +589,8 @@ async function createMCPTools({ streamId, availableTools: result.availableTools, toolKey: `${tool.name}${Constants.mcp_delimiter}${serverName}`, + requestBody, + requestScopedConnections, config: serverConfig, }); if (toolInstance) { @@ -545,8 +614,11 @@ async function createMCPTools({ * @param {string | null} [params.streamId] - The stream ID for resumable mode. * @param {Providers | EModelEndpoint} params.provider - The provider for the tool. * @param {LCAvailableTools} [params.availableTools] + * @param {import('@librechat/api').RequestBody} [params.requestBody] + * @param {import('@librechat/api').RequestScopedMCPConnectionStore} [params.requestScopedConnections] * @param {Record>} [params.userMCPAuthMap] * @param {import('@librechat/api').ParsedServerConfig} [params.config] + * @param {(availableTools: LCAvailableTools) => void} [params.onAvailableTools] * @returns { Promise unknown}> } An object with `_call` method to execute the tool input. */ async function createMCPTool({ @@ -559,14 +631,20 @@ async function createMCPTool({ provider, userMCPAuthMap, availableTools, + requestBody, + requestScopedConnections, config, configServers, + onAvailableTools, streamId = null, }) { const [toolName, serverName] = toolKey.split(Constants.mcp_delimiter); const serverConfig = config ?? (await getMCPServersRegistry().getServerConfig(serverName, user?.id, configServers)); + const requestScopedTools = serverConfig ? requiresEphemeralUserConnection(serverConfig) : false; + const useMissingToolCache = !requestScopedTools; + if (serverConfig?.url) { const appConfig = await getAppConfig({ role: user?.role, @@ -575,11 +653,15 @@ async function createMCPTool({ }); const allowedDomains = appConfig?.mcpSettings?.allowedDomains; const allowedAddresses = appConfig?.mcpSettings?.allowedAddresses; - const isDomainAllowed = await isMCPDomainAllowed( + const isDomainAllowed = await isEarlyDomainAllowed({ serverConfig, + user, + requestBody, + userMCPAuthMap, + serverName, allowedDomains, allowedAddresses, - ); + }); if (!isDomainAllowed) { logger.warn(`[MCP][${serverName}] Domain no longer allowed, skipping tool: ${toolName}`); return undefined; @@ -589,7 +671,7 @@ async function createMCPTool({ /** @type {LCTool | undefined} */ let toolDefinition = availableTools?.[toolKey]?.function; if (!toolDefinition) { - const cachedAt = missingToolCache.get(toolKey); + const cachedAt = useMissingToolCache ? missingToolCache.get(toolKey) : undefined; if (cachedAt && Date.now() - cachedAt < MISSING_TOOL_TTL_MS) { logger.debug( `[MCP][${serverName}][${toolName}] Tool in negative cache, returning unavailable stub.`, @@ -606,13 +688,19 @@ async function createMCPTool({ index, signal, serverName, + serverConfig, configServers, userMCPAuthMap, + requestBody, + requestScopedConnections, streamId, }); + if (result?.availableTools) { + onAvailableTools?.(result.availableTools); + } toolDefinition = result?.availableTools?.[toolKey]?.function; - if (!toolDefinition) { + if (!toolDefinition && useMissingToolCache) { missingToolCache.set(toolKey, Date.now()); evictStale(missingToolCache, MISSING_TOOL_TTL_MS); } @@ -629,6 +717,8 @@ async function createMCPTool({ res, mcpPermissionContext, user, + requestBody, + requestScopedConnections, provider, toolName, serverName, @@ -642,6 +732,8 @@ function createToolInstance({ res, mcpPermissionContext, user: capturedUser = null, + requestBody: capturedRequestBody, + requestScopedConnections: capturedRequestScopedConnections, toolName, serverName, serverConfig: capturedServerConfig, @@ -655,6 +747,12 @@ function createToolInstance({ let schema = parameters ? normalizeJsonSchema(resolveJsonSchemaRefs(parameters)) : null; + if (schema && isGoogle) { + // Gemini/Vertex AI accept only a subset of JSON Schema; sanitize so MCP tools with + // unions, non-string enums, etc. don't 400 (they work as-is on OpenAI/Claude). + schema = sanitizeGeminiSchema(schema); + } + if (!schema || (isGoogle && isEmptyObjectSchema(schema))) { schema = { type: 'object', @@ -669,9 +767,9 @@ function createToolInstance({ /** @type {(toolArguments: Object | string, config?: GraphRunnableConfig) => Promise} */ const _call = async (toolArguments, config) => { - const permissionUser = config?.configurable?.user ?? capturedUser; - const userId = - config?.configurable?.user?.id || config?.configurable?.user_id || capturedUser?.id; + const effectiveUser = config?.configurable?.user ?? capturedUser; + const permissionUser = effectiveUser; + const userId = effectiveUser?.id || config?.configurable?.user_id || capturedUser?.id; /** @type {ReturnType} */ let abortHandler = null; /** @type {AbortSignal} */ @@ -711,7 +809,8 @@ function createToolInstance({ }); if (derivedSignal) { - abortHandler = createAbortHandler({ userId, serverName, toolName, flowManager }); + const tenantId = config?.configurable?.user?.tenantId ?? getTenantId(); + abortHandler = createAbortHandler({ userId, serverName, toolName, tenantId, flowManager }); derivedSignal.addEventListener('abort', abortHandler, { once: true }); } @@ -727,8 +826,10 @@ function createToolInstance({ options: { signal: derivedSignal, }, - user: config?.configurable?.user, - requestBody: config?.configurable?.requestBody, + user: effectiveUser, + requestBody: config?.configurable?.requestBody ?? capturedRequestBody, + requestScopedConnections: + config?.configurable?.requestScopedConnections ?? capturedRequestScopedConnections, customUserVars, flowManager, tokenMethods: { @@ -786,7 +887,9 @@ function createToolInstance({ }); toolInstance.mcp = true; toolInstance.mcpRawServerName = serverName; - toolInstance.mcpJsonSchema = parameters; + // On Google/Vertex, propagate the union-flattened schema so definitions extracted + // from this instance don't reach the Gemini converter with unsupported unions. + toolInstance.mcpJsonSchema = isGoogle ? schema : parameters; return toolInstance; } @@ -836,12 +939,13 @@ async function getMCPSetupData(userId, options = {}) { * Check OAuth flow status for a user and server * @param {string} userId - The user ID * @param {string} serverName - The server name + * @param {string} [tenantId] - The tenant ID for the current request. * @returns {Object} Object containing hasActiveFlow and hasFailedFlow flags */ -async function checkOAuthFlowStatus(userId, serverName) { +async function checkOAuthFlowStatus(userId, serverName, tenantId = getTenantId()) { const flowsCache = getLogStores(CacheKeys.FLOWS); const flowManager = getFlowStateManager(flowsCache); - const flowId = MCPOAuthHandler.generateFlowId(userId, serverName); + const flowId = getOAuthFlowId(userId, serverName, tenantId); try { const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth'); @@ -850,7 +954,10 @@ async function checkOAuthFlowStatus(userId, serverName) { } const flowAge = Date.now() - flowState.createdAt; - const flowTTL = flowState.ttl || 180000; // Default 3 minutes + // Report active only while the flow is still usable (the handling/reuse window), + // not for the full Keyv retention TTL — otherwise the UI shows "connecting" for a + // flow the initiate/callback paths already reject, hiding the connect button. + const flowTTL = flowState.ttl || PENDING_STALE_MS; if (flowState.status === 'FAILED' || flowAge > flowTTL) { const wasCancelled = flowState.error && flowState.error.includes('cancelled'); @@ -949,6 +1056,7 @@ module.exports = { resolveConfigServers, resolveMcpConfigNames, resolveAllMcpConfigs, + createOAuthStart, checkOAuthFlowStatus, getServerConnectionStatus, createUnavailableToolStub, diff --git a/api/server/services/MCP.spec.js b/api/server/services/MCP.spec.js index 38fbb192a4e..30fbc6442b8 100644 --- a/api/server/services/MCP.spec.js +++ b/api/server/services/MCP.spec.js @@ -1,5 +1,6 @@ // Mock all dependencies - define mocks before imports -// Mock all dependencies +const mockGetTenantId = jest.fn(); + jest.mock('@librechat/data-schemas', () => ({ logger: { debug: jest.fn(), @@ -7,6 +8,7 @@ jest.mock('@librechat/data-schemas', () => ({ info: jest.fn(), warn: jest.fn(), }, + getTenantId: mockGetTenantId, })); // Create mock registry instance @@ -45,6 +47,7 @@ const { createMCPTools, createMCPPermissionContext, getMCPSetupData, + createOAuthStart, checkOAuthFlowStatus, getServerConnectionStatus, createUnavailableToolStub, @@ -93,6 +96,7 @@ describe('tests for the new helper functions used by the MCP connection status e beforeEach(() => { jest.clearAllMocks(); jest.spyOn(MCPOAuthHandler, 'generateFlowId'); + mockGetTenantId.mockReturnValue(undefined); mockGetMCPManager = require('~/config').getMCPManager; mockGetFlowStateManager = require('~/config').getFlowStateManager; @@ -100,6 +104,85 @@ describe('tests for the new helper functions used by the MCP connection status e mockGetOAuthReconnectionManager = require('~/config').getOAuthReconnectionManager; }); + describe('createOAuthStart', () => { + const flowId = 'test-server:oauth_login:thread-1:run-1'; + const authUrl = 'https://auth.example.com/oauth?state=test'; + + it('should create a login flow and emit the OAuth URL for the first request', async () => { + const callback = jest.fn(); + const mockFlowManager = { + getFlowState: jest.fn().mockResolvedValue(null), + createFlowWithHandler: jest.fn(async (_flowId, _type, handler) => handler()), + }; + + const oauthStart = createOAuthStart({ + flowId, + flowManager: mockFlowManager, + callback, + }); + + await expect(oauthStart(authUrl)).resolves.toBe(true); + + expect(mockFlowManager.getFlowState).toHaveBeenCalledWith(flowId, 'oauth_login'); + expect(mockFlowManager.createFlowWithHandler).toHaveBeenCalledWith( + flowId, + 'oauth_login', + expect.any(Function), + ); + expect(callback).toHaveBeenCalledWith(authUrl); + expect(logger.debug).toHaveBeenCalledWith('Sent OAuth login request to client'); + }); + + it('should replay the OAuth URL when the login flow already exists', async () => { + const callback = jest.fn(); + const mockFlowManager = { + getFlowState: jest.fn().mockResolvedValue({ + status: 'COMPLETED', + result: true, + }), + createFlowWithHandler: jest.fn(), + }; + + const oauthStart = createOAuthStart({ + flowId, + flowManager: mockFlowManager, + callback, + }); + + await expect(oauthStart(authUrl)).resolves.toBe(true); + + expect(mockFlowManager.getFlowState).toHaveBeenCalledWith(flowId, 'oauth_login'); + expect(mockFlowManager.createFlowWithHandler).not.toHaveBeenCalled(); + expect(callback).toHaveBeenCalledWith(authUrl); + expect(logger.debug).toHaveBeenCalledWith('Re-sent OAuth login request to client'); + }); + + it('should replay the OAuth URL when flow creation is deduped internally', async () => { + const callback = jest.fn(); + const mockFlowManager = { + getFlowState: jest.fn().mockResolvedValue(null), + createFlowWithHandler: jest.fn().mockResolvedValue(true), + }; + + const oauthStart = createOAuthStart({ + flowId, + flowManager: mockFlowManager, + callback, + }); + + await expect(oauthStart(authUrl)).resolves.toBe(true); + + expect(mockFlowManager.getFlowState).toHaveBeenCalledWith(flowId, 'oauth_login'); + expect(mockFlowManager.createFlowWithHandler).toHaveBeenCalledWith( + flowId, + 'oauth_login', + expect.any(Function), + ); + expect(callback).toHaveBeenCalledWith(authUrl); + expect(logger.debug).toHaveBeenCalledWith('Re-sent OAuth login request to client'); + }); + }); + describe('getMCPSetupData', () => { const mockUserId = 'user-123'; const mockConfig = { @@ -249,8 +332,8 @@ describe('tests for the new helper functions used by the MCP connection status e it('should detect failed flow when TTL not specified and flow exceeds default TTL', async () => { const mockFlowState = { status: 'PENDING', - createdAt: Date.now() - 200000, // 200 seconds ago (> 180s default TTL) - // ttl not specified, should use 180000 default + createdAt: Date.now() - 16 * 60 * 1000, // 16 minutes ago (past the PENDING_STALE_MS window) + // ttl not specified, should fall back to the PENDING_STALE_MS default }; const mockFlowManager = { getFlowState: jest.fn(() => mockFlowState) }; mockGetFlowStateManager.mockReturnValue(mockFlowManager); @@ -280,6 +363,28 @@ describe('tests for the new helper functions used by the MCP connection status e ); }); + it('should check the tenant-scoped OAuth flow when tenant context exists', async () => { + mockGetTenantId.mockReturnValue('tenant/a'); + MCPOAuthHandler.generateFlowId.mockReturnValue('tenant-flow-id'); + const mockFlowState = { + status: 'PENDING', + createdAt: Date.now() - 60000, + ttl: 180000, + }; + const mockFlowManager = { getFlowState: jest.fn(() => mockFlowState) }; + mockGetFlowStateManager.mockReturnValue(mockFlowManager); + + const result = await checkOAuthFlowStatus(mockUserId, mockServerName); + + expect(MCPOAuthHandler.generateFlowId).toHaveBeenCalledWith( + mockUserId, + mockServerName, + 'tenant/a', + ); + expect(mockFlowManager.getFlowState).toHaveBeenCalledWith('tenant-flow-id', 'mcp_oauth'); + expect(result).toEqual({ hasActiveFlow: true, hasFailedFlow: false }); + }); + it('should return false flags for other statuses', async () => { const mockFlowState = { status: 'COMPLETED', @@ -669,6 +774,7 @@ describe('User parameter passing tests', () => { beforeEach(() => { jest.clearAllMocks(); + mockGetTenantId.mockReturnValue(undefined); mockReinitMCPServer = require('./Tools/mcp').reinitMCPServer; mockGetMCPManager = require('~/config').getMCPManager; mockGetFlowStateManager = require('~/config').getFlowStateManager; @@ -731,6 +837,57 @@ describe('User parameter passing tests', () => { expect(mockReinitMCPServer.mock.calls[0][0].user).toBe(mockUser); }); + it('should fail tenant-scoped OAuth flows when tool loading is aborted', async () => { + const mockUser = { id: 'tenant-user', name: 'Tenant User' }; + const mockRes = { write: jest.fn(), flush: jest.fn() }; + const abortController = new AbortController(); + const mockFlowManager = { + createFlowWithHandler: jest.fn(), + failFlow: jest.fn(), + }; + mockGetTenantId.mockReturnValue('tenant/a'); + mockGetFlowStateManager.mockReturnValue(mockFlowManager); + MCPOAuthHandler.generateFlowId.mockReturnValue('tenant-flow-id'); + + let resolveReinit; + mockReinitMCPServer.mockImplementation( + () => + new Promise((resolve) => { + resolveReinit = resolve; + }), + ); + + const createToolsPromise = createMCPTools({ + res: mockRes, + user: mockUser, + serverName: 'tenant-abort-server', + provider: 'openai', + signal: abortController.signal, + userMCPAuthMap: {}, + config: { type: 'stdio' }, + }); + + abortController.abort(); + resolveReinit({ tools: [], availableTools: {} }); + await createToolsPromise; + + expect(MCPOAuthHandler.generateFlowId).toHaveBeenCalledWith( + mockUser.id, + 'tenant-abort-server', + 'tenant/a', + ); + expect(mockFlowManager.failFlow).toHaveBeenCalledWith( + 'tenant-flow-id', + 'mcp_oauth', + expect.any(Error), + ); + expect(mockFlowManager.failFlow).toHaveBeenCalledWith( + 'tenant-flow-id', + 'mcp_get_tokens', + expect.any(Error), + ); + }); + it('should throw error if user is not provided', async () => { const mockRes = { write: jest.fn(), flush: jest.fn() }; @@ -793,6 +950,37 @@ describe('User parameter passing tests', () => { expect(mockReinitMCPServer.mock.calls[0][0].user).toBe(mockUser); }); + it('should report available tools discovered during single tool reinit', async () => { + const mockUser = { id: 'user-discovery-callback', role: 'USER' }; + const mockRes = { write: jest.fn(), flush: jest.fn() }; + const onAvailableTools = jest.fn(); + const discoveredTools = { + [`my-tool${D}my-server`]: { + function: { description: 'My Tool', parameters: {} }, + }, + [`other-tool${D}my-server`]: { + function: { description: 'Other Tool', parameters: {} }, + }, + }; + + mockReinitMCPServer.mockResolvedValue({ + availableTools: discoveredTools, + }); + + const result = await createMCPTool({ + res: mockRes, + user: mockUser, + toolKey: `my-tool${D}my-server`, + provider: 'openai', + userMCPAuthMap: {}, + availableTools: undefined, + onAvailableTools, + }); + + expect(result).toBeDefined(); + expect(onAvailableTools).toHaveBeenCalledWith(discoveredTools); + }); + it('should not call reinitMCPServer when tool is in cache', async () => { const mockUser = { id: 'test-user-789' }; const mockRes = { write: jest.fn(), flush: jest.fn() }; @@ -937,6 +1125,126 @@ describe('User parameter passing tests', () => { expect(getRoleByName).toHaveBeenCalledTimes(1); expect(mockCallTool).toHaveBeenCalledTimes(2); }); + + it('should pass the captured user to MCPManager.callTool when invocation config omits configurable.user', async () => { + const mockUser = { id: 'captured-user', email: 'captured@example.com', role: 'USER' }; + const mockRes = { write: jest.fn(), flush: jest.fn() }; + const { getRoleByName } = require('~/models'); + getRoleByName.mockResolvedValue({ + permissions: { + [PermissionTypes.MCP_SERVERS]: { + [Permissions.USE]: true, + }, + }, + }); + + const mockCallTool = jest.fn().mockResolvedValue(['ok', null]); + mockGetMCPManager.mockReturnValue({ + callTool: mockCallTool, + }); + + const mcpTool = await createMCPTool({ + res: mockRes, + user: mockUser, + toolKey: `test-tool${D}test-server`, + provider: 'openai', + userMCPAuthMap: {}, + availableTools: { + [`test-tool${D}test-server`]: { + function: { + description: 'Cached tool', + parameters: { type: 'object', properties: {} }, + }, + }, + }, + }); + + await expect( + mcpTool.invoke( + {}, + { + configurable: { + user_id: mockUser.id, + }, + metadata: { + provider: 'openai', + thread_id: 'thread-1', + run_id: 'run-1', + }, + toolCall: {}, + }, + ), + ).resolves.toBe('ok'); + + expect(mockCallTool).toHaveBeenCalledWith( + expect.objectContaining({ + serverName: 'test-server', + toolName: 'test-tool', + user: mockUser, + }), + ); + }); + + it('should pass captured request body when invocation config omits requestBody', async () => { + const mockUser = { id: 'captured-body-user', email: 'captured@example.com', role: 'USER' }; + const requestBody = { conversationId: 'conv-123', messageId: 'msg-123' }; + const mockRes = { write: jest.fn(), flush: jest.fn() }; + const { getRoleByName } = require('~/models'); + getRoleByName.mockResolvedValue({ + permissions: { + [PermissionTypes.MCP_SERVERS]: { + [Permissions.USE]: true, + }, + }, + }); + + const mockCallTool = jest.fn().mockResolvedValue(['ok', null]); + mockGetMCPManager.mockReturnValue({ + callTool: mockCallTool, + }); + + const mcpTool = await createMCPTool({ + res: mockRes, + user: mockUser, + requestBody, + toolKey: `test-tool${D}test-server`, + provider: 'openai', + userMCPAuthMap: {}, + availableTools: { + [`test-tool${D}test-server`]: { + function: { + description: 'Cached tool', + parameters: { type: 'object', properties: {} }, + }, + }, + }, + }); + + await expect( + mcpTool.invoke( + {}, + { + configurable: { + user: mockUser, + }, + metadata: { + provider: 'openai', + thread_id: 'thread-1', + run_id: 'run-1', + }, + toolCall: {}, + }, + ), + ).resolves.toBe('ok'); + + expect(mockCallTool).toHaveBeenCalledWith( + expect.objectContaining({ + serverName: 'test-server', + toolName: 'test-tool', + requestBody, + }), + ); + }); }); describe('reinitMCPServer (via reconnectServer)', () => { @@ -1107,6 +1415,50 @@ describe('User parameter passing tests', () => { }); }); + it('should validate the resolved runtime URL for tool creation', async () => { + const mockUser = { id: 'runtime-domain-user', role: 'user' }; + const requestBody = { conversationId: 'tenant-a' }; + const mockRes = { write: jest.fn(), flush: jest.fn() }; + + mockRegistryInstance.getServerConfig.mockResolvedValue({ + type: 'streamable-http', + url: 'https://{{LIBRECHAT_BODY_CONVERSATIONID}}.example.com/sse', + source: 'yaml', + }); + + mockGetAppConfig.mockResolvedValue({ + mcpSettings: { allowedDomains: ['*.example.com'] }, + }); + + mockIsMCPDomainAllowed.mockResolvedValueOnce(true); + + const result = await createMCPTool({ + res: mockRes, + user: mockUser, + requestBody, + toolKey: `test-tool${D}test-server`, + provider: 'openai', + userMCPAuthMap: {}, + availableTools: { + [`test-tool${D}test-server`]: { + function: { + description: 'Test tool', + parameters: { type: 'object', properties: {} }, + }, + }, + }, + }); + + expect(result).toBeDefined(); + expect(mockIsMCPDomainAllowed).toHaveBeenCalledWith( + expect.objectContaining({ + url: 'https://tenant-a.example.com/sse', + }), + ['*.example.com'], + undefined, + ); + }); + it('should skip domain validation for stdio transports (no URL)', async () => { const mockUser = { id: 'stdio-test-user' }; const mockRes = { write: jest.fn(), flush: jest.fn() }; @@ -1390,6 +1742,56 @@ describe('User parameter passing tests', () => { // Still only 1 real reconnect — user B was protected by the cache expect(mockReinitMCPServer).toHaveBeenCalledTimes(1); }); + + it('should bypass the negative cache for request-scoped tools', async () => { + const userA = { id: 'request-scoped-user-A' }; + const userB = { id: 'request-scoped-user-B' }; + const mockRes = { write: jest.fn(), flush: jest.fn() }; + const serverName = 'request-scoped-server'; + const toolKey = `tenant-tool${D}${serverName}`; + + mockRegistryInstance.getServerConfig.mockResolvedValue({ + type: 'streamable-http', + url: 'https://api.example.com/{{LIBRECHAT_BODY_MESSAGEID}}/mcp', + source: 'yaml', + }); + + mockReinitMCPServer + .mockResolvedValueOnce({ + availableTools: {}, + }) + .mockResolvedValueOnce({ + availableTools: { + [toolKey]: { + function: { description: 'Tenant tool', parameters: {} }, + }, + }, + }); + + await createMCPTool({ + res: mockRes, + user: userA, + requestBody: { messageId: 'message-a' }, + toolKey, + provider: 'openai', + userMCPAuthMap: {}, + availableTools: undefined, + }); + + const result = await createMCPTool({ + res: mockRes, + user: userB, + requestBody: { messageId: 'message-b' }, + toolKey, + provider: 'openai', + userMCPAuthMap: {}, + availableTools: undefined, + }); + + expect(result).toBeDefined(); + expect(result.name).toContain('tenant-tool'); + expect(mockReinitMCPServer).toHaveBeenCalledTimes(2); + }); }); describe('createMCPTools throttle handling', () => { diff --git a/api/server/services/MCPRequestContext.js b/api/server/services/MCPRequestContext.js new file mode 100644 index 00000000000..50a9c90a61e --- /dev/null +++ b/api/server/services/MCPRequestContext.js @@ -0,0 +1,13 @@ +const { + cleanupMCPRequestContextForReq, + cleanupMCPRequestContext, + createMCPRequestContext, + getMCPRequestContext, +} = require('@librechat/api'); + +module.exports = { + cleanupMCPRequestContextForReq, + cleanupMCPRequestContext, + createMCPRequestContext, + getMCPRequestContext, +}; diff --git a/api/server/services/PermissionService.js b/api/server/services/PermissionService.js index 9d40564127c..65336ab0022 100644 --- a/api/server/services/PermissionService.js +++ b/api/server/services/PermissionService.js @@ -27,6 +27,22 @@ const validateResourceType = (resourceType) => { } }; +const ensureLocalUserPrincipalExists = async (principalId) => { + const user = await db.findUser({ _id: principalId }, '_id'); + if (!user) { + throw new Error('User principal not found'); + } + return user._id.toString(); +}; + +const ensureLocalGroupPrincipalExists = async (principalId) => { + const group = await db.findGroupById(principalId, { _id: 1 }); + if (!group) { + throw new Error('Group principal not found'); + } + return group._id.toString(); +}; + /** * @import { TPrincipal } from 'librechat-data-provider' */ @@ -300,8 +316,8 @@ const ensurePrincipalExists = async function (principal) { return null; } - if (principal.id) { - return principal.id; + if (principal.type === PrincipalType.USER && principal.id) { + return await ensureLocalUserPrincipalExists(principal.id); } if (principal.type === PrincipalType.USER && principal.source === 'entra') { @@ -366,6 +382,10 @@ const ensureGroupPrincipalExists = async function (principal, authContext = null throw new Error(`Invalid principal type: ${principal.type}. Expected '${PrincipalType.GROUP}'`); } + if (principal.id && principal.source !== 'entra') { + return await ensureLocalGroupPrincipalExists(principal.id); + } + if (principal.source === 'entra') { if (!principal.name || !principal.idOnTheSource) { throw new Error('Entra ID group principals must have name and idOnTheSource'); diff --git a/api/server/services/PermissionService.spec.js b/api/server/services/PermissionService.spec.js index 3fcda2a8404..d6f78414a85 100644 --- a/api/server/services/PermissionService.spec.js +++ b/api/server/services/PermissionService.spec.js @@ -1,5 +1,5 @@ const mongoose = require('mongoose'); -const { RoleBits, createModels } = require('@librechat/data-schemas'); +const { RoleBits, createModels, tenantStorage } = require('@librechat/data-schemas'); const { MongoMemoryServer } = require('mongodb-memory-server'); const { ResourceType, @@ -15,6 +15,8 @@ const { getAvailableRoles, grantPermission, checkPermission, + ensurePrincipalExists, + ensureGroupPrincipalExists, } = require('./PermissionService'); const { findRoleByIdentifier, getUserPrincipals, seedDefaultRoles } = require('~/models'); @@ -44,6 +46,8 @@ jest.mock('~/config', () => ({ let mongoServer; let AclEntry; +let User; +let Group; beforeAll(async () => { mongoServer = await MongoMemoryServer.create(); @@ -58,6 +62,8 @@ beforeAll(async () => { Object.assign(mongoose.models, dbModels); AclEntry = dbModels.AclEntry; + User = dbModels.User; + Group = dbModels.Group; // Seed default roles await seedDefaultRoles(); @@ -243,6 +249,69 @@ describe('PermissionService', () => { }); }); + describe('principal validation for ACL writes', () => { + beforeEach(async () => { + await User.deleteMany({ email: /acl-principal/i }); + await Group.deleteMany({ name: /ACL Principal/i }); + }); + + test('rejects a local user id outside the current request context', async () => { + const outsideUser = await User.create({ + name: 'ACL Principal Outside User', + email: 'acl-principal-outside-user@example.com', + tenantId: 'tenant-b', + }); + + await expect( + tenantStorage.run({ tenantId: 'tenant-a' }, async () => + ensurePrincipalExists({ + type: PrincipalType.USER, + id: outsideUser._id.toString(), + name: 'Outside User', + source: 'local', + }), + ), + ).rejects.toThrow('User principal not found'); + }); + + test('accepts a local user id in the current request context', async () => { + const currentUser = await User.create({ + name: 'ACL Principal Current User', + email: 'acl-principal-current-user@example.com', + tenantId: 'tenant-a', + }); + + const principalId = await tenantStorage.run({ tenantId: 'tenant-a' }, async () => + ensurePrincipalExists({ + type: PrincipalType.USER, + id: currentUser._id.toString(), + name: 'Current User', + source: 'local', + }), + ); + + expect(principalId).toBe(currentUser._id.toString()); + }); + + test('rejects a local group id outside the current request context', async () => { + const outsideGroup = await Group.create({ + name: 'ACL Principal Outside Group', + tenantId: 'tenant-b', + }); + + await expect( + tenantStorage.run({ tenantId: 'tenant-a' }, async () => + ensureGroupPrincipalExists({ + type: PrincipalType.GROUP, + id: outsideGroup._id.toString(), + name: 'Outside Group', + source: 'local', + }), + ), + ).rejects.toThrow('Group principal not found'); + }); + }); + describe('checkPermission', () => { let otherResourceId; diff --git a/api/server/services/Skills/sync.js b/api/server/services/Skills/sync.js new file mode 100644 index 00000000000..f19005639f1 --- /dev/null +++ b/api/server/services/Skills/sync.js @@ -0,0 +1,215 @@ +const { FileContext } = require('librechat-data-provider'); +const { + getStorageMetadata, + createGitHubSkillSyncRunner, + createSkillSyncTriggerOrchestrator, + startGitHubSkillSyncScheduler, +} = require('@librechat/api'); +const { logger, runAsSystem } = require('@librechat/data-schemas'); +const db = require('~/models'); +const { getAppConfig } = require('~/server/services/Config'); +const { getStrategyFunctions } = require('~/server/services/Files/strategies'); +const { getFileStrategy } = require('~/server/utils/getFileStrategy'); + +const SYSTEM_USER_ID = '000000000000000000000000'; + +let appConfigRef; +let runner; +let scheduler; + +async function loadCurrentAppConfig() { + try { + const appConfig = await getAppConfig({ baseOnly: true }); + appConfigRef = appConfig; + return appConfig; + } catch (error) { + if (appConfigRef) { + return appConfigRef; + } + throw error; + } +} + +async function getSyncConfig(loadAppConfig = loadCurrentAppConfig) { + const appConfig = await loadAppConfig(); + return appConfig?.skillSync; +} + +async function resolveSkillStorage({ isImage = false, loadAppConfig = loadCurrentAppConfig } = {}) { + const appConfig = await loadAppConfig(); + const source = getFileStrategy(appConfig, { context: FileContext.skill_file, isImage }); + const strategy = getStrategyFunctions(source); + if (!strategy.saveBuffer) { + throw new Error(`Storage backend "${source}" does not support file writes`); + } + return { source, saveBuffer: strategy.saveBuffer }; +} + +async function getSyntheticReq({ userId = SYSTEM_USER_ID, tenantId, loadAppConfig } = {}) { + const appConfig = await (loadAppConfig ?? loadCurrentAppConfig)(); + return { + config: appConfig, + user: { + id: userId, + _id: userId, + tenantId, + }, + }; +} + +function withBaseSkillSyncConfig(req, baseConfig) { + if (!req?.config || req.config.config?.skillSync !== undefined) { + return req; + } + return { + ...req, + config: { + ...req.config, + config: { + ...(req.config.config ?? {}), + skillSync: baseConfig?.skillSync, + }, + }, + }; +} + +function createRunner({ getConfig, loadAppConfig, allowServerCredentials = true } = {}) { + const resolveAppConfig = loadAppConfig ?? loadCurrentAppConfig; + const resolveConfig = getConfig ?? (() => getSyncConfig(resolveAppConfig)); + const createdRunner = createGitHubSkillSyncRunner({ + getConfig: resolveConfig, + getCredentialToken: db.getSkillSyncCredentialToken, + getCredentialSummary: db.getSkillSyncCredentialSummary, + listCredentials: db.listSkillSyncCredentials, + listStatuses: db.listSkillSyncStatuses, + upsertStatus: db.upsertSkillSyncStatus, + tryAcquireLock: db.tryAcquireSkillSyncLock, + refreshLock: db.refreshSkillSyncLock, + releaseLock: db.releaseSkillSyncLock, + createSkill: db.createSkill, + updateSkill: db.updateSkill, + getSkillById: db.getSkillById, + findSkillBySourceIdentity: db.findSkillBySourceIdentity, + listSkillsBySource: db.listSkillsBySource, + listSkillFiles: db.listSkillFiles, + getSkillFileByPath: db.getSkillFileByPath, + upsertSkillFile: db.upsertSkillFile, + deleteSkillFile: db.deleteSkillFile, + deleteSkill: db.deleteSkill, + grantPermission: async ({ + principalType, + principalId, + resourceType, + resourceId, + accessRoleId, + grantedBy, + }) => { + // Default access roles are seeded globally (no tenantId) under runAsSystem, + // but the runner may execute inside a source's tenant context. Resolve the + // role outside tenant isolation so the global role matches, then write the + // ACL entry in the active (tenant) context so tenant users can see it. + const role = await runAsSystem(() => db.findRoleByIdentifier(accessRoleId)); + if (!role) { + throw new Error(`Role ${accessRoleId} not found`); + } + if (role.resourceType !== resourceType) { + throw new Error( + `Role ${accessRoleId} is for ${role.resourceType} resources, not ${resourceType}`, + ); + } + return db.grantPermission( + principalType, + principalId, + resourceType, + resourceId, + role.permBits, + grantedBy, + undefined, + role._id, + ); + }, + saveBuffer: async ({ userId, buffer, fileName, basePath, isImage, tenantId }) => { + const storage = await resolveSkillStorage({ isImage, loadAppConfig: resolveAppConfig }); + const filepath = await storage.saveBuffer({ + userId: userId ?? SYSTEM_USER_ID, + buffer, + fileName, + basePath, + tenantId, + }); + return { + filepath, + source: storage.source, + ...getStorageMetadata({ filepath, source: storage.source }), + }; + }, + deleteFile: async (file) => { + const strategy = getStrategyFunctions(file.source); + if (!strategy.deleteFile) { + return; + } + await strategy.deleteFile( + await getSyntheticReq({ + userId: file.user?.toString?.() ?? file.user ?? SYSTEM_USER_ID, + tenantId: file.tenantId, + loadAppConfig: resolveAppConfig, + }), + file, + ); + }, + allowServerCredentials, + }); + return { + getStatus: createdRunner.getStatus, + runOnce: createdRunner.runOnce, + }; +} + +const triggerOrchestrator = createSkillSyncTriggerOrchestrator({ + createRunner, + logger, +}); + +function getGitHubSkillSyncRunnerForRequest(req) { + return triggerOrchestrator.getRunnerForAdminRequest(withBaseSkillSyncConfig(req, appConfigRef)); +} + +async function maybeRunGitHubSkillSyncForRequest(req) { + const baseConfig = await loadCurrentAppConfig(); + return triggerOrchestrator.maybeRunForRequest({ + ...withBaseSkillSyncConfig(req, baseConfig), + skillSyncAllowServerCredentials: false, + }); +} + +function initializeGitHubSkillSync(appConfig) { + appConfigRef = appConfig; + runner = createRunner(); + scheduler = startGitHubSkillSyncScheduler({ + getConfig: getSyncConfig, + runner, + }); + return { runner, scheduler }; +} + +function getGitHubSkillSyncRunner() { + if (!runner) { + runner = createRunner(); + } + return runner; +} + +function stopGitHubSkillSyncScheduler() { + if (scheduler) { + scheduler.stop(); + scheduler = undefined; + } +} + +module.exports = { + initializeGitHubSkillSync, + getGitHubSkillSyncRunner, + getGitHubSkillSyncRunnerForRequest, + maybeRunGitHubSkillSyncForRequest, + stopGitHubSkillSyncScheduler, +}; diff --git a/api/server/services/Skills/sync.test.js b/api/server/services/Skills/sync.test.js new file mode 100644 index 00000000000..069ffc6db27 --- /dev/null +++ b/api/server/services/Skills/sync.test.js @@ -0,0 +1,583 @@ +const mockGetAppConfig = jest.fn(); +const mockGetStrategyFunctions = jest.fn(); +const mockGetFileStrategy = jest.fn(); +const mockFindRoleByIdentifier = jest.fn(); +const mockGrantPermission = jest.fn(); +let mockRunnerDeps; +let mockRunnerStatus; +const mockCreatedRunners = []; + +jest.mock('~/server/services/Config', () => ({ + getAppConfig: mockGetAppConfig, +})); + +jest.mock('@librechat/api', () => { + const actualApi = jest.requireActual('@librechat/api'); + return { + createSkillSyncTriggerOrchestrator: actualApi.createSkillSyncTriggerOrchestrator, + createGitHubSkillSyncRunner: jest.fn((deps) => { + mockRunnerDeps = deps; + const runner = { + getStatus: jest.fn(async () => { + if (mockRunnerStatus) { + return mockRunnerStatus; + } + const config = await deps.getConfig(); + const github = config?.github ?? {}; + return { + enabled: github.enabled ?? false, + intervalMinutes: github.intervalMinutes ?? 60, + runOnStartup: github.runOnStartup ?? false, + sources: (github.sources ?? []).map((source) => ({ + provider: 'github', + sourceId: source.id, + status: 'idle', + credentialPresent: + deps.allowServerCredentials !== false && + Boolean(source.credentialKey || source.token), + owner: source.owner, + repo: source.repo, + ref: source.ref, + paths: source.paths, + syncedSkillCount: 0, + syncedFileCount: 0, + deletedSkillCount: 0, + deletedFileCount: 0, + })), + credentials: [], + }; + }), + runOnce: jest.fn(async () => deps.getConfig()), + }; + mockCreatedRunners.push({ deps, runner }); + return runner; + }), + getStorageMetadata: jest.fn(() => ({})), + startGitHubSkillSyncScheduler: jest.fn(() => ({ stop: jest.fn() })), + }; +}); + +jest.mock('@librechat/data-schemas', () => ({ + logger: { + error: jest.fn(), + warn: jest.fn(), + }, + runAsSystem: jest.fn((fn) => fn()), +})); + +jest.mock('~/models', () => ({ + findRoleByIdentifier: mockFindRoleByIdentifier, + grantPermission: mockGrantPermission, + getSkillSyncCredentialToken: jest.fn(), + getSkillSyncCredentialSummary: jest.fn(), + listSkillSyncCredentials: jest.fn(async () => []), + listSkillSyncStatuses: jest.fn(async () => []), + upsertSkillSyncStatus: jest.fn(), + tryAcquireSkillSyncLock: jest.fn(), + refreshSkillSyncLock: jest.fn(), + releaseSkillSyncLock: jest.fn(), + createSkill: jest.fn(), + updateSkill: jest.fn(), + getSkillById: jest.fn(), + findSkillBySourceIdentity: jest.fn(), + listSkillsBySource: jest.fn(), + listSkillFiles: jest.fn(), + getSkillFileByPath: jest.fn(), + upsertSkillFile: jest.fn(), + deleteSkillFile: jest.fn(), + deleteSkill: jest.fn(), +})); +jest.mock('~/server/services/Files/strategies', () => ({ + getStrategyFunctions: mockGetStrategyFunctions, +})); +jest.mock('~/server/utils/getFileStrategy', () => ({ getFileStrategy: mockGetFileStrategy })); + +describe('GitHub skill sync service', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetAppConfig.mockReset(); + mockGetStrategyFunctions.mockReset(); + mockGetFileStrategy.mockReset(); + mockFindRoleByIdentifier.mockReset(); + mockGrantPermission.mockReset(); + mockRunnerDeps = undefined; + mockRunnerStatus = undefined; + mockCreatedRunners.length = 0; + }); + + it('resolves sync config from fresh base app config for runner operations', async () => { + const startupSkillSync = { + github: { + enabled: false, + intervalMinutes: 60, + runOnStartup: false, + sources: [], + }, + }; + const freshSkillSync = { + github: { + enabled: true, + intervalMinutes: 5, + runOnStartup: false, + sources: [ + { + id: 'librechat-skills', + owner: 'LibreChat', + repo: 'skills', + ref: 'main', + paths: ['skills'], + credentialKey: 'github-skills-prod', + }, + ], + }, + }; + mockGetAppConfig.mockResolvedValue({ skillSync: freshSkillSync }); + + const service = require('./sync'); + const { runner } = service.initializeGitHubSkillSync({ skillSync: startupSkillSync }); + const result = await runner.runOnce(); + + expect(result).toBe(freshSkillSync); + expect(mockRunnerDeps.getConfig).toBeDefined(); + expect(mockGetAppConfig).toHaveBeenCalledWith({ baseOnly: true }); + }); + + it('does not return raw unvalidated config.skillSync as sync config', async () => { + const rawSkillSync = { + github: { + enabled: true, + sources: 'not-an-array', + }, + }; + mockGetAppConfig.mockResolvedValue({ config: { skillSync: rawSkillSync } }); + + const service = require('./sync'); + const { runner } = service.initializeGitHubSkillSync({ config: { skillSync: rawSkillSync } }); + const result = await runner.runOnce(); + + expect(result).toBeUndefined(); + expect(mockGetAppConfig).toHaveBeenCalledWith({ baseOnly: true }); + }); + + it('does not let user skill-list sync use server credentials from resolved config', async () => { + const skillSync = { + github: { + enabled: true, + intervalMinutes: 60, + runOnStartup: true, + sources: [ + { + id: 'tenant-skills', + owner: 'LibreChat', + repo: 'skills', + ref: 'main', + paths: ['skills'], + token: '${GITHUB_SKILLS_TOKEN}', + tenantId: 'other-tenant', + }, + ], + }, + }; + + const service = require('./sync'); + const started = await service.maybeRunGitHubSkillSyncForRequest({ + config: { skillSync, config: {} }, + user: { id: 'user-1', tenantId: 'tenant-a' }, + }); + + const requestRunner = mockCreatedRunners[0].runner; + const requestConfig = await mockCreatedRunners[0].deps.getConfig(); + expect(started).toBe(false); + expect(mockCreatedRunners[0].deps.allowServerCredentials).toBe(false); + expect(requestRunner.runOnce).not.toHaveBeenCalled(); + expect(requestConfig.github.runOnStartup).toBe(false); + expect(requestConfig.github.sources[0]).toEqual( + expect.objectContaining({ + id: 'tenant-skills', + tenantId: 'tenant-a', + }), + ); + }); + + it('does not auto-start request-scoped sync when server credentials are unavailable', async () => { + const skillSync = { + github: { + enabled: true, + intervalMinutes: 60, + runOnStartup: true, + sources: [ + { + id: 'tenant-skills', + owner: 'LibreChat', + repo: 'skills', + ref: 'main', + paths: ['skills'], + token: '${GITHUB_SKILLS_TOKEN}', + }, + ], + }, + }; + mockRunnerStatus = { + enabled: true, + intervalMinutes: 60, + runOnStartup: false, + sources: [ + { + provider: 'github', + sourceId: 'tenant-skills', + status: 'idle', + credentialPresent: false, + owner: 'LibreChat', + repo: 'skills', + ref: 'main', + paths: ['skills'], + syncedSkillCount: 0, + syncedFileCount: 0, + deletedSkillCount: 0, + deletedFileCount: 0, + }, + ], + credentials: [], + }; + + const service = require('./sync'); + const started = await service.maybeRunGitHubSkillSyncForRequest({ + config: { skillSync, config: {} }, + user: { id: 'user-1', tenantId: 'tenant-a' }, + }); + + expect(started).toBe(false); + expect(mockCreatedRunners[0].deps.allowServerCredentials).toBe(false); + expect(mockCreatedRunners[0].runner.runOnce).not.toHaveBeenCalled(); + }); + + it('does not start a request-scoped sync for base YAML skillSync config', async () => { + const skillSync = { + github: { + enabled: true, + intervalMinutes: 60, + runOnStartup: true, + sources: [ + { + id: 'base-skills', + owner: 'LibreChat', + repo: 'skills', + ref: 'main', + paths: ['skills'], + token: '${GITHUB_SKILLS_TOKEN}', + }, + ], + }, + }; + mockGetAppConfig.mockResolvedValue({ skillSync }); + + const service = require('./sync'); + const started = await service.maybeRunGitHubSkillSyncForRequest({ + config: { skillSync }, + user: { id: 'user-1', tenantId: 'tenant-a' }, + }); + + expect(started).toBe(false); + expect(mockCreatedRunners).toHaveLength(0); + expect(mockGetAppConfig).toHaveBeenCalledWith({ baseOnly: true }); + }); + + it('creates an admin request runner from resolved skillSync config overrides', async () => { + const skillSync = { + github: { + enabled: true, + intervalMinutes: 60, + runOnStartup: true, + sources: [ + { + id: 'tenant-skills', + owner: 'LibreChat', + repo: 'skills', + ref: 'main', + paths: ['skills'], + token: '${GITHUB_SKILLS_TOKEN}', + tenantId: 'other-tenant', + }, + ], + }, + }; + + const service = require('./sync'); + const runner = service.getGitHubSkillSyncRunnerForRequest({ + config: { skillSync, config: {} }, + user: { id: 'user-1', tenantId: 'tenant-a' }, + skillSyncAllowServerCredentials: true, + }); + const config = await mockCreatedRunners[0].deps.getConfig(); + + expect(runner.runOnce).toBe(mockCreatedRunners[0].runner.runOnce); + expect(runner.getStatus).toBe(mockCreatedRunners[0].runner.getStatus); + expect(mockCreatedRunners[0].deps.allowServerCredentials).toBe(true); + expect(config.github.runOnStartup).toBe(true); + expect(config.github.sources[0]).toEqual( + expect.objectContaining({ id: 'tenant-skills', tenantId: 'tenant-a' }), + ); + }); + + it('preserves base admin runner tenant scope when request config has no nested base copy', async () => { + const skillSync = { + github: { + enabled: true, + intervalMinutes: 60, + runOnStartup: true, + sources: [ + { + id: 'base-skills', + owner: 'LibreChat', + repo: 'skills', + ref: 'main', + paths: ['skills'], + token: '${GITHUB_SKILLS_TOKEN}', + tenantId: 'base-tenant', + }, + ], + }, + }; + + const service = require('./sync'); + service.initializeGitHubSkillSync({ skillSync }); + service.getGitHubSkillSyncRunnerForRequest({ + config: { skillSync }, + user: { id: 'user-1', tenantId: 'tenant-a' }, + skillSyncAllowServerCredentials: true, + }); + const config = await mockCreatedRunners[1].deps.getConfig(); + + expect(config.github.sources[0]).toEqual( + expect.objectContaining({ id: 'base-skills', tenantId: 'base-tenant' }), + ); + }); + + it('does not allow request-built admin override runners to use server credentials by default', async () => { + const skillSync = { + github: { + enabled: true, + intervalMinutes: 60, + runOnStartup: true, + sources: [ + { + id: 'tenant-skills', + owner: 'LibreChat', + repo: 'skills', + ref: 'main', + paths: ['skills'], + token: '${GITHUB_SKILLS_TOKEN}', + }, + ], + }, + }; + + const service = require('./sync'); + service.getGitHubSkillSyncRunnerForRequest({ + config: { skillSync, config: {} }, + user: { id: 'user-1', tenantId: 'tenant-a' }, + }); + + expect(mockCreatedRunners[0].deps.allowServerCredentials).toBe(false); + }); + + it('does not start a request-scoped sync when the configured source is already running', async () => { + const skillSync = { + github: { + enabled: true, + intervalMinutes: 60, + runOnStartup: false, + sources: [ + { + id: 'tenant-skills', + owner: 'LibreChat', + repo: 'skills', + ref: 'main', + paths: ['skills'], + token: '${GITHUB_SKILLS_TOKEN}', + }, + ], + }, + }; + mockRunnerStatus = { + enabled: true, + intervalMinutes: 60, + runOnStartup: false, + sources: [ + { + provider: 'github', + sourceId: 'tenant-skills', + status: 'running', + credentialPresent: true, + owner: 'LibreChat', + repo: 'skills', + ref: 'main', + paths: ['skills'], + startedAt: new Date(), + syncedSkillCount: 0, + syncedFileCount: 0, + deletedSkillCount: 0, + deletedFileCount: 0, + }, + ], + credentials: [], + }; + + const service = require('./sync'); + const started = await service.maybeRunGitHubSkillSyncForRequest({ + config: { skillSync, config: {} }, + user: { id: 'user-1', tenantId: 'tenant-a' }, + }); + + expect(started).toBe(false); + expect(mockCreatedRunners[0].runner.runOnce).not.toHaveBeenCalled(); + }); + + it('retries a request-scoped sync when a running source status is stale', async () => { + const skillSync = { + github: { + enabled: true, + intervalMinutes: 60, + runOnStartup: false, + sources: [ + { + id: 'tenant-skills', + owner: 'LibreChat', + repo: 'skills', + ref: 'main', + paths: ['skills'], + token: '${GITHUB_SKILLS_TOKEN}', + }, + ], + }, + }; + mockRunnerStatus = { + enabled: true, + intervalMinutes: 60, + runOnStartup: false, + sources: [ + { + provider: 'github', + sourceId: 'tenant-skills', + status: 'running', + credentialPresent: true, + owner: 'LibreChat', + repo: 'skills', + ref: 'main', + paths: ['skills'], + startedAt: new Date(Date.now() - 40 * 60 * 1000), + syncedSkillCount: 0, + syncedFileCount: 0, + deletedSkillCount: 0, + deletedFileCount: 0, + }, + ], + credentials: [], + }; + + const service = require('./sync'); + const started = await service.maybeRunGitHubSkillSyncForRequest({ + config: { skillSync, config: {} }, + user: { id: 'user-1', tenantId: 'tenant-a' }, + }); + + expect(started).toBe(true); + expect(mockCreatedRunners[0].runner.runOnce).toHaveBeenCalledTimes(1); + }); + + it('uses the file owner when deleting synced files from storage', async () => { + const deleteFile = jest.fn(async () => undefined); + const ownerId = '507f1f77bcf86cd799439011'; + mockGetAppConfig.mockResolvedValue({ skillSync: undefined, paths: {} }); + mockGetStrategyFunctions.mockReturnValue({ deleteFile }); + + const service = require('./sync'); + service.initializeGitHubSkillSync({ skillSync: undefined }); + await mockRunnerDeps.deleteFile({ + filepath: `/uploads/${ownerId}/file.txt`, + source: 'local', + user: ownerId, + tenantId: 'tenant-a', + }); + + expect(deleteFile).toHaveBeenCalledWith( + expect.objectContaining({ + user: expect.objectContaining({ + id: ownerId, + _id: ownerId, + tenantId: 'tenant-a', + }), + }), + expect.objectContaining({ + user: ownerId, + tenantId: 'tenant-a', + }), + ); + }); + + it('does not force manual sync runs into the system tenant context', async () => { + const { runAsSystem } = require('@librechat/data-schemas'); + mockGetAppConfig.mockResolvedValue({ skillSync: undefined, paths: {} }); + + const service = require('./sync'); + const { runner } = service.initializeGitHubSkillSync({ skillSync: undefined }); + await runner.runOnce(); + + expect(runAsSystem).not.toHaveBeenCalled(); + }); + + it('resolves the access role outside tenant isolation but writes the ACL in context', async () => { + const { runAsSystem } = require('@librechat/data-schemas'); + mockGetAppConfig.mockResolvedValue({ skillSync: undefined, paths: {} }); + mockFindRoleByIdentifier.mockResolvedValue({ + _id: 'role-object-id', + resourceType: 'skill', + permBits: 1, + }); + mockGrantPermission.mockResolvedValue({ _id: 'acl-entry-id' }); + + const service = require('./sync'); + service.initializeGitHubSkillSync({ skillSync: undefined }); + await mockRunnerDeps.grantPermission({ + principalType: 'public', + principalId: null, + resourceType: 'skill', + resourceId: 'skill-id', + accessRoleId: 'skill_viewer', + grantedBy: 'system', + }); + + expect(runAsSystem).toHaveBeenCalledTimes(1); + expect(mockFindRoleByIdentifier).toHaveBeenCalledWith('skill_viewer'); + expect(mockGrantPermission).toHaveBeenCalledWith( + 'public', + null, + 'skill', + 'skill-id', + 1, + 'system', + undefined, + 'role-object-id', + ); + }); + + it('fails the grant when the access role does not exist', async () => { + mockGetAppConfig.mockResolvedValue({ skillSync: undefined, paths: {} }); + mockFindRoleByIdentifier.mockResolvedValue(null); + + const service = require('./sync'); + service.initializeGitHubSkillSync({ skillSync: undefined }); + + await expect( + mockRunnerDeps.grantPermission({ + principalType: 'public', + principalId: null, + resourceType: 'skill', + resourceId: 'skill-id', + accessRoleId: 'skill_viewer', + grantedBy: 'system', + }), + ).rejects.toThrow('Role skill_viewer not found'); + expect(mockGrantPermission).not.toHaveBeenCalled(); + }); +}); diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 67b68bdad16..5624aef65f9 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -1,9 +1,7 @@ -const { logger } = require('@librechat/data-schemas'); +const { logger, redactMessage } = require('@librechat/data-schemas'); const { tool: toolFn, DynamicStructuredTool } = require('@librechat/agents/langchain/tools'); const { sleep, - StepTypes, - GraphEvents, createToolSearch, createBashExecutionTool, Constants: AgentConstants, @@ -18,11 +16,18 @@ const { isActionDomainAllowed, buildWebSearchContext, buildImageToolContext, - buildOAuthToolCallName, buildToolClassification, getMissingCustomUserVars, buildWebSearchDynamicContext, getCodeApiAuthHeaders, + getReplayablePendingMCPOAuthStart, + getMCPServerNamesFromTools, + buildMCPAuthToolCall, + buildMCPAuthStepId, + buildMCPAuthRunStepEvent, + buildMCPAuthRunStepDeltaEvent, + buildMCPAuthRunStepCompletedEvent, + isFileAuthoringToolDefinition, } = require('@librechat/api'); const { Time, @@ -64,9 +69,9 @@ const { manifestToolMap, toolkits } = require('~/app/clients/tools/manifest'); const { createOnSearchResults } = require('~/server/services/Tools/search'); const { reinitMCPServer } = require('~/server/services/Tools/mcp'); const { createMCPPermissionContext, resolveConfigServers } = require('~/server/services/MCP'); +const { getMCPRequestContext } = require('~/server/services/MCPRequestContext'); const { recordUsage } = require('~/server/services/Threads'); const { loadTools } = require('~/app/clients/tools/util'); -const { redactMessage } = require('~/config/parsers'); const { findPluginAuthsByKeys } = require('~/models'); const { getFlowStateManager, getMCPServersRegistry } = require('~/config'); const { getLogStores } = require('~/cache'); @@ -524,6 +529,7 @@ const isBuiltInTool = (toolName) => * @returns {Promise<{ * toolDefinitions?: import('@librechat/api').LCTool[]; * toolRegistry?: Map; + * mcpAvailableTools?: Record; * userMCPAuthMap?: Record>; * hasDeferredTools?: boolean; * }>} @@ -595,40 +601,44 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to const flowManager = getFlowStateManager(flowsCache); const configServers = await resolveConfigServers(req); const pendingOAuthServers = new Set(); + const pendingOAuthStarts = new Map(); + const emittedOAuthStarts = new Map(); + const oauthToolCallIds = new Map(); + const oauthStepIndexes = new Map(); + /** @type {Record} */ + const mcpAvailableTools = {}; + const requestScopedConnections = getMCPRequestContext(req, res); + const rememberMCPAvailableTools = (serverName, availableTools) => { + if (!availableTools || Object.keys(availableTools).length === 0) { + return; + } + mcpAvailableTools[serverName] = availableTools; + }; - const createOAuthEmitter = (serverName) => { - return async (authURL) => { - const flowId = `${req.user.id}:${serverName}:${Date.now()}`; - const stepId = 'step_oauth_login_' + serverName; - const toolCall = { + const createOAuthEmitter = (serverName, index) => { + return async (authURL, options) => { + if (emittedOAuthStarts.get(serverName) === authURL) { + return; + } + emittedOAuthStarts.set(serverName, authURL); + + const flowId = + oauthToolCallIds.get(serverName) ?? `${req.user.id}:${serverName}:${Date.now()}`; + const stepId = buildMCPAuthStepId(serverName); + oauthToolCallIds.set(serverName, flowId); + oauthStepIndexes.set(serverName, index); + const toolCall = buildMCPAuthToolCall({ id: flowId, - name: buildOAuthToolCallName(serverName), - type: 'tool_call_chunk', - }; - - const runStepData = { - runId: Constants.USE_PRELIM_RESPONSE_MESSAGE_ID, - id: stepId, - type: StepTypes.TOOL_CALLS, - index: 0, - stepDetails: { - type: StepTypes.TOOL_CALLS, - tool_calls: [toolCall], - }, - }; - - const runStepDeltaData = { - id: stepId, - delta: { - type: StepTypes.TOOL_CALLS, - tool_calls: [{ ...toolCall, args: '' }], - auth: authURL, - expires_at: Date.now() + Time.TWO_MINUTES, - }, - }; + serverName, + }); - const runStepEvent = { event: GraphEvents.ON_RUN_STEP, data: runStepData }; - const runStepDeltaEvent = { event: GraphEvents.ON_RUN_STEP_DELTA, data: runStepDeltaData }; + const runStepEvent = buildMCPAuthRunStepEvent({ stepId, toolCall, index }); + const runStepDeltaEvent = buildMCPAuthRunStepDeltaEvent({ + authURL, + stepId, + toolCall, + options, + }); if (streamId) { await GenerationJobManager.emitChunk(streamId, runStepEvent); @@ -644,7 +654,73 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to }; }; + const createOAuthEndEmitter = (serverName) => { + return async () => { + const stepId = buildMCPAuthStepId(serverName); + const toolCall = buildMCPAuthToolCall({ + id: oauthToolCallIds.get(serverName), + args: '', + output: 'OAuth authentication completed', + serverName, + type: 'tool_call', + }); + const runStepCompletedEvent = buildMCPAuthRunStepCompletedEvent({ + stepId, + toolCall, + index: oauthStepIndexes.get(serverName) ?? 0, + }); + + if (streamId) { + await GenerationJobManager.emitChunk(streamId, runStepCompletedEvent); + } else if (res && !res.writableEnded) { + sendEvent(res, runStepCompletedEvent); + } else { + logger.warn( + `[Tool Definitions] Cannot emit OAuth completion for ${serverName}: no streamId and res not available`, + ); + } + }; + }; + + const getPendingOAuthStartForEmit = async (serverName) => { + const cachedOAuthStart = pendingOAuthStarts.get(serverName); + if (cachedOAuthStart?.options?.expiresAt != null) { + return cachedOAuthStart; + } + + const pendingOAuthStart = await getReplayablePendingMCPOAuthStart({ + flowManager, + userId: req.user.id, + serverName, + }); + if (!pendingOAuthStart) { + return cachedOAuthStart; + } + + if (!cachedOAuthStart || pendingOAuthStart.authURL === cachedOAuthStart.authURL) { + pendingOAuthStarts.set(serverName, pendingOAuthStart); + return pendingOAuthStart; + } + + return cachedOAuthStart; + }; + const getOrFetchMCPServerTools = async (userId, serverName) => { + const addPendingOAuthServer = async () => { + const pendingOAuthStart = await getReplayablePendingMCPOAuthStart({ + flowManager, + userId, + serverName, + }); + if (!pendingOAuthStart) { + return false; + } + + pendingOAuthServers.add(serverName); + pendingOAuthStarts.set(serverName, pendingOAuthStart); + return true; + }; + let serverConfig; try { serverConfig = @@ -677,13 +753,26 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to return null; } - const cached = await getMCPServerTools(userId, serverName); + if (mcpAvailableTools[serverName]) { + return mcpAvailableTools[serverName]; + } + + const cached = await getMCPServerTools(userId, serverName, serverConfig); if (cached) { + rememberMCPAvailableTools(serverName, cached); + await addPendingOAuthServer(); return cached; } - const oauthStart = async () => { + if (await addPendingOAuthServer()) { + return null; + } + + const oauthStart = async (authURL, options) => { pendingOAuthServers.add(serverName); + if (typeof authURL === 'string' && authURL.length > 0) { + pendingOAuthStarts.set(serverName, { authURL, options }); + } }; const result = await reinitMCPServer({ @@ -693,8 +782,11 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to serverName, configServers, userMCPAuthMap, + requestBody: req.body, + requestScopedConnections, }); + rememberMCPAvailableTools(serverName, result?.availableTools); return result?.availableTools || null; }; @@ -766,6 +858,7 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to deferredToolsEnabled, programmaticToolsEnabled, codeExecutionEnabled, + provider: agent.provider, }, { isBuiltInTool, @@ -774,26 +867,51 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to }, ); + for (const serverName of getMCPServerNamesFromTools(filteredTools)) { + if (pendingOAuthServers.has(serverName)) { + continue; + } + + const pendingOAuthStart = await getReplayablePendingMCPOAuthStart({ + flowManager, + userId: req.user.id, + serverName, + }); + if (pendingOAuthStart) { + pendingOAuthServers.add(serverName); + pendingOAuthStarts.set(serverName, pendingOAuthStart); + } + } + if (pendingOAuthServers.size > 0 && (res || streamId)) { const serverNames = Array.from(pendingOAuthServers); logger.info( `[Tool Definitions] OAuth required for ${serverNames.length} server(s): ${serverNames.join(', ')}. Emitting events and waiting.`, ); - const oauthWaitPromises = serverNames.map(async (serverName) => { + const oauthWaitPromises = serverNames.map(async (serverName, index) => { try { + const pendingOAuthStart = await getPendingOAuthStartForEmit(serverName); + const oauthStart = createOAuthEmitter(serverName, index); + if (pendingOAuthStart) { + await oauthStart(pendingOAuthStart.authURL, pendingOAuthStart.options); + } + const result = await reinitMCPServer({ user: req.user, serverName, configServers, userMCPAuthMap, flowManager, + requestBody: req.body, returnOnOAuth: false, - oauthStart: createOAuthEmitter(serverName), + oauthStart, + oauthEnd: createOAuthEndEmitter(serverName), connectionTimeout: Time.TWO_MINUTES, }); if (result?.availableTools) { + rememberMCPAvailableTools(serverName, result.availableTools); logger.info(`[Tool Definitions] OAuth completed for ${serverName}, tools available`); return { serverName, success: true }; } @@ -822,6 +940,7 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to deferredToolsEnabled, programmaticToolsEnabled, codeExecutionEnabled, + provider: agent.provider, }, { isBuiltInTool, @@ -921,6 +1040,8 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to return { toolRegistry, + mcpAvailableTools, + requestScopedConnections, userMCPAuthMap, toolContextMap, dynamicToolContextMap, @@ -1047,6 +1168,7 @@ async function loadAgentTools({ uploadImageBuffer, returnMetadata: true, mcpPermissionContext, + requestScopedConnections: getMCPRequestContext(req, res), [Tools.web_search]: webSearchCallbacks, }, webSearch: appConfig.webSearch, @@ -1122,6 +1244,7 @@ async function loadAgentTools({ if (!hasActionTools) { return { toolRegistry, + requestScopedConnections: getMCPRequestContext(req, res), userMCPAuthMap, toolContextMap, dynamicToolContextMap, @@ -1140,6 +1263,7 @@ async function loadAgentTools({ } return { toolRegistry, + requestScopedConnections: getMCPRequestContext(req, res), userMCPAuthMap, toolContextMap, dynamicToolContextMap, @@ -1268,6 +1392,7 @@ async function loadAgentTools({ return { toolRegistry, + requestScopedConnections: getMCPRequestContext(req, res), toolContextMap, dynamicToolContextMap, userMCPAuthMap, @@ -1293,6 +1418,8 @@ async function loadAgentTools({ * @param {Object} params.agent - The agent object * @param {string[]} params.toolNames - Names of tools to load * @param {Map} [params.toolRegistry] - Tool registry + * @param {Record} [params.mcpAvailableTools] - Run-scoped MCP tool definitions + * @param {import('@librechat/api').RequestScopedMCPConnectionStore} [params.requestScopedConnections] - Run-scoped MCP connections * @param {Record>} [params.userMCPAuthMap] - User MCP auth map * @param {Object} [params.tool_resources] - Tool resources * @param {string|null} [params.streamId] - Stream ID for web search callbacks @@ -1306,6 +1433,8 @@ async function loadToolsForExecution({ agent, toolNames, toolRegistry, + mcpAvailableTools, + requestScopedConnections, userMCPAuthMap, tool_resources, streamId = null, @@ -1313,7 +1442,8 @@ async function loadToolsForExecution({ }) { const appConfig = req.config; const allLoadedTools = []; - const configurable = { userMCPAuthMap }; + const mcpRequestScopedConnections = requestScopedConnections ?? getMCPRequestContext(req, res); + const configurable = { userMCPAuthMap, requestScopedConnections: mcpRequestScopedConnections }; const isToolSearch = toolNames.includes(AgentConstants.TOOL_SEARCH); const ptcToolNames = [ @@ -1380,6 +1510,13 @@ async function loadToolsForExecution({ } } + const fileAuthoringToolNames = new Set( + toolRegistry + ? Array.from(toolRegistry.values()) + .filter((definition) => isFileAuthoringToolDefinition(definition)) + .map((definition) => definition.name) + : [], + ); const specialToolNames = new Set([ AgentConstants.TOOL_SEARCH, AgentConstants.PROGRAMMATIC_TOOL_CALLING, @@ -1387,6 +1524,7 @@ async function loadToolsForExecution({ AgentConstants.BASH_TOOL, AgentConstants.SKILL_TOOL, AgentConstants.READ_FILE, + ...fileAuthoringToolNames, ]); let ptcOrchestratedToolNames = []; @@ -1425,6 +1563,8 @@ async function loadToolsForExecution({ processFileURL, uploadImageBuffer, returnMetadata: true, + mcpAvailableTools, + requestScopedConnections: mcpRequestScopedConnections, [Tools.web_search]: webSearchCallbacks, }, webSearch: appConfig?.webSearch, diff --git a/api/server/services/Tools/mcp.js b/api/server/services/Tools/mcp.js index e13fa9989bc..c0e32ccd8f6 100644 --- a/api/server/services/Tools/mcp.js +++ b/api/server/services/Tools/mcp.js @@ -1,8 +1,9 @@ const { logger } = require('@librechat/data-schemas'); -const { getMissingCustomUserVars } = require('@librechat/api'); +const { getMissingCustomUserVars, requiresEphemeralUserConnection } = require('@librechat/api'); const { CacheKeys, Constants } = require('librechat-data-provider'); const { getMCPManager, getMCPServersRegistry, getFlowStateManager } = require('~/config'); const { findToken, createToken, updateToken, deleteTokens } = require('~/models'); +const { getGraphApiToken } = require('~/server/services/GraphTokenService'); const { exchangeOboToken } = require('~/server/services/OboTokenService'); const { createOboTrustChecker } = require('~/server/services/OboPolicyService'); const { updateMCPServerTools } = require('~/server/services/Config'); @@ -20,7 +21,10 @@ const { getLogStores } = require('~/cache'); * @param {boolean} [params.forceNew] * @param {number} [params.connectionTimeout] * @param {FlowStateManager} [params.flowManager] - * @param {(authURL: string) => Promise} [params.oauthStart] + * @param {(authURL: string, options?: { expiresAt?: number }) => Promise} [params.oauthStart] + * @param {() => Promise} [params.oauthEnd] + * @param {import('@librechat/api').RequestBody} [params.requestBody] + * @param {import('@librechat/api').RequestScopedMCPConnectionStore} [params.requestScopedConnections] * @param {Record>} [params.userMCPAuthMap] */ async function reinitMCPServer({ @@ -35,20 +39,26 @@ async function reinitMCPServer({ oauthStart: _oauthStart, flowManager: _flowManager, serverConfig: providedConfig, + requestBody, + requestScopedConnections, + oauthEnd, }) { /** @type {MCPConnection | null} */ let connection = null; + let serverConfig = providedConfig; /** @type {LCAvailableTools | null} */ let availableTools = null; /** @type {ReturnType | null} */ let tools = null; let oauthRequired = false; let oauthUrl = null; + let ephemeralServer = false; try { const registry = getMCPServersRegistry(); - const serverConfig = - providedConfig ?? (await registry.getServerConfig(serverName, user?.id, configServers)); + serverConfig = + serverConfig ?? (await registry.getServerConfig(serverName, user?.id, configServers)); + ephemeralServer = serverConfig ? requiresEphemeralUserConnection(serverConfig) : false; if (serverConfig?.inspectionFailed) { if (serverConfig.source === 'config') { logger.info( @@ -63,28 +73,29 @@ async function reinitMCPServer({ oauthUrl: null, tools: null, }; - } - logger.info( - `[MCP Reinitialize] Server ${serverName} had failed inspection, attempting reinspection`, - ); - try { - const storageLocation = serverConfig.source === 'user' ? 'DB' : 'CACHE'; - await registry.reinspectServer(serverName, storageLocation, user?.id); - logger.info(`[MCP Reinitialize] Reinspection succeeded for server: ${serverName}`); - } catch (reinspectError) { - logger.error( - `[MCP Reinitialize] Reinspection failed for server ${serverName}:`, - reinspectError, + } else { + logger.info( + `[MCP Reinitialize] Server ${serverName} had failed inspection, attempting reinspection`, ); - return { - availableTools: null, - success: false, - message: `MCP server '${serverName}' is still unreachable`, - oauthRequired: false, - serverName, - oauthUrl: null, - tools: null, - }; + try { + const storageLocation = serverConfig.source === 'user' ? 'DB' : 'CACHE'; + await registry.reinspectServer(serverName, storageLocation, user?.id); + logger.info(`[MCP Reinitialize] Reinspection succeeded for server: ${serverName}`); + } catch (reinspectError) { + logger.error( + `[MCP Reinitialize] Reinspection failed for server ${serverName}:`, + reinspectError, + ); + return { + availableTools: null, + success: false, + message: `MCP server '${serverName}' is still unreachable`, + oauthRequired: false, + serverName, + oauthUrl: null, + tools: null, + }; + } } } @@ -132,9 +143,13 @@ async function reinitMCPServer({ flowManager, tokenMethods, returnOnOAuth, + oauthEnd, customUserVars, + requestBody, + requestScopedConnections, connectionTimeout, serverConfig, + graphTokenResolver: getGraphApiToken, oboTokenResolver: exchangeOboToken, oboTrustChecker: createOboTrustChecker(), }); @@ -168,8 +183,10 @@ async function reinitMCPServer({ tokenMethods, oauthStart, customUserVars, + requestBody, connectionTimeout, configServers, + graphTokenResolver: getGraphApiToken, oboTokenResolver: exchangeOboToken, oboTrustChecker: createOboTrustChecker(), }); @@ -202,6 +219,7 @@ async function reinitMCPServer({ userId: user.id, serverName, tools, + serverConfig, }); } @@ -249,6 +267,17 @@ async function reinitMCPServer({ '[MCP Reinitialize] Error loading MCP Tools, servers may still be initializing:', error, ); + } finally { + if (connection && ephemeralServer && !requestScopedConnections) { + try { + await connection.disconnect(); + } catch (error) { + logger.warn( + `[MCP Reinitialize] Failed to disconnect ephemeral server ${serverName}`, + error, + ); + } + } } } diff --git a/api/server/services/Tools/mcp.spec.js b/api/server/services/Tools/mcp.spec.js index bce51aa29a9..ab8cd3f2811 100644 --- a/api/server/services/Tools/mcp.spec.js +++ b/api/server/services/Tools/mcp.spec.js @@ -1,9 +1,15 @@ const { Constants } = require('librechat-data-provider'); const mockGetConnection = jest.fn(); +const mockDiscoverServerTools = jest.fn(); +const mockGetGraphApiToken = jest.fn(); +const mockUpdateMCPServerTools = jest.fn(); jest.mock('~/config', () => ({ - getMCPManager: jest.fn(() => ({ getConnection: mockGetConnection })), + getMCPManager: jest.fn(() => ({ + getConnection: mockGetConnection, + discoverServerTools: mockDiscoverServerTools, + })), getMCPServersRegistry: jest.fn(() => ({ getServerConfig: jest.fn() })), getFlowStateManager: jest.fn(() => ({})), })); @@ -14,7 +20,10 @@ jest.mock('~/models', () => ({ deleteTokens: jest.fn(), })); jest.mock('~/server/services/Config', () => ({ - updateMCPServerTools: jest.fn(), + updateMCPServerTools: mockUpdateMCPServerTools, +})); +jest.mock('~/server/services/GraphTokenService', () => ({ + getGraphApiToken: mockGetGraphApiToken, })); jest.mock('~/cache', () => ({ getLogStores: jest.fn(() => ({})), @@ -35,6 +44,7 @@ describe('reinitMCPServer — customUserVars gating (issue #10969)', () => { beforeEach(() => { jest.clearAllMocks(); + mockUpdateMCPServerTools.mockResolvedValue({}); }); it('does not connect and exposes no tools when a required customUserVar is unset', async () => { @@ -90,6 +100,77 @@ describe('reinitMCPServer — customUserVars gating (issue #10969)', () => { ); }); + it('passes request body and Graph resolver into connection creation', async () => { + mockGetConnection.mockResolvedValue({ fetchTools: jest.fn().mockResolvedValue([]) }); + const requestBody = { conversationId: 'conv-123', messageId: 'msg-123' }; + + await reinitMCPServer({ + user, + serverName, + serverConfig: { type: 'streamable-http', url: 'https://thingy.example.com/mcp' }, + requestBody, + userMCPAuthMap: undefined, + }); + + expect(mockGetConnection).toHaveBeenCalledWith( + expect.objectContaining({ + requestBody, + graphTokenResolver: mockGetGraphApiToken, + }), + ); + }); + + it('passes request body and Graph resolver into OAuth discovery fallback', async () => { + mockGetConnection.mockRejectedValue(new Error('OAuth authentication required')); + mockDiscoverServerTools.mockResolvedValue({ tools: [], oauthRequired: true, oauthUrl: null }); + const requestBody = { conversationId: 'conv-456', messageId: 'msg-456' }; + + await reinitMCPServer({ + user, + serverName, + serverConfig: { type: 'streamable-http', url: 'https://thingy.example.com/mcp' }, + requestBody, + userMCPAuthMap: undefined, + }); + + expect(mockDiscoverServerTools).toHaveBeenCalledWith( + expect.objectContaining({ + requestBody, + graphTokenResolver: mockGetGraphApiToken, + }), + ); + }); + + it('disconnects ephemeral BODY-scoped connections after loading tools', async () => { + const disconnect = jest.fn().mockResolvedValue(undefined); + const tools = [{ name: 'search', inputSchema: { type: 'object', properties: {} } }]; + const serverConfig = { + type: 'streamable-http', + url: 'https://thingy.example.com/messages/{{LIBRECHAT_BODY_MESSAGEID}}/mcp', + source: 'yaml', + }; + mockGetConnection.mockResolvedValue({ + disconnect, + fetchTools: jest.fn().mockResolvedValue(tools), + }); + + await reinitMCPServer({ + user, + serverName, + serverConfig, + requestBody: { messageId: 'msg-789' }, + userMCPAuthMap: undefined, + }); + + expect(disconnect).toHaveBeenCalledTimes(1); + expect(mockUpdateMCPServerTools).toHaveBeenCalledWith( + expect.objectContaining({ + tools, + serverConfig, + }), + ); + }); + it('proceeds to connect when the server declares no customUserVars', async () => { mockGetConnection.mockResolvedValue({ fetchTools: jest.fn().mockResolvedValue([]) }); diff --git a/api/server/services/__tests__/ToolService.spec.js b/api/server/services/__tests__/ToolService.spec.js index 3fc9e58eb69..0d69fb92ef2 100644 --- a/api/server/services/__tests__/ToolService.spec.js +++ b/api/server/services/__tests__/ToolService.spec.js @@ -11,6 +11,8 @@ const { const mockGetEndpointsConfig = jest.fn(); const mockGetMCPServerTools = jest.fn(); const mockGetCachedTools = jest.fn(); +const mockSendEvent = jest.fn(); +const mockEmitChunk = jest.fn(); jest.mock('~/server/services/Config', () => ({ getEndpointsConfig: (...args) => mockGetEndpointsConfig(...args), getMCPServerTools: (...args) => mockGetMCPServerTools(...args), @@ -23,6 +25,10 @@ jest.mock('@librechat/api', () => ({ ...jest.requireActual('@librechat/api'), loadToolDefinitions: (...args) => mockLoadToolDefinitions(...args), getUserMCPAuthMap: (...args) => mockGetUserMCPAuthMap(...args), + sendEvent: (...args) => mockSendEvent(...args), + GenerationJobManager: { + emitChunk: (...args) => mockEmitChunk(...args), + }, })); const mockLoadToolsUtil = jest.fn(); @@ -36,6 +42,7 @@ const mockLegacyDomainEncode = jest.fn(); const mockDecryptMetadata = jest.fn(); const mockCreateActionTool = jest.fn(); const mockGetServerConfig = jest.fn(); +const mockFlowManager = { getFlowState: jest.fn() }; const mockResolveConfigServers = jest.fn(); const mockUserCanUseMCPServers = jest.fn().mockResolvedValue(true); jest.mock('~/server/services/Tools/credentials', () => ({ @@ -71,7 +78,7 @@ jest.mock('~/models', () => ({ findPluginAuthsByKeys: jest.fn(), })); jest.mock('~/config', () => ({ - getFlowStateManager: jest.fn(() => ({})), + getFlowStateManager: jest.fn(() => mockFlowManager), getMCPServersRegistry: jest.fn(() => ({ getServerConfig: (...args) => mockGetServerConfig(...args), })), @@ -93,6 +100,8 @@ const { processRequiredActions, resolveAgentCapabilities, } = require('../ToolService'); +const { reinitMCPServer } = require('~/server/services/Tools/mcp'); +const { PENDING_STALE_MS } = require('@librechat/api'); function createMockReq(capabilities) { return { @@ -127,6 +136,7 @@ describe('ToolService - Action Capability Gating', () => { mockGetCachedTools.mockResolvedValue(null); mockGetUserMCPAuthMap.mockResolvedValue({}); mockGetServerConfig.mockResolvedValue(undefined); + mockFlowManager.getFlowState.mockResolvedValue(undefined); mockResolveConfigServers.mockResolvedValue({}); }); @@ -297,6 +307,85 @@ describe('ToolService - Action Capability Gating', () => { expect(result.actionsEnabled).toBe(false); }); + it('emits separate MCP OAuth login steps and completion events for multiple pending servers', async () => { + const req = createMockReq([AgentCapabilities.tools]); + const res = { writableEnded: false }; + const servers = ['ELI', 'Vespa']; + mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig([AgentCapabilities.tools])); + mockResolveConfigServers.mockResolvedValue( + Object.fromEntries( + servers.map((serverName) => [ + serverName, + { + type: 'streamable-http', + url: `https://mcp.example.com/${serverName}`, + requiresOAuth: true, + }, + ]), + ), + ); + + mockLoadToolDefinitions + .mockImplementationOnce(async (_args, deps) => { + await deps.getOrFetchMCPServerTools(req.user.id, servers[0]); + await deps.getOrFetchMCPServerTools(req.user.id, servers[1]); + return { + toolDefinitions: [], + toolRegistry: new Map(), + hasDeferredTools: false, + }; + }) + .mockResolvedValue({ + toolDefinitions: [], + toolRegistry: new Map(), + hasDeferredTools: false, + }); + + reinitMCPServer.mockImplementation( + async ({ serverName, returnOnOAuth, oauthStart, oauthEnd }) => { + if (returnOnOAuth === false) { + await oauthStart(`https://auth.example.com/${serverName}`); + await oauthEnd(); + return { availableTools: { [`tool_${serverName}`]: {} } }; + } + + await oauthStart(`https://auth.example.com/${serverName}`); + return { availableTools: null }; + }, + ); + + await loadAgentTools({ + req, + res, + agent: { + id: 'agent_123', + tools: servers.map((server) => `search${Constants.mcp_delimiter}${server}`), + }, + definitionsOnly: true, + }); + + const runStepEvents = mockSendEvent.mock.calls + .map(([, event]) => event) + .filter((event) => event.data?.stepDetails?.type === 'tool_calls'); + const deltaEvents = mockSendEvent.mock.calls + .map(([, event]) => event) + .filter((event) => event.data?.delta?.type === 'tool_calls'); + const authDeltaEvents = deltaEvents.filter((event) => event.data.delta.auth); + const completionEvents = mockSendEvent.mock.calls + .map(([, event]) => event) + .filter((event) => event.data?.result?.tool_call?.name?.startsWith('oauth')); + + expect(runStepEvents.map((event) => event.data.index)).toEqual([0, 1]); + expect(authDeltaEvents.map((event) => event.data.id)).toEqual([ + 'step_oauth_login_ELI', + 'step_oauth_login_Vespa', + ]); + expect(completionEvents.map((event) => event.data.result.id)).toEqual([ + 'step_oauth_login_ELI', + 'step_oauth_login_Vespa', + ]); + }); + it('should not expose cached MCP tool definitions when the registry lookup fails', async () => { const serverName = 'private-server'; const mcpTool = `search${Constants.mcp_delimiter}${serverName}`; @@ -335,6 +424,454 @@ describe('ToolService - Action Capability Gating', () => { expect(mockGetMCPServerTools).not.toHaveBeenCalled(); }); + it('should re-emit pending MCP OAuth prompts when cached tool definitions exist', async () => { + const serverName = 'Google-Workspace'; + const authorizationUrl = 'https://auth.example.com/Google-Workspace'; + const mcpTool = `search${Constants.mcp_delimiter}${serverName}`; + const capabilities = [AgentCapabilities.tools]; + const req = createMockReq(capabilities); + const res = { writableEnded: false }; + mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities)); + mockGetServerConfig.mockResolvedValue({ + type: 'streamable-http', + url: 'https://demo.librechat.ai/mcp', + requiresOAuth: true, + }); + mockGetMCPServerTools.mockResolvedValue({ + [mcpTool]: { + function: { + name: mcpTool, + description: 'Cached search', + parameters: {}, + }, + }, + }); + mockFlowManager.getFlowState.mockResolvedValue({ + status: 'PENDING', + createdAt: Date.now(), + metadata: { authorizationUrl }, + }); + mockLoadToolDefinitions.mockImplementation(async (params, deps) => { + const serverTools = await deps.getOrFetchMCPServerTools(params.userId, serverName); + return { + toolDefinitions: serverTools ? Object.keys(serverTools) : [], + toolRegistry: new Map(), + hasDeferredTools: false, + }; + }); + reinitMCPServer.mockImplementation(async ({ oauthStart }) => { + await oauthStart(authorizationUrl); + return { availableTools: { [mcpTool]: {} } }; + }); + + const result = await loadAgentTools({ + req, + res, + agent: { id: 'agent_123', tools: [mcpTool] }, + definitionsOnly: true, + }); + + expect(result.toolDefinitions).toEqual([mcpTool]); + expect(mockGetMCPServerTools).toHaveBeenCalledWith( + req.user.id, + serverName, + expect.objectContaining({ requiresOAuth: true }), + ); + expect(reinitMCPServer).toHaveBeenCalledWith( + expect.objectContaining({ + serverName, + returnOnOAuth: false, + oauthStart: expect.any(Function), + }), + ); + expect(mockSendEvent).toHaveBeenCalledWith( + res, + expect.objectContaining({ + event: 'on_run_step', + data: expect.objectContaining({ + id: `step_oauth_login_${serverName}`, + }), + }), + ); + expect(mockSendEvent).toHaveBeenCalledWith( + res, + expect.objectContaining({ + event: 'on_run_step_delta', + data: expect.objectContaining({ + id: `step_oauth_login_${serverName}`, + delta: expect.objectContaining({ + auth: authorizationUrl, + }), + }), + }), + ); + }); + + it('should not join in-flight MCP initialization before replaying pending OAuth prompts', async () => { + const serverName = 'Google-Workspace'; + const authorizationUrl = 'https://auth.example.com/Google-Workspace'; + const mcpTool = `${Constants.mcp_all}${Constants.mcp_delimiter}${serverName}`; + const capabilities = [AgentCapabilities.tools]; + const req = createMockReq(capabilities); + const res = { writableEnded: false }; + mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities)); + mockGetServerConfig.mockResolvedValue({ + type: 'streamable-http', + url: 'https://demo.librechat.ai/mcp', + requiresOAuth: true, + }); + mockGetMCPServerTools.mockResolvedValue(null); + mockFlowManager.getFlowState.mockResolvedValue({ + status: 'PENDING', + createdAt: Date.now(), + metadata: { authorizationUrl }, + }); + mockLoadToolDefinitions.mockImplementation(async (params, deps) => { + await deps.getOrFetchMCPServerTools(params.userId, serverName); + return { + toolDefinitions: [], + toolRegistry: new Map(), + hasDeferredTools: false, + }; + }); + reinitMCPServer.mockImplementation(async ({ oauthStart }) => { + await oauthStart(authorizationUrl); + return { availableTools: null }; + }); + + await loadAgentTools({ + req, + res, + agent: { id: 'agent_123', tools: [mcpTool] }, + definitionsOnly: true, + }); + + expect(mockGetMCPServerTools).toHaveBeenCalledWith( + req.user.id, + serverName, + expect.objectContaining({ requiresOAuth: true }), + ); + expect(reinitMCPServer).toHaveBeenCalledTimes(1); + expect(reinitMCPServer).toHaveBeenCalledWith( + expect.objectContaining({ + serverName, + returnOnOAuth: false, + oauthStart: expect.any(Function), + }), + ); + expect(mockSendEvent).toHaveBeenCalledWith( + res, + expect.objectContaining({ + event: 'on_run_step_delta', + data: expect.objectContaining({ + id: `step_oauth_login_${serverName}`, + delta: expect.objectContaining({ + auth: authorizationUrl, + }), + }), + }), + ); + }); + + it('should re-emit pending MCP OAuth prompts when selected MCP tools are already concrete', async () => { + const serverName = `Google${Constants.mcp_delimiter}Workspace`; + const authorizationUrl = 'https://auth.example.com/Google-Workspace'; + const mcpTool = `search${Constants.mcp_delimiter}${serverName}`; + const capabilities = [AgentCapabilities.tools]; + const req = createMockReq(capabilities); + const res = { writableEnded: false }; + mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities)); + mockFlowManager.getFlowState.mockResolvedValue({ + status: 'PENDING', + createdAt: Date.now(), + metadata: { authorizationUrl }, + }); + mockLoadToolDefinitions.mockResolvedValue({ + toolDefinitions: [mcpTool], + toolRegistry: new Map(), + hasDeferredTools: false, + }); + reinitMCPServer.mockImplementation(async ({ oauthStart }) => { + await oauthStart(authorizationUrl); + return { availableTools: { [mcpTool]: {} } }; + }); + + const result = await loadAgentTools({ + req, + res, + agent: { id: 'agent_123', tools: [mcpTool] }, + definitionsOnly: true, + }); + + expect(result.toolDefinitions).toEqual([mcpTool]); + expect(mockGetMCPServerTools).not.toHaveBeenCalled(); + expect(reinitMCPServer).toHaveBeenCalledWith( + expect.objectContaining({ + serverName, + returnOnOAuth: false, + oauthStart: expect.any(Function), + }), + ); + expect(mockSendEvent).toHaveBeenCalledWith( + res, + expect.objectContaining({ + event: 'on_run_step_delta', + data: expect.objectContaining({ + id: `step_oauth_login_${serverName}`, + delta: expect.objectContaining({ + auth: authorizationUrl, + }), + }), + }), + ); + }); + + it('should emit stored pending MCP OAuth prompts before waiting on a silent in-flight join', async () => { + const serverName = 'Google-Workspace'; + const authorizationUrl = 'https://auth.example.com/Google-Workspace'; + const mcpTool = `search${Constants.mcp_delimiter}${serverName}`; + const capabilities = [AgentCapabilities.tools]; + const req = createMockReq(capabilities); + const res = { writableEnded: false }; + mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities)); + mockFlowManager.getFlowState.mockResolvedValue({ + status: 'PENDING', + createdAt: Date.now(), + metadata: { authorizationUrl }, + }); + mockLoadToolDefinitions.mockResolvedValue({ + toolDefinitions: [mcpTool], + toolRegistry: new Map(), + hasDeferredTools: false, + }); + reinitMCPServer.mockResolvedValue({ availableTools: null }); + + const result = await loadAgentTools({ + req, + res, + agent: { id: 'agent_123', tools: [mcpTool] }, + definitionsOnly: true, + }); + + expect(result.toolDefinitions).toEqual([mcpTool]); + expect(reinitMCPServer).toHaveBeenCalledWith( + expect.objectContaining({ + serverName, + returnOnOAuth: false, + oauthStart: expect.any(Function), + }), + ); + expect(mockSendEvent).toHaveBeenCalledWith( + res, + expect.objectContaining({ + event: 'on_run_step_delta', + data: expect.objectContaining({ + id: `step_oauth_login_${serverName}`, + delta: expect.objectContaining({ + auth: authorizationUrl, + }), + }), + }), + ); + }); + + it('should preserve OAuth URLs emitted while discovering MCP tools before a silent wait join', async () => { + const serverName = 'Google-Workspace'; + const authorizationUrl = 'https://auth.example.com/Google-Workspace'; + const mcpTool = `search${Constants.mcp_delimiter}${serverName}`; + const capabilities = [AgentCapabilities.tools]; + const req = createMockReq(capabilities); + const res = { writableEnded: false }; + mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities)); + mockGetServerConfig.mockResolvedValue({ + type: 'streamable-http', + url: 'https://demo.librechat.ai/mcp', + requiresOAuth: true, + }); + mockGetMCPServerTools.mockResolvedValue(null); + mockFlowManager.getFlowState.mockResolvedValue(null); + mockLoadToolDefinitions.mockImplementation(async (params, deps) => { + await deps.getOrFetchMCPServerTools(params.userId, serverName); + return { + toolDefinitions: [], + toolRegistry: new Map(), + hasDeferredTools: false, + }; + }); + reinitMCPServer + .mockImplementationOnce(async ({ oauthStart }) => { + await oauthStart(authorizationUrl, { expiresAt: Date.now() + 60_000 }); + return { availableTools: null }; + }) + .mockResolvedValue({ availableTools: null }); + + await loadAgentTools({ + req, + res, + agent: { id: 'agent_123', tools: [mcpTool] }, + definitionsOnly: true, + }); + + expect(reinitMCPServer).toHaveBeenCalledTimes(2); + expect(mockSendEvent).toHaveBeenCalledWith( + res, + expect.objectContaining({ + event: 'on_run_step_delta', + data: expect.objectContaining({ + id: `step_oauth_login_${serverName}`, + delta: expect.objectContaining({ + auth: authorizationUrl, + }), + }), + }), + ); + }); + + it('should pass request body context into MCP tool definition reinitialization', async () => { + const serverName = 'Body-Scoped'; + const mcpTool = `search${Constants.mcp_delimiter}${serverName}`; + const capabilities = [AgentCapabilities.tools]; + const req = createMockReq(capabilities); + req.body = { conversationId: 'conv-123', messageId: 'msg-123' }; + + mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities)); + mockGetServerConfig.mockResolvedValue({ + type: 'streamable-http', + url: 'https://demo.librechat.ai/messages/{{LIBRECHAT_BODY_MESSAGEID}}/mcp', + source: 'yaml', + }); + mockGetMCPServerTools.mockResolvedValue(null); + mockFlowManager.getFlowState.mockResolvedValue(null); + mockLoadToolDefinitions.mockImplementation(async (params, deps) => { + await deps.getOrFetchMCPServerTools(params.userId, serverName); + return { + toolDefinitions: [], + toolRegistry: new Map(), + hasDeferredTools: false, + }; + }); + reinitMCPServer.mockResolvedValue({ availableTools: null }); + + await loadAgentTools({ + req, + agent: { id: 'agent_123', tools: [mcpTool] }, + definitionsOnly: true, + }); + + expect(reinitMCPServer).toHaveBeenCalledWith( + expect.objectContaining({ + serverName, + requestBody: req.body, + }), + ); + expect(mockGetMCPServerTools).toHaveBeenCalledWith( + req.user.id, + serverName, + expect.objectContaining({ + url: expect.stringContaining('LIBRECHAT_BODY_MESSAGEID'), + }), + ); + }); + + it('returns run-scoped MCP tool definitions for request-scoped servers', async () => { + const serverName = 'ClickHouse'; + const mcpTool = `list_tables${Constants.mcp_delimiter}${serverName}`; + const capabilities = [AgentCapabilities.tools]; + const req = createMockReq(capabilities); + req.body = { conversationId: 'conv-123', messageId: 'msg-123' }; + const availableTools = { + [mcpTool]: { + function: { + name: mcpTool, + description: 'List tables', + parameters: { type: 'object', properties: {} }, + }, + }, + }; + + mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities)); + mockGetServerConfig.mockResolvedValue({ + type: 'streamable-http', + url: 'https://mcp.example.com/{{LIBRECHAT_BODY_MESSAGEID}}/mcp', + source: 'yaml', + }); + mockGetMCPServerTools.mockResolvedValue(null); + mockFlowManager.getFlowState.mockResolvedValue(null); + mockLoadToolDefinitions.mockImplementation(async (params, deps) => { + const serverTools = await deps.getOrFetchMCPServerTools(params.userId, serverName); + return { + toolDefinitions: serverTools ? Object.keys(serverTools) : [], + toolRegistry: new Map([[mcpTool, { name: mcpTool }]]), + hasDeferredTools: false, + }; + }); + reinitMCPServer.mockResolvedValue({ availableTools }); + + const result = await loadAgentTools({ + req, + agent: { id: 'agent_123', tools: [mcpTool] }, + definitionsOnly: true, + }); + + expect(result.toolDefinitions).toEqual([mcpTool]); + expect(result.mcpAvailableTools).toEqual({ [serverName]: availableTools }); + expect(mockGetMCPServerTools).toHaveBeenCalledWith( + req.user.id, + serverName, + expect.objectContaining({ + url: expect.stringContaining('LIBRECHAT_BODY_MESSAGEID'), + }), + ); + }); + + it('should preserve pending-flow expiry for OAuth URLs captured during discovery', async () => { + const serverName = 'Google-Workspace'; + const authorizationUrl = 'https://auth.example.com/Google-Workspace'; + const mcpTool = `search${Constants.mcp_delimiter}${serverName}`; + const capabilities = [AgentCapabilities.tools]; + const req = createMockReq(capabilities); + const res = { writableEnded: false }; + const createdAt = Date.now() - 45_000; + mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities)); + mockGetServerConfig.mockResolvedValue({ + type: 'streamable-http', + url: 'https://demo.librechat.ai/mcp', + requiresOAuth: true, + }); + mockGetMCPServerTools.mockResolvedValue(null); + mockFlowManager.getFlowState.mockResolvedValueOnce(null).mockResolvedValueOnce({ + status: 'PENDING', + createdAt, + metadata: { authorizationUrl }, + }); + mockLoadToolDefinitions.mockImplementation(async (params, deps) => { + await deps.getOrFetchMCPServerTools(params.userId, serverName); + return { + toolDefinitions: [], + toolRegistry: new Map(), + hasDeferredTools: false, + }; + }); + reinitMCPServer + .mockImplementationOnce(async ({ oauthStart }) => { + await oauthStart(authorizationUrl); + return { availableTools: null }; + }) + .mockResolvedValue({ availableTools: null }); + + await loadAgentTools({ + req, + res, + agent: { id: 'agent_123', tools: [mcpTool] }, + definitionsOnly: true, + }); + + const authDeltaEvent = mockSendEvent.mock.calls + .map(([, event]) => event) + .find((event) => event.data?.delta?.auth === authorizationUrl); + expect(authDeltaEvent?.data.delta.expires_at).toBe(createdAt + PENDING_STALE_MS); + }); + it('should use request-scoped MCP config before falling back to the registry', async () => { const serverName = 'config-server'; const mcpTool = `search${Constants.mcp_delimiter}${serverName}`; @@ -380,7 +917,11 @@ describe('ToolService - Action Capability Gating', () => { expect(result.toolDefinitions).toEqual([mcpTool]); expect(mockGetServerConfig).not.toHaveBeenCalled(); - expect(mockGetMCPServerTools).toHaveBeenCalledWith(req.user.id, serverName); + expect(mockGetMCPServerTools).toHaveBeenCalledWith( + req.user.id, + serverName, + expect.objectContaining({ url: 'https://config.example.com/mcp' }), + ); }); }); @@ -449,6 +990,49 @@ describe('ToolService - Action Capability Gating', () => { expect(result.configurable.ptcToolMap.size).toBe(0); }); + it('passes run-scoped MCP tool definitions into PTC execution loading', async () => { + const capabilities = [ + AgentCapabilities.tools, + AgentCapabilities.programmatic_tools, + AgentCapabilities.execute_code, + ]; + const req = createMockReq(capabilities); + const serverName = 'ClickHouse'; + const mcpTool = `list_tables${Constants.mcp_delimiter}${serverName}`; + const mcpAvailableTools = { + [serverName]: { + [mcpTool]: { + function: { + name: mcpTool, + description: 'List tables', + parameters: { type: 'object', properties: {} }, + }, + }, + }, + }; + const toolRegistry = new Map([[mcpTool, { name: mcpTool }]]); + mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities)); + + await loadToolsForExecution({ + req, + res: {}, + agent: { id: 'agent_ptc', tools: [Tools.execute_code] }, + toolNames: [Constants.BASH_PROGRAMMATIC_TOOL_CALLING], + toolRegistry, + mcpAvailableTools, + actionsEnabled: false, + }); + + expect(mockLoadToolsUtil).toHaveBeenCalledWith( + expect.objectContaining({ + tools: [mcpTool], + options: expect.objectContaining({ + mcpAvailableTools, + }), + }), + ); + }); + it('does not load PTC when programmatic tools capability is disabled', async () => { const capabilities = [AgentCapabilities.tools, AgentCapabilities.execute_code]; const req = createMockReq(capabilities); diff --git a/api/server/services/initializeMCPs.js b/api/server/services/initializeMCPs.js index be52b6e6ede..e3b35a6e867 100644 --- a/api/server/services/initializeMCPs.js +++ b/api/server/services/initializeMCPs.js @@ -3,6 +3,21 @@ const { logger } = require('@librechat/data-schemas'); const { mergeAppTools, getAppConfig } = require('./Config'); const { createMCPServersRegistry, createMCPManager } = require('~/config'); +/** + * Resolves the current request's effective MCP allowlists from the merged (tenant-scoped) + * config. The registry calls this per inspection/connection so admin-panel `mcpSettings` + * overrides are honored without a restart. Tenant comes from the ALS context inside + * `getAppConfig`; `userId`/`role` pick up user/role-scoped overrides when an actor exists. + * @param {{ userId?: string, role?: string }} [ctx] + */ +async function resolveMCPAllowlists(ctx) { + const appConfig = await getAppConfig({ role: ctx?.role, userId: ctx?.userId }); + return { + allowedDomains: appConfig?.mcpSettings?.allowedDomains, + allowedAddresses: appConfig?.mcpSettings?.allowedAddresses, + }; +} + /** * Initialize MCP servers */ @@ -15,6 +30,7 @@ async function initializeMCPs() { mongoose, appConfig?.mcpSettings?.allowedDomains, appConfig?.mcpSettings?.allowedAddresses, + resolveMCPAllowlists, ); } catch (error) { logger.error('[MCP] Failed to initialize MCPServersRegistry:', error); diff --git a/api/server/services/initializeMCPs.spec.js b/api/server/services/initializeMCPs.spec.js index c62b85ae1b2..fe0766343c7 100644 --- a/api/server/services/initializeMCPs.spec.js +++ b/api/server/services/initializeMCPs.spec.js @@ -82,6 +82,7 @@ describe('initializeMCPs', () => { expect.anything(), // mongoose ['localhost'], undefined, + expect.any(Function), // per-request allowlist resolver ); }); @@ -98,6 +99,7 @@ describe('initializeMCPs', () => { expect.anything(), allowedDomains, undefined, + expect.any(Function), ); }); @@ -113,9 +115,34 @@ describe('initializeMCPs', () => { expect.anything(), undefined, undefined, + expect.any(Function), ); }); + it('wires a per-request resolver that reads the merged (non-baseOnly) config', async () => { + mockGetAppConfig.mockResolvedValue({ + mcpConfig: null, + mcpSettings: { allowedDomains: ['yaml.com'] }, + }); + + await initializeMCPs(); + + const resolver = mockCreateMCPServersRegistry.mock.calls[0][3]; + expect(typeof resolver).toBe('function'); + + // The resolver resolves the request's merged allowlists — not the boot YAML base. + mockGetAppConfig.mockResolvedValue({ + mcpSettings: { allowedDomains: ['merged.com'], allowedAddresses: ['10.0.0.0/8'] }, + }); + const resolved = await resolver({ userId: 'u1', role: 'ADMIN' }); + + expect(mockGetAppConfig).toHaveBeenLastCalledWith({ role: 'ADMIN', userId: 'u1' }); + expect(resolved).toEqual({ + allowedDomains: ['merged.com'], + allowedAddresses: ['10.0.0.0/8'], + }); + }); + it('should throw and log error if MCPServersRegistry initialization fails', async () => { const registryError = new Error('Registry initialization failed'); mockCreateMCPServersRegistry.mockImplementation(() => { diff --git a/api/server/socialLogins.js b/api/server/socialLogins.js index 78f0e82a322..f4d088e6d03 100644 --- a/api/server/socialLogins.js +++ b/api/server/socialLogins.js @@ -1,7 +1,7 @@ const passport = require('passport'); const session = require('express-session'); const { CacheKeys } = require('librechat-data-provider'); -const { isEnabled, shouldUseSecureCookie } = require('@librechat/api'); +const { math, isEnabled, shouldUseSecureCookie } = require('@librechat/api'); const { logger, DEFAULT_SESSION_EXPIRY } = require('@librechat/data-schemas'); const { openIdJwtLogin, @@ -20,6 +20,23 @@ const { } = require('~/strategies'); const { getLogStores } = require('~/cache'); +const DEFAULT_OPENID_REUSE_MAX_SESSION_AGE_MS = 15 * 60 * 1000; + +const getSessionExpiry = () => math(process.env.SESSION_EXPIRY, DEFAULT_SESSION_EXPIRY); + +const getOpenIdSessionExpiry = () => { + const sessionExpiry = getSessionExpiry(); + if (!isEnabled(process.env.OPENID_REUSE_TOKENS)) { + return sessionExpiry; + } + + const reuseMaxSessionAge = math( + process.env.OPENID_REUSE_MAX_SESSION_AGE_MS, + DEFAULT_OPENID_REUSE_MAX_SESSION_AGE_MS, + ); + return Math.max(sessionExpiry, reuseMaxSessionAge); +}; + /** * Configures OpenID Connect for the application. * @param {Express.Application} app - The Express application instance. @@ -27,7 +44,7 @@ const { getLogStores } = require('~/cache'); */ async function configureOpenId(app) { logger.info('Configuring OpenID Connect...'); - const sessionExpiry = Number(process.env.SESSION_EXPIRY) || DEFAULT_SESSION_EXPIRY; + const sessionExpiry = getOpenIdSessionExpiry(); const sessionOptions = { secret: process.env.OPENID_SESSION_SECRET, resave: false, @@ -97,7 +114,7 @@ const configureSocialLogins = async (app) => { process.env.SAML_SESSION_SECRET ) { logger.info('Configuring SAML Connect...'); - const sessionExpiry = Number(process.env.SESSION_EXPIRY) || DEFAULT_SESSION_EXPIRY; + const sessionExpiry = getSessionExpiry(); const sessionOptions = { secret: process.env.SAML_SESSION_SECRET, resave: false, diff --git a/api/server/socialLogins.spec.js b/api/server/socialLogins.spec.js new file mode 100644 index 00000000000..bf016a43ebb --- /dev/null +++ b/api/server/socialLogins.spec.js @@ -0,0 +1,143 @@ +const mockSessionMiddleware = jest.fn((req, res, next) => next()); +const mockPassportSessionMiddleware = jest.fn((req, res, next) => next()); +const mockSession = jest.fn(() => mockSessionMiddleware); +const mockPassportUse = jest.fn(); +const mockPassportSession = jest.fn(() => mockPassportSessionMiddleware); +const mockGetLogStores = jest.fn(() => 'openid-session-store'); +const mockOpenIdJwtLogin = jest.fn(() => 'openid-jwt-strategy'); +const mockSetupOpenId = jest.fn(); +const mockSetupSaml = jest.fn(); +const mockIsEnabled = jest.fn(); +const mockShouldUseSecureCookie = jest.fn(() => true); +const mockMath = jest.fn((value, fallback) => { + if (value == null || value === '') { + return fallback; + } + if (typeof value === 'number') { + return value; + } + return value + .split('*') + .map((part) => Number(part.trim())) + .reduce((result, part) => result * part, 1); +}); + +jest.mock( + 'express-session', + () => + (...args) => + mockSession(...args), +); +jest.mock('passport', () => ({ + use: (...args) => mockPassportUse(...args), + session: (...args) => mockPassportSession(...args), +})); +jest.mock('librechat-data-provider', () => ({ + CacheKeys: { + OPENID_SESSION: 'openid-session', + SAML_SESSION: 'saml-session', + }, +})); +jest.mock('@librechat/api', () => ({ + math: (...args) => mockMath(...args), + isEnabled: (...args) => mockIsEnabled(...args), + shouldUseSecureCookie: (...args) => mockShouldUseSecureCookie(...args), +})); +jest.mock('@librechat/data-schemas', () => ({ + DEFAULT_SESSION_EXPIRY: 900000, + logger: { error: jest.fn(), info: jest.fn() }, +})); +jest.mock('~/cache', () => ({ getLogStores: (...args) => mockGetLogStores(...args) })); +jest.mock('~/strategies', () => ({ + openIdJwtLogin: (...args) => mockOpenIdJwtLogin(...args), + facebookLogin: jest.fn(), + facebookAdminLogin: jest.fn(), + discordLogin: jest.fn(), + discordAdminLogin: jest.fn(), + setupOpenId: (...args) => mockSetupOpenId(...args), + googleLogin: jest.fn(), + googleAdminLogin: jest.fn(), + githubLogin: jest.fn(), + githubAdminLogin: jest.fn(), + appleLogin: jest.fn(), + appleAdminLogin: jest.fn(), + setupSaml: (...args) => mockSetupSaml(...args), +})); + +const configureSocialLogins = require('./socialLogins'); + +describe('configureSocialLogins OpenID session expiry', () => { + const ORIGINAL_ENV = process.env; + + const setupOpenIdEnv = () => { + process.env.OPENID_CLIENT_ID = 'client-id'; + process.env.OPENID_CLIENT_SECRET = 'client-secret'; + process.env.OPENID_ISSUER = 'https://issuer.example.com'; + process.env.OPENID_SCOPE = 'openid profile email'; + process.env.OPENID_SESSION_SECRET = 'openid-session-secret'; + process.env.OPENID_USE_PKCE = 'false'; + }; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = {}; + setupOpenIdEnv(); + mockSetupOpenId.mockResolvedValue({ issuer: 'https://issuer.example.com' }); + mockIsEnabled.mockImplementation((value) => value === 'true'); + }); + + afterAll(() => { + process.env = ORIGINAL_ENV; + }); + + it('extends the OpenID session cookie to the reuse window when token reuse is enabled', async () => { + process.env.SESSION_EXPIRY = '1000 * 60 * 15'; + process.env.OPENID_REUSE_TOKENS = 'true'; + process.env.OPENID_REUSE_MAX_SESSION_AGE_MS = '1000 * 60 * 60'; + const app = { use: jest.fn() }; + + await configureSocialLogins(app); + + expect(mockSession).toHaveBeenCalledWith( + expect.objectContaining({ + cookie: { + maxAge: 3600000, + secure: true, + }, + }), + ); + expect(mockOpenIdJwtLogin).toHaveBeenCalledWith({ issuer: 'https://issuer.example.com' }); + expect(mockPassportUse).toHaveBeenCalledWith('openidJwt', 'openid-jwt-strategy'); + }); + + it('keeps a longer SESSION_EXPIRY when the reuse window is shorter', async () => { + process.env.SESSION_EXPIRY = '1000 * 60 * 60 * 2'; + process.env.OPENID_REUSE_TOKENS = 'true'; + process.env.OPENID_REUSE_MAX_SESSION_AGE_MS = '1000 * 60 * 60'; + const app = { use: jest.fn() }; + + await configureSocialLogins(app); + + expect(mockSession).toHaveBeenCalledWith( + expect.objectContaining({ + cookie: expect.objectContaining({ maxAge: 7200000 }), + }), + ); + }); + + it('uses SESSION_EXPIRY when OpenID token reuse is disabled', async () => { + process.env.SESSION_EXPIRY = '1000 * 60 * 15'; + process.env.OPENID_REUSE_TOKENS = ''; + process.env.OPENID_REUSE_MAX_SESSION_AGE_MS = '1000 * 60 * 60'; + const app = { use: jest.fn() }; + + await configureSocialLogins(app); + + expect(mockSession).toHaveBeenCalledWith( + expect.objectContaining({ + cookie: expect.objectContaining({ maxAge: 900000 }), + }), + ); + expect(mockPassportUse).not.toHaveBeenCalled(); + }); +}); diff --git a/api/server/utils/__tests__/staticCache.spec.js b/api/server/utils/__tests__/staticCache.spec.js index 5d285017bd4..2b22223393c 100644 --- a/api/server/utils/__tests__/staticCache.spec.js +++ b/api/server/utils/__tests__/staticCache.spec.js @@ -5,6 +5,12 @@ const request = require('supertest'); const zlib = require('zlib'); const staticCache = require('../staticCache'); +const binaryParser = (res, callback) => { + const chunks = []; + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => callback(null, Buffer.concat(chunks))); +}; + describe('staticCache', () => { let app; let testDir; @@ -36,10 +42,15 @@ describe('staticCache', () => { fs.writeFileSync(manifestFile, jsonContent); fs.writeFileSync(swFile, swContent); - // Create gzipped versions of some files + // Create precompressed versions of some files fs.writeFileSync(testFile + '.gz', zlib.gzipSync(jsContent)); + fs.writeFileSync(testFile + '.br', zlib.brotliCompressSync(jsContent)); fs.writeFileSync(path.join(testDir, 'test.css'), 'body { color: red; }'); fs.writeFileSync(path.join(testDir, 'test.css.gz'), zlib.gzipSync('body { color: red; }')); + fs.writeFileSync( + path.join(testDir, 'test.css.br'), + zlib.brotliCompressSync('body { color: red; }'), + ); // Create a file that only exists in gzipped form fs.writeFileSync( @@ -67,6 +78,7 @@ describe('staticCache', () => { delete process.env.NODE_ENV; delete process.env.STATIC_CACHE_S_MAX_AGE; delete process.env.STATIC_CACHE_MAX_AGE; + delete process.env.ENABLE_STATIC_ASSET_BROTLI; }); describe('cache headers in production', () => { beforeEach(() => { @@ -193,6 +205,51 @@ describe('staticCache', () => { process.env.NODE_ENV = 'production'; }); + it('should serve Brotli files when client accepts Brotli encoding', async () => { + process.env.ENABLE_STATIC_ASSET_BROTLI = 'true'; + app.use(staticCache(testDir, { skipGzipScan: false })); + + const response = await request(app) + .get('/test.js') + .set('Accept-Encoding', 'br, gzip, deflate') + .buffer(true) + .parse(binaryParser) + .expect(200); + + expect(response.headers['content-encoding']).toBe('br'); + expect(response.headers['content-type']).toMatch(/javascript/); + expect(response.headers['cache-control']).toBe('public, max-age=172800, s-maxage=86400'); + expect(zlib.brotliDecompressSync(response.body).toString()).toBe('console.log("test");'); + }); + + it('should prefer Brotli over gzip when both encodings are accepted', async () => { + process.env.ENABLE_STATIC_ASSET_BROTLI = 'true'; + app.use(staticCache(testDir, { skipGzipScan: false })); + + const response = await request(app) + .get('/test.css') + .set('Accept-Encoding', 'gzip, br') + .buffer(true) + .parse(binaryParser) + .expect(200); + + expect(response.headers['content-encoding']).toBe('br'); + expect(response.headers['content-type']).toMatch(/css/); + expect(zlib.brotliDecompressSync(response.body).toString()).toBe('body { color: red; }'); + }); + + it('should keep serving gzip when Brotli is not enabled', async () => { + app.use(staticCache(testDir, { skipGzipScan: false })); + + const response = await request(app) + .get('/test.js') + .set('Accept-Encoding', 'br, gzip, deflate') + .expect(200); + + expect(response.headers['content-encoding']).toBe('gzip'); + expect(response.text).toBe('console.log("test");'); + }); + it('should serve gzipped files when client accepts gzip encoding', async () => { app.use(staticCache(testDir, { skipGzipScan: false })); diff --git a/api/server/utils/fallback.js b/api/server/utils/fallback.js new file mode 100644 index 00000000000..3e067ab9caf --- /dev/null +++ b/api/server/utils/fallback.js @@ -0,0 +1,20 @@ +/** Static asset extensions that must 404 when missing — serving the SPA's + * index.html for them breaks strict MIME checks and poisons SW/browser caches. */ +const STATIC_ASSET_EXT = + /\.(?:js|mjs|css|map|json|wasm|webmanifest|png|jpe?g|gif|svg|ico|webp|avif|woff2?|ttf|otf|eot)$/i; + +/** + * Creates the SPA fallback middleware: serves index.html for unmatched + * routes while returning 404 for missing static assets. + * @param {(req: import('express').Request, res: import('express').Response) => void} sendIndexHtml + */ +function createSpaFallback(sendIndexHtml) { + return (req, res) => { + if (STATIC_ASSET_EXT.test(req.path)) { + return res.status(404).end(); + } + return sendIndexHtml(req, res); + }; +} + +module.exports = createSpaFallback; diff --git a/api/server/utils/import/importers-timestamp.spec.js b/api/server/utils/import/importers-timestamp.spec.js index e12c099abb8..268cc74c0d8 100644 --- a/api/server/utils/import/importers-timestamp.spec.js +++ b/api/server/utils/import/importers-timestamp.spec.js @@ -7,6 +7,7 @@ const { getImporter } = require('./importers'); jest.mock('~/models', () => ({ bulkSaveConvos: jest.fn(), bulkSaveMessages: jest.fn(), + bulkIncrementTagCounts: jest.fn(), })); const mockGetEndpointsConfig = jest.fn().mockResolvedValue(null); diff --git a/api/server/utils/import/importers.js b/api/server/utils/import/importers.js index b86be3798e0..435572d65a3 100644 --- a/api/server/utils/import/importers.js +++ b/api/server/utils/import/importers.js @@ -22,8 +22,11 @@ function getImporter(jsonData) { return importClaudeConvo; } // ChatGPT format has mapping object in each conversation - logger.info('Importing ChatGPT conversation'); - return importChatGptConvo; + if (jsonData.length === 0 || jsonData[0]?.mapping) { + logger.info('Importing ChatGPT conversation'); + return importChatGptConvo; + } + throw new Error('Unsupported import type'); } // For ChatbotUI @@ -81,6 +84,7 @@ async function importChatBotUiConvo( logger.info(`user: ${requestUserId} | ChatbotUI conversation imported`); } catch (error) { logger.error(`user: ${requestUserId} | Error creating conversation from ChatbotUI file`, error); + throw error; } } @@ -197,6 +201,7 @@ async function importClaudeConvo( logger.info(`user: ${requestUserId} | Claude conversation imported`); } catch (error) { logger.error(`user: ${requestUserId} | Error creating conversation from Claude file`, error); + throw error; } } @@ -305,6 +310,7 @@ async function importLibreChatConvo( logger.debug(`user: ${requestUserId} | Conversation "${jsonData.title}" imported`); } catch (error) { logger.error(`user: ${requestUserId} | Error creating conversation from LibreChat file`, error); + throw error; } } @@ -336,6 +342,7 @@ async function importChatGptConvo( await importBatchBuilder.saveBatch(); } catch (error) { logger.error(`user: ${requestUserId} | Error creating conversation from imported file`, error); + throw error; } } @@ -355,7 +362,7 @@ function processConversation(conv, importBatchBuilder, requestUserId, defaultMod // Map all message IDs to new UUIDs const messageMap = new Map(); for (const [id, mapping] of Object.entries(conv.mapping)) { - if (mapping.message && mapping.message.content.content_type) { + if (mapping.message?.content?.content_type) { const newMessageId = uuidv4(); messageMap.set(id, newMessageId); } @@ -467,6 +474,9 @@ function processConversation(conv, importBatchBuilder, requestUserId, defaultMod } const newMessageId = messageMap.get(id); + if (!newMessageId) { + continue; + } const parentMessageId = findValidParent(mapping.parent); const messageText = formatMessageText(mapping.message); @@ -474,7 +484,7 @@ function processConversation(conv, importBatchBuilder, requestUserId, defaultMod const isCreatedByUser = role === 'user'; let sender = isCreatedByUser ? 'user' : 'assistant'; const model = - mapping.message.metadata.model_slug || defaultModel || openAISettings.model.default; + mapping.message.metadata?.model_slug || defaultModel || openAISettings.model.default; if (!isCreatedByUser) { /** Extracted model name from model slug */ @@ -598,7 +608,7 @@ function formatMessageText(messageData) { messageText = `\`\`\`json\n${JSON.stringify(messageData.content, null, 2)}\n\`\`\``; } - if (isText && messageData.author.role !== 'user') { + if (isText && messageData.author?.role !== 'user') { messageText = processAssistantMessage(messageData, messageText); } diff --git a/api/server/utils/import/importers.spec.js b/api/server/utils/import/importers.spec.js index 6ccd2f37288..a9bd679f55f 100644 --- a/api/server/utils/import/importers.spec.js +++ b/api/server/utils/import/importers.spec.js @@ -764,6 +764,86 @@ describe('importChatGptConvo', () => { expect(userMsg.createdAt).toEqual(new Date(1000 * 1000)); expect(assistantMsg.createdAt).toEqual(new Date(2000 * 1000)); }); + + it('should import messages missing metadata without failing (newer ChatGPT exports)', async () => { + const testData = [ + { + title: 'Missing Metadata Test', + create_time: 1714585031.148505, + update_time: 1714585060.879308, + mapping: { + 'root-node': { + id: 'root-node', + message: null, + parent: null, + children: ['user-msg-1'], + }, + 'user-msg-1': { + id: 'user-msg-1', + message: { + id: 'user-msg-1', + author: { role: 'user' }, + create_time: 1714585031.150442, + content: { content_type: 'text', parts: ['User message without metadata'] }, + }, + parent: 'root-node', + children: ['assistant-msg-1'], + }, + 'assistant-msg-1': { + id: 'assistant-msg-1', + message: { + id: 'assistant-msg-1', + author: { role: 'assistant' }, + create_time: 1714585032.150442, + content: { content_type: 'text', parts: ['Assistant response without metadata'] }, + }, + parent: 'user-msg-1', + children: ['no-content-msg'], + }, + 'no-content-msg': { + id: 'no-content-msg', + message: { + id: 'no-content-msg', + author: { role: 'tool' }, + create_time: 1714585033.150442, + }, + parent: 'assistant-msg-1', + children: [], + }, + }, + }, + ]; + + const requestUserId = 'user-123'; + const importBatchBuilder = new ImportBatchBuilder(requestUserId); + jest.spyOn(importBatchBuilder, 'saveMessage'); + + const importer = getImporter(testData); + await importer(testData, requestUserId, () => importBatchBuilder); + + const savedMessages = importBatchBuilder.saveMessage.mock.calls.map((call) => call[0]); + expect(savedMessages).toHaveLength(2); + + const userMessage = savedMessages.find((msg) => msg.isCreatedByUser); + const assistantMessage = savedMessages.find((msg) => !msg.isCreatedByUser); + expect(userMessage.model).toBe(openAISettings.model.default); + expect(assistantMessage.model).toBe(openAISettings.model.default); + expect(assistantMessage.parentMessageId).toBe(userMessage.messageId); + }); + + it('should rethrow errors so failed imports are not reported as successful', async () => { + const jsonData = JSON.parse( + fs.readFileSync(path.join(__dirname, '__data__', 'chatgpt-export.json'), 'utf8'), + ); + const requestUserId = 'user-123'; + const importBatchBuilder = new ImportBatchBuilder(requestUserId); + jest.spyOn(importBatchBuilder, 'saveBatch').mockRejectedValue(new Error('db unavailable')); + + const importer = getImporter(jsonData); + await expect(importer(jsonData, requestUserId, () => importBatchBuilder)).rejects.toThrow( + 'db unavailable', + ); + }); }); describe('importLibreChatConvo', () => { @@ -1135,6 +1215,17 @@ describe('getImporter', () => { const jsonData = { unsupported: 'data' }; expect(() => getImporter(jsonData)).toThrow('Unsupported import type'); }); + + it('should throw for array-based files that are not ChatGPT or Claude exports', () => { + const openWebUiExport = [ + { id: 'abc', title: 'Open WebUI Chat', chat: { history: { messages: {} } } }, + ]; + expect(() => getImporter(openWebUiExport)).toThrow('Unsupported import type'); + }); + + it('should route empty arrays to the ChatGPT importer without throwing', () => { + expect(() => getImporter([])).not.toThrow(); + }); }); describe('processAssistantMessage', () => { diff --git a/api/server/utils/staticCache.js b/api/server/utils/staticCache.js index ecaea856d0a..a16830a56c8 100644 --- a/api/server/utils/staticCache.js +++ b/api/server/utils/staticCache.js @@ -6,9 +6,10 @@ const oneDayInSeconds = 24 * 60 * 60; const sMaxAge = process.env.STATIC_CACHE_S_MAX_AGE || oneDayInSeconds; const maxAge = process.env.STATIC_CACHE_MAX_AGE || oneDayInSeconds * 2; +const isEnabled = (value) => value === true || String(value).toLowerCase() === 'true'; /** - * Creates an Express static middleware with optional gzip compression and configurable caching + * Creates an Express static middleware with optional precompressed asset serving and configurable caching * * @param {string} staticPath - The file system path to serve static files from * @param {Object} [options={}] - Configuration options @@ -18,6 +19,7 @@ const maxAge = process.env.STATIC_CACHE_MAX_AGE || oneDayInSeconds * 2; */ function staticCache(staticPath, options = {}) { const { noCache = false, skipGzipScan = false } = options; + const enableBrotli = isEnabled(process.env.ENABLE_STATIC_ASSET_BROTLI); const setHeaders = (res, filePath) => { if (process.env.NODE_ENV?.toLowerCase() !== 'production') { @@ -36,7 +38,8 @@ function staticCache(staticPath, options = {}) { fileName === 'index.html' || fileName.endsWith('.webmanifest') || fileName === 'manifest.json' || - fileName === 'sw.js' + fileName === 'sw.js' || + fileName === 'sw-heal.js' ) { res.setHeader('Cache-Control', 'no-store, no-cache, must-revalidate'); } else { @@ -51,8 +54,8 @@ function staticCache(staticPath, options = {}) { }); } else { return expressStaticGzip(staticPath, { - enableBrotli: false, - orderPreference: ['gz'], + enableBrotli, + orderPreference: enableBrotli ? ['br', 'gz'] : ['gz'], setHeaders, index: false, }); diff --git a/api/strategies/openIdJwtStrategy.js b/api/strategies/openIdJwtStrategy.js index ab1bcd1c0a1..14f50f3f042 100644 --- a/api/strategies/openIdJwtStrategy.js +++ b/api/strategies/openIdJwtStrategy.js @@ -1,7 +1,6 @@ const cookies = require('cookie'); const jwksRsa = require('jwks-rsa'); const { logger } = require('@librechat/data-schemas'); -const { HttpsProxyAgent } = require('https-proxy-agent'); const { SystemRoles } = require('librechat-data-provider'); const { Strategy: JwtStrategy, ExtractJwt } = require('passport-jwt'); const { @@ -10,6 +9,7 @@ const { getOpenIdEmail, getOpenIdIssuer, normalizeOpenIdIssuer, + getHttpsProxyAgent, math, } = require('@librechat/api'); const { updateUser, findUser } = require('~/models'); @@ -73,8 +73,9 @@ const openIdJwtLogin = (openIdConfig) => { jwksUri: openIdConfig.serverMetadata().jwks_uri, }; - if (process.env.PROXY) { - jwksRsaOptions.requestAgent = new HttpsProxyAgent(process.env.PROXY); + const requestAgent = getHttpsProxyAgent(jwksRsaOptions.jwksUri); + if (requestAgent) { + jwksRsaOptions.requestAgent = requestAgent; } return new JwtStrategy( diff --git a/api/strategies/openIdJwtStrategy.spec.js b/api/strategies/openIdJwtStrategy.spec.js index 4a1871110c5..5b4bc86c495 100644 --- a/api/strategies/openIdJwtStrategy.spec.js +++ b/api/strategies/openIdJwtStrategy.spec.js @@ -28,6 +28,7 @@ jest.mock('@librechat/api', () => ({ getOpenIdEmail: jest.requireActual('@librechat/api').getOpenIdEmail, getOpenIdIssuer: jest.fn(() => 'https://issuer.example.com'), normalizeOpenIdIssuer: jest.requireActual('@librechat/api').normalizeOpenIdIssuer, + getHttpsProxyAgent: jest.fn(() => undefined), math: jest.fn((val, fallback) => fallback), })); jest.mock('~/models', () => ({ diff --git a/api/strategies/openidStrategy.js b/api/strategies/openidStrategy.js index 67da4bf007f..d9c2684314f 100644 --- a/api/strategies/openidStrategy.js +++ b/api/strategies/openidStrategy.js @@ -14,14 +14,15 @@ const { getOpenIdEmail, getOpenIdIssuer, getBalanceConfig, + selectOpenIdRole, + getAvatarSaveParams, isEmailDomainAllowed, getAvatarFileStrategy, - getAvatarSaveParams, - selectOpenIdRole, + resolveAppConfigForUser, + getOpenIdProxyDispatcher, getOpenIdRoleSyncOptions, getOpenIdRolesForOpenIdSync, getLibreChatRolesForOpenIdSync, - resolveAppConfigForUser, } = require('@librechat/api'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { resizeAvatar } = require('~/server/services/Files/images/avatar'); @@ -61,11 +62,12 @@ async function customFetch(url, options) { try { /** @type {undici.RequestInit} */ let fetchOptions = options; - if (process.env.PROXY) { - logger.info(`[openidStrategy] proxy agent configured: ${process.env.PROXY}`); + const dispatcher = getOpenIdProxyDispatcher(); + if (dispatcher) { + logger.info('[openidStrategy] proxy dispatcher configured'); fetchOptions = { ...options, - dispatcher: new undici.ProxyAgent(process.env.PROXY), + dispatcher, }; } @@ -107,6 +109,12 @@ This violates RFC 7235 and may cause issues with strict OAuth clients. Removing /** @typedef {Configuration | null} */ let openidConfig = null; +const getOpenIdAuthorizationAudience = () => + (process.env.OPENID_AUDIENCE ?? '') + .split(',') + .map((value) => value.trim()) + .find(Boolean); + /** * Custom OpenID Strategy * @@ -127,10 +135,11 @@ class CustomOpenIDStrategy extends OpenIDStrategy { params.set('state', options.state); } - if (process.env.OPENID_AUDIENCE) { - params.set('audience', process.env.OPENID_AUDIENCE); + const authorizationAudience = getOpenIdAuthorizationAudience(); + if (authorizationAudience) { + params.set('audience', authorizationAudience); logger.debug( - `[openidStrategy] Adding audience to authorization request: ${process.env.OPENID_AUDIENCE}`, + `[openidStrategy] Adding audience to authorization request: ${authorizationAudience}`, ); } @@ -412,9 +421,9 @@ async function resolveGroupsFromOverage(accessToken, sub) { body: JSON.stringify({ securityEnabledOnly: false }), }; - if (process.env.PROXY) { - const { ProxyAgent } = undici; - fetchOptions.dispatcher = new ProxyAgent(process.env.PROXY); + const dispatcher = getOpenIdProxyDispatcher(); + if (dispatcher) { + fetchOptions.dispatcher = dispatcher; } const response = await undici.fetch(url, fetchOptions); diff --git a/api/strategies/openidStrategy.spec.js b/api/strategies/openidStrategy.spec.js index b1d6081fe84..36a491a0685 100644 --- a/api/strategies/openidStrategy.spec.js +++ b/api/strategies/openidStrategy.spec.js @@ -3,7 +3,12 @@ const fetch = require('node-fetch'); const jwtDecode = require('jsonwebtoken/decode'); const { ErrorTypes, FileSources } = require('librechat-data-provider'); const { findUser, createUser, updateUser, findRolesByNames } = require('~/models'); -const { getOpenIdIssuer, resolveAppConfigForUser, isEnabled } = require('@librechat/api'); +const { + getOpenIdProxyDispatcher, + resolveAppConfigForUser, + getOpenIdIssuer, + isEnabled, +} = require('@librechat/api'); const { resizeAvatar } = require('~/server/services/Files/images/avatar'); const { getAppConfig } = require('~/server/services/Config'); const { setupOpenId } = require('./openidStrategy'); @@ -15,7 +20,6 @@ jest.mock('node-fetch'); jest.mock('jsonwebtoken/decode'); jest.mock('undici', () => ({ fetch: jest.fn(), - ProxyAgent: jest.fn(), })); jest.mock('~/server/services/Files/strategies', () => ({ getStrategyFunctions: jest.fn(() => ({ @@ -74,6 +78,7 @@ jest.mock('@librechat/api', () => { enabled: false, })), getOpenIdIssuer: jest.fn(() => 'https://fake-issuer.com'), + getOpenIdProxyDispatcher: jest.fn(() => undefined), getAvatarFileStrategy: jest.fn((config, fallbackStrategy) => { const { FileSources } = jest.requireActual('librechat-data-provider'); if (config?.fileStrategies) { @@ -140,20 +145,28 @@ jest.mock('openid-client', () => { jest.mock('openid-client/passport', () => { /** Store callbacks by strategy name - 'openid' and 'openidAdmin' */ const verifyCallbacks = {}; + const strategies = {}; let lastVerifyCallback; - const mockStrategy = jest.fn((options, verify) => { + const mockStrategy = jest.fn(function (options, verify) { lastVerifyCallback = verify; - return { name: 'openid', options, verify }; + this.name = 'openid'; + this.options = options; + this.verify = verify; }); + mockStrategy.prototype.authorizationRequestParams = jest.fn(() => new URLSearchParams()); return { Strategy: mockStrategy, /** Get the last registered callback (for backward compatibility) */ __getVerifyCallback: () => lastVerifyCallback, + __getStrategyByName: (name) => strategies[name], /** Store callback by name when passport.use is called */ - __setVerifyCallback: (name, callback) => { - verifyCallbacks[name] = callback; + __setStrategy: (name, strategy) => { + strategies[name] = strategy; + if (strategy?.verify) { + verifyCallbacks[name] = strategy.verify; + } }, /** Get callback by strategy name */ __getVerifyCallbackByName: (name) => verifyCallbacks[name], @@ -164,9 +177,7 @@ jest.mock('openid-client/passport', () => { jest.mock('passport', () => ({ use: jest.fn((name, strategy) => { const passportMock = require('openid-client/passport'); - if (strategy && strategy.verify) { - passportMock.__setVerifyCallback(name, strategy.verify); - } + passportMock.__setStrategy(name, strategy); }), })); @@ -210,6 +221,7 @@ describe('setupOpenId', () => { get: jest.fn(), set: jest.fn(), })); + getOpenIdProxyDispatcher.mockReturnValue(undefined); require('openid-client').genericGrantRequest.mockReset(); require('openid-client').genericGrantRequest.mockResolvedValue({ access_token: 'exchanged_graph_token', @@ -232,6 +244,7 @@ describe('setupOpenId', () => { delete process.env.OPENID_USERNAME_CLAIM; delete process.env.OPENID_NAME_CLAIM; delete process.env.OPENID_EMAIL_CLAIM; + delete process.env.OPENID_AUDIENCE; delete process.env.OPENID_AVATAR_AUTHORIZED_ORIGINS; delete process.env.PROXY; delete process.env.OPENID_USE_PKCE; @@ -335,6 +348,61 @@ describe('setupOpenId', () => { expect(metadata.client_secret).toBe('my-secret'); expect(metadata.token_endpoint_auth_method).toBeUndefined(); }); + + it('uses the shared OpenID proxy dispatcher for custom fetch requests', async () => { + const dispatcher = { dispatch: jest.fn() }; + const response = { status: 204, statusText: 'No Content', headers: new Headers() }; + getOpenIdProxyDispatcher.mockReturnValue(dispatcher); + undici.fetch.mockResolvedValue(response); + + await setupOpenId(); + + const [, , , , options] = openidClient.discovery.mock.calls.at(-1); + const openIdFetch = options[openidClient.customFetch]; + await expect( + openIdFetch('https://issuer.example.com/.well-known/openid-configuration', { + method: 'GET', + }), + ).resolves.toBe(response); + + expect(getOpenIdProxyDispatcher).toHaveBeenCalled(); + expect(undici.fetch).toHaveBeenCalledWith( + 'https://issuer.example.com/.well-known/openid-configuration', + { + method: 'GET', + dispatcher, + }, + ); + }); + }); + + describe('authorizationRequestParams', () => { + const getLoginStrategy = () => require('openid-client/passport').__getStrategyByName('openid'); + + it('adds a single OpenID audience to authorization requests', () => { + process.env.OPENID_AUDIENCE = 'librechat'; + + const params = getLoginStrategy().authorizationRequestParams({}, { state: 'login-state' }); + + expect(params.get('audience')).toBe('librechat'); + expect(params.get('state')).toBe('login-state'); + }); + + it('uses the first non-empty audience when OPENID_AUDIENCE accepts multiple JWT audiences', () => { + process.env.OPENID_AUDIENCE = ' librechat , control-plane-web '; + + const params = getLoginStrategy().authorizationRequestParams({}, {}); + + expect(params.get('audience')).toBe('librechat'); + }); + + it('does not add an authorization audience when OPENID_AUDIENCE is empty', () => { + process.env.OPENID_AUDIENCE = ' , '; + + const params = getLoginStrategy().authorizationRequestParams({}, {}); + + expect(params.has('audience')).toBe(false); + }); }); it('should create a new user with correct username when preferred_username claim exists', async () => { diff --git a/api/test/__mocks__/logger.js b/api/test/__mocks__/logger.js index 94dd08bb1c4..699a94883f1 100644 --- a/api/test/__mocks__/logger.js +++ b/api/test/__mocks__/logger.js @@ -58,12 +58,3 @@ jest.mock('~/config', () => { }, }; }); - -jest.mock('~/config/parsers', () => { - return { - redactMessage: jest.fn(), - redactFormat: jest.fn(), - debugTraverse: jest.fn(), - formatConsoleMeta: jest.fn(() => ''), - }; -}); diff --git a/api/test/app/clients/tools/structured/OpenAIImageTools.test.js b/api/test/app/clients/tools/structured/OpenAIImageTools.test.js index aa0726b9163..b83ed5335c6 100644 --- a/api/test/app/clients/tools/structured/OpenAIImageTools.test.js +++ b/api/test/app/clients/tools/structured/OpenAIImageTools.test.js @@ -25,6 +25,8 @@ jest.mock('@librechat/api', () => ({ }, }, extractBaseURL: jest.fn((url) => url), + getProxyDispatcher: jest.fn(() => undefined), + applyAxiosProxyConfig: jest.fn(), })); jest.mock('~/server/services/Files/strategies', () => ({ diff --git a/api/utils/tokens.spec.js b/api/utils/tokens.spec.js index e2c4ac9ba46..82c5a8b31fb 100644 --- a/api/utils/tokens.spec.js +++ b/api/utils/tokens.spec.js @@ -1521,6 +1521,68 @@ describe('Claude Model Tests', () => { }); }); + it('should return correct context length for Claude Fable 5 (1M)', () => { + expect(getModelMaxTokens('claude-fable-5', EModelEndpoint.anthropic)).toBe( + maxTokensMap[EModelEndpoint.anthropic]['claude-fable-5'], + ); + expect(getModelMaxTokens('claude-fable-5')).toBe( + maxTokensMap[EModelEndpoint.anthropic]['claude-fable-5'], + ); + }); + + it('should return correct max output tokens for Claude Fable 5 (128K)', () => { + const { getModelMaxOutputTokens } = require('@librechat/api'); + expect(getModelMaxOutputTokens('claude-fable-5', EModelEndpoint.anthropic)).toBe( + maxOutputTokensMap[EModelEndpoint.anthropic]['claude-fable-5'], + ); + }); + + it('should match model names correctly for Claude Fable 5', () => { + const modelVariations = [ + 'claude-fable-5', + 'claude-fable-5-20260609', + 'claude-fable-5-latest', + 'anthropic/claude-fable-5', + 'claude-fable-5/anthropic', + 'anthropic.claude-fable-5', + ]; + + modelVariations.forEach((model) => { + expect(matchModelName(model, EModelEndpoint.anthropic)).toBe('claude-fable-5'); + }); + }); + + it('should return correct context length for Claude Mythos 5 (1M)', () => { + expect(getModelMaxTokens('claude-mythos-5', EModelEndpoint.anthropic)).toBe( + maxTokensMap[EModelEndpoint.anthropic]['claude-mythos-5'], + ); + expect(getModelMaxTokens('claude-mythos-5')).toBe( + maxTokensMap[EModelEndpoint.anthropic]['claude-mythos-5'], + ); + }); + + it('should return correct max output tokens for Claude Mythos 5 (128K)', () => { + const { getModelMaxOutputTokens } = require('@librechat/api'); + expect(getModelMaxOutputTokens('claude-mythos-5', EModelEndpoint.anthropic)).toBe( + maxOutputTokensMap[EModelEndpoint.anthropic]['claude-mythos-5'], + ); + }); + + it('should match model names correctly for Claude Mythos 5', () => { + const modelVariations = [ + 'claude-mythos-5', + 'claude-mythos-5-20260609', + 'claude-mythos-5-latest', + 'anthropic/claude-mythos-5', + 'claude-mythos-5/anthropic', + 'anthropic.claude-mythos-5', + ]; + + modelVariations.forEach((model) => { + expect(matchModelName(model, EModelEndpoint.anthropic)).toBe('claude-mythos-5'); + }); + }); + it('should return correct context length for Claude Sonnet 4.6 (1M)', () => { expect(getModelMaxTokens('claude-sonnet-4-6', EModelEndpoint.anthropic)).toBe( maxTokensMap[EModelEndpoint.anthropic]['claude-sonnet-4-6'], diff --git a/bun.lock b/bun.lock index 56914cfcc84..6ed9ac13926 100644 --- a/bun.lock +++ b/bun.lock @@ -37,7 +37,7 @@ }, "api": { "name": "@librechat/backend", - "version": "0.8.6", + "version": "0.8.7-rc1", "dependencies": { "@anthropic-ai/vertex-sdk": "^0.14.3", "@aws-sdk/client-bedrock-runtime": "^3.980.0", @@ -130,7 +130,7 @@ }, "client": { "name": "@librechat/frontend", - "version": "0.8.6", + "version": "0.8.7-rc1", "dependencies": { "@ariakit/react": "^0.4.15", "@ariakit/react-core": "^0.4.17", @@ -264,7 +264,7 @@ }, "packages/api": { "name": "@librechat/api", - "version": "1.7.31", + "version": "1.7.32", "devDependencies": { "@babel/preset-env": "^7.21.5", "@babel/preset-react": "^7.18.6", @@ -345,7 +345,7 @@ }, "packages/client": { "name": "@librechat/client", - "version": "0.4.60", + "version": "0.4.61", "devDependencies": { "@babel/core": "^7.28.5", "@babel/preset-env": "^7.28.5", @@ -433,7 +433,7 @@ }, "packages/data-provider": { "name": "librechat-data-provider", - "version": "0.8.503", + "version": "0.8.505", "dependencies": { "axios": "^1.13.5", "dayjs": "^1.11.13", @@ -470,7 +470,7 @@ }, "packages/data-schemas": { "name": "@librechat/data-schemas", - "version": "0.0.52", + "version": "0.0.53", "devDependencies": { "@rollup/plugin-alias": "^5.1.0", "@rollup/plugin-commonjs": "^29.0.0", diff --git a/client/index.html b/client/index.html index 2bd9c91e5e1..79755db659b 100644 --- a/client/index.html +++ b/client/index.html @@ -48,6 +48,91 @@ `; document.head.appendChild(loadingContainerStyle); + diff --git a/client/jest.config.cjs b/client/jest.config.cjs index a40d832da88..9a1fce22008 100644 --- a/client/jest.config.cjs +++ b/client/jest.config.cjs @@ -1,4 +1,4 @@ -/** v0.8.6 */ +/** v0.8.7-rc1 */ module.exports = { roots: ['/src'], testEnvironment: 'jsdom', diff --git a/client/package.json b/client/package.json index d7906026dd3..d54dfd285f4 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/frontend", - "version": "v0.8.6", + "version": "v0.8.7-rc1", "description": "", "type": "module", "scripts": { @@ -70,7 +70,7 @@ "downloadjs": "^1.4.7", "export-from-json": "^1.7.2", "filenamify": "^6.0.0", - "framer-motion": "^11.5.4", + "framer-motion": "^12.40.0", "heic-to": "^1.1.14", "html-to-image": "^1.11.11", "i18next": "^24.2.2", @@ -82,8 +82,15 @@ "lodash": "^4.17.23", "lucide-react": "^0.394.0", "match-sorter": "^8.1.0", + "mdast-util-directive": "^3.0.0", + "mdast-util-from-markdown": "^2.0.1", + "mdast-util-gfm": "^3.0.0", + "mdast-util-math": "^3.0.0", "mermaid": "^11.15.0", + "micromark-extension-directive": "^3.0.1", + "micromark-extension-gfm": "^3.0.0", "micromark-extension-llm-math": "^3.1.0", + "micromark-extension-math": "^3.1.0", "qrcode.react": "^4.2.0", "rc-input-number": "^7.4.2", "react": "^18.2.0", @@ -149,12 +156,12 @@ "jest-canvas-mock": "^2.5.2", "jest-environment-jsdom": "^30.2.0", "jest-file-loader": "^1.0.3", - "jest-junit": "^16.0.0", + "jest-junit": "^17.0.0", "monaco-editor": "^0.55.1", "postcss": "^8.4.31", "postcss-preset-env": "^11.2.0", "tailwindcss": "^3.4.1", - "typescript": "^5.3.3", + "typescript": "^5.9.3", "vite": "^8.0.16", "vite-plugin-compression2": "^2.5.3", "vite-plugin-node-polyfills": "^0.28.0", diff --git a/client/src/@types/i18next.d.ts b/client/src/@types/i18next.d.ts index 82f1ce1a3d1..2070c552715 100644 --- a/client/src/@types/i18next.d.ts +++ b/client/src/@types/i18next.d.ts @@ -1,9 +1,12 @@ -import { defaultNS, resources } from '~/locales/i18n'; +import translationEn from '~/locales/en/translation.json'; +import { defaultNS } from '~/locales/i18n'; declare module 'i18next' { interface CustomTypeOptions { defaultNS: typeof defaultNS; - resources: typeof resources.en; + resources: { + translation: typeof translationEn; + }; strictKeyChecks: true; } } diff --git a/client/src/App.jsx b/client/src/App.jsx index fe280f71297..78ed8438b04 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -4,11 +4,12 @@ import { DndProvider } from 'react-dnd'; import { RouterProvider } from 'react-router-dom'; import * as RadixToast from '@radix-ui/react-toast'; import { HTML5Backend } from 'react-dnd-html5-backend'; -import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; import { Toast, ThemeProvider, ToastProvider } from '@librechat/client'; import { QueryClient, QueryClientProvider, QueryCache } from '@tanstack/react-query'; import { ScreenshotProvider, useApiErrorBoundary } from './hooks'; import WakeLockManager from '~/components/System/WakeLockManager'; +import QueryDevtoolsGate from '~/components/QueryDevtoolsGate'; +import LanguageSync from '~/components/System/LanguageSync'; import { getThemeFromEnv } from './utils/getThemeFromEnv'; import { initializeFontSize } from '~/store/fontSize'; import { LiveAnnouncer } from '~/a11y'; @@ -47,6 +48,7 @@ const App = () => { return ( + { - + diff --git a/client/src/Providers/ArtifactContext.tsx b/client/src/Providers/ArtifactContext.tsx index 938f26d6e86..62136b7c78a 100644 --- a/client/src/Providers/ArtifactContext.tsx +++ b/client/src/Providers/ArtifactContext.tsx @@ -8,17 +8,31 @@ type TArtifactContext = { export const ArtifactContext = createContext({} as TArtifactContext); export const useArtifactContext = () => useContext(ArtifactContext); -export function ArtifactProvider({ children }: { children: ReactNode }) { +export function ArtifactProvider({ + children, + baseIndex = 0, +}: { + children: ReactNode; + /** + * Offset added to every assigned index, so per-block memoized rendering can + * seed each block's provider with the count of artifacts in earlier blocks + * and keep document-order indices stable. + */ + baseIndex?: number; +}) { const counterRef = useRef(0); - const getNextIndex = useCallback((skip: boolean) => { - if (skip) { - return counterRef.current; - } - const nextIndex = counterRef.current; - counterRef.current += 1; - return nextIndex; - }, []); + const getNextIndex = useCallback( + (skip: boolean) => { + if (skip) { + return baseIndex + counterRef.current; + } + const nextIndex = counterRef.current; + counterRef.current += 1; + return baseIndex + nextIndex; + }, + [baseIndex], + ); const resetCounter = useCallback(() => { counterRef.current = 0; diff --git a/client/src/Providers/BadgeRowContext.tsx b/client/src/Providers/BadgeRowContext.tsx index 6cecc39b2da..ffbf41fe16a 100644 --- a/client/src/Providers/BadgeRowContext.tsx +++ b/client/src/Providers/BadgeRowContext.tsx @@ -1,7 +1,7 @@ import React, { createContext, useContext, useEffect, useMemo, useRef } from 'react'; import { useSetRecoilState } from 'recoil'; import { Tools, Constants, LocalStorageKeys, AgentCapabilities } from 'librechat-data-provider'; -import type { TAgentsEndpoint } from 'librechat-data-provider'; +import type { TAgentsEndpoint, TEphemeralAgent } from 'librechat-data-provider'; import { useMCPServerManager, useSearchApiKeyForm, @@ -181,7 +181,7 @@ export default function BadgeRowProvider({ if (prev == null) { /** ephemeralAgent is null — use localStorage defaults */ if (hasOverrides || mcpOverrides) { - const result = { ...initialValues }; + const result: TEphemeralAgent = { ...initialValues }; if (mcpOverrides) { result.mcp = mcpOverrides; } diff --git a/client/src/Providers/CodeBlockContext.tsx b/client/src/Providers/CodeBlockContext.tsx index 2823f532bea..ad2ffe73509 100644 --- a/client/src/Providers/CodeBlockContext.tsx +++ b/client/src/Providers/CodeBlockContext.tsx @@ -8,17 +8,33 @@ type TCodeBlockContext = { export const CodeBlockContext = createContext({} as TCodeBlockContext); export const useCodeBlockContext = () => useContext(CodeBlockContext); -export function CodeBlockProvider({ children }: { children: ReactNode }) { +export function CodeBlockProvider({ + children, + baseIndex = 0, +}: { + children: ReactNode; + /** + * Offset added to every assigned index. When rendering a message as + * independently memoized blocks, each block gets its own provider seeded with + * the running count of executable code blocks in earlier blocks, so document- + * order indices are preserved without a single shared (memoization-fragile) + * counter. + */ + baseIndex?: number; +}) { const counterRef = useRef(0); - const getNextIndex = useCallback((skip: boolean) => { - if (skip) { - return counterRef.current; - } - const nextIndex = counterRef.current; - counterRef.current += 1; - return nextIndex; - }, []); + const getNextIndex = useCallback( + (skip: boolean) => { + if (skip) { + return baseIndex + counterRef.current; + } + const nextIndex = counterRef.current; + counterRef.current += 1; + return baseIndex + nextIndex; + }, + [baseIndex], + ); const resetCounter = useCallback(() => { counterRef.current = 0; diff --git a/client/src/a11y/LiveMessage.tsx b/client/src/a11y/LiveMessage.tsx index b773deae53d..b25b48f1080 100644 --- a/client/src/a11y/LiveMessage.tsx +++ b/client/src/a11y/LiveMessage.tsx @@ -16,17 +16,17 @@ const LiveMessage: React.FC = ({ useEffect(() => { if (ariaLive === 'assertive') { - announceAssertive(message); + announceAssertive({ message }); } else if (ariaLive === 'polite') { - announcePolite(message); + announcePolite({ message }); } }, [message, ariaLive, announceAssertive, announcePolite]); useEffect(() => { return () => { if (clearOnUnmount === true || clearOnUnmount === 'true') { - announceAssertive(''); - announcePolite(''); + announceAssertive({ message: '' }); + announcePolite({ message: '' }); } }; }, [clearOnUnmount, announceAssertive, announcePolite]); diff --git a/client/src/common/selector.ts b/client/src/common/selector.ts index af69ca4af50..002755d114d 100644 --- a/client/src/common/selector.ts +++ b/client/src/common/selector.ts @@ -10,6 +10,8 @@ export interface Endpoint { agentNames?: Record; assistantNames?: Record; modelIcons?: Record; + showMarketplace?: boolean; + searchAliases?: string[]; } export interface SelectedValues { diff --git a/client/src/common/types.ts b/client/src/common/types.ts index 7be7bef749a..9cdebffd2bf 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -351,6 +351,12 @@ export type TOptions = { isResubmission?: boolean; /** Currently only utilized when `isResubmission === true`, uses that message's currently attached files */ overrideFiles?: t.TMessage['files']; + /** + * Assistant message being regenerated. Used to derive the optimistic response + * id for non-tail regenerations without accidentally keying the stream to the + * conversation tail. + */ + targetResponseMessageId?: string | null; /** * Carry forward a user message's manually-invoked skills when the caller * is resubmitting / regenerating that same message — the compose-time @@ -363,7 +369,7 @@ export type TOptions = { addedConvo?: t.TConversation; }; -export type TAskFunction = (props: TAskProps, options?: TOptions) => void; +export type TAskFunction = (props: TAskProps, options?: TOptions) => false | void; /** * Stable context object passed from non-memo'd wrapper components (Message, MessageContent) @@ -652,5 +658,8 @@ export type TThread = { id: string; createdAt: string }; declare global { interface Window { google_tag_manager?: unknown; + __LIBRECHAT_CONFIG__?: { + enableQueryDevtools?: boolean; + }; } } diff --git a/client/src/components/Agents/Marketplace.tsx b/client/src/components/Agents/Marketplace.tsx index adf406f7b06..1e534dbf85b 100644 --- a/client/src/components/Agents/Marketplace.tsx +++ b/client/src/components/Agents/Marketplace.tsx @@ -218,17 +218,17 @@ const AgentMarketplace: React.FC = ({ className = '' }) = {/* Sticky wrapper for search bar and categories */}
-
- - -
+ {isSmallScreen ? ( +
+ + +
+ ) : null} {/* Search bar */}
{/* TODO: Remove this once we have a better way to handle admin settings */} -
- -
+ {!isSmallScreen && }
{/* Category tabs */} diff --git a/client/src/components/Agents/VirtualizedAgentGrid.tsx b/client/src/components/Agents/VirtualizedAgentGrid.tsx index 0fed2c19744..a2843c5b20b 100644 --- a/client/src/components/Agents/VirtualizedAgentGrid.tsx +++ b/client/src/components/Agents/VirtualizedAgentGrid.tsx @@ -1,8 +1,8 @@ import React, { useMemo, useEffect, useCallback, useRef } from 'react'; -import { AutoSizer, List as VirtualList, WindowScroller } from 'react-virtualized'; import { throttle } from 'lodash'; import { Spinner } from '@librechat/client'; import { PermissionBits } from 'librechat-data-provider'; +import { AutoSizer, List as VirtualList, WindowScroller } from 'react-virtualized'; import type t from 'librechat-data-provider'; import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents'; import { useAgentCategories, useLocalize } from '~/hooks'; @@ -175,7 +175,7 @@ const VirtualizedAgentGrid: React.FC = ({ const globalIndex = index * cardsPerRow + cardIndex; return (
- onSelectAgent(agent)} /> +
); })} @@ -282,7 +282,7 @@ const VirtualizedAgentGrid: React.FC = ({ const rowCount = getRowCount(currentAgents.length, cardsPerRow); return ( -
+
}> { }); it('handles null/undefined errors', () => { - render(); + render(); expect(screen.getByText('Something went wrong')).toBeInTheDocument(); expect( diff --git a/client/src/components/Agents/tests/VirtualScrollingPerformance.test.tsx b/client/src/components/Agents/tests/VirtualScrollingPerformance.test.tsx index 43a5be20d5e..198ba4271fd 100644 --- a/client/src/components/Agents/tests/VirtualScrollingPerformance.test.tsx +++ b/client/src/components/Agents/tests/VirtualScrollingPerformance.test.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { jest } from '@jest/globals'; import type * as t from 'librechat-data-provider'; import VirtualizedAgentGrid from '../VirtualizedAgentGrid'; @@ -48,7 +47,9 @@ const mockRowRenderer = jest.fn(); jest.mock('react-virtualized', () => { const ReactActual = jest.requireActual('react'); - const mockRowRendererRef = { current: jest.fn() }; + const mockRowRendererRef: { current: VirtualListMockProps['rowRenderer'] | null } = { + current: null, + }; return { AutoSizer: ({ diff --git a/client/src/components/Agents/tests/VirtualizedAgentGrid.test.tsx b/client/src/components/Agents/tests/VirtualizedAgentGrid.test.tsx index 9fb6c402094..2aed774e819 100644 --- a/client/src/components/Agents/tests/VirtualizedAgentGrid.test.tsx +++ b/client/src/components/Agents/tests/VirtualizedAgentGrid.test.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { jest } from '@jest/globals'; import type t from 'librechat-data-provider'; import VirtualizedAgentGrid from '../VirtualizedAgentGrid'; @@ -166,9 +165,15 @@ jest.mock('../SmartLoader', () => ({ })); jest.mock('../AgentCard', () => { - return function MockAgentCard({ agent, onClick }: { agent: t.Agent; onClick: () => void }) { + return function MockAgentCard({ + agent, + onSelect, + }: { + agent: t.Agent; + onSelect: (agent: t.Agent) => void; + }) { return ( -
+
onSelect(agent)}>

{agent.name}

{agent.description}

diff --git a/client/src/components/Artifacts/Mermaid.tsx b/client/src/components/Artifacts/Mermaid.tsx deleted file mode 100644 index 9d54285cb61..00000000000 --- a/client/src/components/Artifacts/Mermaid.tsx +++ /dev/null @@ -1,176 +0,0 @@ -import React, { useEffect, useRef, useState, useCallback } from 'react'; -import { Button } from '@librechat/client'; -import { ZoomIn, ZoomOut, RotateCcw } from 'lucide-react'; -import { TransformWrapper, TransformComponent } from 'react-zoom-pan-pinch'; -import type { ReactZoomPanPinchRef } from 'react-zoom-pan-pinch'; -import { artifactFlowchartConfig } from '~/utils/mermaid'; - -interface MermaidDiagramProps { - content: string; - isDarkMode?: boolean; -} - -let mermaidPromise: Promise | null = null; - -const loadMermaid = () => { - if (!mermaidPromise) { - mermaidPromise = import('mermaid').then((mod) => mod.default); - } - - return mermaidPromise; -}; - -const MermaidDiagram: React.FC = ({ content, isDarkMode = true }) => { - const mermaidRef = useRef(null); - const transformRef = useRef(null); - const [isRendered, setIsRendered] = useState(false); - const theme = isDarkMode ? 'dark' : 'neutral'; - const bgColor = isDarkMode ? '#212121' : '#FFFFFF'; - - useEffect(() => { - let isMounted = true; - - const renderDiagram = async () => { - try { - const mermaid = await loadMermaid(); - - mermaid.initialize({ - startOnLoad: false, - theme, - securityLevel: 'sandbox', - flowchart: artifactFlowchartConfig, - }); - - if (!mermaidRef.current) { - return; - } - - const { svg } = await mermaid.render('mermaid-diagram', content); - mermaidRef.current.innerHTML = svg; - - const svgElement = mermaidRef.current.querySelector('svg'); - if (svgElement) { - svgElement.style.width = '100%'; - svgElement.style.height = '100%'; - } - if (isMounted) { - setIsRendered(true); - } - } catch (error) { - console.error('Mermaid rendering error:', error); - if (mermaidRef.current) { - mermaidRef.current.innerHTML = 'Error rendering diagram'; - } - } - }; - - renderDiagram(); - - return () => { - isMounted = false; - }; - }, [content, theme]); - - const centerAndFitDiagram = useCallback(() => { - if (transformRef.current && mermaidRef.current) { - const { centerView, zoomToElement } = transformRef.current; - zoomToElement(mermaidRef.current as HTMLElement); - centerView(1, 0); - } - }, []); - - useEffect(() => { - if (isRendered) { - centerAndFitDiagram(); - } - }, [isRendered, centerAndFitDiagram]); - - const handlePanning = useCallback(() => { - if (!transformRef.current) { - return; - } - - const { state, instance } = transformRef.current; - if (!state || !instance) { - return; - } - const { scale, positionX, positionY } = state; - const { wrapperComponent, contentComponent } = instance; - - if (!wrapperComponent || !contentComponent) { - return; - } - - const wrapperRect = wrapperComponent.getBoundingClientRect(); - const contentRect = contentComponent.getBoundingClientRect(); - const maxX = wrapperRect.width - contentRect.width * scale; - const maxY = wrapperRect.height - contentRect.height * scale; - - let newX = positionX; - let newY = positionY; - - if (newX > 0) { - newX = 0; - } - if (newY > 0) { - newY = 0; - } - if (newX < maxX) { - newX = maxX; - } - if (newY < maxY) { - newY = maxY; - } - - if (newX !== positionX || newY !== positionY) { - instance.setTransformState(scale, newX, newY); - } - }, []); - - return ( -
- - {({ zoomIn, zoomOut }) => ( - <> - -
- -
- - - -
- - )} - -
- ); -}; - -export default MermaidDiagram; diff --git a/client/src/components/Auth/__tests__/Login.spec.tsx b/client/src/components/Auth/__tests__/Login.spec.tsx index 3937deb6242..f01c12d2cfe 100644 --- a/client/src/components/Auth/__tests__/Login.spec.tsx +++ b/client/src/components/Auth/__tests__/Login.spec.tsx @@ -1,7 +1,7 @@ import reactRouter from 'react-router-dom'; import userEvent from '@testing-library/user-event'; -import { getByTestId, render, waitFor } from 'test/layout-test-utils'; import type { TStartupConfig } from 'librechat-data-provider'; +import { getByTestId, render, waitFor } from 'test/layout-test-utils'; import * as endpointQueries from '~/data-provider/Endpoints/queries'; import * as miscDataProvider from '~/data-provider/Misc/queries'; import * as authMutations from '~/data-provider/Auth/mutations'; @@ -176,7 +176,7 @@ test('calls loginUser.mutate on login', async () => { }); test('Navigates to / on successful login', async () => { - const { getByLabelText, history } = setup({ + const { getByLabelText } = setup({ // @ts-ignore - we don't need all parameters of the QueryObserverResult useLoginUserReturnValue: { isLoading: false, @@ -202,5 +202,5 @@ test('Navigates to / on successful login', async () => { await userEvent.type(passwordInput, 'password'); await userEvent.click(submitButton); - waitFor(() => expect(history.location.pathname).toBe('/')); + waitFor(() => expect(window.location.pathname).toBe('/')); }); diff --git a/client/src/components/Auth/__tests__/LoginForm.spec.tsx b/client/src/components/Auth/__tests__/LoginForm.spec.tsx index f6376d166d1..14692befaad 100644 --- a/client/src/components/Auth/__tests__/LoginForm.spec.tsx +++ b/client/src/components/Auth/__tests__/LoginForm.spec.tsx @@ -1,9 +1,9 @@ -import { render, getByTestId } from 'test/layout-test-utils'; import userEvent from '@testing-library/user-event'; import type { TStartupConfig } from 'librechat-data-provider'; import * as endpointQueries from '~/data-provider/Endpoints/queries'; import * as miscDataProvider from '~/data-provider/Misc/queries'; import * as authMutations from '~/data-provider/Auth/mutations'; +import { render, getByTestId } from 'test/layout-test-utils'; import * as authQueries from '~/data-provider/Auth/queries'; import Login from '../LoginForm'; @@ -18,8 +18,10 @@ const mockStartupConfig: TStartupConfig = { githubLoginEnabled: true, googleLoginEnabled: true, openidLoginEnabled: true, + appleLoginEnabled: false, openidLabel: 'Test OpenID', openidImageUrl: 'http://test-server.com', + openidAutoRedirect: false, samlLoginEnabled: true, samlLabel: 'Test SAML', samlImageUrl: 'http://test-server.com', @@ -33,9 +35,11 @@ const mockStartupConfig: TStartupConfig = { enabled: false, }, emailEnabled: false, - checkBalance: false, showBirthdayIcon: false, helpAndFaqURL: '', + sharedLinksEnabled: true, + publicSharedLinksEnabled: true, + allowAccountDeletion: true, }; const setup = ({ @@ -106,15 +110,25 @@ beforeEach(() => { test('renders login form', () => { const { getByLabelText } = render( - , + , ); expect(getByLabelText(/email/i)).toBeInTheDocument(); expect(getByLabelText(/password/i)).toBeInTheDocument(); }); test('submits login form', async () => { - const { getByLabelText, getByRole } = render( - , + const { getByLabelText } = render( + , ); const emailInput = getByLabelText(/email/i); const passwordInput = getByLabelText(/password/i); @@ -128,8 +142,13 @@ test('submits login form', async () => { }); test('displays validation error messages', async () => { - const { getByLabelText, getByRole, getByText } = render( - , + const { getByLabelText, getByText } = render( + , ); const emailInput = getByLabelText(/email/i); const passwordInput = getByLabelText(/password/i); diff --git a/client/src/components/Chat/ChatView.tsx b/client/src/components/Chat/ChatView.tsx index 27530a5c228..1c84b93b82a 100644 --- a/client/src/components/Chat/ChatView.tsx +++ b/client/src/components/Chat/ChatView.tsx @@ -4,12 +4,19 @@ import { useForm } from 'react-hook-form'; import { Spinner } from '@librechat/client'; import { useParams } from 'react-router-dom'; import { Constants, buildTree } from 'librechat-data-provider'; -import type { TMessage } from 'librechat-data-provider'; +import type { TChatProject, TMessage } from 'librechat-data-provider'; import type { ChatFormValues } from '~/common'; +import { + useAddedResponse, + useResumeOnLoad, + useAdaptiveSSE, + useChatHelpers, + useLocalize, +} from '~/hooks'; import { ChatContext, AddedChatContext, ChatFormProvider, useFileMapContext } from '~/Providers'; -import { useAddedResponse, useResumeOnLoad, useAdaptiveSSE, useChatHelpers } from '~/hooks'; import ConversationStarters from './Input/ConversationStarters'; import { useGetMessagesByConvoId } from '~/data-provider'; +import ProjectLandingChip from './ProjectLandingChip'; import MessagesView from './Messages/MessagesView'; import Presentation from './Presentation'; import ChatForm from './Input/ChatForm'; @@ -29,8 +36,9 @@ function LoadingSpinner() { ); } -function ChatView({ index = 0 }: { index?: number }) { +function ChatView({ index = 0, project }: { index?: number; project?: TChatProject }) { const { conversationId } = useParams(); + const localize = useLocalize(); const rootSubmission = useRecoilValue(store.submissionByIndex(index)); const isSubmitting = useRecoilValue(store.isSubmittingFamily(index)); const centerFormOnLanding = useRecoilValue(store.centerFormOnLanding); @@ -70,6 +78,7 @@ function ChatView({ index = 0 }: { index?: number }) { (!messagesTree || messagesTree.length === 0) && (conversationId === Constants.NEW_CONVO || !conversationId); const isNavigating = (!messagesTree || messagesTree.length === 0) && conversationId != null; + const isProjectLandingPage = isLandingPage && project != null; if (isLoading && conversationId !== Constants.NEW_CONVO) { content = ; @@ -81,6 +90,11 @@ function ChatView({ index = 0 }: { index?: number }) { content = ; } + const chatFormPlaceholder = + isProjectLandingPage && project + ? localize('com_ui_new_chat_in_project', { name: project.name }) + : undefined; + return ( @@ -104,8 +118,10 @@ function ChatView({ index = 0 }: { index?: number }) { isLandingPage && 'max-w-3xl transition-all duration-200 xl:max-w-4xl', )} > - - {isLandingPage ? :
} + {isProjectLandingPage && project && } + {isLandingPage && } + + {!isLandingPage &&
}
{isLandingPage &&
} diff --git a/client/src/components/Chat/ExportAndShareMenu.tsx b/client/src/components/Chat/ExportAndShareMenu.tsx index 739f2c497b6..5dcbe9ea139 100644 --- a/client/src/components/Chat/ExportAndShareMenu.tsx +++ b/client/src/components/Chat/ExportAndShareMenu.tsx @@ -2,11 +2,12 @@ import { useState, useId, useRef } from 'react'; import { useRecoilValue } from 'recoil'; import * as Ariakit from '@ariakit/react'; import { Upload, Share2 } from 'lucide-react'; +import { PermissionTypes, Permissions } from 'librechat-data-provider'; import { DropdownPopup, TooltipAnchor, useMediaQuery } from '@librechat/client'; import type * as t from '~/common'; import ExportModal from '~/components/Nav/ExportConversation/ExportModal'; import { ShareButton } from '~/components/Conversations/ConvoOptions'; -import { useLocalize } from '~/hooks'; +import { useHasAccess, useLocalize } from '~/hooks'; import store from '~/store'; export default function ExportAndShareMenu({ @@ -22,6 +23,10 @@ export default function ExportAndShareMenu({ const menuId = useId(); const shareButtonRef = useRef(null); const exportButtonRef = useRef(null); + const canCreateSharedLinks = useHasAccess({ + permissionType: PermissionTypes.SHARED_LINKS, + permission: Permissions.CREATE, + }); const isSmallScreen = useMediaQuery('(max-width: 768px)'); const conversation = useRecoilValue(store.conversationByIndex(0)); @@ -48,11 +53,11 @@ export default function ExportAndShareMenu({ label: localize('com_ui_share'), onClick: shareHandler, icon: , - show: isSharedButtonEnabled, + show: isSharedButtonEnabled && canCreateSharedLinks, /** NOTE: THE FOLLOWING PROPS ARE REQUIRED FOR MENU ITEMS THAT OPEN DIALOGS */ hideOnClick: false, ref: shareButtonRef, - render: (props) => ))}
diff --git a/client/src/components/Chat/Input/TokenUsage/Breakdown.tsx b/client/src/components/Chat/Input/TokenUsage/Breakdown.tsx new file mode 100644 index 00000000000..f8c479b68a3 --- /dev/null +++ b/client/src/components/Chat/Input/TokenUsage/Breakdown.tsx @@ -0,0 +1,202 @@ +import type { TokenUsageView } from '~/hooks/Chat/useTokenUsage'; +import type { CurrencyConfig } from '~/utils'; +import { groupToolTokens, formatTokens, formatCost } from '~/utils'; +import { useLocalize } from '~/hooks'; + +interface RowProps { + label: string; + value: number; + max?: number; +} + +function Row({ label, value, max }: RowProps) { + const percent = max != null && max > 0 ? Math.min((value / max) * 100, 100) : null; + return ( +
+ {label} + + {formatTokens(value)} + {percent != null && ( + + )} + +
+ ); +} + +interface BreakdownProps { + view: TokenUsageView; + showCost: boolean; + currency?: CurrencyConfig; +} + +export default function Breakdown({ view, showCost, currency }: BreakdownProps) { + const localize = useLocalize(); + const { usedTokens, maxTokens, percent, snapshot, snapshotActive, branchUsage, hasUsage } = view; + /** Show the all-branches total only when it (a) exceeds the active branch — + * epsilon guards against float summation order surfacing a spurious row in an + * unbranched conversation — and (b) has COMPLETE cost coverage, so a sibling + * branch saved without cost can't render an under-reported total. */ + const showTotal = view.totalUsage.costKnown && view.totalCost - view.branchCost > 1e-9; + + const breakdown = snapshotActive ? snapshot?.breakdown : undefined; + const instructionTokens = + snapshot?.effectiveInstructionTokens ?? breakdown?.instructionTokens ?? 0; + const systemTokens = + (breakdown?.systemMessageTokens ?? 0) + (breakdown?.dynamicInstructionTokens ?? 0); + /** Summary has its own row, so exclude it (it's part of `usedTokens`) to avoid + * double-counting it inside the Messages row on a summarized turn. */ + const messageTokens = Math.max( + 0, + usedTokens - instructionTokens - (breakdown?.summaryTokens ?? 0), + ); + const freeTokens = maxTokens != null ? Math.max(0, maxTokens - usedTokens) : null; + + const groups = + breakdown?.toolTokenCounts != null + ? groupToolTokens(breakdown.toolTokenCounts, breakdown.deferredToolNames) + : null; + const toolRows = + groups == null + ? null + : ([ + [localize('com_ui_context_tools_system'), groups.system], + [localize('com_ui_context_tools_mcp'), groups.mcp], + [localize('com_ui_skills'), groups.skills], + [localize('com_ui_context_subagents'), groups.subagents], + [localize('com_ui_context_tools_system_deferred'), groups.systemDeferred], + [localize('com_ui_context_tools_mcp_deferred'), groups.mcpDeferred], + ] as const); + + return ( +
+
+ + {localize('com_ui_context_window')} + + + {maxTokens != null + ? `${formatTokens(usedTokens)} / ${formatTokens(maxTokens)} (${Math.round(percent)}%)` + : formatTokens(usedTokens)} + +
+ +
+ {percent > 0 && ( +
+ )} +
+ +
+ {breakdown ? ( + <> + + {systemTokens > 0 && ( + + )} + {toolRows != null ? ( + toolRows.map( + ([label, value]) => + value > 0 && , + ) + ) : ( + + )} + {breakdown.summaryTokens > 0 && ( + + )} + {freeTokens != null && ( + + )} + + ) : ( + <> + {view.branchTotals.summaryBaseline > 0 && ( + + )} + + + {maxTokens == null && ( +

{localize('com_ui_context_unknown')}

+ )} +

{localize('com_ui_estimated')}

+ + )} +
+ + {hasUsage && ( + <> +
+
+ + + {branchUsage.cacheRead > 0 && ( + + )} + {branchUsage.cacheWrite > 0 && ( + + )} +
+ + )} + + {showCost && hasUsage && branchUsage.costKnown && ( + <> +
+
+
+ + {showTotal + ? localize('com_ui_context_cost_branch') + : localize('com_ui_context_cost')} + + + {formatCost(view.branchCost, currency)} + +
+ {showTotal && ( +
+ {localize('com_ui_context_cost_total')} + {formatCost(view.totalCost, currency)} +
+ )} +
+ + )} +
+ ); +} diff --git a/client/src/components/Chat/Input/TokenUsage/Gauge.tsx b/client/src/components/Chat/Input/TokenUsage/Gauge.tsx new file mode 100644 index 00000000000..ba7e420302d --- /dev/null +++ b/client/src/components/Chat/Input/TokenUsage/Gauge.tsx @@ -0,0 +1,62 @@ +import { cn } from '~/utils'; + +const SIZE = 28; +const STROKE_WIDTH = 3.5; +const RADIUS = (SIZE - STROKE_WIDTH) / 2; +const CIRCUMFERENCE = 2 * Math.PI * RADIUS; + +interface GaugeProps { + /** 0–100, clamped by the caller */ + percent: number; + /** Max context unknown — render an empty track only */ + indeterminate: boolean; +} + +function getStrokeClass(percent: number, indeterminate: boolean): string { + if (indeterminate) { + return 'stroke-text-secondary'; + } + if (percent > 90) { + return 'stroke-red-500'; + } + if (percent > 75) { + return 'stroke-yellow-500'; + } + return 'stroke-text-secondary'; +} + +export default function Gauge({ percent, indeterminate }: GaugeProps) { + const offset = CIRCUMFERENCE - (percent / 100) * CIRCUMFERENCE; + + return ( + + ); +} diff --git a/client/src/components/Chat/Input/TokenUsage/index.tsx b/client/src/components/Chat/Input/TokenUsage/index.tsx new file mode 100644 index 00000000000..cb9f4894a58 --- /dev/null +++ b/client/src/components/Chat/Input/TokenUsage/index.tsx @@ -0,0 +1,123 @@ +import { memo } from 'react'; +import * as Ariakit from '@ariakit/react'; +import { TooltipAnchor } from '@librechat/client'; +import type { TConversation } from 'librechat-data-provider'; +import type { CurrencyConfig } from '~/utils'; +import { formatTokens, formatCost, cn } from '~/utils'; +import useTokenUsage from '~/hooks/Chat/useTokenUsage'; +import { useGetStartupConfig } from '~/data-provider'; +import { useLocalize } from '~/hooks'; +import Breakdown from './Breakdown'; +import Gauge from './Gauge'; + +interface TokenUsageProps { + index: number; + conversation: TConversation | null; + isSubmitting: boolean; +} + +function TokenUsageIndicator({ + index, + conversation, + isSubmitting, + showCost, + currency, +}: TokenUsageProps & { + showCost: boolean; + currency?: CurrencyConfig; +}) { + const localize = useLocalize(); + const view = useTokenUsage({ index, conversation, isSubmitting }); + const popover = Ariakit.usePopoverStore({ placement: 'top' }); + + /** Hide until the branch has data — keeps a fresh, message-less chat clean and + * lets the indicator animate into view once the first tokens land. */ + if (view.usedTokens <= 0) { + return null; + } + + const hasMax = view.maxTokens != null && view.maxTokens > 0; + const ariaLabel = hasMax + ? localize('com_ui_context_usage_label', { + 0: formatTokens(view.usedTokens), + 1: formatTokens(view.maxTokens ?? 0), + 2: String(Math.round(view.percent)), + }) + : localize('com_ui_context_usage_label_unknown', { 0: formatTokens(view.usedTokens) }); + + const snapshotSummary = hasMax + ? localize('com_ui_context_usage_snapshot', { + 0: formatTokens(view.usedTokens), + 1: formatTokens(view.maxTokens ?? 0), + 2: String(Math.round(view.percent)), + }) + : localize('com_ui_context_usage_snapshot_unknown', { 0: formatTokens(view.usedTokens) }); + const snapshot = + showCost && view.hasUsage && view.branchUsage.costKnown + ? `${snapshotSummary} · ${formatCost(view.branchCost, currency)}` + : snapshotSummary; + + return ( + <> + + + + + + } + /> + + + + + ); +} + +/** Config gate kept outside the indicator so disabled deployments mount nothing */ +const TokenUsage = memo(function TokenUsage(props: TokenUsageProps) { + const { data: startupConfig } = useGetStartupConfig(); + /** Wait for config before mounting: until it loads `contextUsage === false` + * reads as undefined, so a disabled deployment would briefly mount the + * indicator and fire the token-config query on first load */ + if (startupConfig == null || startupConfig.interface?.contextUsage === false) { + return null; + } + return ( + + ); +}); + +export default TokenUsage; diff --git a/client/src/components/Chat/Input/ToolsDropdown.tsx b/client/src/components/Chat/Input/ToolsDropdown.tsx index 10ba4458709..14530c8d37a 100644 --- a/client/src/components/Chat/Input/ToolsDropdown.tsx +++ b/client/src/components/Chat/Input/ToolsDropdown.tsx @@ -278,7 +278,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => { onClick: handleSkillsToggle, hideOnClick: false, render: (props) => ( -
+
- {description && ( -
- {description} -
- )} + {description && + (descriptionIsHTML ? ( +
+ ) : ( +
+ {description} +
+ ))}
); diff --git a/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx b/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx index 5a51db6ce99..b09bef94be9 100644 --- a/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx +++ b/client/src/components/Chat/Menus/Endpoints/ModelSelectorContext.tsx @@ -1,5 +1,5 @@ -import debounce from 'lodash/debounce'; import React, { createContext, useContext, useState, useMemo, useCallback } from 'react'; +import debounce from 'lodash/debounce'; import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider'; import type * as t from 'librechat-data-provider'; import type { Endpoint, SelectedValues } from '~/common'; @@ -161,8 +161,8 @@ export function ModelSelectorProvider({ children, startupConfig }: ModelSelector return null; } const allItems = [...modelSpecs, ...mappedEndpoints]; - return filterItems(allItems, searchValue, agentsMap, assistantsMap || {}); - }, [searchValue, modelSpecs, mappedEndpoints, agentsMap, assistantsMap]); + return filterItems(allItems, searchValue, agentsMap, assistantsMap || {}, localize); + }, [searchValue, modelSpecs, mappedEndpoints, agentsMap, assistantsMap, localize]); const setDebouncedSearchValue = useMemo( () => diff --git a/client/src/components/Chat/Menus/Endpoints/__tests__/utils.test.ts b/client/src/components/Chat/Menus/Endpoints/__tests__/utils.test.ts new file mode 100644 index 00000000000..a4b5069a691 --- /dev/null +++ b/client/src/components/Chat/Menus/Endpoints/__tests__/utils.test.ts @@ -0,0 +1,46 @@ +import type { useLocalize } from '~/hooks'; +import type { Endpoint } from '~/common'; +import { filterItems } from '../utils'; + +const agentsEndpoint: Endpoint = { + value: 'agents', + label: 'My Agents', + hasModels: true, + icon: null, + showMarketplace: true, + searchAliases: ['agent marketplace', 'marketplace'], +}; + +const disabledAgentsEndpoint: Endpoint = { + value: 'agents', + label: 'My Agents', + hasModels: false, + icon: null, +}; + +describe('model selector utilities', () => { + it('matches endpoint search aliases', () => { + const results = filterItems([agentsEndpoint], 'marketplace', undefined, undefined); + expect(results).toEqual([agentsEndpoint]); + }); + + it('matches localized Marketplace labels', () => { + const localize = ((key: string) => { + if (key === 'com_agents_marketplace') { + return 'Tienda de Agentes'; + } + if (key === 'com_ui_marketplace') { + return 'Tienda'; + } + return key; + }) as ReturnType; + + const results = filterItems([agentsEndpoint], 'tienda', undefined, undefined, localize); + expect(results).toEqual([agentsEndpoint]); + }); + + it('does not match agents when there are no selectable agent options', () => { + const results = filterItems([disabledAgentsEndpoint], 'my agents', undefined, undefined); + expect(results).toEqual([]); + }); +}); diff --git a/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx b/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx index c8cef36010f..fc2852edda1 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/EndpointItem.tsx @@ -5,11 +5,12 @@ import { CheckCircle2, MousePointerClick, SettingsIcon } from 'lucide-react'; import { EModelEndpoint, isAgentsEndpoint, isAssistantsEndpoint } from 'librechat-data-provider'; import type { TModelSpec } from 'librechat-data-provider'; import type { Endpoint } from '~/common'; -import { CustomMenu as Menu, CustomMenuItem as MenuItem } from '../CustomMenu'; +import { CustomMenu as Menu, CustomMenuItem as MenuItem, CustomMenuSeparator } from '../CustomMenu'; +import MarketplaceItem, { marketplaceSearchMatches } from './Marketplace'; +import { filterModels, shouldRenderEndpointOption } from '../utils'; import { useModelSelectorContext } from '../ModelSelectorContext'; import { renderEndpointModels } from './EndpointModelItem'; import { ModelSpecItem } from './ModelSpecItem'; -import { filterModels } from '../utils'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; @@ -127,9 +128,15 @@ function EndpointMenuContent({ assistantsMap, ) : null; + const renderedModels = filteredModels ?? endpoint.models?.map((model) => model.name) ?? []; + const showMarketplace = + endpoint.showMarketplace === true && marketplaceSearchMatches(searchValue, localize); + const hasSelectableRows = endpointSpecs.length > 0 || renderedModels.length > 0; return ( <> + {showMarketplace && } + {showMarketplace && hasSelectableRows && } {endpointSpecs.map((spec: TModelSpec) => ( ))} @@ -175,6 +182,10 @@ export function EndpointItem({ endpoint, endpointIndex }: EndpointItemProps) { const isEndpointSelected = !selectedSpec && selectedEndpoint === endpoint.value; + if (!shouldRenderEndpointOption(endpoint)) { + return null; + } + if (endpoint.hasModels) { const placeholder = isAgentsEndpoint(endpoint.value) || isAssistantsEndpoint(endpoint.value) diff --git a/client/src/components/Chat/Menus/Endpoints/components/GroupIcon.tsx b/client/src/components/Chat/Menus/Endpoints/components/GroupIcon.tsx index eb1081435d8..3c4cb840a46 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/GroupIcon.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/GroupIcon.tsx @@ -1,6 +1,7 @@ import React, { memo, useState } from 'react'; import { AlertCircle } from 'lucide-react'; import type { IconMapProps } from '~/common'; +import { getKnownEndpointAsset, hasKnownEndpointIcon } from '~/hooks/Endpoint/UnknownIcon'; import { icons } from '~/hooks/Endpoint/Icons'; interface GroupIconProps { @@ -42,13 +43,27 @@ const GroupIcon: React.FC = ({ iconURL, groupName }) => { ); } + const resolvedIconURL = getKnownEndpointAsset(iconURL); + + if (!resolvedIconURL && hasKnownEndpointIcon(iconURL)) { + const Icon: IconType = icons.unknown as IconType; + return ( + + ); + } + return (
{groupName} label.toLowerCase().includes(searchTerm)); +} + +export default function MarketplaceItem({ + className, + label, +}: { + className?: string; + label: string; +}) { + const navigate = useNavigate(); + + return ( + navigate('/agents')} + aria-label={label} + data-testid="model-selector-marketplace-item" + className={cn( + 'flex w-full cursor-pointer items-center justify-between rounded-lg px-2 text-sm', + className, + )} + > +
+
+
+ {label} +
+
+ ); +} diff --git a/client/src/components/Chat/Menus/Endpoints/components/ModelSpecItem.tsx b/client/src/components/Chat/Menus/Endpoints/components/ModelSpecItem.tsx index a73d1d8e47c..10662fa55b5 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/ModelSpecItem.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/ModelSpecItem.tsx @@ -5,6 +5,7 @@ import type { TModelSpec } from 'librechat-data-provider'; import { useFavorites, useLocalize, useIsActiveItem } from '~/hooks'; import { useModelSelectorContext } from '../ModelSelectorContext'; import { CustomMenuItem as MenuItem } from '../CustomMenu'; +import SpecDescription from './SpecDescription'; import SpecIcon from './SpecIcon'; import { cn } from '~/utils'; @@ -48,9 +49,7 @@ export function ModelSpecItem({ spec, isSelected }: ModelSpecItemProps) { )}
{spec.label} - {spec.description && ( - {spec.description} - )} +
{selectedSpec === spec.name && ( @@ -102,11 +103,20 @@ export function SearchResults({ results, localize, searchValue }: SearchResultsP } else { // For an endpoint item const endpoint = item as Endpoint; - if (endpoint.hasModels && endpoint.models && endpoint.models.length > 0) { + if (!shouldRenderEndpointOption(endpoint)) { + return null; + } + + if (endpoint.hasModels) { const lowerQuery = searchValue.toLowerCase(); - const filteredModels = endpoint.label.toLowerCase().includes(lowerQuery) - ? endpoint.models - : endpoint.models.filter((model) => { + const endpointMatches = endpoint.label.toLowerCase().includes(lowerQuery); + const showMarketplace = + endpoint.showMarketplace === true && + (endpointMatches || marketplaceSearchMatches(searchValue, localize)); + const models = endpoint.models ?? []; + const filteredModels = endpointMatches + ? models + : models.filter((model) => { let modelName = model.name; if ( isAgentsEndpoint(endpoint.value) && @@ -124,7 +134,7 @@ export function SearchResults({ results, localize, searchValue }: SearchResultsP return modelName.toLowerCase().includes(lowerQuery); }); - if (!filteredModels.length) { + if (!filteredModels.length && !showMarketplace) { return null; // skip if no models match } @@ -138,6 +148,12 @@ export function SearchResults({ results, localize, searchValue }: SearchResultsP )} {endpoint.label}
+ {showMarketplace && ( + + )} {filteredModels.map((model) => { const modelId = model.name; diff --git a/client/src/components/Chat/Menus/Endpoints/components/SpecDescription.tsx b/client/src/components/Chat/Menus/Endpoints/components/SpecDescription.tsx new file mode 100644 index 00000000000..b67395380ad --- /dev/null +++ b/client/src/components/Chat/Menus/Endpoints/components/SpecDescription.tsx @@ -0,0 +1,32 @@ +import { useMemo } from 'react'; +import { createConfigHtmlSanitizer, CONFIG_HTML_MEDIA_TAGS, CONFIG_HTML_MEDIA_ATTR } from '~/utils'; + +interface SpecDescriptionProps { + description?: string; +} + +export default function SpecDescription({ description }: SpecDescriptionProps) { + const sanitize = useMemo( + () => + createConfigHtmlSanitizer({ + allowedTags: CONFIG_HTML_MEDIA_TAGS, + allowedAttr: CONFIG_HTML_MEDIA_ATTR, + }), + [], + ); + + if (!description) { + return null; + } + + if (!description.trim().startsWith('<')) { + return {description}; + } + + return ( + + ); +} diff --git a/client/src/components/Chat/Menus/Endpoints/components/__tests__/EndpointItem.test.tsx b/client/src/components/Chat/Menus/Endpoints/components/__tests__/EndpointItem.test.tsx new file mode 100644 index 00000000000..5a8d6eca1c1 --- /dev/null +++ b/client/src/components/Chat/Menus/Endpoints/components/__tests__/EndpointItem.test.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import type { Endpoint, SelectedValues } from '~/common'; +import { EndpointItem } from '../EndpointItem'; + +const mockHandleSelectEndpoint = jest.fn(); +const mockHandleOpenKeyDialog = jest.fn(); +const mockSetEndpointSearchValue = jest.fn(); + +let mockSelectedValues: SelectedValues = { endpoint: '', model: '', modelSpec: '' }; + +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => key, +})); + +jest.mock('~/components/Chat/Menus/Endpoints/ModelSelectorContext', () => ({ + useModelSelectorContext: () => ({ + agentsMap: undefined, + assistantsMap: undefined, + modelSpecs: [], + selectedValues: mockSelectedValues, + endpointSearchValues: {}, + handleOpenKeyDialog: mockHandleOpenKeyDialog, + handleSelectEndpoint: mockHandleSelectEndpoint, + setEndpointSearchValue: mockSetEndpointSearchValue, + endpointRequiresUserKey: () => false, + }), +})); + +jest.mock('~/components/Chat/Menus/Endpoints/CustomMenu', () => { + const React = jest.requireActual('react'); + + return { + CustomMenu: ({ children, label }: { children?: React.ReactNode; label?: React.ReactNode }) => + React.createElement('div', null, label, children), + CustomMenuItem: React.forwardRef(function MockMenuItem( + { children, ...rest }: { children?: React.ReactNode }, + ref: React.Ref, + ) { + return React.createElement('button', { ref, type: 'button', ...rest }, children); + }), + CustomMenuSeparator: () => React.createElement('hr'), + }; +}); + +const disabledAgentsEndpoint: Endpoint = { + value: 'agents', + label: 'My Agents', + hasModels: false, + icon: null, +}; + +const customEndpoint: Endpoint = { + value: 'custom', + label: 'Custom', + hasModels: false, + icon: null, +}; + +describe('EndpointItem', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockSelectedValues = { endpoint: '', model: '', modelSpec: '' }; + }); + + it('does not render agents as a leaf endpoint when no selectable rows exist', () => { + render(); + + expect(screen.queryByText('My Agents')).not.toBeInTheDocument(); + expect(mockHandleSelectEndpoint).not.toHaveBeenCalled(); + }); + + it('keeps non-agent endpoints without models selectable', () => { + render(); + + fireEvent.click(screen.getByRole('button', { name: 'Custom' })); + + expect(mockHandleSelectEndpoint).toHaveBeenCalledWith(customEndpoint); + }); +}); diff --git a/client/src/components/Chat/Menus/Endpoints/components/__tests__/GroupIcon.test.tsx b/client/src/components/Chat/Menus/Endpoints/components/__tests__/GroupIcon.test.tsx new file mode 100644 index 00000000000..ee8ba0f5f06 --- /dev/null +++ b/client/src/components/Chat/Menus/Endpoints/components/__tests__/GroupIcon.test.tsx @@ -0,0 +1,64 @@ +import { render, screen } from '@testing-library/react'; +import GroupIcon from '../GroupIcon'; + +jest.mock('~/hooks/Endpoint/Icons', () => { + const React = jest.requireActual('react'); + const createIcon = + (iconKey: string) => + ({ className, endpoint }: { className?: string; endpoint?: string | null }) => + React.createElement('span', { + className, + 'data-testid': 'endpoint-icon', + 'data-icon-key': iconKey, + 'data-endpoint': endpoint ?? '', + }); + + return { + icons: { + openAI: createIcon('openAI'), + unknown: createIcon('unknown'), + }, + }; +}); + +describe('GroupIcon', () => { + it('renders built-in endpoint icon keys', () => { + render(); + + expect(screen.getByTestId('endpoint-icon')).toHaveAttribute('data-icon-key', 'openAI'); + }); + + it('resolves known endpoint asset aliases case-insensitively', () => { + render(); + + expect(screen.getByRole('img', { name: 'OpenRouter' })).toHaveAttribute( + 'src', + 'assets/openrouter.png', + ); + }); + + it('resolves known endpoint asset aliases to shipped file paths', () => { + render(); + + expect(screen.getByRole('img', { name: 'Helicone' })).toHaveAttribute( + 'src', + 'assets/helicone.svg', + ); + }); + + it('renders known endpoint aliases backed by components', () => { + render(); + + expect(screen.getByTestId('endpoint-icon')).toHaveAttribute('data-icon-key', 'unknown'); + expect(screen.getByTestId('endpoint-icon')).toHaveAttribute('data-endpoint', 'Moonshot'); + }); + + it('renders configured image URLs directly', () => { + render(); + + expect(screen.getByRole('img', { name: 'OpenRouter' })).toHaveAttribute( + 'src', + '/assets/openrouter.png', + ); + }); +}); diff --git a/client/src/components/Chat/Menus/Endpoints/components/__tests__/SearchResults.test.tsx b/client/src/components/Chat/Menus/Endpoints/components/__tests__/SearchResults.test.tsx index 8ab9235f6fd..34acdd0e792 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/__tests__/SearchResults.test.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/__tests__/SearchResults.test.tsx @@ -1,10 +1,11 @@ -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import type { Endpoint, SelectedValues } from '~/common'; import { SearchResults } from '../SearchResults'; const mockHandleSelectSpec = jest.fn(); const mockHandleSelectModel = jest.fn(); const mockHandleSelectEndpoint = jest.fn(); +const mockNavigate = jest.fn(); let mockSelectedValues: SelectedValues; jest.mock('~/components/Chat/Menus/Endpoints/ModelSelectorContext', () => ({ @@ -29,6 +30,10 @@ jest.mock('~/components/Chat/Menus/Endpoints/CustomMenu', () => { }; }); +jest.mock('react-router-dom', () => ({ + useNavigate: () => mockNavigate, +})); + jest.mock('../SpecIcon', () => { const React = jest.requireActual('react'); return { @@ -54,6 +59,24 @@ const noModelsEndpoint: Endpoint = { icon: null, }; +const agentsMarketplaceEndpoint: Endpoint = { + value: 'agents', + label: 'My Agents', + hasModels: true, + models: [{ name: 'agent-1' }], + agentNames: { 'agent-1': 'Support Agent' }, + showMarketplace: true, + searchAliases: ['agent marketplace', 'marketplace'], + icon: null, +}; + +const disabledAgentsEndpoint: Endpoint = { + value: 'agents', + label: 'My Agents', + hasModels: false, + icon: null, +}; + describe('SearchResults', () => { beforeEach(() => { jest.clearAllMocks(); @@ -106,4 +129,36 @@ describe('SearchResults', () => { const item = screen.getByRole('menuitem'); expect(item).toHaveAttribute('aria-selected', 'true'); }); + + it('renders Marketplace from agent endpoint search results and navigates to agents', () => { + mockSelectedValues = { endpoint: '', model: '', modelSpec: '' }; + render( + , + ); + + const item = screen.getByRole('menuitem', { name: 'com_agents_marketplace' }); + expect(item).toBeInTheDocument(); + + fireEvent.click(item); + expect(mockNavigate).toHaveBeenCalledWith('/agents'); + expect(mockHandleSelectModel).not.toHaveBeenCalled(); + }); + + it('does not render agents as a selectable endpoint when marketplace and agent rows are unavailable', () => { + mockSelectedValues = { endpoint: '', model: '', modelSpec: '' }; + render( + , + ); + + expect(screen.queryByRole('menuitem', { name: 'My Agents' })).not.toBeInTheDocument(); + expect(mockHandleSelectEndpoint).not.toHaveBeenCalled(); + }); }); diff --git a/client/src/components/Chat/Menus/Endpoints/components/__tests__/SpecDescription.spec.tsx b/client/src/components/Chat/Menus/Endpoints/components/__tests__/SpecDescription.spec.tsx new file mode 100644 index 00000000000..524e1ff5837 --- /dev/null +++ b/client/src/components/Chat/Menus/Endpoints/components/__tests__/SpecDescription.spec.tsx @@ -0,0 +1,39 @@ +import { render, screen } from '@testing-library/react'; +import SpecDescription from '../SpecDescription'; + +describe('SpecDescription', () => { + it('renders nothing without a description', () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('renders plain text descriptions without interpreting markup', () => { + render(); + + expect(screen.getByText('Fast & accurate < 1s responses')).toBeInTheDocument(); + }); + + it('renders HTML descriptions with inline images', () => { + const { container } = render( + , + ); + + const image = container.querySelector('img'); + expect(image).toHaveAttribute('src', '/assets/claude.png'); + expect(image).toHaveAttribute('alt', 'Claude'); + expect(container).toHaveTextContent('Powered by Claude'); + }); + + it('strips scripts, event handlers, and unsafe URLs from HTML descriptions', () => { + const { container } = render( + , + ); + + expect(container).toHaveTextContent('Safe'); + expect(container.querySelector('script')).toBeNull(); + expect(container.querySelector('[onclick]')).toBeNull(); + expect(container.querySelector('[onerror]')).toBeNull(); + expect(container.querySelector('img')?.getAttribute('src') ?? '').not.toContain('javascript'); + }); +}); diff --git a/client/src/components/Chat/Menus/Endpoints/components/index.ts b/client/src/components/Chat/Menus/Endpoints/components/index.ts index bc08e6a8a18..a2e3478bfdb 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/index.ts +++ b/client/src/components/Chat/Menus/Endpoints/components/index.ts @@ -3,3 +3,4 @@ export * from './EndpointModelItem'; export * from './EndpointItem'; export * from './SearchResults'; export * from './CustomGroup'; +export * from './Marketplace'; diff --git a/client/src/components/Chat/Menus/Endpoints/utils.ts b/client/src/components/Chat/Menus/Endpoints/utils.ts index 1681ed7f1d6..474d3f23ea6 100644 --- a/client/src/components/Chat/Menus/Endpoints/utils.ts +++ b/client/src/components/Chat/Menus/Endpoints/utils.ts @@ -16,13 +16,17 @@ export function filterItems< label: string; name?: string; value?: string; + hasModels?: boolean; models?: Array<{ name: string; isGlobal?: boolean }>; + searchAliases?: string[]; + showMarketplace?: boolean; }, >( items: T[], searchValue: string, agentsMap: TAgentsMap | undefined, assistantsMap: TAssistantsMap | undefined, + localize?: ReturnType, ): T[] | null { const searchTermLower = searchValue.trim().toLowerCase(); if (!searchTermLower) { @@ -30,10 +34,20 @@ export function filterItems< } return items.filter((item) => { + if (!shouldRenderEndpointOption(item)) { + return false; + } + const itemMatches = item.label.toLowerCase().includes(searchTermLower) || (item.name && item.name.toLowerCase().includes(searchTermLower)) || - (item.value && item.value.toLowerCase().includes(searchTermLower)); + (item.value && item.value.toLowerCase().includes(searchTermLower)) || + item.searchAliases?.some((alias) => alias.toLowerCase().includes(searchTermLower)) || + (item.showMarketplace === true && + localize != null && + [localize('com_agents_marketplace'), localize('com_ui_marketplace')].some((label) => + label.toLowerCase().includes(searchTermLower), + )); if (itemMatches) { return true; @@ -67,6 +81,13 @@ export function filterItems< }); } +export function shouldRenderEndpointOption(endpoint: { + value?: string; + hasModels?: boolean; +}): boolean { + return !isAgentsEndpoint(endpoint.value) || endpoint.hasModels === true; +} + export function filterModels( endpoint: Endpoint, models: string[], diff --git a/client/src/components/Chat/Menus/Models/fakeData.ts b/client/src/components/Chat/Menus/Models/fakeData.ts index 43d4cf489a2..6095dde6ef5 100644 --- a/client/src/components/Chat/Menus/Models/fakeData.ts +++ b/client/src/components/Chat/Menus/Models/fakeData.ts @@ -32,7 +32,7 @@ export const data: TModelSpec[] = [ // iconURL: 'https://i.ytimg.com/vi/SaneSRqePVY/maxresdefault.jpg', iconURL: EModelEndpoint.openAI, // Allow using project-included icons preset: { - chatGptLabel: 'Vision Helper', + modelLabel: 'Vision Helper', greeting: "What's up!!", endpoint: EModelEndpoint.openAI, model: 'gpt-4-turbo', diff --git a/client/src/components/Chat/Messages/Content/ContentParts.tsx b/client/src/components/Chat/Messages/Content/ContentParts.tsx index 84e8ce3db86..00569c11f54 100644 --- a/client/src/components/Chat/Messages/Content/ContentParts.tsx +++ b/client/src/components/Chat/Messages/Content/ContentParts.tsx @@ -6,14 +6,14 @@ import type { TAttachment, Agents, } from 'librechat-data-provider'; +import type { ToolCallGroupExpansionState } from './ToolCallGroup'; import { ParallelContentRenderer, type PartWithIndex } from './ParallelContent'; import { mapAttachments, groupSequentialToolCalls } from '~/utils'; import { MessageContext, SearchContext } from '~/Providers'; -import { EditTextPart, EmptyText } from './Parts'; import PendingSkillCall from './Parts/PendingSkillCall'; +import { EditTextPart, EmptyText } from './Parts'; import MemoryArtifacts from './MemoryArtifacts'; import ToolCallGroup from './ToolCallGroup'; -import type { ToolCallGroupExpansionState } from './ToolCallGroup'; import Container from './Container'; import Part from './Part'; @@ -105,6 +105,8 @@ type ContentPartsProps = { * the full message object) so `React.memo` stays shallow-happy. */ manualSkills?: string[]; + /** ISO timestamp of the parent message, surfaced in parallel column headers. */ + createdAt?: string | null; conversationId?: string | null; attachments?: TAttachment[]; searchResults?: { [key: string]: SearchResultData }; @@ -142,6 +144,7 @@ const ContentParts = memo(function ContentParts({ conversationId, isCreatedByUser, isLatestMessage, + createdAt, }: ContentPartsProps) { const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]); const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false; @@ -375,6 +378,7 @@ const ContentParts = memo(function ContentParts({ [ - [rehypeKatex], - [ - rehypeHighlight, - { - detect: true, - ignoreMissing: true, - subset: langSubset, - }, - ], - ], - [], - ); - - const remarkPlugins: Pluggable[] = [ - supersub, - remarkGfm, - remarkDirective, - artifactPlugin, - [remarkMath, { singleDollarTextMath: false }], - unicodeCitation, - mcpUIResourcePlugin, - ]; - if (isInitializing) { return (
@@ -75,34 +34,12 @@ const Markdown = memo(function Markdown({ content = '', isLatestMessage }: TCont return ( - - - - {currentContent} - - - + ); }); diff --git a/client/src/components/Chat/Messages/Content/MarkdownBlocks.bench.tsx b/client/src/components/Chat/Messages/Content/MarkdownBlocks.bench.tsx new file mode 100644 index 00000000000..3c664f4b0c0 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/MarkdownBlocks.bench.tsx @@ -0,0 +1,165 @@ +import React, { Profiler } from 'react'; +import { RecoilRoot } from 'recoil'; +import ReactMarkdown from 'react-markdown'; +import { render } from '@testing-library/react'; +import { getRemarkPlugins, getRehypePlugins, getMarkdownComponents } from './markdownConfig'; +import { ArtifactProvider, CodeBlockProvider } from '~/Providers'; +import CodeBlock from '~/components/Messages/Content/CodeBlock'; +import Markdown from './Markdown'; + +/** + * Streaming render benchmark comparing the previous whole-message renderer + * (one ReactMarkdown re-parsing everything per token) against the per-block + * memoized renderer. This file lives outside `__tests__/` and is named + * `.bench.tsx` so the default jest run skips it; execute it explicitly with: + * + * node node_modules/jest/bin/jest.js --runInBand --coverage=false \ + * --testMatch '**\/MarkdownBlocks.bench.tsx' + * + * Two metrics are reported: + * - codeBlockRenders: deterministic structural metric — how many times code + * blocks render across the whole stream (the memoization win, noise-free). + * - totalMs: summed React Profiler actualDuration (wall-clock; jsdom absolute + * numbers are not browser-accurate, but the OLD/NEW ratio is indicative). + */ + +jest.mock('~/components/Messages/Content/CodeBlock', () => ({ + __esModule: true, + default: jest.fn(() => null), +})); + +const codeBlockMock = CodeBlock as unknown as jest.Mock; + +const LANGS = ['python', 'javascript', 'typescript', 'bash', 'json', 'sql', 'go', 'rust']; + +const buildMessage = (sections: number): string => { + const parts: string[] = []; + for (let i = 0; i < sections; i += 1) { + parts.push(`## Section ${i + 1}`, ''); + parts.push( + `This is paragraph ${i + 1} explaining the code below with some **bold** and ` + + `\`inline\` text, intentionally a bit long to add realistic reflow cost during ` + + `streaming, repeated across every section of the message.`, + '', + ); + const lang = LANGS[i % LANGS.length]; + parts.push('```' + lang); + for (let l = 0; l < 8; l += 1) { + parts.push(`const value_${i}_${l} = computeSomething(${l}, "arg_${l}"); // line ${l}`); + } + parts.push('```', ''); + if (i % 3 === 0) { + parts.push('| Name | Type | Value |', '| --- | --- | --- |'); + for (let r = 0; r < 5; r += 1) { + parts.push(`| item_${i}_${r} | number | ${r * i} |`); + } + parts.push(''); + } + } + return parts.join('\n'); +}; + +const makePrefixes = (content: string, steps: number): string[] => { + const prefixes: string[] = []; + for (let s = 1; s <= steps; s += 1) { + prefixes.push(content.slice(0, Math.ceil((content.length * s) / steps))); + } + return prefixes; +}; + +const OldMarkdown = ({ content }: { content: string }) => ( + + + + {content} + + + +); + +const NewMarkdown = ({ content }: { content: string }) => ( + +); + +const measure = ( + Component: React.ComponentType<{ content: string }>, + prefixes: string[], +): { totalMs: number; codeBlockRenders: number } => { + codeBlockMock.mockClear(); + let totalMs = 0; + const onRender = (_id: string, _phase: string, actualDuration: number) => { + totalMs += actualDuration; + }; + const tree = (content: string) => ( + + + + + + ); + const { rerender, unmount } = render(tree(prefixes[0])); + for (let i = 1; i < prefixes.length; i += 1) { + rerender(tree(prefixes[i])); + } + const result = { totalMs, codeBlockRenders: codeBlockMock.mock.calls.length }; + unmount(); + return result; +}; + +describe('Markdown streaming benchmark (OLD whole-message vs NEW per-block)', () => { + it('reports render cost across a simulated stream', () => { + const content = buildMessage(12); + const steps = 80; + const prefixes = makePrefixes(content, steps); + const iterations = 3; + + // Warm up module/highlight caches so the first measured run isn't skewed. + measure(OldMarkdown, prefixes); + measure(NewMarkdown, prefixes); + + const old: Array<{ totalMs: number; codeBlockRenders: number }> = []; + const neu: Array<{ totalMs: number; codeBlockRenders: number }> = []; + for (let i = 0; i < iterations; i += 1) { + old.push(measure(OldMarkdown, prefixes)); + neu.push(measure(NewMarkdown, prefixes)); + } + + const minMs = (rs: Array<{ totalMs: number }>) => Math.min(...rs.map((r) => r.totalMs)); + const oldMs = minMs(old); + const newMs = minMs(neu); + const oldRenders = old[0].codeBlockRenders; + const newRenders = neu[0].codeBlockRenders; + + console.log( + [ + '', + '================ Markdown streaming benchmark ================', + `message size: ${content.length} chars, stream steps: ${steps}, iterations: ${iterations}`, + '', + `code-block renders over the stream (structural, noise-free):`, + ` OLD (whole-message): ${oldRenders}`, + ` NEW (per-block) : ${newRenders}`, + ` reduction : ${(100 * (1 - newRenders / oldRenders)).toFixed(1)}%`, + '', + `total render time (min of ${iterations}, summed Profiler actualDuration; jsdom):`, + ` OLD: ${oldMs.toFixed(1)} ms`, + ` NEW: ${newMs.toFixed(1)} ms`, + ` speedup: ${(oldMs / newMs).toFixed(2)}x`, + '=============================================================', + '', + ].join('\n'), + ); + + // Sanity: the per-block renderer must not render code blocks MORE than the + // whole-message renderer. The real win is asserted separately below. + expect(newRenders).toBeLessThanOrEqual(oldRenders); + // Memoization should cut total code-block renders by a wide margin. + expect(newRenders).toBeLessThan(oldRenders * 0.5); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/MarkdownBlocks.tsx b/client/src/components/Chat/Messages/Content/MarkdownBlocks.tsx new file mode 100644 index 00000000000..89f2fc76bd6 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/MarkdownBlocks.tsx @@ -0,0 +1,110 @@ +import React, { memo, useMemo } from 'react'; +import ReactMarkdown from 'react-markdown'; +import type { PluggableList } from 'unified'; +import type { ElementType } from 'react'; +import { ArtifactProvider, CodeBlockProvider } from '~/Providers'; +import { splitMarkdownIntoBlocks } from './splitMarkdown'; + +type SharedProps = { + remarkPlugins: PluggableList; + rehypePlugins: PluggableList; + components: { [nodeType: string]: ElementType }; +}; + +type MarkdownBlockProps = SharedProps & { + content: string; + codeBaseIndex: number; + artifactBaseIndex: number; +}; + +/** + * Renders one top-level markdown block inside its own code/artifact providers, + * seeded with the running index of executable code blocks and artifacts in + * earlier blocks. Memoized on `content` and the base indices: a completed block + * whose source slice and bases are unchanged across streamed tokens skips both + * re-parsing and re-rendering, so only the final, still-growing block re-parses. + */ +const MarkdownBlock = memo( + function MarkdownBlock({ + content, + codeBaseIndex, + artifactBaseIndex, + remarkPlugins, + rehypePlugins, + components, + }: MarkdownBlockProps) { + return ( + + + + {content} + + + + ); + }, + (prev, next) => + prev.content === next.content && + prev.codeBaseIndex === next.codeBaseIndex && + prev.artifactBaseIndex === next.artifactBaseIndex, +); +MarkdownBlock.displayName = 'MarkdownBlock'; + +type MarkdownBlocksProps = SharedProps & { + content: string; +}; + +/** + * Splits a message into top-level blocks and renders each independently so + * that, during streaming, only the last block re-parses while earlier blocks + * (tables, code, etc.) stay memoized. Each block's executable code and artifact + * indices are preserved in document order via per-block providers seeded with + * prefix-summed base indices. + */ +const MarkdownBlocks = memo(function MarkdownBlocks({ + content, + remarkPlugins, + rehypePlugins, + components, +}: MarkdownBlocksProps) { + const blocks = useMemo(() => { + let codeBaseIndex = 0; + let artifactBaseIndex = 0; + return splitMarkdownIntoBlocks(content).map((block) => { + const entry = { raw: block.raw, codeBaseIndex, artifactBaseIndex }; + codeBaseIndex += block.codeBlockCount; + artifactBaseIndex += block.artifactCount; + return entry; + }); + }, [content]); + + return ( + <> + {blocks.map((block, index) => ( + // Key includes the base indices so that an in-place edit which inserts a + // block before existing code/artifact blocks (shifting their base) forces + // a remount, refreshing the index each code/artifact block captures in a + // ref. During append-only streaming these stay constant, so completed + // blocks keep a stable key and are not remounted. + + ))} + + ); +}); +MarkdownBlocks.displayName = 'MarkdownBlocks'; + +export default MarkdownBlocks; diff --git a/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx b/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx index c38c40f1e10..5147dd2a9ac 100644 --- a/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx +++ b/client/src/components/Chat/Messages/Content/MarkdownComponents.tsx @@ -185,6 +185,19 @@ export const p: React.ElementType = memo(function MarkdownParagraph({ children } }); p.displayName = 'MarkdownParagraph'; +type TTableProps = { + children: React.ReactNode; +}; + +export const table: React.ElementType = memo(function MarkdownTable({ children }: TTableProps) { + return ( +
+ {children}
+
+ ); +}); +table.displayName = 'MarkdownTable'; + type TImageProps = { src?: string; alt?: string; diff --git a/client/src/components/Chat/Messages/Content/MarkdownErrorBoundary.tsx b/client/src/components/Chat/Messages/Content/MarkdownErrorBoundary.tsx index 0342c60f8a2..08d91a43b5e 100644 --- a/client/src/components/Chat/Messages/Content/MarkdownErrorBoundary.tsx +++ b/client/src/components/Chat/Messages/Content/MarkdownErrorBoundary.tsx @@ -4,9 +4,9 @@ import supersub from 'remark-supersub'; import ReactMarkdown from 'react-markdown'; import rehypeHighlight from 'rehype-highlight'; import type { PluggableList } from 'unified'; -import { code, codeNoExecution, a, p } from './MarkdownComponents'; +import { code, codeNoExecution, a, p, table } from './MarkdownComponents'; +import { langSubset, remarkApproxTilde } from '~/utils'; import { CodeBlockProvider } from '~/Providers'; -import { langSubset } from '~/utils'; interface ErrorBoundaryState { hasError: boolean; @@ -61,6 +61,7 @@ class MarkdownErrorBoundary extends React.Component< { @@ -31,6 +31,7 @@ const MarkdownLite = memo( @@ -193,6 +196,7 @@ export const ParallelColumns = memo(function ParallelColumns({ type ParallelContentRendererProps = { content?: Array; messageId: string; + createdAt?: string | null; conversationId?: string | null; attachments?: TAttachment[]; searchResults?: { [key: string]: SearchResultData }; @@ -207,6 +211,7 @@ type ParallelContentRendererProps = { export const ParallelContentRenderer = memo(function ParallelContentRenderer({ content, messageId, + createdAt, conversationId, attachments, searchResults, @@ -253,6 +258,7 @@ export const ParallelContentRenderer = memo(function ParallelContentRenderer({ columns={columns} groupId={groupId} messageId={messageId} + createdAt={createdAt} renderPart={renderPart} isSubmitting={isSubmitting} conversationId={conversationId} diff --git a/client/src/components/Chat/Messages/Content/Part.tsx b/client/src/components/Chat/Messages/Content/Part.tsx index a0502816f65..6df833370a8 100644 --- a/client/src/components/Chat/Messages/Content/Part.tsx +++ b/client/src/components/Chat/Messages/Content/Part.tsx @@ -18,6 +18,7 @@ import { Text, SkillCall, ReadFileCall, + FileAuthoringCall, BashCall, SubagentCall, } from './Parts'; @@ -230,6 +231,19 @@ const Part = memo(function Part({ onExpand={onToolExpand} /> ); + } else if (isToolCall && (toolCall.name === 'create_file' || toolCall.name === 'edit_file')) { + return ( + + ); } else if (isToolCall && toolCall.name === Tools.bash_tool) { return ( | undefined; + +interface TextEditPreview { + oldText: string; + newText: string; +} + +function hasDiff(output: string): boolean { + return /\n@@\s/.test(output) || output.includes('\n--- ') || output.includes('\n+++ '); +} + +function parseArgsObject(args: ToolCallArgs): Record | undefined { + if (typeof args === 'object' && args !== null) { + return args; + } + if (typeof args !== 'string') { + return undefined; + } + try { + const parsed = JSON.parse(args); + if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { + return parsed as Record; + } + } catch { + return undefined; + } + return undefined; +} + +function textValue(value: unknown): string { + return typeof value === 'string' ? value : ''; +} + +function editPreviewLines(prefix: '-' | '+', text: string): string { + return text + .split('\n') + .map((line) => `${prefix}${line}`) + .join('\n'); +} + +function formatEditPreview(edits: TextEditPreview[]): string { + return edits + .map((edit, index) => { + const suffix = edits.length > 1 ? ` ${index + 1}` : ''; + return [ + `--- old_text${suffix}`, + `+++ new_text${suffix}`, + '@@', + editPreviewLines('-', edit.oldText), + editPreviewLines('+', edit.newText), + ].join('\n'); + }) + .join('\n\n'); +} + +function buildEditArgsPreview(args: ToolCallArgs): string { + const parsed = parseArgsObject(args); + if (Array.isArray(parsed?.edits) && parsed.edits.length > 0) { + const edits = parsed.edits + .map((edit): TextEditPreview | undefined => { + if (typeof edit !== 'object' || edit === null || Array.isArray(edit)) { + return undefined; + } + const entry = edit as Record; + const oldText = textValue(entry.old_text); + const newText = textValue(entry.new_text); + return oldText || newText ? { oldText, newText } : undefined; + }) + .filter((edit): edit is TextEditPreview => !!edit); + return formatEditPreview(edits); + } + + if (parsed) { + const oldText = textValue(parsed.old_text); + const newText = textValue(parsed.new_text); + return oldText || newText ? formatEditPreview([{ oldText, newText }]) : ''; + } + + /** Partial JSON during streaming: pair up field occurrences in document order, covering both single-replacement and batched `edits` args */ + const oldTexts = parseJsonFieldOccurrences(args, 'old_text'); + const newTexts = parseJsonFieldOccurrences(args, 'new_text'); + const editCount = Math.max(oldTexts.length, newTexts.length); + const edits = Array.from({ length: editCount }, (_, index) => ({ + oldText: oldTexts[index] ?? '', + newText: newTexts[index] ?? '', + })).filter((edit) => edit.oldText || edit.newText); + return formatEditPreview(edits); +} + +export default function FileAuthoringCall({ + toolName, + isSubmitting, + initialProgress = 0.1, + args, + output = '', + attachments, + hideAttachments = false, + onExpand, +}: { + toolName: FileAuthoringToolName; + initialProgress: number; + isSubmitting: boolean; + args?: string | Record; + output?: string; + attachments?: TAttachment[]; + hideAttachments?: boolean; + onExpand?: () => void; +}) { + const localize = useLocalize(); + const isCreate = toolName === 'create_file'; + /** `create_file` can overwrite an existing file (sandbox `overwrite: true`, + * or skill SKILL.md updates). The host-authored summary always opens with + * `Created`/`Updated`, so key the finished label off it for truthfulness. */ + const overwrote = isCreate && output.startsWith('Updated '); + const filePath = useMemo(() => parseJsonField(args, 'file_path'), [args]); + const authoredContent = useMemo(() => parseJsonField(args, 'content'), [args]); + const editArgsPreview = useMemo(() => buildEditArgsPreview(args), [args]); + const fileName = filePath.split('/').pop() || filePath; + const fileLang = useMemo(() => langFromPath(filePath), [filePath]); + const argsPreview = isCreate ? authoredContent : editArgsPreview; + const outputIsDiff = hasDiff(output); + /** A diff in the output supersedes the args preview — it carries the input with real file context */ + const preview = outputIsDiff ? output : argsPreview || output; + const showOutputSection = !!output && preview !== output; + const previewIsDiff = outputIsDiff || (!isCreate && !!editArgsPreview && preview !== output); + let previewLang = 'plaintext'; + if (previewIsDiff) { + previewLang = 'diff'; + } else if (isCreate && authoredContent && preview === authoredContent) { + previewLang = fileLang; + } + + const { showCode, toggleCode, expandStyle, expandRef, progress, cancelled, hasError } = + useToolCallState(initialProgress, isSubmitting, output, !!filePath || !!preview, onExpand); + + const highlighted = useLazyHighlight(preview || undefined, previewLang); + const Icon = isCreate && !overwrote ? FilePlus2 : FilePenLine; + let finishedKey: 'com_ui_created_file' | 'com_ui_updated_file' | 'com_ui_edited_file' = + 'com_ui_edited_file'; + if (isCreate) { + finishedKey = overwrote ? 'com_ui_updated_file' : 'com_ui_created_file'; + } + + return ( + <> +
+
+
+
+ {!!preview && ( +
+ +
+                
+                  {highlighted ?? preview}
+                
+              
+ {showOutputSection && ( +
+                  {output}
+                
+ )} +
+ )} +
+
+ {!hideAttachments && attachments && attachments.length > 0 && ( + + )} + + ); +} diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/ArtifactRouting.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/ArtifactRouting.test.tsx index 70a1661dcb2..555c8c6ffe6 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/ArtifactRouting.test.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/ArtifactRouting.test.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import { render, screen, fireEvent, act } from '@testing-library/react'; import { RecoilRoot, useRecoilValue } from 'recoil'; -import type { MutableSnapshot } from 'recoil'; +import { render, screen, fireEvent, act } from '@testing-library/react'; import type { TAttachment } from 'librechat-data-provider'; +import type { MutableSnapshot } from 'recoil'; import Attachment, { AttachmentGroup } from '../Attachment'; import store from '~/store'; @@ -198,9 +198,8 @@ describe('Attachment routing for tool artifacts', () => { * yet); use it as the canonical "unrouted text" example. */ const json = baseAttachment({ filename: 'data.json', - type: 'application/json', text: '{"a":1,"b":2}', - } as Partial); + }); const { container } = renderWith(); expect(container.querySelector('pre')).not.toBeNull(); expect(screen.queryByTestId('mermaid-render')).not.toBeInTheDocument(); @@ -544,10 +543,9 @@ describe('ToolArtifactCard click behaviour', () => { const xlsx = baseAttachment({ file_id: 'just-resolved-xlsx', filename: 'data.xlsx', - type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', text: 'resolved
', textFormat: 'html', - } as Partial); + }); const initializeState = (snap: MutableSnapshot) => { snap.set(store.isSubmittingFamily(0), false); snap.set(store.artifactsVisibility, false); @@ -580,10 +578,9 @@ describe('ToolArtifactCard click behaviour', () => { const xlsx = baseAttachment({ file_id: 'one-shot-xlsx', filename: 'data.xlsx', - type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', text: 'resolved
', textFormat: 'html', - } as Partial); + }); const initializeState = (snap: MutableSnapshot) => { snap.set(store.isSubmittingFamily(0), false); snap.set(store.artifactsVisibility, false); @@ -710,15 +707,13 @@ describe('AttachmentGroup routing', () => { const empty = baseAttachment({ file_id: 'empty-zip', filename: 'placeholder.zip', - type: 'application/zip', bytes: 0, - } as Partial); + }); const real = baseAttachment({ file_id: 'real-zip', filename: 'archive.zip', - type: 'application/zip', bytes: 1024, - } as Partial); + }); const { container } = renderWith(); fireEvent.click(screen.getByRole('button', { name: 'com_ui_show_n_files' })); const chips = Array.from(container.querySelectorAll('[data-testid="file-container"]')); @@ -733,26 +728,22 @@ describe('AttachmentGroup routing', () => { const first = baseAttachment({ file_id: 'file-a', filename: 'a.zip', - type: 'application/zip', - } as Partial); + }); const second = baseAttachment({ file_id: 'file-b', filename: 'b.zip', - type: 'application/zip', - } as Partial); + }); const json = baseAttachment({ file_id: 'file-c', filename: 'c.json', - type: 'application/json', text: '{"c":true}', - } as Partial); + }); const image = baseAttachment({ file_id: 'image-a', filename: 'preview.png', - type: 'image/png', width: 16, height: 16, - } as Partial); + }); const { container } = renderWith( , @@ -784,9 +775,8 @@ describe('AttachmentGroup routing', () => { const sandboxFile = baseAttachment({ file_id: 'sandbox-zip', filename: 'archive-deadbe.zip', - type: 'application/zip', bytes: 1024, - } as Partial); + }); const { container } = renderWith(); const chip = container.querySelector('[data-testid="file-container"]'); expect(chip?.textContent).toBe('archive-deadbe.zip'); @@ -802,9 +792,8 @@ describe('AttachmentGroup routing', () => { const sandboxDotfile = baseAttachment({ file_id: 'sandbox-config', filename: '_.config-abcdef.zip', - type: 'application/zip', bytes: 12, - } as Partial); + }); const { container } = renderWith(); const chip = container.querySelector('[data-testid="file-container"]'); expect(chip?.textContent).toBe('.config.zip'); @@ -823,7 +812,6 @@ describe('AttachmentGroup routing', () => { baseAttachment({ file_id: 'pending-1', filename: 'data.xlsx', - type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', status: 'pending', } as Partial), baseAttachment({ @@ -859,7 +847,6 @@ describe('AttachmentGroup routing', () => { baseAttachment({ file_id: 'c', filename: 'data.json', - type: 'application/json', text: '{"a":1}', } as Partial), baseAttachment({ diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/FileAuthoringCall.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/FileAuthoringCall.test.tsx new file mode 100644 index 00000000000..a57a00c9540 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/FileAuthoringCall.test.tsx @@ -0,0 +1,281 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import FileAuthoringCall from '../FileAuthoringCall'; + +jest.mock('~/hooks', () => ({ + useLocalize: + () => + (key: string, values?: Record): string => { + const translations: Record = { + com_ui_created_file: 'Created {{0}}', + com_ui_creating_file: 'Creating {{0}}', + com_ui_updated_file: 'Updated {{0}}', + com_ui_edited_file: 'Edited {{0}}', + com_ui_editing_file: 'Editing {{0}}', + com_ui_cancelled: 'Cancelled', + com_ui_tool_failed: 'failed', + }; + return (translations[key] ?? key).replace('{{0}}', values?.[0] ?? ''); + }, +})); + +jest.mock('~/utils', () => ({ + cn: (...classes: Array) => classes.filter(Boolean).join(' '), +})); + +jest.mock('~/components/Chat/Messages/Content/ProgressText', () => ({ + __esModule: true, + default: ({ + progress, + inProgressText, + finishedText, + }: { + progress: number; + inProgressText: string; + finishedText: string; + }) =>
{progress < 1 ? inProgressText : finishedText}
, +})); + +jest.mock('../CodeWindowHeader', () => ({ + __esModule: true, + default: ({ language }: { language: string }) => ( +
+ ), +})); + +jest.mock('../Attachment', () => ({ + AttachmentGroup: () =>
, +})); + +jest.mock('../useLazyHighlight', () => ({ + __esModule: true, + default: () => null, +})); + +jest.mock('../useToolCallState', () => ({ + __esModule: true, + default: (initialProgress: number) => ({ + showCode: true, + toggleCode: jest.fn(), + expandStyle: {}, + expandRef: { current: null }, + progress: initialProgress, + cancelled: false, + hasError: false, + }), +})); + +describe('FileAuthoringCall', () => { + it('shows create_file content args while the call is in progress', () => { + render( + , + ); + + expect(screen.getByTestId('progress-text')).toHaveTextContent('Creating SKILL.md'); + expect(screen.getByTestId('code-window-header')).toHaveAttribute('data-language', 'SKILL.md'); + expect(screen.getByText(/Use this skill for testing/)).toBeInTheDocument(); + }); + + it('keeps authored content visible alongside the output after create_file completes', () => { + render( + , + ); + + expect(screen.getByTestId('progress-text')).toHaveTextContent('Created SKILL.md'); + expect(screen.getByTestId('code-window-header')).toHaveAttribute('data-language', 'SKILL.md'); + expect(screen.getByText(/Large generated body stays visible/)).toBeInTheDocument(); + expect(screen.getByText('Created skills/demo/SKILL.md (4096 chars).')).toBeInTheDocument(); + }); + + it('labels a create_file overwrite as Updated when the output summary says so', () => { + render( + , + ); + + expect(screen.getByTestId('progress-text')).toHaveTextContent('Updated SKILL.md'); + }); + + it('prefers the output diff over the args preview after edit_file completes', () => { + const output = [ + 'Edited skills/demo/SKILL.md (exact match).', + '', + '--- skills/demo/SKILL.md', + '+++ skills/demo/SKILL.md', + '@@ -1,1 +1,1 @@', + '-old line', + '+new line', + ].join('\n'); + render( + , + ); + + const preview = screen.getByText((_, element) => element?.tagName.toLowerCase() === 'code'); + expect(preview).toHaveTextContent('-old line'); + expect(preview).toHaveTextContent('+new line'); + expect(screen.queryByText(/--- old_text/)).not.toBeInTheDocument(); + }); + + it('shows the attempted input alongside the error output when edit_file fails', () => { + render( + , + ); + + const preview = screen.getByText((_, element) => element?.tagName.toLowerCase() === 'code'); + expect(preview).toHaveTextContent('-missing text'); + expect(preview).toHaveTextContent('+replacement'); + expect(screen.getByText(/matched 0 locations/)).toBeInTheDocument(); + }); + + it('shows edit_file replacement args while the call is in progress', () => { + render( + , + ); + + expect(screen.getByTestId('progress-text')).toHaveTextContent('Editing SKILL.md'); + expect(screen.getByTestId('code-window-header')).toHaveAttribute('data-language', 'diff'); + const preview = screen.getByText((_, element) => element?.tagName.toLowerCase() === 'code'); + expect(preview).toHaveTextContent('--- old_text'); + expect(preview).toHaveTextContent('+++ new_text'); + expect(preview).toHaveTextContent('-description: Old behavior'); + expect(preview).toHaveTextContent('+description: New behavior'); + }); + + it('streams create_file content from partial JSON string args during run_step_delta', () => { + render( + , + ); + + expect(screen.getByTestId('progress-text')).toHaveTextContent('Creating SKILL.md'); + expect(screen.getByTestId('code-window-header')).toHaveAttribute('data-language', 'SKILL.md'); + expect(screen.getByText(/Streaming body so far/)).toBeInTheDocument(); + }); + + it('streams edit_file replacement preview from partial JSON string args', () => { + render( + , + ); + + const preview = screen.getByText((_, element) => element?.tagName.toLowerCase() === 'code'); + expect(preview).toHaveTextContent('-description: Old behavior'); + expect(preview).toHaveTextContent('+description: New beh'); + }); + + it('streams batched edit_file previews from a partial edits array', () => { + const args = + '{"file_path":"skills/demo/SKILL.md","edits":[' + + '{"old_text":"first old","new_text":"first new"},' + + '{"old_text":"second old","new_text":"second n'; + render( + , + ); + + const preview = screen.getByText((_, element) => element?.tagName.toLowerCase() === 'code'); + expect(preview).toHaveTextContent('--- old_text 1'); + expect(preview).toHaveTextContent('-first old'); + expect(preview).toHaveTextContent('+first new'); + expect(preview).toHaveTextContent('--- old_text 2'); + expect(preview).toHaveTextContent('-second old'); + expect(preview).toHaveTextContent('+second n'); + }); + + it('shows batched edit_file replacements from edits args while the call is in progress', () => { + render( + , + ); + + const preview = screen.getByText((_, element) => element?.tagName.toLowerCase() === 'code'); + expect(preview).toHaveTextContent('--- old_text 1'); + expect(preview).toHaveTextContent('+++ new_text 2'); + expect(preview).toHaveTextContent('-first old'); + expect(preview).toHaveTextContent('+second new'); + }); +}); diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/LogContent.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/LogContent.test.tsx index bb34bb1deeb..19feccef2cf 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/LogContent.test.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/LogContent.test.tsx @@ -1,8 +1,8 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; import { RecoilRoot } from 'recoil'; -import type { MutableSnapshot } from 'recoil'; +import { render, screen } from '@testing-library/react'; import type { TAttachment } from 'librechat-data-provider'; +import type { MutableSnapshot } from 'recoil'; import LogContent from '../LogContent'; import store from '~/store'; @@ -110,9 +110,8 @@ describe('LogContent attachment routing', () => { const json = baseAttachment({ file_id: 'c', filename: 'data.json', - type: 'application/json', text: '{"a":1,"b":2}', - } as Partial); + }); const { container } = renderWith(); expect(container.querySelector('pre')).not.toBeNull(); expect(screen.queryByRole('button', { pressed: true })).not.toBeInTheDocument(); @@ -123,8 +122,7 @@ describe('LogContent attachment routing', () => { const zip = baseAttachment({ file_id: 'd', filename: 'archive.zip', - type: 'application/zip', - } as Partial); + }); renderWith(); expect(screen.getByTestId('log-link')).toHaveAttribute('data-filename', 'archive.zip'); }); @@ -137,9 +135,8 @@ describe('LogContent attachment routing', () => { const pptx = baseAttachment({ file_id: 'e', filename: 'slides.pptx', - type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', text: '
  1. Slide 1
', - } as Partial); + }); renderWith(); expect(screen.getByText('slides.pptx')).toBeInTheDocument(); }); @@ -151,9 +148,8 @@ describe('LogContent attachment routing', () => { const pptx = baseAttachment({ file_id: 'e2', filename: 'slides.pptx', - type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - text: undefined as unknown as string, - } as Partial); + text: undefined, + }); renderWith(); expect(screen.queryByRole('button', { pressed: true })).not.toBeInTheDocument(); expect(screen.getByTestId('log-link')).toHaveAttribute('data-filename', 'slides.pptx'); @@ -168,10 +164,9 @@ describe('LogContent attachment routing', () => { const expired = baseAttachment({ file_id: 'x-expired', filename: 'slides.pptx', - type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', text: '
  1. Slide 1
', expiresAt: Date.now() - 60_000, - } as Partial); + }); renderWith(); // No panel card and no log-link (the expired branch returns plain text). expect(screen.queryByRole('button', { pressed: true })).not.toBeInTheDocument(); @@ -211,7 +206,6 @@ describe('LogContent attachment routing', () => { * bucket, so it would no longer satisfy the "inline pre" check * below. */ filename: 'notes.json', - type: 'application/json', text: '{"a":1,"b":2}', } as Partial), ] as TAttachment[]; diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/SubagentCall.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/SubagentCall.test.tsx index f78f71d75b3..2b968071add 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/SubagentCall.test.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/SubagentCall.test.tsx @@ -8,15 +8,14 @@ import type { SubagentAggregatorState, } from '~/utils/subagentContent'; import type { SubagentProgress } from '~/store/subagents'; - import { foldSubagentEvent, foldSubagentEventIntoTicker, initSubagentAggregatorState, initSubagentTickerState, } from '~/utils/subagentContent'; -import { subagentProgressByToolCallId } from '~/store/subagents'; import SubagentCall, { SUBAGENT_TICKER_THROTTLE_MS } from '../SubagentCall'; +import { subagentProgressByToolCallId } from '~/store/subagents'; jest.mock('~/hooks', () => ({ useLocalize: @@ -622,7 +621,7 @@ describe('SubagentCall — dialog content', () => { ); openSubagentDialog(); expect(screen.getByText('raw final text')).toBeInTheDocument(); - rerender(); + rerender({null}); }); it('renders persistedContent parts when no live events are available (page-refresh flow)', () => { diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/TextAttachment.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/TextAttachment.test.tsx index 6996550c523..aaf388b080c 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/TextAttachment.test.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/TextAttachment.test.tsx @@ -71,7 +71,6 @@ const textAttachment = (overrides: Partial = {}): TAttachment => * bearing, downloadable, expandable) without the panel coupling. */ filename: 'output.json', filepath: '/files/output.json', - type: 'application/json', text: '{"a":1,"b":2,"c":3}', ...overrides, }) as TAttachment; @@ -190,8 +189,7 @@ describe('AttachmentGroup', () => { textAttachment({ file_id: 'b', filename: 'archive.zip', - type: 'application/zip', - text: undefined as unknown as string, + text: undefined, }), ] as TAttachment[]; const { container } = render(); @@ -205,8 +203,7 @@ describe('AttachmentGroup', () => { file_id: 'placeholder', filename: 'placeholder.zip', filepath: '', - type: 'application/zip', - text: undefined as unknown as string, + text: undefined, }), textAttachment({ file_id: 'json', @@ -230,8 +227,7 @@ describe('AttachmentGroup', () => { textAttachment({ file_id: 'archive', filename: 'archive.zip', - type: 'application/zip', - text: undefined as unknown as string, + text: undefined, }), textAttachment({ file_id: 'json', diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/attachmentTypes.test.ts b/client/src/components/Chat/Messages/Content/Parts/__tests__/attachmentTypes.test.ts index 02113c8a255..f624097f594 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/attachmentTypes.test.ts +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/attachmentTypes.test.ts @@ -1,5 +1,4 @@ import type { TAttachment } from 'librechat-data-provider'; -import { TOOL_ARTIFACT_TYPES } from '~/utils/artifacts'; import { artifactTypeForAttachment, attachmentSalience, @@ -8,6 +7,7 @@ import { isInternalSandboxArtifact, isTextAttachment, } from '../attachmentTypes'; +import { TOOL_ARTIFACT_TYPES } from '~/utils/artifacts'; const baseAttachment = (overrides: Partial = {}): TAttachment => ({ @@ -138,9 +138,8 @@ describe('artifactTypeForAttachment', () => { * pipeline instead. */ const attachment = baseAttachment({ filename: 'photo.jpg', - type: 'image/jpeg', text: undefined, - } as Partial); + }); expect(artifactTypeForAttachment(attachment)).toBeNull(); }); }); diff --git a/client/src/components/Chat/Messages/Content/Parts/__tests__/parseJsonField.test.ts b/client/src/components/Chat/Messages/Content/Parts/__tests__/parseJsonField.test.ts index 0d02d9b8361..facffb222f9 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/parseJsonField.test.ts +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/parseJsonField.test.ts @@ -1,4 +1,7 @@ -import parseJsonField, { areToolCallArgsComplete } from '../parseJsonField'; +import parseJsonField, { + parseJsonFieldOccurrences, + areToolCallArgsComplete, +} from '../parseJsonField'; describe('parseJsonField', () => { describe('object args', () => { @@ -100,6 +103,71 @@ describe('parseJsonField', () => { expect(parseJsonField(partial, 'command')).toBe('tab\\there'); }); }); + + describe('in-progress streaming fields — unterminated values', () => { + it('extracts a field whose closing quote has not streamed in yet', () => { + const partial = '{"file_path":"skills/demo/SKILL.md","content":"# Demo\\nline two'; + expect(parseJsonField(partial, 'content')).toBe('# Demo\nline two'); + }); + + it('grows the extracted value as more deltas arrive', () => { + const full = '{"file_path":"a.md","content":"# Title\\n\\nBody text here"}'; + const lengths = [40, 48, 56, full.length]; + const values = lengths.map((len) => parseJsonField(full.slice(0, len), 'content')); + expect(values[values.length - 1]).toBe('# Title\n\nBody text here'); + values.slice(1).forEach((value, index) => { + expect(value.startsWith(values[index])).toBe(true); + }); + }); + + it('drops a dangling backslash from a partially streamed escape', () => { + const partial = '{"content":"line one\\'; + expect(parseJsonField(partial, 'content')).toBe('line one'); + }); + + it('unescapes a complete escaped backslash at the stream edge', () => { + const partial = '{"content":"path C:\\\\'; + expect(parseJsonField(partial, 'content')).toBe('path C:\\'); + }); + + it('handles escaped quotes inside an unterminated value', () => { + const partial = '{"command":"say \\"hi'; + expect(parseJsonField(partial, 'command')).toBe('say "hi'); + }); + + it('returns empty string when the value has not opened yet', () => { + expect(parseJsonField('{"content":', 'content')).toBe(''); + expect(parseJsonField('{"content":"', 'content')).toBe(''); + }); + + it('does not run past a completed field into later args', () => { + const partial = '{"old_text":"keep this","new_text":"and th'; + expect(parseJsonField(partial, 'old_text')).toBe('keep this'); + expect(parseJsonField(partial, 'new_text')).toBe('and th'); + }); + }); +}); + +describe('parseJsonFieldOccurrences', () => { + it('returns empty array for non-string args', () => { + expect(parseJsonFieldOccurrences(undefined, 'old_text')).toEqual([]); + expect(parseJsonFieldOccurrences({ old_text: 'x' }, 'old_text')).toEqual([]); + expect(parseJsonFieldOccurrences('', 'old_text')).toEqual([]); + }); + + it('extracts repeated fields from a partial edits array in document order', () => { + const partial = + '{"file_path":"a.md","edits":[' + + '{"old_text":"first old","new_text":"first new"},' + + '{"old_text":"second old","new_text":"second n'; + expect(parseJsonFieldOccurrences(partial, 'old_text')).toEqual(['first old', 'second old']); + expect(parseJsonFieldOccurrences(partial, 'new_text')).toEqual(['first new', 'second n']); + }); + + it('extracts a single top-level field', () => { + const partial = '{"file_path":"a.md","old_text":"only one'; + expect(parseJsonFieldOccurrences(partial, 'old_text')).toEqual(['only one']); + }); }); describe('areToolCallArgsComplete', () => { diff --git a/client/src/components/Chat/Messages/Content/Parts/index.ts b/client/src/components/Chat/Messages/Content/Parts/index.ts index da495abb825..6d6bf470b75 100644 --- a/client/src/components/Chat/Messages/Content/Parts/index.ts +++ b/client/src/components/Chat/Messages/Content/Parts/index.ts @@ -11,5 +11,6 @@ export { default as AgentUpdate } from './AgentUpdate'; export { default as EditTextPart } from './EditTextPart'; export { default as SkillCall } from './SkillCall'; export { default as ReadFileCall } from './ReadFileCall'; +export { default as FileAuthoringCall } from './FileAuthoringCall'; export { default as BashCall } from './BashCall'; export { default as SubagentCall } from './SubagentCall'; diff --git a/client/src/components/Chat/Messages/Content/Parts/parseJsonField.ts b/client/src/components/Chat/Messages/Content/Parts/parseJsonField.ts index cf4f9a3aa42..e177fff1dda 100644 --- a/client/src/components/Chat/Messages/Content/Parts/parseJsonField.ts +++ b/client/src/components/Chat/Messages/Content/Parts/parseJsonField.ts @@ -15,6 +15,24 @@ export function areToolCallArgsComplete(args: ToolCallArgs): boolean { } } +/** Matches `"field":"value"`, tolerating a missing closing quote and a dangling escape at the end of partially streamed args. */ +function fieldRegex(field: string, flags?: string): RegExp { + const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + return new RegExp(`"${escaped}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)(?:"|\\\\?$)`, flags); +} + +function unescapeJsonString(value: string): string { + return value.replace(/\\(.)/g, (_, c: string) => { + if (c === 'n') { + return '\n'; + } + if (c === '"' || c === '\\') { + return c; + } + return `\\${c}`; + }); +} + /** Extracts a string field from tool call args, handling object, JSON string, and partial-JSON fallback. */ export default function parseJsonField(args: ToolCallArgs, field: string): string { if (typeof args === 'object' && args !== null) { @@ -28,19 +46,17 @@ export default function parseJsonField(args: ToolCallArgs, field: string): strin } catch { // partial JSON during streaming; fall through to regex } - const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); - const re = new RegExp(`"${escaped}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`); - const match = args?.match(re); + const match = args?.match(fieldRegex(field)); if (!match) { return ''; } - return match[1].replace(/\\(.)/g, (_, c: string) => { - if (c === 'n') { - return '\n'; - } - if (c === '"' || c === '\\') { - return c; - } - return `\\${c}`; - }); + return unescapeJsonString(match[1]); +} + +/** Extracts every occurrence of a string field from partially streamed JSON args, in document order. */ +export function parseJsonFieldOccurrences(args: ToolCallArgs, field: string): string[] { + if (typeof args !== 'string' || args.length === 0) { + return []; + } + return Array.from(args.matchAll(fieldRegex(field, 'g')), (match) => unescapeJsonString(match[1])); } diff --git a/client/src/components/Chat/Messages/Content/SiblingHeader.tsx b/client/src/components/Chat/Messages/Content/SiblingHeader.tsx index 080974ed2b2..160966fb136 100644 --- a/client/src/components/Chat/Messages/Content/SiblingHeader.tsx +++ b/client/src/components/Chat/Messages/Content/SiblingHeader.tsx @@ -3,6 +3,7 @@ import { GitBranchPlus } from 'lucide-react'; import { useToastContext } from '@librechat/client'; import { EModelEndpoint, parseEphemeralAgentId, stripAgentIdSuffix } from 'librechat-data-provider'; import type { TMessage, Agent } from 'librechat-data-provider'; +import MessageTimestamp from '~/components/Chat/Messages/ui/MessageTimestamp'; import { useBranchMessageMutation } from '~/data-provider/Messages'; import MessageIcon from '~/components/Share/MessageIcon'; import { useAgentsMapContext } from '~/Providers'; @@ -14,6 +15,8 @@ type SiblingHeaderProps = { agentId?: string; /** The messageId of the parent message */ messageId?: string; + /** ISO timestamp of the parent message */ + createdAt?: string | null; /** The conversationId */ conversationId?: string | null; /** Whether a submission is in progress */ @@ -27,6 +30,7 @@ type SiblingHeaderProps = { export default function SiblingHeader({ agentId, messageId, + createdAt, conversationId, isSubmitting, }: SiblingHeaderProps) { @@ -117,6 +121,7 @@ export default function SiblingHeader({ />
{displayName} +
+ } + /> +
+ ); +} diff --git a/client/src/components/Conversations/ConversationEndpointIcon.tsx b/client/src/components/Conversations/ConversationEndpointIcon.tsx new file mode 100644 index 00000000000..381fe541858 --- /dev/null +++ b/client/src/components/Conversations/ConversationEndpointIcon.tsx @@ -0,0 +1,49 @@ +import { memo } from 'react'; +import type { TConversation, TEndpointsConfig } from 'librechat-data-provider'; +import { useAgentsMapContext, useAssistantsMapContext } from '~/Providers'; +import EndpointIcon from '~/components/Endpoints/EndpointIcon'; +import { areConversationIconFieldsEqual } from './utils'; +import { useGetEndpointsQuery } from '~/data-provider'; + +const emptyEndpointsConfig = {} as TEndpointsConfig; + +type EndpointIconContext = 'message' | 'nav' | 'landing' | 'menu-item'; + +type ConversationEndpointIconProps = { + conversation: TConversation; + className?: string; + context?: EndpointIconContext; + size?: number; +}; + +function ConversationEndpointIcon({ + conversation, + className, + context = 'menu-item', + size = 20, +}: ConversationEndpointIconProps) { + const { data: endpointsConfig = emptyEndpointsConfig } = useGetEndpointsQuery(); + const agentsMap = useAgentsMapContext(); + const assistantMap = useAssistantsMapContext(); + + return ( + + ); +} + +export default memo(ConversationEndpointIcon, (prevProps, nextProps) => { + return ( + prevProps.className === nextProps.className && + prevProps.context === nextProps.context && + prevProps.size === nextProps.size && + areConversationIconFieldsEqual(prevProps.conversation, nextProps.conversation) + ); +}); diff --git a/client/src/components/Conversations/Conversations.tsx b/client/src/components/Conversations/Conversations.tsx index 4a4e1308841..68bf043c633 100644 --- a/client/src/components/Conversations/Conversations.tsx +++ b/client/src/components/Conversations/Conversations.tsx @@ -1,14 +1,23 @@ import { useMemo, memo, type FC, useCallback, useEffect, useRef } from 'react'; import throttle from 'lodash/throttle'; -import { ChevronDown } from 'lucide-react'; import { useRecoilValue } from 'recoil'; -import { Spinner, useMediaQuery } from '@librechat/client'; -import { List, AutoSizer, CellMeasurer, CellMeasurerCache } from 'react-virtualized'; +import { ChevronDown } from 'lucide-react'; +import { QueryKeys } from 'librechat-data-provider'; +import { useQueryClient } from '@tanstack/react-query'; +import { List, CellMeasurer, CellMeasurerCache } from 'react-virtualized'; +import { Spinner, TooltipAnchor, NewChatIcon, useMediaQuery } from '@librechat/client'; import type { TConversation } from 'librechat-data-provider'; -import { useLocalize, TranslationKeys, useFavorites, useShowMarketplace } from '~/hooks'; +import { + useLocalize, + TranslationKeys, + useFavorites, + useShowMarketplace, + useNewConvo, + useElementSize, +} from '~/hooks'; +import { groupConversationsByDate, clearMessagesCache, cn } from '~/utils'; import FavoritesList from '~/components/Nav/Favorites/FavoritesList'; import { useActiveJobs } from '~/data-provider'; -import { groupConversationsByDate, cn } from '~/utils'; import Convo from './Convo'; import store from '~/store'; @@ -32,6 +41,7 @@ interface ConversationsProps { isSearchLoading: boolean; isChatsExpanded: boolean; setIsChatsExpanded: (expanded: boolean) => void; + showFavorites?: boolean; } interface MeasuredRowProps { @@ -76,20 +86,53 @@ interface ChatsHeaderProps { onToggle: () => void; } +const headerIconButtonClassName = + 'flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-text-secondary outline-none transition-colors hover:bg-surface-active-alt hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-black dark:focus-visible:ring-white'; + /** Collapsible header for the Chats section */ const ChatsHeader: FC = memo(({ isExpanded, onToggle }) => { const localize = useLocalize(); + const queryClient = useQueryClient(); + const { newConversation } = useNewConvo(); + const conversation = useRecoilValue(store.conversationByIndex(0)); + + const handleNewChat = useCallback(() => { + clearMessagesCache(queryClient, conversation?.conversationId); + queryClient.invalidateQueries([QueryKeys.messages]); + newConversation(); + }, [conversation?.conversationId, newConversation, queryClient]); + return ( - + + + + } /> - +
); }); @@ -114,42 +157,10 @@ DateLabel.displayName = 'DateLabel'; type FlattenedItem = | { type: 'favorites' } - | { type: 'chats-header' } | { type: 'header'; groupName: string } | { type: 'convo'; convo: TConversation } | { type: 'loading' }; -const MemoizedConvo = memo( - ({ - conversation, - retainView, - toggleNav, - isGenerating, - }: { - conversation: TConversation; - retainView: () => void; - toggleNav: () => void; - isGenerating: boolean; - }) => { - return ( - - ); - }, - (prevProps, nextProps) => { - return ( - prevProps.conversation.conversationId === nextProps.conversation.conversationId && - prevProps.conversation.title === nextProps.conversation.title && - prevProps.conversation.endpoint === nextProps.conversation.endpoint && - prevProps.isGenerating === nextProps.isGenerating - ); - }, -); - const Conversations: FC = ({ conversations: rawConversations, moveToTop, @@ -160,6 +171,7 @@ const Conversations: FC = ({ isSearchLoading, isChatsExpanded, setIsChatsExpanded, + showFavorites = true, }) => { const localize = useLocalize(); const search = useRecoilValue(store.search); @@ -167,6 +179,11 @@ const Conversations: FC = ({ const isSmallScreen = useMediaQuery('(max-width: 768px)'); const convoHeight = isSmallScreen ? 44 : 34; const showAgentMarketplace = useShowMarketplace(); + const { + ref: listContainerRef, + width: listWidth, + height: listHeight, + } = useElementSize(); const favoritesContentKeyRef = useRef(''); @@ -179,7 +196,9 @@ const Conversations: FC = ({ // Determine if FavoritesList will render content const shouldShowFavorites = - !search.query && (isFavoritesLoading || favorites.length > 0 || showAgentMarketplace); + showFavorites && + !search.query && + (isFavoritesLoading || favorites.length > 0 || showAgentMarketplace); favoritesContentKeyRef.current = `${favorites.length}-${showAgentMarketplace ? 1 : 0}-${isFavoritesLoading ? 1 : 0}`; @@ -199,7 +218,6 @@ const Conversations: FC = ({ if (shouldShowFavorites) { items.push({ type: 'favorites' }); } - items.push({ type: 'chats-header' }); if (isChatsExpanded) { groupedConversations.forEach(([groupName, convos]) => { @@ -232,11 +250,9 @@ const Conversations: FC = ({ if (item.type === 'favorites') { return `favorites-${favoritesContentKeyRef.current}`; } - if (item.type === 'chats-header') { - return 'chats-header'; - } if (item.type === 'header') { - return `header-${item.groupName}`; + const firstHeaderIndex = flattenedItemsRef.current[0]?.type === 'favorites' ? 1 : 0; + return `header-${item.groupName}-${index === firstHeaderIndex ? 'first' : 'sub'}`; } if (item.type === 'convo') { return `convo-${item.convo.conversationId}`; @@ -276,6 +292,17 @@ const Conversations: FC = ({ return () => cancelAnimationFrame(frameId); }, [search.query, cache, containerRef]); + /** Grid only re-derives row offsets when the row count changes; reorders that + * keep the count (e.g. a convo bumped across date groups) need an explicit recompute. */ + useEffect(() => { + const frameId = requestAnimationFrame(() => { + if (containerRef.current && 'recomputeRowHeights' in containerRef.current) { + containerRef.current.recomputeRowHeights(0); + } + }); + return () => cancelAnimationFrame(frameId); + }, [flattenedItems, containerRef]); + const rowRenderer = useCallback( ({ index, key, parent, style }) => { const item = flattenedItems[index]; @@ -297,22 +324,9 @@ const Conversations: FC = ({ ); } - if (item.type === 'chats-header') { - return ( - - setIsChatsExpanded(!isChatsExpanded)} - /> - - ); - } - if (item.type === 'header') { - // First date header index depends on whether favorites row is included - // With favorites: [favorites, chats-header, first-header] → index 2 - // Without favorites: [chats-header, first-header] → index 1 - const firstHeaderIndex = shouldShowFavorites ? 2 : 1; + // First date header index depends on whether the favorites row is included + const firstHeaderIndex = flattenedItems[0]?.type === 'favorites' ? 1 : 0; return ( @@ -324,7 +338,7 @@ const Conversations: FC = ({ const isGenerating = activeJobIds.has(item.convo.conversationId ?? ''); return ( - = ({ return null; }, - [ - cache, - flattenedItems, - moveToTop, - toggleNav, - isSmallScreen, - isChatsExpanded, - setIsChatsExpanded, - shouldShowFavorites, - activeJobIds, - ], + [cache, flattenedItems, moveToTop, toggleNav, isSmallScreen, activeJobIds], ); const getRowHeight = useCallback( @@ -370,34 +374,36 @@ const Conversations: FC = ({ return (
+
+ setIsChatsExpanded(!isChatsExpanded)} + /> +
{isSearchLoading ? (
{localize('com_ui_loading')}
) : ( -
- - {({ width, height }) => ( - - )} - +
+
)}
diff --git a/client/src/components/Conversations/Convo.tsx b/client/src/components/Conversations/Convo.tsx index 46e3be09ebc..95d6198df43 100644 --- a/client/src/components/Conversations/Convo.tsx +++ b/client/src/components/Conversations/Convo.tsx @@ -1,13 +1,13 @@ -import React, { useState, useEffect, useRef, useMemo, useCallback } from 'react'; +import React, { memo, useState, useEffect, useRef, useMemo, useCallback } from 'react'; import { useRecoilValue } from 'recoil'; import { useParams } from 'react-router-dom'; import { Constants } from 'librechat-data-provider'; import { useToastContext, useMediaQuery } from '@librechat/client'; import type { TConversation } from 'librechat-data-provider'; -import { useUpdateConversationMutation } from '~/data-provider'; -import EndpointIcon from '~/components/Endpoints/EndpointIcon'; import { useNavigateToConvo, useLocalize, useShiftKey } from '~/hooks'; -import { useGetEndpointsQuery } from '~/data-provider'; +import ConversationEndpointIcon from './ConversationEndpointIcon'; +import { useUpdateConversationMutation } from '~/data-provider'; +import { areConversationRenderPropsEqual } from './utils'; import { NotificationSeverity } from '~/common'; import { ConvoOptions } from './ConvoOptions'; import RenameForm from './RenameForm'; @@ -22,7 +22,7 @@ interface ConversationProps { isGenerating?: boolean; } -export default function Conversation({ +function Conversation({ conversation, retainView, toggleNav, @@ -32,7 +32,6 @@ export default function Conversation({ const localize = useLocalize(); const { showToast } = useToastContext(); const { navigateToConvo } = useNavigateToConvo(); - const { data: endpointsConfig } = useGetEndpointsQuery(); const currentConvoId = useMemo(() => params.conversationId, [params.conversationId]); const updateConvoMutation = useUpdateConversationMutation(currentConvoId ?? ''); const activeConvos = useRecoilValue(store.allConversationsSelector); @@ -171,11 +170,48 @@ export default function Conversation({ renameHandler: handleRename, isActiveConvo, conversationId, + chatProjectId: conversation.chatProjectId, isPopoverActive, setIsPopoverActive: handlePopoverOpenChange, isShiftHeld: isActiveConvo ? isShiftHeld : false, }; + const generatingSpinner = ( + + + + + ); + + let actionVisibilityClassName = + 'pointer-events-none max-w-0 scale-x-0 opacity-0 group-focus-within:pointer-events-auto group-focus-within:max-w-[60px] group-focus-within:scale-x-100 group-focus-within:opacity-100 group-hover:pointer-events-auto group-hover:max-w-[60px] group-hover:scale-x-100 group-hover:opacity-100'; + if (isGenerating) { + actionVisibilityClassName = 'pointer-events-none w-5 scale-x-100 opacity-100'; + } else if (isPopoverActive || isActiveConvo) { + actionVisibilityClassName = 'pointer-events-auto scale-x-100 opacity-100'; + } + + let actionWidthClassName = ''; + if (!isGenerating && !isPopoverActive && isActiveConvo && isShiftHeld) { + actionWidthClassName = 'max-w-[60px]'; + } else if (!isGenerating) { + actionWidthClassName = 'max-w-[28px]'; + } + + const showConvoOptions = !renaming && (hasInteracted || isActiveConvo); + const actionContent = isGenerating + ? generatingSpinner + : showConvoOptions && ; + return (
- {isGenerating ? ( - - - - - ) : ( - - )} + )}
); } + +export default memo(Conversation, areConversationRenderPropsEqual); diff --git a/client/src/components/Conversations/ConvoLink.tsx b/client/src/components/Conversations/ConvoLink.tsx index 543407501e7..6fd251f02b3 100644 --- a/client/src/components/Conversations/ConvoLink.tsx +++ b/client/src/components/Conversations/ConvoLink.tsx @@ -23,7 +23,7 @@ const ConvoLink: React.FC = ({ return (
void; renameHandler: (e: MouseEvent) => void; @@ -53,11 +66,19 @@ function ConvoOptions({ const menuId = useId(); const shareButtonRef = useRef(null); const deleteButtonRef = useRef(null); + const projectButtonRef = useRef(null); const [showShareDialog, setShowShareDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); + const [showProjectDialog, setShowProjectDialog] = useState(false); const [announcement, setAnnouncement] = useState(''); + const canCreateSharedLinks = useHasAccess({ + permissionType: PermissionTypes.SHARED_LINKS, + permission: Permissions.CREATE, + }); + const archiveConvoMutation = useArchiveConvoMutation(); + const assignConversationToProject = useAssignConversationToProjectMutation(); const deleteMutation = useDeleteConversationMutation({ onSuccess: () => { @@ -116,6 +137,37 @@ function ConvoOptions({ setShowDeleteDialog(true); }, []); + const projectHandler = useCallback(() => { + setShowProjectDialog(true); + }, []); + + const removeProjectHandler = useCallback(() => { + const convoId = conversationId ?? ''; + if (!convoId) { + return; + } + assignConversationToProject.mutate( + { conversationId: convoId, projectId: null }, + { + onSuccess: () => { + setIsPopoverActive(false); + showToast({ + message: localize('com_ui_project_updated'), + severity: NotificationSeverity.SUCCESS, + showIcon: true, + }); + }, + onError: () => { + showToast({ + message: localize('com_ui_project_update_error'), + severity: NotificationSeverity.ERROR, + showIcon: true, + }); + }, + }, + ); + }, [assignConversationToProject, conversationId, localize, setIsPopoverActive, showToast]); + const handleInstantDelete = useCallback( (e: MouseEvent) => { e.stopPropagation(); @@ -189,7 +241,7 @@ function ConvoOptions({ label: localize('com_ui_share'), onClick: shareHandler, icon: