diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 7f5566fb979..5d5454e97be 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,4 +1,4 @@ -FROM node:18-bullseye +FROM node:24.16.0-bullseye RUN useradd -m -s /bin/bash vscode RUN mkdir -p /workspaces && chown -R vscode:vscode /workspaces diff --git a/.do/gitnexus/Dockerfile b/.do/gitnexus/Dockerfile index a1266d5a133..8b7e538726e 100644 --- a/.do/gitnexus/Dockerfile +++ b/.do/gitnexus/Dockerfile @@ -5,15 +5,20 @@ # startup. A fresh index only requires rsync + container restart — no # image rebuild on every push. -FROM node:24-slim +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 5d7f69a9def..09749f7e05b 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,22 @@ MONGO_AUTO_CREATE= DOMAIN_CLIENT=http://localhost:3080 DOMAIN_SERVER=http://localhost:3080 +# External admin panel base URL used for admin OAuth/SSO redirects. +# Required when the admin panel is hosted separately from LibreChat. +# May include a path. Do not include a trailing slash. +# Example: https://admin.example.com/admin +ADMIN_PANEL_URL= + +# Session encryption key for the bundled admin panel (min 32 characters). +# Required when using the bundled admin panel in docker-compose/deploy-compose. +# Generate a unique value before starting the stack: +# openssl rand -hex 32 +ADMIN_PANEL_SESSION_SECRET= + +# Host port for the bundled admin panel (default docker-compose only). +# In deploy-compose the panel is served at http://admin.localhost via nginx. +# ADMIN_PANEL_PORT=3000 + NO_INDEX=true # Use the address that is at most n number of hops away from the Express application. # req.socket.remoteAddress is the first hop, and the rest are looked for in the X-Forwarded-For header from right to left. @@ -68,6 +84,8 @@ CONSOLE_JSON=false DEBUG_LOGGING=true DEBUG_CONSOLE=false +# Set to false to disable file-backed Winston transports. +LOG_TO_FILE=true # Set to true to enable agent debug logging AGENT_DEBUG_LOGGING=false @@ -100,6 +118,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 # #==================# @@ -110,13 +132,72 @@ NODE_MAX_OLD_SPACE_SIZE=6144 # LANGFUSE_SECRET_KEY= # LANGFUSE_BASE_URL= +#=======================# +# OpenTelemetry Tracing # +#=======================# + +# Enables backend OpenTelemetry tracing. General backend visibility only; +# use Langfuse for GenAI-specific prompt/model observability. +# OTEL_TRACING_ENABLED=false +# OTEL_SERVICE_NAME=librechat +# OTEL_SERVICE_VERSION= +# OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 +# OTEL_EXPORTER_OTLP_TRACES_ENDPOINT= +# OTEL_EXPORTER_OTLP_HEADERS= +# OTEL_TRACES_EXPORTER=otlp +# 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) # +#===============================# + +# Enables browser Real User Monitoring. Disabled by default. +# Currently supports HyperDX via the browser SDK. +# RUM_ENABLED=false +# RUM_PROVIDER=hyperdx +# RUM_URL=http://localhost:4318 +# RUM_SERVICE_NAME=librechat-web +# RUM_ENVIRONMENT=development + +# Public browser-token mode is suitable for OSS/self-hosted deployments. +# Treat the token as public and restrict/rate-limit ingestion in your RUM backend. +# RUM_AUTH_MODE=publicToken +# RUM_PUBLIC_TOKEN= + +# Authenticated proxy mode sends browser telemetry to this LibreChat backend first. +# The backend validates the LibreChat session, strips app auth, and forwards to the collector. +# RUM_AUTH_MODE=proxy +# RUM_PROXY_TARGET_URL=http://otel-collector:4318 +# RUM_PROXY_TIMEOUT_MS=10000 + +# Optional comma-separated first-party HTTPS origins/URLs that should receive traceparent headers. +# Wildcards and non-HTTPS targets are ignored. +# RUM_TRACE_PROPAGATION_TARGETS=https://api.example.com + +# Privacy defaults: replay, console capture, and full network body capture stay off. +# Console/network capture may collect sensitive browser logs, prompts, responses, or payloads. +# RUM_DISABLE_REPLAY=true +# RUM_CONSOLE_CAPTURE=false +# RUM_ADVANCED_NETWORK_CAPTURE=false +# RUM_SAMPLE_RATE=1 + #===================================================# # Endpoints # #===================================================# # ENDPOINTS=openAI,assistants,azureOpenAI,google,anthropic +# 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 # @@ -144,7 +225,7 @@ PROXY= #============# ANTHROPIC_API_KEY=user_provided -# ANTHROPIC_MODELS=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 @@ -173,15 +254,45 @@ ANTHROPIC_API_KEY=user_provided #=================# # AWS Bedrock # #=================# +# AWS Bedrock credentials +# +# Preferred for local development: configure an AWS profile in ~/.aws/config or +# ~/.aws/credentials, then set BEDROCK_AWS_PROFILE. LibreChat passes this profile +# to the AWS SDK for JavaScript credential provider chain. +# +# In deployed environments, prefer IAM roles or other short-term credentials +# discoverable by the AWS SDK default credential provider chain. If neither +# BEDROCK_AWS_PROFILE nor Bedrock-specific static credentials are set, the SDK +# uses its default provider chain. AWS-standard environment variables still +# follow AWS SDK precedence. +# +# Profiles can use IAM Identity Center, assume-role settings, or credential_process. +# If you use credential_process, secure the config file and helper command, and do +# not write secret material to stderr. +# +# AWS SDK credential chain: +# https://docs.aws.amazon.com/sdk-for-javascript/v3/developer-guide/setting-credentials-node.html +# Shared config/profile settings: +# https://docs.aws.amazon.com/sdkref/latest/guide/settings-reference.html +# credential_process security notes: +# https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-sourcing-external.html # BEDROCK_AWS_DEFAULT_REGION=us-east-1 # A default region must be provided + +# AWS Profile +# BEDROCK_AWS_PROFILE=your-profile-name + +# Static credentials (use only if profiles or IAM roles are not suitable) # BEDROCK_AWS_ACCESS_KEY_ID=someAccessKey # BEDROCK_AWS_SECRET_ACCESS_KEY=someSecretAccessKey # BEDROCK_AWS_SESSION_TOKEN=someSessionToken +# Bedrock API key +# 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-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-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 @@ -192,6 +303,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 # #============# @@ -471,6 +586,10 @@ ALLOW_UNVERIFIED_EMAIL_LOGIN=true SESSION_EXPIRY=1000 * 60 * 15 REFRESH_TOKEN_EXPIRY=(1000 * 60 * 60 * 24) * 7 +# Overrides the Secure attribute for session/auth cookies when set to true or false; +# leave unset to use the default NODE_ENV/DOMAIN_SERVER heuristic. +# Set to false only for HTTP-only deployments where browsers drop Secure cookies. +# SESSION_COOKIE_SECURE=false JWT_SECRET=16f8c0ef4a5d391b26034086c628469d3f9f497f08163ab9b40137092f2909ef JWT_REFRESH_SECRET=eaa5191f2914e30b9387fd84e254e4ba6fc51b4654968a9b0803b456a54b8418 @@ -512,12 +631,24 @@ OPENID_ISSUER= OPENID_SESSION_SECRET= OPENID_SCOPE="openid profile email" OPENID_CALLBACK_URL=/oauth/openid/callback +# Admin panel SSO uses ${DOMAIN_SERVER}/api/admin/oauth/openid/callback as the +# OpenID provider redirect URI. OPENID_REQUIRED_ROLE= OPENID_REQUIRED_ROLE_TOKEN_KIND= OPENID_REQUIRED_ROLE_PARAMETER_PATH= OPENID_ADMIN_ROLE= OPENID_ADMIN_ROLE_PARAMETER_PATH= OPENID_ADMIN_ROLE_TOKEN_KIND= +# Generic OpenID role sync maps non-admin IdP roles/groups to one LibreChat role. +# ADMIN cannot be assigned by generic role sync; use OPENID_ADMIN_ROLE for admin elevation. +# Role priority is ordered from most important to least important. +OPENID_ROLE_SYNC_ENABLED=false +OPENID_ROLE_SYNC_API_ENABLED=false +OPENID_ROLE_SYNC_SOURCE=id +OPENID_ROLE_SYNC_CLAIM= +OPENID_ROLE_SYNC_ROLE_PRIORITY= +# Fallback is authoritative when configured: if no priority role matches, this role is assigned. +OPENID_ROLE_SYNC_FALLBACK_ROLE= # Set to determine which user info property returned from OpenID Provider to store as the User's username OPENID_USERNAME_CLAIM= # Set to determine which user info property returned from OpenID Provider to store as the User's name @@ -525,7 +656,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 @@ -537,10 +670,17 @@ OPENID_IMAGE_URL= # Set to true to automatically redirect to the OpenID provider when a user visits the login page # This will bypass the login form completely for users, only use this if OpenID is your only authentication method OPENID_AUTO_REDIRECT=false -# Set to true to use PKCE (Proof Key for Code Exchange) for OpenID authentication +# Set to true to use PKCE (Proof Key for Code Exchange) for OpenID authentication. +# For public clients (no client secret), leave OPENID_CLIENT_SECRET empty and set this to true. 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. @@ -679,6 +819,9 @@ AWS_BUCKET_NAME= # Required for path-style S3-compatible providers (MinIO, Hetzner, Backblaze B2, etc.) # that don't support virtual-hosted-style URLs (bucket.endpoint). Not needed for AWS S3. # AWS_FORCE_PATH_STYLE=false +# Required for CloudFront signed cookies and signed download URLs +# CLOUDFRONT_KEY_PAIR_ID= +# CLOUDFRONT_PRIVATE_KEY= #========================# # Azure Blob Storage # @@ -695,6 +838,10 @@ AZURE_CONTAINER_NAME=files ALLOW_SHARED_LINKS=true # Allows unauthenticated access to shared links. Defaults to false (auth required) if not set. ALLOW_SHARED_LINKS_PUBLIC=false +# Snapshot files referenced by a shared chat so viewers can preview/download them through +# the shared link (instead of the owner's file ACL). Enabled by default; overrides the +# `interface.sharedLinks.snapshotFiles` yaml setting when set. +# SHARED_LINKS_SNAPSHOT_FILES=true #==============================# # Static File Cache Control # @@ -708,6 +855,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 @@ -730,6 +880,11 @@ HELP_AND_FAQ_URL=https://librechat.ai # such as the below example of 250 mib # CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES=262144000 +# Max size (bytes) of a code-execution artifact (docx/xlsx/csv/pptx/text/pdf) rendered as an +# inline preview. Larger files fall back to download-only. Default: 2 MB (2097152). Note the +# rendered HTML is independently capped at 512 KB, so very rich files may still skip preview. +# FILE_PREVIEW_MAX_EXTRACT_BYTES=2097152 + #===============# # REDIS Options # @@ -746,6 +901,12 @@ HELP_AND_FAQ_URL=https://librechat.ai # Redis cluster (multiple nodes) # REDIS_URI=redis://127.0.0.1:7001,redis://127.0.0.1:7002,redis://127.0.0.1:7003 +# Enable Redis cluster mode when connecting to a cluster through a single URI +# USE_REDIS_CLUSTER=true + +# Managed Redis services with a single endpoint may shard keys internally and reject multi-key DEL +# Set to true to delete keys individually and avoid CROSSSLOT errors while keeping single-node mode +# REDIS_CLUSTER_SAFE_DELETE=true # Redis with TLS/SSL encryption and CA certificate # REDIS_URI=rediss://127.0.0.1:6380 @@ -856,9 +1017,23 @@ 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 +# Max bytes allowed in a non-GET streamable HTTP MCP response before rejecting it. +# Set to 0 to disable. Default: 16777216 (16 MiB) +# MCP_STREAMABLE_HTTP_MAX_RESPONSE_BYTES=16777216 + +# Max bytes allowed in a single SSE line for non-GET streamable HTTP MCP responses. +# Set to 0 to disable. Default: 5242880 (5 MiB) +# MCP_STREAMABLE_HTTP_MAX_LINE_BYTES=5242880 + # Skip code challenge method validation (e.g., for AWS Cognito that supports S256 but doesn't advertise it) # When set to true, forces S256 code challenge even if not advertised in .well-known/openid-configuration # MCP_SKIP_CODE_CHALLENGE_CHECK=false diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index ae9e6d8e4ba..6524947ba22 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -26,7 +26,7 @@ Project maintainers have the right and responsibility to remove, edit, or reject ## 1. Development Setup -1. Use Node.js v20.19.0+ or ^22.12.0 or >= 23.0.0. +1. Use Node.js v24.16.0. 2. Run `npm run smart-reinstall` to install dependencies (uses Turborepo). Use `npm run reinstall` for a clean install, or `npm ci` for a fresh lockfile-based install. 3. Build all compiled code: `npm run build`. 4. Setup and run unit tests: diff --git a/.github/ISSUE_TEMPLATE/BUG-REPORT.yml b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml index 610396959fe..e7ef45f7c42 100644 --- a/.github/ISSUE_TEMPLATE/BUG-REPORT.yml +++ b/.github/ISSUE_TEMPLATE/BUG-REPORT.yml @@ -26,17 +26,14 @@ body: id: version-info attributes: label: Version Information - description: | - If using Docker, please run and provide the output of: - ```bash - docker images | grep librechat - ``` - - If running from source, please run and provide the output of: - ```bash - git rev-parse HEAD - ``` - placeholder: Paste the output here + description: | + In LibreChat, open **Settings → About** and click **Copy diagnostics**, then paste the result here. + This captures the exact version, commit, branch, and build date so maintainers can pinpoint the build you're running. + + If the About panel is unavailable (older version / self-hosted with it disabled), please provide as much of the following as possible instead: + - Docker: `docker images | grep librechat` (image tag) and `docker inspect | grep -i "\"Commit\\|BUILD_"` if build args were set + - Source: `git rev-parse HEAD` and `git rev-parse --abbrev-ref HEAD` + placeholder: Paste the diagnostics block here validations: required: true - type: textarea @@ -93,4 +90,4 @@ body: description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/danny-avila/LibreChat/blob/main/.github/CODE_OF_CONDUCT.md) options: - label: I agree to follow this project's Code of Conduct - required: true \ No newline at end of file + required: true 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/scripts/sync-helm-chart-tags.sh b/.github/scripts/sync-helm-chart-tags.sh new file mode 100755 index 00000000000..f54048a1ea9 --- /dev/null +++ b/.github/scripts/sync-helm-chart-tags.sh @@ -0,0 +1,237 @@ +#!/usr/bin/env bash +set -euo pipefail + +CHART_PATH="${CHART_PATH:-helm/librechat/Chart.yaml}" +DEFAULT_BRANCH="${DEFAULT_BRANCH:-main}" +BASE_REF="${BASE_REF:-refs/remotes/origin/${DEFAULT_BRANCH}}" +BACKFILL_FROM_VERSION="${BACKFILL_FROM_VERSION:-1.9.0}" +PUSH_TAGS="${PUSH_TAGS:-false}" +TAG_PREFIX="${TAG_PREFIX:-chart-}" +GITHUB_SERVER_URL="${GITHUB_SERVER_URL:-https://github.com}" +DISPATCH_WORKFLOW="${DISPATCH_WORKFLOW:-}" +RELEASE_EXISTING_TAG="${RELEASE_EXISTING_TAG:-}" +SEMVER_REGEX='^(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)(-[0-9A-Za-z-]+([.][0-9A-Za-z-]+)*)?([+][0-9A-Za-z-]+([.][0-9A-Za-z-]+)*)?$' + +fail() { + printf '::error::%s\n' "$1" >&2 + exit 1 +} + +git_auth_header() { + token="$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n')" + printf 'AUTHORIZATION: basic %s' "$token" +} + +git_with_auth() { + if [ -n "${GITHUB_TOKEN:-}" ]; then + git -c "http.extraheader=$(git_auth_header)" "$@" + return + fi + + git "$@" +} + +dispatch_release() { + tag="$1" + + if [ -z "$DISPATCH_WORKFLOW" ]; then + return + fi + + if [ -z "${GITHUB_REPOSITORY:-}" ]; then + fail "GITHUB_REPOSITORY is required to dispatch ${DISPATCH_WORKFLOW}" + fi + + if [[ ! "$GITHUB_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + fail "Unexpected repository name: ${GITHUB_REPOSITORY}" + fi + + if [[ ! "$DISPATCH_WORKFLOW" =~ ^[A-Za-z0-9_.-]+[.]ya?ml$ ]]; then + fail "Unexpected workflow file: ${DISPATCH_WORKFLOW}" + fi + + token="${GH_TOKEN:-${GITHUB_TOKEN:-}}" + if [ -z "$token" ]; then + fail "GH_TOKEN or GITHUB_TOKEN is required to dispatch ${DISPATCH_WORKFLOW}" + fi + + command -v gh >/dev/null || + fail "GitHub CLI is required to dispatch ${DISPATCH_WORKFLOW}" + + GH_TOKEN="$token" gh workflow run "$DISPATCH_WORKFLOW" \ + --repo "$GITHUB_REPOSITORY" \ + --ref "$DEFAULT_BRANCH" \ + -f "chart_tag=${tag}" +} + +version_less_than() { + left="${1%%[-+]*}" + right="${2%%[-+]*}" + + IFS=. read -r left_major left_minor left_patch <<<"$left" + IFS=. read -r right_major right_minor right_patch <<<"$right" + + if (( left_major != right_major )); then + (( left_major < right_major )) + return + fi + + if (( left_minor != right_minor )); then + (( left_minor < right_minor )) + return + fi + + (( left_patch < right_patch )) +} + +validate_chart_tag() { + tag="$1" + version="${tag#${TAG_PREFIX}}" + + git check-ref-format "refs/tags/${tag}" >/dev/null || + fail "Refusing to use invalid tag ${tag}" + + if [[ "$tag" != "${TAG_PREFIX}"* || ! "$version" =~ $SEMVER_REGEX ]]; then + fail "Chart tags must use the form ${TAG_PREFIX}, for example ${TAG_PREFIX}2.0.5" + fi +} + +dispatch_existing_tag() { + tag="$1" + + if [ -z "$tag" ]; then + return + fi + + validate_chart_tag "$tag" + + if [ "$PUSH_TAGS" != "true" ]; then + printf 'Would dispatch release workflow for existing %s.\n' "$tag" + return + fi + + if ! git_with_auth ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then + fail "Remote tag ${tag} does not exist" + fi + + printf 'Dispatching release workflow for existing %s.\n' "$tag" + dispatch_release "$tag" +} + +chart_version_at() { + git show "${1}:${CHART_PATH}" 2>/dev/null | awk ' + /^version:[[:space:]]*/ { + value = $0 + sub(/^version:[[:space:]]*/, "", value) + sub(/[[:space:]]*#.*/, "", value) + gsub(/^[[:space:]"'\''"]+|[[:space:]"'\''"]+$/, "", value) + print value + exit + } + ' +} + +case "$PUSH_TAGS" in + true | false) ;; + *) fail "PUSH_TAGS must be true or false" ;; +esac + +if [[ ! "$BACKFILL_FROM_VERSION" =~ $SEMVER_REGEX ]]; then + fail "BACKFILL_FROM_VERSION must be a valid SemVer value" +fi + +git rev-parse --verify "${BASE_REF}^{commit}" >/dev/null || + fail "Unable to resolve ${BASE_REF}; fetch ${DEFAULT_BRANCH} before running this script" + +history_file="$(mktemp)" +versions_file="$(mktemp)" +seen_file="$(mktemp)" +missing_file="$(mktemp)" +cleanup() { + rm -f "$history_file" "$versions_file" "$seen_file" "$missing_file" +} +trap cleanup EXIT + +git log --first-parent --reverse --format=%H "$BASE_REF" -- "$CHART_PATH" >"$history_file" + +if [ ! -s "$history_file" ]; then + fail "No history found for ${CHART_PATH} on ${BASE_REF}" +fi + +while IFS= read -r commit; do + version="$(chart_version_at "$commit")" + + if [ -z "$version" ]; then + continue + fi + + if [[ ! "$version" =~ $SEMVER_REGEX ]]; then + fail "${CHART_PATH} has invalid SemVer '${version}' at ${commit}" + fi + + if version_less_than "$version" "$BACKFILL_FROM_VERSION"; then + continue + fi + + if grep -Fqx "$version" "$seen_file"; then + continue + fi + + printf '%s\n' "$version" >>"$seen_file" + printf '%s\t%s\n' "$version" "$commit" >>"$versions_file" +done <"$history_file" + +if [ ! -s "$versions_file" ]; then + fail "No chart versions found in ${CHART_PATH}" +fi + +while IFS="$(printf '\t')" read -r version commit; do + tag="${TAG_PREFIX}${version}" + + validate_chart_tag "$tag" + + if git rev-parse --quiet --verify "refs/tags/${tag}" >/dev/null; then + continue + fi + + printf '%s\t%s\n' "$tag" "$commit" >>"$missing_file" +done <"$versions_file" + +if [ ! -s "$missing_file" ]; then + printf 'All chart versions on %s already have %s tags.\n' "$BASE_REF" "$TAG_PREFIX" + dispatch_existing_tag "$RELEASE_EXISTING_TAG" + exit 0 +fi + +while IFS="$(printf '\t')" read -r tag commit; do + short_commit="$(git rev-parse --short "$commit")" + + if [ "$PUSH_TAGS" != "true" ]; then + printf 'Would create %s at %s.\n' "$tag" "$short_commit" + continue + fi + + if git_with_auth ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then + printf 'Remote tag %s already exists; dispatching release workflow.\n' "$tag" + dispatch_release "$tag" + continue + fi + + git tag "$tag" "$commit" + + if git_with_auth push origin "refs/tags/${tag}"; then + printf 'Created %s at %s.\n' "$tag" "$short_commit" + dispatch_release "$tag" + continue + fi + + if git_with_auth ls-remote --exit-code --tags origin "refs/tags/${tag}" >/dev/null 2>&1; then + printf 'Remote tag %s was created concurrently; dispatching release workflow.\n' "$tag" + dispatch_release "$tag" + continue + fi + + fail "Failed to push ${tag}" +done <"$missing_file" + +dispatch_existing_tag "$RELEASE_EXISTING_TAG" diff --git a/.github/workflows/a11y.yml b/.github/workflows/a11y.yml index a7cfd08169b..344592cf3ed 100644 --- a/.github/workflows/a11y.yml +++ b/.github/workflows/a11y.yml @@ -11,6 +11,10 @@ on: required: true default: 'false' +permissions: + contents: read + pull-requests: write + jobs: axe-linter: runs-on: ubuntu-latest diff --git a/.github/workflows/backend-review.yml b/.github/workflows/backend-review.yml index 03b7c135d2a..46a698cd5ac 100644 --- a/.github/workflows/backend-review.yml +++ b/.github/workflows/backend-review.yml @@ -5,6 +5,9 @@ on: - 'api/**' - 'packages/**' +permissions: + contents: read + env: NODE_ENV: CI NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}' @@ -17,10 +20,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Use Node.js 20.19 + - name: Use Node.js 24.16.0 uses: actions/setup-node@v4 with: - node-version: '20.19' + node-version: '24.16.0' - name: Restore node_modules cache id: cache-node-modules @@ -32,7 +35,7 @@ jobs: 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') }} + 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' @@ -43,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' @@ -54,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' @@ -65,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' @@ -100,10 +103,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Use Node.js 20.19 + - name: Use Node.js 24.16.0 uses: actions/setup-node@v4 with: - node-version: '20.19' + node-version: '24.16.0' - name: Restore node_modules cache id: cache-node-modules @@ -115,7 +118,7 @@ jobs: 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') }} + 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' @@ -159,10 +162,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Use Node.js 20.19 + - name: Use Node.js 24.16.0 uses: actions/setup-node@v4 with: - node-version: '20.19' + node-version: '24.16.0' - name: Restore node_modules cache id: cache-node-modules @@ -174,7 +177,7 @@ jobs: 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') }} + 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' @@ -212,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 }} @@ -228,10 +235,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Use Node.js 20.19 + - name: Use Node.js 24.16.0 uses: actions/setup-node@v4 with: - node-version: '20.19' + node-version: '24.16.0' - name: Restore node_modules cache id: cache-node-modules @@ -243,7 +250,7 @@ jobs: 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') }} + 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' @@ -275,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' @@ -286,10 +293,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Use Node.js 20.19 + - name: Use Node.js 24.16.0 uses: actions/setup-node@v4 with: - node-version: '20.19' + node-version: '24.16.0' - name: Restore node_modules cache id: cache-node-modules @@ -301,7 +308,7 @@ jobs: 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') }} + 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' @@ -324,10 +331,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Use Node.js 20.19 + - name: Use Node.js 24.16.0 uses: actions/setup-node@v4 with: - node-version: '20.19' + node-version: '24.16.0' - name: Restore node_modules cache id: cache-node-modules @@ -339,7 +346,7 @@ jobs: 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') }} + 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' @@ -361,17 +368,25 @@ 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 - timeout-minutes: 10 + # 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. 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 - - name: Use Node.js 20.19 + - name: Use Node.js 24.16.0 uses: actions/setup-node@v4 with: - node-version: '20.19' + node-version: '24.16.0' - name: Restore node_modules cache id: cache-node-modules @@ -383,7 +398,7 @@ jobs: 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') }} + 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' @@ -407,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/build.yml b/.github/workflows/build.yml index a2131c4b985..9210b80a93d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -3,6 +3,9 @@ name: Linux_Container_Workflow on: workflow_dispatch: +permissions: + contents: read + env: RUNNER_VERSION: 2.293.0 @@ -12,26 +15,26 @@ jobs: steps: # checkout the repo - name: 'Checkout GitHub Action' - uses: actions/checkout@main + uses: actions/checkout@v4 - name: 'Login via Azure CLI' - uses: azure/login@v1 + uses: azure/login@v2 with: creds: ${{ secrets.AZURE_CREDENTIALS }} - name: 'Build GitHub Runner container image' - uses: azure/docker-login@v1 + uses: docker/login-action@v3 with: - login-server: ${{ secrets.REGISTRY_LOGIN_SERVER }} + registry: ${{ secrets.REGISTRY_LOGIN_SERVER }} username: ${{ secrets.REGISTRY_USERNAME }} password: ${{ secrets.REGISTRY_PASSWORD }} - run: | docker build --build-arg RUNNER_VERSION=${{ env.RUNNER_VERSION }} -t ${{ secrets.REGISTRY_LOGIN_SERVER }}/pwd9000-github-runner-lin:${{ env.RUNNER_VERSION }} . - name: 'Push container image to ACR' - uses: azure/docker-login@v1 + uses: docker/login-action@v3 with: - login-server: ${{ secrets.REGISTRY_LOGIN_SERVER }} + registry: ${{ secrets.REGISTRY_LOGIN_SERVER }} username: ${{ secrets.REGISTRY_USERNAME }} password: ${{ secrets.REGISTRY_PASSWORD }} - run: | diff --git a/.github/workflows/cache-integration-tests.yml b/.github/workflows/cache-integration-tests.yml index caebbfc4454..1a70e4b6b0e 100644 --- a/.github/workflows/cache-integration-tests.yml +++ b/.github/workflows/cache-integration-tests.yml @@ -15,6 +15,9 @@ on: - 'redis-config/**' - '.github/workflows/cache-integration-tests.yml' +permissions: + contents: read + jobs: cache_integration_tests: name: Integration Tests that use actual Redis Cache @@ -25,11 +28,10 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Use Node.js 20.x + - name: Use Node.js 24.16.0 uses: actions/setup-node@v4 with: - node-version: 20 - cache: 'npm' + node-version: '24.16.0' - name: Install Redis tools run: | @@ -54,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/client.yml b/.github/workflows/client.yml index e3e3e445e46..e4dc8c56264 100644 --- a/.github/workflows/client.yml +++ b/.github/workflows/client.yml @@ -14,31 +14,27 @@ on: default: 'Manual publish requested' permissions: - id-token: write # Required for OIDC trusted publishing contents: read jobs: - build-and-publish: + pack: runs-on: ubuntu-latest - environment: publish # Must match npm trusted publisher config + outputs: + skip: ${{ steps.check.outputs.skip }} steps: - uses: actions/checkout@v4 - + - name: Use Node.js uses: actions/setup-node@v4 with: - node-version: '20.x' - registry-url: 'https://registry.npmjs.org' - - - name: Update npm for OIDC support - run: npm install -g npm@latest # Must be 11.5.1+ for provenance - + node-version: '24.16.0' + - name: Install client dependencies run: cd packages/client && npm ci - + - name: Build client run: cd packages/client && npm run build - + - name: Check version change id: check working-directory: packages/client @@ -52,13 +48,47 @@ jobs: echo "Version changed, proceeding with publish" echo "skip=false" >> $GITHUB_OUTPUT fi - + - name: Pack package if: steps.check.outputs.skip != 'true' working-directory: packages/client - run: npm pack - - - name: Publish + run: | + mkdir -p "$GITHUB_WORKSPACE/npm-package" + npm pack --pack-destination "$GITHUB_WORKSPACE/npm-package" + + - name: Upload package if: steps.check.outputs.skip != 'true' - working-directory: packages/client + uses: actions/upload-artifact@v4 + with: + name: librechat-client-package + path: npm-package/*.tgz + if-no-files-found: error + retention-days: 2 + + publish-npm: + needs: pack + if: github.ref == 'refs/heads/main' && needs.pack.outputs.skip != 'true' + runs-on: ubuntu-latest + environment: publish # Must match npm trusted publisher config + permissions: + contents: read + id-token: write # Required for OIDC trusted publishing + steps: + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '24.16.0' + registry-url: 'https://registry.npmjs.org' + + - name: Install npm with OIDC support + run: npm install -g npm@11.14.1 --ignore-scripts + + - name: Download package + uses: actions/download-artifact@v4 + with: + name: librechat-client-package + path: npm-package + + - name: Publish + working-directory: npm-package run: npm publish *.tgz --access public --provenance 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/data-provider.yml b/.github/workflows/data-provider.yml index 9a514b00762..eae746ece94 100644 --- a/.github/workflows/data-provider.yml +++ b/.github/workflows/data-provider.yml @@ -14,34 +14,54 @@ on: default: 'Manual publish requested' permissions: - id-token: write # Required for OIDC trusted publishing contents: read jobs: - build: + pack: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: '24.16.0' - run: cd packages/data-provider && npm ci - run: cd packages/data-provider && npm run build + - name: Pack package + run: | + mkdir -p npm-package + cd packages/data-provider + npm pack --pack-destination "$GITHUB_WORKSPACE/npm-package" + - name: Upload package + uses: actions/upload-artifact@v4 + with: + name: librechat-data-provider-package + path: npm-package/*.tgz + if-no-files-found: error + retention-days: 2 publish-npm: - needs: build + needs: pack + if: github.ref == 'refs/heads/main' runs-on: ubuntu-latest environment: publish # Must match npm trusted publisher config + permissions: + contents: read + id-token: write # Required for OIDC trusted publishing steps: - - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: '24.16.0' registry-url: 'https://registry.npmjs.org' - - - name: Update npm for OIDC support - run: npm install -g npm@latest # Must be 11.5.1+ for provenance - - - run: cd packages/data-provider && npm ci - - run: cd packages/data-provider && npm run build - - run: cd packages/data-provider && npm publish --provenance + + - name: Install npm with OIDC support + run: npm install -g npm@11.14.1 --ignore-scripts + + - name: Download package + uses: actions/download-artifact@v4 + with: + name: librechat-data-provider-package + path: npm-package + + - name: Publish package + working-directory: npm-package + run: npm publish *.tgz --provenance diff --git a/.github/workflows/data-schemas.yml b/.github/workflows/data-schemas.yml index 882dc4f4b69..bb8f90ea842 100644 --- a/.github/workflows/data-schemas.yml +++ b/.github/workflows/data-schemas.yml @@ -14,31 +14,27 @@ on: default: 'Manual publish requested' permissions: - id-token: write # Required for OIDC trusted publishing contents: read jobs: - build-and-publish: + pack: runs-on: ubuntu-latest - environment: publish # Must match npm trusted publisher config + outputs: + skip: ${{ steps.check.outputs.skip }} steps: - uses: actions/checkout@v4 - + - name: Use Node.js uses: actions/setup-node@v4 with: - node-version: '20.x' - registry-url: 'https://registry.npmjs.org' - - - name: Update npm for OIDC support - run: npm install -g npm@latest # Must be 11.5.1+ for provenance - + node-version: '24.16.0' + - name: Install dependencies run: cd packages/data-schemas && npm ci - + - name: Build run: cd packages/data-schemas && npm run build - + - name: Check version change id: check working-directory: packages/data-schemas @@ -52,13 +48,47 @@ jobs: echo "Version changed, proceeding with publish" echo "skip=false" >> $GITHUB_OUTPUT fi - + - name: Pack package if: steps.check.outputs.skip != 'true' working-directory: packages/data-schemas - run: npm pack - - - name: Publish + run: | + mkdir -p "$GITHUB_WORKSPACE/npm-package" + npm pack --pack-destination "$GITHUB_WORKSPACE/npm-package" + + - name: Upload package if: steps.check.outputs.skip != 'true' - working-directory: packages/data-schemas + uses: actions/upload-artifact@v4 + with: + name: librechat-data-schemas-package + path: npm-package/*.tgz + if-no-files-found: error + retention-days: 2 + + publish-npm: + needs: pack + if: github.ref == 'refs/heads/main' && needs.pack.outputs.skip != 'true' + runs-on: ubuntu-latest + environment: publish # Must match npm trusted publisher config + permissions: + contents: read + id-token: write # Required for OIDC trusted publishing + steps: + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: '24.16.0' + registry-url: 'https://registry.npmjs.org' + + - name: Install npm with OIDC support + run: npm install -g npm@11.14.1 --ignore-scripts + + - name: Download package + uses: actions/download-artifact@v4 + with: + name: librechat-data-schemas-package + path: npm-package + + - name: Publish + working-directory: npm-package run: npm publish *.tgz --access public --provenance diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index a255932e3e6..57875bc513e 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -7,6 +7,9 @@ on: - completed workflow_dispatch: +permissions: + contents: read + jobs: deploy: runs-on: ubuntu-latest @@ -29,7 +32,7 @@ jobs: DO_HOST: ${{ secrets.DO_HOST }} DO_USER: ${{ secrets.DO_USER }} run: | - ssh -o StrictHostKeyChecking=no ${DO_USER}@${DO_HOST} << EOF + ssh ${DO_USER}@${DO_HOST} << EOF sudo -i -u danny bash << 'EEOF' cd ~/LibreChat && \ git fetch origin main && \ diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 5c143b45318..e4b73da617a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,6 +3,9 @@ name: Deploy_GHRunner_Linux_ACI on: workflow_dispatch: +permissions: + contents: read + env: RUNNER_VERSION: 2.293.0 ACI_RESOURCE_GROUP: 'Demo-ACI-GitHub-Runners-RG' @@ -20,7 +23,7 @@ jobs: uses: actions/checkout@v4 - name: 'Login via Azure CLI' - uses: azure/login@v1 + uses: azure/login@v2 with: creds: ${{ secrets.AZURE_CREDENTIALS }} diff --git a/.github/workflows/dev-branch-images.yml b/.github/workflows/dev-branch-images.yml index 9d40cd3fc41..f0e2ba54b57 100644 --- a/.github/workflows/dev-branch-images.yml +++ b/.github/workflows/dev-branch-images.yml @@ -9,6 +9,14 @@ on: - 'api/**' - 'client/**' - 'packages/**' + - 'package.json' + - 'package-lock.json' + - 'Dockerfile' + - 'Dockerfile.multi' + +permissions: + contents: read + packages: write concurrency: group: ${{ github.workflow }}-${{ github.ref }} @@ -43,7 +51,7 @@ jobs: # Log in to GitHub Container Registry - name: Log in to GitHub Container Registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} @@ -61,6 +69,12 @@ jobs: run: | cp .env.example .env + - name: Compute build metadata + run: | + echo "BUILD_COMMIT=${{ github.sha }}" >> $GITHUB_ENV + echo "BUILD_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV + echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV + # Build and push Docker images for each target - name: Build and push Docker images uses: docker/build-push-action@v5 @@ -75,3 +89,7 @@ jobs: ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest platforms: linux/amd64,linux/arm64 target: ${{ matrix.target }} + build-args: | + BUILD_COMMIT=${{ env.BUILD_COMMIT }} + BUILD_BRANCH=${{ env.BUILD_BRANCH }} + BUILD_DATE=${{ env.BUILD_DATE }} diff --git a/.github/workflows/dev-images.yml b/.github/workflows/dev-images.yml index a6417556aa6..efdd2027546 100644 --- a/.github/workflows/dev-images.yml +++ b/.github/workflows/dev-images.yml @@ -9,6 +9,14 @@ on: - 'api/**' - 'client/**' - 'packages/**' + - 'package.json' + - 'package-lock.json' + - 'Dockerfile' + - 'Dockerfile.multi' + +permissions: + contents: read + packages: write jobs: build: @@ -39,7 +47,7 @@ jobs: # Log in to GitHub Container Registry - name: Log in to GitHub Container Registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} @@ -57,6 +65,12 @@ jobs: run: | cp .env.example .env + - name: Compute build metadata + run: | + echo "BUILD_COMMIT=${{ github.sha }}" >> $GITHUB_ENV + echo "BUILD_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV + echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV + # Build and push Docker images for each target - name: Build and push Docker images uses: docker/build-push-action@v5 @@ -71,3 +85,7 @@ jobs: ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest platforms: linux/amd64,linux/arm64 target: ${{ matrix.target }} + build-args: | + BUILD_COMMIT=${{ env.BUILD_COMMIT }} + BUILD_BRANCH=${{ env.BUILD_BRANCH }} + BUILD_DATE=${{ env.BUILD_DATE }} diff --git a/.github/workflows/dev-staging-images.yml b/.github/workflows/dev-staging-images.yml index e63dc5f0af0..6deb86205ca 100644 --- a/.github/workflows/dev-staging-images.yml +++ b/.github/workflows/dev-staging-images.yml @@ -3,6 +3,10 @@ name: Docker Dev Staging Images Build on: workflow_dispatch: +permissions: + contents: read + packages: write + jobs: build: runs-on: ubuntu-latest @@ -31,7 +35,7 @@ jobs: # Log in to GitHub Container Registry - name: Log in to GitHub Container Registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} @@ -49,6 +53,12 @@ jobs: run: | cp .env.example .env + - name: Compute build metadata + run: | + echo "BUILD_COMMIT=${{ github.sha }}" >> $GITHUB_ENV + echo "BUILD_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV + echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV + # Build and push Docker images for each target - name: Build and push Docker images uses: docker/build-push-action@v5 @@ -63,4 +73,7 @@ jobs: ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest platforms: linux/amd64,linux/arm64 target: ${{ matrix.target }} - + build-args: | + BUILD_COMMIT=${{ env.BUILD_COMMIT }} + BUILD_BRANCH=${{ env.BUILD_BRANCH }} + BUILD_DATE=${{ env.BUILD_DATE }} 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 8203da4e8bf..3ab8528b042 100644 --- a/.github/workflows/eslint-ci.yml +++ b/.github/workflows/eslint-ci.yml @@ -10,6 +10,8 @@ on: paths: - 'api/**' - 'client/**' + - 'packages/**' + - '.github/workflows/eslint-ci.yml' jobs: eslint_checks: @@ -25,31 +27,34 @@ jobs: with: fetch-depth: 0 - - name: Set up Node.js 20.x + - name: Set up Node.js 24.16.0 uses: actions/setup-node@v4 with: - node-version: 20 + node-version: '24.16.0' cache: npm - name: Install dependencies run: npm ci - # Run ESLint on changed files within the api/ and client/ directories. + # Run ESLint on changed files within the api/, client/, and packages/ directories. - name: Run ESLint on changed files run: | # Extract the base commit SHA from the pull_request event payload. BASE_SHA=$(jq --raw-output .pull_request.base.sha "$GITHUB_EVENT_PATH") echo "Base commit SHA: $BASE_SHA" - # Get changed files (only JS/TS files in api/ or client/) - CHANGED_FILES=$(git diff --name-only --diff-filter=ACMRTUXB "$BASE_SHA" HEAD | grep -E '^(api|client)/.*\.(js|jsx|ts|tsx)$' || true) + # Get changed files (only JS/TS files in api/, client/, or packages/) + 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 + ) # Debug output echo "Changed files:" - echo "$CHANGED_FILES" + printf '%s\n' "${CHANGED_FILES[@]}" # Ensure there are files to lint before running ESLint - if [[ -z "$CHANGED_FILES" ]]; then + if [[ ${#CHANGED_FILES[@]} -eq 0 ]]; then echo "No matching files changed. Skipping ESLint." exit 0 fi @@ -57,4 +62,67 @@ jobs: # Run ESLint npx eslint --no-error-on-unmatched-pattern \ --config eslint.config.mjs \ - $CHANGED_FILES + --max-warnings=0 \ + -- "${CHANGED_FILES[@]}" + + # Run Prettier --check on the same set of changed files to catch + # formatting drift in PRs that bypassed the local pre-commit hook + # (e.g. GitHub UI edit-and-merge, `git commit --no-verify`). + - name: Run Prettier --check 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 Prettier." + exit 0 + fi + + echo "Files to check:" + printf '%s\n' "${CHANGED_FILES[@]}" + + # `prettier --check` exits non-zero if any file would be reformatted. + # Suggest the local fix in the failure message so contributors aren't + # left guessing how to resolve. + if ! npx prettier --check --no-error-on-unmatched-pattern -- "${CHANGED_FILES[@]}"; then + echo "" + echo "::error::Prettier formatting drift detected. Fix locally with:" + echo "::error:: npx prettier --write " + 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 05b3f4154f4..a3f31efba63 100644 --- a/.github/workflows/frontend-review.yml +++ b/.github/workflows/frontend-review.yml @@ -4,7 +4,12 @@ on: pull_request: paths: - 'client/**' + - 'packages/client/**' - 'packages/data-provider/**' + - '.github/workflows/frontend-review.yml' + +permissions: + contents: read env: NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}' @@ -17,10 +22,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Use Node.js 20.19 + - name: Use Node.js 24.16.0 uses: actions/setup-node@v4 with: - node-version: '20.19' + node-version: '24.16.0' - name: Restore node_modules cache id: cache-node-modules @@ -31,7 +36,7 @@ jobs: client/node_modules packages/client/node_modules packages/data-provider/node_modules - key: node-modules-frontend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }} + 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' @@ -42,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' @@ -53,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' @@ -73,18 +78,66 @@ 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 - - name: Use Node.js 20.19 + - name: Use Node.js 24.16.0 uses: actions/setup-node@v4 with: - node-version: '20.19' + node-version: '24.16.0' - name: Restore node_modules cache id: cache-node-modules @@ -95,7 +148,7 @@ jobs: client/node_modules packages/client/node_modules packages/data-provider/node_modules - key: node-modules-frontend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }} + 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' @@ -113,22 +166,26 @@ 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 - - name: Use Node.js 20.19 + - name: Use Node.js 24.16.0 uses: actions/setup-node@v4 with: - node-version: '20.19' + node-version: '24.16.0' - name: Restore node_modules cache id: cache-node-modules @@ -139,7 +196,7 @@ jobs: client/node_modules packages/client/node_modules packages/data-provider/node_modules - key: node-modules-frontend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }} + 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' @@ -157,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: @@ -169,10 +226,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Use Node.js 20.19 + - name: Use Node.js 24.16.0 uses: actions/setup-node@v4 with: - node-version: '20.19' + node-version: '24.16.0' - name: Restore node_modules cache id: cache-node-modules @@ -183,7 +240,7 @@ jobs: client/node_modules packages/client/node_modules packages/data-provider/node_modules - key: node-modules-frontend-${{ runner.os }}-20.19-${{ hashFiles('package-lock.json') }} + 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' diff --git a/.github/workflows/generate_embeddings.yml b/.github/workflows/generate_embeddings.yml index c514f9c1d6b..3c6f2717c30 100644 --- a/.github/workflows/generate_embeddings.yml +++ b/.github/workflows/generate_embeddings.yml @@ -7,14 +7,17 @@ on: paths: - 'docs/**' +permissions: + contents: read + jobs: generate: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - uses: supabase/embeddings-generator@v0.0.5 with: supabase-url: ${{ secrets.SUPABASE_URL }} supabase-service-role-key: ${{ secrets.SUPABASE_SERVICE_ROLE_KEY }} openai-key: ${{ secrets.OPENAI_DOC_EMBEDDINGS_KEY }} - docs-root-path: 'docs' \ No newline at end of file + docs-root-path: 'docs' 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 ac7de2973b4..89f906f38dd 100644 --- a/.github/workflows/gitnexus-index.yml +++ b/.github/workflows/gitnexus-index.yml @@ -1,12 +1,13 @@ name: GitNexus Index on: + # PR branches are NOT auto-indexed — an embeddings run is too slow to + # spend on every PR push. Only main/dev are indexed automatically; + # individual PRs are indexed on demand via the /gitnexus command or a + # manual workflow_dispatch. push: branches: [main, dev] paths-ignore: ['**.md', 'docs/**', 'LICENSE', '.github/**'] - pull_request: - branches: [main, dev] - paths-ignore: ['**.md', 'docs/**', 'LICENSE', '.github/**'] workflow_dispatch: inputs: embeddings: @@ -25,9 +26,13 @@ on: type: string default: '' pr_ref: - description: 'PR head SHA or ref to check out (set by /gitnexus command)' + description: 'Optional PR head ref to check out; defaults to refs/pull//head when pr_number is set' type: string default: '' + deploy_after: + description: 'Dispatch GitNexus Deploy after a successful index run' + type: boolean + default: false permissions: contents: read @@ -39,32 +44,60 @@ concurrency: cancel-in-progress: true env: - GITNEXUS_VERSION: '1.5.3' + GITNEXUS_VERSION: '1.6.7' jobs: index: permissions: contents: read pull-requests: read # read changed files to decide whether embeddings are needed - # Push + dispatch run unconditionally. Native pull_request events - # are restricted to PRs authored by danny-avila only — this keeps - # automatic CI spend low on a repo with 200+ open PRs. - # - # Other contributors' PRs can still be indexed on demand: + # Push + dispatch run unconditionally. The pull_request trigger is + # disabled (see `on:` above), so this never runs automatically on a + # PR. PRs are indexed on demand instead: # - /gitnexus index (PR comment command, contributor-gated) # - workflow_dispatch (manual dispatch from Actions UI) - # Both bypass this filter because they arrive as workflow_dispatch, - # not pull_request. + # Both arrive as workflow_dispatch. The pull_request guard is kept as + # a safety net should the trigger ever be re-added. if: | 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' + env: + PR_NUMBER: ${{ inputs.pr_number }} + PR_REF: ${{ inputs.pr_ref }} + run: | + set -euo pipefail + if [ -n "$PR_NUMBER" ]; then + if [[ ! "$PR_NUMBER" =~ ^[0-9]+$ ]]; then + echo "::error::pr_number must be numeric" + exit 1 + fi + EXPECTED_REF="refs/pull/${PR_NUMBER}/head" + if [ -n "$PR_REF" ] && [ "$PR_REF" != "$EXPECTED_REF" ]; then + echo "::error::pr_ref must match ${EXPECTED_REF}" + exit 1 + fi + elif [ -n "$PR_REF" ]; then + echo "::error::pr_ref requires pr_number" + exit 1 + fi + - name: Resolve GitNexus flags id: flags env: + EVENT_NAME: ${{ github.event_name }} + ENABLE_EMBEDDINGS_INPUT: ${{ inputs.embeddings }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PR_NUM: ${{ github.event.pull_request.number }} run: | set -euo pipefail @@ -79,15 +112,14 @@ jobs: # (default false). This also covers the # /gitnexus index [embeddings] command. ENABLE_EMBEDDINGS=false - case "${{ github.event_name }}" in + case "$EVENT_NAME" in workflow_dispatch) - [ "${{ inputs.embeddings }}" = "true" ] && ENABLE_EMBEDDINGS=true + [ "$ENABLE_EMBEDDINGS_INPUT" = "true" ] && ENABLE_EMBEDDINGS=true ;; push) ENABLE_EMBEDDINGS=true ;; pull_request) - PR_NUM="${{ github.event.pull_request.number }}" CHANGED=$(gh api "repos/${{ github.repository }}/pulls/$PR_NUM/files" \ --paginate --jq '.[].filename' 2>/dev/null || echo "") if printf '%s\n' "$CHANGED" | grep -qE '^(api/|client/|packages/)'; then @@ -108,7 +140,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 24 + node-version: '24.16.0' - name: Install GitNexus CLI working-directory: ${{ runner.temp }} @@ -134,7 +166,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 @@ -145,14 +177,33 @@ jobs: # repo for every PR, so checkout works for fork PRs too. When # pr_ref is empty (native push/pull_request), fall back to the # default ref actions/checkout would use. - ref: ${{ inputs.pr_ref || '' }} + ref: ${{ inputs.pr_ref || (inputs.pr_number != '' && format('refs/pull/{0}/head', inputs.pr_number) || '') }} 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 @@ -163,10 +214,10 @@ jobs: set -euo pipefail FLAGS=(--skip-agents-md --verbose) - if [ "${{ steps.flags.outputs.enable_embeddings }}" = "true" ]; then + if [ "$ENABLE_EMBEDDINGS" = "true" ]; then FLAGS+=(--embeddings) fi - if [ "${{ inputs.force }}" = "true" ]; then + if [ "$FORCE" = "true" ]; then FLAGS+=(--force) fi "$GITNEXUS_BIN" analyze "$GITHUB_WORKSPACE" "${FLAGS[@]}" @@ -206,63 +257,62 @@ jobs: if: | always() && (inputs.pr_number != '' || - (github.triggering_actor == 'github-actions[bot]' && needs.index.result == 'success')) + inputs.deploy_after) runs-on: ubuntu-latest timeout-minutes: 5 permissions: contents: read - actions: write # dispatch gitnexus-deploy.yml on bot-triggered runs + actions: write # dispatch gitnexus-deploy.yml when deploy_after is set pull-requests: write # post completion comments for /gitnexus command runs steps: - # GitHub suppresses workflow_run events for workflow runs whose - # triggering actor is GITHUB_TOKEN (to prevent recursive chaining). - # That means when this workflow is dispatched by gitnexus-pr-command - # via `gh api workflow_dispatch`, the deploy workflow's workflow_run - # trigger never fires. Manually dispatch the deploy here in that - # specific case — user-triggered runs continue to rely on the - # existing workflow_run trigger, so we don't double-deploy. - - name: Trigger deploy workflow for bot-triggered runs - if: github.triggering_actor == 'github-actions[bot]' && needs.index.result == 'success' + # GitHub suppresses workflow_run events for workflow runs triggered + # 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 with: script: | - core.info('Triggering actor is github-actions[bot]; workflow_run would not fire. 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). + core.info('deploy_after=true; dispatching gitnexus-deploy.yml manually.'); await github.rest.actions.createWorkflowDispatch({ owner: context.repo.owner, repo: context.repo.repo, workflow_id: 'gitnexus-deploy.yml', ref: 'main', inputs: { - pr_number: '${{ inputs.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 + env: + EMBEDDINGS_INPUT: ${{ inputs.embeddings }} + INDEX_RESULT: ${{ needs.index.result }} + PR_NUMBER: ${{ inputs.pr_number }} with: script: | - const outcome = '${{ needs.index.result }}' === 'success' ? '✅ indexed' : '❌ index failed'; - const prNum = parseInt('${{ inputs.pr_number }}', 10); + const indexSucceeded = process.env.INDEX_RESULT === 'success'; + const outcome = indexSucceeded ? '✅ indexed' : '❌ index failed'; + const prNum = parseInt(process.env.PR_NUMBER || '', 10); + if (!Number.isSafeInteger(prNum)) { + core.setFailed(`Invalid PR number: ${process.env.PR_NUMBER}`); + return; + } const runUrl = `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`; - const embeddingsFlag = '${{ inputs.embeddings }}' === 'true' ? 'with embeddings' : 'graph-only'; + const embeddingsFlag = process.env.EMBEDDINGS_INPUT === 'true' ? 'with embeddings' : 'graph-only'; const body = [ `### GitNexus: ${outcome}`, ``, `PR #${prNum} was indexed ${embeddingsFlag}.`, `[Index run](${runUrl})`, '', - '${{ needs.index.result }}' === 'success' - ? '⏳ Waiting for deploy to serve the fresh index…' + indexSucceeded + ? '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/gitnexus-pr-command.yml b/.github/workflows/gitnexus-pr-command.yml index b299beb3b1b..214a526897f 100644 --- a/.github/workflows/gitnexus-pr-command.yml +++ b/.github/workflows/gitnexus-pr-command.yml @@ -94,18 +94,38 @@ jobs: - name: Dispatch gitnexus-index workflow uses: actions/github-script@v7 + env: + EMBEDDINGS: ${{ steps.parse.outputs.embeddings }} + PR_NUMBER: ${{ steps.parse.outputs.pr_number }} + PR_REF: ${{ steps.parse.outputs.pr_ref }} with: script: | + const prNumber = process.env.PR_NUMBER || ''; + const prRef = process.env.PR_REF || ''; + const embeddings = process.env.EMBEDDINGS || 'false'; + if (!/^[0-9]+$/.test(prNumber)) { + core.setFailed(`Invalid PR number: ${prNumber}`); + return; + } + if (prRef !== `refs/pull/${prNumber}/head`) { + core.setFailed(`Invalid PR ref: ${prRef}`); + return; + } + if (!['true', 'false'].includes(embeddings)) { + core.setFailed(`Invalid embeddings value: ${embeddings}`); + return; + } await github.rest.actions.createWorkflowDispatch({ owner: context.repo.owner, repo: context.repo.repo, workflow_id: 'gitnexus-index.yml', ref: 'main', inputs: { - pr_number: '${{ steps.parse.outputs.pr_number }}', - pr_ref: '${{ steps.parse.outputs.pr_ref }}', - embeddings: '${{ steps.parse.outputs.embeddings }}', + pr_number: prNumber, + pr_ref: prRef, + embeddings, force: 'false', + deploy_after: 'true', }, }); diff --git a/.github/workflows/helmcharts.yml b/.github/workflows/helmcharts.yml index 2b9f7f45de6..9e0308ec727 100644 --- a/.github/workflows/helmcharts.yml +++ b/.github/workflows/helmcharts.yml @@ -5,18 +5,54 @@ on: push: tags: - "chart-*" + workflow_dispatch: + inputs: + chart_tag: + description: "Existing chart tag to release, for example chart-2.0.5" + required: true + type: string jobs: release: permissions: - contents: write + contents: read packages: write runs-on: ubuntu-latest + env: + CHART_REPOSITORY: ${{ github.repository_owner }}/librechat-chart steps: + - name: Resolve chart tag + id: chart-version + env: + EVENT_NAME: ${{ github.event_name }} + INPUT_CHART_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.chart_tag || '' }} + REF_NAME: ${{ github.ref_name }} + run: | + set -euo pipefail + CHART_TAG="$REF_NAME" + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + CHART_TAG="$INPUT_CHART_TAG" + fi + + CHART_VERSION="${CHART_TAG#chart-}" + SEMVER_REGEX='^[0-9]+[.][0-9]+[.][0-9]+(-[0-9A-Za-z.-]+)?([+][0-9A-Za-z.-]+)?$' + if [[ "$CHART_TAG" != chart-* || ! "$CHART_VERSION" =~ $SEMVER_REGEX ]]; then + echo "::error::Chart tags must use the form chart-, for example chart-2.0.3" + exit 1 + fi + + { + printf 'CHART_REF=refs/tags/%s\n' "$CHART_TAG" + printf 'CHART_TAG=%s\n' "$CHART_TAG" + printf 'CHART_VERSION=%s\n' "$CHART_VERSION" + } >> "$GITHUB_OUTPUT" + - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 + persist-credentials: false + ref: ${{ steps.chart-version.outputs.CHART_REF }} - name: Configure Git run: | @@ -35,12 +71,6 @@ jobs: cd ../librechat-rag-api helm dependency build - - name: Get Chart Version - id: chart-version - run: | - CHART_VERSION=$(echo "${{ github.ref_name }}" | cut -d'-' -f2) - echo "CHART_VERSION=${CHART_VERSION}" >> "$GITHUB_OUTPUT" - # Log in to GitHub Container Registry - name: Log in to GitHub Container Registry uses: docker/login-action@v3 @@ -55,7 +85,7 @@ jobs: uses: appany/helm-oci-chart-releaser@v0.4.2 with: name: librechat - repository: ${{ github.actor }}/librechat-chart + repository: ${{ env.CHART_REPOSITORY }} tag: ${{ steps.chart-version.outputs.CHART_VERSION }} path: helm/librechat registry: ghcr.io @@ -67,9 +97,9 @@ jobs: uses: appany/helm-oci-chart-releaser@v0.4.2 with: name: librechat-rag-api - repository: ${{ github.actor }}/librechat-chart + repository: ${{ env.CHART_REPOSITORY }} tag: ${{ steps.chart-version.outputs.CHART_VERSION }} path: helm/librechat-rag-api registry: ghcr.io registry_username: ${{ github.actor }} - registry_password: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file + registry_password: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/i18n-unused-keys.yml b/.github/workflows/i18n-unused-keys.yml index 8f773532d33..6341c19d142 100644 --- a/.github/workflows/i18n-unused-keys.yml +++ b/.github/workflows/i18n-unused-keys.yml @@ -18,10 +18,11 @@ jobs: detect-unused-i18n-keys: runs-on: ubuntu-latest permissions: + contents: read pull-requests: write steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 - name: Find unused i18next keys id: find-unused @@ -140,7 +141,7 @@ jobs: gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \ -f body="$COMMENT_BODY" \ - -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" + -H "Authorization: token $GITHUB_TOKEN" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/locize-i18n-sync.yml b/.github/workflows/locize-i18n-sync.yml index f34648dfd9d..c0b9af5a5f7 100644 --- a/.github/workflows/locize-i18n-sync.yml +++ b/.github/workflows/locize-i18n-sync.yml @@ -6,6 +6,9 @@ on: repository_dispatch: types: [locize/versionPublished] +permissions: + contents: read + jobs: sync-translations: name: Sync Translation Keys with Locize @@ -13,21 +16,26 @@ jobs: steps: - name: Checkout Repository uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set Up Node.js uses: actions/setup-node@v4 with: - node-version: 20 + node-version: '24.16.0' - name: Install locize CLI - run: npm install -g locize-cli + run: npm install -g locize-cli@12.2.0 --ignore-scripts --no-audit --no-fund # Sync translations (Push missing keys & remove deleted ones) - name: Sync Locize with Repository if: ${{ github.event_name == 'push' }} + env: + LOCIZE_API_KEY: ${{ secrets.LOCIZE_API_KEY }} + LOCIZE_PROJECT_ID: ${{ secrets.LOCIZE_PROJECT_ID }} run: | cd client/src/locales - locize sync --api-key ${{ secrets.LOCIZE_API_KEY }} --project-id ${{ secrets.LOCIZE_PROJECT_ID }} --language en + locize sync --cdn-type pro --api-key "$LOCIZE_API_KEY" --project-id "$LOCIZE_PROJECT_ID" --language en # When triggered by repository_dispatch, skip sync step. - name: Skip sync step on non-push events @@ -39,12 +47,13 @@ jobs: runs-on: ubuntu-latest needs: sync-translations permissions: - contents: write - pull-requests: write + contents: read steps: # 1. Check out the repository. - name: Checkout Repository uses: actions/checkout@v4 + with: + persist-credentials: false # 2. Download translation files from locize. - name: Download Translations from locize @@ -53,20 +62,38 @@ jobs: project-id: ${{ secrets.LOCIZE_PROJECT_ID }} path: "client/src/locales" - # 3. Create a Pull Request using built-in functionality. + # 3. Create a Pull Request using a dedicated fine-grained PAT so this + # workflow does not depend on the global GITHUB_TOKEN PR-creation setting. - name: Create Pull Request + id: create-pull-request uses: peter-evans/create-pull-request@v7 with: - token: ${{ secrets.GITHUB_TOKEN }} - sign-commits: true + token: ${{ secrets.LOCIZE_PR_TOKEN }} + add-paths: | + client/src/locales/** commit-message: "🌍 i18n: Update translation.json with latest translations" base: main branch: i18n/locize-translation-update - reviewers: danny-avila title: "🌍 i18n: Update translation.json with latest translations" body: | **Description**: - 🎯 **Objective**: Update `translation.json` with the latest translations from locize. - 🔍 **Details**: This PR is automatically generated upon receiving a versionPublished event with version "latest". It reflects the newest translations provided by locize. - ✅ **Status**: Ready for review. - labels: "🌍 i18n" \ No newline at end of file + labels: "🌍 i18n" + + - name: Request Reviewer + if: ${{ steps.create-pull-request.outputs.pull-request-number != '' }} + env: + GH_TOKEN: ${{ secrets.LOCIZE_PR_TOKEN }} + PR_NUMBER: ${{ steps.create-pull-request.outputs.pull-request-number }} + REVIEWER: danny-avila + run: | + author="$(gh pr view "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --json author --jq '.author.login')" + + if [ "$author" = "$REVIEWER" ]; then + echo "Skipping reviewer request because $REVIEWER authored PR #$PR_NUMBER." + exit 0 + fi + + gh pr edit "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --add-reviewer "$REVIEWER" diff --git a/.github/workflows/main-image-workflow.yml b/.github/workflows/main-image-workflow.yml index 43c9d957534..e5f76fe26ef 100644 --- a/.github/workflows/main-image-workflow.yml +++ b/.github/workflows/main-image-workflow.yml @@ -3,6 +3,10 @@ name: Docker Compose Build Latest Main Image Tag (Manual Dispatch) on: workflow_dispatch: +permissions: + contents: read + packages: write + jobs: build: runs-on: ubuntu-latest @@ -19,11 +23,26 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + ref: main + fetch-depth: 0 - name: Fetch tags and set the latest tag run: | - git fetch --tags - echo "LATEST_TAG=$(git describe --tags `git rev-list --tags --max-count=1`)" >> $GITHUB_ENV + set -euo pipefail + git fetch --tags --force + LATEST_TAG=$(git tag --list 'v[0-9]*' --sort=-v:refname | grep -E '^v[0-9]+[.][0-9]+[.][0-9]+$' | head -n 1) + if [ -z "$LATEST_TAG" ]; then + echo "::error::No stable v tag found" + exit 1 + fi + printf 'LATEST_TAG=%s\n' "$LATEST_TAG" >> "$GITHUB_ENV" + + - name: Compute build metadata + run: | + printf 'BUILD_COMMIT=%s\n' "$(git rev-parse HEAD)" >> "$GITHUB_ENV" + printf 'BUILD_BRANCH=main\n' >> "$GITHUB_ENV" + printf 'BUILD_DATE=%s\n' "$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_ENV" # Set up QEMU - name: Set up QEMU @@ -35,7 +54,7 @@ jobs: # Log in to GitHub Container Registry - name: Log in to GitHub Container Registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} @@ -67,3 +86,7 @@ jobs: ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest platforms: linux/amd64,linux/arm64 target: ${{ matrix.target }} + build-args: | + BUILD_COMMIT=${{ env.BUILD_COMMIT }} + BUILD_BRANCH=${{ env.BUILD_BRANCH }} + BUILD_DATE=${{ env.BUILD_DATE }} diff --git a/.github/workflows/playwright-mock.yml b/.github/workflows/playwright-mock.yml new file mode 100644 index 00000000000..fc94dc02d64 --- /dev/null +++ b/.github/workflows/playwright-mock.yml @@ -0,0 +1,137 @@ +name: Playwright E2E Tests + +on: + pull_request: + workflow_dispatch: + inputs: + reason: + description: 'Reason for manual trigger' + required: false + default: 'Manual e2e run' + +permissions: + contents: read + +concurrency: + group: playwright-mock-${{ github.ref }} + cancel-in-progress: true + +env: + NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}' + PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1' + +jobs: + e2e: + runs-on: ubuntu-latest + timeout-minutes: 30 + env: + E2E_CHROMIUM_CHANNEL: chrome + 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 + packages/data-schemas/node_modules + packages/api/node_modules + api/node_modules + key: node-modules-e2e-${{ 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: 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('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' + 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('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' + 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('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' + run: npm run build:api + + - name: Restore client-package build cache + id: cache-client-package + 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/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' + run: npm run build:client-package + + - name: Restore client app build cache + id: cache-client-app + 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/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' + run: npm run build:client + + - name: Install Playwright runtime dependencies + timeout-minutes: 5 + run: | + google-chrome --version + npx playwright install-deps chrome + + - name: Run mock-LLM Tier-1 e2e + run: npx playwright test --config=e2e/playwright.config.mock.ts + env: + CI: 'true' + + - name: Upload Playwright HTML report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: e2e/playwright-report/** + retention-days: 7 + if-no-files-found: ignore + + - name: Upload traces & screenshots + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-test-results + path: e2e/specs/.test-results/** + retention-days: 7 + if-no-files-found: ignore diff --git a/.github/workflows/retry-docker-builds.yml b/.github/workflows/retry-docker-builds.yml new file mode 100644 index 00000000000..f4543977195 --- /dev/null +++ b/.github/workflows/retry-docker-builds.yml @@ -0,0 +1,136 @@ +name: Retry Failed Docker Builds + +on: + workflow_run: + workflows: + - Docker Build Smoke Tests + - Docker Compose Build Latest Main Image Tag (Manual Dispatch) + - Docker Dev Branch Images Build + - Docker Dev Images Build + - Docker Dev Staging Images Build + - Docker Images Build on Tag + types: + - completed + +permissions: + actions: write + contents: read + +jobs: + retry-failed-jobs: + name: Re-run failed jobs + if: > + (github.event.workflow_run.conclusion == 'failure' || + github.event.workflow_run.conclusion == 'timed_out') && + github.event.workflow_run.run_attempt < 3 + runs-on: ubuntu-latest + steps: + - name: Check failed run is still current + id: current-run + env: + GH_TOKEN: ${{ github.token }} + HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + RUN_EVENT: ${{ github.event.workflow_run.event }} + RUN_ID: ${{ github.event.workflow_run.id }} + WORKFLOW_ID: ${{ github.event.workflow_run.workflow_id }} + WORKFLOW_NAME: ${{ github.event.workflow_run.name }} + run: | + set -euo pipefail + + encode_uri() { + jq -nr --arg value "$1" '$value | @uri' + } + + workflow_runs_path="repos/${GITHUB_REPOSITORY}/actions/workflows/${WORKFLOW_ID}/runs" + query="per_page=1&exclude_pull_requests=false" + + if [ "$WORKFLOW_NAME" = "Docker Images Build on Tag" ]; then + if [ -z "$HEAD_BRANCH" ]; then + echo "No tag name found for ${WORKFLOW_NAME}; skipping retry." + echo "should_retry=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if ! tag_ref=$(gh api "repos/${GITHUB_REPOSITORY}/git/ref/tags/$(encode_uri "$HEAD_BRANCH")" --jq '.object'); then + echo "Tag ${HEAD_BRANCH} no longer exists; skipping retry." + echo "should_retry=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + tag_object_type=$(jq -r '.type' <<< "$tag_ref") + tag_object_sha=$(jq -r '.sha' <<< "$tag_ref") + + if [ "$tag_object_type" = "tag" ]; then + tag_head_sha=$(gh api "repos/${GITHUB_REPOSITORY}/git/tags/${tag_object_sha}" --jq '.object.sha') + else + tag_head_sha="$tag_object_sha" + fi + + if [ "$tag_head_sha" = "$HEAD_SHA" ]; then + echo "should_retry=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "Skipping retry for stale ${WORKFLOW_NAME} run ${RUN_ID}; tag ${HEAD_BRANCH} now points to ${tag_head_sha}." + echo "should_retry=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [ -n "$RUN_EVENT" ]; then + query="${query}&event=$(encode_uri "$RUN_EVENT")" + fi + + if [ -n "$HEAD_BRANCH" ]; then + query_with_ref="${query}&branch=$(encode_uri "$HEAD_BRANCH")" + latest_run=$(gh api "${workflow_runs_path}?${query_with_ref}" --jq '.workflow_runs[0] // empty') + else + latest_run="" + fi + + if [ -z "$latest_run" ]; then + latest_run=$(gh api "${workflow_runs_path}?${query}" --jq '.workflow_runs[0] // empty') + fi + + if [ -z "$latest_run" ]; then + echo "No matching workflow runs found for ${WORKFLOW_NAME}; skipping retry." + echo "should_retry=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + latest_run_id=$(jq -r '.id' <<< "$latest_run") + latest_head_sha=$(jq -r '.head_sha' <<< "$latest_run") + + if [ "$latest_run_id" = "$RUN_ID" ] || [ "$latest_head_sha" = "$HEAD_SHA" ]; then + echo "should_retry=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "Skipping retry for stale ${WORKFLOW_NAME} run ${RUN_ID}; newer run ${latest_run_id} is at ${latest_head_sha}." + echo "should_retry=false" >> "$GITHUB_OUTPUT" + + - name: Re-run failed Docker jobs + if: steps.current-run.outputs.should_retry == 'true' + env: + GH_TOKEN: ${{ github.token }} + RUN_ATTEMPT: ${{ github.event.workflow_run.run_attempt }} + RUN_ID: ${{ github.event.workflow_run.id }} + WORKFLOW_NAME: ${{ github.event.workflow_run.name }} + run: | + set -euo pipefail + + cancelled_jobs=$(gh api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/jobs?per_page=100" \ + | jq -s '[.[].jobs[]? | select(.conclusion == "cancelled")] | length') + + if [ "$cancelled_jobs" -gt 0 ]; then + endpoint="rerun" + echo "Found ${cancelled_jobs} cancelled job(s); re-running the full workflow run." + else + endpoint="rerun-failed-jobs" + echo "Re-running failed jobs only." + fi + + echo "Retrying ${WORKFLOW_NAME} (run ${RUN_ID}, attempt ${RUN_ATTEMPT})." + gh api \ + --method POST \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${RUN_ID}/${endpoint}" diff --git a/.github/workflows/sync-helm-chart-tags.yml b/.github/workflows/sync-helm-chart-tags.yml new file mode 100644 index 00000000000..bde4c2f49a1 --- /dev/null +++ b/.github/workflows/sync-helm-chart-tags.yml @@ -0,0 +1,85 @@ +name: Sync Helm Chart Tags + +on: + push: + branches: + - main + workflow_dispatch: + inputs: + release_existing_tag: + description: "Existing chart-* tag to dispatch if tag creation succeeded but release dispatch failed" + required: false + type: string + +permissions: + contents: read + +concurrency: + group: sync-helm-chart-tags + cancel-in-progress: false + +jobs: + noop: + name: Ignore non-main push + if: github.event_name == 'push' && github.ref != 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 1 + steps: + - name: Skip tag sync + run: echo "Helm chart tag sync only runs on main pushes or manual dispatch." + + sync: + name: Sync chart tags + if: github.event_name == 'workflow_dispatch' || github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + actions: write + contents: write + env: + BASE_REF: refs/remotes/origin/main + BACKFILL_FROM_VERSION: 1.9.0 + CHART_PATH: helm/librechat/Chart.yaml + DEFAULT_BRANCH: main + DISPATCH_WORKFLOW: helmcharts.yml + GITHUB_SERVER_URL: ${{ github.server_url }} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + RELEASE_EXISTING_TAG: ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.release_existing_tag || '' }} + REPO_DIR: /tmp/librechat-sync + TAG_PREFIX: chart- + steps: + - name: Fetch main and tags + shell: bash + run: | + set -euo pipefail + + if [[ ! "$GITHUB_REPOSITORY" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then + echo "::error::Unexpected repository name: $GITHUB_REPOSITORY" + exit 1 + fi + + if [[ "$GITHUB_SERVER_URL" != "https://github.com" ]]; then + echo "::error::Unexpected GitHub server URL: $GITHUB_SERVER_URL" + exit 1 + fi + + rm -rf "$REPO_DIR" + git init "$REPO_DIR" + cd "$REPO_DIR" + git remote add origin "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" + AUTH_HEADER="$(printf 'x-access-token:%s' "$GITHUB_TOKEN" | base64 | tr -d '\n')" + git -c "http.extraheader=AUTHORIZATION: basic ${AUTH_HEADER}" \ + fetch --prune --force --tags origin \ + "+refs/heads/${DEFAULT_BRANCH}:refs/remotes/origin/${DEFAULT_BRANCH}" + git checkout --detach "$BASE_REF" + + - name: Create missing chart tags + shell: bash + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PUSH_TAGS: "true" + run: | + set -euo pipefail + cd "$REPO_DIR" + .github/scripts/sync-helm-chart-tags.sh diff --git a/.github/workflows/tag-images.yml b/.github/workflows/tag-images.yml index e90f43978ab..3b4dfc0cd0b 100644 --- a/.github/workflows/tag-images.yml +++ b/.github/workflows/tag-images.yml @@ -3,7 +3,11 @@ name: Docker Images Build on Tag on: push: tags: - - '*' + - 'v*' + +permissions: + contents: read + packages: write jobs: build: @@ -23,6 +27,25 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Validate release tag + id: release-tag + env: + REF_NAME: ${{ github.ref_name }} + run: | + set -euo pipefail + TAG_REGEX='^v[0-9]+[.][0-9]+[.][0-9]+(-rc[0-9]+)?$' + STABLE_TAG_REGEX='^v[0-9]+[.][0-9]+[.][0-9]+$' + if [[ ! "$REF_NAME" =~ $TAG_REGEX ]]; then + echo "::error::Docker release tags must use v or v-rcN, for example v0.8.5 or v0.8.5-rc1" + exit 1 + fi + printf 'image_tag=%s\n' "$REF_NAME" >> "$GITHUB_OUTPUT" + if [[ "$REF_NAME" =~ $STABLE_TAG_REGEX ]]; then + echo "is_stable=true" >> "$GITHUB_OUTPUT" + else + echo "is_stable=false" >> "$GITHUB_OUTPUT" + fi + # Set up QEMU - name: Set up QEMU uses: docker/setup-qemu-action@v3 @@ -33,7 +56,7 @@ jobs: # Log in to GitHub Container Registry - name: Log in to GitHub Container Registry - uses: docker/login-action@v2 + uses: docker/login-action@v3 with: registry: ghcr.io username: ${{ github.actor }} @@ -51,6 +74,34 @@ jobs: run: | cp .env.example .env + - name: Compute build metadata + run: | + echo "BUILD_COMMIT=${{ github.sha }}" >> $GITHUB_ENV + echo "BUILD_BRANCH=${{ github.ref_name }}" >> $GITHUB_ENV + echo "BUILD_DATE=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_ENV + + - name: Resolve image tags + id: image-tags + env: + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + IMAGE_NAME: ${{ matrix.image_name }} + IMAGE_TAG: ${{ steps.release-tag.outputs.image_tag }} + IS_STABLE: ${{ steps.release-tag.outputs.is_stable }} + run: | + set -euo pipefail + git fetch --tags --force + LATEST_STABLE_TAG=$(git tag --list 'v[0-9]*' --sort=-v:refname | grep -E '^v[0-9]+[.][0-9]+[.][0-9]+$' | head -n 1 || true) + { + echo 'tags<> "$GITHUB_OUTPUT" + # Build and push Docker images for each target - name: Build and push Docker images uses: docker/build-push-action@v5 @@ -58,10 +109,10 @@ jobs: context: . file: ${{ matrix.file }} push: true - tags: | - ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:${{ github.ref_name }} - ghcr.io/${{ github.repository_owner }}/${{ matrix.image_name }}:latest - ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:${{ github.ref_name }} - ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest + tags: ${{ steps.image-tags.outputs.tags }} platforms: linux/amd64,linux/arm64 target: ${{ matrix.target }} + build-args: | + BUILD_COMMIT=${{ env.BUILD_COMMIT }} + BUILD_BRANCH=${{ env.BUILD_BRANCH }} + BUILD_DATE=${{ env.BUILD_DATE }} diff --git a/.github/workflows/unused-packages.yml b/.github/workflows/unused-packages.yml index f67c1d23be9..5401d37d5b5 100644 --- a/.github/workflows/unused-packages.yml +++ b/.github/workflows/unused-packages.yml @@ -14,15 +14,16 @@ jobs: detect-unused-packages: runs-on: ubuntu-latest permissions: + contents: read pull-requests: write steps: - uses: actions/checkout@v4 - - name: Use Node.js 20.x + - name: Use Node.js 24.16.0 uses: actions/setup-node@v4 with: - node-version: 20 + node-version: '24.16.0' cache: 'npm' - name: Install depcheck @@ -272,7 +273,7 @@ jobs: gh api "repos/${{ github.repository }}/issues/${PR_NUMBER}/comments" \ -f body="$COMMENT_BODY" \ - -H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" + -H "Authorization: token $GITHUB_TOKEN" env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index d775e70a263..15aa483faab 100644 --- a/.gitignore +++ b/.gitignore @@ -88,6 +88,7 @@ archive .vscode/settings.json src/style - official.css /e2e/specs/.test-results/ +/e2e/.generated/ /e2e/playwright-report/ /playwright/.cache/ .DS_Store @@ -178,3 +179,4 @@ claude-flow hive-mind-prompt-*.txt CLAUDE.md .gsd +codedb.snapshot 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/.nvmrc b/.nvmrc new file mode 100644 index 00000000000..b832e4001db --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.16.0 diff --git a/CLAUDE.md b/CLAUDE.md index 81362cfc570..8172a614056 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -143,7 +143,7 @@ Multi-line imports count total character length across all lines. Consolidate va | `npm run frontend:dev` | Start frontend dev server with HMR (port 3090, requires backend running) | | `npm run build:data-provider` | Rebuild `packages/data-provider` after changes | -- Node.js: v20.19.0+ or ^22.12.0 or >= 23.0.0 +- Node.js: v24.16.0 - Database: MongoDB - Backend runs on `http://localhost:3080/`; frontend dev server on `http://localhost:3090/` diff --git a/Dockerfile b/Dockerfile index 809f167413c..9e5d41b5d6c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ -# v0.8.6-rc1 +# v0.8.7 # Base node image -FROM node:20-alpine AS node +FROM node:24.16.0-alpine AS node RUN apk upgrade --no-cache RUN apk add --no-cache jemalloc @@ -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 ; \ @@ -59,6 +59,18 @@ RUN \ npm prune --production; \ npm cache clean --force +# Optional build metadata surfaced in Settings -> About for support triage. +# Declared here (after the heavy install/build steps) so that commit/date +# changing on every CI run does not bust the cache for dependency install +# and frontend build layers. When unset, the backend falls back to local +# git resolution (if .git is present), and finally to empty values. +ARG BUILD_COMMIT= +ARG BUILD_BRANCH= +ARG BUILD_DATE= +ENV BUILD_COMMIT=${BUILD_COMMIT} +ENV BUILD_BRANCH=${BUILD_BRANCH} +ENV BUILD_DATE=${BUILD_DATE} + # Node API setup EXPOSE 3080 ENV HOST=0.0.0.0 diff --git a/Dockerfile.multi b/Dockerfile.multi index f392a51e405..ce429c02bb1 100644 --- a/Dockerfile.multi +++ b/Dockerfile.multi @@ -1,11 +1,16 @@ # Dockerfile.multi -# v0.8.6-rc1 +# v0.8.7 # Set configurable max-old-space-size with default ARG NODE_MAX_OLD_SPACE_SIZE=6144 +# Optional build metadata surfaced in Settings -> About for support triage. +ARG BUILD_COMMIT= +ARG BUILD_BRANCH= +ARG BUILD_DATE= + # Base for all builds -FROM node:20-alpine AS base-min +FROM node:24.16.0-alpine AS base-min ARG NPM_CI_TIMEOUT_SECONDS=1500 ARG NPM_CI_ATTEMPTS=2 RUN apk upgrade --no-cache @@ -104,10 +109,20 @@ 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 COPY --from=client-build /app/client/dist ./client/dist +# Propagate build metadata into runtime env so /api/config can expose it. +# Declared here (after the heavy install/copy steps) so that commit/date +# changing on every CI run does not bust the cache for those layers. +ARG BUILD_COMMIT +ARG BUILD_BRANCH +ARG BUILD_DATE +ENV BUILD_COMMIT=${BUILD_COMMIT} +ENV BUILD_BRANCH=${BUILD_BRANCH} +ENV BUILD_DATE=${BUILD_DATE} WORKDIR /app/api EXPOSE 3080 ENV HOST=0.0.0.0 diff --git a/README.md b/README.md index a7f68d9a920..54bf286e853 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ - Secure, Sandboxed Execution in Python, Node.js (JS/TS), Go, C/C++, Java, PHP, Rust, and Fortran - Seamless File Handling: Upload, process, and download files directly - No Privacy Concerns: Fully isolated and secure execution + - Open-Source & Self-Hostable: powered by [ClickHouse/code-interpreter](https://github.com/ClickHouse/code-interpreter) - 🔦 **Agents & Tools Integration**: - **[LibreChat Agents](https://www.librechat.ai/docs/features/agents)**: @@ -74,6 +75,8 @@ - Agent Marketplace: Discover and deploy community-built agents - Collaborative Sharing: Share agents with specific users and groups - Flexible & Extensible: Use MCP Servers, tools, file search, code execution, and more + - [Skills](https://www.librechat.ai/docs/features/skills): Create reusable `SKILL.md` instruction bundles for manual, automatic, or always-on agent workflows + - [Subagents](https://www.librechat.ai/docs/features/subagents): Delegate focused work to isolated child agent runs with their own context windows - Compatible with Custom Endpoints, OpenAI, Azure, Anthropic, AWS Bedrock, Google, Vertex AI, Responses API, and more - [Model Context Protocol (MCP) Support](https://modelcontextprotocol.io/clients#librechat) for Tools @@ -135,8 +138,14 @@ - Multi-User, Secure Authentication with OAuth2, LDAP, & Email Login Support - Built-in Moderation, and Token spend tools +- 🎛️ **[Admin Panel](https://www.librechat.ai/docs/features/admin_panel)**: + - Browser-based UI to manage users, groups, roles, and configuration overrides + - Edit settings and per-role/group permissions live, without redeploying + - Bundled with the Docker Compose stacks for one-command setup + - ⚙️ **Configuration & Deployment**: - Configure Proxy, Reverse Proxy, Docker, & many Deployment options + - Use [S3 with CloudFront](https://www.librechat.ai/docs/configuration/cdn/cloudfront) for stable media links, edge delivery, signed cookies, and secured downloads - Use completely local or deploy on the cloud - 📖 **Open-Source & Community**: 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 e641017c74f..b7c5a8ae59d 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -6,7 +6,9 @@ const { checkBalance, getBalanceConfig, buildMessageFiles, + sanitizeFileForTransmit, extractFileContext, + getReferencedQuotes, encodeAndFormatAudios, encodeAndFormatVideos, encodeAndFormatDocuments, @@ -14,6 +16,7 @@ const { const { Constants, FileSources, + Tools, ContentTypes, excludedKeys, EModelEndpoint, @@ -30,6 +33,115 @@ const { logViolation } = require('~/cache'); const TextStream = require('./TextStream'); const db = require('~/models'); +const collectHistoricalFileRefs = (message) => { + const refs = []; + if (Array.isArray(message.files)) { + refs.push(...message.files); + } + if (Array.isArray(message.attachments)) { + refs.push(...message.attachments); + } + return refs; +}; + +const collectHistoricalFileIds = (messages) => { + const fileIds = new Set(); + for (const message of messages) { + for (const ref of collectHistoricalFileRefs(message)) { + if (ref?.file_id) { + fileIds.add(ref.file_id); + } + } + } + return Array.from(fileIds); +}; + +const buildOwnerFileFilter = (fileIds, user) => { + if (!user?.id || fileIds.length === 0) { + return null; + } + + const filter = { + file_id: { $in: fileIds }, + user: user.id, + }; + if (user.tenantId) { + filter.tenantId = user.tenantId; + } + return filter; +}; + +const TOOL_ATTACHMENT_KEYS = [ + Tools.file_search, + Tools.web_search, + Tools.ui_resources, + Tools.memory, +]; +const DISPLAY_ATTACHMENT_FIELDS = [ + 'filename', + 'filepath', + 'expiresAt', + 'conversationId', + 'messageId', + 'toolCallId', + 'name', +]; +const PER_MESSAGE_FILE_ATTACHMENT_FIELDS = ['messageId', 'toolCallId']; + +const pickFields = (source, fields) => { + const picked = {}; + for (const field of fields) { + if (source?.[field] !== undefined) { + picked[field] = source[field]; + } + } + return picked; +}; + +const sanitizeDisplayOnlyAttachment = (ref) => { + if (!ref || ref.file_id) { + return undefined; + } + + const attachment = pickFields(ref, DISPLAY_ATTACHMENT_FIELDS); + if (TOOL_ATTACHMENT_KEYS.includes(ref.type)) { + attachment.type = ref.type; + } + for (const key of TOOL_ATTACHMENT_KEYS) { + if (ref[key] !== undefined) { + attachment[key] = ref[key]; + } + } + + return Object.keys(attachment).length > 0 ? attachment : undefined; +}; + +const rehydrateMessageFileRefs = (refs, filesById, { preserveDisplayOnly = false } = {}) => { + if (!Array.isArray(refs)) { + return undefined; + } + + const files = []; + for (const ref of refs) { + const file = filesById.get(ref?.file_id); + if (file) { + files.push({ + ...sanitizeFileForTransmit(file), + ...pickFields(ref, PER_MESSAGE_FILE_ATTACHMENT_FIELDS), + }); + continue; + } + + if (preserveDisplayOnly) { + const displayOnlyAttachment = sanitizeDisplayOnlyAttachment(ref); + if (displayOnlyAttachment) { + files.push(displayOnlyAttachment); + } + } + } + return files.length > 0 ? files : undefined; +}; + class BaseClient { constructor(apiKey, options = {}) { this.apiKey = apiKey; @@ -277,6 +389,21 @@ class BaseClient { text: message, }); + /** + * Attach quoted excerpts (the "Add to chat" selections from `req.body.quotes`) + * before `getReqData`/`onStart` fire, so the optimistic bubble, resumable job + * metadata, and the saved row all carry them. Only on fresh turns — edits + * replay an existing message that already has its quotes. The excerpts are + * merged into the model-facing text later, per message, in `buildMessages`, + * keeping the stored `text` clean while the count stays consistent. + */ + if (!opts.isEdited) { + const referencedQuotes = getReferencedQuotes(this.options.req?.body?.quotes); + if (referencedQuotes != null) { + userMessage.quotes = referencedQuotes; + } + } + if (typeof opts?.getReqData === 'function') { opts.getReqData({ userMessage, @@ -700,14 +827,13 @@ class BaseClient { user, ); this.savedMessageIds.add(responseMessage.messageId); - delete responseMessage.tokenCount; return responseMessage; } async loadHistory(conversationId, parentMessageId = null) { logger.debug('[BaseClient] Loading history:', { conversationId, parentMessageId }); - const messages = (await db.getMessages({ conversationId })) ?? []; + const messages = (await db.getMessages({ conversationId, user: this.user })) ?? []; if (messages.length === 0) { return []; @@ -1217,8 +1343,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, @@ -1309,12 +1435,26 @@ class BaseClient { return _messages; } - const seen = new Set(); + const contextSeen = new Set(); const attachmentsProcessed = this.options.attachments && !(this.options.attachments instanceof Promise); if (attachmentsProcessed) { for (const attachment of this.options.attachments) { - seen.add(attachment.file_id); + if (attachment?.file_id) { + contextSeen.add(attachment.file_id); + } + } + } + + const historicalFileIds = collectHistoricalFileIds(_messages); + const fileFilter = buildOwnerFileFilter(historicalFileIds, this.options.req?.user); + const authorizedFilesById = new Map(); + if (fileFilter) { + const files = (await db.getFiles(fileFilter, {}, {})) ?? []; + for (const file of files) { + if (file?.file_id) { + authorizedFilesById.set(file.file_id, file); + } } } @@ -1328,38 +1468,57 @@ class BaseClient { this.message_file_map = {}; } - const fileIds = []; - for (const file of message.files) { - if (seen.has(file.file_id)) { - continue; + delete message.fileContext; + + const contextFiles = []; + if (Array.isArray(message.files)) { + for (const file of message.files) { + if (!file?.file_id || contextSeen.has(file.file_id)) { + continue; + } + const authorizedFile = authorizedFilesById.get(file.file_id); + if (authorizedFile) { + contextFiles.push(authorizedFile); + contextSeen.add(file.file_id); + } } - fileIds.push(file.file_id); - seen.add(file.file_id); } - if (fileIds.length === 0) { - return message; + const rehydratedFiles = rehydrateMessageFileRefs(message.files, authorizedFilesById); + if (rehydratedFiles) { + message.files = rehydratedFiles; + } else { + delete message.files; } - const files = await db.getFiles( + const rehydratedAttachments = rehydrateMessageFileRefs( + message.attachments, + authorizedFilesById, { - file_id: { $in: fileIds }, + preserveDisplayOnly: true, }, - {}, - {}, ); + if (rehydratedAttachments) { + message.attachments = rehydratedAttachments; + } else { + delete message.attachments; + } + + if (contextFiles.length === 0) { + return message; + } - await this.addFileContextToMessage(message, files); - await this.processAttachments(message, files); + await this.addFileContextToMessage(message, contextFiles); + await this.processAttachments(message, contextFiles); - this.message_file_map[message.messageId] = files; + this.message_file_map[message.messageId] = contextFiles; return message; }; const promises = []; for (const message of _messages) { - if (!message.files) { + if (!message.files && !message.attachments) { promises.push(message); continue; } diff --git a/api/app/clients/specs/BaseClient.test.js b/api/app/clients/specs/BaseClient.test.js index eb6ae656e99..d565f870121 100644 --- a/api/app/clients/specs/BaseClient.test.js +++ b/api/app/clients/specs/BaseClient.test.js @@ -1,5 +1,5 @@ const { Constants } = require('librechat-data-provider'); -const { initializeFakeClient } = require('./FakeClient'); +const { FakeClient, initializeFakeClient } = require('./FakeClient'); jest.mock('~/db/connect'); jest.mock('~/server/services/Config', () => ({ @@ -38,7 +38,7 @@ jest.mock('~/models', () => ({ updateFileUsage: jest.fn(), })); -const { getConvo, saveConvo, saveMessage } = require('~/models'); +const { getConvo, getFiles, getMessages, saveConvo, saveMessage } = require('~/models'); jest.mock('@librechat/agents', () => { const actual = jest.requireActual('@librechat/agents'); @@ -622,6 +622,27 @@ describe('BaseClient', () => { expect(chatMessages2[chatMessages2.length - 1].text).toEqual("What's up"); }); + test('loadHistory should scope database reads to the current user', async () => { + const user = 'user-123'; + TestClient = new FakeClient(apiKey, options); + TestClient.user = user; + getMessages.mockResolvedValueOnce([ + { + role: 'user', + isCreatedByUser: true, + text: 'Hello', + messageId: '1', + conversationId, + }, + ]); + + const chatMessages = await TestClient.loadHistory(conversationId, '1'); + + expect(getMessages).toHaveBeenCalledWith({ conversationId, user }); + expect(chatMessages).toHaveLength(1); + expect(chatMessages[0].text).toBe('Hello'); + }); + /* Most of the new sendMessage logic revolving around edited/continued AI messages * can be summarized by the following test. The condition will load the entire history up to * the message that is being edited, which will trigger the AI API to 'continue' the response. @@ -1295,4 +1316,296 @@ describe('BaseClient', () => { expect(userSave[0].files[0].file_id).toBe('file-abc'); }); }); + + describe('addPreviousAttachments authorization', () => { + const ownerFile = { + file_id: 'owner-file', + filename: 'owner.txt', + filepath: '/uploads/owner.txt', + source: 'local', + type: 'text/plain', + bytes: 100, + object: 'file', + user: 'user-1', + embedded: false, + usage: 0, + text: 'authorized owner text', + _id: 'owner-mongo-id', + metadata: { + codeEnvRef: { + kind: 'user', + id: 'user-1', + storage_session_id: 'owner-session', + file_id: 'owner-code-file', + }, + }, + }; + + beforeEach(() => { + getFiles.mockReset(); + TestClient.options.resendFiles = true; + TestClient.options.attachments = undefined; + TestClient.options.req = { + user: { + id: 'user-1', + tenantId: 'tenant-a', + }, + }; + TestClient.addFileContextToMessage = jest.fn(async (message, files) => { + const text = files + .map((file) => file.text) + .filter(Boolean) + .join('\n'); + if (text) { + message.fileContext = text; + } + }); + TestClient.processAttachments = jest.fn(async (_message, files) => files); + TestClient.checkVisionRequest = jest.fn(); + }); + + test('rehydrates historical file refs from owner-scoped DB rows only', async () => { + getFiles.mockResolvedValueOnce([ownerFile]); + + const [message] = await TestClient.addPreviousAttachments([ + { + messageId: 'msg-1', + text: 'Use the attachment', + files: [ + { + file_id: 'owner-file', + filename: 'attacker-controlled-owner-name.txt', + filepath: '/forged/owner.txt', + text: 'forged owner text', + }, + { + file_id: 'victim-file', + filename: 'victim.txt', + filepath: '/victim/private.txt', + text: 'victim private text', + }, + ], + attachments: [ + { + file_id: 'victim-file', + filename: 'victim-output.csv', + text: 'victim output text', + }, + ], + fileContext: 'stale victim private text', + }, + ]); + + expect(getFiles).toHaveBeenCalledWith( + { + file_id: { $in: ['owner-file', 'victim-file'] }, + user: 'user-1', + tenantId: 'tenant-a', + }, + {}, + {}, + ); + expect(TestClient.addFileContextToMessage).toHaveBeenCalledWith(message, [ownerFile]); + expect(TestClient.processAttachments).toHaveBeenCalledWith(message, [ownerFile]); + expect(message.fileContext).toBe('authorized owner text'); + expect(message.files).toEqual([ + expect.objectContaining({ + file_id: 'owner-file', + filename: 'owner.txt', + filepath: '/uploads/owner.txt', + source: 'local', + metadata: ownerFile.metadata, + }), + ]); + expect(message.files[0].text).toBeUndefined(); + expect(message.files[0]._id).toBeUndefined(); + expect(message.attachments).toBeUndefined(); + expect(JSON.stringify(message)).not.toContain('victim'); + expect(JSON.stringify(message)).not.toContain('forged owner text'); + }); + + test('strips historical file context when no authenticated owner scope is available', async () => { + TestClient.options.req = {}; + + const [message] = await TestClient.addPreviousAttachments([ + { + messageId: 'msg-2', + files: [{ file_id: 'victim-file', filename: 'victim.txt' }], + fileContext: 'stale victim private text', + }, + ]); + + expect(getFiles).not.toHaveBeenCalled(); + expect(message.files).toBeUndefined(); + expect(message.fileContext).toBeUndefined(); + }); + + test('preserves repeated owner-authorized historical file refs after the first context use', async () => { + getFiles.mockResolvedValueOnce([ownerFile]); + + const [firstMessage, secondMessage] = await TestClient.addPreviousAttachments([ + { + messageId: 'msg-repeat-1', + files: [{ file_id: 'owner-file', filename: 'first-forged.txt' }], + }, + { + messageId: 'msg-repeat-2', + files: [{ file_id: 'owner-file', filename: 'second-forged.txt' }], + }, + ]); + + expect(getFiles).toHaveBeenCalledTimes(1); + expect(getFiles).toHaveBeenCalledWith( + { + file_id: { $in: ['owner-file'] }, + user: 'user-1', + tenantId: 'tenant-a', + }, + {}, + {}, + ); + expect(TestClient.addFileContextToMessage).toHaveBeenCalledTimes(1); + expect(TestClient.addFileContextToMessage).toHaveBeenCalledWith(firstMessage, [ownerFile]); + expect(secondMessage.fileContext).toBeUndefined(); + expect(firstMessage.files).toEqual([ + expect.objectContaining({ file_id: 'owner-file', filename: 'owner.txt' }), + ]); + expect(secondMessage.files).toEqual([ + expect.objectContaining({ file_id: 'owner-file', filename: 'owner.txt' }), + ]); + expect(JSON.stringify(secondMessage)).not.toContain('second-forged'); + }); + + test('preserves download-only historical attachments without trusting file fields', async () => { + const [message] = await TestClient.addPreviousAttachments([ + { + messageId: 'msg-download-only', + attachments: [ + { + filename: 'report.csv', + filepath: '/api/files/code/download/session/file', + expiresAt: 123456, + conversationId: 'conversation-1', + messageId: 'assistant-message', + toolCallId: 'tool-call-1', + text: 'untrusted text should not survive', + source: 'forged-source', + metadata: { codeEnvRef: { id: 'victim' } }, + }, + ], + fileContext: 'stale context', + }, + ]); + + expect(getFiles).not.toHaveBeenCalled(); + expect(message.fileContext).toBeUndefined(); + expect(message.attachments).toEqual([ + { + filename: 'report.csv', + filepath: '/api/files/code/download/session/file', + expiresAt: 123456, + conversationId: 'conversation-1', + messageId: 'assistant-message', + toolCallId: 'tool-call-1', + }, + ]); + expect(JSON.stringify(message)).not.toContain('untrusted text'); + expect(JSON.stringify(message)).not.toContain('forged-source'); + expect(JSON.stringify(message)).not.toContain('victim'); + }); + + test('merges safe per-message metadata onto authorized DB-backed attachments', async () => { + getFiles.mockResolvedValueOnce([ownerFile]); + + const [message] = await TestClient.addPreviousAttachments([ + { + messageId: 'msg-artifact', + attachments: [ + { + file_id: 'owner-file', + filename: 'forged-artifact.csv', + filepath: '/forged/artifact.csv', + source: 'forged-source', + metadata: { codeEnvRef: { id: 'victim' } }, + text: 'forged artifact text', + messageId: 'assistant-message', + toolCallId: 'tool-call-2', + }, + ], + }, + ]); + + expect(message.attachments).toEqual([ + expect.objectContaining({ + file_id: 'owner-file', + filename: 'owner.txt', + filepath: '/uploads/owner.txt', + source: 'local', + metadata: ownerFile.metadata, + messageId: 'assistant-message', + toolCallId: 'tool-call-2', + }), + ]); + expect(message.attachments[0].text).toBeUndefined(); + expect(message.attachments[0]._id).toBeUndefined(); + expect(JSON.stringify(message)).not.toContain('forged-artifact'); + expect(JSON.stringify(message)).not.toContain('forged artifact text'); + }); + }); + + describe('sendMessage quote references', () => { + // The blockquote merge itself lives in AgentClient.buildMessages / prependQuotes + // (covered by packages/api specs). BaseClient's job is to attach the normalized + // quotes onto the user message early and keep the stored text clean. + test('attaches normalized quotes before getReqData fires and keeps stored text clean', async () => { + TestClient.options.req = { body: { quotes: [' the selected text ', '', 42] } }; + TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} }); + let captured; + await TestClient.sendMessage('What does this mean?', { + getReqData: (data) => { + if (data.userMessage) { + captured = { text: data.userMessage.text, quotes: data.userMessage.quotes }; + } + }, + }); + + // Quotes are present (trimmed, non-strings dropped) at getReqData time, and + // the user text is never mutated by the merge. + expect(captured).toBeDefined(); + expect(captured.quotes).toEqual(['the selected text']); + expect(captured.text).toBe('What does this mean?'); + + const userSave = TestClient.saveMessageToDatabase.mock.calls.find( + ([msg]) => msg.isCreatedByUser, + ); + expect(userSave[0].text).toBe('What does this mean?'); + expect(userSave[0].quotes).toEqual(['the selected text']); + }); + + test('persists multiple quotes in order on the saved message', async () => { + TestClient.options.req = { body: { quotes: ['first excerpt', 'second excerpt'] } }; + TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} }); + + await TestClient.sendMessage('Compare these'); + + const userSave = TestClient.saveMessageToDatabase.mock.calls.find( + ([msg]) => msg.isCreatedByUser, + ); + expect(userSave[0].text).toBe('Compare these'); + expect(userSave[0].quotes).toEqual(['first excerpt', 'second excerpt']); + }); + + test('leaves the message untouched when no quotes are provided', async () => { + TestClient.options.req = { body: {} }; + TestClient.saveMessageToDatabase = jest.fn().mockResolvedValue({ message: {} }); + + await TestClient.sendMessage('Just a question'); + + const userSave = TestClient.saveMessageToDatabase.mock.calls.find( + ([msg]) => msg.isCreatedByUser, + ); + expect(userSave[0].text).toBe('Just a question'); + expect(userSave[0].quotes).toBeUndefined(); + }); + }); }); diff --git a/api/app/clients/tools/structured/DALLE3.js b/api/app/clients/tools/structured/DALLE3.js index 8ce46323afd..5bcd87a0cee 100644 --- a/api/app/clients/tools/structured/DALLE3.js +++ b/api/app/clients/tools/structured/DALLE3.js @@ -1,10 +1,16 @@ 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 } = require('@librechat/api'); +const { + getImageBasename, + extractBaseURL, + getProxyDispatcher, + getEnvProxyDispatcher, + createMinimalRetentionRequest, +} = require('@librechat/api'); const { FileContext, ContentTypes } = require('librechat-data-provider'); const dalle3JsonSchema = { @@ -49,6 +55,7 @@ class DALLE3 extends Tool { this.userId = fields.userId; this.tenantId = fields.req?.user?.tenantId; + this.retentionRequest = createMinimalRetentionRequest(fields.req); this.fileStrategy = fields.fileStrategy; /** @type {boolean} */ this.isAgent = fields.isAgent; @@ -77,10 +84,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, }; } @@ -181,9 +188,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(); @@ -230,6 +237,7 @@ Error Message: ${error.message}`); fileStrategy: this.fileStrategy, context: FileContext.image_generation, tenantId: this.tenantId, + req: this.retentionRequest, }); if (this.returnMetadata) { diff --git a/api/app/clients/tools/structured/FluxAPI.js b/api/app/clients/tools/structured/FluxAPI.js index dc94a25e828..fd0464c34e9 100644 --- a/api/app/clients/tools/structured/FluxAPI.js +++ b/api/app/clients/tools/structured/FluxAPI.js @@ -2,8 +2,12 @@ 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 { Tool } = require('@librechat/agents/langchain/tools'); +const { + applyAxiosProxyConfig, + createMinimalRetentionRequest, + getHttpsProxyAgent, +} = require('@librechat/api'); const { FileContext, ContentTypes } = require('librechat-data-provider'); const fluxApiJsonSchema = { @@ -110,6 +114,7 @@ class FluxAPI extends Tool { this.userId = fields.userId; this.tenantId = fields.req?.user?.tenantId; + this.retentionRequest = createMinimalRetentionRequest(fields.req); this.fileStrategy = fields.fileStrategy; /** @type {boolean} **/ @@ -148,10 +153,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 */ @@ -305,8 +307,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(); @@ -343,6 +346,7 @@ class FluxAPI extends Tool { basePath: 'images', context: FileContext.image_generation, tenantId: this.tenantId, + req: this.retentionRequest, }); logger.debug('[FluxAPI] Image saved to path:', result.filepath); @@ -536,8 +540,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(); @@ -574,6 +579,7 @@ class FluxAPI extends Tool { basePath: 'images', context: FileContext.image_generation, tenantId: this.tenantId, + req: this.retentionRequest, }); logger.debug('[FluxAPI] Finetuned image saved to path:', result.filepath); diff --git a/api/app/clients/tools/structured/GeminiImageGen.js b/api/app/clients/tools/structured/GeminiImageGen.js index 49b9a63e0ed..04265bbba99 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, getTransactionsConfig, } = require('@librechat/api'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); @@ -20,14 +20,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); }; @@ -119,7 +119,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 0d7ee643e3f..d92d17b77e6 100644 --- a/api/app/clients/tools/structured/OpenAIImageTools.js +++ b/api/app/clients/tools/structured/OpenAIImageTools.js @@ -2,12 +2,16 @@ 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 } = require('@librechat/api'); +const { + logAxiosError, + oaiToolkit, + extractBaseURL, + getProxyDispatcher, + applyAxiosProxyConfig, +} = require('@librechat/api'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { getFiles } = require('~/models'); @@ -123,10 +127,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, }; } @@ -233,10 +237,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, }; } @@ -349,9 +353,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/structured/specs/imageTools-agent.spec.js b/api/app/clients/tools/structured/specs/imageTools-agent.spec.js index f88b76a1166..2d36ad4b7fa 100644 --- a/api/app/clients/tools/structured/specs/imageTools-agent.spec.js +++ b/api/app/clients/tools/structured/specs/imageTools-agent.spec.js @@ -100,11 +100,21 @@ describe('image tools - agent mode ToolMessage format', () => { }); it('keeps tenant context without retaining the request object', () => { - const req = { user: { tenantId: 'tenant-a' }, socket: {} }; + const req = { + user: { id: 'user-1', tenantId: 'tenant-a' }, + body: { conversationId: 'convo-1', isTemporary: 'true' }, + config: { interfaceConfig: { retentionMode: 'all' } }, + socket: {}, + }; const dalle = new DALLE3({ isAgent: false, processFileURL: jest.fn(), req }); expect(dalle.tenantId).toBe('tenant-a'); expect(dalle.req).toBeUndefined(); + expect(dalle.retentionRequest).toEqual({ + user: { id: 'user-1', tenantId: 'tenant-a' }, + body: { conversationId: 'convo-1', isTemporary: 'true' }, + config: { interfaceConfig: { retentionMode: 'all' } }, + }); }); it('invoke() returns ToolMessage with base64 in artifact, not serialized in content', async () => { @@ -181,11 +191,90 @@ describe('image tools - agent mode ToolMessage format', () => { }); it('keeps tenant context without retaining the request object', () => { - const req = { user: { tenantId: 'tenant-a' }, socket: {} }; + const req = { + user: { id: 'user-1', tenantId: 'tenant-a' }, + body: { conversationId: 'convo-1', isTemporary: 'true' }, + config: { interfaceConfig: { retentionMode: 'all' } }, + socket: {}, + }; const flux = new FluxAPI({ isAgent: false, processFileURL: jest.fn(), req }); expect(flux.tenantId).toBe('tenant-a'); expect(flux.req).toBeUndefined(); + expect(flux.retentionRequest).toEqual({ + user: { id: 'user-1', tenantId: 'tenant-a' }, + body: { conversationId: 'convo-1', isTemporary: 'true' }, + config: { interfaceConfig: { retentionMode: 'all' } }, + }); + }); + + it('passes minimal retention context when saving generated images', async () => { + const processFileURL = jest.fn().mockResolvedValue({ filepath: '/images/generated.png' }); + const req = { + user: { id: 'user-1', tenantId: 'tenant-a' }, + body: { conversationId: 'convo-1', isTemporary: 'true' }, + config: { interfaceConfig: { retentionMode: 'all' } }, + socket: {}, + }; + const flux = new FluxAPI({ + isAgent: false, + processFileURL, + req, + userId: 'user-1', + fileStrategy: 'local', + }); + const invokePromise = flux.invoke( + makeToolCall('flux', { prompt: 'a box', endpoint: '/v1/flux-dev' }), + ); + await jest.runAllTimersAsync(); + await invokePromise; + + expect(processFileURL).toHaveBeenCalledWith( + expect.objectContaining({ + req: { + user: { id: 'user-1', tenantId: 'tenant-a' }, + body: { conversationId: 'convo-1', isTemporary: 'true' }, + config: { interfaceConfig: { retentionMode: 'all' } }, + }, + }), + ); + }); + + it('passes minimal retention context when saving finetuned generated images', async () => { + const processFileURL = jest.fn().mockResolvedValue({ filepath: '/images/generated.png' }); + const req = { + user: { id: 'user-1', tenantId: 'tenant-a' }, + body: { conversationId: 'convo-1', isTemporary: 'true' }, + config: { interfaceConfig: { retentionMode: 'all' } }, + socket: {}, + }; + const flux = new FluxAPI({ + isAgent: false, + processFileURL, + req, + userId: 'user-1', + fileStrategy: 'local', + }); + const invokePromise = flux.invoke( + makeToolCall('flux', { + action: 'generate_finetuned', + prompt: 'a box', + finetune_id: 'ft-abc123', + endpoint: '/v1/flux-pro-finetuned', + }), + ); + await jest.runAllTimersAsync(); + await invokePromise; + + expect(processFileURL).toHaveBeenCalledWith( + expect.objectContaining({ + req: { + user: { id: 'user-1', tenantId: 'tenant-a' }, + body: { conversationId: 'convo-1', isTemporary: 'true' }, + config: { interfaceConfig: { retentionMode: 'all' } }, + }, + }), + ); }); it('invoke() returns ToolMessage with base64 in artifact, not serialized in content', async () => { diff --git a/api/app/clients/tools/util/fileSearch.js b/api/app/clients/tools/util/fileSearch.js index a9faf71c54f..be7589a8263 100644 --- a/api/app/clients/tools/util/fileSearch.js +++ b/api/app/clients/tools/util/fileSearch.js @@ -25,7 +25,7 @@ const fileSearchJsonSchema = { * @param {Agent['tool_resources']} options.tool_resources * @param {string} [options.agentId] - The agent ID for file access control * @returns {Promise<{ - * files: Array<{ file_id: string; filename: string }>, + * files: Array<{ file_id: string; filename: string; fromAgent: boolean }>, * toolContext: string * }>} */ @@ -70,6 +70,7 @@ const primeFiles = async (options) => { files.push({ file_id: file.file_id, filename: file.filename, + fromAgent: agentResourceIds.has(file.file_id), }); } @@ -80,7 +81,7 @@ const primeFiles = async (options) => { * * @param {Object} options * @param {string} options.userId - * @param {Array<{ file_id: string; filename: string }>} options.files + * @param {Array<{ file_id: string; filename: string; fromAgent?: boolean }>} options.files * @param {string} [options.entity_id] * @param {boolean} [options.fileCitations=false] - Whether to include citation instructions * @returns @@ -97,7 +98,7 @@ const createFileSearchTool = async ({ userId, files, entity_id, fileCitations = } /** - * @param {import('librechat-data-provider').TFile} file + * @param {import('librechat-data-provider').TFile & { fromAgent?: boolean }} file * @returns {{ file_id: string, query: string, k: number, entity_id?: string }} */ const createQueryBody = (file) => { @@ -106,7 +107,14 @@ const createFileSearchTool = async ({ userId, files, entity_id, fileCitations = query, k: 5, }; - if (!entity_id) { + // User-attached files are embedded under the user id (no entity); + // only agent knowledge-base files carry the agent's entity_id. + // Sending entity_id for user attachments makes the RAG API's entity + // filter return no results for them. When files are provided by + // primeFiles, fromAgent is always set; for callers that pass files + // directly without the flag, the safe default is unscoped (no + // entity_id). + if (!entity_id || file.fromAgent !== true) { return body; } body.entity_id = entity_id; diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index 89a79f3cbd7..adeb9f7ca99 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -35,7 +35,13 @@ const { createGeminiImageTool, createOpenAIImageTools, } = require('../'); -const { createMCPTool, createMCPTools, resolveConfigServers } = require('~/server/services/MCP'); +const { + createMCPTool, + createMCPTools, + 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'); @@ -227,6 +233,13 @@ const loadTools = async ({ }; const requestedTools = {}; + const hasMCPTools = tools.some((toolName) => toolName && mcpToolPattern.test(toolName)); + const mcpPermissionContext = + options.mcpPermissionContext ?? createMCPPermissionContext(options.req); + const canUseMCP = hasMCPTools + ? await mcpPermissionContext.canUseServers(options.req?.user) + : true; + let loggedMCPDenied = false; if (functions === true) { toolConstructors.dalle = DALLE3; @@ -266,7 +279,7 @@ const loadTools = async ({ /** Resolve config-source servers for the current user/tenant context */ let configServers; - if (tools.some((tool) => tool && mcpToolPattern.test(tool))) { + if (hasMCPTools && canUseMCP) { configServers = await resolveConfigServers(options.req); } @@ -345,6 +358,16 @@ const loadTools = async ({ }; continue; } else if (tool && mcpToolPattern.test(tool)) { + if (!canUseMCP) { + if (!loggedMCPDenied) { + logger.warn( + `[handleTools] User ${options.req?.user?.id} lacks MCP server use permission`, + ); + loggedMCPDenied = true; + } + continue; + } + const [toolName, serverName] = tool.split(Constants.mcp_delimiter); if (toolName === Constants.mcp_server) { /** Placeholder used for UI purposes */ @@ -429,22 +452,27 @@ 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)) { continue; } const mcpParams = { + mcpPermissionContext, index, signal, user: safeUser, userMCPAuthMap, configServers, + requestBody: options.req?.body, + requestScopedConnections, res: options.res, streamId: options.req?._resumableStreamId || null, model: agent?.model ?? model, @@ -465,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); } @@ -479,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/cache/getLogStores.js b/api/cache/getLogStores.js index 70eb681e53a..45a6a699947 100644 --- a/api/cache/getLogStores.js +++ b/api/cache/getLogStores.js @@ -7,6 +7,7 @@ const { sessionCache, standardCache, violationCache, + registerShutdownTask, } = require('@librechat/api'); const namespaces = { @@ -195,23 +196,16 @@ if (!cacheConfig.USE_REDIS && !cacheConfig.CI) { cleanupIntervals.add(monitor); } - const dispose = () => { + // Register cleanup with the centralized graceful-shutdown coordinator + // (see packages/api/src/app/shutdown.ts) rather than attaching a direct + // signal handler — multiple competing handlers race the HTTP drain. + registerShutdownTask('cache cleanup', async () => { cacheConfig.DEBUG_MEMORY_CACHE && console.log('[Cache] Cleaning up and shutting down...'); cleanupIntervals.forEach((interval) => clearInterval(interval)); cleanupIntervals.clear(); - - // One final cleanup before exit - clearAllExpiredFromCache().then(() => { - cacheConfig.DEBUG_MEMORY_CACHE && console.log('[Cache] Final cleanup completed'); - process.exit(0); - }); - }; - - // Handle various termination signals - process.on('SIGTERM', dispose); - process.on('SIGINT', dispose); - process.on('SIGQUIT', dispose); - process.on('SIGHUP', dispose); + await clearAllExpiredFromCache(); + cacheConfig.DEBUG_MEMORY_CACHE && console.log('[Cache] Final cleanup completed'); + }); } /** diff --git a/api/config/__tests__/parsers.spec.js b/api/config/__tests__/parsers.spec.js deleted file mode 100644 index f54675ce3ae..00000000000 --- a/api/config/__tests__/parsers.spec.js +++ /dev/null @@ -1,358 +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('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('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/meiliLogger.js b/api/config/meiliLogger.js index 398672da5c3..7eb6e3b9866 100644 --- a/api/config/meiliLogger.js +++ b/api/config/meiliLogger.js @@ -29,14 +29,16 @@ const getLogDir = () => { return path.join(__dirname, '..', 'logs'); }; -const logDir = getLogDir(); - -const { NODE_ENV, DEBUG_LOGGING = false } = process.env; +const { NODE_ENV, DEBUG_LOGGING = false, LOG_TO_FILE = true } = process.env; 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, @@ -68,17 +70,23 @@ const fileFormat = winston.format.combine( ); const logLevel = useDebugLogging ? 'debug' : 'error'; -const transports = [ - new winston.transports.DailyRotateFile({ - level: logLevel, - filename: `${logDir}/meiliSync-%DATE%.log`, - datePattern: 'YYYY-MM-DD', - zippedArchive: true, - maxSize: '20m', - maxFiles: '14d', - format: fileFormat, - }), -]; +const transports = []; + +if (useFileLogging) { + const logDir = getLogDir(); + + transports.push( + new winston.transports.DailyRotateFile({ + level: logLevel, + filename: `${logDir}/meiliSync-%DATE%.log`, + datePattern: 'YYYY-MM-DD', + zippedArchive: true, + maxSize: '20m', + maxFiles: '14d', + format: fileFormat, + }), + ); +} const consoleFormat = winston.format.combine( winston.format.colorize({ all: true }), diff --git a/api/db/connect.js b/api/db/connect.js index 3534884b575..a63d3301b69 100644 --- a/api/db/connect.js +++ b/api/db/connect.js @@ -1,10 +1,12 @@ require('dotenv').config(); -const { isEnabled } = require('@librechat/api'); +const { isEnabled, instrumentMongooseQueryMetrics } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); const mongoose = require('mongoose'); const MONGO_URI = process.env.MONGO_URI; +instrumentMongooseQueryMetrics(mongoose); + if (!MONGO_URI) { throw new Error('Please define the MONGO_URI environment variable'); } diff --git a/api/db/utils.js b/api/db/utils.js index 32051be78d1..f3302c92dac 100644 --- a/api/db/utils.js +++ b/api/db/utils.js @@ -1,4 +1,4 @@ -const { logger } = require('@librechat/data-schemas'); +const { logger, buildRetentionVisibilityFilter } = require('@librechat/data-schemas'); const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -26,7 +26,10 @@ async function batchResetMeiliFlags(collection) { try { while (hasMore) { const docs = await collection - .find({ expiredAt: null, _meiliIndex: { $ne: false } }, { projection: { _id: 1 } }) + .find( + { ...buildRetentionVisibilityFilter(), _meiliIndex: { $ne: false } }, + { projection: { _id: 1 } }, + ) .limit(BATCH_SIZE) .toArray(); diff --git a/api/db/utils.spec.js b/api/db/utils.spec.js index adf4f6cd86a..477bd70050d 100644 --- a/api/db/utils.spec.js +++ b/api/db/utils.spec.js @@ -83,6 +83,60 @@ describe('batchResetMeiliFlags', () => { expect(expiredDoc._meiliIndex).toBe(true); }); + it('should reset active non-temporary documents with expiredAt set for all-data retention', async () => { + const retentionDate = new Date(Date.now() + 60 * 60 * 1000); + await testCollection.insertMany([ + { + _id: new mongoose.Types.ObjectId(), + isTemporary: false, + expiredAt: retentionDate, + _meiliIndex: true, + }, + { + _id: new mongoose.Types.ObjectId(), + isTemporary: true, + expiredAt: retentionDate, + _meiliIndex: true, + }, + ]); + + const result = await batchResetMeiliFlags(testCollection); + + expect(result).toBe(1); + + const retainedDoc = await testCollection.findOne({ isTemporary: false }); + const temporaryDoc = await testCollection.findOne({ isTemporary: true }); + expect(retainedDoc._meiliIndex).toBe(false); + expect(temporaryDoc._meiliIndex).toBe(true); + }); + + it('should not reset expired non-temporary documents with expiredAt set for all-data retention', async () => { + const retentionDate = new Date(Date.now() - 60 * 60 * 1000); + await testCollection.insertMany([ + { + _id: new mongoose.Types.ObjectId(), + isTemporary: false, + expiredAt: retentionDate, + _meiliIndex: true, + }, + { + _id: new mongoose.Types.ObjectId(), + isTemporary: false, + expiredAt: null, + _meiliIndex: true, + }, + ]); + + const result = await batchResetMeiliFlags(testCollection); + + expect(result).toBe(1); + + const expiredDoc = await testCollection.findOne({ expiredAt: retentionDate }); + const permanentDoc = await testCollection.findOne({ expiredAt: null }); + expect(expiredDoc._meiliIndex).toBe(true); + expect(permanentDoc._meiliIndex).toBe(false); + }); + it('should not modify documents with _meiliIndex: false', async () => { await testCollection.insertMany([ { _id: new mongoose.Types.ObjectId(), expiredAt: null, _meiliIndex: false }, diff --git a/api/jest.config.js b/api/jest.config.js index 47f8b7287bf..daa12004d67 100644 --- a/api/jest.config.js +++ b/api/jest.config.js @@ -1,3 +1,14 @@ +const esModules = [ + 'openid-client', + 'oauth4webapi', + 'jose', + '@langchain/langgraph', + '@langchain/langgraph-checkpoint', + '@langchain/langgraph-sdk', + '@mistralai/mistralai', + 'uuid', +].join('|'); + module.exports = { testEnvironment: 'node', clearMocks: true, @@ -12,5 +23,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 eb4cf2a1917..9971d42119b 100644 --- a/api/package.json +++ b/api/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/backend", - "version": "v0.8.6-rc1", + "version": "v0.8.7", "description": "", "scripts": { "start": "echo 'please run this from the root directory'", @@ -39,18 +39,29 @@ "@aws-sdk/client-cloudfront": "^3.1042.0", "@aws-sdk/client-s3": "^3.980.0", "@aws-sdk/cloudfront-signer": "^3.1036.0", + "@aws-sdk/credential-providers": "^3.1045.0", "@aws-sdk/s3-request-presigner": "^3.758.0", "@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.1.86", + "@librechat/agents": "^3.2.46", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", "@modelcontextprotocol/sdk": "^1.29.0", "@node-saml/passport-saml": "^5.1.0", + "@opentelemetry/api": "^1.9.0", + "@opentelemetry/instrumentation-express": "^0.56.0", + "@opentelemetry/instrumentation-http": "^0.207.0", + "@opentelemetry/instrumentation-ioredis": "^0.55.0", + "@opentelemetry/instrumentation-mongodb": "^0.60.0", + "@opentelemetry/instrumentation-mongoose": "^0.54.0", + "@opentelemetry/instrumentation-undici": "^0.18.0", + "@opentelemetry/resources": "^2.6.1", + "@opentelemetry/sdk-node": "^0.218.0", + "@opentelemetry/semantic-conventions": "^1.39.0", "@smithy/node-http-handler": "^4.4.5", "ai-tokenizer": "^1.0.6", "axios": "^1.16.0", @@ -71,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", + "js-yaml": "^4.2.0", "jsonwebtoken": "^9.0.0", + "jszip": "^3.10.1", "jwks-rsa": "^3.2.0", "keyv": "^5.3.2", "keyv-file": "^5.1.2", @@ -88,11 +101,12 @@ "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", + "multer": "^2.2.0", "nanoid": "^3.3.7", "node-fetch": "^2.7.0", - "nodemailer": "^8.0.5", + "nodemailer": "^9.0.1", "ollama": "^0.5.0", "openai": "5.8.2", "openid-client": "^6.5.0", @@ -106,10 +120,10 @@ "passport-ldapauth": "^3.0.1", "passport-local": "^1.0.0", "pdfjs-dist": "^5.4.624", + "prom-client": "^15.1.3", "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", @@ -119,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/Balance.js b/api/server/controllers/Balance.js index fd9b32e74c2..8df579e5c6f 100644 --- a/api/server/controllers/Balance.js +++ b/api/server/controllers/Balance.js @@ -1,7 +1,13 @@ const { findBalanceByUser } = require('~/models'); async function balanceController(req, res) { - const balanceData = await findBalanceByUser(req.user.id); + const balanceLocals = res.locals || {}; + + if (balanceLocals.balanceConfigEnabled === false) { + return res.sendStatus(204); + } + + const balanceData = balanceLocals.balanceData ?? (await findBalanceByUser(req.user.id)); if (!balanceData) { return res.status(404).json({ error: 'Balance not found' }); diff --git a/api/server/controllers/Balance.spec.js b/api/server/controllers/Balance.spec.js new file mode 100644 index 00000000000..833f2d8c549 --- /dev/null +++ b/api/server/controllers/Balance.spec.js @@ -0,0 +1,72 @@ +jest.mock('~/models', () => ({ + findBalanceByUser: jest.fn(), +})); + +const { findBalanceByUser } = require('~/models'); +const balanceController = require('./Balance'); + +describe('balanceController', () => { + const createResponse = () => ({ + locals: {}, + status: jest.fn().mockReturnThis(), + json: jest.fn().mockReturnThis(), + sendStatus: jest.fn().mockReturnThis(), + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('returns no content without reading balance when balance config is disabled', async () => { + const req = { + user: { id: 'user-1' }, + }; + const res = createResponse(); + res.locals.balanceConfigEnabled = false; + + await balanceController(req, res); + + expect(findBalanceByUser).not.toHaveBeenCalled(); + expect(res.sendStatus).toHaveBeenCalledWith(204); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('uses balance data attached by middleware without a second read', async () => { + const req = { + user: { id: 'user-1' }, + }; + const res = createResponse(); + res.locals.balanceConfigEnabled = true; + res.locals.balanceData = { + _id: 'balance-1', + user: 'user-1', + tokenCredits: 100, + autoRefillEnabled: false, + }; + + await balanceController(req, res); + + expect(findBalanceByUser).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + user: 'user-1', + tokenCredits: 100, + autoRefillEnabled: false, + }); + }); + + it('returns not found when balance is enabled and no record exists', async () => { + findBalanceByUser.mockResolvedValue(null); + const req = { + user: { id: 'user-1' }, + }; + const res = createResponse(); + res.locals.balanceConfigEnabled = true; + + await balanceController(req, res); + + expect(findBalanceByUser).toHaveBeenCalledWith('user-1'); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'Balance not found' }); + }); +}); diff --git a/api/server/controllers/ContextProjectionController.js b/api/server/controllers/ContextProjectionController.js new file mode 100644 index 00000000000..9c56b2ae342 --- /dev/null +++ b/api/server/controllers/ContextProjectionController.js @@ -0,0 +1,35 @@ +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, + getMessageTextStats: db.getMessageTextStats, + }, + 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 1f200fce83d..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(), @@ -385,15 +400,17 @@ const getUserEffectivePermissions = async (req, res) => { */ const searchPrincipals = async (req, res) => { try { - const { q: query, limit = 20, types } = req.query; + const { q: rawQuery, limit = 20, types } = req.query; - if (!query || query.trim().length === 0) { + if (typeof rawQuery !== 'string' || rawQuery.trim().length === 0) { return res.status(400).json({ error: 'Query parameter "q" is required and must not be empty', }); } - if (query.trim().length < 2) { + const query = rawQuery.trim(); + + if (query.length < 2) { return res.status(400).json({ error: 'Query must be at least 2 characters long', }); @@ -410,7 +427,7 @@ const searchPrincipals = async (req, res) => { typeFilters = validTypes.length > 0 ? validTypes : null; } - const localResults = await db.searchPrincipals(query.trim(), searchLimit, typeFilters); + const localResults = await db.searchPrincipals(query, searchLimit, typeFilters); let allPrincipals = [...localResults]; const useEntraId = entraIdPrincipalFeatureEnabled(req.user); @@ -437,7 +454,7 @@ const searchPrincipals = async (req, res) => { const graphResults = await searchEntraIdPrincipals( accessToken, req.user.openidId, - query.trim(), + query, graphType, searchLimit - localResults.length, ); @@ -466,7 +483,7 @@ const searchPrincipals = async (req, res) => { } const scoredResults = allPrincipals.map((item) => ({ ...item, - _searchScore: db.calculateRelevanceScore(item, query.trim()), + _searchScore: db.calculateRelevanceScore(item, query), })); const finalResults = db @@ -478,7 +495,7 @@ const searchPrincipals = async (req, res) => { }); res.status(200).json({ - query: query.trim(), + query, limit: searchLimit, types: typeFilters, results: finalResults, @@ -492,7 +509,6 @@ const searchPrincipals = async (req, res) => { logger.error('Error searching principals:', error); res.status(500).json({ error: 'Failed to search principals', - details: error.message, }); } }; diff --git a/api/server/controllers/PluginController.js b/api/server/controllers/PluginController.js index c5d5c5b8880..7bb21a7c58f 100644 --- a/api/server/controllers/PluginController.js +++ b/api/server/controllers/PluginController.js @@ -6,7 +6,13 @@ const { getAppConfig } = require('~/server/services/Config'); const getAvailablePluginsController = async (req, res) => { try { - const appConfig = await getAppConfig({ role: req.user?.role, tenantId: req.user?.tenantId }); + const appConfig = + req.config ?? + (await getAppConfig({ + role: req.user?.role, + userId: req.user?.id, + tenantId: req.user?.tenantId, + })); const { filteredTools = [], includedTools = [] } = appConfig; const uniquePlugins = filterUniquePlugins(availableTools); @@ -41,7 +47,12 @@ const getAvailableTools = async (req, res) => { } const appConfig = - req.config ?? (await getAppConfig({ role: req.user?.role, tenantId: req.user?.tenantId })); + req.config ?? + (await getAppConfig({ + role: req.user?.role, + userId: req.user?.id, + tenantId: req.user?.tenantId, + })); let toolDefinitions = await getCachedTools(); diff --git a/api/server/controllers/PluginController.spec.js b/api/server/controllers/PluginController.spec.js index 9288680567d..b392ab575d6 100644 --- a/api/server/controllers/PluginController.spec.js +++ b/api/server/controllers/PluginController.spec.js @@ -98,10 +98,10 @@ describe('PluginController', () => { require('~/app/clients/tools').availableTools.push(...mockPlugins); - getAppConfig.mockResolvedValueOnce({ + mockReq.config = { filteredTools: [], includedTools: ['key1'], - }); + }; await getAvailablePluginsController(mockReq, mockRes); @@ -118,10 +118,10 @@ describe('PluginController', () => { require('~/app/clients/tools').availableTools.push(...mockPlugins); - getAppConfig.mockResolvedValueOnce({ + mockReq.config = { filteredTools: ['key2'], includedTools: [], - }); + }; await getAvailablePluginsController(mockReq, mockRes); @@ -139,10 +139,10 @@ describe('PluginController', () => { require('~/app/clients/tools').availableTools.push(...mockPlugins); - getAppConfig.mockResolvedValueOnce({ + mockReq.config = { includedTools: ['key1', 'key2'], filteredTools: ['key2'], - }); + }; await getAvailablePluginsController(mockReq, mockRes); 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 5b38d6d5624..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, @@ -25,17 +26,47 @@ const { getAppConfig } = require('~/server/services/Config'); const { getLogStores } = require('~/cache'); const db = require('~/models'); +const PUBLIC_USER_RESPONSE_FIELDS = [ + '_id', + 'id', + 'name', + 'username', + 'email', + 'emailVerified', + 'avatar', + 'provider', + 'role', + 'plugins', + 'twoFactorEnabled', + 'termsAccepted', + 'personalization', + 'favorites', + 'skillStates', + 'createdAt', + 'updatedAt', + 'tenantId', +]; + +const sanitizeUserForResponse = (user) => { + const source = user.toObject != null ? user.toObject() : user; + return PUBLIC_USER_RESPONSE_FIELDS.reduce((userData, field) => { + if (source[field] !== undefined) { + userData[field] = source[field]; + } + return userData; + }, {}); +}; + const getUserController = async (req, res) => { - const appConfig = await getAppConfig({ role: req.user?.role, tenantId: req.user?.tenantId }); + const appConfig = + req.config ?? + (await getAppConfig({ + role: req.user?.role, + userId: req.user?.id, + tenantId: req.user?.tenantId, + })); /** @type {IUser} */ - const userData = req.user.toObject != null ? req.user.toObject() : { ...req.user }; - /** - * These fields should not exist due to secure field selection, but deletion - * is done in case of alternate database incompatibility with Mongo API - * */ - delete userData.password; - delete userData.totpSecret; - delete userData.backupCodes; + const userData = sanitizeUserForResponse(req.user); if (appConfig.fileStrategy === FileSources.s3 && userData.avatar) { const avatarNeedsRefresh = needsRefresh(userData.avatar, 3600); if (!avatarNeedsRefresh) { @@ -165,7 +196,13 @@ const deleteUserMcpServers = async (userId) => { }; const updateUserPluginsController = async (req, res) => { - const appConfig = await getAppConfig({ role: req.user?.role, tenantId: req.user?.tenantId }); + const appConfig = + req.config ?? + (await getAppConfig({ + role: req.user?.role, + userId: req.user?.id, + tenantId: req.user?.tenantId, + })); const { user } = req; const { pluginKey, action, auth, isEntityTool } = req.body; try { @@ -323,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); @@ -351,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); } @@ -365,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); @@ -395,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( @@ -480,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 30e6190e286..6a165fe7182 100644 --- a/api/server/controllers/UserController.spec.js +++ b/api/server/controllers/UserController.spec.js @@ -75,7 +75,7 @@ jest.mock('@librechat/api', () => ({ })); jest.mock('~/server/services/Files/process', () => ({ - processDeleteRequest: jest.fn().mockResolvedValue(undefined), + processDeleteRequest: jest.fn().mockResolvedValue({ deletedFileIds: [], failedFileIds: [] }), })); jest.mock('~/server/services/Config', () => ({ @@ -108,9 +108,153 @@ afterEach(async () => { } }); -const { deleteUserController } = 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 = { + status: jest.fn().mockReturnThis(), + send: jest.fn().mockReturnThis(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should only expose public user response fields from the request user', async () => { + const createdAt = new Date('2026-01-01T00:00:00.000Z'); + const updatedAt = new Date('2026-01-02T00:00:00.000Z'); + const req = { + config: {}, + user: { + id: 'user-id', + _id: 'user-id', + name: 'OpenID User', + username: 'openid-user', + email: 'openid@test.com', + emailVerified: true, + avatar: '/avatars/user-id.png', + provider: 'openid', + role: 'USER', + plugins: ['web_search'], + twoFactorEnabled: true, + termsAccepted: true, + personalization: { memories: false }, + favorites: [{ model: 'gpt-5', endpoint: 'openAI' }], + skillStates: { skill_one: true }, + createdAt, + updatedAt, + tenantId: 'tenant-id', + password: 'hashed-password', + __v: 1, + totpSecret: 'totp-secret', + backupCodes: [{ codeHash: 'backup-code' }], + pendingTotpSecret: 'pending-totp-secret', + pendingBackupCodes: [{ codeHash: 'pending-backup-code' }], + refreshToken: [{ refreshToken: 'legacy-refresh-token' }], + googleId: 'google-id', + openidId: 'openid-id', + openidIssuer: 'openid-issuer', + idOnTheSource: 'external-source-id', + federatedTokens: { + access_token: 'access-token', + id_token: 'id-token', + refresh_token: 'refresh-token', + }, + openidTokens: { + access_token: 'openid-access-token', + refresh_token: 'openid-refresh-token', + }, + tokenset: { + access_token: 'tokenset-access-token', + refresh_token: 'tokenset-refresh-token', + }, + safeLookingRuntimeField: 'internal-value', + }, + }; + + await getUserController(req, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(200); + const sentUser = mockRes.send.mock.calls[0][0]; + expect(sentUser).toMatchObject({ + id: 'user-id', + _id: 'user-id', + name: 'OpenID User', + username: 'openid-user', + email: 'openid@test.com', + emailVerified: true, + avatar: '/avatars/user-id.png', + provider: 'openid', + role: 'USER', + plugins: ['web_search'], + twoFactorEnabled: true, + termsAccepted: true, + personalization: { memories: false }, + favorites: [{ model: 'gpt-5', endpoint: 'openAI' }], + skillStates: { skill_one: true }, + createdAt, + updatedAt, + tenantId: 'tenant-id', + }); + expect(sentUser).not.toHaveProperty('password'); + expect(sentUser).not.toHaveProperty('__v'); + expect(sentUser).not.toHaveProperty('totpSecret'); + expect(sentUser).not.toHaveProperty('backupCodes'); + expect(sentUser).not.toHaveProperty('pendingTotpSecret'); + expect(sentUser).not.toHaveProperty('pendingBackupCodes'); + expect(sentUser).not.toHaveProperty('refreshToken'); + expect(sentUser).not.toHaveProperty('googleId'); + expect(sentUser).not.toHaveProperty('openidId'); + expect(sentUser).not.toHaveProperty('openidIssuer'); + expect(sentUser).not.toHaveProperty('idOnTheSource'); + expect(sentUser).not.toHaveProperty('federatedTokens'); + expect(sentUser).not.toHaveProperty('openidTokens'); + expect(sentUser).not.toHaveProperty('tokenset'); + expect(sentUser).not.toHaveProperty('safeLookingRuntimeField'); + }); +}); describe('deleteUserController', () => { const mockRes = { diff --git a/api/server/controllers/__tests__/PermissionsController.spec.js b/api/server/controllers/__tests__/PermissionsController.spec.js index a8d95184550..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(), @@ -43,7 +48,12 @@ jest.mock('~/server/services/GraphApiService', () => ({ searchEntraIdPrincipals: jest.fn(), })); -const { updateResourcePermissions } = require('../PermissionsController'); +const db = require('~/models'); +const { + updateResourcePermissions, + searchPrincipals, + getResourcePermissions, +} = require('../PermissionsController'); const createMockReq = (overrides = {}) => ({ params: { resourceType: ResourceType.AGENT, resourceId: '507f1f77bcf86cd799439011' }, @@ -65,6 +75,180 @@ const flushPromises = () => new Promise((resolve) => setImmediate(resolve)); describe('PermissionsController', () => { beforeEach(() => { jest.clearAllMocks(); + mockGetTenantId.mockReturnValue(undefined); + }); + + describe('searchPrincipals', () => { + beforeEach(() => { + db.searchPrincipals.mockResolvedValue([]); + db.calculateRelevanceScore.mockReturnValue(50); + db.sortPrincipalsByRelevance.mockImplementation((results) => results); + }); + + it('rejects non-string query parameters', async () => { + const req = createMockReq({ + query: { q: ['alice'] }, + }); + const res = createMockRes(); + + await searchPrincipals(req, res); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ + error: 'Query parameter "q" is required and must not be empty', + }); + expect(db.searchPrincipals).not.toHaveBeenCalled(); + }); + + it('searches with the trimmed literal query', async () => { + db.searchPrincipals.mockResolvedValue([ + { + id: 'user-1', + type: PrincipalType.USER, + name: 'Regex [invalid User', + source: 'local', + }, + ]); + + const req = createMockReq({ + query: { q: ' [invalid ', limit: '5', types: PrincipalType.USER }, + }); + const res = createMockRes(); + + await searchPrincipals(req, res); + + expect(db.searchPrincipals).toHaveBeenCalledWith('[invalid', 5, [PrincipalType.USER]); + expect(db.calculateRelevanceScore).toHaveBeenCalledWith( + expect.objectContaining({ name: 'Regex [invalid User' }), + '[invalid', + ); + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + query: '[invalid', + limit: 5, + count: 1, + }), + ); + }); + + it('does not expose internal error details on search failures', async () => { + db.searchPrincipals.mockRejectedValue(new Error('database failure with internal detail')); + + const req = createMockReq({ + query: { q: 'alice' }, + }); + const res = createMockRes(); + + await searchPrincipals(req, res); + + expect(res.status).toHaveBeenCalledWith(500); + expect(res.json).toHaveBeenCalledWith({ + error: 'Failed to search principals', + }); + }); + }); + + 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', () => { diff --git a/api/server/controllers/__tests__/UserController.mcpOAuth.spec.js b/api/server/controllers/__tests__/UserController.mcpOAuth.spec.js index 2d23b4b02c3..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: { @@ -67,7 +75,7 @@ jest.mock('~/server/services/Config/getCachedTools', () => ({ })); jest.mock('~/server/services/Files/process', () => ({ - processDeleteRequest: jest.fn(), + processDeleteRequest: jest.fn().mockResolvedValue({ deletedFileIds: [], failedFileIds: [] }), })); jest.mock('~/server/services/Config', () => ({ @@ -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 bc6acde53d7..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,8 +128,9 @@ function stubDeletionMocks() { mockDeleteUserPluginAuth.mockResolvedValue(); mockDeleteUserById.mockResolvedValue(); mockDeleteAllSharedLinks.mockResolvedValue(); + mockDeleteAllSharedLinksWithCleanup.mockResolvedValue({ deletedCount: 0 }); mockGetFiles.mockResolvedValue([]); - mockProcessDeleteRequest.mockResolvedValue(); + mockProcessDeleteRequest.mockResolvedValue({ deletedFileIds: [], failedFileIds: [] }); mockDeleteFiles.mockResolvedValue(); mockDeleteToolCalls.mockResolvedValue(); mockDeleteUserAgents.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 65b9cf75b21..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), @@ -81,7 +90,7 @@ jest.mock('~/server/services/Config/getCachedTools', () => ({ })); jest.mock('~/server/services/Files/process', () => ({ - processDeleteRequest: jest.fn(), + processDeleteRequest: jest.fn().mockResolvedValue({ deletedFileIds: [], failedFileIds: [] }), })); jest.mock('~/server/services/Config', () => ({ @@ -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__/jobReplacement.spec.js b/api/server/controllers/agents/__tests__/jobReplacement.spec.js index efa79ca4ba2..7f7a775b75b 100644 --- a/api/server/controllers/agents/__tests__/jobReplacement.spec.js +++ b/api/server/controllers/agents/__tests__/jobReplacement.spec.js @@ -35,6 +35,15 @@ jest.mock('@librechat/data-schemas', () => ({ jest.mock('@librechat/api', () => ({ isEnabled: jest.fn().mockReturnValue(false), GenerationJobManager: mockGenerationJobManager, + getReferencedQuotes: jest.fn((quotes) => { + if (!Array.isArray(quotes)) { + return null; + } + const normalized = quotes + .filter((quote) => typeof quote === 'string' && quote.trim().length > 0) + .map((quote) => quote.trim()); + return normalized.length > 0 ? normalized : null; + }), checkAndIncrementPendingRequest: jest.fn().mockResolvedValue({ allowed: true }), decrementPendingRequest: (...args) => mockDecrementPendingRequest(...args), getViolationInfo: jest.fn(), 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..8024b8d4086 --- /dev/null +++ b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js @@ -0,0 +1,621 @@ +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, + getReferencedQuotes: jest.fn((quotes) => { + if (!Array.isArray(quotes)) { + return null; + } + const normalized = quotes + .filter((quote) => typeof quote === 'string' && quote.trim().length > 0) + .map((quote) => quote.trim()); + return normalized.length > 0 ? normalized : null; + }), + 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..4bc6d19e7e5 100644 --- a/api/server/controllers/agents/__tests__/responses.unit.spec.js +++ b/api/server/controllers/agents/__tests__/responses.unit.spec.js @@ -10,6 +10,53 @@ 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(() => ({})); +const mockBuildAgentScopedContext = jest.fn().mockResolvedValue(new Map()); +const mockBuildAgentContextAttachmentsByAgentId = jest.fn().mockReturnValue(new Map()); +const mockApplyContextToAgent = jest.fn().mockResolvedValue(undefined); jest.mock('nanoid', () => ({ nanoid: jest.fn(() => 'mock-nanoid-123'), @@ -40,7 +87,11 @@ jest.mock('@librechat/api', () => ({ createRun: jest.fn().mockResolvedValue({ processStream: jest.fn().mockResolvedValue(undefined), }), + applyContextToAgent: (...args) => mockApplyContextToAgent(...args), buildToolSet: jest.fn().mockReturnValue(new Set()), + buildAgentScopedContext: (...args) => mockBuildAgentScopedContext(...args), + buildAgentContextAttachmentsByAgentId: (...args) => + mockBuildAgentContextAttachmentsByAgentId(...args), scopeSkillIds: jest.fn().mockImplementation((ids) => ids), resolveAgentScopedSkillIds: jest .fn() @@ -53,16 +104,26 @@ jest.mock('@librechat/api', () => ({ model_parameters: {}, toolRegistry: {}, edges: [], + agentContextAttachments: [], }), - discoverConnectedAgents: jest.fn().mockResolvedValue({ - agentConfigs: new Map(), - edges: [], - skippedAgentIds: new Set(), - userMCPAuthMap: undefined, + discoverConnectedAgents: jest.fn().mockImplementation(async (computedParams, deps) => { + // Call onAgentInitialized for each agent config if provided by the mock setup + if (deps?.onAgentInitialized && mockGlobalDiscoveredAgentConfigs) { + for (const [agentId, config] of mockGlobalDiscoveredAgentConfigs) { + deps.onAgentInitialized(agentId, config, config); + } + } + return { + agentConfigs: mockGlobalDiscoveredAgentConfigs ?? new Map(), + edges: [], + skippedAgentIds: new Set(), + userMCPAuthMap: undefined, + }; }), getBalanceConfig: mockGetBalanceConfig, getTransactionsConfig: mockGetTransactionsConfig, recordCollectedUsage: mockRecordCollectedUsage, + createSubagentUsageSink: jest.fn().mockReturnValue(jest.fn()), extractManualSkills: jest.fn().mockReturnValue(undefined), injectSkillPrimes: jest.fn().mockReturnValue({ initialMessages: [], @@ -78,6 +139,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({ @@ -150,10 +212,29 @@ jest.mock('~/server/controllers/ModelController', () => ({ getModelsConfig: jest.fn().mockResolvedValue({}), })); +jest.mock('~/server/services/MCP', () => ({ + resolveConfigServers: jest.fn().mockResolvedValue({}), +})); + +jest.mock('~/config', () => ({ + getMCPManager: jest.fn().mockReturnValue({}), +})); + 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(), })); @@ -196,12 +277,15 @@ jest.mock('~/models', () => ({ getConvo: jest.fn().mockResolvedValue(null), })); +let mockGlobalDiscoveredAgentConfigs = null; + describe('createResponse controller', () => { let createResponse; let req, res; beforeEach(() => { jest.clearAllMocks(); + mockGlobalDiscoveredAgentConfigs = null; const controller = require('../responses'); createResponse = controller.createResponse; @@ -392,6 +476,89 @@ describe('createResponse controller', () => { }); }); + describe('agent context parity with UI path', () => { + it('applies agent-scoped attachment context before createRun', async () => { + const api = require('@librechat/api'); + api.initializeAgent.mockResolvedValueOnce({ + id: 'agent-123', + model: 'claude-3', + model_parameters: {}, + toolRegistry: {}, + edges: [], + agentContextAttachments: [{ file_id: 'file-1', filename: 'ocr_file.pdf' }], + }); + mockBuildAgentContextAttachmentsByAgentId.mockReturnValueOnce( + new Map([['agent-123', [{ file_id: 'file-1', filename: 'ocr_file.pdf' }]]]), + ); + mockBuildAgentScopedContext.mockResolvedValueOnce( + new Map([['agent-123', 'PDF context: ocr_file.pdf']]), + ); + + await createResponse(req, res); + + expect(mockBuildAgentContextAttachmentsByAgentId).toHaveBeenCalledWith([ + expect.objectContaining({ id: 'agent-123' }), + ]); + expect(mockBuildAgentScopedContext).toHaveBeenCalledWith( + expect.objectContaining({ + agentIds: ['agent-123'], + attachmentsByAgentId: expect.any(Map), + req, + }), + ); + expect(mockApplyContextToAgent).toHaveBeenCalledWith( + expect.objectContaining({ + agent: expect.objectContaining({ id: 'agent-123' }), + agentId: 'agent-123', + sharedRunContext: 'PDF context: ocr_file.pdf', + }), + ); + }); + + it('applies context to primary and discovered handoff agents', async () => { + const api = require('@librechat/api'); + const handoffConfig = { + id: 'agent-handoff', + model: 'claude-3', + model_parameters: {}, + toolRegistry: {}, + edges: [], + agentContextAttachments: [{ file_id: 'file-2', filename: 'handoff_context.pdf' }], + }; + + // Set primary agent to have edges pointing to handoff agent + api.initializeAgent.mockResolvedValueOnce({ + id: 'agent-123', + model: 'claude-3', + model_parameters: {}, + toolRegistry: {}, + edges: [{ source: 'agent-123', target: 'agent-handoff' }], + agentContextAttachments: [{ file_id: 'file-1', filename: 'primary_context.pdf' }], + }); + + // Set global config so discoverConnectedAgents mock can invoke onAgentInitialized + mockGlobalDiscoveredAgentConfigs = new Map([['agent-handoff', handoffConfig]]); + + mockBuildAgentScopedContext.mockResolvedValueOnce( + new Map([ + ['agent-123', 'Primary context'], + ['agent-handoff', 'Handoff context'], + ]), + ); + + await createResponse(req, res); + + const appliedAgentIds = mockApplyContextToAgent.mock.calls.map((call) => call[0].agentId); + expect(appliedAgentIds).toEqual(expect.arrayContaining(['agent-123', 'agent-handoff'])); + expect(mockApplyContextToAgent).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: 'agent-handoff', + sharedRunContext: 'Handoff context', + }), + ); + }); + }); + describe('token usage recording - streaming', () => { beforeEach(() => { req.body.stream = true; @@ -458,4 +625,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 0338918412c..e073c964d76 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -9,9 +9,10 @@ const { logToolError, sanitizeTitle, payloadParser, - resolveHeaders, createSafeUser, initializeAgent, + resolveConfigHeaders, + countTokens, getBalanceConfig, omitTitleOptions, getProviderConfig, @@ -20,6 +21,15 @@ const { applyContextToAgent, isMemoryAgentEnabled, recordCollectedUsage, + sendEvent, + computeUsageCostUSD, + aggregateEmittedUsage, + resolveAgentTokenConfig, + buildPersistedContextUsage, + computeSummaryUsedTokens, + priorRunOutputTokens, + createSubagentUsageSink, + anyAgentReplaysReasoningContent, GenerationJobManager, getTransactionsConfig, resolveRecursionLimit, @@ -28,11 +38,20 @@ const { createMultiAgentMapper, filterMalformedContentParts, countFormattedMessageTokens, + prependFileContext, + prependQuotes, hydrateMissingIndexTokenCounts, injectSkillPrimes, + collectFreshSkillPrimeNames, isSkillPrimeMessage, + collectFileIds, + processTextWithTokenLimit, + buildAgentScopedContext, buildSkillPrimeContentParts, buildInitialToolSessions, + hasUrlContextTool, + appendYouTubeVideoParts, + resolveYouTubeInjectionConfig, } = require('@librechat/api'); const { Callback, @@ -44,6 +63,7 @@ const { } = require('@librechat/agents'); const { Constants, + UsageEvents, Permissions, VisionModes, ContentTypes, @@ -53,6 +73,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'); @@ -65,6 +86,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); @@ -78,6 +101,14 @@ class AgentClient extends BaseClient { /** @type {AgentRun} */ this.run; + /** Resolves with the agent run once `chatCompletion` initializes it (or + * `null` if initialization fails), letting immediate-mode title generation + * await the run instead of throwing when fired before the run exists. + * @type {Promise | null} */ + this._runReady = null; + /** @type {((run: AgentRun | null) => void) | null} */ + this._resolveRun = null; + const { agentConfigs, contentParts, @@ -86,11 +117,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} */ @@ -109,6 +151,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} */ @@ -123,6 +170,8 @@ class AgentClient extends BaseClient { this.usage; /** @type {Record} */ this.indexTokenCountMap = {}; + /** @type {Array> | null} */ + this.memoryPayload = null; /** @type {(messages: BaseMessage[]) => Promise} */ this.processMemory; } @@ -197,6 +246,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, @@ -270,10 +320,15 @@ class AgentClient extends BaseClient { })) : []), ]; + const sharedRunAttachmentIds = new Set(); if (this.options.attachments) { const attachments = await this.options.attachments; const latestMessage = orderedMessages[orderedMessages.length - 1]; + for (const fileId of collectFileIds(attachments)) { + sharedRunAttachmentIds.add(fileId); + } + if (this.message_file_map) { this.message_file_map[latestMessage.messageId] = attachments; } else { @@ -297,39 +352,71 @@ 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); + } + + /** + * Durably re-merge quoted excerpts into every user turn that carries them + * (current and historical) so the model receives the referenced context on + * every prompt and the token count matches what was persisted. Applied to + * the memory copy too so the canonical per-message count includes them. + */ + if (Array.isArray(message.quotes) && message.quotes.length > 0) { + prependQuotes(formattedMessage, message.quotes); + prependQuotes(memoryFormattedMessage, message.quotes); } - 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; + /** + * Force a recount when the message carries quotes: a plain text-only + * "Save" edit recomputes `tokenCount` from `text` alone while leaving + * `message.quotes` persisted, so the stored count would undercount the + * quote block this turn prepends. Recounting from the quote-merged memory + * copy keeps context accounting accurate (and self-heals stale counts). + */ + const needsCanonicalTokenCount = + !hasDbTokenCount || + (this.isVisionModel && (message.image_urls || message.files)) || + (Array.isArray(message.quotes) && message.quotes.length > 0); + + 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]; @@ -344,13 +431,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)) { @@ -359,32 +452,62 @@ 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}`, ); } return formattedMessage; }); + /** + * Native YouTube -> video understanding: when Google `url_context` is enabled + * (resolved to the native `urlContext` provider tool), inject any YouTube URLs + * from the latest user turn as Gemini `fileData` video parts. The URL Context + * tool cannot read YouTube, so this routes those links through the video path + * while other URLs still flow through `urlContext`. Done after token counting + * (video tokens are reported by the provider) and only on the LLM payload, so + * the memory copy and persisted message are untouched. + */ + const latestOrdered = orderedMessages[orderedMessages.length - 1]; + const provider = this.options.agent?.provider; + if ( + latestOrdered?.isCreatedByUser === true && + (provider === Providers.GOOGLE || provider === Providers.VERTEXAI) && + hasUrlContextTool(this.options.agent?.tools) + ) { + const latestFormatted = formattedMessages[formattedMessages.length - 1]; + /** Use the resolved run model (model_parameters override) rather than the saved base model. */ + const resolvedModel = + this.options.agent?.model_parameters?.model ?? this.options.agent?.model; + const { max, mimeType } = resolveYouTubeInjectionConfig({ + provider, + model: resolvedModel, + }); + latestFormatted.content = appendYouTubeVideoParts({ + enabled: true, + text: latestOrdered.text, + content: latestFormatted.content, + max, + mimeType, + }); + } + 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(); @@ -402,8 +525,16 @@ class AgentClient extends BaseClient { const sharedRunContext = sharedRunContextParts.join('\n\n'); const memoryAgentEnabled = isMemoryAgentEnabled(this.options.req.config?.memory); - /** Preserve canonical pre-format token counts for all history entering graph formatting */ - this.indexTokenCountMap = canonicalTokenCountMap; + const agentScopedContext = await buildAgentScopedContext({ + agentIds: allAgents.map(({ agentId }) => agentId), + attachmentsByAgentId: this.options.agentContextAttachmentsByAgentId, + sharedRunAttachmentIds, + req: this.options.req, + tokenCountFn: (text) => countTokens(text), + }); + + /** 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. */ @@ -439,10 +570,14 @@ class AgentClient extends BaseClient { await Promise.all( allAgents.map(({ agent, agentId }) => { - const agentRunContext = - memoryContext && (agentId === this.options.agent.id || memoryAgentEnabled) - ? [sharedRunContext, memoryContext].filter(Boolean).join('\n\n') - : sharedRunContext; + const agentRunContextParts = [sharedRunContext]; + if (memoryContext && (agentId === this.options.agent.id || memoryAgentEnabled)) { + agentRunContextParts.push(memoryContext); + } + const scopedContext = agentScopedContext.get(agentId); + if (scopedContext) { + agentRunContextParts.push(scopedContext); + } return applyContextToAgent({ agent, @@ -450,7 +585,7 @@ class AgentClient extends BaseClient { logger, mcpManager, configServers, - sharedRunContext: agentRunContext, + sharedRunContext: agentRunContextParts.filter(Boolean).join('\n\n'), ephemeralAgent: agentId === this.options.agent.id ? ephemeralAgent : undefined, }); }), @@ -712,7 +847,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); @@ -729,11 +901,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); + } + /** 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 { completion, metadata: { thoughtSignatures: signatures } }; + 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, + }); } /** @@ -768,6 +1029,7 @@ class AgentClient extends BaseClient { balance, transactions, endpointTokenConfig: this.options.endpointTokenConfig, + resolveEndpointTokenConfig: (usage) => this.resolveAgentEndpointTokenConfig(usage), }, ); @@ -784,6 +1046,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} @@ -856,12 +1196,53 @@ class AgentClient extends BaseClient { agents: [this.options.agent, ...(this.agentConfigs ? this.agentConfigs.values() : [])], }); + /** + * Reconstruct `reasoning_content` on prior tool-call turns: DeepSeek + * thinking-mode (#13366) or custom endpoints opting in via + * `customParams.includeReasoningHistory` (e.g. Xiaomi MiMo, Kimi). + * Walks subagents too — the opted-in endpoint may appear only as a + * nested subagent, not the primary or a top-level handoff agent. + */ + const needsReasoningContentFormat = anyAgentReplaysReasoningContent([ + this.options.agent, + ...(this.agentConfigs ? Array.from(this.agentConfigs.values()) : []), + ]); + /** + * 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 = + needsReasoningContentFormat || freshSkillPrimeNames.size > 0 + ? { + ...(needsReasoningContentFormat ? { preserveReasoningContent: true } : {}), + ...(freshSkillPrimeNames.size > 0 + ? { skipSkillBodyNames: freshSkillPrimeNames } + : {}), + } + : undefined; let { messages: initialMessages, indexTokenCountMap, summary: initialSummary, boundaryTokenAdjustment, - } = formatAgentMessages(payload, this.indexTokenCountMap, toolSet, skillPrimeResult?.skills); + } = formatAgentMessages( + payload, + this.indexTokenCountMap, + toolSet, + skillPrimeResult?.skills, + formatOptions, + ); if (boundaryTokenAdjustment) { logger.debug( `[AgentClient] Boundary token adjustment: ${boundaryTokenAdjustment.original} → ${boundaryTokenAdjustment.adjusted} (${boundaryTokenAdjustment.remainingChars}/${boundaryTokenAdjustment.totalChars} chars)`, @@ -880,9 +1261,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) @@ -925,6 +1308,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 */ @@ -965,7 +1359,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 */ @@ -993,9 +1387,21 @@ class AgentClient extends BaseClient { customHandlers: this.options.eventHandlers, requestBody: config.configurable.requestBody, user: createSafeUser(this.options.req?.user), + tenantId: this.options.req?.user?.tenantId, 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) { @@ -1003,6 +1409,10 @@ class AgentClient extends BaseClient { } this.run = run; + if (this._resolveRun) { + this._resolveRun(run); + this._resolveRun = null; + } const streamId = this.options.req?._resumableStreamId; if (streamId && run.Graph) { @@ -1079,11 +1489,12 @@ class AgentClient extends BaseClient { }); } } catch (err) { - logger.error( - '[api/server/controllers/agents/client.js #sendCompletion] Operation aborted', - err, - ); - if (!abortController.signal.aborted) { + if (abortController.signal.aborted) { + logger.debug( + '[api/server/controllers/agents/client.js #sendCompletion] Operation aborted by user', + { conversationId: this.conversationId, name: err?.name, code: err?.code }, + ); + } else { logger.error( '[api/server/controllers/agents/client.js #sendCompletion] Unhandled error type', err, @@ -1108,6 +1519,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) { @@ -1134,6 +1553,10 @@ class AgentClient extends BaseClient { err, ); } + if (this._resolveRun) { + this._resolveRun(this.run ?? null); + this._resolveRun = null; + } run = null; config = null; memoryPromise = null; @@ -1141,14 +1564,58 @@ class AgentClient extends BaseClient { } /** - * + * Resolves with the agent run once it is initialized, or `null` if + * initialization fails. Lets immediate-mode title generation await the run + * instead of throwing when fired before `chatCompletion` assigns `this.run`. + * Rejects promptly if the provided signal aborts before the run is ready. + * @param {AbortSignal} [signal] + * @returns {Promise} + */ + _waitForRun(signal) { + if (this.run) { + return Promise.resolve(this.run); + } + if (!this._runReady) { + this._runReady = new Promise((resolve) => { + this._resolveRun = resolve; + }); + } + if (!signal) { + return this._runReady; + } + if (signal.aborted) { + return Promise.reject(new Error('Aborted before run initialization')); + } + return new Promise((resolve, reject) => { + const onAbort = () => reject(new Error('Aborted before run initialization')); + signal.addEventListener('abort', onAbort, { once: true }); + this._runReady.then((run) => { + signal.removeEventListener('abort', onAbort); + resolve(run); + }); + }); + } + + /** * @param {Object} params * @param {string} params.text - * @param {string} params.conversationId + * @param {AbortController} params.abortController + * @param {boolean} [params.immediate] When true, the title is generated as soon + * as the request is made — the run is awaited (instead of throwing) and the + * title derives from the user's input only (`contentParts` is empty). */ - async titleConvo({ text, abortController }) { + async titleConvo({ text, abortController, immediate = false }) { if (!this.run) { - throw new Error('Run not initialized'); + if (!immediate) { + throw new Error('Run not initialized'); + } + await this._waitForRun(abortController?.signal); + if (!this.run) { + logger.debug( + '[api/server/controllers/agents/client.js #titleConvo] Run unavailable for immediate title generation', + ); + return; + } } const { handleLLMEnd, collected: collectedMetadata } = createMetadataAggregator(); const { req, agent } = this.options; @@ -1254,12 +1721,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 || @@ -1268,27 +1748,26 @@ 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({ provider, clientOptions, inputText: text, - contentParts: this.contentParts, + contentParts: immediate ? [] : this.contentParts, titleMethod: endpointConfig?.titleMethod, titlePrompt: endpointConfig?.titlePrompt, titlePromptTemplate: endpointConfig?.titlePromptTemplate, diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index 31bd5227d5f..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', () => ({ @@ -13,6 +13,8 @@ jest.mock('@librechat/agents', () => ({ jest.mock('@librechat/api', () => ({ ...jest.requireActual('@librechat/api'), checkAccess: jest.fn(), + countFormattedMessageTokens: jest.fn(() => 42), + countTokens: jest.fn((text) => Math.ceil(String(text ?? '').length / 4)), initializeAgent: jest.fn(), createMemoryProcessor: jest.fn(), isMemoryAgentEnabled: jest.fn((config) => { @@ -126,6 +128,52 @@ describe('AgentClient - titleConvo', () => { ).rejects.toThrow('Run not initialized'); }); + it('waits for the run in immediate mode instead of throwing', async () => { + client.run = null; + const abortController = new AbortController(); + + const titlePromise = client.titleConvo({ text: 'Test', abortController, immediate: true }); + + // Simulate `chatCompletion` assigning the run (client.js: `this.run = run`). + client.run = mockRun; + client._resolveRun(mockRun); + + await titlePromise; + expect(mockRun.generateTitle).toHaveBeenCalled(); + }); + + it('passes empty contentParts in immediate mode (title from the user input only)', async () => { + client.contentParts = [{ type: 'text', text: 'Streaming response so far' }]; + const abortController = new AbortController(); + + await client.titleConvo({ text: 'Hello there', abortController, immediate: true }); + + const call = mockRun.generateTitle.mock.calls[0][0]; + expect(call.contentParts).toEqual([]); + expect(call.inputText).toBe('Hello there'); + }); + + it('uses live contentParts in non-immediate (final) mode', async () => { + client.contentParts = [{ type: 'text', text: 'Full response' }]; + const abortController = new AbortController(); + + await client.titleConvo({ text: 'Hello there', abortController }); + + const call = mockRun.generateTitle.mock.calls[0][0]; + expect(call.contentParts).toEqual([{ type: 'text', text: 'Full response' }]); + }); + + it('rejects promptly when aborted before the run initializes in immediate mode', async () => { + client.run = null; + const abortController = new AbortController(); + abortController.abort(); + + await expect( + client.titleConvo({ text: 'Test', abortController, immediate: true }), + ).rejects.toThrow('Aborted before run initialization'); + expect(mockRun.generateTitle).not.toHaveBeenCalled(); + }); + it('should use titlePrompt from endpoint config', async () => { const text = 'Test conversation text'; const abortController = new AbortController(); @@ -177,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: {} }; @@ -1429,6 +1522,323 @@ describe('AgentClient - titleConvo', () => { }); }); + describe('buildMessages with request and agent-scoped context attachments', () => { + let client; + let mockReq; + let mockRes; + let mockAgent; + + const makeTextFile = (file_id, filename, text) => ({ + user: 'user-123', + file_id, + filename, + filepath: `/uploads/${filename}`, + object: 'file', + type: 'text/plain', + bytes: text.length, + embedded: false, + usage: 0, + source: 'text', + 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', + endpoint: EModelEndpoint.openAI, + provider: EModelEndpoint.openAI, + instructions: 'Primary instructions', + model_parameters: { + model: 'gpt-4', + }, + tools: [], + }; + + mockReq = { + user: { + id: 'user-123', + personalization: { + memories: true, + }, + }, + body: { + endpoint: EModelEndpoint.openAI, + fileTokenLimit: 1000, + }, + config: { + memory: { + disabled: true, + }, + }, + }; + mockRes = {}; + + client = new AgentClient({ + req: mockReq, + res: mockRes, + agent: mockAgent, + endpoint: EModelEndpoint.agents, + }); + client.conversationId = 'convo-123'; + client.responseMessageId = 'response-123'; + client.shouldSummarize = false; + client.maxContextTokens = 4096; + client.useMemory = jest.fn().mockResolvedValue(undefined); + }); + + 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', + 'primary.txt', + 'Primary private context', + ); + const handoffContext = makeTextFile( + 'handoff-context', + 'handoff.txt', + 'Handoff private context', + ); + const handoffAgent = { + id: 'handoff-agent', + endpoint: EModelEndpoint.openAI, + provider: EModelEndpoint.openAI, + instructions: 'Handoff instructions', + model_parameters: { + model: 'gpt-4', + }, + tools: [], + }; + + client.options.attachments = [requestFile]; + client.options.agentContextAttachmentsByAgentId = new Map([ + ['primary-agent', [primaryContext]], + ['handoff-agent', [handoffContext]], + ]); + client.agentConfigs = new Map([['handoff-agent', handoffAgent]]); + + const result = await client.buildMessages( + [ + { + messageId: 'msg-1', + parentMessageId: null, + sender: 'User', + text: 'Use the available context.', + isCreatedByUser: true, + }, + ], + 'msg-1', + {}, + ); + + 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('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'); + + client.options.attachments = [sharedFile]; + client.options.agentContextAttachmentsByAgentId = new Map([['primary-agent', [sharedFile]]]); + client.agentConfigs = new Map(); + + const result = await client.buildMessages( + [ + { + messageId: 'msg-1', + parentMessageId: null, + sender: 'User', + text: 'Use the available context.', + isCreatedByUser: true, + }, + ], + 'msg-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 () => { + const primaryContext = makeTextFile( + 'primary-context', + 'primary.txt', + 'Direct primary context', + ); + + client.options.agentContextAttachmentsByAgentId = new Map([ + ['primary-agent', [primaryContext]], + ]); + client.agentConfigs = new Map(); + + await client.buildMessages( + [ + { + messageId: 'msg-1', + parentMessageId: null, + sender: 'User', + text: 'Answer from your context.', + isCreatedByUser: true, + }, + ], + 'msg-1', + {}, + ); + + expect(mockAgent.additional_instructions).toContain('Direct primary context'); + }); + }); + describe('runMemory method', () => { let client; let mockReq; @@ -1645,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/filterAuthorizedTools.spec.js b/api/server/controllers/agents/filterAuthorizedTools.spec.js index e6b41aef161..89835fac069 100644 --- a/api/server/controllers/agents/filterAuthorizedTools.spec.js +++ b/api/server/controllers/agents/filterAuthorizedTools.spec.js @@ -1,12 +1,13 @@ const mongoose = require('mongoose'); const { v4: uuidv4 } = require('uuid'); -const { Constants } = require('librechat-data-provider'); +const { Constants, actionDelimiter } = require('librechat-data-provider'); const { agentSchema } = require('@librechat/data-schemas'); const { MongoMemoryServer } = require('mongodb-memory-server'); const d = Constants.mcp_delimiter; const mockGetAllServerConfigs = jest.fn(); +const mockUserCanUseMCPServers = jest.fn(); jest.mock('~/server/services/Config', () => ({ getCachedTools: jest.fn().mockResolvedValue({ @@ -24,6 +25,10 @@ jest.mock('~/config', () => ({ jest.mock('~/server/services/MCP', () => ({ resolveConfigServers: jest.fn().mockResolvedValue({}), + createMCPPermissionContext: jest.fn((req) => ({ + canUseServers: (user) => mockUserCanUseMCPServers(user, req), + })), + userCanUseMCPServers: (...args) => mockUserCanUseMCPServers(...args), })); jest.mock('~/server/services/Files/strategies', () => ({ @@ -106,6 +111,7 @@ describe('MCP Tool Authorization', () => { authorizedServer: { type: 'sse', url: 'https://authorized.example.com' }, anotherServer: { type: 'sse', url: 'https://another.example.com' }, }); + mockUserCanUseMCPServers.mockResolvedValue(true); mockReq = { user: { @@ -127,11 +133,13 @@ describe('MCP Tool Authorization', () => { describe('filterAuthorizedTools', () => { const availableTools = { web_search: true, custom_tool: true }; const userId = 'test-user-123'; + const testUser = { id: userId, role: 'USER' }; test('should keep authorized MCP tools and strip unauthorized ones', async () => { const result = await filterAuthorizedTools({ tools: [`toolA${d}authorizedServer`, `toolB${d}forbiddenServer`, 'web_search'], userId, + user: testUser, availableTools, }); @@ -140,6 +148,39 @@ describe('MCP Tool Authorization', () => { expect(result).not.toContain(`toolB${d}forbiddenServer`); }); + test('should strip MCP tools when user lacks MCP server use permission', async () => { + mockUserCanUseMCPServers.mockResolvedValue(false); + + const result = await filterAuthorizedTools({ + tools: [ + `toolA${d}authorizedServer`, + `${Constants.mcp_all}${d}authorizedServer`, + 'web_search', + ], + userId, + user: testUser, + availableTools, + }); + + expect(result).toEqual(['web_search']); + expect(mockUserCanUseMCPServers).toHaveBeenCalledWith({ id: userId, role: 'USER' }); + expect(mockGetAllServerConfigs).not.toHaveBeenCalled(); + }); + + test('should strip MCP tools when user context is missing', async () => { + mockUserCanUseMCPServers.mockResolvedValueOnce(false); + + const result = await filterAuthorizedTools({ + tools: [`toolA${d}authorizedServer`, 'web_search'], + userId, + availableTools, + }); + + expect(result).toEqual(['web_search']); + expect(mockUserCanUseMCPServers).toHaveBeenCalledWith(undefined); + expect(mockGetAllServerConfigs).not.toHaveBeenCalled(); + }); + test('should keep system tools without querying MCP registry', async () => { const result = await filterAuthorizedTools({ tools: ['execute_code', 'file_search', 'web_search'], @@ -170,6 +211,7 @@ describe('MCP Tool Authorization', () => { const result = await filterAuthorizedTools({ tools: [`toolA${d}someServer`, 'web_search'], userId, + user: testUser, availableTools, }); @@ -188,6 +230,7 @@ describe('MCP Tool Authorization', () => { `steal${d}nonexistent`, ], userId, + user: testUser, availableTools, }); @@ -224,6 +267,7 @@ describe('MCP Tool Authorization', () => { await filterAuthorizedTools({ tools: [`tool${d}authorizedServer`], userId: 'specific-user-id', + user: { id: 'specific-user-id', role: 'USER' }, availableTools, }); @@ -241,6 +285,7 @@ describe('MCP Tool Authorization', () => { const result = await filterAuthorizedTools({ tools: [`tool${d}config-override-server`, `tool${d}unauthorizedServer`], userId, + user: testUser, availableTools, configServers, }); @@ -254,6 +299,7 @@ describe('MCP Tool Authorization', () => { await filterAuthorizedTools({ tools: [`tool1${d}authorizedServer`, `tool2${d}anotherServer`, `tool3${d}unknownServer`], userId, + user: testUser, availableTools, }); @@ -270,6 +316,7 @@ describe('MCP Tool Authorization', () => { const result = await filterAuthorizedTools({ tools: [...existingTools, `newTool${d}unknownServer`, 'web_search'], userId, + user: testUser, availableTools, existingTools, }); @@ -288,6 +335,7 @@ describe('MCP Tool Authorization', () => { const result = await filterAuthorizedTools({ tools: [`toolA${d}serverA`, 'web_search'], userId, + user: testUser, availableTools, }); @@ -303,6 +351,7 @@ describe('MCP Tool Authorization', () => { const result = await filterAuthorizedTools({ tools: [malformedTool, `legit${d}serverA`, 'web_search'], userId, + user: testUser, availableTools, existingTools: [malformedTool, `legit${d}serverA`], }); @@ -312,6 +361,43 @@ describe('MCP Tool Authorization', () => { expect(result).not.toContain(malformedTool); }); + test('should gate app-level MCP tools present in the global tool cache', async () => { + const appMcpTool = `appTool${d}authorizedServer`; + const forbiddenAppMcpTool = `appTool${d}forbiddenServer`; + const cacheWithMCPTools = { + ...availableTools, + [appMcpTool]: true, + [forbiddenAppMcpTool]: true, + }; + + const result = await filterAuthorizedTools({ + tools: [appMcpTool, forbiddenAppMcpTool, 'web_search'], + userId, + user: testUser, + availableTools: cacheWithMCPTools, + }); + + expect(result).toContain(appMcpTool); + expect(result).toContain('web_search'); + expect(result).not.toContain(forbiddenAppMcpTool); + }); + + test('should strip app-level MCP tools from the cache when user lacks MCP server use permission', async () => { + mockUserCanUseMCPServers.mockResolvedValue(false); + const appMcpTool = `appTool${d}authorizedServer`; + const cacheWithMCPTools = { ...availableTools, [appMcpTool]: true }; + + const result = await filterAuthorizedTools({ + tools: [appMcpTool, 'web_search'], + userId, + user: testUser, + availableTools: cacheWithMCPTools, + }); + + expect(result).toEqual(['web_search']); + expect(mockGetAllServerConfigs).not.toHaveBeenCalled(); + }); + test('should reject malformed MCP tool keys with multiple delimiters', async () => { const result = await filterAuthorizedTools({ tools: [ @@ -321,6 +407,7 @@ describe('MCP Tool Authorization', () => { 'web_search', ], userId, + user: testUser, availableTools, }); @@ -348,6 +435,27 @@ describe('MCP Tool Authorization', () => { expect(agent.tools).not.toContain(`attack${d}forbiddenServer`); }); + test('should strip all MCP tools on create when user lacks MCP server use permission', async () => { + mockUserCanUseMCPServers.mockResolvedValue(false); + mockReq.body = { + provider: 'openai', + model: 'gpt-4', + name: 'MCP Denied Test Agent', + tools: [ + 'web_search', + `validTool${d}authorizedServer`, + `${Constants.mcp_all}${d}authorizedServer`, + ], + }; + + await createAgentHandler(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(201); + const agent = mockRes.json.mock.calls[0][0]; + expect(agent.tools).toEqual(['web_search']); + expect(agent.mcpServerNames).toEqual([]); + }); + test('should not 500 when MCP registry is uninitialized', async () => { getMCPServersRegistry.mockImplementation(() => { throw new Error('MCPServersRegistry has not been initialized.'); @@ -446,6 +554,129 @@ describe('MCP Tool Authorization', () => { expect(updatedAgent.tools).not.toContain(`attack${d}forbiddenServer`); }); + test('should strip all MCP tools, including retained ones, when user lacks MCP server use permission', async () => { + mockUserCanUseMCPServers.mockResolvedValue(false); + mockReq.user.id = existingAgentAuthorId.toString(); + mockReq.params.id = existingAgentId; + mockReq.body = { + tools: ['web_search', `existingTool${d}authorizedServer`, `newTool${d}anotherServer`], + }; + + await updateAgentHandler(mockReq, mockRes); + + expect(mockRes.json).toHaveBeenCalled(); + const updatedAgent = mockRes.json.mock.calls[0][0]; + // Permission revoked: update must not preserve stale MCP bindings, matching + // the create/duplicate/revert paths. + expect(updatedAgent.tools).toEqual(['web_search']); + expect(mockGetAllServerConfigs).not.toHaveBeenCalled(); + }); + + test('should strip retained MCP tools on an unrelated owner edit after permission revocation', async () => { + mockUserCanUseMCPServers.mockResolvedValue(false); + mockReq.user.id = existingAgentAuthorId.toString(); + mockReq.params.id = existingAgentId; + mockReq.body = { + name: 'Renamed After Revocation', + }; + + await updateAgentHandler(mockReq, mockRes); + + expect(mockRes.json).toHaveBeenCalled(); + const updatedAgent = mockRes.json.mock.calls[0][0]; + expect(updatedAgent.tools).toEqual(['web_search']); + expect(updatedAgent.name).toBe('Renamed After Revocation'); + }); + + test('should not strip shared agent MCP tools on unrelated editor changes after revocation', async () => { + mockUserCanUseMCPServers.mockResolvedValue(false); + mockReq.user.id = new mongoose.Types.ObjectId().toString(); + mockReq.params.id = existingAgentId; + mockReq.body = { + name: 'Shared Rename After Revocation', + }; + + await updateAgentHandler(mockReq, mockRes); + + expect(mockRes.json).toHaveBeenCalled(); + const updatedAgent = mockRes.json.mock.calls[0][0]; + const agentInDb = await Agent.findOne({ id: existingAgentId }); + expect(updatedAgent.tools).toContain(`existingTool${d}authorizedServer`); + expect(updatedAgent.name).toBe('Shared Rename After Revocation'); + expect(agentInDb.tools).toContain(`existingTool${d}authorizedServer`); + expect(agentInDb.mcpServerNames).toEqual(['authorizedServer']); + }); + + test('should not strip shared agent MCP tools on frontend-style full tools save after revocation', async () => { + mockUserCanUseMCPServers.mockResolvedValue(false); + mockReq.user.id = new mongoose.Types.ObjectId().toString(); + mockReq.params.id = existingAgentId; + mockReq.body = { + name: 'Shared Full Save After Revocation', + tools: ['web_search', `existingTool${d}authorizedServer`], + }; + + await updateAgentHandler(mockReq, mockRes); + + expect(mockRes.json).toHaveBeenCalled(); + const updatedAgent = mockRes.json.mock.calls[0][0]; + const agentInDb = await Agent.findOne({ id: existingAgentId }); + expect(updatedAgent.tools).toContain(`existingTool${d}authorizedServer`); + expect(updatedAgent.name).toBe('Shared Full Save After Revocation'); + expect(agentInDb.tools).toContain(`existingTool${d}authorizedServer`); + expect(agentInDb.mcpServerNames).toEqual(['authorizedServer']); + expect(mockGetAllServerConfigs).not.toHaveBeenCalled(); + }); + + test('should reject new shared-agent MCP tools after revocation while retaining existing MCP tools', async () => { + mockUserCanUseMCPServers.mockResolvedValue(false); + mockReq.user.id = new mongoose.Types.ObjectId().toString(); + mockReq.params.id = existingAgentId; + mockReq.body = { + tools: ['web_search', `existingTool${d}authorizedServer`, `newTool${d}anotherServer`], + }; + + await updateAgentHandler(mockReq, mockRes); + + expect(mockRes.json).toHaveBeenCalled(); + const updatedAgent = mockRes.json.mock.calls[0][0]; + const agentInDb = await Agent.findOne({ id: existingAgentId }); + expect(updatedAgent.tools).toContain(`existingTool${d}authorizedServer`); + expect(updatedAgent.tools).not.toContain(`newTool${d}anotherServer`); + expect(agentInDb.tools).toContain(`existingTool${d}authorizedServer`); + expect(agentInDb.tools).not.toContain(`newTool${d}anotherServer`); + expect(agentInDb.mcpServerNames).toEqual(['authorizedServer']); + expect(mockGetAllServerConfigs).not.toHaveBeenCalled(); + }); + + test('should not strip action tools whose operationId contains the MCP delimiter on revocation', async () => { + // `sync_mcp_state_action_...` contains the `_mcp_` substring but is a + // genuine OpenAPI action tool (isActionTool === true). Losing + // MCP_SERVERS.USE must not drop it — action use is unrelated to MCP. + const actionTool = `sync_mcp_state${actionDelimiter}api---example---com`; + await Agent.updateOne( + { id: existingAgentId }, + { $set: { tools: ['web_search', actionTool] } }, + ); + + mockUserCanUseMCPServers.mockResolvedValue(false); + mockReq.user.id = existingAgentAuthorId.toString(); + mockReq.params.id = existingAgentId; + mockReq.body = { + name: 'Edited Without MCP Permission', + tools: ['web_search', actionTool], + }; + + await updateAgentHandler(mockReq, mockRes); + + expect(mockRes.json).toHaveBeenCalled(); + const updatedAgent = mockRes.json.mock.calls[0][0]; + const agentInDb = await Agent.findOne({ id: existingAgentId }); + expect(updatedAgent.tools).toContain(actionTool); + expect(updatedAgent.tools).toContain('web_search'); + expect(agentInDb.mcpServerNames).toEqual([]); + }); + test('should allow adding authorized MCP tools', async () => { mockReq.user.id = existingAgentAuthorId.toString(); mockReq.params.id = existingAgentId; diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index 7d2395b09f1..9000c247087 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,10 @@ const OpenAIChatCompletionController = async (req, res) => { conversationId, }, user: { id: userId }, + tenantId: req.user?.tenantId, + /** 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 51ac9a4885c..0b8c0cee1ae 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -1,18 +1,26 @@ 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, + getReferencedQuotes, + 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) { @@ -74,6 +82,92 @@ 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, quotes }, conversationId) { + if (typeof messageId !== 'string' || messageId.length === 0) { + return null; + } + + /** + * Seed normalized quotes here too: if the user aborts before `sendMessage` + * reaches `onStart` (during init/tool loading), `abortMiddleware` falls back + * to this preliminary metadata, which must carry the excerpts so the stopped + * turn keeps its `MessageQuotes`. + */ + const referencedQuotes = getReferencedQuotes(quotes); + + return { + messageId, + parentMessageId, + conversationId, + text, + ...(referencedQuotes != null && { quotes: referencedQuotes }), + }; +} + +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. @@ -93,6 +187,23 @@ 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). + * Resolved from the agent's actual endpoint once the client is initialized. */ + let titleTiming = 'immediate'; + const { allowed, pendingRequests, limit } = await checkAndIncrementPendingRequest(userId); if (!allowed) { const violationInfo = getViolationInfo(pendingRequests, limit); @@ -120,6 +231,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 @@ -127,6 +239,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. @@ -148,6 +273,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'); @@ -163,13 +294,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) { @@ -187,7 +319,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); @@ -207,12 +339,19 @@ 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; } client = result.client; + // Resolve title timing from the public agents endpoint first, then fall + // back to the agent's actual backing provider/custom endpoint. + titleTiming = resolveTitleTiming({ + appConfig: req.config, + endpoint: [endpointOption?.endpoint, client?.options?.agent?.endpoint], + }); + if (client?.sender) { GenerationJobManager.updateMetadata(streamId, { sender: client.sender }); } @@ -243,6 +382,61 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit ); } + /** Immediate-mode title generation runs in parallel with the response, so + * the conversation row may not exist when the title resolves. `convoReady` + * resolves once the response (and thus the conversation) has been saved, + * gating the title's `saveConvo`. Declared here so both the success tail + * and the catch block can settle it and gate `disposeClient` on the title. */ + let immediateTitlePromise = null; + let titleEventPromise = null; + let acceptsTitleEvents = true; + let resolveConvoReady; + const convoReady = new Promise((resolve) => { + resolveConvoReady = resolve; + }); + /** Dedicated controller so a user Stop (or a replaced stream) cancels the + * in-flight title — kept separate from `job.abortController`, which + * `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(); + } else { + job.abortController.signal.addEventListener('abort', abortTitleOnJobAbort, { once: true }); + } + const titleEligible = + addTitle && parentMessageId === Constants.NO_PARENT && isNewConvo && !req.body?.isTemporary; + const emitTitleEvent = ({ conversationId: titleConversationId, title }) => { + titleEventPromise = (async () => { + if (!acceptsTitleEvents || titleAbortController.signal.aborted) { + return; + } + const currentJob = await GenerationJobManager.getJob(streamId); + if (!currentJob || currentJob.createdAt !== jobCreatedAt) { + return; + } + if (titleAbortController.signal.aborted) { + return; + } + await GenerationJobManager.emitChunk(streamId, { + event: 'title', + data: { + conversationId: titleConversationId, + title, + }, + }); + })().catch((err) => { + logger.error('[ResumableAgentController] Error emitting title event', err); + }); + return titleEventPromise; + }; + try { const onStart = (userMsg, respMsgId, _isNewConvo) => { userMessage = userMsg; @@ -255,6 +449,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit parentMessageId: userMsg.parentMessageId, conversationId: userMsg.conversationId, text: userMsg.text, + quotes: userMsg.quotes, }, }); @@ -289,7 +484,24 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit }, }; - const response = await client.sendMessage(text, messageOptions); + const sendPromise = client.sendMessage(text, messageOptions); + + if (titleEligible && titleTiming === 'immediate') { + immediateTitlePromise = addTitle(req, { + text, + conversationId, + client, + immediate: true, + convoReady, + signal: titleAbortController.signal, + discardSignal: titleDiscardController.signal, + onTitleGenerated: emitTitleEvent, + }).catch((err) => { + logger.error('[ResumableAgentController] Error in immediate title generation', err); + }); + } + + const response = await sendPromise; const messageId = response.messageId; const endpoint = endpointOption.endpoint; @@ -355,11 +567,46 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit originalCreatedAt: jobCreatedAt, currentCreatedAt: currentJob?.createdAt, }); + // Discard the stale title from this replaced stream: cancel it and + // 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) { + disposeClient(client); + } + }); + } else if (client) { + disposeClient(client); + } return; } + // If the user stopped this turn, cancel the title BEFORE unblocking its + // persistence wait — otherwise resolving `convoReady` lets the title task + // resume and save before the later abort runs. + if (wasAbortedBeforeComplete) { + titleAbortController.abort(); + } else { + job.abortController.signal.removeEventListener('abort', abortTitleOnJobAbort); + } + + // The conversation row now exists and this stream is authoritative; allow + // any in-flight immediate title generation to persist (saveConvo uses noUpsert). + resolveConvoReady(); + acceptsTitleEvents = false; + + if (titleEventPromise) { + await titleEventPromise; + } + if (!wasAbortedBeforeComplete) { const finalEvent = { final: true, @@ -379,7 +626,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, @@ -399,10 +646,23 @@ 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 (shouldGenerateTitle) { + if (titleTiming === 'immediate') { + // Title was fired in parallel above (if eligible); a stopped turn already + // aborted it before `resolveConvoReady`. Defer disposal until it settles + // so the run/req aren't torn down mid-generation. + if (immediateTitlePromise) { + immediateTitlePromise.finally(() => { + if (client) { + disposeClient(client); + } + }); + } else if (client) { + disposeClient(client); + } + } else if (shouldGenerateTitle) { addTitle(req, { text, response: { ...response }, @@ -422,6 +682,16 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit } } } catch (error) { + // Any failure (user Stop, or a preflight/quota failure before the run is + // even created) must cancel the title and unblock its waits: the title's + // `_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(); + // Check if this was an abort (not a real error) const wasAborted = job.abortController.signal.aborted || error.message?.includes('abort'); @@ -434,9 +704,16 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit GenerationJobManager.completeJob(streamId, error.message); } - await decrementPendingRequest(userId); + await finishResumableRequest(req, userId); - if (client) { + // Defer disposal until any immediate title settles (it holds the run/req). + if (immediateTitlePromise) { + immediateTitlePromise.finally(() => { + if (client) { + disposeClient(client); + } + }); + } else if (client) { disposeClient(client); } @@ -451,7 +728,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); @@ -462,7 +739,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); } @@ -510,6 +787,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 @@ -616,8 +904,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, }); @@ -653,6 +941,7 @@ const _LegacyAgentController = async (req, res, next, initializeClient, addTitle parentMessageId: userMsg.parentMessageId, conversationId, text: userMsg.text, + quotes: userMsg.quotes, }, }); }; diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index b2805fc19fc..c88545c3e6b 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -11,19 +11,24 @@ const { } = require('librechat-data-provider'); const { createRun, + applyContextToAgent, buildToolSet, - loadSkillStates, - resolveAgentScopedSkillIds, + buildAgentScopedContext, + buildAgentContextAttachmentsByAgentId, createSafeUser, initializeAgent, + loadSkillStates, getBalanceConfig, + injectSkillPrimes, + extractManualSkills, recordCollectedUsage, + createSubagentUsageSink, getTransactionsConfig, - extractManualSkills, - injectSkillPrimes, - createToolExecuteHandler, + findPiiMatchInMessages, discoverConnectedAgents, + createToolExecuteHandler, getRemoteAgentPermissions, + resolveAgentScopedSkillIds, // Responses API writeDone, buildResponse, @@ -56,10 +61,15 @@ 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 { resolveConfigServers } = require('~/server/services/MCP'); +const { getMCPManager } = require('~/config'); const { logViolation } = require('~/cache'); const db = require('~/models'); @@ -350,6 +360,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 +384,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 +395,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 +425,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 +450,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 +473,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 +512,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 +556,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, }, @@ -516,6 +567,29 @@ const createResponse = async (req, res) => { const runAgents = [primaryConfig, ...handoffAgentConfigs.values()]; const mergedMCPAuthMap = discoveredMCPAuthMap ?? primaryConfig.userMCPAuthMap; + const agentContextAttachmentsByAgentId = buildAgentContextAttachmentsByAgentId(runAgents); + const agentScopedContext = await buildAgentScopedContext({ + agentIds: runAgents.map(({ id }) => id), + attachmentsByAgentId: agentContextAttachmentsByAgentId, + req, + }); + + const mcpManager = getMCPManager(); + const configServers = await resolveConfigServers(req); + + await Promise.all( + runAgents.map((runAgent) => + applyContextToAgent({ + agent: runAgent, + agentId: runAgent.id, + logger, + mcpManager, + configServers, + sharedRunContext: agentScopedContext.get(runAgent.id) ?? '', + }), + ), + ); + // Determine if streaming is enabled (check both request and agent config) const streamingDisabled = !!primaryConfig.model_parameters?.disableStreaming; const actuallyStreaming = isStreaming && !streamingDisabled; @@ -532,6 +606,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 +658,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 +714,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 +778,10 @@ const createResponse = async (req, res) => { conversationId, }, user: { id: userId }, + tenantId: req.user?.tenantId, + /** Bills subagent child-run model calls (reported outside the + * streamEvents loop) into the same collectedUsage array. */ + subagentUsageSink: createSubagentUsageSink(collectedUsage), }); if (!run) { @@ -811,17 +894,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 +956,10 @@ const createResponse = async (req, res) => { conversationId, }, user: { id: userId }, + tenantId: req.user?.tenantId, + /** 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 536044e5cce..ec1c6e62f2b 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -24,6 +24,7 @@ const { AccessRoleIds, PrincipalType, EToolResources, + isActionTool, PermissionBits, actionDelimiter, AgentCapabilities, @@ -42,7 +43,11 @@ const { resizeAvatar } = require('~/server/services/Files/images/avatar'); const { getFileStrategy } = require('~/server/utils/getFileStrategy'); const { filterFile } = require('~/server/services/Files/process'); const { getCachedTools } = require('~/server/services/Config'); -const { resolveConfigServers } = require('~/server/services/MCP'); +const { + createMCPPermissionContext, + resolveConfigServers, + userCanUseMCPServers, +} = require('~/server/services/MCP'); const { getMCPServersRegistry } = require('~/config'); const { getLogStores } = require('~/cache'); const db = require('~/models'); @@ -59,6 +64,38 @@ const getSafeModelParameters = (modelParameters) => { const { useResponsesApi } = modelParameters ?? {}; return typeof useResponsesApi === 'boolean' ? { useResponsesApi } : {}; }; +const hasEditBit = (permission) => (permission & PermissionBits.EDIT) === PermissionBits.EDIT; + +const sanitizeViewerSkillScope = (agent, accessibleSkillSet) => { + const skillScopeEnabled = agent.skills_enabled === true; + delete agent.skills_enabled; + + if (!skillScopeEnabled) { + delete agent.skills; + return agent; + } + + const configuredSkills = Array.isArray(agent.skills) ? agent.skills : []; + if (configuredSkills.length === 0) { + // Empty allowlist means the viewer's full accessible catalog. + delete agent.skills; + agent.skills_enabled = true; + return agent; + } + + const visibleSkills = configuredSkills + .map((skillId) => String(skillId)) + .filter((skillId) => accessibleSkillSet.has(skillId)); + + if (visibleSkills.length === 0) { + delete agent.skills; + return agent; + } + + agent.skills = visibleSkills; + agent.skills_enabled = true; + return agent; +}; /** * Looks up each referenced agent id in Mongo, splits them into three @@ -156,6 +193,9 @@ const isSubagentsCapabilityEnabled = (req) => { * @param {object} params * @param {string[]} params.tools - Raw tool strings from the request * @param {string} params.userId - Requesting user ID for MCP server access check + * @param {string} [params.role] - Requesting user's role for ACL principal resolution + * @param {object} [params.user] - Requesting user for MCP server use permission checks + * @param {{ canUseServers: (user?: object) => Promise }} [params.mcpPermissionContext] - Request-scoped MCP permission context * @param {Record} params.availableTools - Global non-MCP tool cache * @param {string[]} [params.existingTools] - Tools already persisted on the agent document * @param {Record} [params.configServers] - Config-source MCP servers resolved from appConfig overrides @@ -164,6 +204,9 @@ const isSubagentsCapabilityEnabled = (req) => { const filterAuthorizedTools = async ({ tools, userId, + role, + user, + mcpPermissionContext, availableTools, existingTools, configServers, @@ -172,21 +215,39 @@ const filterAuthorizedTools = async ({ let mcpServerConfigs; let registryUnavailable = false; const existingToolSet = existingTools?.length ? new Set(existingTools) : null; + const hasMCPTools = tools.some((tool) => tool?.includes(Constants.mcp_delimiter)); + const canUseMCP = hasMCPTools + ? await (mcpPermissionContext + ? mcpPermissionContext.canUseServers(user) + : userCanUseMCPServers(user)) + : true; + let loggedMCPDenied = false; for (const tool of tools) { - if (availableTools[tool] || systemTools[tool]) { - filteredTools.push(tool); + const isActionToolName = typeof tool === 'string' && isActionTool(tool); + const isMCPTool = tool?.includes(Constants.mcp_delimiter) && !isActionToolName; + + if (!isMCPTool) { + if (availableTools[tool] || systemTools[tool] || isActionToolName) { + filteredTools.push(tool); + } continue; } - if (!tool?.includes(Constants.mcp_delimiter)) { + if (!canUseMCP) { + if (!loggedMCPDenied) { + logger.warn(`[filterAuthorizedTools] User ${userId} lacks MCP server use permission`); + loggedMCPDenied = true; + } continue; } if (mcpServerConfigs === undefined) { try { mcpServerConfigs = - (await getMCPServersRegistry().getAllServerConfigs(userId, configServers)) ?? {}; + (role + ? await getMCPServersRegistry().getAllServerConfigs(userId, configServers, role) + : await getMCPServersRegistry().getAllServerConfigs(userId, configServers)) ?? {}; } catch (e) { logger.warn( '[filterAuthorizedTools] MCP registry unavailable, filtering all MCP tools', @@ -244,10 +305,14 @@ const pruneToolResourceFileIdsForOwner = async ({ tool_resources, ownerId, logPr const ownerIdStr = ownerId.toString(); try { - const ownerFiles = await db.getFiles({ file_id: { $in: referencedFileIds } }, null, { - file_id: 1, - user: 1, - }); + const ownerFiles = await db.getFiles( + { file_id: { $in: referencedFileIds }, user: ownerIdStr }, + null, + { + file_id: 1, + user: 1, + }, + ); const allowedIds = new Set( (ownerFiles ?? []) .filter((file) => file.user && file.user.toString() === ownerIdStr) @@ -351,9 +416,13 @@ const createAgentHandler = async (req, res) => { getCachedTools().then((t) => t ?? {}), hasMCPTools ? resolveConfigServers(req) : Promise.resolve(undefined), ]); + const mcpPermissionContext = createMCPPermissionContext(req); agentData.tools = await filterAuthorizedTools({ tools, userId, + role: req.user.role, + user: req.user, + mcpPermissionContext, availableTools, configServers, }); @@ -573,26 +642,55 @@ const updateAgentHandler = async (req, res) => { }); } - if (updateData.tools) { - const existingToolSet = new Set(existingAgent.tools ?? []); - const newMCPTools = updateData.tools.filter( - (t) => !existingToolSet.has(t) && t?.includes(Constants.mcp_delimiter), - ); - - if (newMCPTools.length > 0) { - const [availableTools, configServers] = await Promise.all([ - getCachedTools().then((t) => t ?? {}), - resolveConfigServers(req), - ]); - const approvedNew = await filterAuthorizedTools({ - tools: newMCPTools, - userId: req.user.id, - availableTools, - configServers, - }); - const rejectedSet = new Set(newMCPTools.filter((t) => !approvedNew.includes(t))); - if (rejectedSet.size > 0) { - updateData.tools = updateData.tools.filter((t) => !rejectedSet.has(t)); + const isMCPTool = (t) => + typeof t === 'string' && t.includes(Constants.mcp_delimiter) && !isActionTool(t); + const hasToolUpdate = updateData.tools !== undefined; + const editingOwnAgent = existingAgent.author?.toString() === req.user.id; + const existingTools = existingAgent.tools ?? []; + const effectiveTools = (hasToolUpdate ? updateData.tools : existingAgent.tools) ?? []; + const requestedMCPTools = effectiveTools.filter(isMCPTool); + const existingMCPTools = existingTools.filter(isMCPTool); + + if (requestedMCPTools.length > 0 || (hasToolUpdate && existingMCPTools.length > 0)) { + const mcpPermissionContext = createMCPPermissionContext(req); + if (!(await mcpPermissionContext.canUseServers(req.user))) { + if (editingOwnAgent) { + updateData.tools = effectiveTools.filter((t) => !isMCPTool(t)); + } else if (hasToolUpdate) { + const existingMCPToolSet = new Set(existingMCPTools); + const nextTools = updateData.tools.filter( + (t) => !isMCPTool(t) || existingMCPToolSet.has(t), + ); + const nextToolSet = new Set(nextTools); + for (const existingMCPTool of existingMCPTools) { + if (!nextToolSet.has(existingMCPTool)) { + nextTools.push(existingMCPTool); + } + } + updateData.tools = nextTools; + } + } else if (hasToolUpdate) { + const existingToolSet = new Set(existingTools); + const newMCPTools = requestedMCPTools.filter((t) => !existingToolSet.has(t)); + + if (newMCPTools.length > 0) { + const [availableTools, configServers] = await Promise.all([ + getCachedTools().then((t) => t ?? {}), + resolveConfigServers(req), + ]); + const approvedNew = await filterAuthorizedTools({ + tools: newMCPTools, + userId: req.user.id, + role: req.user.role, + user: req.user, + mcpPermissionContext, + availableTools, + configServers, + }); + const rejectedSet = new Set(newMCPTools.filter((t) => !approvedNew.includes(t))); + if (rejectedSet.size > 0) { + updateData.tools = updateData.tools.filter((t) => !rejectedSet.has(t)); + } } } } @@ -745,9 +843,13 @@ const duplicateAgentHandler = async (req, res) => { getCachedTools().then((t) => t ?? {}), resolveConfigServers(req), ]); + const mcpPermissionContext = createMCPPermissionContext(req); newAgentData.tools = await filterAuthorizedTools({ tools: newAgentData.tools, userId, + role: req.user.role, + user: req.user, + mcpPermissionContext, availableTools, existingTools: newAgentData.tools, configServers, @@ -838,7 +940,7 @@ const deleteAgentHandler = async (req, res) => { const getListAgentsHandler = async (req, res) => { try { const userId = req.user.id; - const { category, search, limit, cursor, promoted } = req.query; + const { category, search, limit = 100, cursor, promoted } = req.query; let requiredPermission = req.query.requiredPermission; if (typeof requiredPermission === 'string') { requiredPermission = parseInt(requiredPermission, 10); @@ -848,6 +950,7 @@ const getListAgentsHandler = async (req, res) => { } else if (typeof requiredPermission !== 'number') { requiredPermission = PermissionBits.VIEW; } + const canReturnSkillConfig = hasEditBit(requiredPermission); // Base filter const filter = {}; @@ -921,6 +1024,7 @@ const getListAgentsHandler = async (req, res) => { otherParams: filter, limit, after: cursor, + includeSkillConfig: true, }); const agents = data?.data ?? []; @@ -928,10 +1032,24 @@ const getListAgentsHandler = async (req, res) => { return res.json(data); } + let accessibleSkillSet = null; + if (!canReturnSkillConfig) { + const accessibleSkillIds = await findAccessibleResources({ + userId, + role: req.user.role, + resourceType: ResourceType.SKILL, + requiredPermissions: PermissionBits.VIEW, + }); + accessibleSkillSet = new Set(accessibleSkillIds.map((oid) => oid.toString())); + } + const publicSet = new Set(publiclyAccessibleIds.map((oid) => oid.toString())); const urlCache = cachedRefresh?.urlCache; data.data = agents.map((agent) => { + if (accessibleSkillSet) { + sanitizeViewerSkillScope(agent, accessibleSkillSet); + } try { if (agent?._id && publicSet.has(agent._id.toString())) { agent.isPublic = true; @@ -1103,9 +1221,13 @@ const revertAgentVersionHandler = async (req, res) => { getCachedTools().then((t) => t ?? {}), resolveConfigServers(req), ]); + const mcpPermissionContext = createMCPPermissionContext(req); const filteredTools = await filterAuthorizedTools({ tools: updatedAgent.tools, userId: req.user.id, + role: req.user.role, + user: req.user, + mcpPermissionContext, availableTools, existingTools: updatedAgent.tools, configServers, diff --git a/api/server/controllers/agents/v1.spec.js b/api/server/controllers/agents/v1.spec.js index 17904ad3fd6..fda2bdd6167 100644 --- a/api/server/controllers/agents/v1.spec.js +++ b/api/server/controllers/agents/v1.spec.js @@ -2,7 +2,7 @@ const mongoose = require('mongoose'); const { nanoid } = require('nanoid'); const { v4: uuidv4 } = require('uuid'); const { agentSchema, fileSchema } = require('@librechat/data-schemas'); -const { FileSources, PermissionBits } = require('librechat-data-provider'); +const { FileSources, PermissionBits, ResourceType } = require('librechat-data-provider'); const { MongoMemoryServer } = require('mongodb-memory-server'); // Only mock the dependencies that are not database-related @@ -1303,6 +1303,68 @@ describe('Agent Controllers - Mass Assignment Protection', () => { expect(response.data[0].name).toBe('Agent A1'); }); + test('should return only expected safe list fields for VIEW callers', async () => { + const hiddenSkillId = new mongoose.Types.ObjectId(); + await Agent.findByIdAndUpdate(agentA1._id, { + avatar: { filepath: '/avatars/a1.png', source: FileSources.local }, + category: 'general', + support_contact: { name: 'Support', email: 'support@example.com' }, + is_promoted: true, + instructions: 'private system instructions', + tools: ['execute_code'], + actions: ['example.com::action'], + model_parameters: { temperature: 0.7 }, + tool_resources: { file_search: { file_ids: ['file-1'] } }, + tool_options: { execute_code: { defer_loading: true } }, + subagents: { enabled: true, agent_ids: [agentA2.id] }, + edges: [{ from: agentA1.id, to: agentA2.id }], + skills_enabled: true, + skills: [hiddenSkillId.toString()], + }); + + 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]; + const agent = response.data[0]; + expect(Object.keys(agent).sort()).toEqual( + [ + '_id', + 'author', + 'avatar', + 'category', + 'description', + 'id', + 'is_promoted', + 'name', + 'support_contact', + 'updatedAt', + ].sort(), + ); + expect(agent).toEqual( + expect.objectContaining({ + id: agentA1.id, + name: 'Agent A1', + description: 'User A agent 1', + author: userA.toString(), + category: 'general', + is_promoted: true, + }), + ); + }); + test('should return multiple accessible agents', async () => { // User B has access to multiple agents mockReq.user.id = userB.toString(); @@ -1428,6 +1490,118 @@ describe('Agent Controllers - Mass Assignment Protection', () => { expect(response.data).toHaveLength(1); }); + test('should return only viewer-accessible skill scope for VIEW list callers', async () => { + const visibleSkillId = new mongoose.Types.ObjectId(); + const hiddenSkillId = new mongoose.Types.ObjectId(); + await Agent.findByIdAndUpdate(agentA1._id, { + skills_enabled: true, + skills: [visibleSkillId.toString(), hiddenSkillId.toString()], + }); + + 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([visibleSkillId]); + } + 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_enabled).toBe(true); + expect(response.data[0].skills).toEqual([visibleSkillId.toString()]); + expect(response.data[0].skills).not.toContain(hiddenSkillId.toString()); + }); + + test('should omit skill scope for VIEW list callers with no accessible configured skills', async () => { + const hiddenSkillId = new mongoose.Types.ObjectId(); + await Agent.findByIdAndUpdate(agentA1._id, { + skills_enabled: true, + skills: [hiddenSkillId.toString()], + }); + + 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).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(); + await Agent.findByIdAndUpdate(agentA1._id, { + skills_enabled: true, + skills: [visibleSkillId.toString(), hiddenSkillId.toString()], + }); + + mockReq.user.id = userB.toString(); + mockReq.query.requiredPermission = String(PermissionBits.EDIT); + findAccessibleResources.mockResolvedValue([agentA1._id]); + findPubliclyAccessibleResources.mockResolvedValue([]); + + await getListAgentsHandler(mockReq, mockRes); + + const response = mockRes.json.mock.calls[0][0]; + expect(response.data).toHaveLength(1); + expect(response.data[0].skills_enabled).toBe(true); + expect(response.data[0].skills).toEqual([ + visibleSkillId.toString(), + hiddenSkillId.toString(), + ]); + expect(findAccessibleResources).not.toHaveBeenCalledWith( + expect.objectContaining({ resourceType: ResourceType.SKILL }), + ); + }); + test('should handle promoted filter with ACL', async () => { // Create a promoted agent const promotedAgent = await Agent.create({ diff --git a/api/server/controllers/auth/oauth.js b/api/server/controllers/auth/oauth.js index 3502be8a4cb..ede02febb29 100644 --- a/api/server/controllers/auth/oauth.js +++ b/api/server/controllers/auth/oauth.js @@ -45,7 +45,9 @@ function createOAuthHandler(redirectUri = domains.client) { /** Get refresh token from tokenset for OpenID users */ const refreshToken = - req.user.tokenset?.refresh_token || req.user.federatedTokens?.refresh_token; + req.user.provider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS) === true + ? req.user.tokenset?.refresh_token || req.user.federatedTokens?.refresh_token + : undefined; const expiresAt = Date.now() + sessionExpiry; const callbackUrl = new URL(redirectUri); diff --git a/api/server/controllers/auth/oauth.spec.js b/api/server/controllers/auth/oauth.spec.js new file mode 100644 index 00000000000..4a20442d4fc --- /dev/null +++ b/api/server/controllers/auth/oauth.spec.js @@ -0,0 +1,151 @@ +const mockIsEnabled = jest.fn(); +const mockGetAdminPanelUrl = jest.fn(); +const mockIsAdminPanelRedirect = jest.fn(); +const mockGenerateAdminExchangeCode = jest.fn(); +const mockSyncUserEntraGroupMemberships = jest.fn(); +const mockSetAuthTokens = jest.fn(); +const mockSetOpenIDAuthTokens = jest.fn(); +const mockGetLogStores = jest.fn(); +const mockCheckBan = jest.fn(); +const mockGenerateToken = jest.fn(); +const mockLogger = { info: jest.fn(), error: jest.fn() }; + +jest.mock('librechat-data-provider', () => ({ + CacheKeys: { ADMIN_OAUTH_EXCHANGE: 'admin-oauth-exchange' }, +})); + +jest.mock('@librechat/data-schemas', () => ({ + logger: mockLogger, + DEFAULT_SESSION_EXPIRY: 60000, +})); + +jest.mock('@librechat/api', () => ({ + isEnabled: (...args) => mockIsEnabled(...args), + getAdminPanelUrl: (...args) => mockGetAdminPanelUrl(...args), + isAdminPanelRedirect: (...args) => mockIsAdminPanelRedirect(...args), + generateAdminExchangeCode: (...args) => mockGenerateAdminExchangeCode(...args), +})); + +jest.mock('~/server/services/PermissionService', () => ({ + syncUserEntraGroupMemberships: (...args) => mockSyncUserEntraGroupMemberships(...args), +})); + +jest.mock('~/server/services/AuthService', () => ({ + setAuthTokens: (...args) => mockSetAuthTokens(...args), + setOpenIDAuthTokens: (...args) => mockSetOpenIDAuthTokens(...args), +})); + +jest.mock( + '~/cache/getLogStores', + () => + (...args) => + mockGetLogStores(...args), +); + +jest.mock('~/server/middleware', () => ({ + checkBan: (...args) => mockCheckBan(...args), +})); + +jest.mock('~/models', () => ({ + generateToken: (...args) => mockGenerateToken(...args), +})); + +const { createOAuthHandler } = require('./oauth'); + +const ORIGINAL_ENV = process.env; + +function buildReq(overrides = {}) { + return { + user: { + _id: 'user-123', + email: 'admin@example.com', + provider: 'openid', + tokenset: { refresh_token: 'openid-refresh-token', access_token: 'openid-access-token' }, + federatedTokens: { refresh_token: 'federated-refresh-token' }, + }, + pkceChallenge: 'pkce-challenge', + banned: false, + ...overrides, + }; +} + +function buildRes() { + return { + headersSent: false, + redirect: jest.fn(), + }; +} + +describe('createOAuthHandler', () => { + beforeEach(() => { + jest.clearAllMocks(); + process.env = { + ...ORIGINAL_ENV, + DOMAIN_CLIENT: 'http://localhost:3080', + DOMAIN_SERVER: 'http://localhost:3080', + OPENID_REUSE_TOKENS: 'false', + }; + mockIsEnabled.mockImplementation((value) => value === 'true' || value === true); + mockGetAdminPanelUrl.mockReturnValue('http://admin.example.com'); + mockIsAdminPanelRedirect.mockReturnValue(true); + mockGetLogStores.mockReturnValue({}); + mockCheckBan.mockResolvedValue(undefined); + mockGenerateToken.mockResolvedValue('jwt-token'); + mockGenerateAdminExchangeCode.mockResolvedValue('exchange-code'); + }); + + afterAll(() => { + process.env = ORIGINAL_ENV; + }); + + it('omits refresh token from admin exchange when OPENID_REUSE_TOKENS is disabled', async () => { + const handler = createOAuthHandler('http://admin.example.com/auth/openid/callback'); + const req = buildReq(); + const res = buildRes(); + const next = jest.fn(); + + await handler(req, res, next); + + expect(mockGenerateAdminExchangeCode).toHaveBeenCalledWith( + {}, + req.user, + 'jwt-token', + undefined, + 'http://admin.example.com', + 'pkce-challenge', + expect.any(Number), + ); + expect(res.redirect).toHaveBeenCalledWith( + 'http://admin.example.com/auth/openid/callback?code=exchange-code', + ); + expect(mockSetOpenIDAuthTokens).not.toHaveBeenCalled(); + expect(mockSetAuthTokens).not.toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); + }); + + it('includes refresh token in admin exchange when OPENID_REUSE_TOKENS is enabled', async () => { + process.env.OPENID_REUSE_TOKENS = 'true'; + const handler = createOAuthHandler('http://admin.example.com/auth/openid/callback'); + const req = buildReq(); + const res = buildRes(); + const next = jest.fn(); + + await handler(req, res, next); + + expect(mockGenerateAdminExchangeCode).toHaveBeenCalledWith( + {}, + req.user, + 'jwt-token', + 'openid-refresh-token', + 'http://admin.example.com', + 'pkce-challenge', + expect.any(Number), + ); + expect(res.redirect).toHaveBeenCalledWith( + 'http://admin.example.com/auth/openid/callback?code=exchange-code', + ); + expect(mockSetOpenIDAuthTokens).not.toHaveBeenCalled(); + expect(mockSetAuthTokens).not.toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); + }); +}); diff --git a/api/server/controllers/mcp.js b/api/server/controllers/mcp.js index 8d20cbc82ca..85b840891dc 100644 --- a/api/server/controllers/mcp.js +++ b/api/server/controllers/mcp.js @@ -5,18 +5,35 @@ * @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, isMCPDomainNotAllowedError, isMCPInspectionFailedError, } = require('@librechat/api'); -const { Constants, MCPServerUserInputSchema } = require('librechat-data-provider'); -const { resolveConfigServers, resolveAllMcpConfigs } = require('~/server/services/MCP'); +const { + Constants, + Permissions, + ResourceType, + PermissionBits, + PermissionTypes, + MCP_USER_INPUT_FIELDS, + MCPServerUserInputSchema, +} = require('librechat-data-provider'); +const { + resolveConfigServers, + resolveMcpConfigNames, + 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'); /** * Handles MCP-specific errors and sends appropriate HTTP responses. @@ -84,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); @@ -113,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), ); } @@ -142,6 +164,7 @@ const getMCPTools = async (req, res) => { authField: key, label: value.title || key, description: value.description || '', + sensitive: value.sensitive, })); server.authenticated = false; } @@ -178,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 @@ -190,13 +262,71 @@ 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 }); } }; +/** + * Returns true when the request body's parsed config configures OBO. We block + * non-permission holders from creating or updating any DB-stored MCP server + * that mints per-user delegated tokens. + */ +function configHasObo(parsedConfig) { + return ( + !!parsedConfig && + typeof parsedConfig === 'object' && + 'obo' in parsedConfig && + parsedConfig.obo != null + ); +} + +/** + * Fields a user without `CONFIGURE_OBO` may modify on an OBO server (allowlist). + * Any field not on this list is locked: changes to it (add, modify, or remove) + * require the permission. Allowlisting is fail-closed — when upstream introduces + * a new MCP server config field, it lands in the locked set by default until + * explicitly opted in here. Anything that could redirect the OBO token flow + * (`url`, `proxy`, `headers`), change scopes (`obo`), or reroute auth (`oauth`, + * `apiKey`, `customUserVars`) MUST stay locked. + */ +const OBO_USER_EDITABLE_FIELDS = new Set(['title', 'description', 'iconPath']); + +/** + * Returns true when any non-allowlisted user-input field differs between the + * existing server config and the new payload. Treats add, remove, and modify + * as changes (stable JSON compare, with absence on either side counting as a + * change unless both sides are absent). The comparison surface is + * `MCP_USER_INPUT_FIELDS` (schema-derived from `MCPServerUserInputSchema`), + * so new fields on the schema are picked up automatically and stay locked + * by default until added to the allowlist above. + */ +function violatesOboLockdown(existingConfig, newConfig) { + for (const field of MCP_USER_INPUT_FIELDS) { + if (OBO_USER_EDITABLE_FIELDS.has(field)) continue; + const existing = existingConfig?.[field]; + const next = newConfig?.[field]; + if (existing === undefined && next === undefined) continue; + if (JSON.stringify(existing) !== JSON.stringify(next)) { + return true; + } + } + return false; +} + +async function callerCanConfigureObo(req) { + return checkAccess({ + req, + user: req.user, + permissionType: PermissionTypes.MCP_SERVERS, + permissions: [Permissions.CONFIGURE_OBO], + getRoleByName: db.getRoleByName, + }); +} + /** * Create MCP server * @route POST /api/mcp/servers @@ -213,15 +343,25 @@ const createMCPServerController = async (req, res) => { errors: validation.error.errors, }); } + if (configHasObo(validation.data) && !(await callerCanConfigureObo(req))) { + logger.warn( + `[createMCPServer] User ${userId} attempted to configure OBO without ${Permissions.CONFIGURE_OBO} permission`, + ); + return res + .status(403) + .json({ message: 'Forbidden: Insufficient permissions to configure OBO' }); + } + const reservedServerNames = await resolveMcpConfigNames(req); const result = await getMCPServersRegistry().addServer( 'temp_server_name', validation.data, 'DB', userId, + reservedServerNames, ); res.status(201).json({ serverName: result.serverName, - ...redactServerSecrets(result.config), + ...redactServerSecrets(result.config, { canEdit: true }), }); } catch (error) { logger.error('[createMCPServer]', error); @@ -254,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 }); @@ -278,6 +420,36 @@ const updateMCPServerController = async (req, res) => { errors: validation.error.errors, }); } + + /** + * On an existing OBO server, lock down every user-input field except the + * cosmetic allowlist (title, description, iconPath) for callers without + * CONFIGURE_OBO. This closes the OBO redirect vector — without it, a user + * with UPDATE could change `url` (or `proxy`/`headers`/`customUserVars`) + * to point OBO-minted tokens at an attacker-controlled endpoint. Adds, + * modifies, and removes are all caught. + */ + const existingConfig = await getMCPServersRegistry().getServerConfig(serverName, userId); + if (configHasObo(existingConfig) && !(await callerCanConfigureObo(req))) { + if (violatesOboLockdown(existingConfig, validation.data)) { + logger.warn( + `[updateMCPServer] User ${userId} attempted to modify a locked field on OBO server '${serverName}' without ${Permissions.CONFIGURE_OBO} permission`, + ); + return res + .status(403) + .json({ message: 'Forbidden: Insufficient permissions to configure OBO' }); + } + } else if (configHasObo(validation.data) && !(await callerCanConfigureObo(req))) { + // Adding OBO to a non-OBO server (or first-time configuration) still + // requires the permission, even if existing has no OBO. + logger.warn( + `[updateMCPServer] User ${userId} attempted to add OBO to '${serverName}' without ${Permissions.CONFIGURE_OBO} permission`, + ); + return res + .status(403) + .json({ message: 'Forbidden: Insufficient permissions to configure OBO' }); + } + const parsedConfig = await getMCPServersRegistry().updateServer( serverName, validation.data, @@ -285,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/controllers/tools.js b/api/server/controllers/tools.js index 07be1210c14..4551adf617c 100644 --- a/api/server/controllers/tools.js +++ b/api/server/controllers/tools.js @@ -10,6 +10,7 @@ const { } = require('librechat-data-provider'); const { getRoleByName, createToolCall, getToolCallsByConvo, getMessage } = require('~/models'); const { processFileURL, uploadImageBuffer } = require('~/server/services/Files/process'); +const { getRetentionExpiry } = require('~/server/services/Files/retention'); const { processCodeOutput, runPreviewFinalize } = require('~/server/services/Files/Code/process'); const { loadAuthValues } = require('~/server/services/Tools/credentials'); const { loadTools } = require('~/app/clients/tools/util'); @@ -167,6 +168,7 @@ const callTool = async (req, res) => { conversationId, result: content, user: req.user.id, + ...(await getRetentionExpiry(req)), }; if (!artifact || !artifact.files || toolId !== Tools.execute_code) { diff --git a/api/server/experimental.js b/api/server/experimental.js index b12b9deffe1..ac289615a35 100644 --- a/api/server/experimental.js +++ b/api/server/experimental.js @@ -16,14 +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 { @@ -35,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'); @@ -139,8 +145,32 @@ if (cluster.isMaster) { logger.info(`Spawning ${workers} workers to simulate multi-pod environment`); let activeWorkers = 0; + const listeningWorkers = new Set(); + let retentionSweepWorkerId = null; const startTime = Date.now(); + const assignRetentionSweepWorker = () => { + if (retentionSweepWorkerId && cluster.workers[retentionSweepWorkerId]) { + return; + } + + const connectedWorkers = Object.values(cluster.workers).filter( + (worker) => worker && worker.isConnected(), + ); + const availableWorkers = connectedWorkers.filter((worker) => listeningWorkers.has(worker.id)); + const workerPool = availableWorkers.length > 0 ? availableWorkers : connectedWorkers; + const retentionSweepWorker = workerPool[workerPool.length - 1]; + if (!retentionSweepWorker) { + return; + } + + retentionSweepWorkerId = retentionSweepWorker.id; + logger.info( + wrapLogMessage(`Worker ${retentionSweepWorker.process.pid} assigned to file-retention sweep`), + ); + retentionSweepWorker.send({ type: 'file-retention-sweep-worker' }); + }; + /** Flush Redis cache before starting workers */ flushRedisCache() .then(() => { @@ -162,19 +192,29 @@ if (cluster.isMaster) { `Worker ${worker.process.pid} is online (${activeWorkers}/${workers}) after ${uptime}s`, ); - /** Notify the last worker to perform one-time initialization tasks */ + /** Assign one worker for process-wide background jobs */ if (activeWorkers === workers) { - const allWorkers = Object.values(cluster.workers); - const lastWorker = allWorkers[allWorkers.length - 1]; - if (lastWorker) { - logger.info(wrapLogMessage(`All ${workers} workers are online`)); - lastWorker.send({ type: 'last-worker' }); - } + logger.info(wrapLogMessage(`All ${workers} workers are online`)); + } + }); + + cluster.on('listening', (worker) => { + listeningWorkers.add(worker.id); + if ( + listeningWorkers.size === workers || + (!retentionSweepWorkerId && activeWorkers >= workers) + ) { + assignRetentionSweepWorker(); } }); cluster.on('exit', (worker, code, signal) => { activeWorkers--; + listeningWorkers.delete(worker.id); + if (worker.id === retentionSweepWorkerId) { + retentionSweepWorkerId = null; + assignRetentionSweepWorker(); + } logger.error( `Worker ${worker.process.pid} died (${activeWorkers}/${workers}). Code: ${code}, Signal: ${signal}`, ); @@ -202,6 +242,32 @@ if (cluster.isMaster) { * Each worker runs a full Express server instance */ const app = express(); + /** + * The master may assign the sweep worker before or after this worker has + * loaded app config. These flags join the IPC assignment with config + * availability and ensure the background sweep starts only once. + */ + let shouldStartExpiredFileSweep = false; + let expiredFileSweepOptions = null; + let expiredFileSweepStarted = false; + + const startExpiredFileSweepOnce = () => { + if (!shouldStartExpiredFileSweep || expiredFileSweepStarted || !expiredFileSweepOptions) { + return; + } + + expiredFileSweepStarted = true; + startExpiredFileSweep(expiredFileSweepOptions); + }; + + /** Handle inter-process messages from master */ + process.on('message', (msg) => { + if (msg.type === 'file-retention-sweep-worker') { + shouldStartExpiredFileSweep = true; + logger.info(wrapLogMessage(`Worker ${process.pid} is assigned file-retention sweep`)); + startExpiredFileSweepOnce(); + } + }); const startServer = async () => { logger.info(`Worker ${process.pid} initializing...`); @@ -233,6 +299,9 @@ if (cluster.isMaster) { /** Initialize app configuration */ const appConfig = await getAppConfig(); initializeFileStorage(appConfig); + initializeGitHubSkillSync(appConfig); + expiredFileSweepOptions = { appConfig, loadAppConfig: getAppConfig }; + startExpiredFileSweepOnce(); await performStartupChecks(appConfig); await updateInterfacePerms({ appConfig, getRoleByName, updateAccessPermissions }); @@ -252,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')); @@ -285,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)); @@ -307,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); @@ -319,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); @@ -342,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); @@ -390,19 +468,6 @@ if (cluster.isMaster) { process.exit(1); } }); - - /** Handle inter-process messages from master */ - process.on('message', async (msg) => { - if (msg.type === 'last-worker') { - logger.info( - wrapLogMessage( - `Worker ${process.pid} is the last worker and can perform special initialization tasks`, - ), - ); - /** Add any one-time initialization tasks here */ - /** For example: scheduled jobs, cleanup tasks, etc. */ - } - }); }; startServer().catch((err) => { diff --git a/api/server/index.js b/api/server/index.js index 7c7f0e22bfe..c9486c90727 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -1,4 +1,4 @@ -require('dotenv').config(); +const telemetry = require('./telemetry'); const fs = require('fs'); const path = require('path'); require('module-alias')({ base: path.resolve(__dirname, '..') }); @@ -13,33 +13,41 @@ const { logger, runAsSystem } = require('@librechat/data-schemas'); const { isEnabled, apiNotFound, + createMetrics, ErrorController, memoryDiagnostics, performStartupChecks, handleJsonParseError, GenerationJobManager, + QUERY_DEVTOOLS_HEADER, createStreamServices, initializeFileStorage, - updateInterfacePermissions, + initializeDeploymentSkills, + maybeInjectQueryDevtoolsBootstrap, preAuthTenantMiddleware, + setupGracefulShutdown, + updateInterfacePermissions, } = require('@librechat/api'); const { connectDb, indexSync } = require('~/db'); -const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager'); const { - getRoleByName, updateAccessPermissions, - seedDatabase, sweepOrphanedPreviews, + getRoleByName, + seedDatabase, } = require('~/models'); +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 { 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 optionalJwtAuth = require('./middleware/optionalJwtAuth'); const noIndex = require('./middleware/noIndex'); const logApiResponse = require('./middleware/logApiResponse'); const routes = require('./routes'); @@ -52,8 +60,38 @@ const host = HOST || 'localhost'; 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) { + logger.warn('[metrics] METRICS_SECRET is not set - /metrics will return 401 for all requests'); + } + if (typeof Bun !== 'undefined') { axios.defaults.headers.common['Accept-Encoding'] = 'gzip'; } @@ -84,6 +122,9 @@ 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); await updateInterfacePermissions({ appConfig, getRoleByName, updateAccessPermissions }); @@ -105,9 +146,34 @@ 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) => { + if (!serverReady) { + return res.status(503).send('NOT_READY'); + } + return res.status(200).send('OK'); + }); /* Middleware */ + app.use(metricsMiddleware); app.use(noIndex); app.use(express.json({ limit: '3mb' })); app.use(express.urlencoded({ extended: true, limit: '3mb' })); @@ -136,10 +202,15 @@ 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)); + if (telemetry.enabled) { + app.use(telemetry.telemetryMiddleware); + } + if (!ALLOW_SOCIAL_LOGIN) { console.warn('Social logins are disabled. Set ALLOW_SOCIAL_LOGIN=true to enable them.'); } @@ -171,7 +242,9 @@ 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/admin/audit-log', routes.adminAuditLog); app.use('/api/actions', routes.actions); app.use('/api/keys', routes.keys); app.use('/api/api-keys', routes.apiKeys); @@ -180,6 +253,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); @@ -192,6 +266,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); @@ -199,30 +274,26 @@ const startServer = async () => { app.use('/api/tags', routes.tags); app.use('/api/mcp', routes.mcp); + app.use('/api/rum', routes.rum); + + app.use('/metrics', metricsRouter); /** 404 for unmatched API routes */ 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) { + app.use(telemetry.telemetryErrorMiddleware); + } /** Error handler (must be last - Express identifies error middleware by its 4-arg signature) */ app.use(ErrorController); - app.listen(port, host, async (err) => { + configureGenerationStreams(); + + const server = app.listen(port, host, async (err) => { if (err) { logger.error('Failed to start server:', err); process.exit(1); @@ -251,20 +322,20 @@ const startServer = async () => { }); await checkMigrations(); - // Configure stream services (auto-detects Redis from USE_REDIS env var) - const streamServices = createStreamServices(); - GenerationJobManager.configure(streamServices); - GenerationJobManager.initialize(); - const inspectFlags = process.execArgv.some((arg) => arg.startsWith('--inspect')); if (inspectFlags || isEnabled(process.env.MEM_DIAG)) { memoryDiagnostics.start(); } + serverReady = true; + logger.info('Server readiness checks passing.'); } catch (initErr) { + serverReady = false; logger.error('Post-listen initialization failed:', initErr); process.exit(1); } }); + + setupGracefulShutdown(server); }; /** diff --git a/api/server/index.metrics.spec.js b/api/server/index.metrics.spec.js new file mode 100644 index 00000000000..c907aca3b61 --- /dev/null +++ b/api/server/index.metrics.spec.js @@ -0,0 +1,166 @@ +const fs = require('fs'); +const request = require('supertest'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const mongoose = require('mongoose'); + +jest.mock('~/server/services/Config', () => ({ + loadCustomConfig: jest.fn(() => Promise.resolve({})), + getAppConfig: jest.fn().mockResolvedValue({ + paths: { + uploads: '/tmp', + dist: '/tmp/dist', + fonts: '/tmp/fonts', + assets: '/tmp/assets', + }, + fileStrategy: 'local', + imageOutputType: 'PNG', + }), + setCachedTools: jest.fn(), +})); + +jest.mock('~/app/clients/tools', () => ({ + createOpenAIImageTools: jest.fn(() => []), + createYouTubeTools: jest.fn(() => []), + manifestToolMap: {}, + toolkits: [], +})); + +jest.mock('~/config', () => ({ + createMCPServersRegistry: jest.fn(), + createMCPManager: jest.fn().mockResolvedValue({ + getAppToolFunctions: jest.fn().mockResolvedValue({}), + }), +})); + +describe('Server metrics route', () => { + jest.setTimeout(30_000); + + let mongoServer; + let app; + + const originalReadFileSync = fs.readFileSync; + + beforeAll(() => { + fs.readFileSync = function (filepath, options) { + if (filepath.includes('index.html')) { + return 'LibreChat

'; + } + return originalReadFileSync(filepath, options); + }; + }); + + afterAll(() => { + fs.readFileSync = originalReadFileSync; + }); + + beforeAll(async () => { + const fs = require('fs'); + const path = require('path'); + + const dirs = ['/tmp/dist', '/tmp/fonts', '/tmp/assets']; + dirs.forEach((dir) => { + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + }); + + fs.writeFileSync( + path.join('/tmp/dist', 'index.html'), + 'LibreChat
', + ); + + mongoServer = await MongoMemoryServer.create(); + process.env.MONGO_URI = mongoServer.getUri(); + process.env.PORT = '0'; + process.env.METRICS_SECRET = 'test-secret'; + app = require('~/server'); + + await healthCheckPoll(app); + }); + + afterEach(() => { + process.env.METRICS_SECRET = 'test-secret'; + }); + + afterAll(async () => { + delete process.env.METRICS_SECRET; + await mongoServer.stop(); + await mongoose.disconnect(); + }); + + it('returns 401 at /metrics when METRICS_SECRET is unset', async () => { + const response = await request(app).get('/metrics'); + expect(response.status).toBe(401); + }); + + it('returns 401 at /metrics when no token provided', async () => { + process.env.METRICS_SECRET = 'test-secret'; + + const response = await request(app).get('/metrics'); + + expect(response.status).toBe(401); + }); + + it('returns 401 at /metrics when wrong token provided', async () => { + process.env.METRICS_SECRET = 'test-secret'; + + const response = await request(app).get('/metrics').set('Authorization', 'Bearer wrong-token'); + + expect(response.status).toBe(401); + }); + + it('returns 401 at /metrics when the bearer scheme is omitted', async () => { + process.env.METRICS_SECRET = 'test-secret'; + + const response = await request(app).get('/metrics').set('Authorization', 'test-secret'); + + expect(response.status).toBe(401); + }); + + it('returns 401 at /metrics for non-bearer auth schemes', async () => { + process.env.METRICS_SECRET = 'test-secret'; + + const response = await request(app).get('/metrics').set('Authorization', 'Basic test-secret'); + + expect(response.status).toBe(401); + }); + + it('exposes Prometheus metrics at /metrics with correct bearer token', async () => { + process.env.METRICS_SECRET = 'test-secret'; + + const response = await request(app).get('/metrics').set('Authorization', 'Bearer test-secret'); + + expect(response.status).toBe(200); + expect(response.headers['content-type']).toMatch(/text\/plain/); + expect(response.text).toMatch(/^# HELP /m); + expect(response.text).toMatch(/^# TYPE /m); + }); + + it('accepts lowercase bearer scheme at /metrics', async () => { + process.env.METRICS_SECRET = 'test-secret'; + + const response = await request(app).get('/metrics').set('Authorization', 'bearer test-secret'); + + expect(response.status).toBe(200); + }); +}); + +async function healthCheckPoll(app, retries = 0) { + const maxRetries = Math.floor(10000 / 30); + try { + const response = await request(app).get('/health'); + if (response.status === 200) { + return; + } + } catch { + // Ignore connection errors during polling. + } + + if (retries < maxRetries) { + await new Promise((resolve) => setTimeout(resolve, 30)); + await healthCheckPoll(app, retries + 1); + return; + } + + throw new Error('App did not become healthy within 10 seconds.'); +} diff --git a/api/server/index.spec.js b/api/server/index.spec.js index 7b3d062fce0..3e0fc07127d 100644 --- a/api/server/index.spec.js +++ b/api/server/index.spec.js @@ -1,4 +1,5 @@ const fs = require('fs'); +const path = require('path'); const request = require('supertest'); const { MongoMemoryServer } = require('mongodb-memory-server'); const mongoose = require('mongoose'); @@ -32,6 +33,83 @@ jest.mock('~/config', () => ({ }), })); +jest.mock( + '@librechat/api/telemetry', + () => ({ + initializeTelemetry: jest.fn(() => ({ + enabled: false, + status: 'disabled', + shutdown: jest.fn(), + })), + telemetryMiddleware: jest.fn((_req, _res, next) => next()), + telemetryErrorMiddleware: jest.fn((err, _req, _res, next) => next(err)), + }), + { virtual: true }, +); + +describe('Telemetry wiring', () => { + const source = fs.readFileSync(path.join(__dirname, 'index.js'), 'utf8'); + + it('loads telemetry before other server imports', () => { + const firstStatement = source + .split('\n') + .map((line) => line.trim()) + .find(Boolean); + + expect(firstStatement).toBe("const telemetry = require('./telemetry');"); + }); + + it('mounts telemetry middleware after static assets and before routes', () => { + const telemetryMiddlewareIndex = source.indexOf('app.use(telemetry.telemetryMiddleware);'); + const staticAssetsIndex = source.indexOf('app.use(staticCache(appConfig.paths.assets));'); + const apiRoutesIndex = source.indexOf("app.use('/api/auth'"); + + expect(telemetryMiddlewareIndex).toBeGreaterThan(-1); + expect(staticAssetsIndex).toBeGreaterThan(-1); + expect(apiRoutesIndex).toBeGreaterThan(-1); + expect(staticAssetsIndex).toBeLessThan(telemetryMiddlewareIndex); + expect(telemetryMiddlewareIndex).toBeLessThan(apiRoutesIndex); + }); + + it('mounts telemetry error middleware before ErrorController', () => { + const telemetryErrorMiddlewareIndex = source.indexOf( + 'app.use(telemetry.telemetryErrorMiddleware);', + ); + const errorControllerIndex = source.indexOf('app.use(ErrorController);'); + + expect(telemetryErrorMiddlewareIndex).toBeGreaterThan(-1); + expect(errorControllerIndex).toBeGreaterThan(-1); + expect(telemetryErrorMiddlewareIndex).toBeLessThan(errorControllerIndex); + }); +}); + +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); @@ -134,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 7f0963398d7..b70f371a941 100644 --- a/api/server/middleware/__tests__/requireJwtAuth.spec.js +++ b/api/server/middleware/__tests__/requireJwtAuth.spec.js @@ -41,25 +41,194 @@ jest.mock('@librechat/data-schemas', () => { const tenantStorage = new AsyncLocalStorage(); return { 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, }; }); // Mock @librechat/api — the real tenantContextMiddleware is TS and cannot be // required directly from CJS tests. This thin wrapper mirrors the real logic -// (read req.user.tenantId, call tenantStorage.run) using the same data-schemas +// (read request context, call tenantStorage.run) using the same 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; + }; + const getUserId = (user) => + normalizeContextValue(user?.id?.toString?.()) ?? normalizeContextValue(user?._id?.toString?.()); + const getRequestId = (req) => + normalizeContextValue(req.requestId) ?? + normalizeContextValue(req.id) ?? + normalizeContextValue(req.headers?.['x-request-id']) ?? + 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 tenantId = req.user?.tenantId; - if (!tenantId) { + const context = { + tenantId: normalizeContextValue(req.user?.tenantId), + userId: getUserId(req.user), + requestId: getRequestId(req), + }; + if (!context.tenantId && !context.userId && !context.requestId) { return next(); } - return tenantStorage.run({ tenantId }, async () => next()); + return tenantStorage.run(context, async () => next()); }, }; }); @@ -67,8 +236,13 @@ jest.mock('@librechat/api', () => { // ── Helpers ───────────────────────────────────────────────────────────── const requireJwtAuth = require('../requireJwtAuth'); -const { getTenantId } = 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'; @@ -82,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(). */ @@ -110,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) { @@ -151,6 +334,27 @@ describe('requireJwtAuth tenant context chaining', () => { expect(next).toHaveBeenCalled(); }); + it('refreshes CloudFront auth cookies inside the request context', () => { + let observedContext; + maybeRefreshCloudFrontAuthCookiesMiddleware.mockImplementationOnce( + (_req, _res, middlewareNext) => { + observedContext = { + tenantId: getTenantId(), + userId: getUserId(), + }; + middlewareNext(); + }, + ); + const req = mockReq({ id: 'user-123', tenantId: 'tenant-abc', role: 'user' }); + const res = mockRes(); + const next = jest.fn(); + + requireJwtAuth(req, res, next); + + expect(observedContext).toEqual({ tenantId: 'tenant-abc', userId: 'user-123' }); + expect(next).toHaveBeenCalled(); + }); + it('ALS tenant context is NOT set when user has no tenantId', async () => { const tenantId = await runAuth({ role: 'user' }); expect(tenantId).toBeUndefined(); @@ -166,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', () => { @@ -225,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'); @@ -350,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/__tests__/validateMessageReq.spec.js b/api/server/middleware/__tests__/validateMessageReq.spec.js new file mode 100644 index 00000000000..1889e62fbdb --- /dev/null +++ b/api/server/middleware/__tests__/validateMessageReq.spec.js @@ -0,0 +1,237 @@ +jest.mock('~/models', () => ({ + getConvo: jest.fn(), +})); + +jest.mock('@librechat/api', () => ({ + GenerationJobManager: { + getJob: jest.fn(), + }, +})); + +jest.mock('@librechat/data-schemas', () => ({ + logger: { + warn: jest.fn(), + }, +})); + +const validateMessageReq = require('../validateMessageReq'); +const { getConvo } = require('~/models'); +const { GenerationJobManager } = require('@librechat/api'); +const { logger } = require('@librechat/data-schemas'); + +function createResponse() { + const res = { + json: jest.fn(), + send: jest.fn(), + status: jest.fn(), + }; + res.status.mockReturnValue(res); + return res; +} + +describe('validateMessageReq', () => { + const userId = 'user-123'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should reject requests when URL and body conversationId values differ', async () => { + const req = { + params: { conversationId: 'convo-owned' }, + body: { conversationId: 'convo-victim' }, + user: { id: userId }, + }; + const res = createResponse(); + const next = jest.fn(); + + await validateMessageReq(req, res, next); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'Conversation ID mismatch' }); + expect(getConvo).not.toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); + }); + + it('should reject requests when URL and nested message conversationId values differ', async () => { + const req = { + params: { conversationId: 'convo-owned' }, + body: { message: { conversationId: 'convo-victim' } }, + user: { id: userId }, + }; + const res = createResponse(); + const next = jest.fn(); + + await validateMessageReq(req, res, next); + + expect(res.status).toHaveBeenCalledWith(400); + expect(res.json).toHaveBeenCalledWith({ error: 'Conversation ID mismatch' }); + expect(getConvo).not.toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); + }); + + it('should validate ownership against the URL conversationId when values match', async () => { + const req = { + params: { conversationId: 'convo-owned' }, + body: { conversationId: 'convo-owned' }, + user: { id: userId }, + }; + const res = createResponse(); + const next = jest.fn(); + getConvo.mockResolvedValue({ conversationId: 'convo-owned', user: userId }); + + await validateMessageReq(req, res, next); + + expect(getConvo).toHaveBeenCalledWith(userId, 'convo-owned'); + expect(next).toHaveBeenCalledTimes(1); + }); + + it('should allow message reads for an owned active generation job before the conversation is saved', async () => { + const req = { + method: 'GET', + params: { conversationId: 'active-convo' }, + body: {}, + user: { id: userId, tenantId: 'tenant-a' }, + }; + const res = createResponse(); + const next = jest.fn(); + getConvo.mockResolvedValue(null); + GenerationJobManager.getJob.mockResolvedValue({ + status: 'running', + metadata: { userId, tenantId: 'tenant-a' }, + }); + + await validateMessageReq(req, res, next); + + expect(GenerationJobManager.getJob).toHaveBeenCalledWith('active-convo'); + expect(next).toHaveBeenCalledTimes(1); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('should allow message reads for an owned active generation job without tenant metadata', async () => { + const req = { + method: 'GET', + params: { conversationId: 'active-convo' }, + body: {}, + user: { id: userId }, + }; + const res = createResponse(); + const next = jest.fn(); + getConvo.mockResolvedValue(null); + GenerationJobManager.getJob.mockResolvedValue({ + status: 'running', + metadata: { userId }, + }); + + await validateMessageReq(req, res, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(res.status).not.toHaveBeenCalled(); + }); + + it('should reject active job message reads owned by another user', async () => { + const req = { + method: 'GET', + params: { conversationId: 'active-convo' }, + body: {}, + user: { id: userId }, + }; + const res = createResponse(); + const next = jest.fn(); + getConvo.mockResolvedValue(null); + GenerationJobManager.getJob.mockResolvedValue({ + status: 'running', + metadata: { userId: 'another-user' }, + }); + + await validateMessageReq(req, res, next); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'Conversation not found' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should reject active job message reads from another tenant', async () => { + const req = { + method: 'GET', + params: { conversationId: 'active-convo' }, + body: {}, + user: { id: userId, tenantId: 'tenant-a' }, + }; + const res = createResponse(); + const next = jest.fn(); + getConvo.mockResolvedValue(null); + GenerationJobManager.getJob.mockResolvedValue({ + status: 'running', + metadata: { userId, tenantId: 'tenant-b' }, + }); + + await validateMessageReq(req, res, next); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'Conversation not found' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should reject message-by-id reads before the conversation is saved', async () => { + const req = { + method: 'GET', + params: { conversationId: 'active-convo', messageId: 'message-id' }, + body: {}, + user: { id: userId }, + }; + const res = createResponse(); + const next = jest.fn(); + getConvo.mockResolvedValue(null); + + await validateMessageReq(req, res, next); + + expect(GenerationJobManager.getJob).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'Conversation not found' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should return not found when active job lookup fails', async () => { + const req = { + method: 'GET', + params: { conversationId: 'active-convo' }, + body: {}, + user: { id: userId }, + }; + const res = createResponse(); + const next = jest.fn(); + const error = new Error('job store unavailable'); + getConvo.mockResolvedValue(null); + GenerationJobManager.getJob.mockRejectedValue(error); + + await validateMessageReq(req, res, next); + + expect(GenerationJobManager.getJob).toHaveBeenCalledWith('active-convo'); + expect(logger.warn).toHaveBeenCalledWith( + '[validateMessageReq] Active job lookup failed for active-convo:', + error, + ); + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ error: 'Conversation not found' }); + expect(next).not.toHaveBeenCalled(); + }); + + it('should not allow unsaved conversation writes through active job ownership', async () => { + const req = { + method: 'POST', + params: { conversationId: 'active-convo' }, + body: {}, + user: { id: userId }, + }; + const res = createResponse(); + const next = jest.fn(); + getConvo.mockResolvedValue(null); + + await validateMessageReq(req, res, next); + + expect(GenerationJobManager.getJob).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(404); + expect(next).not.toHaveBeenCalled(); + }); +}); diff --git a/api/server/middleware/abortMiddleware.js b/api/server/middleware/abortMiddleware.js index e0c5ae0ff09..feed002b0e3 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'); @@ -14,6 +15,36 @@ const { sendError } = require('~/server/middleware/error'); const { abortRun } = require('./abortRun'); const db = require('~/models'); +/** + * @param {Error | unknown} error + * @returns {boolean} + */ +const isAbortError = (error) => { + const visited = new Set(); + let current = error; + + while (current && typeof current === 'object' && !visited.has(current)) { + visited.add(current); + + const errorName = current.name; + const errorCode = current.code; + const errorMessage = typeof current.message === 'string' ? current.message : ''; + + if ( + errorName === 'AbortError' || + errorCode === 'ABORT_ERR' || + errorMessage.includes('AbortError') || + /(?:operation|request|stream) was aborted/i.test(errorMessage) + ) { + return true; + } + + current = current.cause; + } + + return false; +}; + /** * Spend tokens for all models from collected usage. * This handles both sequential and parallel agent execution. @@ -110,6 +141,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({ @@ -150,6 +189,7 @@ async function abortMessage(req, res) { parentMessageId: jobData.userMessage.parentMessageId, conversationId: jobData.userMessage.conversationId, text: jobData.userMessage.text, + quotes: jobData.userMessage.quotes, isCreatedByUser: true, }) : null, @@ -190,18 +230,26 @@ const handleAbort = function () { * @returns {Promise} */ const handleAbortError = async (res, req, error, data) => { + const { sender, conversationId, messageId, parentMessageId, userMessageId, partialText } = data; + if (error?.message?.includes('base64')) { logger.error('[handleAbortError] Error in base64 encoding', { ...error, stack: smartTruncateText(error?.stack, 1000), message: truncateText(error.message, 350), }); + } else if (isAbortError(error)) { + logger.debug('[handleAbortError] AI response aborted by user', { + conversationId, + code: error?.code, + name: error?.name, + message: truncateText(error?.message ?? 'AbortError', 350), + }); } else { logger.error('[handleAbortError] AI response error; aborting request:', error); } - const { sender, conversationId, messageId, parentMessageId, userMessageId, partialText } = data; - if (error.stack && error.stack.includes('google')) { + if (error?.stack && error.stack.includes('google')) { logger.warn( `AI Response error for conversation ${conversationId} likely caused by Google censor/filter`, ); diff --git a/api/server/middleware/abortMiddleware.spec.js b/api/server/middleware/abortMiddleware.spec.js index a4ce85674bc..06e434065ab 100644 --- a/api/server/middleware/abortMiddleware.spec.js +++ b/api/server/middleware/abortMiddleware.spec.js @@ -73,7 +73,18 @@ jest.mock('./abortRun', () => ({ abortRun: jest.fn(), })); -const { spendCollectedUsage } = require('./abortMiddleware'); +const { logger } = require('@librechat/data-schemas'); +const { sendError } = require('~/server/middleware/error'); +const { handleAbortError, spendCollectedUsage } = require('./abortMiddleware'); + +const buildAbortRequest = () => ({ + body: { + model: 'gpt-4', + }, + user: { + id: 'user-123', + }, +}); describe('abortMiddleware - spendCollectedUsage', () => { beforeEach(() => { @@ -237,3 +248,65 @@ describe('abortMiddleware - spendCollectedUsage', () => { }); }); }); + +describe('abortMiddleware - handleAbortError', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it.each([ + [ + 'native DOMException AbortError', + new DOMException('The operation was aborted', 'AbortError'), + 'AbortError', + ], + [ + 'wrapped AbortError message', + new Error('SSE stream disconnected: AbortError: The operation was aborted'), + 'Error', + ], + [ + 'cause-nested AbortError', + new Error('Request failed', { + cause: new DOMException('The operation was aborted', 'AbortError'), + }), + 'Error', + ], + ])('logs a %s as a debug event instead of an error', async (_label, error, name) => { + await handleAbortError({}, buildAbortRequest(), error, { + sender: 'AI', + conversationId: 'convo-123', + messageId: 'message-123', + parentMessageId: 'parent-123', + userMessageId: 'user-message-123', + }); + + expect(logger.error).not.toHaveBeenCalled(); + expect(logger.debug).toHaveBeenCalledWith('[handleAbortError] AI response aborted by user', { + conversationId: 'convo-123', + code: error.code, + name, + message: error.message, + }); + expect(sendError).toHaveBeenCalledTimes(1); + }); + + it('keeps unexpected generation errors classified as errors', async () => { + const error = new Error('Provider failed'); + + await handleAbortError({}, buildAbortRequest(), error, { + sender: 'AI', + conversationId: 'convo-123', + messageId: 'message-123', + parentMessageId: 'parent-123', + userMessageId: 'user-message-123', + }); + + expect(logger.error).toHaveBeenCalledWith( + '[handleAbortError] AI response error; aborting request:', + error, + ); + expect(logger.debug).not.toHaveBeenCalled(); + expect(sendError).toHaveBeenCalledTimes(1); + }); +}); 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/accessResources/fileAccess.js b/api/server/middleware/accessResources/fileAccess.js index e1c5803c640..0ce391371eb 100644 --- a/api/server/middleware/accessResources/fileAccess.js +++ b/api/server/middleware/accessResources/fileAccess.js @@ -108,7 +108,9 @@ const fileAccess = async (req, res, next) => { // Tenant-scoped files are restricted to their tenant. Legacy files without // tenantId remain governed by owner/agent ACLs for non-tenant migrations. if (fileTenantId && fileTenantId !== userTenantId) { - logger.warn(`[fileAccess] User ${userId} denied cross-tenant access to file ${fileId}`); + logger.warn( + `[fileAccess] User ${userId} denied cross-tenant access to file ${fileId} (route ${req.originalUrl})`, + ); return denyFileAccess(res); } @@ -129,7 +131,9 @@ const fileAccess = async (req, res, next) => { return next(); } - logger.warn(`[fileAccess] User ${userId} denied access to file ${fileId}`); + logger.warn( + `[fileAccess] User ${userId} denied access to file ${fileId} (route ${req.originalUrl})`, + ); return denyFileAccess(res); } catch (error) { logger.error('[fileAccess] Error checking file access:', error); diff --git a/api/server/middleware/buildEndpointOption.js b/api/server/middleware/buildEndpointOption.js index cf4f773160b..84fb85dba83 100644 --- a/api/server/middleware/buildEndpointOption.js +++ b/api/server/middleware/buildEndpointOption.js @@ -1,4 +1,10 @@ -const { handleError } = require('@librechat/api'); +const { + handleError, + applyModelSpecPreset, + findModelSpecByName, + isModelSpecEndpointMatch, + resolveModelSpecPromptPrefixVariables, +} = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); const { EndpointURLs, @@ -21,6 +27,8 @@ const buildFunction = { async function buildEndpointOption(req, res, next) { const { endpoint, endpointType } = req.body; + const isAgents = + isAgentsEndpoint(endpoint) || req.baseUrl.startsWith(EndpointURLs[EModelEndpoint.agents]); let endpointsConfig; try { @@ -48,50 +56,80 @@ async function buildEndpointOption(req, res, next) { } const appConfig = req.config; + let appliedModelSpecPrivateFields = new Set(); 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' }); } - const currentModelSpec = list.find((s) => s.name === spec); + const currentModelSpec = findModelSpecByName({ list }, spec); if (!currentModelSpec) { return handleError(res, { text: 'Invalid model spec' }); } - if (endpoint !== currentModelSpec.preset.endpoint) { + if (!isModelSpecEndpointMatch(currentModelSpec, endpoint)) { return handleError(res, { text: 'Model spec mismatch' }); } try { - currentModelSpec.preset.spec = spec; - parsedBody = parseCompactConvo({ + const result = applyModelSpecPreset({ + modelSpec: currentModelSpec, + parsedBody: parsedBodyForModelSpec, endpoint, endpointType, - conversation: currentModelSpec.preset, defaultParamsEndpoint, + includePresetDefaults: true, }); - if (currentModelSpec.iconURL != null && currentModelSpec.iconURL !== '') { - parsedBody.iconURL = currentModelSpec.iconURL; - } + parsedBody = result.parsedBody; + appliedModelSpecPrivateFields = result.appliedPrivateFields; } catch (error) { logger.error(`Error parsing model spec for endpoint ${endpoint}`, error); return handleError(res, { text: 'Error parsing model spec' }); } } else if (parsedBody.spec && appConfig.modelSpecs?.list) { - // Non-enforced mode: if spec is selected, derive iconURL from model spec - const modelSpec = appConfig.modelSpecs.list.find((s) => s.name === parsedBody.spec); - if (modelSpec?.iconURL) { - parsedBody.iconURL = modelSpec.iconURL; + const modelSpec = findModelSpecByName(appConfig.modelSpecs, parsedBody.spec); + if (modelSpec) { + if (!isModelSpecEndpointMatch(modelSpec, endpoint)) { + return handleError(res, { text: 'Model spec mismatch' }); + } + + try { + const result = applyModelSpecPreset({ + modelSpec, + parsedBody, + endpoint, + endpointType, + defaultParamsEndpoint, + }); + parsedBody = result.parsedBody; + appliedModelSpecPrivateFields = result.appliedPrivateFields; + } catch (error) { + logger.error(`Error parsing model spec for endpoint ${endpoint}`, error); + return handleError(res, { text: 'Error parsing model spec' }); + } } } + if (!isAgents && appliedModelSpecPrivateFields.has('promptPrefix')) { + parsedBody = resolveModelSpecPromptPrefixVariables( + parsedBody, + req.user, + req.body.clientTimestamp, + ); + } + try { - const isAgents = - isAgentsEndpoint(endpoint) || req.baseUrl.startsWith(EndpointURLs[EModelEndpoint.agents]); const builder = isAgents ? (...args) => buildFunction[EModelEndpoint.agents](req, ...args) : buildFunction[endpointType ?? endpoint]; @@ -101,7 +139,10 @@ 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, + tenantId: req.user.tenantId, + }); } next(); diff --git a/api/server/middleware/buildEndpointOption.spec.js b/api/server/middleware/buildEndpointOption.spec.js index 5d93acd6bbb..abf49c14566 100644 --- a/api/server/middleware/buildEndpointOption.spec.js +++ b/api/server/middleware/buildEndpointOption.spec.js @@ -17,6 +17,10 @@ const mockBuildOptions = jest.fn((_endpoint, parsedBody) => ({ ...parsedBody, endpoint: _endpoint, })); +const mockAgentBuildOptions = jest.fn((_req, endpoint, parsedBody) => ({ + ...parsedBody, + endpoint, +})); jest.mock('~/server/services/Endpoints/azureAssistants', () => ({ buildOptions: mockBuildOptions, @@ -25,12 +29,13 @@ jest.mock('~/server/services/Endpoints/assistants', () => ({ buildOptions: mockBuildOptions, })); jest.mock('~/server/services/Endpoints/agents', () => ({ - buildOptions: mockBuildOptions, + buildOptions: mockAgentBuildOptions, })); jest.mock('~/models', () => ({ updateFilesUsage: jest.fn(), })); +const { updateFilesUsage } = require('~/models'); const mockGetEndpointsConfig = jest.fn(); jest.mock('~/server/services/Config', () => ({ @@ -38,6 +43,7 @@ jest.mock('~/server/services/Config', () => ({ })); jest.mock('@librechat/api', () => ({ + ...jest.requireActual('@librechat/api'), handleError: jest.fn(), })); @@ -183,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: { @@ -191,6 +200,7 @@ describe('buildEndpointOption - defaultParamsEndpoint parsing', () => { }, }, ); + req.baseUrl = '/api/agents/chat'; await buildEndpointOption(req, createRes(), jest.fn()); @@ -204,7 +214,227 @@ 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 () => { + mockGetEndpointsConfig.mockResolvedValue({}); + + const modelSpec = { + name: 'guarded-openai', + iconURL: 'openAI', + preset: { + endpoint: EModelEndpoint.openAI, + model: 'gpt-4o', + promptPrefix: 'private prompt prefix', + instructions: 'private instructions', + additional_instructions: 'private additional instructions', + temperature: 0.2, + maxContextTokens: 10000, + }, + }; + + const req = createReq( + { + endpoint: EModelEndpoint.openAI, + spec: 'guarded-openai', + model: 'gpt-4o', + temperature: 0.8, + }, + { + modelSpecs: { + enforce: false, + list: [modelSpec], + }, + }, + ); + req.baseUrl = '/api/agents/chat'; + + await buildEndpointOption(req, createRes(), jest.fn()); + + expect(req.body.endpointOption.promptPrefix).toBe('private prompt prefix'); + expect(req.body.endpointOption.instructions).toBeUndefined(); + expect(req.body.endpointOption.additional_instructions).toBeUndefined(); + expect(req.body.endpointOption.temperature).toBe(0.8); + expect(req.body.endpointOption.maxContextTokens).toBeUndefined(); + expect(req.body.endpointOption.iconURL).toBe('openAI'); + }); + + it('should reject non-enforced model specs for a different endpoint', async () => { + mockGetEndpointsConfig.mockResolvedValue({}); + + const req = createReq( + { + endpoint: EModelEndpoint.openAI, + spec: 'guarded-google', + model: 'gpt-4o', + }, + { + modelSpecs: { + enforce: false, + list: [ + { + name: 'guarded-google', + preset: { + endpoint: EModelEndpoint.google, + model: 'gemini-pro', + promptPrefix: 'private google prompt', + }, + }, + ], + }, + }, + ); + const res = createRes(); + const next = jest.fn(); + const { handleError } = require('@librechat/api'); + + await buildEndpointOption(req, res, next); + + expect(handleError).toHaveBeenCalledWith(res, { text: 'Model spec mismatch' }); + expect(mockAgentBuildOptions).not.toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); + }); + + it('should restore private model spec examples when the parser supplies an empty default', async () => { + mockGetEndpointsConfig.mockResolvedValue({}); + + const examples = [{ input: { content: 'hello' }, output: { content: 'world' } }]; + const req = createReq( + { + endpoint: EModelEndpoint.google, + spec: 'guarded-google', + model: 'gemini-pro', + }, + { + modelSpecs: { + enforce: false, + list: [ + { + name: 'guarded-google', + preset: { + endpoint: EModelEndpoint.google, + model: 'gemini-pro', + examples, + }, + }, + ], + }, + }, + ); + req.baseUrl = '/api/agents/chat'; + + await buildEndpointOption(req, createRes(), jest.fn()); + + expect(req.body.endpointOption.examples).toEqual(examples); + }); + + it('should resolve special variables for restored non-agent promptPrefix', async () => { + mockGetEndpointsConfig.mockResolvedValue({}); + + const req = createReq( + { + endpoint: EModelEndpoint.assistants, + spec: 'guarded-assistant', + assistant_id: 'asst_123', + }, + { + modelSpecs: { + enforce: false, + list: [ + { + name: 'guarded-assistant', + preset: { + endpoint: EModelEndpoint.assistants, + assistant_id: 'asst_123', + promptPrefix: 'Help {{current_user}}.', + }, + }, + ], + }, + }, + ); + req.user = { name: 'Ada' }; + + await buildEndpointOption(req, createRes(), jest.fn()); + + expect(req.body.endpointOption.promptPrefix).toBe('Help Ada.'); + }); + + it('should leave restored agent promptPrefix variables for agent initialization', async () => { + mockGetEndpointsConfig.mockResolvedValue({}); + + const req = createReq( + { + endpoint: EModelEndpoint.openAI, + spec: 'guarded-openai', + model: 'gpt-4o', + }, + { + modelSpecs: { + enforce: false, + list: [ + { + name: 'guarded-openai', + preset: { + endpoint: EModelEndpoint.openAI, + model: 'gpt-4o', + promptPrefix: 'Help {{current_user}}.', + }, + }, + ], + }, + }, + ); + req.baseUrl = '/api/agents/chat'; + req.user = { name: 'Ada' }; + + await buildEndpointOption(req, createRes(), jest.fn()); + + expect(req.body.endpointOption.promptPrefix).toBe('Help {{current_user}}.'); }); it('should fall back to OpenAI schema when getEndpointsConfig fails', async () => { @@ -235,6 +465,30 @@ 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', + tenantId: undefined, + }); + 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/checkDomainAllowed.js b/api/server/middleware/checkDomainAllowed.js index f7a3f00e68e..104e6af7921 100644 --- a/api/server/middleware/checkDomainAllowed.js +++ b/api/server/middleware/checkDomainAllowed.js @@ -18,6 +18,7 @@ const checkDomainAllowed = async (req, res, next) => { const email = req?.user?.email; const appConfig = await getAppConfig({ role: req?.user?.role, + userId: req?.user?.id, tenantId: req?.user?.tenantId, }); 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/contextProjectionLimiter.js b/api/server/middleware/limiters/contextProjectionLimiter.js new file mode 100644 index 00000000000..1f70c7ea8e3 --- /dev/null +++ b/api/server/middleware/limiters/contextProjectionLimiter.js @@ -0,0 +1,19 @@ +const rateLimit = require('express-rate-limit'); +const { limiterCache } = require('@librechat/api'); + +const { CONTEXT_PROJECTION_WINDOW = 1, CONTEXT_PROJECTION_MAX = 20 } = process.env; + +const windowMs = (parseInt(CONTEXT_PROJECTION_WINDOW, 10) || 1) * 60 * 1000; +const max = parseInt(CONTEXT_PROJECTION_MAX, 10) || 20; + +const contextProjectionLimiter = rateLimit({ + windowMs, + max, + handler: (_req, res) => { + res.status(429).json({ message: 'Too many context projection requests. Try again later' }); + }, + keyGenerator: (req) => req.user?.id, + store: limiterCache('context_projection_limiter'), +}); + +module.exports = contextProjectionLimiter; diff --git a/api/server/middleware/limiters/index.js b/api/server/middleware/limiters/index.js index a38188d2a6b..19f246d0396 100644 --- a/api/server/middleware/limiters/index.js +++ b/api/server/middleware/limiters/index.js @@ -9,8 +9,10 @@ const registerLimiter = require('./registerLimiter'); const toolCallLimiter = require('./toolCallLimiter'); const messageLimiters = require('./messageLimiters'); const promptUsageLimiter = require('./promptUsageLimiter'); +const contextProjectionLimiter = require('./contextProjectionLimiter'); const verifyEmailLimiter = require('./verifyEmailLimiter'); const resetPasswordLimiter = require('./resetPasswordLimiter'); +const twoFactorTempLimiter = require('./twoFactorTempLimiter'); module.exports = { ...uploadLimiters, @@ -21,8 +23,10 @@ module.exports = { loginLimiter, registerLimiter, toolCallLimiter, + contextProjectionLimiter, createTTSLimiters, 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/moderateText.js b/api/server/middleware/moderateText.js index 775afbafbf2..1aed0991ad7 100644 --- a/api/server/middleware/moderateText.js +++ b/api/server/middleware/moderateText.js @@ -1,5 +1,5 @@ const axios = require('axios'); -const { isEnabled } = require('@librechat/api'); +const { isEnabled, getReferencedQuotes, mergeQuotedText } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); const { ErrorTypes } = require('librechat-data-provider'); const denyRequest = require('./denyRequest'); @@ -11,10 +11,29 @@ async function moderateText(req, res, next) { try { const { text } = req.body; + /** + * Moderate the typed text, each quoted excerpt, and the merged blockquote+text + * exactly as the model receives it. Quotes are normalized via + * `getReferencedQuotes` first (matching `BaseClient`); moderating the merged + * string also covers content split across a quote and the typed body. The + * moderation API accepts an array of inputs. + */ + const safeText = typeof text === 'string' ? text : ''; + const inputs = []; + if (safeText.length > 0) { + inputs.push(safeText); + } + const quotes = getReferencedQuotes(req.body.quotes); + if (quotes != null) { + inputs.push(...quotes); + inputs.push(mergeQuotedText(safeText, quotes)); + } + const input = inputs.length > 1 ? inputs : (inputs[0] ?? text); + const response = await axios.post( process.env.OPENAI_MODERATION_REVERSE_PROXY || 'https://api.openai.com/v1/moderations', { - input: text, + input, }, { headers: { diff --git a/api/server/middleware/optionalShareFileAuth.js b/api/server/middleware/optionalShareFileAuth.js new file mode 100644 index 00000000000..bebf087d201 --- /dev/null +++ b/api/server/middleware/optionalShareFileAuth.js @@ -0,0 +1,88 @@ +const cookie = require('cookie'); +const jwt = require('jsonwebtoken'); +const { isEnabled } = require('@librechat/api'); +const { logger, runAsSystem } = require('@librechat/data-schemas'); +const { SystemRoles } = require('librechat-data-provider'); +const { getUserById, findSession } = require('~/models'); + +const verifySignedUserId = (token) => { + try { + const payload = jwt.verify(token, process.env.JWT_REFRESH_SECRET); + return typeof payload?.id === 'string' ? payload.id : null; + } catch { + return null; + } +}; + +const getRefreshTokenUserId = async (token) => { + const userId = verifySignedUserId(token); + if (!userId) { + return null; + } + + const session = await runAsSystem(() => findSession({ userId, refreshToken: token })); + return session ? userId : null; +}; + +const getOpenIdUserId = (parsed, req) => { + if (parsed.token_provider !== 'openid' || !isEnabled(process.env.OPENID_REUSE_TOKENS)) { + return null; + } + + const sessionRefreshToken = req.session?.openidTokens?.refreshToken; + if (!parsed.refreshToken || parsed.refreshToken !== sessionRefreshToken) { + return null; + } + + return verifySignedUserId(parsed.openid_user_id); +}; + +/** + * Fallback auth for share file routes that are hit by ``/anchor requests, + * which can't carry the bearer access token. Resolves the viewer from the + * `refreshToken` cookie (or an active OpenID session plus signed `openid_user_id` + * cookie) so non-public shared links can authorize the viewer's ACL. Never + * blocks: on any failure it leaves `req.user` unset and lets + * `canAccessSharedLink` decide (public access, 401, or 403). + */ +const optionalShareFileAuth = async (req, res, next) => { + if (req.user) { + return next(); + } + + try { + const cookieHeader = req.headers.cookie; + if (!cookieHeader) { + return next(); + } + + const parsed = cookie.parse(cookieHeader); + const userId = + getOpenIdUserId(parsed, req) || + (parsed.refreshToken ? await getRefreshTokenUserId(parsed.refreshToken) : null); + if (!userId) { + return next(); + } + + // Resolve in system context: this runs before canAccessSharedLink establishes + // the share tenant, so under strict tenant isolation a tenant-scoped User + // query would otherwise throw. The viewer's id comes from verified, active + // cookie auth; the share's tenant-scoped ACL check still gates access. + const user = await runAsSystem(() => + getUserById(userId, '-password -__v -totpSecret -backupCodes'), + ); + if (user) { + user.id = user._id.toString(); + if (!user.role) { + user.role = SystemRoles.USER; + } + req.user = user; + } + } catch (error) { + logger.warn('[optionalShareFileAuth] cookie auth failed:', error?.message); + } + + return next(); +}; + +module.exports = optionalShareFileAuth; diff --git a/api/server/middleware/optionalShareFileAuth.spec.js b/api/server/middleware/optionalShareFileAuth.spec.js new file mode 100644 index 00000000000..ffc0cf5cfc9 --- /dev/null +++ b/api/server/middleware/optionalShareFileAuth.spec.js @@ -0,0 +1,136 @@ +const mockVerify = jest.fn(); +const mockGetUserById = jest.fn(); +const mockFindSession = jest.fn(); +const mockRunAsSystem = jest.fn((fn) => fn()); + +jest.mock('jsonwebtoken', () => ({ verify: (...args) => mockVerify(...args) })); +jest.mock('@librechat/api', () => ({ isEnabled: (v) => v === 'true' || v === true }), { + virtual: true, +}); +jest.mock( + '@librechat/data-schemas', + () => ({ + logger: { warn: jest.fn(), error: jest.fn() }, + runAsSystem: (...args) => mockRunAsSystem(...args), + }), + { virtual: true }, +); +jest.mock('librechat-data-provider', () => ({ SystemRoles: { USER: 'USER' } }), { + virtual: true, +}); +jest.mock('~/models', () => ({ + getUserById: (...args) => mockGetUserById(...args), + findSession: (...args) => mockFindSession(...args), +})); + +const optionalShareFileAuth = require('./optionalShareFileAuth'); + +const run = async (req) => { + const next = jest.fn(); + await optionalShareFileAuth(req, {}, next); + return next; +}; + +describe('optionalShareFileAuth', () => { + beforeEach(() => { + jest.clearAllMocks(); + process.env.JWT_REFRESH_SECRET = 'test-secret'; + }); + + it('short-circuits when a bearer user is already set (no cookie work)', async () => { + const req = { user: { id: 'u1' }, headers: { cookie: 'refreshToken=x' } }; + const next = await run(req); + expect(next).toHaveBeenCalledTimes(1); + expect(mockVerify).not.toHaveBeenCalled(); + expect(mockGetUserById).not.toHaveBeenCalled(); + expect(mockFindSession).not.toHaveBeenCalled(); + }); + + it('resolves the viewer from a valid refreshToken cookie with a live session', async () => { + mockVerify.mockReturnValue({ id: 'viewer-1' }); + mockFindSession.mockResolvedValue({ _id: 'session-1' }); + mockGetUserById.mockResolvedValue({ _id: 'viewer-1', role: 'USER' }); + const req = { headers: { cookie: 'refreshToken=good.jwt' } }; + const next = await run(req); + expect(next).toHaveBeenCalledTimes(1); + expect(mockVerify).toHaveBeenCalledWith('good.jwt', 'test-secret'); + expect(mockFindSession).toHaveBeenCalledWith({ userId: 'viewer-1', refreshToken: 'good.jwt' }); + expect(mockRunAsSystem).toHaveBeenCalledTimes(2); + expect(req.user).toMatchObject({ id: 'viewer-1', role: 'USER' }); + }); + + it('defaults the role to USER when the record has none', async () => { + mockVerify.mockReturnValue({ id: 'viewer-2' }); + mockFindSession.mockResolvedValue({ _id: 'session-2' }); + mockGetUserById.mockResolvedValue({ _id: 'viewer-2' }); + const req = { headers: { cookie: 'refreshToken=good.jwt' } }; + await run(req); + expect(req.user.role).toBe('USER'); + }); + + it('leaves req.user unset when there is no cookie', async () => { + const req = { headers: {} }; + const next = await run(req); + expect(next).toHaveBeenCalledTimes(1); + expect(req.user).toBeUndefined(); + expect(mockGetUserById).not.toHaveBeenCalled(); + }); + + it('leaves req.user unset when the refresh token has no live session', async () => { + mockVerify.mockReturnValue({ id: 'viewer-3' }); + mockFindSession.mockResolvedValue(null); + const req = { headers: { cookie: 'refreshToken=revoked.jwt' } }; + const next = await run(req); + expect(next).toHaveBeenCalledTimes(1); + expect(req.user).toBeUndefined(); + expect(mockFindSession).toHaveBeenCalledWith({ + userId: 'viewer-3', + refreshToken: 'revoked.jwt', + }); + expect(mockRunAsSystem).toHaveBeenCalledTimes(1); + expect(mockGetUserById).not.toHaveBeenCalled(); + }); + + it('leaves req.user unset when the token is invalid', async () => { + mockVerify.mockImplementation(() => { + throw new Error('bad token'); + }); + const req = { headers: { cookie: 'refreshToken=bad' } }; + const next = await run(req); + expect(next).toHaveBeenCalledTimes(1); + expect(req.user).toBeUndefined(); + expect(mockGetUserById).not.toHaveBeenCalled(); + }); + + it('uses the signed openid_user_id cookie only for active OpenID-reuse sessions', async () => { + process.env.OPENID_REUSE_TOKENS = 'true'; + mockVerify.mockReturnValue({ id: 'oidc-1' }); + mockGetUserById.mockResolvedValue({ _id: 'oidc-1', role: 'USER' }); + const req = { + headers: { + cookie: 'token_provider=openid; refreshToken=stored-refresh; openid_user_id=signed.jwt', + }, + session: { openidTokens: { refreshToken: 'stored-refresh' } }, + }; + await run(req); + expect(mockVerify).toHaveBeenCalledWith('signed.jwt', 'test-secret'); + expect(mockFindSession).not.toHaveBeenCalled(); + expect(req.user).toMatchObject({ id: 'oidc-1' }); + delete process.env.OPENID_REUSE_TOKENS; + }); + + it('leaves req.user unset for OpenID-reuse cookies without an active matching session', async () => { + process.env.OPENID_REUSE_TOKENS = 'true'; + mockVerify.mockReturnValue({ id: 'oidc-2' }); + const req = { + headers: { + cookie: 'token_provider=openid; refreshToken=stale-refresh; openid_user_id=signed.jwt', + }, + session: { openidTokens: { refreshToken: 'current-refresh' } }, + }; + await run(req); + expect(req.user).toBeUndefined(); + expect(mockGetUserById).not.toHaveBeenCalled(); + delete process.env.OPENID_REUSE_TOKENS; + }); +}); diff --git a/api/server/middleware/requireJwtAuth.js b/api/server/middleware/requireJwtAuth.js index e9abbc7fa89..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,15 +36,7 @@ const getAuthenticatedUserId = (user) => user?.id?.toString?.() ?? user?._id?.to const refreshCloudFrontCookies = maybeRefreshCloudFrontAuthCookiesMiddleware ?? ((_req, _res, next) => next()); -/** - * Custom Middleware to handle JWT authentication, with support for OpenID token reuse. - * Switches between JWT and OpenID authentication based on cookies and environment settings. - * - * After successful authentication (req.user populated), automatically chains into - * `tenantContextMiddleware` to propagate `req.user.tenantId` into AsyncLocalStorage - * for downstream Mongoose tenant isolation. - */ -const requireJwtAuth = (req, res, next) => { +const getAuthStrategies = (req) => { const cookieHeader = req.headers.cookie; const parsedCookies = cookieHeader ? cookies.parse(cookieHeader) : {}; const tokenProvider = parsedCookies.token_provider; @@ -47,7 +45,107 @@ const requireJwtAuth = (req, res, next) => { const openIdReuseUserId = getValidOpenIdReuseUserId(parsedCookies); const useOpenIdJwt = tokenProvider === 'openid' && openidJwtAvailable && openIdReuseUserId != null; - const strategies = useOpenIdJwt ? ['openidJwt', 'jwt'] : ['jwt']; + + 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. + * + * After successful authentication (req.user populated), automatically chains into + * `tenantContextMiddleware` to propagate request context into AsyncLocalStorage + * for downstream Mongoose tenant isolation and structured logging. + */ +const requireJwtAuth = (req, res, next) => { + 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,26 +155,39 @@ 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; - refreshCloudFrontCookies(req, res, (refreshErr) => { - if (refreshErr) { - return next(refreshErr); + logFallbackSuccess(strategy); + tenantContextMiddleware(req, res, (tenantErr) => { + if (tenantErr) { + return next(tenantErr); } - // req.user is now populated by passport — set up tenant ALS context - tenantContextMiddleware(req, res, next); + refreshCloudFrontCookies(req, res, next); }); })(req, res, next); }; @@ -84,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/middleware/validateMessageReq.js b/api/server/middleware/validateMessageReq.js index 430444a1727..15967cdc528 100644 --- a/api/server/middleware/validateMessageReq.js +++ b/api/server/middleware/validateMessageReq.js @@ -1,20 +1,61 @@ +const { GenerationJobManager } = require('@librechat/api'); +const { logger } = require('@librechat/data-schemas'); const { getConvo } = require('~/models'); +function hasTenantMismatch(job, user) { + // Untenanted jobs remain readable by their owner for pre-multi-tenancy deployments. + return job.metadata?.tenantId != null && job.metadata.tenantId !== user.tenantId; +} + +async function canReadActiveJobConversation(req, conversationId) { + if (req.method !== 'GET' || req.params?.messageId) { + return false; + } + + let job; + try { + job = await GenerationJobManager.getJob(conversationId); + } catch (error) { + logger.warn(`[validateMessageReq] Active job lookup failed for ${conversationId}:`, error); + return false; + } + + if (!job || job.status !== 'running') { + return false; + } + + return job.metadata?.userId === req.user.id && !hasTenantMismatch(job, req.user); +} + // Middleware to validate conversationId and user relationship const validateMessageReq = async (req, res, next) => { - let conversationId = req.params.conversationId || req.body.conversationId; + const body = req.body ?? {}; + const paramConversationId = req.params?.conversationId; + const bodyConversationId = body.conversationId; + const nestedConversationId = body.message?.conversationId; - if (conversationId === 'new') { - return res.status(200).send([]); + if ( + (paramConversationId && + ((bodyConversationId && paramConversationId !== bodyConversationId) || + (nestedConversationId && paramConversationId !== nestedConversationId))) || + (bodyConversationId && nestedConversationId && bodyConversationId !== nestedConversationId) + ) { + return res.status(400).json({ error: 'Conversation ID mismatch' }); } - if (!conversationId && req.body.message) { - conversationId = req.body.message.conversationId; + const conversationId = paramConversationId || bodyConversationId || nestedConversationId; + + if (conversationId === 'new') { + return res.status(200).send([]); } const conversation = await getConvo(req.user.id, conversationId); if (!conversation) { + if (await canReadActiveJobConversation(req, conversationId)) { + return next(); + } + return res.status(404).json({ error: 'Conversation not found' }); } 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.rum.spec.js b/api/server/routes/__tests__/config.rum.spec.js new file mode 100644 index 00000000000..2b0a5b20e24 --- /dev/null +++ b/api/server/routes/__tests__/config.rum.spec.js @@ -0,0 +1,194 @@ +jest.mock('~/cache/getLogStores'); + +const mockGetAppConfig = jest.fn(); +jest.mock('~/server/services/Config/app', () => ({ + getAppConfig: (...args) => mockGetAppConfig(...args), +})); + +jest.mock('~/server/services/Config/ldap', () => ({ + getLdapConfig: jest.fn(() => null), +})); + +jest.mock('~/server/middleware/roles/capabilities', () => ({ + hasCapability: jest.fn(), +})); + +jest.mock('@librechat/data-schemas', () => ({ + ...jest.requireActual('@librechat/data-schemas'), + getTenantId: jest.fn(() => undefined), +})); + +jest.mock('@librechat/api', () => ({ + ...jest.requireActual('@librechat/api'), + getCloudFrontConfig: jest.fn(() => null), +})); + +const request = require('supertest'); +const express = require('express'); +const configRoute = require('../config'); + +function createApp(user) { + const app = express(); + app.disable('x-powered-by'); + if (user) { + app.use((req, _res, next) => { + req.user = user; + next(); + }); + } + app.use('/api/config', configRoute); + return app; +} + +const baseAppConfig = { + registration: { socialLogins: ['google', 'github'] }, + interfaceConfig: { modelSelect: true }, + turnstileConfig: { siteKey: 'test-key' }, + modelSpecs: { list: [{ name: 'test-spec' }] }, +}; + +const mockUser = { + id: 'user123', + role: 'USER', + tenantId: undefined, +}; + +afterEach(() => { + jest.resetAllMocks(); + delete process.env.RUM_ENABLED; + delete process.env.RUM_PROVIDER; + delete process.env.RUM_URL; + delete process.env.RUM_PROXY_TARGET_URL; + delete process.env.RUM_SERVICE_NAME; + delete process.env.RUM_AUTH_MODE; + delete process.env.RUM_PUBLIC_TOKEN; + delete process.env.RUM_TRACE_PROPAGATION_TARGETS; + delete process.env.RUM_CONSOLE_CAPTURE; + delete process.env.RUM_DISABLE_REPLAY; + delete process.env.RUM_ADVANCED_NETWORK_CAPTURE; + delete process.env.RUM_SAMPLE_RATE; + delete process.env.RUM_ENVIRONMENT; +}); + +describe('GET /api/config RUM config', () => { + it('includes public-token RUM config when enabled with valid env', async () => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + process.env.RUM_ENABLED = 'true'; + process.env.RUM_URL = 'https://rum.example.com'; + process.env.RUM_PUBLIC_TOKEN = 'public-token'; + process.env.RUM_TRACE_PROPAGATION_TARGETS = + 'https://app.example.com,https://api.openai.com,*,http://api.example.com'; + process.env.RUM_SAMPLE_RATE = '0.25'; + process.env.RUM_ENVIRONMENT = 'test'; + const app = createApp(null); + + const response = await request(app).get('/api/config'); + + expect(response.body.rum).toEqual({ + provider: 'hyperdx', + enabled: true, + url: 'https://rum.example.com', + serviceName: 'librechat-web', + authMode: 'publicToken', + publicToken: 'public-token', + tracePropagationTargets: ['https://app.example.com', 'https://api.openai.com'], + consoleCapture: false, + disableReplay: true, + advancedNetworkCapture: false, + sampleRate: 0.25, + environment: 'test', + }); + }); + + it('omits malformed RUM config', async () => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + process.env.RUM_ENABLED = 'true'; + process.env.RUM_URL = 'not a url'; + process.env.RUM_PUBLIC_TOKEN = 'public-token'; + const app = createApp(null); + + const response = await request(app).get('/api/config'); + + expect(response.body).not.toHaveProperty('rum'); + }); + + it('includes proxy RUM config when enabled with valid env', async () => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + process.env.RUM_ENABLED = 'true'; + process.env.RUM_AUTH_MODE = 'proxy'; + process.env.RUM_PROXY_TARGET_URL = 'http://otel-collector:4318'; + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body.rum).toEqual({ + provider: 'hyperdx', + enabled: true, + url: '/api/rum', + serviceName: 'librechat-web', + authMode: 'proxy', + consoleCapture: false, + disableReplay: true, + advancedNetworkCapture: false, + }); + }); + + it('omits proxy RUM config without a target collector URL', async () => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + process.env.RUM_ENABLED = 'true'; + process.env.RUM_AUTH_MODE = 'proxy'; + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body).not.toHaveProperty('rum'); + }); + + it('omits RUM config when the URL contains credentials', async () => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + process.env.RUM_ENABLED = 'true'; + process.env.RUM_URL = 'https://user:password@rum.example.com'; + process.env.RUM_PUBLIC_TOKEN = 'public-token'; + const app = createApp(null); + + const response = await request(app).get('/api/config'); + + expect(response.body).not.toHaveProperty('rum'); + }); + + it('allows IPv6 localhost HTTP RUM URLs in public-token mode', async () => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + process.env.RUM_ENABLED = 'true'; + process.env.RUM_URL = 'http://[::1]:4318'; + process.env.RUM_PUBLIC_TOKEN = 'public-token'; + const app = createApp(null); + + const response = await request(app).get('/api/config'); + + expect(response.body.rum?.url).toBe('http://[::1]:4318'); + }); + + it('omits unsupported userJwt RUM config for authenticated users', async () => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + process.env.RUM_ENABLED = 'true'; + process.env.RUM_URL = 'https://rum.example.com'; + process.env.RUM_AUTH_MODE = 'userJwt'; + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body).not.toHaveProperty('rum'); + }); + + it('omits unsupported userJwt RUM config for unauthenticated users', async () => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + process.env.RUM_ENABLED = 'true'; + process.env.RUM_URL = 'https://rum.example.com'; + process.env.RUM_AUTH_MODE = 'userJwt'; + const app = createApp(null); + + const response = await request(app).get('/api/config'); + + expect(response.body).not.toHaveProperty('rum'); + }); +}); diff --git a/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js index d92c56b8bbe..af82399e765 100644 --- a/api/server/routes/__tests__/config.spec.js +++ b/api/server/routes/__tests__/config.spec.js @@ -21,9 +21,16 @@ jest.mock('@librechat/data-schemas', () => ({ })); const mockGetCloudFrontConfig = jest.fn(() => null); +const mockResolveBuildInfo = jest.fn(() => ({ + commit: null, + commitShort: null, + branch: null, + buildDate: null, +})); jest.mock('@librechat/api', () => ({ ...jest.requireActual('@librechat/api'), getCloudFrontConfig: (...args) => mockGetCloudFrontConfig(...args), + resolveBuildInfo: (...args) => mockResolveBuildInfo(...args), })); const request = require('supertest'); @@ -63,6 +70,12 @@ const mockUser = { afterEach(() => { jest.resetAllMocks(); + mockResolveBuildInfo.mockReturnValue({ + commit: null, + commitShort: null, + branch: null, + buildDate: null, + }); delete process.env.APP_TITLE; delete process.env.CHECK_BALANCE; delete process.env.START_BALANCE; @@ -88,6 +101,9 @@ afterEach(() => { delete process.env.SAML_CERT; delete process.env.SAML_SESSION_SECRET; delete process.env.ALLOW_ACCOUNT_DELETION; + delete process.env.ANALYTICS_GTM_ID; + delete process.env.CUSTOM_FOOTER; + delete process.env.HELP_AND_FAQ_URL; }); describe('GET /api/config', () => { @@ -143,9 +159,52 @@ describe('GET /api/config', () => { expect(response.body).not.toHaveProperty('bundlerURL'); expect(response.body).not.toHaveProperty('staticBundlerURL'); expect(response.body).not.toHaveProperty('sharePointFilePickerEnabled'); + expect(response.body).not.toHaveProperty('sharePointBaseUrl'); + expect(response.body).not.toHaveProperty('sharePointPickerGraphScope'); + expect(response.body).not.toHaveProperty('sharePointPickerSharePointScope'); expect(response.body).not.toHaveProperty('conversationImportMaxFileSize'); }); + it('should strip authenticated-only informational fields from unauthenticated response (#12688)', async () => { + process.env.ANALYTICS_GTM_ID = 'GTM-XYZ'; + process.env.CUSTOM_FOOTER = 'internal footer text'; + process.env.HELP_AND_FAQ_URL = 'https://internal.example.com/faq'; + mockGetAppConfig.mockResolvedValue(baseAppConfig); + const app = createApp(null); + + const response = await request(app).get('/api/config'); + + expect(response.statusCode).toBe(200); + expect(response.body).not.toHaveProperty('showBirthdayIcon'); + expect(response.body).not.toHaveProperty('helpAndFaqURL'); + expect(response.body).not.toHaveProperty('sharedLinksEnabled'); + expect(response.body).not.toHaveProperty('publicSharedLinksEnabled'); + expect(response.body).not.toHaveProperty('analyticsGtmId'); + expect(response.body).not.toHaveProperty('openidReuseTokens'); + expect(response.body).not.toHaveProperty('allowAccountDeletion'); + expect(response.body).not.toHaveProperty('customFooter'); + }); + + it('should not include share-only fields when share context is requested', async () => { + process.env.ANALYTICS_GTM_ID = 'GTM-XYZ'; + process.env.CUSTOM_FOOTER = 'public footer text'; + process.env.HELP_AND_FAQ_URL = 'https://internal.example.com/faq'; + process.env.SANDPACK_BUNDLER_URL = 'https://bundler.test'; + process.env.SANDPACK_STATIC_BUNDLER_URL = 'https://static-bundler.test'; + mockGetAppConfig.mockResolvedValue(baseAppConfig); + const app = createApp(null); + + const response = await request(app).get('/api/config?context=share'); + + expect(response.statusCode).toBe(200); + expect(response.body).not.toHaveProperty('analyticsGtmId'); + expect(response.body).not.toHaveProperty('customFooter'); + expect(response.body).not.toHaveProperty('bundlerURL'); + expect(response.body).not.toHaveProperty('staticBundlerURL'); + expect(response.body).not.toHaveProperty('helpAndFaqURL'); + expect(response.body).not.toHaveProperty('allowAccountDeletion'); + }); + it('should include socialLogins and turnstile from base config', async () => { mockGetAppConfig.mockResolvedValue(baseAppConfig); const app = createApp(null); @@ -193,7 +252,7 @@ describe('GET /api/config', () => { expect(response.body).toHaveProperty('serverDomain'); }); - it('should advertise CloudFront cookie refresh only when signed-cookie mode is active', async () => { + it('should omit CloudFront cookie refresh from unauthenticated response (#12688)', async () => { mockGetAppConfig.mockResolvedValue(baseAppConfig); mockGetCloudFrontConfig.mockReturnValue({ domain: 'https://cdn.example.com', @@ -206,69 +265,9 @@ describe('GET /api/config', () => { const response = await request(app).get('/api/config'); - expect(response.body.cloudFront).toEqual({ - cookieRefresh: { - endpoint: '/api/auth/cloudfront/refresh', - domain: 'https://cdn.example.com', - }, - }); - }); - - it('should omit CloudFront cookie refresh when signed-cookie mode is inactive', async () => { - mockGetAppConfig.mockResolvedValue(baseAppConfig); - mockGetCloudFrontConfig.mockReturnValue({ - domain: 'https://cdn.example.com', - imageSigning: 'url', - }); - const app = createApp(null); - - const response = await request(app).get('/api/config'); - - expect(response.body).not.toHaveProperty('cloudFront'); - }); - - it('should omit CloudFront cookie refresh when cookie mode cannot mint cookies', async () => { - mockGetAppConfig.mockResolvedValue(baseAppConfig); - mockGetCloudFrontConfig.mockReturnValue({ - domain: 'https://cdn.example.com', - imageSigning: 'cookies', - }); - const app = createApp(null); - - const response = await request(app).get('/api/config'); - expect(response.body).not.toHaveProperty('cloudFront'); }); - it('should default allowAccountDeletion to true when env var is unset', async () => { - mockGetAppConfig.mockResolvedValue(baseAppConfig); - const app = createApp(null); - - const response = await request(app).get('/api/config'); - - expect(response.body.allowAccountDeletion).toBe(true); - }); - - it('should set allowAccountDeletion to false when ALLOW_ACCOUNT_DELETION=false', async () => { - process.env.ALLOW_ACCOUNT_DELETION = 'false'; - mockGetAppConfig.mockResolvedValue(baseAppConfig); - const app = createApp(null); - - const response = await request(app).get('/api/config'); - - expect(response.body.allowAccountDeletion).toBe(false); - }); - - it('should set allowAccountDeletion to true when ALLOW_ACCOUNT_DELETION=true', async () => { - process.env.ALLOW_ACCOUNT_DELETION = 'true'; - mockGetAppConfig.mockResolvedValue(baseAppConfig); - const app = createApp(null); - - const response = await request(app).get('/api/config'); - - expect(response.body.allowAccountDeletion).toBe(true); - }); - it('should return 500 when getAppConfig throws', async () => { mockGetAppConfig.mockRejectedValue(new Error('Config service failure')); const app = createApp(null); @@ -322,6 +321,45 @@ describe('GET /api/config', () => { expect(response.body.webSearch).toEqual({ searchProvider: 'tavily' }); }); + it('should strip private prompt fields from model spec presets', async () => { + mockGetAppConfig.mockResolvedValue({ + ...baseAppConfig, + modelSpecs: { + enforce: false, + prioritize: true, + list: [ + { + name: 'guarded-spec', + label: 'Guarded Spec', + skills: ['private-skill'], + preset: { + endpoint: 'openAI', + model: 'gpt-4o', + promptPrefix: 'private prompt prefix', + instructions: 'private assistant instructions', + additional_instructions: 'private additional instructions', + system: 'private bedrock system', + context: 'private context', + examples: [{ input: { content: 'a' }, output: { content: 'b' } }], + greeting: 'Hello', + }, + }, + ], + }, + }); + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.statusCode).toBe(200); + expect(response.body.modelSpecs.list[0].preset).toEqual({ + endpoint: 'openAI', + model: 'gpt-4o', + greeting: 'Hello', + }); + expect(response.body.modelSpecs.list[0]).not.toHaveProperty('skills'); + }); + it('should include full interface config', async () => { mockGetAppConfig.mockResolvedValue(baseAppConfig); const app = createApp(mockUser); @@ -345,6 +383,70 @@ describe('GET /api/config', () => { expect(response.body.conversationImportMaxFileSize).toBe(5000000); }); + it('should include post-login informational fields', async () => { + process.env.ANALYTICS_GTM_ID = 'GTM-XYZ'; + process.env.CUSTOM_FOOTER = 'authenticated footer text'; + mockGetAppConfig.mockResolvedValue(baseAppConfig); + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body).toHaveProperty('helpAndFaqURL'); + expect(response.body).toHaveProperty('sharedLinksEnabled'); + expect(response.body).toHaveProperty('publicSharedLinksEnabled'); + expect(response.body).toHaveProperty('showBirthdayIcon'); + expect(response.body).toHaveProperty('openidReuseTokens'); + expect(response.body.analyticsGtmId).toBe('GTM-XYZ'); + expect(response.body.customFooter).toBe('authenticated footer text'); + }); + + it('should advertise CloudFront cookie refresh when signed-cookie mode is active', async () => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + cookieDomain: '.example.com', + privateKey: 'test-private-key', + keyPairId: 'K123ABC', + }); + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body.cloudFront).toEqual({ + cookieRefresh: { + endpoint: '/api/auth/cloudfront/refresh', + domain: 'https://cdn.example.com', + }, + }); + }); + + it('should omit CloudFront cookie refresh when signed-cookie mode is inactive', async () => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'url', + }); + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body).not.toHaveProperty('cloudFront'); + }); + + it('should omit CloudFront cookie refresh when cookie mode cannot mint cookies', async () => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + mockGetCloudFrontConfig.mockReturnValue({ + domain: 'https://cdn.example.com', + imageSigning: 'cookies', + }); + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body).not.toHaveProperty('cloudFront'); + }); + it('should merge per-user balance override into config', async () => { mockGetAppConfig.mockResolvedValue({ ...baseAppConfig, @@ -409,4 +511,112 @@ describe('GET /api/config', () => { expect(response.body).toHaveProperty('error'); }); }); + + describe('buildInfo payload', () => { + const populatedBuildInfo = { + commit: 'abcdef1234567890abcdef1234567890abcdef12', + commitShort: 'abcdef1', + branch: 'dev', + buildDate: '2026-04-20T12:00:00Z', + }; + + it('includes buildInfo in authenticated response when interface flag is not explicitly disabled', async () => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + mockResolveBuildInfo.mockReturnValue(populatedBuildInfo); + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body.buildInfo).toEqual(populatedBuildInfo); + }); + + it('omits buildInfo when interface.buildInfo is false', async () => { + mockGetAppConfig.mockResolvedValue({ + ...baseAppConfig, + interfaceConfig: { ...baseAppConfig.interfaceConfig, buildInfo: false }, + }); + mockResolveBuildInfo.mockReturnValue(populatedBuildInfo); + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body).not.toHaveProperty('buildInfo'); + }); + + it('omits buildInfo when all resolver fields are null', async () => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + mockResolveBuildInfo.mockReturnValue({ + commit: null, + commitShort: null, + branch: null, + buildDate: null, + }); + const app = createApp(mockUser); + + const response = await request(app).get('/api/config'); + + expect(response.body).not.toHaveProperty('buildInfo'); + }); + + it('includes buildInfo in unauthenticated response when flag is not disabled', async () => { + mockGetAppConfig.mockResolvedValue(baseAppConfig); + mockResolveBuildInfo.mockReturnValue(populatedBuildInfo); + const app = createApp(null); + + const response = await request(app).get('/api/config'); + + expect(response.body.buildInfo).toEqual(populatedBuildInfo); + }); + + it('omits buildInfo in unauthenticated response when interface.buildInfo is false', async () => { + mockGetAppConfig.mockResolvedValue({ + ...baseAppConfig, + interfaceConfig: { ...baseAppConfig.interfaceConfig, buildInfo: false }, + }); + mockResolveBuildInfo.mockReturnValue(populatedBuildInfo); + const app = createApp(null); + + const response = await request(app).get('/api/config'); + + expect(response.body).not.toHaveProperty('buildInfo'); + }); + + it('propagates interface.buildInfo=false in unauthenticated response so clients can hide About tab', async () => { + mockGetAppConfig.mockResolvedValue({ + ...baseAppConfig, + interfaceConfig: { ...baseAppConfig.interfaceConfig, buildInfo: false }, + }); + const app = createApp(null); + + const response = await request(app).get('/api/config'); + + expect(response.body.interface).toBeDefined(); + expect(response.body.interface.buildInfo).toBe(false); + }); + + it('does not add interface.buildInfo=true to unauthenticated response (default stays implicit)', async () => { + mockGetAppConfig.mockResolvedValue({ + ...baseAppConfig, + interfaceConfig: { privacyPolicy: { externalUrl: 'https://x' }, buildInfo: true }, + }); + const app = createApp(null); + + const response = await request(app).get('/api/config'); + + expect(response.body.interface).toBeDefined(); + expect(response.body.interface).not.toHaveProperty('buildInfo'); + }); + + it('includes interface block with only buildInfo=false when nothing else is set', async () => { + mockGetAppConfig.mockResolvedValue({ + ...baseAppConfig, + interfaceConfig: { buildInfo: false }, + }); + const app = createApp(null); + + const response = await request(app).get('/api/config'); + + expect(response.body.interface).toEqual({ buildInfo: false }); + }); + }); }); diff --git a/api/server/routes/__tests__/convos.spec.js b/api/server/routes/__tests__/convos.spec.js index 23978f28e9b..9c760f50574 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); }); }); @@ -544,6 +561,80 @@ describe('Convos Routes', () => { expect(response.body).toEqual({ error: 'conversationId is required' }); }); }); + + describe('POST /convos/pin', () => { + const mockConversationId = 'conv-123'; + + it('should pin a conversation', async () => { + const mockPinnedConvo = { conversationId: mockConversationId, pinned: true }; + saveConvo.mockResolvedValue(mockPinnedConvo); + + const response = await request(app).post('/api/convos/pin').send({ arg: mockPinnedConvo }); + + expect(response.status).toBe(200); + expect(response.body).toEqual(mockPinnedConvo); + expect(saveConvo).toHaveBeenCalledWith( + { userId: 'test-user-123' }, + { conversationId: mockConversationId, pinned: true }, + { context: `POST /api/convos/pin ${mockConversationId}` }, + ); + }); + + it('should unpin a conversation', async () => { + const mockUnpinnedConvo = { conversationId: mockConversationId, pinned: false }; + saveConvo.mockResolvedValue(mockUnpinnedConvo); + + const response = await request(app).post('/api/convos/pin').send({ arg: mockUnpinnedConvo }); + + expect(response.status).toBe(200); + expect(response.body).toEqual(mockUnpinnedConvo); + expect(saveConvo).toHaveBeenCalledWith( + { userId: 'test-user-123' }, + { conversationId: mockConversationId, pinned: false }, + { context: `POST /api/convos/pin ${mockConversationId}` }, + ); + }); + + it('should return 400 when conversationId is missing', async () => { + const response = await request(app) + .post('/api/convos/pin') + .send({ arg: { pinned: true } }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'conversationId is required' }); + expect(saveConvo).not.toHaveBeenCalled(); + }); + + it('should return 400 when pinned is not a boolean', async () => { + const response = await request(app) + .post('/api/convos/pin') + .send({ arg: { conversationId: mockConversationId, pinned: 'yes' } }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'pinned must be a boolean' }); + expect(saveConvo).not.toHaveBeenCalled(); + }); + + it('should return 400 when pinned is missing', async () => { + const response = await request(app) + .post('/api/convos/pin') + .send({ arg: { conversationId: mockConversationId } }); + + expect(response.status).toBe(400); + expect(response.body).toEqual({ error: 'pinned is required' }); + expect(saveConvo).not.toHaveBeenCalled(); + }); + + it('should return 500 when saveConvo fails', async () => { + saveConvo.mockRejectedValue(new Error('Database error')); + + const response = await request(app) + .post('/api/convos/pin') + .send({ arg: { conversationId: mockConversationId, pinned: true } }); + + expect(response.status).toBe(500); + }); + }); }); /** diff --git a/api/server/routes/__tests__/mcp.spec.js b/api/server/routes/__tests__/mcp.spec.js index 30a86a53b2f..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,7 +24,13 @@ 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; jest.mock('@librechat/api', () => { const actual = jest.requireActual('@librechat/api'); @@ -35,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(), @@ -46,7 +62,15 @@ jest.mock('@librechat/api', () => { deleteUserTokens: jest.fn(), }, getUserMCPAuthMap: jest.fn(), - generateCheckAccess: jest.fn(() => (req, res, next) => next()), + generateCheckAccess: jest.fn(({ permissionType, permissions }) => (req, res, next) => { + const { PermissionTypes, Permissions } = require('librechat-data-provider'); + const isMCPUseCheck = + permissionType === PermissionTypes.MCP_SERVERS && permissions.includes(Permissions.USE); + if (isMCPUseCheck && !mockMCPUseAllowed) { + return res.status(403).json({ message: 'Forbidden: Insufficient permissions' }); + } + return next(); + }), MCPServersRegistry: { getInstance: () => mockRegistryInstance, }, @@ -108,9 +132,11 @@ jest.mock('~/server/services/Config/mcp', () => ({ })); const mockResolveAllMcpConfigs = jest.fn().mockResolvedValue({}); +const mockResolveMcpConfigNames = jest.fn().mockResolvedValue([]); jest.mock('~/server/services/MCP', () => ({ getMCPSetupData: jest.fn(), resolveConfigServers: jest.fn().mockResolvedValue({}), + resolveMcpConfigNames: (...args) => mockResolveMcpConfigNames(...args), resolveAllMcpConfigs: (...args) => mockResolveAllMcpConfigs(...args), getServerConnectionStatus: jest.fn(), })); @@ -143,6 +169,7 @@ describe('MCP Routes', () => { let app; let mongoServer; let mcpRouter; + let currentUser; beforeAll(async () => { mongoServer = await MongoMemoryServer.create(); @@ -157,7 +184,7 @@ describe('MCP Routes', () => { app.use(cookieParser()); app.use((req, res, next) => { - req.user = { id: 'test-user-id' }; + req.user = currentUser ?? { id: 'test-user-id' }; next(); }); @@ -171,17 +198,182 @@ describe('MCP Routes', () => { beforeEach(() => { jest.clearAllMocks(); + 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 + * call records but NOT implementations, so a `.mockRejectedValue(...)` set + * by an earlier test leaks into later ones — including the new + * `getServerConfig` lookup in updateMCPServerController. + */ + mockRegistryInstance.getServerConfig.mockReset().mockResolvedValue(undefined); + mockRegistryInstance.addServer.mockReset(); + mockRegistryInstance.updateServer.mockReset(); + mockRegistryInstance.removeServer.mockReset(); }); describe('GET /:serverName/oauth/initiate', () => { 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' }, }, }), @@ -215,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, ); }); @@ -228,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), @@ -238,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); @@ -398,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 () => { @@ -516,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 = { @@ -564,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, }), }; @@ -674,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' }), @@ -1084,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()); @@ -1176,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'); @@ -1539,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', @@ -1953,6 +2487,16 @@ describe('MCP Routes', () => { }); describe('GET /tools', () => { + it('should deny MCP tools when user lacks MCP server use permission', async () => { + mockMCPUseAllowed = false; + + const response = await request(app).get('/api/mcp/tools'); + + expect(response.status).toBe(403); + expect(response.body).toEqual({ message: 'Forbidden: Insufficient permissions' }); + expect(mockResolveAllMcpConfigs).not.toHaveBeenCalled(); + }); + it('should continue returning MCP tools when one server cache lookup fails', async () => { const { Constants } = require('librechat-data-provider'); const { logger } = require('@librechat/data-schemas'); @@ -2061,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', }, }; @@ -2137,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 }); @@ -2155,6 +2701,35 @@ describe('MCP Routes', () => { }), 'DB', 'test-user-id', + [], + ); + }); + + it('should reserve config-managed server names when creating MCP server', async () => { + const validConfig = { + type: 'sse', + url: 'https://mcp-server.example.com/sse', + title: 'Test SSE Server', + }; + + mockResolveMcpConfigNames.mockResolvedValueOnce(['config_slack']); + mockRegistryInstance.addServer.mockResolvedValue({ + serverName: 'test-sse-server', + config: validConfig, + }); + + const response = await request(app).post('/api/mcp/servers').send({ config: validConfig }); + + expect(response.status).toBe(201); + expect(mockRegistryInstance.addServer).toHaveBeenCalledWith( + 'temp_server_name', + expect.objectContaining({ + type: 'sse', + url: 'https://mcp-server.example.com/sse', + }), + 'DB', + 'test-user-id', + ['config_slack'], ); }); @@ -2286,6 +2861,240 @@ describe('MCP Routes', () => { expect(response.status).toBe(500); expect(response.body).toEqual({ message: 'Database connection failed' }); }); + + describe('OBO permission gate', () => { + const oboConfig = { + type: 'streamable-http', + url: 'https://mcp-server.example.com/mcp', + title: 'OBO Server', + obo: { scopes: 'api://mcp-server-id/Mcp.Tools.ReadWrite' }, + }; + const db = require('~/models'); + + beforeEach(() => { + currentUser = { id: 'test-user-id', role: 'USER' }; + mockRegistryInstance.addServer.mockResolvedValue({ + serverName: 'obo-server', + config: oboConfig, + }); + }); + + it('rejects POST with obo body when role lacks CONFIGURE_OBO', async () => { + db.getRoleByName.mockResolvedValue({ + name: 'USER', + permissions: { + MCP_SERVERS: { + USE: true, + CREATE: true, + SHARE: false, + SHARE_PUBLIC: false, + CONFIGURE_OBO: false, + }, + }, + }); + + const response = await request(app).post('/api/mcp/servers').send({ config: oboConfig }); + + expect(response.status).toBe(403); + expect(response.body.message).toMatch(/Insufficient permissions to configure OBO/); + expect(mockRegistryInstance.addServer).not.toHaveBeenCalled(); + }); + + it('allows POST with obo body when role has CONFIGURE_OBO', async () => { + db.getRoleByName.mockResolvedValue({ + name: 'USER', + permissions: { + MCP_SERVERS: { + USE: true, + CREATE: true, + SHARE: false, + SHARE_PUBLIC: false, + CONFIGURE_OBO: true, + }, + }, + }); + + const response = await request(app).post('/api/mcp/servers').send({ config: oboConfig }); + + expect(response.status).toBe(201); + expect(mockRegistryInstance.addServer).toHaveBeenCalled(); + }); + + it('allows POST without obo body regardless of CONFIGURE_OBO', async () => { + db.getRoleByName.mockResolvedValue({ + name: 'USER', + permissions: { + MCP_SERVERS: { + USE: true, + CREATE: true, + CONFIGURE_OBO: false, + }, + }, + }); + + const nonOboConfig = { + type: 'streamable-http', + url: 'https://mcp-server.example.com/mcp', + title: 'Plain Server', + }; + mockRegistryInstance.addServer.mockResolvedValue({ + serverName: 'plain-server', + config: nonOboConfig, + }); + + const response = await request(app).post('/api/mcp/servers').send({ config: nonOboConfig }); + + expect(response.status).toBe(201); + expect(db.getRoleByName).not.toHaveBeenCalled(); + expect(mockRegistryInstance.addServer).toHaveBeenCalled(); + }); + + it('rejects PATCH with obo body when role lacks CONFIGURE_OBO', async () => { + db.getRoleByName.mockResolvedValue({ + name: 'USER', + permissions: { + MCP_SERVERS: { + USE: true, + CREATE: true, + CONFIGURE_OBO: false, + }, + }, + }); + + const response = await request(app) + .patch('/api/mcp/servers/obo-server') + .send({ config: oboConfig }); + + expect(response.status).toBe(403); + expect(response.body.message).toMatch(/Insufficient permissions to configure OBO/); + expect(mockRegistryInstance.updateServer).not.toHaveBeenCalled(); + }); + + it('allows PATCH without CONFIGURE_OBO when OBO is unchanged', async () => { + // Editor without CONFIGURE_OBO should still be able to edit non-OBO fields + // (title, URL, description) on an OBO server as long as the OBO block is + // re-sent unchanged. Closes the regression where any save of an OBO server + // by such a user was rejected even when OBO itself was not being modified. + db.getRoleByName.mockResolvedValue({ + name: 'USER', + permissions: { + MCP_SERVERS: { + USE: true, + CREATE: true, + CONFIGURE_OBO: false, + }, + }, + }); + mockRegistryInstance.getServerConfig.mockResolvedValue({ + ...oboConfig, + obo: { scopes: 'api://mcp-server-id/Mcp.Tools.ReadWrite' }, + }); + mockRegistryInstance.updateServer.mockResolvedValue({ + ...oboConfig, + title: 'Renamed OBO Server', + }); + + const response = await request(app) + .patch('/api/mcp/servers/obo-server') + .send({ + config: { + ...oboConfig, + title: 'Renamed OBO Server', + obo: { scopes: 'api://mcp-server-id/Mcp.Tools.ReadWrite' }, + }, + }); + + expect(response.status).toBe(200); + expect(mockRegistryInstance.updateServer).toHaveBeenCalled(); + }); + + it('rejects PATCH that removes OBO from an existing OBO server without CONFIGURE_OBO', async () => { + // Closes the silent-downgrade vector: a user with UPDATE but not + // CONFIGURE_OBO must not be able to convert an OBO server to non-OBO, + // because doing so de-secures the server end-to-end. + db.getRoleByName.mockResolvedValue({ + name: 'USER', + permissions: { + MCP_SERVERS: { + USE: true, + CREATE: true, + CONFIGURE_OBO: false, + }, + }, + }); + mockRegistryInstance.getServerConfig.mockResolvedValue({ + ...oboConfig, + obo: { scopes: 'api://mcp-server-id/Mcp.Tools.ReadWrite' }, + }); + + // Submit body that omits the obo field (auth_type changed away from OBO) + const downgradePayload = { + type: 'streamable-http', + url: 'https://mcp-server.example.com/mcp', + title: 'OBO Server', + }; + const response = await request(app) + .patch('/api/mcp/servers/obo-server') + .send({ config: downgradePayload }); + + expect(response.status).toBe(403); + expect(response.body.message).toMatch(/Insufficient permissions to configure OBO/); + expect(mockRegistryInstance.updateServer).not.toHaveBeenCalled(); + }); + + it('rejects PATCH that redirects the URL of an existing OBO server without CONFIGURE_OBO', async () => { + // Closes the OBO redirect vector — the original trust-boundary concern + // CONFIGURE_OBO was introduced to address. A user with UPDATE but + // without the permission must not be able to point an existing OBO + // server at an attacker-controlled endpoint, which would cause OBO + // tokens minted for other users to be exfiltrated to that endpoint. + // The same allowlist policy also covers `proxy`, `headers`, transport + // type, and auth blocks. + db.getRoleByName.mockResolvedValue({ + name: 'USER', + permissions: { + MCP_SERVERS: { + USE: true, + CREATE: true, + CONFIGURE_OBO: false, + }, + }, + }); + mockRegistryInstance.getServerConfig.mockResolvedValue({ + ...oboConfig, + obo: { scopes: 'api://mcp-server-id/Mcp.Tools.ReadWrite' }, + }); + + const redirectPayload = { + ...oboConfig, + url: 'https://attacker.example.com/mcp', + obo: { scopes: 'api://mcp-server-id/Mcp.Tools.ReadWrite' }, + }; + const response = await request(app) + .patch('/api/mcp/servers/obo-server') + .send({ config: redirectPayload }); + + expect(response.status).toBe(403); + expect(response.body.message).toMatch(/Insufficient permissions to configure OBO/); + expect(mockRegistryInstance.updateServer).not.toHaveBeenCalled(); + }); + }); + + it('should fail closed when config-managed names cannot be resolved', async () => { + const validConfig = { + type: 'sse', + url: 'https://mcp-server.example.com/sse', + title: 'Test Server', + }; + + mockResolveMcpConfigNames.mockRejectedValueOnce(new Error('Config lookup failed')); + + const response = await request(app).post('/api/mcp/servers').send({ config: validConfig }); + + expect(response.status).toBe(500); + expect(response.body).toEqual({ message: 'Config lookup failed' }); + expect(mockRegistryInstance.addServer).not.toHaveBeenCalled(); + }); }); describe('GET /servers/:serverName', () => { @@ -2294,6 +3103,7 @@ describe('MCP Routes', () => { type: 'sse', url: 'https://mcp-server.example.com/sse', title: 'Test Server', + source: 'user', }; mockRegistryInstance.getServerConfig.mockResolvedValue(mockConfig); @@ -2362,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__/messages-delete.spec.js b/api/server/routes/__tests__/messages-delete.spec.js index 714d497719e..36c4e8e9e6a 100644 --- a/api/server/routes/__tests__/messages-delete.spec.js +++ b/api/server/routes/__tests__/messages-delete.spec.js @@ -197,3 +197,88 @@ describe('DELETE /:conversationId/:messageId – route handler', () => { expect(response.body).toEqual({ error: 'Internal server error' }); }); }); + +describe('message route conversation ownership filters', () => { + let app; + const { getMessages, saveConvo, saveMessage } = require('~/models'); + + const authenticatedUserId = 'user-owner-123'; + + beforeAll(() => { + const messagesRouter = require('../messages'); + + app = express(); + app.use(express.json()); + app.use((req, res, next) => { + req.user = { id: authenticatedUserId }; + next(); + }); + app.use('/api/messages', messagesRouter); + }); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should save POST messages with the validated URL conversationId', async () => { + const urlConversationId = '11111111-1111-4111-8111-111111111111'; + const bodyConversationId = '22222222-2222-4222-8222-222222222222'; + const savedMessage = { + messageId: 'message-1', + conversationId: urlConversationId, + text: 'hello', + user: authenticatedUserId, + }; + + saveMessage.mockResolvedValue(savedMessage); + saveConvo.mockResolvedValue({ conversationId: urlConversationId }); + + const response = await request(app).post(`/api/messages/${urlConversationId}`).send({ + messageId: savedMessage.messageId, + conversationId: bodyConversationId, + text: savedMessage.text, + }); + + expect(response.status).toBe(201); + expect(saveMessage).toHaveBeenCalledWith( + expect.objectContaining({ userId: authenticatedUserId }), + expect.objectContaining({ + messageId: savedMessage.messageId, + conversationId: urlConversationId, + text: savedMessage.text, + user: authenticatedUserId, + }), + { context: 'POST /api/messages/:conversationId' }, + ); + expect(saveMessage.mock.calls[0][1].conversationId).not.toBe(bodyConversationId); + expect(saveConvo).toHaveBeenCalledWith( + expect.objectContaining({ userId: authenticatedUserId }), + savedMessage, + { context: 'POST /api/messages/:conversationId' }, + ); + }); + + it('should filter conversation message reads by authenticated user', async () => { + getMessages.mockResolvedValue([{ messageId: 'message-1', conversationId: 'convo-1' }]); + + const response = await request(app).get('/api/messages/convo-1'); + + expect(response.status).toBe(200); + expect(getMessages).toHaveBeenCalledWith( + { conversationId: 'convo-1', user: authenticatedUserId }, + '-_id -__v -user', + ); + }); + + it('should filter single message reads by authenticated user', async () => { + getMessages.mockResolvedValue([{ messageId: 'message-1', conversationId: 'convo-1' }]); + + const response = await request(app).get('/api/messages/convo-1/message-1'); + + expect(response.status).toBe(200); + expect(getMessages).toHaveBeenCalledWith( + { conversationId: 'convo-1', messageId: 'message-1', user: authenticatedUserId }, + '-_id -__v -user', + ); + }); +}); diff --git a/api/server/routes/__tests__/rum.spec.js b/api/server/routes/__tests__/rum.spec.js new file mode 100644 index 00000000000..cdd0ec3e761 --- /dev/null +++ b/api/server/routes/__tests__/rum.spec.js @@ -0,0 +1,73 @@ +const express = require('express'); +const request = require('supertest'); + +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', () => ({ + requireRumProxyAuth: (...args) => mockRequireRumProxyAuth(...args), +})); + +jest.mock('@librechat/api', () => ({ + getRumProxyBodyLimit: jest.fn(() => '3mb'), + isRumProxyEnabled: (...args) => mockIsRumProxyEnabled(...args), + proxyRumRequest: (...args) => mockProxyRumRequest(...args), +})); + +describe('RUM proxy routes', () => { + let app; + + beforeAll(() => { + const rumRouter = require('../rum'); + + app = express(); + app.use('/api/rum', rumRouter); + }); + + beforeEach(() => { + mockRequireRumProxyAuth.mockClear(); + mockIsRumProxyEnabled.mockReset(); + mockProxyRumRequest.mockClear(); + }); + + it('returns 404 before auth and proxying when RUM proxy mode is disabled', async () => { + mockIsRumProxyEnabled.mockReturnValue(false); + + const response = await request(app) + .post('/api/rum/v1/traces') + .set('Content-Type', 'application/x-protobuf') + .send(Buffer.from('payload')); + + expect(response.status).toBe(404); + expect(response.body).toEqual({ message: 'RUM proxy is not configured' }); + expect(mockRequireRumProxyAuth).not.toHaveBeenCalled(); + expect(mockProxyRumRequest).not.toHaveBeenCalled(); + }); + + it('authenticates and proxies when RUM proxy mode is enabled', async () => { + mockIsRumProxyEnabled.mockReturnValue(true); + + const response = await request(app) + .post('/api/rum/v1/traces') + .set('Content-Type', 'application/x-protobuf') + .send(Buffer.from('payload')); + + expect(response.status).toBe(202); + 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 new file mode 100644 index 00000000000..c9d6cb00373 --- /dev/null +++ b/api/server/routes/__tests__/share.spec.js @@ -0,0 +1,737 @@ +const express = require('express'); +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()); +const mockBuildSharedLinkStartupPayload = jest.fn(); +const mockCanAccessSharedLink = jest.fn((_req, _res, next) => next()); +const mockGetAppConfig = jest.fn(); +const mockGetTenantId = jest.fn(() => undefined); + +jest.mock('@librechat/api', () => ({ + isEnabled: jest.fn(() => true), + generateCheckAccess: jest.fn(() => mockSharedLinksAccess), + grantCreationPermissions: (...args) => mockGrantCreationPermissions(...args), + updateSharedLinkPermissionsExpiration: (...args) => + mockUpdateSharedLinkPermissionsExpiration(...args), + ensureLinkPermissions: jest.fn(), + isFileSnapshotEnabled: jest.fn(() => true), + isFileSnapshotKillSwitchActive: jest.fn(() => false), + buildSharedLinkStartupPayload: (...args) => mockBuildSharedLinkStartupPayload(...args), + deleteSharedLinkWithCleanup: jest.fn(), + getSharedLinkExpiration: (...args) => mockGetSharedLinkExpiration(...args), + isActiveExpirationDate: jest.fn((expiredAt) => expiredAt > new Date()), +})); + +jest.mock('@librechat/data-schemas', () => ({ + logger: { error: jest.fn(), warn: jest.fn() }, + getTenantId: (...args) => mockGetTenantId(...args), + createTempChatExpirationDate: jest.fn(() => new Date('2030-01-01T00:00:00.000Z')), + runAsSystem: jest.fn((fn) => fn()), + tenantStorage: { run: jest.fn((_ctx, fn) => fn()) }, + SYSTEM_TENANT_ID: '__SYSTEM__', +})); + +jest.mock('librechat-data-provider', () => ({ + PermissionTypes: { + SHARED_LINKS: 'SHARED_LINKS', + }, + Permissions: { + CREATE: 'CREATE', + SHARE_PUBLIC: 'SHARE_PUBLIC', + }, + RetentionMode: { + ALL: 'all', + TEMPORARY: 'temporary', + }, + FileSources: { + local: 'local', + s3: 's3', + cloudfront: 'cloudfront', + azure_blob: 'azure_blob', + firebase: 'firebase', + text: 'text', + }, +})); + +jest.mock('mongoose', () => ({ + models: { + Conversation: { + findOne: jest.fn(), + }, + SharedLink: { + findOne: jest.fn(), + }, + }, +})); + +jest.mock('~/models', () => ({ + getFiles: jest.fn(), + updateFile: jest.fn(), + getSharedMessages: jest.fn(), + createSharedLink: jest.fn(), + updateSharedLink: jest.fn(), + deleteSharedLink: jest.fn(), + getSharedLinks: jest.fn(), + getSharedLink: jest.fn(), + getSharedLinkFile: jest.fn(), + backfillSharedLinkFiles: jest.fn(), + getRoleByName: jest.fn(), +})); + +const mockGetStrategyFunctions = jest.fn(); +jest.mock('~/server/services/Files/strategies', () => ({ + getStrategyFunctions: (...args) => mockGetStrategyFunctions(...args), +})); +jest.mock('~/server/utils/files', () => ({ + cleanFileName: jest.fn((name) => name), + getContentDisposition: jest.fn((name, disposition = 'attachment') => `${disposition}; ${name}`), +})); + +jest.mock( + '~/server/middleware/canAccessSharedLink', + () => + (...args) => + mockCanAccessSharedLink(...args), +); +jest.mock('~/server/middleware/optionalShareFileAuth', () => (_req, _res, next) => next()); +jest.mock('~/server/middleware/optionalJwtAuth', () => (req, _res, next) => next()); +jest.mock('~/server/middleware/requireJwtAuth', () => (req, res, next) => next()); +jest.mock('~/server/middleware/config/app', () => (_req, _res, next) => next()); +jest.mock('~/server/services/Config/app', () => ({ + getAppConfig: (...args) => mockGetAppConfig(...args), +})); + +const { Readable } = require('stream'); +const { RetentionMode } = require('librechat-data-provider'); +const { createTempChatExpirationDate, logger } = require('@librechat/data-schemas'); +const { + deleteSharedLinkWithCleanup, + isFileSnapshotEnabled, + isFileSnapshotKillSwitchActive, +} = require('@librechat/api'); +const { + getFiles, + updateFile, + getSharedMessages, + createSharedLink, + updateSharedLink, + getSharedLinkFile, + backfillSharedLinkFiles, + getRoleByName, +} = require('~/models'); +const shareRouter = require('../share'); + +const activeExpiration = new Date('2030-01-01T00:00:00.000Z'); +const expiredExpiration = new Date('2020-01-01T00:00:00.000Z'); + +const lean = (value) => ({ + lean: jest.fn().mockResolvedValue(value), +}); + +const buildApp = ({ retentionMode = RetentionMode.TEMPORARY } = {}) => { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + req.user = { id: 'user-123' }; + req.config = { interfaceConfig: { retentionMode } }; + next(); + }); + app.use('/api/share', shareRouter); + return app; +}; + +describe('share routes', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetTenantId.mockReturnValue(undefined); + mockGetAppConfig.mockResolvedValue({ + interfaceConfig: { + privacyPolicy: { externalUrl: 'https://example.com/privacy' }, + }, + }); + mockBuildSharedLinkStartupPayload.mockReturnValue({ + appTitle: 'Shared Chat', + bundlerURL: 'https://bundler.example.com', + interface: { + privacyPolicy: { externalUrl: 'https://example.com/privacy' }, + }, + }); + getRoleByName.mockResolvedValue({ + permissions: { + SHARED_LINKS: { + SHARE_PUBLIC: true, + }, + }, + }); + mockGrantCreationPermissions.mockResolvedValue(undefined); + }); + + it('serves shared startup config after shared-link access is granted', async () => { + const response = await request(buildApp()).get('/api/share/share-123/config'); + + expect(response.status).toBe(200); + expect(response.headers['cache-control']).toBe('private, no-store'); + expect(mockCanAccessSharedLink).toHaveBeenCalled(); + expect(mockGetAppConfig).toHaveBeenCalledWith({ baseOnly: true }); + expect(mockBuildSharedLinkStartupPayload).toHaveBeenCalledWith({ + interfaceConfig: { + privacyPolicy: { externalUrl: 'https://example.com/privacy' }, + }, + }); + expect(response.body).toEqual({ + appTitle: 'Shared Chat', + bundlerURL: 'https://bundler.example.com', + interface: { + privacyPolicy: { externalUrl: 'https://example.com/privacy' }, + }, + }); + }); + + it('uses tenant-scoped app config for shared startup config when tenant context is present', async () => { + mockGetTenantId.mockReturnValue('tenant-abc'); + + const response = await request(buildApp()).get('/api/share/share-123/config'); + + expect(response.status).toBe(200); + expect(mockGetAppConfig).toHaveBeenCalledWith({ tenantId: 'tenant-abc' }); + }); + + it('uses base app config for shared startup config in system context', async () => { + mockGetTenantId.mockReturnValue('__SYSTEM__'); + + const response = await request(buildApp()).get('/api/share/share-123/config'); + + expect(response.status).toBe(200); + expect(mockGetAppConfig).toHaveBeenCalledWith({ baseOnly: true }); + }); + + 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({ _id: 'link-123', shareId: 'share-123' }); + + const response = await request(buildApp()) + .post('/api/share/convo-123') + .send({ targetMessageId: 'msg-123' }); + + expect(response.status).toBe(200); + expect(mockGetSharedLinkExpiration).toHaveBeenCalledWith( + expect.objectContaining({ + conversationId: 'convo-123', + req: expect.objectContaining({ user: { id: 'user-123' } }), + }), + expect.objectContaining({ + getConvo: expect.any(Function), + createExpirationDate: createTempChatExpirationDate, + logger, + }), + ); + const [, dependencies] = mockGetSharedLinkExpiration.mock.calls[0]; + mongoose.models.Conversation.findOne.mockReturnValue(lean({ expiredAt: activeExpiration })); + await dependencies.getConvo('user-123', 'convo-123'); + expect(mongoose.models.Conversation.findOne).toHaveBeenCalledWith( + { conversationId: 'convo-123', user: 'user-123' }, + 'isTemporary expiredAt', + ); + expect(createSharedLink).toHaveBeenCalledWith( + 'user-123', + 'convo-123', + 'msg-123', + new Date('2030-01-01T00:00:00.000Z'), + true, + ); + expect(mockGrantCreationPermissions).toHaveBeenCalledWith( + 'link-123', + 'user-123', + true, + new Date('2030-01-01T00:00:00.000Z'), + ); + expect(mockSharedLinksAccess).toHaveBeenCalled(); + }); + + it('snapshots files by default when the user does not opt out', async () => { + mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration); + createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' }); + + await request(buildApp()).post('/api/share/convo-123').send({ targetMessageId: 'msg-123' }); + + expect(createSharedLink).toHaveBeenCalledWith( + 'user-123', + 'convo-123', + 'msg-123', + expect.anything(), + true, + ); + }); + + it('does not snapshot files when the user opts out (snapshotFiles=false)', async () => { + mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration); + createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' }); + + await request(buildApp()) + .post('/api/share/convo-123') + .send({ targetMessageId: 'msg-123', snapshotFiles: false }); + + expect(createSharedLink).toHaveBeenCalledWith( + 'user-123', + 'convo-123', + 'msg-123', + expect.anything(), + false, + ); + }); + + it('forces snapshotFiles=false when the feature is disabled, ignoring the body flag', async () => { + isFileSnapshotEnabled.mockReturnValueOnce(false); + mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration); + createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' }); + + await request(buildApp()) + .post('/api/share/convo-123') + .send({ targetMessageId: 'msg-123', snapshotFiles: true }); + + expect(createSharedLink).toHaveBeenCalledWith( + 'user-123', + 'convo-123', + 'msg-123', + expect.anything(), + false, + ); + }); + + it('passes the snapshotFiles opt-out through on update', async () => { + mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' })); + mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration); + updateSharedLink.mockResolvedValue({ _id: 'link-456', shareId: 'share-456' }); + + await request(buildApp()).patch('/api/share/share-123').send({ snapshotFiles: false }); + + expect(updateSharedLink).toHaveBeenCalledWith( + 'user-123', + 'share-123', + undefined, + expect.anything(), + false, + ); + }); + + it('rejects new shares when the retained conversation expired', async () => { + mockGetSharedLinkExpiration.mockResolvedValue(expiredExpiration); + createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' }); + + const response = await request(buildApp()) + .post('/api/share/convo-123') + .send({ targetMessageId: 'msg-123' }); + + expect(response.status).toBe(404); + expect(createSharedLink).not.toHaveBeenCalled(); + }); + + it('rejects new shares for expired conversations in all retention mode', async () => { + mockGetSharedLinkExpiration.mockResolvedValue(expiredExpiration); + createSharedLink.mockResolvedValue({ _id: 'link-123', shareId: 'share-123' }); + + const response = await request(buildApp({ retentionMode: RetentionMode.ALL })) + .post('/api/share/convo-123') + .send({ targetMessageId: 'msg-123' }); + + expect(response.status).toBe(404); + expect(createSharedLink).not.toHaveBeenCalled(); + }); + + it('expires updated shares for retained non-temporary conversations', async () => { + mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' })); + mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration); + updateSharedLink.mockResolvedValue({ _id: 'link-456', shareId: 'share-456' }); + + const response = await request(buildApp()).patch('/api/share/share-123'); + + expect(response.status).toBe(200); + expect(mongoose.models.SharedLink.findOne).toHaveBeenCalledWith( + { shareId: 'share-123', user: 'user-123' }, + 'conversationId', + ); + expect(mockGetSharedLinkExpiration).toHaveBeenCalledTimes(1); + expect(mockGetSharedLinkExpiration).toHaveBeenCalledWith( + expect.objectContaining({ + conversationId: 'convo-123', + req: expect.objectContaining({ user: { id: 'user-123' } }), + }), + expect.objectContaining({ + getConvo: expect.any(Function), + createExpirationDate: createTempChatExpirationDate, + logger, + }), + ); + expect(updateSharedLink).toHaveBeenCalledWith( + 'user-123', + 'share-123', + undefined, + new Date('2030-01-01T00:00:00.000Z'), + true, + ); + expect(mockUpdateSharedLinkPermissionsExpiration).toHaveBeenCalledWith( + 'link-456', + new Date('2030-01-01T00:00:00.000Z'), + ); + }); + + it('rejects updated shares when the retained conversation expired', async () => { + mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' })); + mockGetSharedLinkExpiration.mockResolvedValue(expiredExpiration); + updateSharedLink.mockResolvedValue({ shareId: 'share-456' }); + + const response = await request(buildApp()).patch('/api/share/share-123'); + + expect(response.status).toBe(404); + expect(updateSharedLink).not.toHaveBeenCalled(); + }); + + it('rejects updated shares for expired conversations in all retention mode', async () => { + mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' })); + mockGetSharedLinkExpiration.mockResolvedValue(expiredExpiration); + updateSharedLink.mockResolvedValue({ shareId: 'share-456' }); + + const response = await request(buildApp({ retentionMode: RetentionMode.ALL })).patch( + '/api/share/share-123', + ); + + expect(response.status).toBe(404); + expect(mongoose.models.SharedLink.findOne).toHaveBeenCalledWith( + { shareId: 'share-123', user: 'user-123' }, + 'conversationId', + ); + expect(updateSharedLink).not.toHaveBeenCalled(); + }); + + 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({ _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, true); + expect(mockUpdateSharedLinkPermissionsExpiration).toHaveBeenCalledWith('link-456', null); + expect(mockSharedLinksAccess).not.toHaveBeenCalled(); + }); + + it('preserves updated share expiration when the conversation cannot be found', async () => { + mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' })); + mockGetSharedLinkExpiration.mockResolvedValue(undefined); + updateSharedLink.mockResolvedValue({ 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, + undefined, + true, + ); + expect(mockUpdateSharedLinkPermissionsExpiration).not.toHaveBeenCalled(); + }); + + it('clears updated share expiration when creating a new expiration throws', async () => { + const error = new Error('bad config'); + mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' })); + mockGetSharedLinkExpiration.mockImplementationOnce(async (_input, dependencies) => { + dependencies.logger.error('[getSharedLinkExpiration] Error creating expiration date:', error); + return null; + }); + updateSharedLink.mockResolvedValue({ _id: 'link-456', shareId: 'share-456' }); + + const response = await request(buildApp()).patch('/api/share/share-123'); + + expect(response.status).toBe(200); + expect(logger.error).toHaveBeenCalledWith( + '[getSharedLinkExpiration] Error creating expiration date:', + error, + ); + expect(updateSharedLink).toHaveBeenCalledWith('user-123', 'share-123', undefined, null, true); + expect(mockUpdateSharedLinkPermissionsExpiration).toHaveBeenCalledWith('link-456', null); + }); + + it('updates share target message while applying retention expiration', async () => { + mongoose.models.SharedLink.findOne.mockReturnValue(lean({ conversationId: 'convo-123' })); + mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration); + updateSharedLink.mockResolvedValue({ shareId: 'share-456', targetMessageId: 'msg-456' }); + + const response = await request(buildApp()) + .patch('/api/share/share-123') + .send({ targetMessageId: 'msg-456' }); + + expect(response.status).toBe(200); + expect(updateSharedLink).toHaveBeenCalledWith( + 'user-123', + 'share-123', + 'msg-456', + new Date('2030-01-01T00:00:00.000Z'), + true, + ); + }); + + it('rejects non-string target message updates', async () => { + const response = await request(buildApp()) + .patch('/api/share/share-123') + .send({ targetMessageId: 123 }); + + 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'); + }); +}); + +describe('share-scoped file routes', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetStrategyFunctions.mockReturnValue({ + getDownloadStream: jest.fn(async () => Readable.from(['file-bytes'])), + }); + // Live file record present by default (resolveShareFile requires it). + getFiles.mockResolvedValue([{ status: 'ready' }]); + }); + + it('serves a snapshotted image inline from its original stored object', async () => { + const getDownloadStream = jest.fn(async () => Readable.from(['file-bytes'])); + mockGetStrategyFunctions.mockReturnValue({ getDownloadStream }); + getSharedLinkFile.mockResolvedValue({ + file: { + file_id: 'file-1', + source: 'local', + filepath: '/images/owner/pic.png', + type: 'image/png', + filename: 'pic.png', + }, + hasSnapshots: true, + }); + + const response = await request(buildApp()).get('/api/share/share-123/files/file-1'); + + expect(response.status).toBe(200); + expect(response.headers['content-type']).toContain('image/png'); + expect(response.headers['x-content-type-options']).toBe('nosniff'); + expect(response.headers['content-disposition']).toContain('inline'); + expect(mockGetStrategyFunctions).toHaveBeenCalledWith('local'); + expect(getDownloadStream).toHaveBeenCalledWith(expect.anything(), '/images/owner/pic.png'); + expect(backfillSharedLinkFiles).not.toHaveBeenCalled(); + }); + + it('forces attachment for unsafe inline types (no stored XSS)', async () => { + const getDownloadStream = jest.fn(async () => Readable.from([''])); + mockGetStrategyFunctions.mockReturnValue({ getDownloadStream }); + getSharedLinkFile.mockResolvedValue({ + file: { + file_id: 'file-1', + source: 'local', + filepath: '/uploads/owner/evil.svg', + type: 'image/svg+xml', + filename: 'evil.svg', + }, + hasSnapshots: true, + }); + + const response = await request(buildApp()).get('/api/share/share-123/files/file-1'); + + expect(response.status).toBe(200); + expect(response.headers['content-type']).toContain('application/octet-stream'); + expect(response.headers['content-disposition']).toContain('attachment'); + expect(response.headers['x-content-type-options']).toBe('nosniff'); + }); + + it('downloads a snapshotted file as an attachment', async () => { + getSharedLinkFile.mockResolvedValue({ + file: { + file_id: 'file-1', + source: 'local', + filepath: '/uploads/owner/file-1', + type: 'application/pdf', + filename: 'report.pdf', + }, + hasSnapshots: true, + }); + + const response = await request(buildApp()).get('/api/share/share-123/files/file-1/download'); + + expect(response.status).toBe(200); + expect(response.headers['content-disposition']).toContain('attachment'); + }); + + it('returns preview status read live from the file record', async () => { + getSharedLinkFile.mockResolvedValue({ + file: { file_id: 'file-1', source: 'local' }, + hasSnapshots: true, + }); + getFiles.mockResolvedValue([{ status: 'ready', text: 'extracted text', textFormat: 'text' }]); + + const response = await request(buildApp()).get('/api/share/share-123/files/file-1/preview'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + file_id: 'file-1', + status: 'ready', + text: 'extracted text', + textFormat: 'text', + }); + expect(getFiles).toHaveBeenCalledWith({ file_id: 'file-1' }, null, {}); + }); + + it('404s for a file not in the snapshot without rebuilding it', async () => { + getSharedLinkFile.mockResolvedValue({ file: null, hasSnapshots: true }); + + const response = await request(buildApp()).get('/api/share/share-123/files/not-shared'); + + expect(response.status).toBe(404); + expect(backfillSharedLinkFiles).not.toHaveBeenCalled(); + expect(mockGetStrategyFunctions).not.toHaveBeenCalled(); + }); + + it('lazily backfills only a legacy share that has no snapshot field', async () => { + getSharedLinkFile.mockResolvedValue({ file: null, hasSnapshots: false }); + backfillSharedLinkFiles.mockResolvedValue({ + file_id: 'file-1', + source: 'local', + filepath: '/images/owner/pic.png', + type: 'image/png', + filename: 'pic.png', + }); + + const response = await request(buildApp()).get('/api/share/share-123/files/file-1'); + + expect(response.status).toBe(200); + expect(backfillSharedLinkFiles).toHaveBeenCalledWith('share-123', 'file-1'); + }); + + it('404s cleanly when the snapshotted file is no longer available', async () => { + getSharedLinkFile.mockResolvedValue({ + file: { file_id: 'file-1', source: 'local', filepath: '/uploads/owner/gone.pdf' }, + hasSnapshots: true, + }); + getFiles.mockResolvedValue([]); // original record deleted/expired + + const response = await request(buildApp()).get('/api/share/share-123/files/file-1'); + + expect(response.status).toBe(404); + expect(mockGetStrategyFunctions).not.toHaveBeenCalled(); + }); + + it('404s (no serving) when the global kill switch is active', async () => { + isFileSnapshotKillSwitchActive.mockReturnValueOnce(true); + + const response = await request(buildApp()).get('/api/share/share-123/files/file-1'); + + expect(response.status).toBe(404); + expect(getSharedLinkFile).not.toHaveBeenCalled(); + expect(mockGetStrategyFunctions).not.toHaveBeenCalled(); + }); + + it('404s (no serving, no backfill) for a link that opted out of file sharing', async () => { + getSharedLinkFile.mockResolvedValue({ file: null, hasSnapshots: false, optedOut: true }); + + const response = await request(buildApp()).get('/api/share/share-123/files/file-1'); + + expect(response.status).toBe(404); + expect(backfillSharedLinkFiles).not.toHaveBeenCalled(); + expect(mockGetStrategyFunctions).not.toHaveBeenCalled(); + }); + + it('404s when the snapshotted file version was overwritten (revision mismatch)', async () => { + getSharedLinkFile.mockResolvedValue({ + file: { + file_id: 'file-1', + source: 'local', + filepath: '/uploads/owner/x', + previewRevision: 'r1', + }, + hasSnapshots: true, + }); + getFiles.mockResolvedValue([{ status: 'ready', previewRevision: 'r2' }]); + + const response = await request(buildApp()).get('/api/share/share-123/files/file-1'); + + expect(response.status).toBe(404); + expect(mockGetStrategyFunctions).not.toHaveBeenCalled(); + }); + + it('404s when the snapshotted file was overwritten (size/bytes mismatch)', async () => { + getSharedLinkFile.mockResolvedValue({ + file: { file_id: 'file-1', source: 'local', filepath: '/uploads/owner/x', bytes: 100 }, + hasSnapshots: true, + }); + getFiles.mockResolvedValue([{ status: 'ready', bytes: 200 }]); + + const response = await request(buildApp()).get('/api/share/share-123/files/file-1'); + + expect(response.status).toBe(404); + expect(mockGetStrategyFunctions).not.toHaveBeenCalled(); + }); + + it('strips a cache-busting query string before local streaming', async () => { + const getDownloadStream = jest.fn(async () => Readable.from(['bytes'])); + mockGetStrategyFunctions.mockReturnValue({ getDownloadStream }); + getSharedLinkFile.mockResolvedValue({ + file: { + file_id: 'file-1', + source: 'local', + filepath: '/images/owner/pic.png?v=2', + type: 'image/png', + filename: 'pic.png', + bytes: 100, + }, + hasSnapshots: true, + }); + getFiles.mockResolvedValue([{ status: 'ready', bytes: 100 }]); + + const response = await request(buildApp()).get('/api/share/share-123/files/file-1'); + + expect(response.status).toBe(200); + expect(getDownloadStream).toHaveBeenCalledWith(expect.anything(), '/images/owner/pic.png'); + }); + + it('sweeps an orphaned pending preview to failed', async () => { + getSharedLinkFile.mockResolvedValue({ + file: { file_id: 'file-1', source: 'local' }, + hasSnapshots: true, + }); + const stale = new Date(Date.now() - 5 * 60 * 1000); + getFiles.mockResolvedValue([{ status: 'pending', updatedAt: stale }]); + updateFile.mockResolvedValue({ status: 'failed', previewError: 'orphaned' }); + + const response = await request(buildApp()).get('/api/share/share-123/files/file-1/preview'); + + expect(response.status).toBe(200); + expect(response.body).toEqual({ + file_id: 'file-1', + status: 'failed', + previewError: 'orphaned', + }); + expect(updateFile).toHaveBeenCalledWith( + { file_id: 'file-1', status: 'failed', previewError: 'orphaned' }, + { status: 'pending', updatedAt: stale }, + ); + }); +}); 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 806edc66cc6..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 { @@ -107,6 +107,7 @@ router.get('/:action_id/oauth/callback', async (req, res) => { client_url: flowState.metadata.client_url, redirect_uri: flowState.metadata.redirect_uri, token_exchange_method: flowState.metadata.token_exchange_method, + allowedAddresses: flowState.metadata.allowedAddresses, /** Encrypted values */ encrypted_oauth_client_id: flowState.metadata.encrypted_oauth_client_id, encrypted_oauth_client_secret: flowState.metadata.encrypted_oauth_client_secret, diff --git a/api/server/routes/admin/audit.js b/api/server/routes/admin/audit.js new file mode 100644 index 00000000000..7332f8296a0 --- /dev/null +++ b/api/server/routes/admin/audit.js @@ -0,0 +1,35 @@ +const express = require('express'); +const { createAdminAuditLogHandlers } = require('@librechat/api'); +const { SystemCapabilities } = require('@librechat/data-schemas'); +const { requireCapability } = require('~/server/middleware/roles/capabilities'); +const { requireJwtAuth } = require('~/server/middleware'); +const db = require('~/models'); + +const router = express.Router(); + +const requireAdminAccess = requireCapability(SystemCapabilities.ACCESS_ADMIN); +const requireAuditLogRead = requireCapability(SystemCapabilities.READ_AUDIT_LOG); + +const handlers = createAdminAuditLogHandlers({ + listAuditLogPage: db.listAuditLogPage, + findAuditLogEntry: db.findAuditLogEntry, + streamAuditLogEntries: db.streamAuditLogEntries, + verifyAuditChain: db.verifyAuditChain, +}); + +/** + * `ACCESS_ADMIN` gates entry to the admin surface; `READ_AUDIT_LOG` then gates + * this specific feature within that surface. The two capabilities are + * independent in `CapabilityImplications`, so a role delegated only + * `READ_AUDIT_LOG` without `ACCESS_ADMIN` would otherwise bypass the admin + * boundary on this router — every other admin router enforces the same pair. + */ +router.use(requireJwtAuth, requireAdminAccess, requireAuditLogRead); + +router.get('/', handlers.listAuditLog); +/** Literal sub-paths MUST precede `/:id` so they aren't matched as `{ id }`. */ +router.get('/export.csv', handlers.exportAuditLogCsv); +router.get('/verify', handlers.verifyAuditLog); +router.get('/:id', handlers.getAuditLogEntry); + +module.exports = router; diff --git a/api/server/routes/admin/config.js b/api/server/routes/admin/config.js index 0632077ea97..ab7aa01a2bd 100644 --- a/api/server/routes/admin/config.js +++ b/api/server/routes/admin/config.js @@ -2,6 +2,7 @@ const express = require('express'); const { createAdminConfigHandlers } = require('@librechat/api'); const { SystemCapabilities } = require('@librechat/data-schemas'); const { + hasCapability, hasConfigCapability, requireCapability, } = require('~/server/middleware/roles/capabilities'); @@ -18,10 +19,12 @@ const handlers = createAdminConfigHandlers({ findConfigByPrincipal: db.findConfigByPrincipal, upsertConfig: db.upsertConfig, patchConfigFields: db.patchConfigFields, + tombstoneConfigField: db.tombstoneConfigField, unsetConfigField: db.unsetConfigField, deleteConfig: db.deleteConfig, toggleConfigActive: db.toggleConfigActive, hasConfigCapability, + hasCapability, getAppConfig, invalidateConfigCaches, }); @@ -33,6 +36,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/grants.js b/api/server/routes/admin/grants.js index a0fa73dc430..f48c3ddd608 100644 --- a/api/server/routes/admin/grants.js +++ b/api/server/routes/admin/grants.js @@ -21,6 +21,9 @@ const handlers = createAdminGrantsHandlers({ getHeldCapabilities: db.getHeldCapabilities, getCachedPrincipals, checkRoleExists: async (name) => (await db.getRoleByName(name)) != null, + recordAuditEntry: db.recordAuditEntry, + /** Opt-in: fail the grant request if its audit entry can't be persisted. */ + auditFailClosed: process.env.AUDIT_LOG_FAIL_CLOSED === 'true', }); router.use(requireJwtAuth, requireAdminAccess); diff --git a/api/server/routes/admin/roles.js b/api/server/routes/admin/roles.js index f2bbd7f7ea3..5c6d4e92e8b 100644 --- a/api/server/routes/admin/roles.js +++ b/api/server/routes/admin/roles.js @@ -29,6 +29,7 @@ const handlers = createAdminRolesHandlers({ deleteConfig: db.deleteConfig, deleteAclEntries: db.deleteAclEntries, deleteGrantsForPrincipal: db.deleteGrantsForPrincipal, + recordAuditEntry: db.recordAuditEntry, }); router.use(requireJwtAuth, requireAdminAccess); 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 cccccedfd85..eab7c908aaf 100644 --- a/api/server/routes/agents/actions.js +++ b/api/server/routes/agents/actions.js @@ -1,7 +1,15 @@ const express = require('express'); const { nanoid } = require('nanoid'); const { logger } = require('@librechat/data-schemas'); -const { generateCheckAccess, isActionDomainAllowed } = require('@librechat/api'); +const { + generateCheckAccess, + planAgentActionUpdate, + isActionDomainAllowed, + legacyActionDomainEncode, + validateActionOAuthMetadata, + ACTION_CREDENTIAL_REFRESH_MESSAGE, + buildActionOAuthTokenDeleteQueries, +} = require('@librechat/api'); const { Permissions, ResourceType, @@ -12,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], @@ -47,6 +57,7 @@ router.get('/', async (req, res) => { const agentsResponse = await db.getListAgentsByAccess({ accessibleIds: editableAgentObjectIds, + limit: null, }); const editableAgentIds = agentsResponse.data.map((agent) => agent.id); @@ -87,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 @@ -130,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]]} */ @@ -147,47 +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: _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; - } + 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, + }); - actions.push(action); + if (plannedUpdate.requiresCredentialRefresh) { + return res.status(400).json({ + message: ACTION_CREDENTIAL_REFRESH_MESSAGE, + }); } - 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) + try { + await validateActionOAuthMetadata( + plannedUpdate.metadata.auth, + appConfig?.actions?.allowedAddresses, ); - }; + } catch (error) { + return res.status(400).json({ message: error.message }); + } - const tools = _tools - .filter((tool) => !shouldRemoveAgentTool(tool)) - .concat(functions.map((tool) => `${tool.function.name}${actionDelimiter}${encodedDomain}`)); + if (plannedUpdate.deleteOAuthTokens && requestedActionId) { + // Keep the callback URL stable while preventing old OAuth tokens from following a new target. + await deleteActionOAuthTokens(requestedActionId); + } // 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, @@ -195,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 bbb39f5d2c9..145a6c03161 100644 --- a/api/server/routes/agents/index.js +++ b/api/server/routes/agents/index.js @@ -1,5 +1,11 @@ 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 { uaParser, @@ -42,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 @@ -76,35 +80,43 @@ router.get('/chat/stream/:streamId', async (req, res) => { return res.status(403).json({ error: 'Unauthorized' }); } + const streamTelemetry = createSseStreamTelemetry({ req, res, streamId, isResume }); + res.setHeader('Content-Encoding', 'identity'); res.setHeader('Content-Type', 'text/event-stream'); res.setHeader('Cache-Control', 'no-cache, no-transform'); res.setHeader('Connection', 'keep-alive'); res.setHeader('X-Accel-Buffering', 'no'); res.flushHeaders(); + streamTelemetry.recordHeadersFlushed(); logger.debug(`[AgentStream] Client subscribed to ${streamId}, resume: ${isResume}`); - const writeEvent = (event) => { + const writeEvent = (event, options = {}) => { if (!res.writableEnded) { - res.write(`event: message\ndata: ${JSON.stringify(event)}\n\n`); + const eventName = options.eventName ?? 'message'; + const payload = `event: ${eventName}\ndata: ${JSON.stringify(event)}\n\n`; + res.write(payload); + streamTelemetry.recordWrite(payload, { final: options.final }); if (typeof res.flush === 'function') { res.flush(); } + return true; } + + return false; }; const onDone = (event) => { - writeEvent(event); + streamTelemetry.recordFinalEventEmitted(); + writeEvent(event, { final: true }); res.end(); }; const onError = (error) => { if (!res.writableEnded) { - res.write(`event: error\ndata: ${JSON.stringify({ error })}\n\n`); - if (typeof res.flush === 'function') { - res.flush(); - } + streamTelemetry.recordErrorEventEmitted(); + writeEvent({ error }, { eventName: 'error' }); res.end(); } }; @@ -117,12 +129,7 @@ router.get('/chat/stream/:streamId', async (req, res) => { if (!res.writableEnded) { if (resumeState) { - res.write( - `event: message\ndata: ${JSON.stringify({ sync: true, resumeState, pendingEvents })}\n\n`, - ); - if (typeof res.flush === 'function') { - res.flush(); - } + writeEvent({ sync: true, resumeState, pendingEvents }); GenerationJobManager.markSyncSent(streamId); logger.debug( `[AgentStream] Sent sync event for ${streamId} with ${resumeState.runSteps.length} run steps, ${pendingEvents.length} pending events`, @@ -143,6 +150,7 @@ router.get('/chat/stream/:streamId', async (req, res) => { } if (!result) { + streamTelemetry.recordSubscribeFailed(); onError('Failed to subscribe to stream'); return; } @@ -268,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 = { @@ -279,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, @@ -286,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( { @@ -309,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/agents/middleware.js b/api/server/routes/agents/middleware.js index f71c25c6f8f..efb4a8c0b0d 100644 --- a/api/server/routes/agents/middleware.js +++ b/api/server/routes/agents/middleware.js @@ -18,6 +18,7 @@ const apiKeyMiddleware = createRequireApiKeyAuth({ const requireRemoteAgentAuth = createRemoteAgentAuth({ apiKeyMiddleware, findUser: db.findUser, + getRolesByNames: db.findRolesByNames, updateUser: db.updateUser, getAppConfig, }); diff --git a/api/server/routes/assistants/actions.js b/api/server/routes/assistants/actions.js index 7ddaffe5e7b..7d35cc6a99f 100644 --- a/api/server/routes/assistants/actions.js +++ b/api/server/routes/assistants/actions.js @@ -1,7 +1,7 @@ const express = require('express'); const { nanoid } = require('nanoid'); const { logger } = require('@librechat/data-schemas'); -const { isActionDomainAllowed } = require('@librechat/api'); +const { isActionDomainAllowed, validateActionOAuthMetadata } = require('@librechat/api'); const { actionDelimiter, EModelEndpoint, removeNullishValues } = require('librechat-data-provider'); const { legacyDomainEncode, @@ -71,6 +71,12 @@ router.post('/:assistant_id', async (req, res) => { metadata = { ...action.metadata, ...metadata }; } + try { + await validateActionOAuthMetadata(metadata.auth, appConfig?.actions?.allowedAddresses); + } catch (error) { + return res.status(400).json({ message: error.message }); + } + if (!assistant) { return res.status(404).json({ message: 'Assistant not found' }); } 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/balance.js b/api/server/routes/balance.js index 87d84288806..70958244195 100644 --- a/api/server/routes/balance.js +++ b/api/server/routes/balance.js @@ -1,8 +1,17 @@ const express = require('express'); +const { createSetBalanceConfig } = require('@librechat/api'); const router = express.Router(); const controller = require('../controllers/Balance'); const { requireJwtAuth } = require('../middleware/'); +const { findBalanceByUser, upsertBalanceFields } = require('~/models'); +const { getAppConfig } = require('~/server/services/Config'); -router.get('/', requireJwtAuth, controller); +const setBalanceConfig = createSetBalanceConfig({ + getAppConfig, + findBalanceByUser, + upsertBalanceFields, +}); + +router.get('/', requireJwtAuth, setBalanceConfig, controller); module.exports = router; diff --git a/api/server/routes/config.js b/api/server/routes/config.js index 46f4cc09dac..f6eb66374e2 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -1,9 +1,18 @@ const express = require('express'); -const { isEnabled, getBalanceConfig, getCloudFrontConfig } = require('@librechat/api'); -const { defaultSocialLogins } = require('librechat-data-provider'); +const { + isEnabled, + getBalanceConfig, + getCloudFrontConfig, + resolveBuildInfo, + resolveTitleTiming, + sanitizeModelSpecs, + isFileSnapshotEnabled, +} = require('@librechat/api'); +const { EModelEndpoint, defaultSocialLogins } = require('librechat-data-provider'); const { logger, getTenantId, SystemCapabilities } = require('@librechat/data-schemas'); const { hasCapability } = require('~/server/middleware/roles/capabilities'); const { getLdapConfig } = require('~/server/services/Config/ldap'); +const { getRumConfig } = require('~/server/services/Config/rum'); const { getAppConfig } = require('~/server/services/Config/app'); const router = express.Router(); @@ -20,15 +29,29 @@ const publicSharedLinksEnabled = const sharePointFilePickerEnabled = isEnabled(process.env.ENABLE_SHAREPOINT_FILEPICKER); const openidReuseTokens = isEnabled(process.env.OPENID_REUSE_TOKENS); +/** + * Resolve build metadata eagerly at module load so the first `/api/config` + * request does not pay the cost of `execFileSync('git', ...)` on the hot path. + * The resolver caches its result after the first call. + */ +resolveBuildInfo(); + function isBirthday() { const today = new Date(); return today.getMonth() === 1 && today.getDate() === 11; } -function buildSharedPayload() { +/** + * Pre-login fields rendered by the unauthenticated login, registration, password-reset, + * and email-verification pages. Any field added here is readable by anonymous callers + * of `GET /api/config`, so keep this set strictly to what those pages need. + * + * See client consumers under `client/src/components/Auth/` and `client/src/routes/Layouts/Startup.tsx`. + */ +function buildPreLoginPayload() { const isOpenIdEnabled = !!process.env.OPENID_CLIENT_ID && - !!process.env.OPENID_CLIENT_SECRET && + (isEnabled(process.env.OPENID_USE_PKCE) || !!process.env.OPENID_CLIENT_SECRET?.trim()) && !!process.env.OPENID_ISSUER && !!process.env.OPENID_SESSION_SECRET; @@ -69,6 +92,47 @@ function buildSharedPayload() { !!process.env.EMAIL_PASSWORD && !!process.env.EMAIL_FROM, passwordResetEnabled, + }; + + const minPasswordLength = parseInt(process.env.MIN_PASSWORD_LENGTH, 10); + if (minPasswordLength && !isNaN(minPasswordLength)) { + payload.minPasswordLength = minPasswordLength; + } + + if (ldap) { + payload.ldap = ldap; + } + + return payload; +} + +/** + * Fields shared by authenticated chat and share-view config. Anonymous share + * views receive these through `/api/share/:shareId/config` after share access + * checks, not through the generic startup config endpoint. + */ +function buildPublicSharePayload() { + /** @type {Partial} */ + const payload = { + analyticsGtmId: process.env.ANALYTICS_GTM_ID, + }; + + if (typeof process.env.CUSTOM_FOOTER === 'string') { + payload.customFooter = process.env.CUSTOM_FOOTER; + } + + return payload; +} + +/** + * Post-login fields appended only when `req.user` is present. These describe the + * authenticated UX (account-settings links, share-link feature flags, birthday icon, + * openid token-reuse marker) and are not needed on the pre-login screens, so they + * are not exposed to unauthenticated callers. + */ +function buildPostLoginPayload() { + /** @type {Partial} */ + const payload = { showBirthdayIcon: isBirthday() || isEnabled(process.env.SHOW_BIRTHDAY_ICON) || @@ -76,7 +140,6 @@ function buildSharedPayload() { helpAndFaqURL: process.env.HELP_AND_FAQ_URL || 'https://librechat.ai', sharedLinksEnabled, publicSharedLinksEnabled, - analyticsGtmId: process.env.ANALYTICS_GTM_ID, openidReuseTokens, /** Read inline (not module-level) for per-request evaluation and test isolation */ allowAccountDeletion: @@ -84,20 +147,23 @@ function buildSharedPayload() { isEnabled(process.env.ALLOW_ACCOUNT_DELETION), }; - const minPasswordLength = parseInt(process.env.MIN_PASSWORD_LENGTH, 10); - if (minPasswordLength && !isNaN(minPasswordLength)) { - payload.minPasswordLength = minPasswordLength; - } + return payload; +} - if (ldap) { - payload.ldap = ldap; +function buildBuildInfoPayload(interfaceConfig) { + if (interfaceConfig?.buildInfo === false) { + return undefined; } - - if (typeof process.env.CUSTOM_FOOTER === 'string') { - payload.customFooter = process.env.CUSTOM_FOOTER; + const info = resolveBuildInfo(); + if (!info.commit && !info.branch && !info.buildDate) { + return undefined; } - - return payload; + return { + commit: info.commit, + commitShort: info.commitShort, + branch: info.branch, + buildDate: info.buildDate, + }; } function buildWebSearchConfig(appConfig) { @@ -138,8 +204,9 @@ function buildCloudFrontStartupConfig() { router.get('/', async function (req, res) { try { - const sharedPayload = buildSharedPayload(); - const cloudFront = buildCloudFrontStartupConfig(); + const preLoginPayload = buildPreLoginPayload(); + const publicSharePayload = buildPublicSharePayload(); + const rum = getRumConfig(); if (!req.user) { const tenantId = getTenantId(); @@ -147,14 +214,15 @@ router.get('/', async function (req, res) { /** @type {Partial} */ const payload = { - ...sharedPayload, + ...preLoginPayload, socialLogins: baseConfig?.registration?.socialLogins ?? defaultSocialLogins, turnstile: baseConfig?.turnstileConfig, - ...(cloudFront ? { cloudFront } : {}), + ...(rum ? { rum } : {}), }; const interfaceConfig = baseConfig?.interfaceConfig; - if (interfaceConfig?.privacyPolicy || interfaceConfig?.termsOfService) { + const buildInfoDisabled = interfaceConfig?.buildInfo === false; + if (interfaceConfig?.privacyPolicy || interfaceConfig?.termsOfService || buildInfoDisabled) { payload.interface = {}; if (interfaceConfig.privacyPolicy) { payload.interface.privacyPolicy = interfaceConfig.privacyPolicy; @@ -162,6 +230,14 @@ router.get('/', async function (req, res) { if (interfaceConfig.termsOfService) { payload.interface.termsOfService = interfaceConfig.termsOfService; } + if (buildInfoDisabled) { + payload.interface.buildInfo = false; + } + } + + const unauthBuildInfo = buildBuildInfoPayload(interfaceConfig); + if (unauthBuildInfo) { + payload.buildInfo = unauthBuildInfo; } return res.status(200).send(payload); @@ -174,14 +250,22 @@ router.get('/', async function (req, res) { }); const balanceConfig = getBalanceConfig(appConfig); + const cloudFront = buildCloudFrontStartupConfig(); /** @type {TStartupConfig} */ const payload = { - ...sharedPayload, + ...preLoginPayload, + ...publicSharePayload, + ...buildPostLoginPayload(), + sharedLinksSnapshotFilesEnabled: sharedLinksEnabled && isFileSnapshotEnabled(appConfig), socialLogins: appConfig?.registration?.socialLogins ?? defaultSocialLogins, interface: appConfig?.interfaceConfig, + titleGenerationTiming: resolveTitleTiming({ + appConfig, + endpoint: EModelEndpoint.agents, + }), turnstile: appConfig?.turnstileConfig, - modelSpecs: appConfig?.modelSpecs, + modelSpecs: sanitizeModelSpecs(appConfig?.modelSpecs), balance: balanceConfig, bundlerURL: process.env.SANDPACK_BUNDLER_URL, staticBundlerURL: process.env.SANDPACK_STATIC_BUNDLER_URL, @@ -193,6 +277,7 @@ router.get('/', async function (req, res) { ? parseInt(process.env.CONVERSATION_IMPORT_MAX_FILE_SIZE_BYTES, 10) : 0, ...(cloudFront ? { cloudFront } : {}), + ...(rum ? { rum } : {}), }; const webSearch = buildWebSearchConfig(appConfig); @@ -200,6 +285,11 @@ router.get('/', async function (req, res) { payload.webSearch = webSearch; } + const buildInfo = buildBuildInfoPayload(appConfig?.interfaceConfig); + if (buildInfo) { + payload.buildInfo = buildInfo; + } + if (!payload.allowAccountDeletion) { try { const userId = req.user.id ?? req.user._id?.toString(); diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index 3d65343648d..879b701d2e9 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); @@ -189,6 +202,34 @@ router.post('/archive', validateConvoAccess, async (req, res) => { } }); +router.post('/pin', validateConvoAccess, async (req, res) => { + const { conversationId, pinned } = req.body?.arg ?? {}; + + if (!conversationId) { + return res.status(400).json({ error: 'conversationId is required' }); + } + + if (pinned === undefined) { + return res.status(400).json({ error: 'pinned is required' }); + } + + if (typeof pinned !== 'boolean') { + return res.status(400).json({ error: 'pinned must be a boolean' }); + } + + try { + const dbResponse = await db.saveConvo( + { userId: req.user.id }, + { conversationId, pinned }, + { context: `POST /api/convos/pin ${conversationId}` }, + ); + res.status(200).json(dbResponse); + } catch (error) { + logger.error('Error pinning conversation', error); + res.status(500).send('Error pinning conversation'); + } +}); + /** Maximum allowed length for conversation titles */ const MAX_CONVO_TITLE_LENGTH = 1024; @@ -276,6 +317,7 @@ router.post( filepath: req.file.path, requestUserId: req.user.id, userRole: req.user.role, + interfaceConfig: req.config?.interfaceConfig, }); res.status(201).json({ message: 'Conversation(s) imported successfully' }); } catch (error) { diff --git a/api/server/routes/endpoints.js b/api/server/routes/endpoints.js index e7ff1c70000..ea55a9e54ac 100644 --- a/api/server/routes/endpoints.js +++ b/api/server/routes/endpoints.js @@ -1,9 +1,21 @@ 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 { contextProjectionLimiter } = require('~/server/middleware/limiters'); 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, + contextProjectionLimiter, + configMiddleware, + contextProjectionController, +); module.exports = router; diff --git a/api/server/routes/files/files.agents.test.js b/api/server/routes/files/files.agents.test.js index d2c76ea139a..664721f35be 100644 --- a/api/server/routes/files/files.agents.test.js +++ b/api/server/routes/files/files.agents.test.js @@ -14,7 +14,7 @@ const { createAgent, createFile } = require('~/models'); // Only mock the external dependencies that we don't want to test jest.mock('~/server/services/Files/process', () => ({ - processDeleteRequest: jest.fn().mockResolvedValue({}), + processDeleteRequest: jest.fn().mockResolvedValue({ deletedFileIds: [], failedFileIds: [] }), filterFile: jest.fn(), processFileUpload: jest.fn(), processAgentFileUpload: jest.fn().mockImplementation(async ({ res }) => { diff --git a/api/server/routes/files/files.test.js b/api/server/routes/files/files.test.js index 5758b77387d..473731de014 100644 --- a/api/server/routes/files/files.test.js +++ b/api/server/routes/files/files.test.js @@ -16,7 +16,7 @@ const { createAgent, createFile } = require('~/models'); // Only mock the external dependencies that we don't want to test jest.mock('~/server/services/Files/process', () => ({ - processDeleteRequest: jest.fn().mockResolvedValue({}), + processDeleteRequest: jest.fn().mockResolvedValue({ deletedFileIds: [], failedFileIds: [] }), filterFile: jest.fn(), processFileUpload: jest.fn(), processAgentFileUpload: jest.fn(), 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/files/preview.spec.js b/api/server/routes/files/preview.spec.js index 426b0697c68..36de49c223d 100644 --- a/api/server/routes/files/preview.spec.js +++ b/api/server/routes/files/preview.spec.js @@ -36,7 +36,7 @@ jest.mock('~/models', () => ({ jest.mock('~/server/services/Files/process', () => ({ filterFile: jest.fn(), processFileUpload: jest.fn(), - processDeleteRequest: jest.fn(), + processDeleteRequest: jest.fn().mockResolvedValue({ deletedFileIds: [], failedFileIds: [] }), processAgentFileUpload: jest.fn(), })); diff --git a/api/server/routes/index.js b/api/server/routes/index.js index cab2f92ed1d..ac2b38f579a 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -6,12 +6,15 @@ 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 adminAuditLog = require('./admin/audit'); 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'); @@ -32,8 +35,10 @@ const auth = require('./auth'); const keys = require('./keys'); const user = require('./user'); const mcp = require('./mcp'); +const rum = require('./rum'); module.exports = { + rum, mcp, auth, adminAuth, @@ -41,7 +46,9 @@ module.exports = { adminGrants, adminGroups, adminRoles, + adminSkills, adminUsers, + adminAuditLog, keys, apiKeys, user, @@ -57,6 +64,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 219f6455dc2..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], @@ -68,7 +93,7 @@ const checkMCPCreate = generateCheckAccess({ * Get all MCP tools available to the user * Returns only MCP tools, completely decoupled from regular LibreChat tools */ -router.get('/tools', requireJwtAuth, async (req, res) => { +router.get('/tools', requireJwtAuth, checkMCPUsePermissions, async (req, res) => { return getMCPTools(req, res); }); @@ -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 21b2b23feaf..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'); @@ -269,7 +274,7 @@ router.post('/artifact/:messageId', async (req, res) => { router.get('/:conversationId', validateMessageReq, async (req, res) => { try { const { conversationId } = req.params; - const messages = await db.getMessages({ conversationId }, '-_id -__v -user'); + const messages = await db.getMessages({ conversationId, user: req.user.id }, '-_id -__v -user'); res.status(200).json(messages); } catch (error) { logger.error('Error fetching messages:', error); @@ -279,7 +284,7 @@ router.get('/:conversationId', validateMessageReq, async (req, res) => { router.post('/:conversationId', validateMessageReq, async (req, res) => { try { - const message = req.body; + const message = { ...req.body, conversationId: req.params.conversationId }; const reqCtx = { userId: req?.user?.id, isTemporary: req?.body?.isTemporary, @@ -304,7 +309,10 @@ router.post('/:conversationId', validateMessageReq, async (req, res) => { router.get('/:conversationId/:messageId', validateMessageReq, async (req, res) => { try { const { conversationId, messageId } = req.params; - const message = await db.getMessages({ conversationId, messageId }, '-_id -__v -user'); + const message = await db.getMessages( + { conversationId, messageId, user: req.user.id }, + '-_id -__v -user', + ); if (!message) { return res.status(404).json({ error: 'Message not found' }); } @@ -331,7 +339,7 @@ router.put('/:conversationId/:messageId', validateMessageReq, async (req, res) = } const message = ( - await db.getMessages({ conversationId, messageId }, 'content tokenCount') + await db.getMessages({ conversationId, messageId, user: req.user.id }, 'content tokenCount') )?.[0]; if (!message) { return res.status(404).json({ error: 'Message not found' }); @@ -388,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/oauth.js b/api/server/routes/oauth.js index 53021580313..d5e4c939ea9 100644 --- a/api/server/routes/oauth.js +++ b/api/server/routes/oauth.js @@ -4,7 +4,13 @@ const passport = require('passport'); const { randomState } = require('openid-client'); const { logger } = require('@librechat/data-schemas'); const { ErrorTypes } = require('librechat-data-provider'); -const { createSetBalanceConfig } = require('@librechat/api'); +const { + buildOAuthFailureLog, + createOpenIDCallbackAuthenticator, + createSetBalanceConfig, + getOAuthFailureMessage, + redirectToAuthFailure, +} = require('@librechat/api'); const { checkDomainAllowed, loginLimiter, logHeaders } = require('~/server/middleware'); const { createOAuthHandler } = require('~/server/controllers/auth/oauth'); const { findBalanceByUser, upsertBalanceFields } = require('~/models'); @@ -23,19 +29,35 @@ const domains = { server: process.env.DOMAIN_SERVER, }; +const authFailureRedirectOptions = { + clientDomain: domains.client, + authFailedError: ErrorTypes.AUTH_FAILED, +}; + router.use(logHeaders); router.use(loginLimiter); const oauthHandler = createOAuthHandler(); +const authenticateOpenIDCallback = createOpenIDCallbackAuthenticator({ + passport, + logger, + ...authFailureRedirectOptions, +}); router.get('/error', (req, res) => { /** A single error message is pushed by passport when authentication fails. */ - const errorMessage = req.session?.messages?.pop() || 'Unknown OAuth error'; - logger.error('Error in OAuth authentication:', { - message: errorMessage, - }); - - res.redirect(`${domains.client}/login?redirect=false&error=${ErrorTypes.AUTH_FAILED}`); + const errorMessage = getOAuthFailureMessage(req); + logger.warn( + '[OAuth] Authentication failed', + buildOAuthFailureLog({ + provider: 'unknown', + req, + info: { message: errorMessage }, + defaultMessage: errorMessage, + }), + ); + + redirectToAuthFailure(res, authFailureRedirectOptions); }); /** @@ -100,11 +122,7 @@ router.get('/openid', (req, res, next) => { router.get( '/openid/callback', - passport.authenticate('openid', { - failureRedirect: `${domains.client}/oauth/error`, - failureMessage: true, - session: false, - }), + authenticateOpenIDCallback, setBalanceConfig, checkDomainAllowed, oauthHandler, diff --git a/api/server/routes/oauth.test.js b/api/server/routes/oauth.test.js new file mode 100644 index 00000000000..b6739ffdc57 --- /dev/null +++ b/api/server/routes/oauth.test.js @@ -0,0 +1,191 @@ +const express = require('express'); +const request = require('supertest'); + +const originalDomainClient = process.env.DOMAIN_CLIENT; +process.env.DOMAIN_CLIENT = 'http://client.test'; + +const mockLogger = { + warn: jest.fn(), + error: jest.fn(), + info: jest.fn(), + debug: jest.fn(), +}; + +const mockOAuthHandler = jest.fn((_req, res) => res.status(204).end()); +const mockOpenIDCallbackMiddleware = jest.fn((_req, _res, next) => next()); +let mockOpenIDCallbackAuthenticatorOptions; +const mockCreateOpenIDCallbackAuthenticator = jest.fn((options) => { + mockOpenIDCallbackAuthenticatorOptions = options; + return mockOpenIDCallbackMiddleware; +}); +const mockBuildOAuthFailureLog = jest.fn(({ provider, req, err, info, defaultMessage }) => ({ + provider, + code: err?.code ?? info?.code ?? info?.error ?? req.query?.error, + name: err?.name ?? info?.name, + message: + err?.message ?? + info?.message ?? + info?.error_description ?? + req.query?.error_description ?? + defaultMessage, + cause_code: err?.cause?.code ?? info?.cause?.code, + cause_name: err?.cause?.name ?? info?.cause?.name, + has_code: req.query?.code != null, + has_state: req.query?.state != null, + query_error: req.query?.error, + query_error_description: req.query?.error_description, + path: req.path, + forwarded_for: req.headers?.['x-forwarded-for'], + user_agent: req.headers?.['user-agent'], +})); +const mockGetOAuthFailureMessage = jest.fn( + (req) => + req.session?.messages?.pop() ?? + req.query?.error_description ?? + req.query?.error ?? + 'OAuth authentication failed', +); +const mockRedirectToAuthFailure = jest.fn((res, { clientDomain, authFailedError }) => + res.redirect(`${clientDomain}/login?redirect=false&error=${authFailedError}`), +); +const mockPassportAuthenticate = jest.fn(() => (_req, _res, next) => next()); + +jest.mock('passport', () => ({ + authenticate: (...args) => mockPassportAuthenticate(...args), +})); + +jest.mock('openid-client', () => ({ + randomState: jest.fn(() => 'random-state'), +})); + +jest.mock('@librechat/data-schemas', () => ({ + logger: mockLogger, +})); + +jest.mock('librechat-data-provider', () => ({ + ...jest.requireActual('librechat-data-provider'), + ErrorTypes: { + AUTH_FAILED: 'auth_failed', + }, +})); + +jest.mock('@librechat/api', () => ({ + buildOAuthFailureLog: (...args) => mockBuildOAuthFailureLog(...args), + createOpenIDCallbackAuthenticator: (...args) => mockCreateOpenIDCallbackAuthenticator(...args), + createSetBalanceConfig: jest.fn(() => (_req, _res, next) => next()), + getOAuthFailureMessage: (...args) => mockGetOAuthFailureMessage(...args), + redirectToAuthFailure: (...args) => mockRedirectToAuthFailure(...args), +})); + +jest.mock('~/server/middleware', () => ({ + checkDomainAllowed: jest.fn((_req, _res, next) => next()), + loginLimiter: jest.fn((_req, _res, next) => next()), + logHeaders: jest.fn((_req, _res, next) => next()), +})); + +jest.mock('~/server/controllers/auth/oauth', () => ({ + createOAuthHandler: jest.fn(() => mockOAuthHandler), +})); + +jest.mock('~/models', () => ({ + findBalanceByUser: jest.fn(), + upsertBalanceFields: jest.fn(), +})); + +jest.mock('~/server/services/Config', () => ({ + getAppConfig: jest.fn(), +})); + +afterAll(() => { + if (originalDomainClient === undefined) { + delete process.env.DOMAIN_CLIENT; + return; + } + process.env.DOMAIN_CLIENT = originalDomainClient; +}); + +function getOAuthRouter() { + jest.resetModules(); + return require('./oauth'); +} + +function createApp(sessionMessages) { + const app = express(); + app.use((req, _res, next) => { + if (sessionMessages) { + req.session = { messages: [...sessionMessages] }; + } + next(); + }); + app.use('/oauth', getOAuthRouter()); + app.use((err, _req, res, _next) => { + res.status(500).json({ message: err.message }); + }); + return app; +} + +describe('OAuth route failure logging', () => { + beforeEach(() => { + mockLogger.warn.mockClear(); + mockLogger.error.mockClear(); + mockLogger.info.mockClear(); + mockLogger.debug.mockClear(); + mockOAuthHandler.mockClear(); + mockOpenIDCallbackMiddleware.mockClear(); + mockBuildOAuthFailureLog.mockClear(); + mockGetOAuthFailureMessage.mockClear(); + mockRedirectToAuthFailure.mockClear(); + mockPassportAuthenticate.mockClear(); + mockOpenIDCallbackAuthenticatorOptions = undefined; + mockPassportAuthenticate.mockImplementation(() => (_req, _res, next) => next()); + mockOpenIDCallbackMiddleware.mockImplementation((_req, _res, next) => next()); + }); + + it('wires the package OpenID callback middleware into the route', async () => { + const app = createApp(); + + await request(app) + .get('/oauth/openid/callback?code=secret-code&state=secret-state') + .expect(204); + + expect(mockOpenIDCallbackAuthenticatorOptions).toEqual({ + passport: expect.objectContaining({ authenticate: expect.any(Function) }), + logger: mockLogger, + clientDomain: 'http://client.test', + authFailedError: 'auth_failed', + }); + expect(mockOpenIDCallbackMiddleware).toHaveBeenCalledWith( + expect.any(Object), + expect.any(Object), + expect.any(Function), + ); + expect(mockOAuthHandler).toHaveBeenCalled(); + }); + + it('logs structured fallback errors without using Unknown OAuth error', async () => { + const app = createApp(); + + const response = await request(app) + .get('/oauth/error?error=access_denied&error_description=Denied%20by%20provider') + .set('x-forwarded-for', '203.0.113.10') + .expect(302); + + expect(response.headers.location).toBe( + 'http://client.test/login?redirect=false&error=auth_failed', + ); + expect(mockLogger.warn).toHaveBeenCalledWith( + '[OAuth] Authentication failed', + expect.objectContaining({ + provider: 'unknown', + code: 'access_denied', + message: 'Denied by provider', + query_error: 'access_denied', + query_error_description: 'Denied by provider', + has_code: false, + has_state: false, + forwarded_for: '203.0.113.10', + }), + ); + expect(JSON.stringify(mockLogger.warn.mock.calls[0])).not.toContain('Unknown OAuth error'); + }); +}); 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 new file mode 100644 index 00000000000..cc5c2f281a4 --- /dev/null +++ b/api/server/routes/rum.js @@ -0,0 +1,28 @@ +const express = require('express'); +const { getRumProxyBodyLimit, isRumProxyEnabled, proxyRumRequest } = require('@librechat/api'); +const { requireRumProxyAuth } = require('~/server/middleware'); + +const router = express.Router(); +const rawOtlpBody = express.raw({ + limit: getRumProxyBodyLimit(), + type: ['application/x-protobuf', 'application/octet-stream'], +}); + +function requireRumProxyEnabled(_req, res, next) { + if (!isRumProxyEnabled()) { + return res.status(404).json({ message: 'RUM proxy is not configured' }); + } + + return next(); +} + +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 296644afded..1a15bd2f73c 100644 --- a/api/server/routes/share.js +++ b/api/server/routes/share.js @@ -1,33 +1,254 @@ +const mongoose = require('mongoose'); const express = require('express'); -const { isEnabled } = require('@librechat/api'); -const { logger } = require('@librechat/data-schemas'); const { + isEnabled, + generateCheckAccess, + grantCreationPermissions, + ensureLinkPermissions, + isFileSnapshotEnabled, + isFileSnapshotKillSwitchActive, + buildSharedLinkStartupPayload, + deleteSharedLinkWithCleanup, + updateSharedLinkPermissionsExpiration, + isActiveExpirationDate, + getSharedLinkExpiration, +} = require('@librechat/api'); +const { + logger, + getTenantId, + runAsSystem, + tenantStorage, + SYSTEM_TENANT_ID, + createTempChatExpirationDate, +} = require('@librechat/data-schemas'); +const { FileSources, PermissionTypes, Permissions } = require('librechat-data-provider'); +const { + getFiles, + updateFile, getSharedMessages, createSharedLink, updateSharedLink, - deleteSharedLink, getSharedLinks, getSharedLink, + getSharedLinkFile, + backfillSharedLinkFiles, + getRoleByName, } = require('~/models'); +const { getStrategyFunctions } = require('~/server/services/Files/strategies'); +const { cleanFileName, getContentDisposition } = require('~/server/utils/files'); +const canAccessSharedLink = require('~/server/middleware/canAccessSharedLink'); +const optionalShareFileAuth = require('~/server/middleware/optionalShareFileAuth'); +const optionalJwtAuth = require('~/server/middleware/optionalJwtAuth'); const requireJwtAuth = require('~/server/middleware/requireJwtAuth'); +const configMiddleware = require('~/server/middleware/config/app'); +const { getAppConfig } = require('~/server/services/Config/app'); const router = express.Router(); +const checkSharedLinksAccess = generateCheckAccess({ + permissionType: PermissionTypes.SHARED_LINKS, + permissions: [Permissions.CREATE], + getRoleByName, +}); + +const resolveSharedLinkExpiration = (req, conversationId) => + getSharedLinkExpiration( + { req, conversationId }, + { + getConvo: async (userId, sourceConversationId) => { + const Conversation = mongoose.models.Conversation; + return Conversation.findOne( + { conversationId: sourceConversationId, user: userId }, + 'isTemporary expiredAt', + ).lean(); + }, + createExpirationDate: createTempChatExpirationDate, + logger, + }, + ); + /** * Shared messages */ const allowSharedLinks = process.env.ALLOW_SHARED_LINKS === undefined || isEnabled(process.env.ALLOW_SHARED_LINKS); +/** Run within the snapshot file's tenant context (mirrors canAccessSharedLink). */ +const runWithTenant = (tenantId, fn) => + tenantId ? tenantStorage.run({ tenantId }, fn) : runAsSystem(fn); + +/** Mirrors the owner preview route: pending records older than this are swept to + * 'failed' on the next poll so the client poller terminates. */ +const PREVIEW_LAZY_SWEEP_CUTOFF_MS = 2 * 60 * 1000; + +const getShareStartupPayload = async () => { + const tenantId = getTenantId(); + const appConfig = await getAppConfig( + tenantId && tenantId !== SYSTEM_TENANT_ID ? { tenantId } : { baseOnly: true }, + ); + return buildSharedLinkStartupPayload(appConfig); +}; + +/** + * MIME types that are safe to render inline. Everything else (text/html, SVG, + * and other active content) is served as an `attachment` so a public viewer + * can't execute uploaded bytes under the app origin by opening the URL directly. + */ +const SAFE_INLINE_TYPES = new Set([ + 'image/png', + 'image/jpeg', + 'image/jpg', + 'image/gif', + 'image/webp', + 'image/bmp', + 'image/avif', + 'image/x-icon', + 'application/pdf', +]); + +/** + * Resolve a snapshotted file for a shared link. A file_id absent from the + * share's snapshot is denied (404) — this prevents a viewer from reaching files + * outside the shared-link snapshot. Only legacy shares (no `fileSnapshots` field + * at all) trigger a lazy backfill; an ordinary miss does not rebuild. The live + * file record is also required: if the original was deleted/expired, return a + * clean 404 instead of letting the stream error after headers are sent. + */ +const resolveShareFile = async (req, res, next) => { + try { + // Global kill switch only (env-based, viewer-independent): disabling stops + // serving for every link. The viewer's own config must NOT affect serving. + if (isFileSnapshotKillSwitchActive()) { + return res.status(404).json({ message: 'Shared file access is disabled' }); + } + + const { shareId, file_id } = req.params; + const { file, hasSnapshots, optedOut } = await getSharedLinkFile(shareId, file_id); + // Per-link opt-out: never serve and never backfill an opted-out link. + if (optedOut) { + return res.status(404).json({ message: 'File not found in shared link' }); + } + let snapshot = file; + if (!snapshot && !hasSnapshots) { + snapshot = await backfillSharedLinkFiles(shareId, file_id); + } + if (!snapshot) { + logger.warn( + `[shareFileAccess] File ${file_id} not in snapshot for share ${shareId} (route ${req.originalUrl})`, + ); + return res.status(404).json({ message: 'File not found in shared link' }); + } + + const [liveFile] = await getFiles({ file_id }, null, {}); + if (!liveFile) { + logger.warn( + `[shareFileAccess] Snapshotted file ${file_id} no longer available for share ${shareId}`, + ); + return res.status(404).json({ message: 'File no longer available' }); + } + + // Pin to the snapshotted version so an old link can't surface post-share content + // after a reused file_id (e.g. code-exec same-filename outputs) is overwritten. + // previewRevision changes for deferred/office files; `bytes` catches other + // overwrites that change size, and is stable across S3 URL refresh and the + // pending->ready transition (which don't alter file size). Same-size content + // swaps remain a best-effort gap inherent to the no-byte-copy design. + const revisionChanged = + (snapshot.previewRevision ?? null) !== (liveFile.previewRevision ?? null); + const bytesChanged = + snapshot.bytes != null && liveFile.bytes != null && snapshot.bytes !== liveFile.bytes; + if (revisionChanged || bytesChanged) { + logger.warn( + `[shareFileAccess] Snapshot version mismatch for file ${file_id} (share ${shareId})`, + ); + return res.status(404).json({ message: 'File no longer available' }); + } + + req.shareFile = snapshot; + req.liveFile = liveFile; + return next(); + } catch (error) { + logger.error('[shareFileAccess] Error resolving shared file:', error); + return res.status(500).json({ message: 'Error resolving shared file' }); + } +}; + +/** Stream (or redirect to) a snapshotted file from its original stored object. */ +const streamSharedFile = async (req, res, file, requestedDisposition) => { + const source = file.source || FileSources.local; + const { getDownloadStream, getDownloadURL } = getStrategyFunctions(source); + + // Inline only safe preview types; anything else is forced to attachment. + const disposition = + requestedDisposition === 'inline' && SAFE_INLINE_TYPES.has(file.type) ? 'inline' : 'attachment'; + + // Redirect to a signed storage URL only when explicitly requested (?direct=true); + // by default stream through the server so blob (XHR) callers work without bucket CORS. + const isDirectSource = source === FileSources.s3 || source === FileSources.cloudfront; + if (req.query.direct === 'true' && getDownloadURL && isDirectSource) { + try { + const url = await getDownloadURL({ + req, + file, + customFilename: cleanFileName(file.filename), + contentType: file.type || 'application/octet-stream', + }); + if (url) { + res.setHeader('Cache-Control', 'no-store'); + return res.redirect(302, url); + } + } catch (error) { + logger.warn('[shareFileAccess] download URL generation failed, streaming instead:', error); + } + } + + if (!getDownloadStream) { + return res.status(501).send('Not Implemented'); + } + + // Strip any cache-busting query string (e.g. code-output images add `?v=...`) so + // the local stream resolves the real filename, not a literal `*.png?v=...` path. + const streamPath = (file.storageKey || file.filepath || '').split('?')[0]; + const fileStream = await getDownloadStream(req, streamPath); + fileStream.on('error', (error) => { + logger.error('[shareFileAccess] Stream error:', error); + }); + + res.setHeader('X-Content-Type-Options', 'nosniff'); + res.setHeader('Content-Disposition', getContentDisposition(file.filename, disposition)); + res.setHeader( + 'Content-Type', + disposition === 'inline' ? file.type || 'application/octet-stream' : 'application/octet-stream', + ); + res.setHeader('Cache-Control', 'private, max-age=3600'); + return fileStream.pipe(res); +}; + if (allowSharedLinks) { - const allowSharedLinksPublic = isEnabled(process.env.ALLOW_SHARED_LINKS_PUBLIC); + router.get('/:shareId/config', optionalJwtAuth, canAccessSharedLink, async (_req, res) => { + try { + const payload = await getShareStartupPayload(); + res.set('Cache-Control', 'private, no-store'); + res.status(200).json(payload); + } catch (error) { + logger.error('Error getting shared startup config:', error); + res.status(500).json({ message: 'Error getting shared startup config' }); + } + }); + router.get( '/:shareId', - allowSharedLinksPublic ? (req, res, next) => next() : requireJwtAuth, + optionalJwtAuth, + canAccessSharedLink, + configMiddleware, async (req, res) => { try { - const share = await getSharedMessages(req.params.shareId); - + const share = await getSharedMessages(req.params.shareId, req.shareResourceId, { + // Viewer-independent: the per-link choice (stored on the share) decides + // file inclusion; only a global env kill switch can force it off here. + snapshotFiles: !isFileSnapshotKillSwitchActive(), + }); if (share) { + res.set('Cache-Control', 'private, no-store'); res.status(200).json(share); } else { res.status(404).end(); @@ -38,6 +259,97 @@ if (allowSharedLinks) { } }, ); + + /** + * Preview status for a snapshotted file. Read live from the file record so the + * status is always current (deferred previews may resolve after the share was + * created) and large extracted text is never embedded in the share document. + */ + router.get( + '/:shareId/files/:file_id/preview', + optionalJwtAuth, + optionalShareFileAuth, + canAccessSharedLink, + configMiddleware, + resolveShareFile, + async (req, res) => { + try { + const { file_id } = req.params; + let liveFile = req.liveFile; + // Lazy-sweep orphaned pending records to 'failed' so the client preview + // poller reaches a terminal state (mirrors the owner preview route). + if (liveFile?.status === 'pending' && liveFile.updatedAt instanceof Date) { + const ageMs = Date.now() - liveFile.updatedAt.getTime(); + if (ageMs > PREVIEW_LAZY_SWEEP_CUTOFF_MS) { + const swept = await updateFile( + { file_id, status: 'failed', previewError: 'orphaned' }, + { status: 'pending', updatedAt: liveFile.updatedAt }, + ); + if (swept) { + liveFile = swept; + } + } + } + const status = liveFile?.status || 'ready'; + const payload = { file_id, status }; + if (status === 'ready' && liveFile?.text != null) { + payload.text = liveFile.text; + payload.textFormat = liveFile.textFormat ?? null; + } else if (status === 'failed' && liveFile?.previewError) { + payload.previewError = liveFile.previewError; + } + res.set('Cache-Control', 'private, no-store'); + return res.status(200).json(payload); + } catch (error) { + logger.error('[shareFileAccess] Error fetching shared preview:', error); + return res.status(500).json({ message: 'Error fetching preview' }); + } + }, + ); + + /** Download a snapshotted file (attachment disposition). */ + router.get( + '/:shareId/files/:file_id/download', + optionalJwtAuth, + optionalShareFileAuth, + canAccessSharedLink, + configMiddleware, + resolveShareFile, + async (req, res) => { + try { + await runWithTenant(req.shareFile.tenantId, () => + streamSharedFile(req, res, req.shareFile, 'attachment'), + ); + } catch (error) { + logger.error('[shareFileAccess] Error downloading shared file:', error); + if (!res.headersSent) { + res.status(500).send('Error downloading file'); + } + } + }, + ); + + /** Inline-serve a snapshotted file (image src, generic view). */ + router.get( + '/:shareId/files/:file_id', + optionalJwtAuth, + optionalShareFileAuth, + canAccessSharedLink, + configMiddleware, + resolveShareFile, + async (req, res) => { + try { + await runWithTenant(req.shareFile.tenantId, () => + streamSharedFile(req, res, req.shareFile, 'inline'), + ); + } catch (error) { + logger.error('[shareFileAccess] Error serving shared file:', error); + if (!res.headersSent) { + res.status(500).send('Error serving file'); + } + } + }, + ); } /** @@ -48,7 +360,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 @@ -60,7 +371,6 @@ router.get('/', requireJwtAuth, async (req, res) => { req.user.id, params.pageParam, params.pageSize, - params.isPublic, params.sortBy, params.sortDirection, params.search, @@ -84,9 +394,16 @@ 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, + snapshotFiles: share.snapshotFiles, conversationId: req.params.conversationId, }); } catch (error) { @@ -95,25 +412,77 @@ router.get('/link/:conversationId', requireJwtAuth, async (req, res) => { } }); -router.post('/:conversationId', requireJwtAuth, async (req, res) => { - try { - const { targetMessageId } = req.body; - const created = await createSharedLink(req.user.id, req.params.conversationId, targetMessageId); - if (created) { - res.status(200).json(created); - } else { - res.status(404).end(); +router.post( + '/:conversationId', + requireJwtAuth, + configMiddleware, + checkSharedLinksAccess, + async (req, res) => { + try { + const { targetMessageId } = req.body; + const expiredAt = await resolveSharedLinkExpiration(req, req.params.conversationId); + if (expiredAt != null && !isActiveExpirationDate(expiredAt)) { + 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; + // Per-link opt-out: snapshot only when the feature is enabled AND the user + // did not uncheck "share files" (body flag absent defaults to enabled). + const snapshotFiles = isFileSnapshotEnabled(req.config) && req.body?.snapshotFiles !== false; + + const created = await createSharedLink( + req.user.id, + req.params.conversationId, + targetMessageId, + expiredAt, + snapshotFiles, + ); + if (created) { + await grantCreationPermissions(created._id, req.user.id, grantPublic, expiredAt); + res.status(200).json(created); + } else { + res.status(404).end(); + } + } catch (error) { + logger.error('Error creating shared link:', error); + res.status(500).json({ message: 'Error creating shared link' }); } - } catch (error) { - logger.error('Error creating shared link:', error); - res.status(500).json({ message: 'Error creating shared link' }); - } -}); + }, +); -router.patch('/:shareId', requireJwtAuth, async (req, res) => { +router.patch('/:shareId', requireJwtAuth, configMiddleware, async (req, res) => { try { - const updatedShare = await updateSharedLink(req.user.id, req.params.shareId); + const { targetMessageId } = req.body ?? {}; + if (targetMessageId !== undefined && typeof targetMessageId !== 'string') { + return res.status(400).json({ message: 'targetMessageId must be a string' }); + } + + let expiredAt; + const SharedLink = mongoose.models.SharedLink; + const existing = await SharedLink.findOne( + { shareId: req.params.shareId, user: req.user.id }, + 'conversationId', + ).lean(); + if (existing?.conversationId) { + expiredAt = await resolveSharedLinkExpiration(req, existing.conversationId); + } + if (expiredAt != null && !isActiveExpirationDate(expiredAt)) { + return res.status(404).end(); + } + + const updatedShare = await updateSharedLink( + req.user.id, + req.params.shareId, + targetMessageId, + expiredAt, + isFileSnapshotEnabled(req.config) && req.body?.snapshotFiles !== false, + ); if (updatedShare) { + if (updatedShare._id && expiredAt !== undefined) { + await updateSharedLinkPermissionsExpiration(updatedShare._id, expiredAt); + } res.status(200).json(updatedShare); } else { res.status(404).end(); @@ -126,7 +495,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 859496bf7c2..db8a536d8e5 100644 --- a/api/server/services/ActionService.js +++ b/api/server/services/ActionService.js @@ -9,6 +9,7 @@ const { refreshAccessToken, GenerationJobManager, createSSRFSafeAgents, + validateActionOAuthMetadata, } = require('@librechat/api'); const { Time, @@ -28,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; @@ -203,6 +204,8 @@ async function createActionTool({ if (metadata.auth && metadata.auth.type !== AuthTypeEnum.None) { try { if (metadata.auth.type === AuthTypeEnum.OAuth && metadata.auth.authorization_url) { + await validateActionOAuthMetadata(metadata.auth, allowedAddresses); + const action_id = action.action_id; const identifier = `${userId}:${action.action_id}`; const requestLogin = async () => { @@ -240,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', @@ -266,6 +269,7 @@ async function createActionTool({ client_url: metadata.auth.client_url, redirect_uri: `${process.env.DOMAIN_SERVER}/api/actions/${action_id}/oauth/callback`, token_exchange_method: metadata.auth.token_exchange_method, + allowedAddresses, /** Encrypted values */ encrypted_oauth_client_id: encrypted.oauth_client_id, encrypted_oauth_client_secret: encrypted.oauth_client_secret, @@ -328,6 +332,7 @@ async function createActionTool({ encrypted_oauth_client_id: encrypted.oauth_client_id, token_exchange_method: metadata.auth.token_exchange_method, encrypted_oauth_client_secret: encrypted.oauth_client_secret, + allowedAddresses, }, { findToken, @@ -336,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/Artifacts/update.js b/api/server/services/Artifacts/update.js index be1644b11c4..11831c96e8b 100644 --- a/api/server/services/Artifacts/update.js +++ b/api/server/services/Artifacts/update.js @@ -1,130 +1,9 @@ -const ARTIFACT_START = ':::artifact'; -const ARTIFACT_END = ':::'; - -/** - * Find all artifact boundaries in the message - * @param {TMessage} message - * @returns {Array<{start: number, end: number, source: 'content'|'text', partIndex?: number}>} - */ -const findAllArtifacts = (message) => { - const artifacts = []; - - // Check content parts first - if (message.content?.length) { - message.content.forEach((part, partIndex) => { - if (part.type === 'text' && typeof part.text === 'string') { - let currentIndex = 0; - let start = part.text.indexOf(ARTIFACT_START, currentIndex); - - while (start !== -1) { - const end = part.text.indexOf(ARTIFACT_END, start + ARTIFACT_START.length); - artifacts.push({ - start, - end: end !== -1 ? end + ARTIFACT_END.length : part.text.length, - source: 'content', - partIndex, - text: part.text, - }); - - currentIndex = end !== -1 ? end + ARTIFACT_END.length : part.text.length; - start = part.text.indexOf(ARTIFACT_START, currentIndex); - } - } - }); - } - - // Check message.text if no content parts - if (!artifacts.length && message.text) { - let currentIndex = 0; - let start = message.text.indexOf(ARTIFACT_START, currentIndex); - - while (start !== -1) { - const end = message.text.indexOf(ARTIFACT_END, start + ARTIFACT_START.length); - artifacts.push({ - start, - end: end !== -1 ? end + ARTIFACT_END.length : message.text.length, - source: 'text', - text: message.text, - }); - - currentIndex = end !== -1 ? end + ARTIFACT_END.length : message.text.length; - start = message.text.indexOf(ARTIFACT_START, currentIndex); - } - } - - return artifacts; -}; - -const replaceArtifactContent = (originalText, artifact, original, updated) => { - const artifactContent = artifact.text.substring(artifact.start, artifact.end); - - // Find boundaries between ARTIFACT_START and ARTIFACT_END - const contentStart = artifactContent.indexOf('\n', artifactContent.indexOf(ARTIFACT_START)) + 1; - let contentEnd = artifactContent.lastIndexOf(ARTIFACT_END); - - // Special case: if contentEnd is 0, it means the only ::: found is at the start of :::artifact - // This indicates an incomplete artifact (no closing :::) - // We need to check that it's exactly at position 0 (the beginning of artifactContent) - if (contentEnd === 0 && artifactContent.indexOf(ARTIFACT_START) === 0) { - contentEnd = artifactContent.length; - } - - if (contentStart === -1 || contentEnd === -1) { - return null; - } - - // Check if there are code blocks - handle both ```\n and ```lang\n formats - let codeBlockStart = artifactContent.indexOf('```', contentStart); - const codeBlockEnd = artifactContent.lastIndexOf('\n```', contentEnd); - - // If we found opening backticks, find the actual newline (skipping any language identifier) - if (codeBlockStart !== -1) { - const newlineAfterBackticks = artifactContent.indexOf('\n', codeBlockStart); - if (newlineAfterBackticks !== -1 && newlineAfterBackticks < contentEnd) { - codeBlockStart = newlineAfterBackticks; - } else { - codeBlockStart = -1; - } - } - - // Determine where to look for the original content - let searchStart, searchEnd; - if (codeBlockStart !== -1) { - // Code block starts - searchStart is right after the newline following ```[lang] - searchStart = codeBlockStart + 1; // after the newline - - if (codeBlockEnd !== -1 && codeBlockEnd > codeBlockStart) { - // Code block has proper ending - searchEnd = codeBlockEnd; - } else { - // No closing backticks found or they're before the opening (shouldn't happen) - // This might be an incomplete artifact - search to contentEnd - searchEnd = contentEnd; - } - } else { - // No code blocks at all - searchStart = contentStart; - searchEnd = contentEnd; - } - - const innerContent = artifactContent.substring(searchStart, searchEnd); - // Remove trailing newline from original for comparison - const originalTrimmed = original.replace(/\n$/, ''); - const relativeIndex = innerContent.indexOf(originalTrimmed); - - if (relativeIndex === -1) { - return null; - } - - const absoluteIndex = artifact.start + searchStart + relativeIndex; - const endText = originalText.substring(absoluteIndex + originalTrimmed.length); - const hasTrailingNewline = endText.startsWith('\n'); - - const updatedText = - originalText.substring(0, absoluteIndex) + updated + (hasTrailingNewline ? '' : '\n') + endText; - - return updatedText.replace(/\n+(?=```\n:::)/g, '\n'); -}; +const { + ARTIFACT_START, + ARTIFACT_END, + findAllArtifacts, + replaceArtifactContent, +} = require('@librechat/api'); module.exports = { ARTIFACT_START, diff --git a/api/server/services/Artifacts/update.spec.js b/api/server/services/Artifacts/update.spec.js index 39a4f02863e..59cd1325a93 100644 --- a/api/server/services/Artifacts/update.spec.js +++ b/api/server/services/Artifacts/update.spec.js @@ -75,6 +75,85 @@ describe('findAllArtifacts', () => { expect(result).toHaveLength(2); expect(result[1].start).toBeGreaterThan(result[0].end); }); + + test('should ignore artifact close markers inside fenced content', () => { + const content = 'before\n:::\nafter'; + const artifactText = `${ARTIFACT_START}{identifier="markdown" type="text/markdown" title="Markdown"} +\`\`\`markdown +${content} +\`\`\` +${ARTIFACT_END}`; + const message = { text: `${artifactText}\ntrailer` }; + + const result = findAllArtifacts(message); + + expect(result).toHaveLength(1); + expect(result[0].end).toBe(artifactText.length); + }); + + test('should allow trailing text after an artifact close marker', () => { + const artifactText = `${ARTIFACT_START}{identifier="plain" type="text/plain" title="Plain"} +content +${ARTIFACT_END}Thanks for reading`; + const message = { text: `${artifactText}\n${createArtifactText({ content: 'next' })}` }; + + const result = findAllArtifacts(message); + + expect(result).toHaveLength(2); + expect(message.text.slice(result[0].end, result[0].end + 18)).toBe('Thanks for reading'); + }); + + test('should not end an unclosed artifact at an internal marker in a closed fence', () => { + const artifactText = `${ARTIFACT_START}{identifier="markdown" type="text/markdown" title="Markdown"} +\`\`\`markdown +before +::: +after +\`\`\` +trailer`; + const message = { text: artifactText }; + + const result = findAllArtifacts(message); + + expect(result).toHaveLength(1); + expect(result[0].end).toBe(artifactText.length); + }); + + test('should keep artifact start markers inside fenced content', () => { + const artifactText = `${ARTIFACT_START}{identifier="markdown" type="text/markdown" title="Markdown"} +\`\`\`markdown +before +::: +:::artifact{identifier="sample" type="text/plain" title="Sample"} +after +\`\`\` +${ARTIFACT_END}`; + const message = { text: artifactText }; + + const result = findAllArtifacts(message); + + expect(result).toHaveLength(1); + expect(result[0].end).toBe(artifactText.length); + }); + + test('should preserve the first fallback close in an unclosed fence', () => { + const firstArtifact = `${ARTIFACT_START}{identifier="first" type="text/html" title="First"} +\`\`\`html +
first
+${ARTIFACT_END}`; + const secondArtifact = createArtifactText({ + content: '
second
', + wrapCode: false, + prefix: '{identifier="second" type="text/html" title="Second"}', + }); + const message = { text: `${firstArtifact}\n${secondArtifact}` }; + + const result = findAllArtifacts(message); + + expect(result).toHaveLength(2); + expect(result[0].end).toBe(firstArtifact.length); + expect(result[1].start).toBe(firstArtifact.length + 1); + }); }); describe('replaceArtifactContent', () => { @@ -143,6 +222,49 @@ describe('replaceArtifactContent', () => { expect(result).toBe(`${ARTIFACT_START}\n${updated}\n${ARTIFACT_END}`); }); + + test('should replace markdown artifacts with internal code fences', () => { + const original = `# Notes + +\`\`\`js +console.log('inside'); +\`\`\` + +Done`; + const updated = original.replace('Done', 'Updated'); + const artifactText = `${ARTIFACT_START}{identifier="notes" type="text/markdown" title="Notes"} +${original} +${ARTIFACT_END}`; + const message = { text: artifactText }; + const artifacts = findAllArtifacts(message); + + const result = replaceArtifactContent(artifactText, artifacts[0], original, updated); + + expect(result).not.toBeNull(); + expect(result).toContain('Updated'); + expect(result).toContain("console.log('inside');"); + }); + + test('should replace unclosed artifacts with internal markers in fenced content', () => { + const original = `before +\`\`\`markdown +inside +::: +still inside +\`\`\` +after`; + const artifactText = `${ARTIFACT_START}{identifier="notes" type="text/markdown" title="Notes"} +${original}`; + const message = { text: artifactText }; + const artifacts = findAllArtifacts(message); + const updated = original.replace('after', 'updated'); + + const result = replaceArtifactContent(artifactText, artifacts[0], original, updated); + + expect(result).not.toBeNull(); + expect(result).toContain('updated'); + expect(result).toContain(':::'); + }); }); describe('replaceArtifactContent with shared text', () => { @@ -375,6 +497,26 @@ ${original}`; expect(result).toContain('UPDATED'); }); + test('should handle complete artifact marker with unclosed wrapping code block', () => { + const original = '# Markdown document\n\n```js\nconsole.log("missing closing fence");'; + const artifactText = `${ARTIFACT_START}{identifier="doc" type="text/markdown" title="Doc"} +\`\`\`markdown +${original} +${ARTIFACT_END}`; + const message = { text: artifactText }; + const artifacts = findAllArtifacts(message); + + expect(artifacts).toHaveLength(1); + expect(artifacts[0].end).toBe(artifactText.length); + + const updated = original.replace('Markdown document', 'Updated document'); + const result = replaceArtifactContent(artifactText, artifacts[0], original, updated); + + expect(result).not.toBeNull(); + expect(result).toContain('Updated document'); + expect(result).toContain(ARTIFACT_END); + }); + test('should handle incomplete artifacts without code blocks', () => { const original = 'Some plain text content'; const incompleteArtifact = `${ARTIFACT_START}{id="test"}\n${original}`; diff --git a/api/server/services/AuthService.js b/api/server/services/AuthService.js index 943c5a81f6f..8f6c281ee1d 100644 --- a/api/server/services/AuthService.js +++ b/api/server/services/AuthService.js @@ -3,6 +3,7 @@ const jwt = require('jsonwebtoken'); const { webcrypto } = require('node:crypto'); const { logger, + getTenantId, DEFAULT_SESSION_EXPIRY, DEFAULT_REFRESH_TOKEN_EXPIRY, } = require('@librechat/data-schemas'); @@ -44,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; @@ -132,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, @@ -146,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); @@ -173,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' }; }; @@ -191,13 +324,13 @@ const verifyEmail = async (req) => { /** * Register a new user. * @param {IUser} user - * @param {Partial} [additionalData={}] + * @param {Partial} [additionalData={}] Trusted server-provided fields, such as CLI overrides. * @returns {Promise<{status: number, message: string, user?: IUser}>} */ const registerUser = async (user, additionalData = {}) => { - const { error } = registerSchema.safeParse(user); - if (error) { - const errorMessage = errorsToString(error.errors); + const result = registerSchema.safeParse(user); + if (!result.success) { + const errorMessage = errorsToString(result.error.errors); logger.info( 'Route: register - Validation Error', { name: 'Request params:', value: user }, @@ -207,11 +340,13 @@ const registerUser = async (user, additionalData = {}) => { return { status: 404, message: errorMessage }; } - const { email, password, name, username, provider } = user; + const { email, password, name, username } = result.data; + const { provider, ...trustedAdditionalData } = additionalData ?? {}; let newUserId; try { - const appConfig = await getAppConfig({ baseOnly: true }); + const tenantId = getTenantId(); + const appConfig = await getAppConfig(tenantId ? { tenantId } : {}); if (!isEmailDomainAllowed(email, appConfig?.registration?.allowedDomains)) { const errorMessage = 'The email address provided cannot be used. Please use a different email address.'; @@ -245,7 +380,7 @@ const registerUser = async (user, additionalData = {}) => { avatar: null, role: isFirstRegisteredUser ? SystemRoles.ADMIN : SystemRoles.USER, password: bcrypt.hashSync(password, salt), - ...additionalData, + ...trustedAdditionalData, }; const emailEnabled = checkEmailConfig(); @@ -334,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, @@ -383,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'); @@ -416,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' }; }; @@ -448,6 +582,8 @@ const getCloudFrontAuthCookieSkipReason = (scope) => { return null; }; +const shouldLogCloudFrontAuthCookieSkip = (reason) => reason !== 'cloudfront_disabled'; + /** * Refreshes CloudFront signed cookies for authenticated image/avatar access. * @param {ServerRequest | null} req @@ -477,15 +613,17 @@ const setCloudFrontAuthCookies = (req, res, user, options = {}) => { }; const skipReason = getCloudFrontAuthCookieSkipReason(scope); if (skipReason) { - logger.debug('[setCloudFrontAuthCookies] CloudFront auth cookies skipped', { - attempted: false, - set: false, - reason: skipReason, - has_user_id: Boolean(scope.userId), - has_tenant_scope: Boolean(scope.tenantId), - has_storage_region: Boolean(scope.storageRegion), - has_previous_scope: Boolean(getPreviousCloudFrontScope(req)?.userId), - }); + if (shouldLogCloudFrontAuthCookieSkip(skipReason)) { + logger.debug('[setCloudFrontAuthCookies] CloudFront auth cookies skipped', { + attempted: false, + set: false, + reason: skipReason, + has_user_id: Boolean(scope.userId), + has_tenant_scope: Boolean(scope.tenantId), + has_storage_region: Boolean(scope.storageRegion), + has_previous_scope: Boolean(getPreviousCloudFrontScope(req)?.userId), + }); + } return false; } @@ -717,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) { @@ -725,6 +862,8 @@ const resendVerificationEmail = async (req) => { return { status: 200, message: genericVerificationMessage }; } + await deleteEmailVerificationTokens(user); + const [verifyToken, hash] = createTokenHash(); const verificationLink = `${ @@ -746,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 c81b78e8e15..03579e7278f 100644 --- a/api/server/services/AuthService.spec.js +++ b/api/server/services/AuthService.spec.js @@ -1,31 +1,44 @@ -jest.mock('@librechat/data-schemas', () => ({ - logger: { info: jest.fn(), warn: jest.fn(), debug: jest.fn(), error: jest.fn() }, - 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(), @@ -42,11 +55,25 @@ jest.mock('~/models', () => ({ deleteUserById: jest.fn(), generateRefreshToken: jest.fn(), })); -jest.mock('~/strategies/validators', () => ({ registerSchema: { parse: jest.fn() } })); +jest.mock('~/strategies/validators', () => ({ + registerSchema: { + safeParse: jest.fn((user) => ({ + success: true, + data: { + name: user.name, + username: user.username, + email: user.email, + password: user.password, + confirm_password: user.confirm_password, + }, + })), + }, +})); jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn() })); jest.mock('~/server/utils', () => ({ sendEmail: jest.fn() })); const { + checkEmailConfig, shouldUseSecureCookie, isEmailDomainAllowed, resolveAppConfigForUser, @@ -55,20 +82,32 @@ const { parseCloudFrontCookieScope, } = require('@librechat/api'); const jwt = require('jsonwebtoken'); -const { logger } = require('@librechat/data-schemas'); +const { logger, getTenantId } = require('@librechat/data-schemas'); const { findUser, + findToken, + createUser, + updateUser, + countUsers, getUserById, 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 */ @@ -381,6 +420,161 @@ describe('setOpenIDAuthTokens', () => { }); }); +describe('registerUser', () => { + const registrationPayload = { + name: 'Test User', + username: 'testuser', + email: 'test@example.com', + password: 'Password123!', + confirm_password: 'Password123!', + }; + + beforeEach(() => { + jest.clearAllMocks(); + process.env.ALLOW_UNVERIFIED_EMAIL_LOGIN = 'false'; + checkEmailConfig.mockReturnValue(false); + isEmailDomainAllowed.mockReturnValue(true); + getAppConfig.mockResolvedValue({ + balance: { enabled: false }, + registration: { allowedDomains: [] }, + }); + findUser.mockResolvedValue(null); + countUsers.mockResolvedValue(1); + createUser.mockResolvedValue({ _id: 'new-user-id' }); + updateUser.mockResolvedValue({ _id: 'new-user-id' }); + }); + + it('ignores provider values from the public registration payload', async () => { + const result = await registerUser({ ...registrationPayload, provider: 'google' }); + + expect(result.status).toBe(200); + expect(createUser.mock.calls[0][0]).toEqual( + expect.objectContaining({ + email: registrationPayload.email, + provider: 'local', + }), + ); + }); + + it('allows trusted callers to set provider through additional data', async () => { + const result = await registerUser(registrationPayload, { + emailVerified: true, + provider: 'google', + }); + + expect(result.status).toBe(200); + expect(createUser.mock.calls[0][0]).toEqual( + expect.objectContaining({ + email: registrationPayload.email, + emailVerified: true, + provider: 'google', + }), + ); + }); +}); + +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(); @@ -444,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', () => { @@ -576,13 +1069,9 @@ describe('CloudFront cookie integration', () => { expect(result).toBe(false); expect(setCloudFrontCookies).not.toHaveBeenCalled(); - expect(logger.debug).toHaveBeenCalledWith( + expect(logger.debug).not.toHaveBeenCalledWith( '[setCloudFrontAuthCookies] CloudFront auth cookies skipped', - expect.objectContaining({ - attempted: false, - set: false, - reason: 'cloudfront_disabled', - }), + expect.any(Object), ); }); @@ -820,3 +1309,62 @@ describe('CloudFront cookie integration', () => { }); }); }); + +describe('registerUser - allowedDomains admin-panel override', () => { + const validUser = { + email: 'new-user@example.com', + password: 'a-secure-password', + name: 'New User', + username: 'new-user', + }; + + beforeEach(() => { + jest.clearAllMocks(); + getTenantId.mockReturnValue(undefined); + isEmailDomainAllowed.mockReturnValue(true); + getAppConfig.mockResolvedValue({ + registration: { allowedDomains: ['example.com'] }, + balance: undefined, + }); + findUser.mockResolvedValue(null); + countUsers.mockResolvedValue(0); + }); + + it('should resolve the full app config so admin-panel overrides on the __base__ principal apply', async () => { + // Regression guard for getAppConfig({ baseOnly: true }): that option short-circuits + // before the DB override merge, which silently ignores any admin-panel edits to + // registration.allowedDomains (the admin panel writes overrides to the __base__ + // principal in the configs collection). registerUser must request the merged config + // so the global __base__ override is honored, same as it is for SSO callbacks via + // checkDomainAllowed. + await registerUser(validUser); + + expect(getAppConfig).toHaveBeenCalledTimes(1); + expect(getAppConfig).toHaveBeenCalledWith({}); + expect(getAppConfig).not.toHaveBeenCalledWith(expect.objectContaining({ baseOnly: true })); + }); + + it('should pass tenantId from ALS so the merged-config cache key matches tenant-scoped DB queries', async () => { + // /api/auth runs through preAuthTenantMiddleware, which puts a tenantId into + // AsyncLocalStorage. Mongoose queries inside getApplicableConfigs are scoped by ALS, + // but the per-principal merged-config cache key uses the explicit tenantId param. + // If we don't forward the ALS tenantId, tenant A's request caches at `__default__` + // and a later tenant B request can hit that entry — leaking config across tenants. + getTenantId.mockReturnValue('tenant-x'); + + await registerUser(validUser); + + expect(getAppConfig).toHaveBeenCalledWith({ tenantId: 'tenant-x' }); + }); + + it('should block registration when the resolved allowedDomains rejects the email', async () => { + isEmailDomainAllowed.mockReturnValue(false); + + const result = await registerUser({ ...validUser, email: 'blocked@evil.com' }); + + expect(result.status).toBe(403); + expect(result.message).toMatch(/cannot be used/i); + // Domain check must happen before any DB user lookup. + expect(findUser).not.toHaveBeenCalled(); + }); +}); diff --git a/api/server/services/Config/EndpointService.js b/api/server/services/Config/EndpointService.js index 058341ca2cc..56fa84f2a26 100644 --- a/api/server/services/Config/EndpointService.js +++ b/api/server/services/Config/EndpointService.js @@ -17,6 +17,13 @@ const { const userProvidedOpenAI = isUserProvided(openAIApiKey); const anthropicUsesVertex = isEnabled(process.env.ANTHROPIC_USE_VERTEX); +const firstNonEmpty = (...values) => values.find((value) => value != null && value !== ''); +const bedrockUserProvidedCredential = [ + process.env.BEDROCK_AWS_BEARER_TOKEN, + process.env.BEDROCK_AWS_ACCESS_KEY_ID, + process.env.BEDROCK_AWS_SECRET_ACCESS_KEY, + process.env.BEDROCK_AWS_SESSION_TOKEN, +].find(isUserProvided); module.exports = { config: { @@ -38,7 +45,13 @@ module.exports = { EModelEndpoint.azureAssistants, ), [EModelEndpoint.bedrock]: generateConfig( - process.env.BEDROCK_AWS_SECRET_ACCESS_KEY ?? process.env.BEDROCK_AWS_DEFAULT_REGION, + bedrockUserProvidedCredential ?? + firstNonEmpty( + process.env.BEDROCK_AWS_BEARER_TOKEN, + process.env.BEDROCK_AWS_SECRET_ACCESS_KEY, + process.env.BEDROCK_AWS_PROFILE, + process.env.BEDROCK_AWS_DEFAULT_REGION, + ), ), /* key will be part of separate config */ [EModelEndpoint.agents]: generateConfig('true', undefined, EModelEndpoint.agents), diff --git a/api/server/services/Config/__tests__/EndpointService.spec.js b/api/server/services/Config/__tests__/EndpointService.spec.js index 82f7175d235..0943daca474 100644 --- a/api/server/services/Config/__tests__/EndpointService.spec.js +++ b/api/server/services/Config/__tests__/EndpointService.spec.js @@ -60,6 +60,12 @@ describe('EndpointService', () => { process.env = { ...originalEnv }; delete process.env.ANTHROPIC_API_KEY; delete process.env.ANTHROPIC_USE_VERTEX; + delete process.env.BEDROCK_AWS_ACCESS_KEY_ID; + delete process.env.BEDROCK_AWS_SECRET_ACCESS_KEY; + delete process.env.BEDROCK_AWS_SESSION_TOKEN; + delete process.env.BEDROCK_AWS_BEARER_TOKEN; + delete process.env.BEDROCK_AWS_PROFILE; + delete process.env.BEDROCK_AWS_DEFAULT_REGION; Object.assign(process.env, env); return require('../EndpointService').config; } @@ -80,4 +86,34 @@ describe('EndpointService', () => { expect(config[EModelEndpoint.anthropic]).toEqual({ userProvide: true }); }); + + it('requires a user Bedrock key when bearer token user_provided is set with a legacy static secret', () => { + const config = loadConfig({ + BEDROCK_AWS_SECRET_ACCESS_KEY: 'legacy-secret', + BEDROCK_AWS_BEARER_TOKEN: 'user_provided', + BEDROCK_AWS_DEFAULT_REGION: 'us-east-1', + }); + + expect(config[EModelEndpoint.bedrock]).toEqual({ userProvide: true }); + }); + + it('enables Bedrock with static bearer token before static secret credentials', () => { + const config = loadConfig({ + BEDROCK_AWS_SECRET_ACCESS_KEY: 'legacy-secret', + BEDROCK_AWS_BEARER_TOKEN: 'bedrock-api-key', + BEDROCK_AWS_DEFAULT_REGION: 'us-east-1', + }); + + expect(config[EModelEndpoint.bedrock]).toEqual({ userProvide: false }); + }); + + it('skips blank optional Bedrock env vars before falling back to region', () => { + const config = loadConfig({ + BEDROCK_AWS_BEARER_TOKEN: '', + BEDROCK_AWS_PROFILE: '', + BEDROCK_AWS_DEFAULT_REGION: 'us-east-1', + }); + + expect(config[EModelEndpoint.bedrock]).toEqual({ userProvide: false }); + }); }); 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/loadAsyncEndpoints.js b/api/server/services/Config/loadAsyncEndpoints.js index 0d6a05aff78..5eaf6afb650 100644 --- a/api/server/services/Config/loadAsyncEndpoints.js +++ b/api/server/services/Config/loadAsyncEndpoints.js @@ -1,10 +1,34 @@ const path = require('path'); +const fs = require('fs/promises'); const { logger } = require('@librechat/data-schemas'); const { loadServiceKey, isUserProvided } = require('@librechat/api'); const { config } = require('./EndpointService'); +const defaultServiceKeyPath = path.join(__dirname, '../../..', 'data', 'auth.json'); + +async function getServiceKeyPath() { + const serviceKeyPath = process.env.GOOGLE_SERVICE_KEY_FILE?.trim(); + if (serviceKeyPath) { + return serviceKeyPath; + } + + try { + await fs.access(defaultServiceKeyPath); + return defaultServiceKeyPath; + } catch (error) { + if (error?.code !== 'ENOENT') { + logger.warn( + `Unable to access default Google service key file: ${defaultServiceKeyPath}`, + error, + ); + } + return null; + } +} + async function loadAsyncEndpoints() { - let serviceKey, googleUserProvides; + let serviceKey; + let googleUserProvides = false; const { googleKey } = config; /** Check if GOOGLE_KEY is provided at all(including 'user_provided') */ @@ -14,15 +38,15 @@ async function loadAsyncEndpoints() { /** If GOOGLE_KEY is provided, check if it's user_provided */ googleUserProvides = isUserProvided(googleKey); } else { - /** Only attempt to load service key if GOOGLE_KEY is not provided */ - const serviceKeyPath = - process.env.GOOGLE_SERVICE_KEY_FILE || path.join(__dirname, '../../..', 'data', 'auth.json'); - - try { - serviceKey = await loadServiceKey(serviceKeyPath); - } catch (error) { - logger.error('Error loading service key', error); - serviceKey = null; + const serviceKeyPath = await getServiceKeyPath(); + + if (serviceKeyPath) { + try { + serviceKey = await loadServiceKey(serviceKeyPath); + } catch (error) { + logger.warn('Error loading Google service key', error); + serviceKey = null; + } } } diff --git a/api/server/services/Config/loadAsyncEndpoints.spec.js b/api/server/services/Config/loadAsyncEndpoints.spec.js new file mode 100644 index 00000000000..1c63bf42285 --- /dev/null +++ b/api/server/services/Config/loadAsyncEndpoints.spec.js @@ -0,0 +1,119 @@ +const mockAccess = jest.fn(); +const mockLoadServiceKey = jest.fn(); +const mockIsUserProvided = jest.fn((value) => value === 'user_provided'); +const mockLogger = { + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn(), +}; + +function mockOptionalModule(moduleName, factory) { + try { + require.resolve(moduleName); + jest.doMock(moduleName, factory); + } catch { + jest.doMock(moduleName, factory, { virtual: true }); + } +} + +function mockDependencies() { + jest.doMock('fs/promises', () => ({ + access: mockAccess, + })); + + mockOptionalModule('@librechat/api', () => ({ + isEnabled: (value) => value === true || value === 'true' || value === '1', + isUserProvided: mockIsUserProvided, + loadServiceKey: mockLoadServiceKey, + })); + + mockOptionalModule('@librechat/data-schemas', () => ({ + logger: mockLogger, + })); + + mockOptionalModule('librechat-data-provider', () => ({ + EModelEndpoint: { + agents: 'agents', + anthropic: 'anthropic', + assistants: 'assistants', + azureAssistants: 'azureAssistants', + azureOpenAI: 'azureOpenAI', + bedrock: 'bedrock', + google: 'google', + openAI: 'openAI', + }, + })); + + jest.doMock('~/server/utils/handleText', () => ({ + generateConfig: (key) => (key ? { userProvide: key === 'user_provided' } : false), + })); +} + +describe('loadAsyncEndpoints', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + mockDependencies(); + process.env = { ...originalEnv }; + delete process.env.GOOGLE_KEY; + delete process.env.GOOGLE_SERVICE_KEY_FILE; + }); + + afterAll(() => { + process.env = originalEnv; + }); + + function loadModule(env = {}) { + process.env = { ...process.env, ...env }; + return require('./loadAsyncEndpoints'); + } + + it('does not load the default Google service key when the default file is missing', async () => { + mockAccess.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' })); + const loadAsyncEndpoints = loadModule(); + + const result = await loadAsyncEndpoints(); + + expect(result).toEqual({ google: false }); + expect(mockLoadServiceKey).not.toHaveBeenCalled(); + expect(mockLogger.error).not.toHaveBeenCalled(); + }); + + it('loads the default Google service key when the default file exists', async () => { + const serviceKey = { project_id: 'test-project' }; + mockAccess.mockResolvedValue(); + mockLoadServiceKey.mockResolvedValue(serviceKey); + const loadAsyncEndpoints = loadModule(); + + const result = await loadAsyncEndpoints(); + + expect(result).toEqual({ google: { userProvide: false } }); + expect(mockLoadServiceKey).toHaveBeenCalledWith(expect.stringContaining('api/data/auth.json')); + }); + + it('loads an explicitly configured Google service key path without probing the default file', async () => { + const serviceKey = { project_id: 'test-project' }; + mockLoadServiceKey.mockResolvedValue(serviceKey); + const loadAsyncEndpoints = loadModule({ + GOOGLE_SERVICE_KEY_FILE: '/secrets/google-service-account.json', + }); + + const result = await loadAsyncEndpoints(); + + expect(result).toEqual({ google: { userProvide: false } }); + expect(mockAccess).not.toHaveBeenCalled(); + expect(mockLoadServiceKey).toHaveBeenCalledWith('/secrets/google-service-account.json'); + }); + + it('uses GOOGLE_KEY without probing for a service key', async () => { + const loadAsyncEndpoints = loadModule({ GOOGLE_KEY: 'user_provided' }); + + const result = await loadAsyncEndpoints(); + + expect(result).toEqual({ google: { userProvide: true } }); + expect(mockAccess).not.toHaveBeenCalled(); + expect(mockLoadServiceKey).not.toHaveBeenCalled(); + }); +}); diff --git a/api/server/services/Config/loadConfigModels.spec.js b/api/server/services/Config/loadConfigModels.spec.js index d3ec0309aeb..59f9a5c04ea 100644 --- a/api/server/services/Config/loadConfigModels.spec.js +++ b/api/server/services/Config/loadConfigModels.spec.js @@ -93,6 +93,41 @@ describe('loadConfigModels', () => { expect(result).toEqual({}); }); + it('passes userId when resolving scoped model config', async () => { + getAppConfig.mockResolvedValue({}); + + await loadConfigModels({ + user: { id: 'testUserId', role: 'USER', tenantId: 'tenant-a' }, + }); + + expect(getAppConfig).toHaveBeenCalledWith({ + role: 'USER', + userId: 'testUserId', + tenantId: 'tenant-a', + }); + }); + + it('uses req.config when available instead of calling getAppConfig', async () => { + const result = await loadConfigModels({ + user: { id: 'testUserId' }, + config: { + endpoints: { + custom: [ + { + name: 'LocalOnly', + apiKey: 'local-key', + baseURL: 'https://example.com/v1', + models: { default: ['local-model'], fetch: false }, + }, + ], + }, + }, + }); + + expect(getAppConfig).not.toHaveBeenCalled(); + expect(result.LocalOnly).toEqual(['local-model']); + }); + it('handles azure models and endpoint correctly', async () => { getAppConfig.mockResolvedValue({ endpoints: { diff --git a/api/server/services/Config/loadCustomConfig.js b/api/server/services/Config/loadCustomConfig.js index c9147549749..c719a846657 100644 --- a/api/server/services/Config/loadCustomConfig.js +++ b/api/server/services/Config/loadCustomConfig.js @@ -177,7 +177,8 @@ https://www.librechat.ai/docs/configuration/stt_tts`); // Validate and fill out missing values for custom parameters function parseCustomParams(endpointName, customParams) { - const paramEndpoint = customParams.defaultParamsEndpoint; + const paramEndpoint = customParams.defaultParamsEndpoint ?? 'custom'; + customParams.defaultParamsEndpoint = paramEndpoint; customParams.paramDefinitions = customParams.paramDefinitions || []; // Checks if `defaultParamsEndpoint` is a key in `paramSettings`. diff --git a/api/server/services/Config/loadCustomConfig.spec.js b/api/server/services/Config/loadCustomConfig.spec.js index 3fce8777e30..ff7ee90629c 100644 --- a/api/server/services/Config/loadCustomConfig.spec.js +++ b/api/server/services/Config/loadCustomConfig.spec.js @@ -11,7 +11,7 @@ jest.mock('librechat-data-provider', () => { paramSettings: { foo: {}, bar: {}, - custom: {}, + custom: [], openrouter: [ { key: 'promptCache', @@ -59,6 +59,7 @@ jest.mock('@librechat/data-schemas', () => { const axios = require('axios'); const { loadYaml } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); +const { ReasoningParameterFormat, ReasoningResponseKey } = require('librechat-data-provider'); const loadCustomConfig = require('./loadCustomConfig'); describe('loadCustomConfig', () => { @@ -307,11 +308,28 @@ describe('loadCustomConfig', () => { ); }); - it('throws an error when defaultParamsEndpoint is not provided', async () => { - const malformedCustomParams = { defaultParamsEndpoint: undefined }; - await expect(loadCustomParams(malformedCustomParams)).rejects.toThrow( - 'defaultParamsEndpoint of "Google" endpoint is invalid. Valid options are foo, bar, custom, openrouter, google', - ); + it('defaults defaultParamsEndpoint when only reasoningFormat is provided', async () => { + const parsedConfig = await loadCustomParams({ + reasoningFormat: ReasoningParameterFormat.reasoningObject, + }); + + expect(parsedConfig.endpoints.custom[0].customParams).toEqual({ + defaultParamsEndpoint: 'custom', + reasoningFormat: ReasoningParameterFormat.reasoningObject, + paramDefinitions: [], + }); + }); + + it('defaults defaultParamsEndpoint when only reasoningKey is provided', async () => { + const parsedConfig = await loadCustomParams({ + reasoningKey: ReasoningResponseKey.reasoning, + }); + + expect(parsedConfig.endpoints.custom[0].customParams).toEqual({ + defaultParamsEndpoint: 'custom', + reasoningKey: ReasoningResponseKey.reasoning, + paramDefinitions: [], + }); }); it('fills the paramDefinitions with missing values', async () => { diff --git a/api/server/services/Config/loadDefaultEConfig.js b/api/server/services/Config/loadDefaultEConfig.js index 557b93ce8ec..d8e51e6455e 100644 --- a/api/server/services/Config/loadDefaultEConfig.js +++ b/api/server/services/Config/loadDefaultEConfig.js @@ -8,10 +8,12 @@ const { config } = require('./EndpointService'); * @returns {Promise>} An object whose keys are endpoint names and values are objects that contain the endpoint configuration and an order. */ async function loadDefaultEndpointsConfig(appConfig) { - const { google } = await loadAsyncEndpoints(appConfig); const { assistants, azureAssistants, azureOpenAI } = config; const enabledEndpoints = getEnabledEndpoints(); + const { google } = enabledEndpoints.includes(EModelEndpoint.google) + ? await loadAsyncEndpoints(appConfig) + : { google: false }; const endpointConfig = { [EModelEndpoint.openAI]: config[EModelEndpoint.openAI], diff --git a/api/server/services/Config/loadDefaultEConfig.spec.js b/api/server/services/Config/loadDefaultEConfig.spec.js new file mode 100644 index 00000000000..6845a549a7d --- /dev/null +++ b/api/server/services/Config/loadDefaultEConfig.spec.js @@ -0,0 +1,74 @@ +const mockGetEnabledEndpoints = jest.fn(); +const mockLoadAsyncEndpoints = jest.fn(); + +function mockOptionalModule(moduleName, factory) { + try { + require.resolve(moduleName); + jest.doMock(moduleName, factory); + } catch { + jest.doMock(moduleName, factory, { virtual: true }); + } +} + +function mockDependencies() { + mockOptionalModule('librechat-data-provider', () => ({ + EModelEndpoint: { + agents: 'agents', + anthropic: 'anthropic', + assistants: 'assistants', + azureAssistants: 'azureAssistants', + azureOpenAI: 'azureOpenAI', + bedrock: 'bedrock', + google: 'google', + openAI: 'openAI', + }, + getEnabledEndpoints: mockGetEnabledEndpoints, + })); + + jest.doMock('./loadAsyncEndpoints', () => mockLoadAsyncEndpoints); + + jest.doMock('./EndpointService', () => ({ + config: { + agents: { userProvide: false }, + anthropic: false, + assistants: false, + azureAssistants: false, + azureOpenAI: false, + bedrock: false, + openAI: { userProvide: false }, + }, + })); +} + +describe('loadDefaultEndpointsConfig', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.resetModules(); + mockDependencies(); + }); + + it('does not probe async Google credentials when Google is excluded from enabled endpoints', async () => { + mockGetEnabledEndpoints.mockReturnValue(['openAI']); + const loadDefaultEndpointsConfig = require('./loadDefaultEConfig'); + + const result = await loadDefaultEndpointsConfig(); + + expect(mockLoadAsyncEndpoints).not.toHaveBeenCalled(); + expect(result).toEqual({ + openAI: { userProvide: false, order: 0 }, + }); + }); + + it('loads async Google credentials when Google is enabled', async () => { + mockGetEnabledEndpoints.mockReturnValue(['google']); + mockLoadAsyncEndpoints.mockResolvedValue({ google: { userProvide: false } }); + const loadDefaultEndpointsConfig = require('./loadDefaultEConfig'); + + const result = await loadDefaultEndpointsConfig(); + + expect(mockLoadAsyncEndpoints).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + google: { userProvide: false, order: 0 }, + }); + }); +}); diff --git a/api/server/services/Config/loadDefaultModels.js b/api/server/services/Config/loadDefaultModels.js index 85f2c42a333..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, @@ -17,21 +18,43 @@ const { getAppConfig } = require('./app'); async function loadDefaultModels(req) { try { const appConfig = - req.config ?? (await getAppConfig({ role: req.user?.role, tenantId: req.user?.tenantId })); + req.config ?? + (await getAppConfig({ + role: req.user?.role, + userId: req.user?.id, + tenantId: req.user?.tenantId, + })); 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/Config/rum.js b/api/server/services/Config/rum.js new file mode 100644 index 00000000000..c9f36ea80b5 --- /dev/null +++ b/api/server/services/Config/rum.js @@ -0,0 +1,151 @@ +const { getRumProxyClientUrl, isEnabled, isRumProxyEnabled } = require('@librechat/api'); +const { logger } = require('@librechat/data-schemas'); + +const DEFAULT_RUM_SERVICE_NAME = 'librechat-web'; + +function parseBooleanEnv(value, defaultValue = false) { + if (value == null || value === '') { + return defaultValue; + } + + return isEnabled(value); +} + +function parseNumberEnv(value) { + if (value == null || value === '') { + return undefined; + } + + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function parseCsvEnv(value) { + if (!value) { + return []; + } + + return value + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +function parseUrl(value) { + try { + return new URL(value); + } catch { + return undefined; + } +} + +function isLocalhost(url) { + return url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]'; +} + +function isSafeRumUrl(url) { + if (url.username || url.password) { + return false; + } + + if (url.protocol === 'https:') { + return true; + } + + return url.protocol === 'http:' && isLocalhost(url); +} + +function isSafeTraceTarget(target) { + if (target.includes('*')) { + return false; + } + + const url = parseUrl(target); + if (!url || url.protocol !== 'https:') { + return false; + } + + return true; +} + +function getRumConfig() { + if (!parseBooleanEnv(process.env.RUM_ENABLED)) { + return undefined; + } + + const provider = process.env.RUM_PROVIDER || 'hyperdx'; + if (provider !== 'hyperdx') { + logger.warn(`[config] Unsupported RUM provider "${provider}", disabling RUM`); + return undefined; + } + + const authMode = process.env.RUM_AUTH_MODE || 'publicToken'; + if (authMode !== 'publicToken' && authMode !== 'proxy') { + logger.warn(`[config] Unsupported RUM auth mode "${authMode}", disabling RUM`); + return undefined; + } + + let rumUrl; + if (authMode === 'proxy') { + rumUrl = getRumProxyClientUrl(); + + if (!isRumProxyEnabled()) { + logger.warn('[config] RUM proxy mode requires RUM_PROXY_TARGET_URL, disabling RUM'); + return undefined; + } + } else { + rumUrl = process.env.RUM_URL; + const parsedUrl = rumUrl ? parseUrl(rumUrl) : undefined; + + if (!parsedUrl || !isSafeRumUrl(parsedUrl)) { + logger.warn('[config] Invalid RUM_URL, disabling RUM'); + return undefined; + } + + if (!process.env.RUM_PUBLIC_TOKEN) { + logger.warn('[config] RUM publicToken mode requires RUM_PUBLIC_TOKEN, disabling RUM'); + return undefined; + } + + rumUrl = parsedUrl.href.replace(/\/$/, ''); + } + + const rawTracePropagationTargets = parseCsvEnv(process.env.RUM_TRACE_PROPAGATION_TARGETS); + const tracePropagationTargets = rawTracePropagationTargets.filter(isSafeTraceTarget); + if (rawTracePropagationTargets.length !== tracePropagationTargets.length) { + logger.info('[config] Ignored unsafe RUM trace propagation targets'); + } + + const configuredSampleRate = parseNumberEnv(process.env.RUM_SAMPLE_RATE); + const sampleRate = + configuredSampleRate != null && configuredSampleRate >= 0 && configuredSampleRate <= 1 + ? configuredSampleRate + : undefined; + const consoleCapture = parseBooleanEnv(process.env.RUM_CONSOLE_CAPTURE); + const advancedNetworkCapture = parseBooleanEnv(process.env.RUM_ADVANCED_NETWORK_CAPTURE); + + if (consoleCapture) { + logger.warn('[config] RUM console capture is enabled and may collect sensitive browser logs'); + } + + if (advancedNetworkCapture) { + logger.warn('[config] RUM advanced network capture is enabled and may collect payload data'); + } + + return { + provider: 'hyperdx', + enabled: true, + url: rumUrl, + serviceName: process.env.RUM_SERVICE_NAME || DEFAULT_RUM_SERVICE_NAME, + authMode, + ...(authMode === 'publicToken' ? { publicToken: process.env.RUM_PUBLIC_TOKEN } : {}), + ...(tracePropagationTargets.length > 0 ? { tracePropagationTargets } : {}), + consoleCapture, + disableReplay: parseBooleanEnv(process.env.RUM_DISABLE_REPLAY, true), + advancedNetworkCapture, + ...(sampleRate != null ? { sampleRate } : {}), + ...(process.env.RUM_ENVIRONMENT ? { environment: process.env.RUM_ENVIRONMENT } : {}), + }; +} + +module.exports = { getRumConfig }; 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 f1db6d325ae..9caee651c41 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -9,7 +9,10 @@ const { GenerationJobManager, getCustomEndpointConfig, discoverConnectedAgents, + resolveAgentTokenConfig, resolveAgentScopedSkillIds, + resolveModelSpecSkillIds, + buildAgentContextAttachmentsByAgentId, } = require('@librechat/api'); const { ResourceType, @@ -30,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'); @@ -131,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. */ @@ -139,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, @@ -164,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 * }>} */ @@ -183,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, @@ -194,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(), @@ -222,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, @@ -232,6 +271,9 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { collectedThoughtSignatures, streamId, subagentAggregatorsByToolCallId, + usageCost, + contextUsageSink, + usageEmitSink, }); if (!endpointOption.agent) { @@ -278,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( { @@ -298,6 +381,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { allowedProviders, isInitialAgent: true, accessibleSkillIds: primaryScopedSkillIds, + skillAuthoringAvailable: primarySkillAuthoringAvailable, codeEnvAvailable, skillStates, defaultActiveOnShare, @@ -314,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, @@ -370,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, @@ -389,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 — @@ -399,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. @@ -456,6 +521,13 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { parentMessageId, allowedProviders, primaryAgentId: primaryConfig.id, + accessibleSkillIds, + editableSkillIds, + skillsCapabilityEnabled, + ephemeralSkillsToggle, + skillCreateAllowed, + skillStates, + defaultActiveOnShare, codeEnvAvailable, }); @@ -467,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 @@ -564,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, @@ -575,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, }), @@ -604,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); @@ -796,6 +860,11 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { } } + const agentContextAttachmentsByAgentId = buildAgentContextAttachmentsByAgentId([ + primaryConfig, + ...agentConfigs.values(), + ]); + let endpointConfig = appConfig.endpoints?.[primaryConfig.endpoint]; if (!isAgentsEndpoint(primaryConfig.endpoint) && !endpointConfig) { try { @@ -836,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, @@ -851,12 +942,25 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { agent: primaryConfig, spec: endpointOption.spec, iconURL: endpointOption.iconURL, - attachments: primaryConfig.attachments, + chatProjectId: endpointOption.chatProjectId, + attachments: primaryConfig.requestAttachments ?? primaryConfig.attachments, + agentContextAttachmentsByAgentId, endpointType: endpointOption.endpointType, resendFiles: primaryConfig.resendFiles ?? true, 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 3bf6a67e50c..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(() => {}); @@ -183,6 +185,15 @@ describe('initializeClient — processAgent ACL gate', () => { }); const edges = [{ from: PRIMARY_ID, to: AUTHORIZED_ID, edgeType: 'handoff' }]; + const requestAttachment = { file_id: 'request_file', filename: 'request.txt' }; + const primaryContextAttachment = { file_id: 'primary_context', filename: 'primary.txt' }; + const handoffContextAttachment = { file_id: 'handoff_context', filename: 'handoff.txt' }; + const primaryConfig = { + ...makePrimaryConfig(edges), + attachments: [primaryContextAttachment, requestAttachment], + requestAttachments: [requestAttachment], + agentContextAttachments: [primaryContextAttachment], + }; const handoffConfig = { id: AUTHORIZED_ID, edges: [], @@ -190,14 +201,13 @@ describe('initializeClient — processAgent ACL gate', () => { toolRegistry: new Map(), userMCPAuthMap: null, tool_resources: {}, + agentContextAttachments: [handoffContextAttachment], }; let callCount = 0; mockInitializeAgent.mockImplementation(() => { callCount++; - return callCount === 1 - ? Promise.resolve(makePrimaryConfig(edges)) - : Promise.resolve(handoffConfig); + return callCount === 1 ? Promise.resolve(primaryConfig) : Promise.resolve(handoffConfig); }); await initializeClient({ @@ -210,6 +220,99 @@ describe('initializeClient — processAgent ACL gate', () => { expect(mockInitializeAgent).toHaveBeenCalledTimes(2); expect(agentClientArgs.agent.edges).toHaveLength(1); expect(agentClientArgs.agent.edges[0].to).toBe(AUTHORIZED_ID); + expect(agentClientArgs.attachments).toEqual([requestAttachment]); + expect(agentClientArgs.agentContextAttachmentsByAgentId.get(PRIMARY_ID)).toEqual([ + primaryContextAttachment, + ]); + expect(agentClientArgs.agentContextAttachmentsByAgentId.get(AUTHORIZED_ID)).toEqual([ + 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); }); }); @@ -436,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 b7e1a54e062..0aa955055e8 100644 --- a/api/server/services/Endpoints/agents/title.js +++ b/api/server/services/Endpoints/agents/title.js @@ -5,9 +5,47 @@ const getLogStores = require('~/cache/getLogStores'); const { saveConvo } = require('~/models'); /** - * Add title to conversation in a way that avoids memory retention + * Add title to conversation in a way that avoids memory retention. + * + * @param {ServerRequest} req + * @param {Object} params + * @param {string} params.text - The user's first message. + * @param {TMessage} [params.response] - The assistant response (legacy/`final` timing only). + * @param {AgentClient} params.client + * @param {string} [params.conversationId] - Required for `immediate` timing, where + * `response` is not yet available; falls back to `response.conversationId`. + * @param {boolean} [params.immediate] - When true, the title is generated in parallel + * with the response (from the user's first message) and persisted to the conversation + * only after `convoReady` resolves (the conversation row must exist for `noUpsert`). + * @param {Promise} [params.convoReady] - Resolves once the conversation has been + * 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 + * 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. */ -const addTitle = async (req, { text, response, client }) => { +const addTitle = async ( + req, + { + text, + response, + client, + conversationId, + immediate = false, + convoReady, + signal, + discardSignal, + onTitleGenerated, + }, +) => { const { TITLE_CONVO = true } = process.env ?? {}; if (!isEnabled(TITLE_CONVO)) { return; @@ -22,8 +60,14 @@ const addTitle = async (req, { text, response, client }) => { return; } + const convoId = conversationId ?? response?.conversationId; + if (!convoId) { + logger.warn('[addTitle] Missing conversationId; skipping title generation'); + return; + } + const titleCache = getLogStores(CacheKeys.GEN_TITLE); - const key = `${req.user.id}-${response.conversationId}`; + const key = `${req.user.id}-${convoId}`; /** @type {NodeJS.Timeout} */ let timeoutId; try { @@ -35,12 +79,22 @@ const addTitle = async (req, { text, response, client }) => { let titlePromise; let abortController = new AbortController(); + /** Propagate a request abort (Stop) to the title generation so a cancelled + * turn does not consume the title model or surface a title. */ + if (signal) { + if (signal.aborted) { + abortController.abort(); + } else { + signal.addEventListener('abort', () => abortController.abort(), { once: true }); + } + } if (client && typeof client.titleConvo === 'function') { titlePromise = Promise.race([ client .titleConvo({ text, abortController, + immediate, }) .catch((error) => { logger.error('Client title error:', error); @@ -65,6 +119,39 @@ const addTitle = async (req, { text, response, client }) => { } await titleCache.set(key, title, 120000); + + if (!signal?.aborted && typeof onTitleGenerated === 'function') { + try { + await onTitleGenerated({ conversationId: convoId, title }); + } catch (error) { + logger.error('Error emitting generated title:', error); + } + } + + /** In immediate mode the title is generated in parallel with the response, + * so the conversation row may not exist yet. `saveConvo` with `noUpsert` + * is a silent no-op when the row is missing, which would drop the title + * from the database (the cache above still serves the live UI). Wait for + * the controller to signal the conversation has been persisted. */ + if (convoReady) { + await convoReady; + } + + 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); + } + return; + } + await saveConvo( { userId: req?.user?.id, @@ -72,7 +159,7 @@ const addTitle = async (req, { text, response, client }) => { interfaceConfig: req?.config?.interfaceConfig, }, { - conversationId: response.conversationId, + conversationId: convoId, title, }, { context: 'api/server/services/Endpoints/agents/title.js', noUpsert: true }, diff --git a/api/server/services/Endpoints/agents/title.test.js b/api/server/services/Endpoints/agents/title.test.js new file mode 100644 index 00000000000..41537619900 --- /dev/null +++ b/api/server/services/Endpoints/agents/title.test.js @@ -0,0 +1,313 @@ +/** Backing store so `get` reflects prior `set`/`delete` — addTitle reads the cache + * back to avoid clobbering a replacement stream's title on abort. */ +const mockCacheStore = new Map(); +const mockCache = { + get: jest.fn((key) => mockCacheStore.get(key)), + set: jest.fn((key, value) => mockCacheStore.set(key, value)), + delete: jest.fn((key) => mockCacheStore.delete(key)), +}; +const mockSaveConvo = jest.fn(); + +jest.mock('@librechat/api', () => ({ + isEnabled: (val) => val === true || val === 'true', + sanitizeTitle: (title) => title, +})); + +jest.mock('@librechat/data-schemas', () => ({ + logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +jest.mock('librechat-data-provider', () => ({ + CacheKeys: { GEN_TITLE: 'GEN_TITLE' }, +})); + +jest.mock('~/cache/getLogStores', () => jest.fn(() => mockCache)); + +jest.mock('~/models', () => ({ + saveConvo: (...args) => mockSaveConvo(...args), +})); + +const addTitle = require('./title'); + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +const makeClient = (title = 'Generated Title') => ({ + options: { titleConvo: true }, + titleConvo: jest.fn().mockResolvedValue(title), +}); + +const makeReq = () => ({ user: { id: 'user-1' }, body: {}, config: {} }); + +describe('agents addTitle', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockCacheStore.clear(); + }); + + it('uses the explicit conversationId for the cache key and saveConvo (immediate mode)', async () => { + const client = makeClient('My Title'); + + await addTitle(makeReq(), { + text: 'hello', + client, + conversationId: 'cid-immediate', + immediate: true, + convoReady: Promise.resolve(), + }); + + expect(mockCache.set).toHaveBeenCalledWith( + 'user-1-cid-immediate', + 'My Title', + expect.any(Number), + ); + expect(mockSaveConvo).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ conversationId: 'cid-immediate', title: 'My Title' }), + expect.objectContaining({ noUpsert: true }), + ); + }); + + it('passes immediate:true through to client.titleConvo', async () => { + const client = makeClient(); + + await addTitle(makeReq(), { + text: 'hello', + client, + conversationId: 'cid', + immediate: true, + convoReady: Promise.resolve(), + }); + + expect(client.titleConvo).toHaveBeenCalledWith(expect.objectContaining({ immediate: true })); + }); + + it('falls back to response.conversationId in legacy (final) mode', async () => { + const client = makeClient('Legacy Title'); + + await addTitle(makeReq(), { + text: 'hi', + client, + response: { conversationId: 'resp-cid' }, + }); + + expect(mockCache.set).toHaveBeenCalledWith( + 'user-1-resp-cid', + 'Legacy Title', + expect.any(Number), + ); + expect(mockSaveConvo).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ conversationId: 'resp-cid', title: 'Legacy Title' }), + expect.objectContaining({ noUpsert: true }), + ); + expect(client.titleConvo).toHaveBeenCalledWith(expect.objectContaining({ immediate: false })); + }); + + it('caches the title immediately but defers saveConvo until convoReady resolves', async () => { + const client = makeClient('Deferred Title'); + let resolveConvo; + const convoReady = new Promise((resolve) => { + resolveConvo = resolve; + }); + + const pending = addTitle(makeReq(), { + text: 'hello', + client, + conversationId: 'cid-defer', + immediate: true, + convoReady, + }); + + await flush(); + + // Title is cached for the live UI, but persistence waits for the row to exist. + expect(mockCache.set).toHaveBeenCalledWith( + 'user-1-cid-defer', + 'Deferred Title', + expect.any(Number), + ); + expect(mockSaveConvo).not.toHaveBeenCalled(); + + resolveConvo(); + await pending; + + expect(mockSaveConvo).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ conversationId: 'cid-defer', title: 'Deferred Title' }), + expect.objectContaining({ noUpsert: true }), + ); + }); + + it('notifies when the title is cached before waiting for convoReady', async () => { + const order = []; + const client = makeClient('Streamed Title'); + const onTitleGenerated = jest.fn(async () => { + order.push('title-event'); + }); + let resolveConvo; + const convoReady = new Promise((resolve) => { + resolveConvo = resolve; + }); + + mockCache.set.mockImplementationOnce((key, value) => { + order.push('cache'); + mockCacheStore.set(key, value); + }); + mockSaveConvo.mockImplementationOnce(async () => { + order.push('save'); + }); + + const pending = addTitle(makeReq(), { + text: 'hello', + client, + conversationId: 'cid-stream', + immediate: true, + convoReady, + onTitleGenerated, + }); + + await flush(); + + expect(onTitleGenerated).toHaveBeenCalledWith({ + conversationId: 'cid-stream', + title: 'Streamed Title', + }); + expect(order).toEqual(['cache', 'title-event']); + expect(mockSaveConvo).not.toHaveBeenCalled(); + + resolveConvo(); + await pending; + + expect(order).toEqual(['cache', 'title-event', 'save']); + }); + + it('skips generation when the endpoint disables titleConvo', async () => { + const client = makeClient(); + client.options.titleConvo = false; + + await addTitle(makeReq(), { text: 'hi', client, conversationId: 'cid', immediate: true }); + + expect(client.titleConvo).not.toHaveBeenCalled(); + expect(mockSaveConvo).not.toHaveBeenCalled(); + }); + + it('skips generation for temporary conversations', async () => { + const client = makeClient(); + const req = makeReq(); + req.body.isTemporary = true; + + await addTitle(req, { text: 'hi', client, conversationId: 'cid', immediate: true }); + + expect(client.titleConvo).not.toHaveBeenCalled(); + expect(mockSaveConvo).not.toHaveBeenCalled(); + }); + + it('skips generation when neither conversationId nor response is provided', async () => { + const client = makeClient(); + + await addTitle(makeReq(), { text: 'hi', client }); + + expect(client.titleConvo).not.toHaveBeenCalled(); + expect(mockCache.set).not.toHaveBeenCalled(); + expect(mockSaveConvo).not.toHaveBeenCalled(); + }); + + it('propagates the abort signal to the title model call', async () => { + const client = makeClient(); + const ac = new AbortController(); + ac.abort(); + + await addTitle(makeReq(), { + text: 'hi', + client, + conversationId: 'cid', + immediate: true, + convoReady: Promise.resolve(), + signal: ac.signal, + }); + + 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 superseded", async () => { + const client = makeClient('Stale Title'); + const ac = new AbortController(); + ac.abort(); + // Simulate a replacement stream having cached its own (newer) title under the + // shared `userId-conversationId` key by the time this stale task re-reads it. + mockCache.get.mockImplementationOnce(() => 'Newer Title'); + + await addTitle(makeReq(), { + text: 'hi', + client, + conversationId: 'cid', + 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 7329bf6ac22..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'); @@ -142,6 +141,7 @@ class STTService { req.config ?? (await getAppConfig({ role: req?.user?.role, + userId: req?.user?.id, tenantId: req?.user?.tenantId, })); const sttSchema = appConfig?.speech?.stt; @@ -302,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 1125dd74ed9..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); @@ -297,6 +294,7 @@ class TTSService { req.config ?? (await getAppConfig({ role: req.user?.role, + userId: req.user?.id, tenantId: req.user?.tenantId, })); try { @@ -366,6 +364,7 @@ class TTSService { req.config ?? (await getAppConfig({ role: req.user?.role, + userId: req.user?.id, tenantId: req.user?.tenantId, })); const provider = this.getProvider(appConfig); diff --git a/api/server/services/Files/Audio/getCustomConfigSpeech.js b/api/server/services/Files/Audio/getCustomConfigSpeech.js index b438771ec10..1edca8e1882 100644 --- a/api/server/services/Files/Audio/getCustomConfigSpeech.js +++ b/api/server/services/Files/Audio/getCustomConfigSpeech.js @@ -15,10 +15,13 @@ const { getAppConfig } = require('~/server/services/Config'); */ async function getCustomConfigSpeech(req, res) { try { - const appConfig = await getAppConfig({ - role: req.user?.role, - tenantId: req.user?.tenantId, - }); + const appConfig = + req.config ?? + (await getAppConfig({ + role: req.user?.role, + userId: req.user?.id, + tenantId: req.user?.tenantId, + })); if (!appConfig) { return res.status(200).send({ diff --git a/api/server/services/Files/Audio/getVoices.js b/api/server/services/Files/Audio/getVoices.js index 22bd7cea6e4..a7cd7dbc30a 100644 --- a/api/server/services/Files/Audio/getVoices.js +++ b/api/server/services/Files/Audio/getVoices.js @@ -18,6 +18,7 @@ async function getVoices(req, res) { req.config ?? (await getAppConfig({ role: req.user?.role, + userId: req.user?.id, tenantId: req.user?.tenantId, })); diff --git a/api/server/services/Files/Code/__tests__/process-traversal.spec.js b/api/server/services/Files/Code/__tests__/process-traversal.spec.js index 791d6d258ca..57609c545aa 100644 --- a/api/server/services/Files/Code/__tests__/process-traversal.spec.js +++ b/api/server/services/Files/Code/__tests__/process-traversal.spec.js @@ -92,6 +92,11 @@ jest.mock('~/server/utils', () => ({ determineFileType: jest.fn().mockResolvedValue({ mime: 'text/csv' }), })); +jest.mock('~/server/services/Files/retention', () => ({ + getRetentionExpiry: jest.fn(() => ({})), +})); + +const { getRetentionExpiry } = require('~/server/services/Files/retention'); const { createFile } = require('~/models'); const { processCodeOutput } = require('../process'); @@ -143,6 +148,12 @@ describe('processCodeOutput path traversal protection', () => { expect(fileArg.tenantId).toBe('tenantA'); }); + test('getRetentionExpiry is called with the request object', async () => { + mockSanitizeArtifactPath.mockReturnValueOnce('output.csv'); + await processCodeOutput({ ...baseParams, name: 'output.csv' }); + expect(getRetentionExpiry).toHaveBeenCalledWith(baseParams.req); + }); + test('sanitized name is used for image file records', async () => { const { convertImage } = require('~/server/services/Files/images/convert'); convertImage.mockResolvedValueOnce({ diff --git a/api/server/services/Files/Code/crud.js b/api/server/services/Files/Code/crud.js index 035027b52d2..553599d3354 100644 --- a/api/server/services/Files/Code/crud.js +++ b/api/server/services/Files/Code/crud.js @@ -58,6 +58,71 @@ async function getCodeOutputDownloadStream(fileIdentifier, identity, req) { } } +/** + * Deletes a file from the Code Environment server. + * + * @param {ServerRequest} req - Current authenticated request, used to mint Code API auth. + * @param {MongoFile} file - File metadata containing `metadata.codeEnvRef`. + * @returns {Promise} + */ +async function deleteCodeEnvFile(req, file) { + const ref = file?.metadata?.codeEnvRef; + if (!ref) { + return; + } + + let lastError; + const missingOrUnsupportedStatuses = new Set([404, 405]); + try { + const baseURL = getCodeBaseURL(); + const query = buildCodeEnvDownloadQuery({ + kind: ref.kind, + id: ref.id, + ...(ref.kind === 'skill' ? { version: ref.version } : {}), + }); + const authHeaders = await getCodeApiAuthHeaders(req); + const baseRequest = { + method: 'delete', + headers: { + 'User-Agent': 'LibreChat/1.0', + ...authHeaders, + }, + httpAgent: codeServerHttpAgent, + httpsAgent: codeServerHttpsAgent, + timeout: 15000, + }; + const urls = [ + `${baseURL}/sessions/${ref.storage_session_id}/objects/${ref.file_id}${query}`, + `${baseURL}/files/${ref.storage_session_id}/${ref.file_id}${query}`, + ]; + + for (const url of urls) { + try { + await axios({ ...baseRequest, url }); + return; + } catch (error) { + lastError = error; + if (!missingOrUnsupportedStatuses.has(error.response?.status)) { + throw error; + } + } + } + } catch (error) { + lastError = error; + } + + if (lastError) { + logAxiosError({ + error: lastError, + message: `Error deleting code environment file: ${lastError.message}`, + }); + if (lastError.response?.status === 404) { + return; + } + throw new Error(lastError.message || 'An error occurred during file deletion.'); + } +} + /** * Uploads a file to the Code Environment server. * @@ -209,6 +274,7 @@ async function batchUploadCodeEnvFiles({ req, files, kind, id, version, read_onl } module.exports = { + deleteCodeEnvFile, getCodeOutputDownloadStream, uploadCodeEnvFile, batchUploadCodeEnvFiles, diff --git a/api/server/services/Files/Code/crud.spec.js b/api/server/services/Files/Code/crud.spec.js index e26eb4dbbb0..4e9c755ae1b 100644 --- a/api/server/services/Files/Code/crud.spec.js +++ b/api/server/services/Files/Code/crud.spec.js @@ -61,7 +61,7 @@ const { codeServerHttpsAgent, getCodeApiAuthHeaders, } = require('@librechat/api'); -const { getCodeOutputDownloadStream, uploadCodeEnvFile } = require('./crud'); +const { deleteCodeEnvFile, getCodeOutputDownloadStream, uploadCodeEnvFile } = require('./crud'); describe('Code CRUD', () => { beforeEach(() => { @@ -155,6 +155,94 @@ describe('Code CRUD', () => { }); }); + describe('deleteCodeEnvFile', () => { + const req = { user: { id: 'user-123' } }; + const file = { + metadata: { + codeEnvRef: { + kind: 'agent', + id: 'agent-abc', + storage_session_id: 'session-1', + file_id: 'file-1', + }, + }, + }; + + it('deletes the code environment object with resource identity and auth headers', async () => { + getCodeApiAuthHeaders.mockResolvedValue({ Authorization: 'Bearer codeapi-token' }); + mockAxios.mockResolvedValue({ status: 204 }); + + await deleteCodeEnvFile(req, file); + + expect(getCodeApiAuthHeaders).toHaveBeenCalledWith(req); + expect(mockAxios).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'delete', + url: 'https://code-api.example.com/sessions/session-1/objects/file-1?kind=agent&id=agent-abc', + headers: expect.objectContaining({ + Authorization: 'Bearer codeapi-token', + 'User-Agent': 'LibreChat/1.0', + }), + httpAgent: codeServerHttpAgent, + httpsAgent: codeServerHttpsAgent, + timeout: 15000, + }), + ); + }); + + it.each([404, 405])( + 'falls back to the legacy code environment delete route after a %s', + async (status) => { + mockAxios + .mockRejectedValueOnce( + Object.assign(new Error('missing route'), { response: { status } }), + ) + .mockResolvedValueOnce({ status: 204 }); + + await deleteCodeEnvFile(req, file); + + expect(mockAxios).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + method: 'delete', + url: 'https://code-api.example.com/sessions/session-1/objects/file-1?kind=agent&id=agent-abc', + }), + ); + expect(mockAxios).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + method: 'delete', + url: 'https://code-api.example.com/files/session-1/file-1?kind=agent&id=agent-abc', + }), + ); + }, + ); + + it('skips files without a code environment ref', async () => { + await deleteCodeEnvFile(req, {}); + + expect(mockAxios).not.toHaveBeenCalled(); + expect(getCodeApiAuthHeaders).not.toHaveBeenCalled(); + }); + + it('treats missing code environment objects as already deleted', async () => { + mockAxios + .mockRejectedValueOnce(Object.assign(new Error('missing'), { response: { status: 404 } })) + .mockRejectedValueOnce(Object.assign(new Error('missing'), { response: { status: 404 } })); + + await expect(deleteCodeEnvFile(req, file)).resolves.toBeUndefined(); + expect(mockAxios).toHaveBeenCalledTimes(2); + }); + + it('throws when code environment deletion fails', async () => { + mockAxios.mockRejectedValue( + Object.assign(new Error('unavailable'), { response: { status: 500 } }), + ); + + await expect(deleteCodeEnvFile(req, file)).rejects.toThrow('unavailable'); + }); + }); + describe('uploadCodeEnvFile', () => { const baseUploadParams = { req: { user: { id: 'user-123' } }, diff --git a/api/server/services/Files/Code/process.js b/api/server/services/Files/Code/process.js index a04c6329c66..10ba254a19c 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -36,6 +36,7 @@ const { filterFilesByAgentAccess } = require('~/server/services/Files/permission const { createFile, getFiles, updateFile, claimCodeFile } = require('~/models'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { convertImage } = require('~/server/services/Files/images/convert'); +const { getRetentionExpiry } = require('~/server/services/Files/retention'); const { determineFileType } = require('~/server/utils'); const axios = createAxiosInstance(); @@ -463,6 +464,7 @@ const processCodeOutput = async ({ source: appConfig.fileStrategy, context: FileContext.execute_code, metadata: { codeEnvRef }, + ...(await getRetentionExpiry(req)), }; await createFile(file, true); return { file: Object.assign(file, { messageId, toolCallId }) }; @@ -565,6 +567,7 @@ const processCodeOutput = async ({ context: FileContext.execute_code, usage: isUpdate ? (claimed.usage ?? 0) + 1 : 1, createdAt: isUpdate ? claimed.createdAt : formattedDate, + ...(await getRetentionExpiry(req)), }; if (expectsPreview) { @@ -1039,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 6ade00b1f71..0bff06adf32 100644 --- a/api/server/services/Files/Code/process.spec.js +++ b/api/server/services/Files/Code/process.spec.js @@ -137,6 +137,10 @@ jest.mock('~/server/services/Files/images/convert', () => ({ convertImage: jest.fn(), })); +jest.mock('~/server/services/Files/retention', () => ({ + getRetentionExpiry: jest.fn(() => ({})), +})); + // Mock determineFileType jest.mock('~/server/utils', () => ({ determineFileType: jest.fn(), @@ -145,6 +149,7 @@ jest.mock('~/server/utils', () => ({ const http = require('http'); const https = require('https'); const { createFile, getFiles } = require('~/models'); +const { getRetentionExpiry } = require('~/server/services/Files/retention'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { convertImage } = require('~/server/services/Files/images/convert'); const { determineFileType } = require('~/server/utils'); @@ -156,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 = { @@ -233,6 +244,7 @@ describe('Code Process', () => { expect(result.file_id).toBe('mock-uuid-1234'); expect(result.usage).toBe(1); + expect(getRetentionExpiry).toHaveBeenCalledWith(baseParams.req); }); }); @@ -1620,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/Files/images/avatar.js b/api/server/services/Files/images/avatar.js index 640aed293ff..72e038cbece 100644 --- a/api/server/services/Files/images/avatar.js +++ b/api/server/services/Files/images/avatar.js @@ -26,7 +26,7 @@ const MAX_AVATAR_BYTES = 10 * 1024 * 1024; * measurable benefit on this path. If this ever becomes a hot path, hoist * the agents to module scope. */ -async function fetchAvatarBuffer(input) { +async function fetchAvatarBuffer(input, fetchOptions = {}) { let parsed; try { parsed = new URL(input); @@ -44,6 +44,7 @@ async function fetchAvatarBuffer(input) { * stronger of the two for this path — bounds total slow-loris exposure. */ const response = await fetch(parsed.href, { + headers: fetchOptions.headers, agent: (urlObj) => (urlObj.protocol === 'https:' ? httpsAgent : httpAgent), redirect: 'error', timeout: 5000, @@ -80,6 +81,7 @@ async function fetchAvatarBuffer(input) { * @param {string} options.desiredFormat - The desired output format of the image. * @param {(string|Buffer|File)} params.input - The input representing the avatar image. Can be a URL (string), * a Buffer, or a File object. + * @param {{ headers?: Record }} [params.fetchOptions] - Optional headers for trusted avatar URLs. * * @returns {Promise} * A promise that resolves to a resized buffer. @@ -87,7 +89,7 @@ async function fetchAvatarBuffer(input) { * @throws {Error} Throws an error if the user ID is undefined, the input type is invalid, the image fetching fails, * or any other error occurs during the processing. */ -async function resizeAvatar({ userId, input, desiredFormat = EImageOutputType.PNG }) { +async function resizeAvatar({ userId, input, desiredFormat = EImageOutputType.PNG, fetchOptions }) { try { if (userId === undefined) { throw new Error('User ID is undefined'); @@ -95,7 +97,7 @@ async function resizeAvatar({ userId, input, desiredFormat = EImageOutputType.PN let imageBuffer; if (typeof input === 'string') { - imageBuffer = await fetchAvatarBuffer(input); + imageBuffer = await fetchAvatarBuffer(input, fetchOptions); } else if (input instanceof Buffer) { imageBuffer = input; } else if (typeof input === 'object' && input instanceof File) { diff --git a/api/server/services/Files/images/avatar.spec.js b/api/server/services/Files/images/avatar.spec.js index cc3e0b1ad15..2eb2240468f 100644 --- a/api/server/services/Files/images/avatar.spec.js +++ b/api/server/services/Files/images/avatar.spec.js @@ -117,6 +117,26 @@ describe('resizeAvatar — fetchAvatarBuffer', () => { expect(agentFn(new URL('http://anything'))).toEqual({ __kind: 'http' }); expect(createSSRFSafeAgents).toHaveBeenCalledTimes(1); }); + + it('passes configured fetch headers while preserving shared fetch controls', async () => { + fetch.mockResolvedValueOnce(makeResponse({ body: Buffer.from('rawimg') })); + await resizeAvatar({ + userId: 'u1', + input: 'https://cdn.example.com/avatar.png', + fetchOptions: { + headers: { + Authorization: 'Bearer avatar-token', + }, + }, + }); + + const opts = fetch.mock.calls[0][1]; + expect(opts.headers).toEqual({ Authorization: 'Bearer avatar-token' }); + expect(opts.redirect).toBe('error'); + expect(opts.timeout).toBe(5000); + expect(opts.size).toBe(10 * 1024 * 1024); + expect(typeof opts.agent).toBe('function'); + }); }); describe('rejects unsafe responses', () => { diff --git a/api/server/services/Files/process.js b/api/server/services/Files/process.js index ea8ee14840a..f6d92ac5af6 100644 --- a/api/server/services/Files/process.js +++ b/api/server/services/Files/process.js @@ -18,12 +18,14 @@ const { getEndpointFileConfig, documentParserMimeTypes, } = require('librechat-data-provider'); -const { logger } = require('@librechat/data-schemas'); +const { logger, runAsSystem } = require('@librechat/data-schemas'); const { sanitizeFilename, parseText, processAudioFile, getStorageMetadata, + sweepExpiredFiles: sweepExpiredFilesWithDeps, + startExpiredFileSweep: startExpiredFileSweepWithDeps, } = require('@librechat/api'); const { convertImage, @@ -36,6 +38,7 @@ const { loadAuthValues } = require('~/server/services/Tools/credentials'); const { getFileStrategy } = require('~/server/utils/getFileStrategy'); const { checkCapability } = require('~/server/services/Config'); const { LB_QueueAsyncCall } = require('~/server/utils/queue'); +const { getRetentionExpiry, getAgentFileRetentionExpiry } = require('./retention'); const { getStrategyFunctions } = require('./strategies'); const { determineFileType } = require('~/server/utils'); const { STTService } = require('./Audio/STTService'); @@ -64,6 +67,19 @@ const createSanitizedUploadWrapper = (uploadFunction) => { }; }; +const hasCodeEnvRef = (file) => file?.metadata?.codeEnvRef != null; + +const isMissingStorageError = (err) => { + const code = err?.code ?? err?.status ?? err?.statusCode ?? err?.response?.status; + if ([404, '404', 'ENOENT', 'NoSuchKey', 'NotFound', 'ResourceNotFound'].includes(code)) { + return true; + } + + return /(?:file|object|blob|key|resource) (?:not found|does not exist)|no such (?:file|key)/i.test( + String(err?.message ?? ''), + ); +}; + /** * Enqueues the delete operation to the leaky bucket queue if necessary, or adds it directly to promises. * @@ -72,10 +88,19 @@ const createSanitizedUploadWrapper = (uploadFunction) => { * @param {MongoFile} params.file - The file object to delete. * @param {Function} params.deleteFile - The delete file function. * @param {Promise[]} params.promises - The array of promises to await. - * @param {string[]} params.resolvedFileIds - The array of promises to await. + * @param {Set} params.resolvedFileIds - File IDs whose storage delete succeeded. + * @param {Set} params.failedFileIds - File IDs whose storage delete failed. * @param {OpenAI | undefined} [params.openai] - If an OpenAI file, the initialized OpenAI client. */ -function enqueueDeleteOperation({ req, file, deleteFile, promises, resolvedFileIds, openai }) { +function enqueueDeleteOperation({ + req, + file, + deleteFile, + promises, + resolvedFileIds, + failedFileIds, + openai, +}) { if (checkOpenAIStorage(file.source)) { // Enqueue to leaky bucket promises.push( @@ -85,10 +110,17 @@ function enqueueDeleteOperation({ req, file, deleteFile, promises, resolvedFileI [], (err, result) => { if (err) { + if (isMissingStorageError(err)) { + resolvedFileIds.add(file.file_id); + logger.warn('File storage was already missing during delete', err); + resolve(result); + return; + } + failedFileIds.add(file.file_id); logger.error('Error deleting file from OpenAI source', err); reject(err); } else { - resolvedFileIds.push(file.file_id); + resolvedFileIds.add(file.file_id); resolve(result); } }, @@ -99,8 +131,14 @@ function enqueueDeleteOperation({ req, file, deleteFile, promises, resolvedFileI // Add directly to promises promises.push( deleteFile(req, file) - .then(() => resolvedFileIds.push(file.file_id)) + .then(() => resolvedFileIds.add(file.file_id)) .catch((err) => { + if (isMissingStorageError(err)) { + resolvedFileIds.add(file.file_id); + logger.warn('File storage was already missing during delete', err); + return; + } + failedFileIds.add(file.file_id); logger.error('Error deleting file', err); return Promise.reject(err); }), @@ -108,6 +146,49 @@ function enqueueDeleteOperation({ req, file, deleteFile, promises, resolvedFileI } } +const getDeleteMethod = ({ source, deletionMethods }) => { + if (deletionMethods[source]) { + return deletionMethods[source]; + } + + const { deleteFile } = getStrategyFunctions(source); + if (!deleteFile) { + throw new Error(`Delete function not implemented for ${source}`); + } + + deletionMethods[source] = deleteFile; + return deleteFile; +}; + +const createDeleteFileWithSecondaryStorage = ({ source, deleteFile, deletionMethods }) => { + return async (req, file, openai) => { + const secondaryDeleteMethods = []; + if (file.embedded === true && source !== FileSources.vectordb) { + secondaryDeleteMethods.push( + getDeleteMethod({ source: FileSources.vectordb, deletionMethods }), + ); + } + if (hasCodeEnvRef(file) && source !== FileSources.execute_code) { + secondaryDeleteMethods.push( + getDeleteMethod({ source: FileSources.execute_code, deletionMethods }), + ); + } + + try { + await deleteFile(req, file, openai); + } catch (err) { + if (!isMissingStorageError(err)) { + throw err; + } + logger.warn('Primary file storage was already missing during delete', err); + } + + await Promise.all( + secondaryDeleteMethods.map((secondaryDeleteFile) => secondaryDeleteFile(req, file)), + ); + }; +}; + // TODO: refactor as currently only image files can be deleted this way // as other filetypes will not reside in public path /** @@ -121,11 +202,13 @@ function enqueueDeleteOperation({ req, file, deleteFile, promises, resolvedFileI * @param {string} [params.req.body.assistant_id] - The assistant ID if file uploaded is associated to an assistant. * @param {string} [params.req.body.tool_resource] - The tool resource if assistant file uploaded is associated to a tool resource. * - * @returns {Promise} + * @returns {Promise<{ deletedFileIds: string[], failedFileIds: string[] }>} + * @throws {Error} When storage deletion cannot be scheduled or file metadata cleanup fails. */ const processDeleteRequest = async ({ req, files }) => { const appConfig = req.config; - const resolvedFileIds = []; + const resolvedFileIds = new Set(); + const failedFileIds = new Set(); const deletionMethods = {}; const promises = []; @@ -167,7 +250,7 @@ const processDeleteRequest = async ({ req, files }) => { } if (source === FileSources.text) { - resolvedFileIds.push(file.file_id); + resolvedFileIds.add(file.file_id); continue; } @@ -191,25 +274,16 @@ const processDeleteRequest = async ({ req, files }) => { promises.push(openai.beta.assistants.files.del(req.body.assistant_id, file.file_id)); } - if (deletionMethods[source]) { - enqueueDeleteOperation({ - req, - file, - deleteFile: deletionMethods[source], - promises, - resolvedFileIds, - openai, - }); - continue; - } - - const { deleteFile } = getStrategyFunctions(source); - if (!deleteFile) { - throw new Error(`Delete function not implemented for ${source}`); - } - - deletionMethods[source] = deleteFile; - enqueueDeleteOperation({ req, file, deleteFile, promises, resolvedFileIds, openai }); + const deleteFile = getDeleteMethod({ source, deletionMethods }); + enqueueDeleteOperation({ + req, + file, + deleteFile: createDeleteFileWithSecondaryStorage({ source, deleteFile, deletionMethods }), + promises, + resolvedFileIds, + failedFileIds, + openai, + }); } if (agentFiles.length > 0) { @@ -222,17 +296,60 @@ const processDeleteRequest = async ({ req, files }) => { } await Promise.allSettled(promises); - await db.deleteFiles(resolvedFileIds); - - if (resolvedFileIds.length > 0) { + const deletedFileIds = [...resolvedFileIds]; + let metadataDeletedFileIds = deletedFileIds; + if (deletedFileIds.length > 0) { try { - await db.removeAgentResourceFilesFromAllAgents({ file_ids: resolvedFileIds }); + await db.deleteFiles(deletedFileIds); } catch (error) { - logger.error('Error cleaning up orphaned agent file references', error); + logger.error('Error deleting file metadata after storage deletion', error); + deletedFileIds.forEach((fileId) => failedFileIds.add(fileId)); + metadataDeletedFileIds = []; + throw error; + } + if (metadataDeletedFileIds.length > 0) { + try { + await db.removeAgentResourceFilesFromAllAgents({ file_ids: metadataDeletedFileIds }); + } catch (error) { + logger.error('Error cleaning up orphaned agent file references', error); + } } } + + return { + deletedFileIds: metadataDeletedFileIds, + failedFileIds: [...failedFileIds], + }; }; +/** + * Deletes expired file storage before removing the corresponding File records. + * + * Mongo TTL indexes delete only the metadata document, so file retention uses + * this application sweep for records with `expiredAt` instead. + * + * @param {object} params + * @param {AppConfig} params.appConfig + * @param {number} [params.limit] + * @param {() => Promise} [params.loadAppConfig] + * @returns {Promise<{ scanned: number, deleted: number, failed: number }>} + */ +async function sweepExpiredFiles(options = {}) { + return sweepExpiredFilesWithDeps(options, { + getExpiredFiles: db.getExpiredFiles, + processDeleteRequest, + logger, + }); +} + +function startExpiredFileSweep(options = {}) { + return startExpiredFileSweepWithDeps(options, { + sweepExpiredFiles, + runAsSystem, + logger, + }); +} + /** * Processes a file URL using a specified file handling strategy. This function accepts a strategy name, * fetches the corresponding file processing functions (for saving and retrieving file URLs), and then @@ -251,6 +368,7 @@ const processDeleteRequest = async ({ req, files }) => { * @param {string} params.basePath - The base path or directory where the file will be saved or retrieved from. * @param {FileContext} params.context - The context of the file (e.g., 'avatar', 'image_generation', etc.) * @param {string} [params.tenantId] - Optional tenant identifier for tenant-prefixed storage paths. + * @param {ServerRequest} [params.req] - Request context used to apply data retention metadata. * @returns {Promise} A promise that resolves to the DB representation (MongoFile) * of the processed file. It throws an error if the file processing fails at any stage. */ @@ -262,6 +380,7 @@ const processFileURL = async ({ basePath, context, tenantId, + req, }) => { const { saveURL, getFileURL } = getStrategyFunctions(fileStrategy); try { @@ -305,6 +424,7 @@ const processFileURL = async ({ source: fileStrategy, type, context, + ...(await getRetentionExpiry(req)), tenantId, width: dimensions.width, height: dimensions.height, @@ -355,6 +475,7 @@ const processImageFile = async ({ req, res, metadata, returnFile = false }) => { context: FileContext.message_attachment, source, type: `image/${appConfig.imageOutputType}`, + ...(await getRetentionExpiry(req)), width, height, tenantId: req.user.tenantId, @@ -415,6 +536,7 @@ const uploadImageBuffer = async ({ req, context, metadata = {}, resize = true }) source, type, width, + ...(await getRetentionExpiry(req)), height, tenantId: req.user.tenantId, }, @@ -517,6 +639,7 @@ const processFileUpload = async ({ req, res, metadata }) => { context: isAssistantUpload ? FileContext.assistants : FileContext.message_attachment, model: isAssistantUpload ? req.body.model : undefined, type: file.mimetype, + ...(await getRetentionExpiry(req)), embedded, source, height, @@ -631,20 +754,28 @@ const processAgentFileUpload = async ({ req, res, metadata }) => { `Extracted text from "${file.originalname}" exceeds the 15MB storage limit (${Math.round(textBytes / megabyte)}MB). Try a shorter document.`, ); } - const fileInfo = removeNullishValues({ - text, - bytes, - file_id, - temp_file_id, - user: req.user.id, - type, - filepath: filepath ?? file.path, - source: FileSources.text, - filename: file.originalname, - model: messageAttachment ? undefined : req.body.model, - context: messageAttachment ? FileContext.message_attachment : FileContext.agents, - tenantId: req.user.tenantId, + const retentionExpiry = await getAgentFileRetentionExpiry({ + req, + messageAttachment, + tool_resource, }); + const fileInfo = { + ...removeNullishValues({ + text, + bytes, + file_id, + temp_file_id, + user: req.user.id, + type, + filepath: filepath ?? file.path, + source: FileSources.text, + filename: file.originalname, + model: messageAttachment ? undefined : req.body.model, + context: messageAttachment ? FileContext.message_attachment : FileContext.agents, + tenantId: req.user.tenantId, + }), + ...retentionExpiry, + }; if (!messageAttachment && tool_resource) { await db.addAgentResourceFile({ @@ -825,24 +956,32 @@ const processAgentFileUpload = async ({ req, res, metadata }) => { }); } - const fileInfo = removeNullishValues({ - user: req.user.id, - file_id, - temp_file_id, - bytes, - filepath, - ...storageMetadata, - filename: filename ?? sanitizeFilename(file.originalname), - context: messageAttachment ? FileContext.message_attachment : FileContext.agents, - model: messageAttachment ? undefined : req.body.model, - metadata: fileInfoMetadata, - type: file.mimetype, - embedded, - source, - height, - width, - tenantId: req.user.tenantId, + const retentionExpiry = await getAgentFileRetentionExpiry({ + req, + messageAttachment, + tool_resource, }); + const fileInfo = { + ...removeNullishValues({ + user: req.user.id, + file_id, + temp_file_id, + bytes, + filepath, + ...storageMetadata, + filename: filename ?? sanitizeFilename(file.originalname), + context: messageAttachment ? FileContext.message_attachment : FileContext.agents, + model: messageAttachment ? undefined : req.body.model, + metadata: fileInfoMetadata, + type: file.mimetype, + embedded, + source, + height, + width, + tenantId: req.user.tenantId, + }), + ...retentionExpiry, + }; const result = await db.createFile(fileInfo, true); @@ -887,6 +1026,7 @@ const processOpenAIFile = async ({ source, model: openai.req.body.model, filename: originalName ?? file_id, + ...(await getRetentionExpiry(openai.req)), tenantId: openai.req?.user?.tenantId, }; @@ -894,7 +1034,11 @@ const processOpenAIFile = async ({ await db.createFile(file, true); } else if (updateUsage) { try { - await db.updateFileUsage({ file_id }); + await db.updateFileUsage({ + file_id, + user: userId, + tenantId: openai.req?.user?.tenantId, + }); } catch (error) { logger.error('Error updating file usage', error); } @@ -931,9 +1075,14 @@ const processOpenAIImageOutput = async ({ req, buffer, file_id, filename, fileEx context: FileContext.assistants_output, file_id, filename, + ...(await getRetentionExpiry(req)), tenantId: req.user.tenantId, }; - db.createFile(file, true); + try { + await db.createFile(file, true); + } catch (error) { + logger.warn('Error saving OpenAI image output file metadata', error); + } return file; }; @@ -1091,6 +1240,7 @@ async function saveBase64Image( user: req.user.id, bytes: image.bytes, width: image.width, + ...(await getRetentionExpiry(req)), height: image.height, tenantId: req.user.tenantId, }, @@ -1182,6 +1332,8 @@ module.exports = { saveBase64Image, processImageFile, uploadImageBuffer, + sweepExpiredFiles, + startExpiredFileSweep, processFileUpload, processDeleteRequest, processAgentFileUpload, diff --git a/api/server/services/Files/process.spec.js b/api/server/services/Files/process.spec.js index 99457522d45..1521f873bf5 100644 --- a/api/server/services/Files/process.spec.js +++ b/api/server/services/Files/process.spec.js @@ -1,22 +1,64 @@ jest.mock('uuid', () => ({ v4: jest.fn(() => 'mock-uuid') })); jest.mock('@librechat/data-schemas', () => ({ - logger: { warn: jest.fn(), debug: jest.fn(), error: jest.fn() }, + logger: { warn: jest.fn(), debug: jest.fn(), error: jest.fn(), info: jest.fn() }, + runAsSystem: jest.fn((fn) => fn()), + createTempChatExpirationDate: jest.fn(() => new Date('2030-01-01T00:00:00.000Z')), })); -jest.mock('@librechat/agents', () => ({})); - -jest.mock('@librechat/api', () => ({ - sanitizeFilename: jest.fn((n) => n), - parseText: jest.fn().mockResolvedValue({ text: '', bytes: 0 }), - processAudioFile: jest.fn(), - getStorageMetadata: jest.fn(() => ({})), +jest.mock('@librechat/agents', () => ({ + Providers: { + XAI: 'xai', + DEEPSEEK: 'deepseek', + MOONSHOT: 'moonshot', + OPENROUTER: 'openrouter', + VERTEXAI: 'vertexai', + }, })); -jest.mock('librechat-data-provider', () => ({ - ...jest.requireActual('librechat-data-provider'), - mergeFileConfig: jest.fn(), -})); +jest.mock('librechat-data-provider', () => { + const actual = jest.requireActual('librechat-data-provider'); + return { + ...actual, + Providers: actual.Providers, + RetentionMode: actual.RetentionMode ?? { ALL: 'all', TEMPORARY: 'temporary' }, + documentParserMimeTypes: actual.documentParserMimeTypes ?? [ + /^application\/pdf$/, + /^application\/vnd\.openxmlformats-officedocument\./, + /^application\/vnd\.ms-excel$/, + /^application\/vnd\.oasis\.opendocument\./, + /^application\/(?:x-)?msexcel$/, + ], + mergeFileConfig: jest.fn(), + }; +}); + +jest.mock('@librechat/api', () => { + const actualDataProvider = jest.requireActual('librechat-data-provider'); + const RetentionMode = actualDataProvider.RetentionMode ?? { ALL: 'all', TEMPORARY: 'temporary' }; + const getRetentionExpiry = jest.fn(() => ({})); + return { + sanitizeFilename: jest.fn((n) => n), + parseText: jest.fn().mockResolvedValue({ text: '', bytes: 0 }), + processAudioFile: jest.fn(), + getStorageMetadata: jest.fn(() => ({})), + getRetentionExpiry, + getAgentFileRetentionExpiry: jest.fn(({ req, messageAttachment, toolResource }) => { + const interfaceConfig = req?.config?.interfaceConfig; + if ( + !messageAttachment && + !!toolResource && + (interfaceConfig?.retentionMode !== RetentionMode.ALL || + interfaceConfig?.retainAgentFiles === true) + ) { + return {}; + } + return getRetentionExpiry(req); + }), + sweepExpiredFiles: jest.fn().mockResolvedValue({ scanned: 0, deleted: 0, failed: 0 }), + startExpiredFileSweep: jest.fn().mockReturnValue('sweep-interval'), + }; +}); jest.mock('~/server/services/Files/images', () => ({ convertImage: jest.fn(), @@ -41,8 +83,12 @@ jest.mock('~/models', () => ({ createFile: jest.fn().mockResolvedValue({ file_id: 'created-file-id' }), updateFileUsage: jest.fn(), deleteFiles: jest.fn(), + findFileById: jest.fn(), + getConvo: jest.fn(), + getExpiredFiles: jest.fn(), addAgentResourceFile: jest.fn().mockResolvedValue({}), removeAgentResourceFiles: jest.fn(), + removeAgentResourceFilesFromAllAgents: jest.fn(), })); jest.mock('~/server/utils/getFileStrategy', () => ({ @@ -61,6 +107,16 @@ jest.mock('~/server/services/Files/strategies', () => ({ getStrategyFunctions: jest.fn(), })); +jest.mock('./VectorDB/crud', () => ({ + uploadVectors: jest.fn().mockResolvedValue({ + bytes: 42, + filename: 'upload.bin', + filepath: 'vectordb', + embedded: true, + }), + deleteVectors: jest.fn(), +})); + jest.mock('~/server/utils', () => ({ determineFileType: jest.fn(), })); @@ -69,17 +125,31 @@ jest.mock('~/server/services/Files/Audio/STTService', () => ({ STTService: { getInstance: jest.fn() }, })); +const { + getRetentionExpiry, + getAgentFileRetentionExpiry, + sweepExpiredFiles: sweepExpiredFilesWithDeps, + startExpiredFileSweep: startExpiredFileSweepWithDeps, +} = require('@librechat/api'); const { EToolResources, FileSources, FileContext, + RetentionMode, AgentCapabilities, } = require('librechat-data-provider'); const { mergeFileConfig } = require('librechat-data-provider'); const { checkCapability } = require('~/server/services/Config'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); +const { uploadVectors } = require('./VectorDB/crud'); const db = require('~/models'); -const { processAgentFileUpload, processFileURL } = require('./process'); +const { + processAgentFileUpload, + processDeleteRequest, + processFileURL, + sweepExpiredFiles, + startExpiredFileSweep, +} = require('./process'); const PDF_MIME = 'application/pdf'; const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; @@ -90,7 +160,7 @@ const ODT_MIME = 'application/vnd.oasis.opendocument.text'; const ODP_MIME = 'application/vnd.oasis.opendocument.presentation'; const ODG_MIME = 'application/vnd.oasis.opendocument.graphics'; -const makeReq = ({ mimetype = PDF_MIME, ocrConfig = null } = {}) => ({ +const makeReq = ({ mimetype = PDF_MIME, ocrConfig = null, interfaceConfig, body } = {}) => ({ user: { id: 'user-123', tenantId: 'tenant-a' }, file: { path: '/tmp/upload.bin', @@ -98,11 +168,12 @@ const makeReq = ({ mimetype = PDF_MIME, ocrConfig = null } = {}) => ({ filename: 'upload-uuid.bin', mimetype, }, - body: { model: 'gpt-4o' }, + body: { model: 'gpt-4o', ...body }, config: { fileConfig: {}, fileStrategy: 'local', ocr: ocrConfig, + ...(interfaceConfig ? { interfaceConfig } : {}), }, }); @@ -124,6 +195,17 @@ const makeFileConfig = ({ ocrSupportedMimeTypes = [] } = {}) => ({ text: { supportedMimeTypes: [] }, }); +const setupStoredFileUpload = (result = {}) => { + const handleFileUpload = jest.fn().mockResolvedValue({ + bytes: 42, + filename: 'upload.bin', + filepath: '/uploads/upload.bin', + ...result, + }); + getStrategyFunctions.mockReturnValue({ handleFileUpload }); + return handleFileUpload; +}; + describe('processAgentFileUpload', () => { beforeEach(() => { jest.clearAllMocks(); @@ -347,6 +429,198 @@ describe('processAgentFileUpload', () => { }); }); + describe('retention for agent resource uploads', () => { + test('skips retention metadata for persistent agent context files outside all-data retention when retainAgentFiles is disabled', async () => { + const expiredAt = new Date('2030-01-01T00:00:00.000Z'); + const req = makeReq({ + mimetype: PDF_MIME, + ocrConfig: null, + interfaceConfig: { retentionMode: RetentionMode.TEMPORARY, retainAgentFiles: false }, + body: { conversationId: 'temporary-convo', isTemporary: true }, + }); + + await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }); + + expect(getAgentFileRetentionExpiry).toHaveBeenCalledWith( + { + req, + messageAttachment: false, + toolResource: EToolResources.context, + }, + expect.any(Object), + ); + expect(getRetentionExpiry).not.toHaveBeenCalled(); + expect(db.createFile).toHaveBeenCalledWith(expect.not.objectContaining({ expiredAt }), true); + expect(db.addAgentResourceFile).toHaveBeenCalledWith( + expect.objectContaining({ + agent_id: 'agent-abc', + tool_resource: EToolResources.context, + }), + ); + }); + + test('skips retention metadata for persistent agent context files outside all-data retention when retainAgentFiles is enabled', async () => { + const expiredAt = new Date('2030-01-01T00:00:00.000Z'); + const req = makeReq({ + mimetype: PDF_MIME, + ocrConfig: null, + interfaceConfig: { retentionMode: RetentionMode.TEMPORARY, retainAgentFiles: true }, + }); + + await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }); + + expect(getRetentionExpiry).not.toHaveBeenCalled(); + expect(db.createFile).toHaveBeenCalledWith(expect.not.objectContaining({ expiredAt }), true); + expect(db.addAgentResourceFile).toHaveBeenCalledWith( + expect.objectContaining({ + agent_id: 'agent-abc', + tool_resource: EToolResources.context, + }), + ); + }); + + test('applies all-data retention metadata to persistent agent context files when retainAgentFiles is disabled', async () => { + const expiredAt = new Date('2030-01-01T00:00:00.000Z'); + getRetentionExpiry.mockResolvedValueOnce({ expiredAt }); + const req = makeReq({ + mimetype: PDF_MIME, + ocrConfig: null, + interfaceConfig: { retentionMode: RetentionMode.ALL, retainAgentFiles: false }, + }); + + await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }); + + expect(getRetentionExpiry).toHaveBeenCalledTimes(1); + expect(getRetentionExpiry.mock.calls[0][0]).toBe(req); + expect(db.createFile).toHaveBeenCalledWith( + expect.objectContaining({ + expiredAt, + context: FileContext.agents, + }), + true, + ); + expect(db.addAgentResourceFile).toHaveBeenCalledWith( + expect.objectContaining({ + agent_id: 'agent-abc', + tool_resource: EToolResources.context, + }), + ); + }); + + test('skips all-data retention metadata for persistent agent context files when retainAgentFiles is enabled', async () => { + const expiredAt = new Date('2030-01-01T00:00:00.000Z'); + const req = makeReq({ + mimetype: PDF_MIME, + ocrConfig: null, + interfaceConfig: { retentionMode: RetentionMode.ALL, retainAgentFiles: true }, + }); + + await processAgentFileUpload({ req, res: mockRes, metadata: makeMetadata() }); + + expect(getAgentFileRetentionExpiry).toHaveBeenCalledWith( + { + req, + messageAttachment: false, + toolResource: EToolResources.context, + }, + expect.any(Object), + ); + expect(getRetentionExpiry).not.toHaveBeenCalled(); + expect(db.createFile).toHaveBeenCalledWith( + expect.objectContaining({ + context: FileContext.agents, + }), + true, + ); + expect(db.createFile).toHaveBeenCalledWith(expect.not.objectContaining({ expiredAt }), true); + expect(db.addAgentResourceFile).toHaveBeenCalledWith( + expect.objectContaining({ + agent_id: 'agent-abc', + tool_resource: EToolResources.context, + }), + ); + }); + + test('applies retention metadata to context files uploaded as message attachments', async () => { + const expiredAt = new Date('2030-01-01T00:00:00.000Z'); + getRetentionExpiry.mockResolvedValueOnce({ expiredAt }); + const req = makeReq({ mimetype: PDF_MIME, ocrConfig: null }); + + await processAgentFileUpload({ + req, + res: mockRes, + metadata: { ...makeMetadata(), message_file: true }, + }); + + expect(getRetentionExpiry).toHaveBeenCalledTimes(1); + expect(getRetentionExpiry.mock.calls[0][0]).toBe(req); + expect(db.createFile).toHaveBeenCalledWith( + expect.objectContaining({ + expiredAt, + context: FileContext.message_attachment, + }), + true, + ); + expect(db.addAgentResourceFile).not.toHaveBeenCalled(); + }); + + test('skips retention metadata for persistent agent file-search files outside all-data retention', async () => { + const expiredAt = new Date('2030-01-01T00:00:00.000Z'); + setupStoredFileUpload(); + const req = makeReq({ mimetype: 'text/plain', ocrConfig: null }); + + await processAgentFileUpload({ + req, + res: mockRes, + metadata: { ...makeMetadata(), tool_resource: EToolResources.file_search }, + }); + + expect(uploadVectors).toHaveBeenCalled(); + expect(getRetentionExpiry).not.toHaveBeenCalled(); + expect(db.createFile).toHaveBeenCalledWith(expect.not.objectContaining({ expiredAt }), true); + expect(db.addAgentResourceFile).toHaveBeenCalledWith( + expect.objectContaining({ + agent_id: 'agent-abc', + tool_resource: EToolResources.file_search, + }), + ); + }); + + test('applies all-data retention metadata to persistent agent file-search files', async () => { + const expiredAt = new Date('2030-01-01T00:00:00.000Z'); + getRetentionExpiry.mockResolvedValueOnce({ expiredAt }); + setupStoredFileUpload(); + const req = makeReq({ + mimetype: 'text/plain', + ocrConfig: null, + interfaceConfig: { retentionMode: RetentionMode.ALL }, + }); + + await processAgentFileUpload({ + req, + res: mockRes, + metadata: { ...makeMetadata(), tool_resource: EToolResources.file_search }, + }); + + expect(uploadVectors).toHaveBeenCalled(); + expect(getRetentionExpiry).toHaveBeenCalledTimes(1); + expect(getRetentionExpiry.mock.calls[0][0]).toBe(req); + expect(db.createFile).toHaveBeenCalledWith( + expect.objectContaining({ + expiredAt, + context: FileContext.agents, + }), + true, + ); + expect(db.addAgentResourceFile).toHaveBeenCalledWith( + expect.objectContaining({ + agent_id: 'agent-abc', + tool_resource: EToolResources.file_search, + }), + ); + }); + }); + /* Phase C / option α regression: the upload must persist its sandbox * pointer under `metadata.codeEnvRef` (the post-cutover schema). The * legacy `metadata.fileIdentifier` key is silently stripped by mongoose @@ -450,6 +724,72 @@ describe('processAgentFileUpload', () => { ); }); + it('skips retention metadata for persistent agent execute_code files outside all-data retention', async () => { + const expiredAt = new Date('2030-01-01T00:00:00.000Z'); + setupCodeEnvUpload({ storage_session_id: 'sess-4', file_id: 'fid-4' }); + const req = makeReq(); + + await processAgentFileUpload({ + req, + res: mockRes, + metadata: { + agent_id: 'agent-abc', + tool_resource: EToolResources.execute_code, + file_id: 'file-uuid', + }, + }); + + expect(getRetentionExpiry).not.toHaveBeenCalled(); + expect(db.createFile).toHaveBeenCalledWith(expect.not.objectContaining({ expiredAt }), true); + expect(db.addAgentResourceFile).toHaveBeenCalledWith( + expect.objectContaining({ + agent_id: 'agent-abc', + tool_resource: EToolResources.execute_code, + }), + ); + }); + + it('applies all-data retention metadata to persistent agent execute_code files', async () => { + const expiredAt = new Date('2030-01-01T00:00:00.000Z'); + getRetentionExpiry.mockResolvedValueOnce({ expiredAt }); + setupCodeEnvUpload({ storage_session_id: 'sess-5', file_id: 'fid-5' }); + const req = makeReq({ interfaceConfig: { retentionMode: RetentionMode.ALL } }); + + await processAgentFileUpload({ + req, + res: mockRes, + metadata: { + agent_id: 'agent-abc', + tool_resource: EToolResources.execute_code, + file_id: 'file-uuid', + }, + }); + + expect(getRetentionExpiry).toHaveBeenCalledTimes(1); + expect(getRetentionExpiry.mock.calls[0][0]).toBe(req); + expect(db.createFile).toHaveBeenCalledWith( + expect.objectContaining({ + expiredAt, + context: FileContext.agents, + metadata: { + codeEnvRef: { + kind: 'agent', + id: 'agent-abc', + storage_session_id: 'sess-5', + file_id: 'fid-5', + }, + }, + }), + true, + ); + expect(db.addAgentResourceFile).toHaveBeenCalledWith( + expect.objectContaining({ + agent_id: 'agent-abc', + tool_resource: EToolResources.execute_code, + }), + ); + }); + it('does not persist legacy fileIdentifier key (mongoose strict drops it)', async () => { setupCodeEnvUpload({ storage_session_id: 'sess-3', file_id: 'fid-3' }); const req = makeReq(); @@ -534,6 +874,110 @@ describe('processFileURL', () => { ); }); + it('applies retention metadata for generated images when retention mode is all', async () => { + getRetentionExpiry.mockResolvedValueOnce({ + expiredAt: new Date('2030-01-01T00:00:00.000Z'), + }); + const saveURL = jest.fn().mockResolvedValue({ + filepath: 'https://cdn.example.com/t/tenant-a/images/user-123/image.png', + bytes: 512, + type: 'image/png', + }); + const getFileURL = jest.fn(); + getStrategyFunctions.mockReturnValue({ saveURL, getFileURL }); + + await processFileURL({ + fileStrategy: FileSources.cloudfront, + userId: 'user-123', + URL: 'https://example.com/image.png', + fileName: 'image.png', + basePath: 'images', + context: FileContext.image_generation, + tenantId: 'tenant-a', + req: { + user: { id: 'user-123', tenantId: 'tenant-a' }, + body: {}, + config: { interfaceConfig: { retentionMode: 'all', retainAgentFiles: true } }, + }, + }); + + expect(db.createFile).toHaveBeenCalledWith( + expect.objectContaining({ + expiredAt: new Date('2030-01-01T00:00:00.000Z'), + }), + true, + ); + }); + + it('applies retention metadata for retained non-temporary conversations', async () => { + const saveURL = jest.fn().mockResolvedValue({ + filepath: 'https://cdn.example.com/t/tenant-a/images/user-123/image.png', + bytes: 512, + type: 'image/png', + }); + const getFileURL = jest.fn(); + getStrategyFunctions.mockReturnValue({ saveURL, getFileURL }); + getRetentionExpiry.mockResolvedValueOnce({ + expiredAt: new Date('2030-01-01T00:00:00.000Z'), + }); + + await processFileURL({ + fileStrategy: FileSources.cloudfront, + userId: 'user-123', + URL: 'https://example.com/image.png', + fileName: 'image.png', + basePath: 'images', + context: FileContext.image_generation, + tenantId: 'tenant-a', + req: { + user: { id: 'user-123', tenantId: 'tenant-a' }, + body: { conversationId: 'convo-123' }, + config: { interfaceConfig: { retentionMode: RetentionMode.TEMPORARY } }, + }, + }); + + expect(db.createFile).toHaveBeenCalledWith( + expect.objectContaining({ + expiredAt: new Date('2030-01-01T00:00:00.000Z'), + }), + true, + ); + }); + + it('keeps expired retained conversation files on the parent expiration', async () => { + const parentExpiredAt = new Date('2020-01-01T00:00:00.000Z'); + const saveURL = jest.fn().mockResolvedValue({ + filepath: 'https://cdn.example.com/t/tenant-a/images/user-123/image.png', + bytes: 512, + type: 'image/png', + }); + const getFileURL = jest.fn(); + getStrategyFunctions.mockReturnValue({ saveURL, getFileURL }); + getRetentionExpiry.mockResolvedValueOnce({ expiredAt: parentExpiredAt }); + + await processFileURL({ + fileStrategy: FileSources.cloudfront, + userId: 'user-123', + URL: 'https://example.com/image.png', + fileName: 'image.png', + basePath: 'images', + context: FileContext.image_generation, + tenantId: 'tenant-a', + req: { + user: { id: 'user-123', tenantId: 'tenant-a' }, + body: { conversationId: 'convo-123' }, + config: { interfaceConfig: { retentionMode: RetentionMode.TEMPORARY } }, + }, + }); + + expect(db.createFile).toHaveBeenCalledWith( + expect.objectContaining({ + expiredAt: parentExpiredAt, + }), + true, + ); + }); + it('falls back to getFileURL with user and tenant context when metadata lacks filepath', async () => { const saveURL = jest.fn().mockResolvedValue({ bytes: 256, @@ -602,3 +1046,293 @@ describe('processFileURL', () => { ); }); }); + +describe('processDeleteRequest', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('removes metadata when backing storage is already missing', async () => { + const missingError = Object.assign(new Error('no such file'), { code: 'ENOENT' }); + const deleteFile = jest.fn().mockRejectedValue(missingError); + getStrategyFunctions.mockReturnValue({ deleteFile }); + db.deleteFiles.mockResolvedValue({ deletedCount: 1 }); + + const result = await processDeleteRequest({ + req: { + body: {}, + config: {}, + user: { id: 'user-123', tenantId: 'tenant-a' }, + }, + files: [ + { + file_id: 'expired-file', + filepath: '/images/user-123/expired.png', + source: FileSources.local, + }, + ], + }); + + expect(db.deleteFiles).toHaveBeenCalledWith(['expired-file']); + expect(result).toEqual({ deletedFileIds: ['expired-file'], failedFileIds: [] }); + }); + + it('does not treat unrelated not found messages as missing storage', async () => { + const deleteFile = jest.fn().mockRejectedValue(new Error('Configuration not found')); + getStrategyFunctions.mockReturnValue({ deleteFile }); + + const result = await processDeleteRequest({ + req: { + body: {}, + config: {}, + user: { id: 'user-123', tenantId: 'tenant-a' }, + }, + files: [ + { + file_id: 'expired-file', + filepath: '/images/user-123/expired.png', + source: FileSources.local, + }, + ], + }); + + expect(db.deleteFiles).not.toHaveBeenCalled(); + expect(result).toEqual({ deletedFileIds: [], failedFileIds: ['expired-file'] }); + }); + + it('throws metadata delete failures after storage deletion succeeds', async () => { + const deleteFile = jest.fn().mockResolvedValue(undefined); + const metadataError = new Error('mongo unavailable'); + getStrategyFunctions.mockReturnValue({ deleteFile }); + db.deleteFiles.mockRejectedValue(metadataError); + + await expect( + processDeleteRequest({ + req: { + body: {}, + config: {}, + user: { id: 'user-123', tenantId: 'tenant-a' }, + }, + files: [ + { + file_id: 'expired-file', + filepath: '/images/user-123/expired.png', + source: FileSources.local, + }, + ], + }), + ).rejects.toThrow('mongo unavailable'); + + expect(db.deleteFiles).toHaveBeenCalledWith(['expired-file']); + expect(db.removeAgentResourceFilesFromAllAgents).not.toHaveBeenCalled(); + }); + + it('deletes vector storage before removing embedded file metadata', async () => { + const primaryDelete = jest.fn().mockResolvedValue(undefined); + const vectorDelete = jest.fn().mockResolvedValue(undefined); + getStrategyFunctions.mockImplementation((source) => + source === FileSources.vectordb + ? { deleteFile: vectorDelete } + : { deleteFile: primaryDelete }, + ); + db.deleteFiles.mockResolvedValue({ deletedCount: 1 }); + const req = { + body: {}, + config: {}, + user: { id: 'user-123', tenantId: 'tenant-a' }, + }; + const file = { + file_id: 'embedded-file', + filepath: '/uploads/embedded.txt', + source: FileSources.local, + embedded: true, + }; + + const result = await processDeleteRequest({ req, files: [file] }); + + expect(primaryDelete).toHaveBeenCalledWith(req, file, undefined); + expect(vectorDelete).toHaveBeenCalledWith(req, file); + expect(db.deleteFiles).toHaveBeenCalledWith(['embedded-file']); + expect(result).toEqual({ deletedFileIds: ['embedded-file'], failedFileIds: [] }); + }); + + it('keeps embedded file metadata when vector deletion fails', async () => { + const primaryDelete = jest.fn().mockResolvedValue(undefined); + const vectorDelete = jest.fn().mockRejectedValue(new Error('rag unavailable')); + getStrategyFunctions.mockImplementation((source) => + source === FileSources.vectordb + ? { deleteFile: vectorDelete } + : { deleteFile: primaryDelete }, + ); + const req = { + body: {}, + config: {}, + user: { id: 'user-123', tenantId: 'tenant-a' }, + }; + const file = { + file_id: 'embedded-file', + filepath: '/uploads/embedded.txt', + source: FileSources.local, + embedded: true, + }; + + const result = await processDeleteRequest({ req, files: [file] }); + + expect(primaryDelete).toHaveBeenCalledWith(req, file, undefined); + expect(vectorDelete).toHaveBeenCalledWith(req, file); + expect(db.deleteFiles).not.toHaveBeenCalled(); + expect(result).toEqual({ deletedFileIds: [], failedFileIds: ['embedded-file'] }); + }); + + it('does not delete vector storage when primary embedded file deletion fails', async () => { + const primaryDelete = jest.fn().mockRejectedValue(new Error('permission denied')); + const vectorDelete = jest.fn().mockResolvedValue(undefined); + getStrategyFunctions.mockImplementation((source) => + source === FileSources.vectordb + ? { deleteFile: vectorDelete } + : { deleteFile: primaryDelete }, + ); + const req = { + body: {}, + config: {}, + user: { id: 'user-123', tenantId: 'tenant-a' }, + }; + const file = { + file_id: 'embedded-file', + filepath: '/uploads/embedded.txt', + source: FileSources.local, + embedded: true, + }; + + const result = await processDeleteRequest({ req, files: [file] }); + + expect(primaryDelete).toHaveBeenCalledWith(req, file, undefined); + expect(vectorDelete).not.toHaveBeenCalled(); + expect(db.deleteFiles).not.toHaveBeenCalled(); + expect(result).toEqual({ deletedFileIds: [], failedFileIds: ['embedded-file'] }); + }); + + it('still deletes vector storage when primary embedded file storage is already missing', async () => { + const missingError = Object.assign(new Error('no such file'), { code: 'ENOENT' }); + const primaryDelete = jest.fn().mockRejectedValue(missingError); + const vectorDelete = jest.fn().mockResolvedValue(undefined); + getStrategyFunctions.mockImplementation((source) => + source === FileSources.vectordb + ? { deleteFile: vectorDelete } + : { deleteFile: primaryDelete }, + ); + db.deleteFiles.mockResolvedValue({ deletedCount: 1 }); + const req = { + body: {}, + config: {}, + user: { id: 'user-123', tenantId: 'tenant-a' }, + }; + const file = { + file_id: 'embedded-file', + filepath: '/uploads/embedded.txt', + source: FileSources.local, + embedded: true, + }; + + const result = await processDeleteRequest({ req, files: [file] }); + + expect(primaryDelete).toHaveBeenCalledWith(req, file, undefined); + expect(vectorDelete).toHaveBeenCalledWith(req, file); + expect(db.deleteFiles).toHaveBeenCalledWith(['embedded-file']); + expect(result).toEqual({ deletedFileIds: ['embedded-file'], failedFileIds: [] }); + }); + + it('deletes code environment storage before removing code resource file metadata', async () => { + const primaryDelete = jest.fn().mockResolvedValue(undefined); + const codeDelete = jest.fn().mockResolvedValue(undefined); + getStrategyFunctions.mockImplementation((source) => + source === FileSources.execute_code + ? { deleteFile: codeDelete } + : { deleteFile: primaryDelete }, + ); + db.deleteFiles.mockResolvedValue({ deletedCount: 1 }); + const req = { + body: {}, + config: {}, + user: { id: 'user-123', tenantId: 'tenant-a' }, + }; + const file = { + file_id: 'code-resource-file', + filepath: '/uploads/code-resource.txt', + source: FileSources.local, + metadata: { + codeEnvRef: { + kind: 'agent', + id: 'agent-abc', + storage_session_id: 'sess-1', + file_id: 'fid-1', + }, + }, + }; + + const result = await processDeleteRequest({ req, files: [file] }); + + expect(primaryDelete).toHaveBeenCalledWith(req, file, undefined); + expect(codeDelete).toHaveBeenCalledWith(req, file); + expect(db.deleteFiles).toHaveBeenCalledWith(['code-resource-file']); + expect(result).toEqual({ deletedFileIds: ['code-resource-file'], failedFileIds: [] }); + }); +}); + +describe('sweepExpiredFiles', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('delegates expired file sweeping to the shared package with backend dependencies', async () => { + const options = { + appConfig: { paths: { publicPath: '/tmp/public', uploads: '/tmp/uploads' } }, + limit: 1, + }; + sweepExpiredFilesWithDeps.mockResolvedValue({ scanned: 1, deleted: 1, failed: 0 }); + + const result = await sweepExpiredFiles(options); + + expect(sweepExpiredFilesWithDeps).toHaveBeenCalledWith( + options, + expect.objectContaining({ + getExpiredFiles: db.getExpiredFiles, + processDeleteRequest: expect.any(Function), + logger: expect.objectContaining({ + error: expect.any(Function), + info: expect.any(Function), + warn: expect.any(Function), + }), + }), + ); + expect(result).toEqual({ scanned: 1, deleted: 1, failed: 0 }); + }); +}); + +describe('startExpiredFileSweep', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('delegates background sweep startup to the shared package with system context', () => { + const options = { + appConfig: { paths: { publicPath: '/tmp/public', uploads: '/tmp/uploads' } }, + }; + + const interval = startExpiredFileSweep(options); + + expect(startExpiredFileSweepWithDeps).toHaveBeenCalledWith( + options, + expect.objectContaining({ + sweepExpiredFiles: expect.any(Function), + runAsSystem: expect.any(Function), + logger: expect.objectContaining({ + error: expect.any(Function), + info: expect.any(Function), + warn: expect.any(Function), + }), + }), + ); + expect(interval).toBe('sweep-interval'); + }); +}); diff --git a/api/server/services/Files/retention.js b/api/server/services/Files/retention.js new file mode 100644 index 00000000000..e7394e29524 --- /dev/null +++ b/api/server/services/Files/retention.js @@ -0,0 +1,43 @@ +const { + getRetentionExpiry: getRetentionExpiryWithDeps, + getAgentFileRetentionExpiry: getAgentFileRetentionExpiryWithDeps, +} = require('@librechat/api'); +const { logger, createTempChatExpirationDate } = require('@librechat/data-schemas'); +const db = require('~/models'); + +const getRetentionDependencies = () => ({ + getConvo: db.getConvoRetention ?? db.getConvo, + createExpirationDate: createTempChatExpirationDate, + logger, +}); + +/** + * Returns `{ expiredAt }` when the request indicates data retention applies, otherwise `{}`. + * Spread into file data objects before calling createFile. + * @param {ServerRequest} req + * @returns {Promise<{ expiredAt?: Date | null }>} + */ +async function getRetentionExpiry(req) { + return getRetentionExpiryWithDeps(req, getRetentionDependencies()); +} + +/** + * Returns `{ expiredAt }` for agent file uploads when retention applies, otherwise `{}`. + * @param {object} params + * @param {ServerRequest} params.req + * @param {boolean} [params.messageAttachment] + * @param {string} [params.tool_resource] + * @param {string} [params.toolResource] + * @returns {Promise<{ expiredAt?: Date | null }>} + */ +async function getAgentFileRetentionExpiry({ tool_resource, toolResource, ...params }) { + return getAgentFileRetentionExpiryWithDeps( + { ...params, toolResource: tool_resource ?? toolResource }, + getRetentionDependencies(), + ); +} + +module.exports = { + getRetentionExpiry, + getAgentFileRetentionExpiry, +}; diff --git a/api/server/services/Files/strategies.js b/api/server/services/Files/strategies.js index e5acbd6903f..fd5c57bc97c 100644 --- a/api/server/services/Files/strategies.js +++ b/api/server/services/Files/strategies.js @@ -72,7 +72,7 @@ const { processAzureAvatar, } = require('./Azure'); const { uploadOpenAIFile, deleteOpenAIFile, getOpenAIFileStream } = require('./OpenAI'); -const { getCodeOutputDownloadStream, uploadCodeEnvFile } = require('./Code'); +const { deleteCodeEnvFile, getCodeOutputDownloadStream, uploadCodeEnvFile } = require('./Code'); const { uploadVectors, deleteVectors } = require('./VectorDB'); /** @@ -221,8 +221,7 @@ const codeOutputStrategy = () => ({ handleImageUpload: null, /** @type {typeof prepareImagesLocal | null} */ prepareImagePayload: null, - /** @type {typeof deleteLocalFile | null} */ - deleteFile: null, + deleteFile: deleteCodeEnvFile, handleFileUpload: uploadCodeEnvFile, getDownloadStream: getCodeOutputDownloadStream, }); diff --git a/api/server/services/GraphTokenService.js b/api/server/services/GraphTokenService.js index 843adbe5a2d..5eba0f2d0e3 100644 --- a/api/server/services/GraphTokenService.js +++ b/api/server/services/GraphTokenService.js @@ -1,77 +1,19 @@ -const client = require('openid-client'); const { logger } = require('@librechat/data-schemas'); -const { CacheKeys } = require('librechat-data-provider'); -const { getOpenIdConfig } = require('~/strategies/openidStrategy'); -const getLogStores = require('~/cache/getLogStores'); +const { exchangeOboToken } = require('./OboTokenService'); /** - * Get Microsoft Graph API token using existing token exchange mechanism + * Get Microsoft Graph API token using the On-Behalf-Of flow. + * Thin wrapper around the generic OBO exchange for Graph-specific error context. + * * @param {Object} user - User object with OpenID information * @param {string} accessToken - Federated access token used as OBO assertion * @param {string} scopes - Graph API scopes for the token - * @param {boolean} fromCache - Whether to try getting token from cache first + * @param {boolean} [fromCache=true] - Whether to try getting token from cache first * @returns {Promise} Graph API token response with access_token and expires_in */ async function getGraphApiToken(user, accessToken, scopes, fromCache = true) { try { - if (!user.openidId) { - throw new Error('User must be authenticated via Entra ID to access Microsoft Graph'); - } - - if (!accessToken) { - throw new Error('Access token is required for token exchange'); - } - - if (!scopes) { - throw new Error('Graph API scopes are required for token exchange'); - } - - const config = getOpenIdConfig(); - if (!config) { - throw new Error('OpenID configuration not available'); - } - - const cacheKey = `${user.openidId}:${scopes}`; - const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS); - - if (fromCache) { - const cachedToken = await tokensCache.get(cacheKey); - if (cachedToken) { - logger.debug(`[GraphTokenService] Using cached Graph API token for user: ${user.openidId}`); - return cachedToken; - } - } - - logger.debug(`[GraphTokenService] Requesting new Graph API token for user: ${user.openidId}`); - logger.debug(`[GraphTokenService] Requested scopes: ${scopes}`); - - const grantResponse = await client.genericGrantRequest( - config, - 'urn:ietf:params:oauth:grant-type:jwt-bearer', - { - scope: scopes, - assertion: accessToken, - requested_token_use: 'on_behalf_of', - }, - ); - - const tokenResponse = { - access_token: grantResponse.access_token, - token_type: 'Bearer', - expires_in: grantResponse.expires_in || 3600, - scope: scopes, - }; - - await tokensCache.set( - cacheKey, - tokenResponse, - (grantResponse.expires_in || 3600) * 1000, // Convert to milliseconds - ); - - logger.debug( - `[GraphTokenService] Successfully obtained and cached Graph API token for user: ${user.openidId}`, - ); - return tokenResponse; + return await exchangeOboToken(user, accessToken, scopes, fromCache); } catch (error) { logger.error( `[GraphTokenService] Failed to acquire Graph API token for user ${user.openidId}:`, diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js index 9d27734d945..46971a416e2 100644 --- a/api/server/services/MCP.js +++ b/api/server/services/MCP.js @@ -1,30 +1,46 @@ 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, CacheKeys, Constants, isAssistantsEndpoint } = require('librechat-data-provider'); +const { + Time, + CacheKeys, + Constants, + Permissions, + PermissionTypes, + isAssistantsEndpoint, +} = require('librechat-data-provider'); const { getOAuthReconnectionManager, getMCPServersRegistry, getFlowStateManager, getMCPManager, } = require('~/config'); -const { findToken, createToken, updateToken, deleteTokens } = require('~/models'); +const db = require('~/models'); +const { findToken, createToken, updateToken, deleteTokens } = db; const { getGraphApiToken } = require('./GraphTokenService'); +const { exchangeOboToken } = require('./OboTokenService'); +const { createOboTrustChecker } = require('./OboPolicyService'); const { reinitMCPServer } = require('./Tools/mcp'); const { getAppConfig } = require('./Config'); const { getLogStores } = require('~/cache'); @@ -36,6 +52,31 @@ const RECONNECT_THROTTLE_MS = 10_000; const missingToolCache = new Map(); const MISSING_TOOL_TTL_MS = 10_000; +async function userCanUseMCPServers(user, req) { + if (!user?.id || !user?.role) { + return false; + } + + try { + return await checkAccessWithRequestCache({ + req, + user, + permissionType: PermissionTypes.MCP_SERVERS, + permissions: [Permissions.USE], + getRoleByName: db.getRoleByName, + }); + } catch (error) { + logger.error(`[MCP][User: ${user.id}] Failed MCP permission check`, error); + return false; + } +} + +function createMCPPermissionContext(req) { + return { + canUseServers: (user = req?.user) => userCanUseMCPServers(user, req), + }; +} + function evictStale(map, ttl) { if (map.size <= MAX_CACHE_SIZE) { return; @@ -54,6 +95,22 @@ 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); +} + +async function getAppConfigForUser(userId, user) { + return await getAppConfig({ role: user?.role, tenantId: getTenantId(), userId }); +} + /** * Resolves config-source MCP servers from admin Config overrides for the current * request context. Returns the parsed configs keyed by server name. @@ -63,12 +120,7 @@ const unavailableMsg = async function resolveConfigServers(req) { try { const registry = getMCPServersRegistry(); - const user = req?.user; - const appConfig = await getAppConfig({ - role: user?.role, - tenantId: getTenantId(), - userId: user?.id, - }); + const appConfig = await getAppConfigForRequest(req); return await registry.ensureConfigServers(appConfig?.mcpConfig || {}); } catch (error) { logger.warn( @@ -79,6 +131,18 @@ async function resolveConfigServers(req) { } } +/** + * Resolves operator-managed MCP server names from admin Config overrides for the current request. + * Returns a request-time snapshot for DB server creation, not a cross-process lock. + * @throws Propagates app config lookup errors to keep DB server creation fail-closed. + * @param {import('express').Request} req - Express request with user context + * @returns {Promise} + */ +async function resolveMcpConfigNames(req) { + const appConfig = await getAppConfigForRequest(req); + return Object.keys(appConfig?.mcpConfig || {}); +} + /** * Resolves config-source servers and merges all server configs (YAML + config + user DB) * for the given user context. Shared helper for controllers needing the full merged config. @@ -88,7 +152,7 @@ async function resolveConfigServers(req) { */ async function resolveAllMcpConfigs(userId, user) { const registry = getMCPServersRegistry(); - const appConfig = await getAppConfig({ role: user?.role, tenantId: getTenantId(), userId }); + const appConfig = await getAppConfigForUser(userId, user); let configServers = {}; try { configServers = await registry.ensureConfigServers(appConfig?.mcpConfig || {}); @@ -98,9 +162,48 @@ async function resolveAllMcpConfigs(userId, user) { error, ); } + if (user?.role) { + return await registry.getAllServerConfigs(userId, configServers, user.role); + } + 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 @@ -145,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 { @@ -179,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 { @@ -204,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; }; } @@ -230,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 { @@ -253,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')); @@ -267,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); }; } @@ -288,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({ @@ -296,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`, @@ -369,6 +478,8 @@ async function reconnectServer({ oauthStart, flowManager, userMCPAuthMap, + requestBody, + requestScopedConnections, forceNew: true, returnOnOAuth: false, connectionTimeout: Time.THIRTY_SECONDS, @@ -389,6 +500,7 @@ async function reconnectServer({ * * @param {Object} params * @param {ServerResponse} params.res - The Express response object for sending events. + * @param {{ canUseServers: (user?: IUser) => Promise }} [params.mcpPermissionContext] - Request-scoped MCP permission context. * @param {IUser} params.user - The user from the request object. * @param {string} params.serverName * @param {string} params.model @@ -397,11 +509,14 @@ 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. */ async function createMCPTools({ res, + mcpPermissionContext, user, index, signal, @@ -410,19 +525,30 @@ 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, tenantId: user?.tenantId }); + const appConfig = await getAppConfig({ + role: user?.role, + tenantId: user?.tenantId, + userId: user?.id, + }); 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 []; @@ -435,8 +561,11 @@ async function createMCPTools({ index, signal, serverName, + serverConfig, configServers, userMCPAuthMap, + requestBody, + requestScopedConnections, streamId, }); if (result === null) { @@ -452,6 +581,7 @@ async function createMCPTools({ for (const tool of result.tools) { const toolInstance = await createMCPTool({ res, + mcpPermissionContext, user, provider, userMCPAuthMap, @@ -459,6 +589,8 @@ async function createMCPTools({ streamId, availableTools: result.availableTools, toolKey: `${tool.name}${Constants.mcp_delimiter}${serverName}`, + requestBody, + requestScopedConnections, config: serverConfig, }); if (toolInstance) { @@ -473,6 +605,7 @@ async function createMCPTools({ * Creates a single tool from the specified MCP Server via `toolKey`. * @param {Object} params * @param {ServerResponse} params.res - The Express response object for sending events. + * @param {{ canUseServers: (user?: IUser) => Promise }} [params.mcpPermissionContext] - Request-scoped MCP permission context. * @param {IUser} params.user - The user from the request object. * @param {string} params.toolKey - The toolKey for the tool. * @param {string} params.model - The model for the tool. @@ -481,12 +614,16 @@ 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({ res, + mcpPermissionContext, user, index, signal, @@ -494,23 +631,37 @@ 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, tenantId: user?.tenantId }); + const appConfig = await getAppConfig({ + role: user?.role, + tenantId: user?.tenantId, + userId: user?.id, + }); 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; @@ -520,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.`, @@ -537,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); } @@ -558,6 +715,10 @@ async function createMCPTool({ return createToolInstance({ res, + mcpPermissionContext, + user, + requestBody, + requestScopedConnections, provider, toolName, serverName, @@ -569,6 +730,10 @@ async function createMCPTool({ function createToolInstance({ res, + mcpPermissionContext, + user: capturedUser = null, + requestBody: capturedRequestBody, + requestScopedConnections: capturedRequestScopedConnections, toolName, serverName, serverConfig: capturedServerConfig, @@ -582,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', @@ -596,18 +767,26 @@ function createToolInstance({ /** @type {(toolArguments: Object | string, config?: GraphRunnableConfig) => Promise} */ const _call = async (toolArguments, config) => { - const userId = config?.configurable?.user?.id || config?.configurable?.user_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} */ let derivedSignal = null; try { + const provider = (config?.metadata?.provider || capturedProvider)?.toLowerCase(); + const canUseMCP = mcpPermissionContext + ? await mcpPermissionContext.canUseServers(permissionUser) + : await userCanUseMCPServers(permissionUser); + if (!canUseMCP) { + throw new Error('Forbidden: Insufficient MCP server permissions'); + } const flowsCache = getLogStores(CacheKeys.FLOWS); const flowManager = getFlowStateManager(flowsCache); derivedSignal = config?.signal ? AbortSignal.any([config.signal]) : undefined; const mcpManager = getMCPManager(userId); - const provider = (config?.metadata?.provider || capturedProvider)?.toLowerCase(); const { args: _args, stepId, ...toolCall } = config.toolCall ?? {}; const flowId = `${serverName}:oauth_login:${config.metadata.thread_id}:${config.metadata.run_id}`; @@ -630,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 }); } @@ -646,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: { @@ -659,6 +841,8 @@ function createToolInstance({ oauthStart, oauthEnd, graphTokenResolver: getGraphApiToken, + oboTokenResolver: exchangeOboToken, + oboTrustChecker: createOboTrustChecker(), }); if (isAssistantsEndpoint(provider) && Array.isArray(result)) { @@ -703,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; } @@ -720,7 +906,9 @@ async function getMCPSetupData(userId, options = {}) { const appConfig = await getAppConfig({ role, tenantId, userId }); const configServers = await registry.ensureConfigServers(appConfig?.mcpConfig || {}); - const mcpConfig = await registry.getAllServerConfigs(userId, configServers); + const mcpConfig = role + ? await registry.getAllServerConfigs(userId, configServers, role) + : await registry.getAllServerConfigs(userId, configServers); const mcpManager = getMCPManager(userId); /** @type {Map} */ let appConnections = new Map(); @@ -751,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'); @@ -765,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'); @@ -858,9 +1050,13 @@ async function getServerConnectionStatus( module.exports = { createMCPTool, createMCPTools, + createMCPPermissionContext, + userCanUseMCPServers, getMCPSetupData, resolveConfigServers, + resolveMcpConfigNames, resolveAllMcpConfigs, + createOAuthStart, checkOAuthFlowStatus, getServerConnectionStatus, createUnavailableToolStub, diff --git a/api/server/services/MCP.spec.js b/api/server/services/MCP.spec.js index 3288aadd906..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 @@ -38,12 +40,14 @@ jest.mock('@librechat/api', () => { const { logger } = require('@librechat/data-schemas'); const { MCPOAuthHandler } = require('@librechat/api'); -const { CacheKeys, Constants } = require('librechat-data-provider'); +const { CacheKeys, Constants, Permissions, PermissionTypes } = require('librechat-data-provider'); const D = Constants.mcp_delimiter; const { createMCPTool, createMCPTools, + createMCPPermissionContext, getMCPSetupData, + createOAuthStart, checkOAuthFlowStatus, getServerConnectionStatus, createUnavailableToolStub, @@ -71,6 +75,8 @@ jest.mock('~/models', () => ({ findToken: jest.fn(), createToken: jest.fn(), updateToken: jest.fn(), + deleteTokens: jest.fn(), + getRoleByName: jest.fn(), })); jest.mock('./Tools/mcp', () => ({ @@ -90,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; @@ -97,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 = { @@ -246,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); @@ -277,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', @@ -660,12 +768,15 @@ describe('tests for the new helper functions used by the MCP connection status e describe('User parameter passing tests', () => { let mockReinitMCPServer; + let mockGetMCPManager; let mockGetFlowStateManager; let mockGetLogStores; beforeEach(() => { jest.clearAllMocks(); + mockGetTenantId.mockReturnValue(undefined); mockReinitMCPServer = require('./Tools/mcp').reinitMCPServer; + mockGetMCPManager = require('~/config').getMCPManager; mockGetFlowStateManager = require('~/config').getFlowStateManager; mockGetLogStores = require('~/cache').getLogStores; @@ -726,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() }; @@ -788,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() }; @@ -813,6 +1006,245 @@ describe('User parameter passing tests', () => { // Verify reinitMCPServer was NOT called since tool was in cache expect(mockReinitMCPServer).not.toHaveBeenCalled(); }); + + it('should reject tool execution when user lacks MCP server use permission', async () => { + const mockUser = { id: 'mcp-denied-user', role: 'USER' }; + const mockRes = { write: jest.fn(), flush: jest.fn() }; + const { getRoleByName } = require('~/models'); + getRoleByName.mockResolvedValue({ + permissions: { + [PermissionTypes.MCP_SERVERS]: { + [Permissions.USE]: false, + }, + }, + }); + + 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: mockUser, + }, + metadata: { + provider: 'openai', + }, + toolCall: {}, + }, + ), + ).rejects.toThrow( + '[MCP][test-server][test-tool] tool call failed: Forbidden: Insufficient MCP server permissions', + ); + expect(mockGetMCPManager).not.toHaveBeenCalled(); + }); + + it('should reuse request-scoped MCP permission checks across tool executions', async () => { + const mockUser = { id: 'mcp-allowed-user', role: 'USER' }; + const mockReq = { user: mockUser }; + 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 availableTools = { + [`search${D}test-server`]: { + function: { + description: 'Search tool', + parameters: { type: 'object', properties: {} }, + }, + }, + [`fetch${D}test-server`]: { + function: { + description: 'Fetch tool', + parameters: { type: 'object', properties: {} }, + }, + }, + }; + const mcpPermissionContext = createMCPPermissionContext(mockReq); + + const searchTool = await createMCPTool({ + mcpPermissionContext, + res: mockRes, + user: mockUser, + toolKey: `search${D}test-server`, + provider: 'openai', + userMCPAuthMap: {}, + availableTools, + }); + const fetchTool = await createMCPTool({ + mcpPermissionContext, + res: mockRes, + user: mockUser, + toolKey: `fetch${D}test-server`, + provider: 'openai', + userMCPAuthMap: {}, + availableTools, + }); + + const invocationConfig = { + configurable: { + user: mockUser, + }, + metadata: { + provider: 'openai', + thread_id: 'thread-1', + run_id: 'run-1', + }, + toolCall: {}, + }; + + await expect(searchTool.invoke({}, invocationConfig)).resolves.toBe('ok'); + await expect(fetchTool.invoke({}, invocationConfig)).resolves.toBe('ok'); + + 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)', () => { @@ -922,8 +1354,12 @@ describe('User parameter passing tests', () => { // Should not call reinitMCPServer since domain check failed expect(mockReinitMCPServer).not.toHaveBeenCalled(); - // Verify getAppConfig was called with user role - expect(mockGetAppConfig).toHaveBeenCalledWith({ role: 'user' }); + // Verify getAppConfig was called with the user scope + expect(mockGetAppConfig).toHaveBeenCalledWith({ + role: 'user', + tenantId: undefined, + userId: 'domain-test-user', + }); // Verify domain validation was called with correct parameters expect(mockIsMCPDomainAllowed).toHaveBeenCalledWith( @@ -971,8 +1407,56 @@ describe('User parameter passing tests', () => { // Should create tool successfully expect(result).toBeDefined(); - // Verify getAppConfig was called with user role - expect(mockGetAppConfig).toHaveBeenCalledWith({ role: 'admin' }); + // Verify getAppConfig was called with the user scope + expect(mockGetAppConfig).toHaveBeenCalledWith({ + role: 'admin', + tenantId: undefined, + userId: 'domain-test-user', + }); + }); + + 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 () => { @@ -1047,8 +1531,12 @@ describe('User parameter passing tests', () => { // Should not call reinitMCPServer since domain check failed early expect(mockReinitMCPServer).not.toHaveBeenCalled(); - // Verify getAppConfig was called with user role - expect(mockGetAppConfig).toHaveBeenCalledWith({ role: 'user' }); + // Verify getAppConfig was called with the user scope + expect(mockGetAppConfig).toHaveBeenCalledWith({ + role: 'user', + tenantId: undefined, + userId: 'domain-test-user', + }); }); it('should use user role when fetching domain restrictions', async () => { @@ -1100,9 +1588,17 @@ describe('User parameter passing tests', () => { availableTools, }); - // Verify getAppConfig was called with correct roles - expect(mockGetAppConfig).toHaveBeenNthCalledWith(1, { role: 'admin' }); - expect(mockGetAppConfig).toHaveBeenNthCalledWith(2, { role: 'user' }); + // Verify getAppConfig was called with the correct user scopes + expect(mockGetAppConfig).toHaveBeenNthCalledWith(1, { + role: 'admin', + tenantId: undefined, + userId: 'admin-user', + }); + expect(mockGetAppConfig).toHaveBeenNthCalledWith(2, { + role: 'user', + tenantId: undefined, + userId: 'regular-user', + }); }); }); @@ -1246,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/OboPolicyService.js b/api/server/services/OboPolicyService.js new file mode 100644 index 00000000000..99949a2701d --- /dev/null +++ b/api/server/services/OboPolicyService.js @@ -0,0 +1,43 @@ +const { isOboConfigStillTrusted } = require('@librechat/api'); +const db = require('~/models'); + +/** + * Checks whether a parsed MCP server config is DB-sourced (user-created) using + * the same `isUserSourced` heuristics as the rest of the MCP layer: an explicit + * `source` is authoritative when present; otherwise `dbId` presence is used. + */ +function isDbSourced({ source, dbId }) { + if (source != null) { + return source === 'user'; + } + return !!dbId; +} + +/** + * Builds the predicate the MCP runtime calls before performing an OBO token exchange. + * + * YAML/Config-sourced configs (admin-defined) bypass the check — admins are + * already trusted at the deployment level. DB-sourced configs (created via the + * UI) are gated on the original author still holding `MCP_SERVERS.CONFIGURE_OBO`, + * so retained configs fail closed when an author's role is downgraded. + */ +function createOboTrustChecker() { + return async ({ source, author, dbId }) => { + if (!isDbSourced({ source, dbId })) { + return true; + } + return isOboConfigStillTrusted({ + authorId: author, + getUserRoleByAuthorId: async (userId) => { + const user = await db.findUser({ _id: userId }, 'role'); + return user?.role; + }, + getRolePermissions: async (roleName) => { + const role = await db.getRoleByName(roleName); + return role?.permissions; + }, + }); + }; +} + +module.exports = { createOboTrustChecker }; diff --git a/api/server/services/OboTokenService.js b/api/server/services/OboTokenService.js new file mode 100644 index 00000000000..be269be9934 --- /dev/null +++ b/api/server/services/OboTokenService.js @@ -0,0 +1,194 @@ +const client = require('openid-client'); +const { logger } = require('@librechat/data-schemas'); +const { CacheKeys } = require('librechat-data-provider'); +const { getOpenIdConfig } = require('~/strategies/openidStrategy'); +const getLogStores = require('~/cache/getLogStores'); + +const RETRYABLE_STATUS_CODES = new Set([429, 500, 502, 503, 504]); +const RETRYABLE_ERROR_CODES = new Set(['ETIMEDOUT', 'ECONNRESET', 'EAI_AGAIN', 'ENOTFOUND']); +const OBO_RETRY_DELAY_MS = 300; + +/** + * In-flight OBO exchanges keyed by `${openidId}:${scopes}`. + * + * Without coalescing, parallel tool calls that arrive on a cache miss each issue + * their own jwt-bearer request to the IdP. Under fan-out, Entra intermittently + * returns errors that look non-retryable, surfacing as "identity provider + * rejected the OBO token exchange." A user retry then hits the populated cache + * and succeeds, which matches the observed flakiness. Sharing a single upstream + * exchange per key removes the thundering herd. + */ +const inFlightExchanges = new Map(); + +function getErrorStatus(error) { + return error?.status ?? error?.statusCode ?? error?.response?.status; +} + +function getErrorCode(error) { + return typeof error?.code === 'string' ? error.code.toUpperCase() : undefined; +} + +function isRetryableOboExchangeError(error) { + const status = getErrorStatus(error); + if (status != null && RETRYABLE_STATUS_CODES.has(status)) { + return true; + } + + const code = getErrorCode(error); + if (code != null && RETRYABLE_ERROR_CODES.has(code)) { + return true; + } + + const message = String(error?.message ?? '').toLowerCase(); + return ( + message.includes('timed out') || + message.includes('timeout') || + message.includes('econnreset') || + message.includes('socket hang up') || + message.includes('temporarily unavailable') || + message.includes('too many requests') || + message.includes('service unavailable') + ); +} + +function tagOboExchangeError(error, retryable) { + if (error && typeof error === 'object') { + error.retryable = retryable; + error.oboFailureReason = 'exchange_failed'; + } + return error; +} + +async function delay(ms) { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function performOboExchange({ user, accessToken, scopes, config, tokensCache, cacheKey }) { + const requestGrant = async () => + client.genericGrantRequest(config, 'urn:ietf:params:oauth:grant-type:jwt-bearer', { + scope: scopes, + assertion: accessToken, + requested_token_use: 'on_behalf_of', + }); + + let grantResponse; + try { + grantResponse = await requestGrant(); + } catch (error) { + const retryable = isRetryableOboExchangeError(error); + if (!retryable) { + throw tagOboExchangeError(error, false); + } + + logger.warn( + `[OboTokenService] Transient OBO exchange failure for user: ${user.openidId}, retrying once`, + error, + ); + await delay(OBO_RETRY_DELAY_MS); + + try { + grantResponse = await requestGrant(); + } catch (retryError) { + throw tagOboExchangeError(retryError, isRetryableOboExchangeError(retryError)); + } + } + + const tokenResponse = { + access_token: grantResponse.access_token, + token_type: 'Bearer', + expires_in: grantResponse.expires_in || 3600, + scope: scopes, + }; + + await tokensCache.set(cacheKey, tokenResponse, (grantResponse.expires_in || 3600) * 1000); + + logger.debug( + `[OboTokenService] Successfully obtained and cached OBO token for user: ${user.openidId}`, + ); + return tokenResponse; +} + +/** + * Exchange a user's access token for a downstream-scoped token via the + * OAuth 2.0 On-Behalf-Of (jwt-bearer) grant. + * + * Concurrent callers for the same `${openidId}:${scopes}` key share a single + * upstream exchange (see `inFlightExchanges`) so a fan-out of tool calls right + * after a cache miss does not produce N parallel requests to the IdP. + * + * @param {Object} user - User object with OpenID information + * @param {string} accessToken - Federated access token used as OBO assertion + * @param {string} scopes - Scopes to request for the downstream service + * @param {boolean} [fromCache=true] - When true, read from cache and join any + * in-flight exchange. When false, bypass both and force a fresh exchange. + * @returns {Promise} Token response with access_token and expires_in + */ +async function exchangeOboToken(user, accessToken, scopes, fromCache = true) { + if (!user.openidId) { + throw new Error('User must be authenticated via OpenID to perform OBO token exchange'); + } + + if (!accessToken) { + throw new Error('Access token is required for OBO exchange'); + } + + if (!scopes) { + throw new Error('Scopes are required for OBO exchange'); + } + + const config = getOpenIdConfig(); + if (!config) { + throw new Error('OpenID configuration not available'); + } + + const cacheKey = `${user.openidId}:${scopes}`; + const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS); + + if (fromCache) { + const cachedToken = await tokensCache.get(cacheKey); + if (cachedToken) { + logger.debug(`[OboTokenService] Using cached token for user: ${user.openidId}`); + return cachedToken; + } + + const inFlight = inFlightExchanges.get(cacheKey); + if (inFlight) { + logger.debug(`[OboTokenService] Joining in-flight OBO exchange for user: ${user.openidId}`); + return inFlight; + } + } + + logger.debug( + `[OboTokenService] Requesting new OBO token for user: ${user.openidId}, scopes: ${scopes}`, + ); + + const exchangePromise = performOboExchange({ + user, + accessToken, + scopes, + config, + tokensCache, + cacheKey, + }); + + if (fromCache) { + inFlightExchanges.set(cacheKey, exchangePromise); + exchangePromise + .finally(() => { + if (inFlightExchanges.get(cacheKey) === exchangePromise) { + inFlightExchanges.delete(cacheKey); + } + }) + .catch(() => { + /* The original rejection is delivered to the awaiting caller; this + * chain exists only to run cleanup, so swallow it here to avoid an + * unhandled-rejection warning on the cleanup promise. */ + }); + } + + return exchangePromise; +} + +module.exports = { + exchangeOboToken, +}; diff --git a/api/server/services/OboTokenService.spec.js b/api/server/services/OboTokenService.spec.js new file mode 100644 index 00000000000..122ddee4b21 --- /dev/null +++ b/api/server/services/OboTokenService.spec.js @@ -0,0 +1,342 @@ +jest.mock('~/strategies/openidStrategy'); +jest.mock('~/cache/getLogStores'); +jest.mock('@librechat/data-schemas', () => ({ + ...jest.requireActual('@librechat/data-schemas'), + logger: { + error: jest.fn(), + debug: jest.fn(), + warn: jest.fn(), + }, +})); + +const client = require('openid-client'); +const { getOpenIdConfig } = require('~/strategies/openidStrategy'); +const getLogStores = require('~/cache/getLogStores'); +const { exchangeOboToken } = require('./OboTokenService'); + +describe('OboTokenService', () => { + let mockTokensCache; + let mockOpenIdConfig; + + const mockUser = { + openidId: 'oidc-sub-123', + email: 'test@example.com', + name: 'Test User', + }; + + beforeEach(() => { + jest.clearAllMocks(); + + mockTokensCache = { + get: jest.fn().mockResolvedValue(null), + set: jest.fn().mockResolvedValue(undefined), + }; + getLogStores.mockReturnValue(mockTokensCache); + + mockOpenIdConfig = { + client_id: 'test-client-id', + issuer: 'https://login.microsoftonline.com/tenant-id/v2.0', + }; + getOpenIdConfig.mockReturnValue(mockOpenIdConfig); + + client.genericGrantRequest.mockResolvedValue({ + access_token: 'exchanged-obo-token', + expires_in: 3600, + }); + }); + + describe('input validation', () => { + it('should throw when user has no openidId', async () => { + await expect( + exchangeOboToken({ email: 'test@example.com' }, 'access-token', 'api://scope'), + ).rejects.toThrow('User must be authenticated via OpenID to perform OBO token exchange'); + }); + + it('should throw when accessToken is missing', async () => { + await expect(exchangeOboToken(mockUser, '', 'api://scope')).rejects.toThrow( + 'Access token is required for OBO exchange', + ); + }); + + it('should throw when scopes are missing', async () => { + await expect(exchangeOboToken(mockUser, 'access-token', '')).rejects.toThrow( + 'Scopes are required for OBO exchange', + ); + }); + + it('should throw when OpenID config is not available', async () => { + getOpenIdConfig.mockReturnValue(null); + + await expect(exchangeOboToken(mockUser, 'access-token', 'api://scope')).rejects.toThrow( + 'OpenID configuration not available', + ); + }); + }); + + describe('cache behavior', () => { + it('should return cached token when fromCache is true and cache hit', async () => { + const cachedToken = { + access_token: 'cached-obo-token', + token_type: 'Bearer', + expires_in: 1800, + scope: 'api://mcp-server/Scope.Read', + }; + mockTokensCache.get.mockResolvedValue(cachedToken); + + const result = await exchangeOboToken( + mockUser, + 'access-token', + 'api://mcp-server/Scope.Read', + true, + ); + + expect(result).toBe(cachedToken); + expect(mockTokensCache.get).toHaveBeenCalledWith('oidc-sub-123:api://mcp-server/Scope.Read'); + expect(client.genericGrantRequest).not.toHaveBeenCalled(); + }); + + it('should skip cache when fromCache is false', async () => { + const cachedToken = { access_token: 'cached-obo-token' }; + mockTokensCache.get.mockResolvedValue(cachedToken); + + const result = await exchangeOboToken( + mockUser, + 'access-token', + 'api://mcp-server/Scope.Read', + false, + ); + + expect(mockTokensCache.get).not.toHaveBeenCalled(); + expect(client.genericGrantRequest).toHaveBeenCalled(); + expect(result.access_token).toBe('exchanged-obo-token'); + }); + + it('should default fromCache to true', async () => { + mockTokensCache.get.mockResolvedValue(null); + + await exchangeOboToken(mockUser, 'access-token', 'api://scope'); + + expect(mockTokensCache.get).toHaveBeenCalledWith('oidc-sub-123:api://scope'); + }); + }); + + describe('OBO token exchange', () => { + it('should call genericGrantRequest with jwt-bearer grant type', async () => { + await exchangeOboToken(mockUser, 'user-access-token', 'api://mcp-server/Tools.ReadWrite'); + + expect(client.genericGrantRequest).toHaveBeenCalledWith( + mockOpenIdConfig, + 'urn:ietf:params:oauth:grant-type:jwt-bearer', + { + scope: 'api://mcp-server/Tools.ReadWrite', + assertion: 'user-access-token', + requested_token_use: 'on_behalf_of', + }, + ); + }); + + it('should return token response with correct structure', async () => { + const result = await exchangeOboToken( + mockUser, + 'access-token', + 'api://mcp-server/Tools.ReadWrite', + ); + + expect(result).toEqual({ + access_token: 'exchanged-obo-token', + token_type: 'Bearer', + expires_in: 3600, + scope: 'api://mcp-server/Tools.ReadWrite', + }); + }); + + it('should cache the exchanged token with correct TTL', async () => { + client.genericGrantRequest.mockResolvedValue({ + access_token: 'new-obo-token', + expires_in: 1800, + }); + + await exchangeOboToken(mockUser, 'access-token', 'api://scope'); + + expect(mockTokensCache.set).toHaveBeenCalledWith( + 'oidc-sub-123:api://scope', + { + access_token: 'new-obo-token', + token_type: 'Bearer', + expires_in: 1800, + scope: 'api://scope', + }, + 1800 * 1000, + ); + }); + + it('should default expires_in to 3600 when not in response', async () => { + client.genericGrantRequest.mockResolvedValue({ + access_token: 'no-expiry-token', + }); + + const result = await exchangeOboToken(mockUser, 'access-token', 'api://scope'); + + expect(result.expires_in).toBe(3600); + expect(mockTokensCache.set).toHaveBeenCalledWith( + 'oidc-sub-123:api://scope', + expect.objectContaining({ expires_in: 3600 }), + 3600 * 1000, + ); + }); + + it('should propagate errors from genericGrantRequest', async () => { + client.genericGrantRequest.mockRejectedValue( + new Error('invalid_grant: AADSTS50013: Assertion failed signature validation'), + ); + + await expect(exchangeOboToken(mockUser, 'bad-token', 'api://scope')).rejects.toThrow( + 'invalid_grant: AADSTS50013: Assertion failed signature validation', + ); + }); + + it('should retry once for transient Entra failures and succeed on the second attempt', async () => { + const transientError = Object.assign(new Error('Service unavailable'), { status: 503 }); + const setTimeoutSpy = jest.spyOn(global, 'setTimeout').mockImplementation((callback) => { + callback(); + return 0; + }); + + try { + client.genericGrantRequest.mockRejectedValueOnce(transientError).mockResolvedValueOnce({ + access_token: 'retried-obo-token', + expires_in: 1800, + }); + + const result = await exchangeOboToken(mockUser, 'access-token', 'api://scope'); + + expect(client.genericGrantRequest).toHaveBeenCalledTimes(2); + expect(result).toEqual({ + access_token: 'retried-obo-token', + token_type: 'Bearer', + expires_in: 1800, + scope: 'api://scope', + }); + } finally { + setTimeoutSpy.mockRestore(); + } + }); + + it('should not retry permanent OBO exchange failures', async () => { + const permanentError = new Error( + 'invalid_grant: AADSTS50013: Assertion failed signature validation', + ); + client.genericGrantRequest.mockRejectedValue(permanentError); + + await expect(exchangeOboToken(mockUser, 'bad-token', 'api://scope')).rejects.toThrow( + 'invalid_grant: AADSTS50013: Assertion failed signature validation', + ); + + expect(client.genericGrantRequest).toHaveBeenCalledTimes(1); + }); + }); + + describe('cache key isolation', () => { + it('should use different cache keys for different scopes', async () => { + await exchangeOboToken(mockUser, 'access-token', 'api://server-a/Scope.A'); + await exchangeOboToken(mockUser, 'access-token', 'api://server-b/Scope.B'); + + expect(mockTokensCache.get).toHaveBeenCalledWith('oidc-sub-123:api://server-a/Scope.A'); + expect(mockTokensCache.get).toHaveBeenCalledWith('oidc-sub-123:api://server-b/Scope.B'); + }); + + it('should use different cache keys for different users', async () => { + const otherUser = { openidId: 'oidc-sub-456', email: 'other@example.com' }; + + await exchangeOboToken(mockUser, 'access-token', 'api://scope'); + await exchangeOboToken(otherUser, 'access-token', 'api://scope'); + + expect(mockTokensCache.get).toHaveBeenCalledWith('oidc-sub-123:api://scope'); + expect(mockTokensCache.get).toHaveBeenCalledWith('oidc-sub-456:api://scope'); + }); + }); + + describe('single-flight coalescing', () => { + /** Yields long enough for both pending callers to advance past their cache lookup. */ + const flushMicrotasks = () => new Promise((resolve) => setImmediate(resolve)); + + it('coalesces concurrent exchanges for the same key into one IdP call', async () => { + let resolveGrant; + client.genericGrantRequest.mockReturnValueOnce( + new Promise((resolve) => { + resolveGrant = resolve; + }), + ); + + const callA = exchangeOboToken(mockUser, 'access-token', 'api://shared'); + const callB = exchangeOboToken(mockUser, 'access-token', 'api://shared'); + + await flushMicrotasks(); + expect(client.genericGrantRequest).toHaveBeenCalledTimes(1); + + resolveGrant({ access_token: 'shared-obo-token', expires_in: 3600 }); + + const [resultA, resultB] = await Promise.all([callA, callB]); + expect(resultA).toEqual(resultB); + expect(resultA.access_token).toBe('shared-obo-token'); + expect(client.genericGrantRequest).toHaveBeenCalledTimes(1); + expect(mockTokensCache.set).toHaveBeenCalledTimes(1); + }); + + it('does not coalesce exchanges for different keys', async () => { + await Promise.all([ + exchangeOboToken(mockUser, 'access-token', 'api://scope-a'), + exchangeOboToken(mockUser, 'access-token', 'api://scope-b'), + ]); + + expect(client.genericGrantRequest).toHaveBeenCalledTimes(2); + }); + + it('clears the in-flight slot after a successful exchange', async () => { + await exchangeOboToken(mockUser, 'access-token', 'api://scope'); + expect(client.genericGrantRequest).toHaveBeenCalledTimes(1); + + await exchangeOboToken(mockUser, 'access-token', 'api://scope'); + expect(client.genericGrantRequest).toHaveBeenCalledTimes(2); + }); + + it('clears the in-flight slot after a failed exchange', async () => { + client.genericGrantRequest + .mockRejectedValueOnce( + new Error('invalid_grant: AADSTS50013: Assertion failed signature validation'), + ) + .mockResolvedValueOnce({ access_token: 'fresh-token', expires_in: 3600 }); + + await expect(exchangeOboToken(mockUser, 'access-token', 'api://scope')).rejects.toThrow( + 'invalid_grant', + ); + + const result = await exchangeOboToken(mockUser, 'access-token', 'api://scope'); + expect(result.access_token).toBe('fresh-token'); + expect(client.genericGrantRequest).toHaveBeenCalledTimes(2); + }); + + it('bypasses in-flight coalescing when fromCache is false', async () => { + let resolveFirst; + client.genericGrantRequest + .mockReturnValueOnce( + new Promise((resolve) => { + resolveFirst = resolve; + }), + ) + .mockResolvedValueOnce({ access_token: 'forced-fresh-token', expires_in: 3600 }); + + const callA = exchangeOboToken(mockUser, 'access-token', 'api://scope', true); + await flushMicrotasks(); + + const callB = exchangeOboToken(mockUser, 'access-token', 'api://scope', false); + expect(client.genericGrantRequest).toHaveBeenCalledTimes(2); + + resolveFirst({ access_token: 'in-flight-token', expires_in: 3600 }); + + const [resultA, resultB] = await Promise.all([callA, callB]); + expect(resultA.access_token).toBe('in-flight-token'); + expect(resultB.access_token).toBe('forced-fresh-token'); + }); + }); +}); diff --git a/api/server/services/PermissionService.js b/api/server/services/PermissionService.js index 4d6d437842c..491dce8c51b 100644 --- a/api/server/services/PermissionService.js +++ b/api/server/services/PermissionService.js @@ -26,6 +26,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' */ @@ -299,8 +315,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') { @@ -365,6 +381,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 e214d8ac496..ec63f63192d 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/Threads/manage.js b/api/server/services/Threads/manage.js index 27520f38a55..772cbd977bf 100644 --- a/api/server/services/Threads/manage.js +++ b/api/server/services/Threads/manage.js @@ -467,7 +467,7 @@ async function checkMessageGaps({ apiMessages.push(currentMessage); } - const dbMessages = await getMessages({ conversationId }); + const dbMessages = await getMessages({ conversationId, user: openai.req.user.id }); const assistant_id = dbMessages?.[0]?.model; const syncedMessages = await syncMessages({ diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 1c1e2cf4b95..b11529205bf 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,10 +16,18 @@ const { isActionDomainAllowed, buildWebSearchContext, buildImageToolContext, - buildOAuthToolCallName, buildToolClassification, + getMissingCustomUserVars, buildWebSearchDynamicContext, getCodeApiAuthHeaders, + getReplayablePendingMCPOAuthStart, + getMCPServerNamesFromTools, + buildMCPAuthToolCall, + buildMCPAuthStepId, + buildMCPAuthRunStepEvent, + buildMCPAuthRunStepDeltaEvent, + buildMCPAuthRunStepCompletedEvent, + isFileAuthoringToolDefinition, } = require('@librechat/api'); const { Time, @@ -62,12 +68,12 @@ const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/pro const { manifestToolMap, toolkits } = require('~/app/clients/tools/manifest'); const { createOnSearchResults } = require('~/server/services/Tools/search'); const { reinitMCPServer } = require('~/server/services/Tools/mcp'); -const { resolveConfigServers } = require('~/server/services/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 } = require('~/config'); +const { getFlowStateManager, getMCPServersRegistry } = require('~/config'); const { getLogStores } = require('~/cache'); const domainSeparatorRegex = new RegExp(actionDomainSeparator, 'g'); @@ -523,6 +529,7 @@ const isBuiltInTool = (toolName) => * @returns {Promise<{ * toolDefinitions?: import('@librechat/api').LCTool[]; * toolRegistry?: Map; + * mcpAvailableTools?: Record; * userMCPAuthMap?: Record>; * hasDeferredTools?: boolean; * }>} @@ -550,6 +557,9 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to const codeExecutionEnabled = agent.tools?.includes(Tools.execute_code) === true && enabledCapabilities.has(AgentCapabilities.execute_code); + const hasMCPTools = agent.tools?.some((tool) => tool?.includes(Constants.mcp_delimiter)); + const mcpPermissionContext = createMCPPermissionContext(req); + const canUseMCP = hasMCPTools ? await mcpPermissionContext.canUseServers(req.user) : true; const filteredTools = agent.tools?.filter((tool) => { if (tool === Tools.file_search) { @@ -564,6 +574,9 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to if (isActionTool(tool)) { return actionsEnabled; } + if (tool?.includes(Constants.mcp_delimiter)) { + return areToolsEnabled && canUseMCP; + } if (!areToolsEnabled) { return false; } @@ -576,9 +589,9 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to /** @type {Record>} */ let userMCPAuthMap; - if (agent.tools?.some((t) => t.includes(Constants.mcp_delimiter))) { + if (filteredTools?.some((t) => t.includes(Constants.mcp_delimiter))) { userMCPAuthMap = await getUserMCPAuthMap({ - tools: agent.tools, + tools: filteredTools, userId: req.user.id, findPluginAuthsByKeys, }); @@ -588,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); @@ -637,14 +654,125 @@ 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 cached = await getMCPServerTools(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 = + configServers?.[serverName] ?? + (await getMCPServersRegistry().getServerConfig(serverName, userId, configServers)); + } catch (err) { + logger.warn( + `[Tool Definitions] MCP registry unavailable while resolving '${serverName}': ${ + err?.message ?? err + }. Skipping MCP tool exposure for this lookup.`, + ); + return null; + } + + if (!serverConfig) { + logger.warn( + `[Tool Definitions] Skipping MCP server '${serverName}': no server config found (server may have been removed).`, + ); + return null; + } + + const customUserVars = userMCPAuthMap?.[`${Constants.mcp_prefix}${serverName}`]; + const missingUserVars = getMissingCustomUserVars(serverConfig, customUserVars); + if (missingUserVars.length > 0) { + logger.warn( + `[Tool Definitions] Skipping MCP server '${serverName}': required user-provided variable(s) not set: ${missingUserVars.join( + ', ', + )}. Tools will not be exposed until the user configures them.`, + ); + return null; + } + + 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({ @@ -654,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; }; @@ -727,6 +858,7 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to deferredToolsEnabled, programmaticToolsEnabled, codeExecutionEnabled, + provider: agent.provider, }, { isBuiltInTool, @@ -735,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 }; } @@ -783,6 +940,7 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to deferredToolsEnabled, programmaticToolsEnabled, codeExecutionEnabled, + provider: agent.provider, }, { isBuiltInTool, @@ -882,6 +1040,8 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to return { toolRegistry, + mcpAvailableTools, + requestScopedConnections, userMCPAuthMap, toolContextMap, dynamicToolContextMap, @@ -950,6 +1110,9 @@ async function loadAgentTools({ }; const areToolsEnabled = checkCapability(AgentCapabilities.tools); const actionsEnabled = checkCapability(AgentCapabilities.actions); + const hasMCPTools = agent.tools?.some((tool) => tool?.includes(Constants.mcp_delimiter)); + const mcpPermissionContext = createMCPPermissionContext(req); + const canUseMCP = hasMCPTools ? await mcpPermissionContext.canUseServers(req.user) : true; let includesWebSearch = false; const _agentTools = agent.tools?.filter((tool) => { @@ -962,6 +1125,8 @@ async function loadAgentTools({ return includesWebSearch; } else if (isActionTool(tool)) { return actionsEnabled; + } else if (tool?.includes(Constants.mcp_delimiter)) { + return areToolsEnabled && canUseMCP; } else if (!areToolsEnabled) { return false; } @@ -979,9 +1144,9 @@ async function loadAgentTools({ /** @type {Record>} */ let userMCPAuthMap; - if (agent.tools?.some((t) => t.includes(Constants.mcp_delimiter))) { + if (_agentTools?.some((t) => t.includes(Constants.mcp_delimiter))) { userMCPAuthMap = await getUserMCPAuthMap({ - tools: agent.tools, + tools: _agentTools, userId: req.user.id, findPluginAuthsByKeys, }); @@ -1002,6 +1167,8 @@ async function loadAgentTools({ processFileURL, uploadImageBuffer, returnMetadata: true, + mcpPermissionContext, + requestScopedConnections: getMCPRequestContext(req, res), [Tools.web_search]: webSearchCallbacks, }, webSearch: appConfig.webSearch, @@ -1077,6 +1244,7 @@ async function loadAgentTools({ if (!hasActionTools) { return { toolRegistry, + requestScopedConnections: getMCPRequestContext(req, res), userMCPAuthMap, toolContextMap, dynamicToolContextMap, @@ -1095,6 +1263,7 @@ async function loadAgentTools({ } return { toolRegistry, + requestScopedConnections: getMCPRequestContext(req, res), userMCPAuthMap, toolContextMap, dynamicToolContextMap, @@ -1223,6 +1392,7 @@ async function loadAgentTools({ return { toolRegistry, + requestScopedConnections: getMCPRequestContext(req, res), toolContextMap, dynamicToolContextMap, userMCPAuthMap, @@ -1248,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 @@ -1261,6 +1433,8 @@ async function loadToolsForExecution({ agent, toolNames, toolRegistry, + mcpAvailableTools, + requestScopedConnections, userMCPAuthMap, tool_resources, streamId = null, @@ -1268,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 = [ @@ -1276,20 +1451,25 @@ async function loadToolsForExecution({ AgentConstants.PROGRAMMATIC_TOOL_CALLING, ].filter((name) => toolNames.includes(name)); const isPTCRequested = ptcToolNames.length > 0; + const isBashToolRequested = toolNames.includes(AgentConstants.BASH_TOOL); + const isLegacyExecuteCodeRequested = toolNames.includes(Tools.execute_code); + const isCodeExecutionToolRequested = isBashToolRequested || isLegacyExecuteCodeRequested; let enabledCapabilities; - if (actionsEnabled === undefined || isPTCRequested) { + if (actionsEnabled === undefined || isPTCRequested || isCodeExecutionToolRequested) { enabledCapabilities = await resolveAgentCapabilities(req, appConfig, agent?.id); } if (actionsEnabled === undefined) { actionsEnabled = enabledCapabilities.has(AgentCapabilities.actions); } + const codeExecutionEnabled = + enabledCapabilities?.has(AgentCapabilities.execute_code) === true && + agent?.tools?.includes(Tools.execute_code) === true; const isPTC = isPTCRequested && enabledCapabilities.has(AgentCapabilities.programmatic_tools) && - enabledCapabilities.has(AgentCapabilities.execute_code) && - agent?.tools?.includes(Tools.execute_code) === true; + codeExecutionEnabled; logger.debug( `[loadToolsForExecution] isToolSearch: ${isToolSearch}, toolRegistry: ${toolRegistry?.size ?? 'undefined'}`, @@ -1323,7 +1503,16 @@ async function loadToolsForExecution({ } } - const isBashTool = toolNames.includes(AgentConstants.BASH_TOOL); + const isBashTool = + isBashToolRequested && + codeExecutionEnabled && + toolRegistry?.has(AgentConstants.BASH_TOOL) === true; + if (isBashToolRequested && !isBashTool) { + logger.warn( + `[loadToolsForExecution] Skipping unregistered or unauthorized ${AgentConstants.BASH_TOOL}. ` + + `User: ${req.user.id} | Agent: ${agent?.id ?? 'unknown'}`, + ); + } if (isBashTool) { try { const bashTool = createBashExecutionTool({ @@ -1335,6 +1524,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, @@ -1342,6 +1538,7 @@ async function loadToolsForExecution({ AgentConstants.BASH_TOOL, AgentConstants.SKILL_TOOL, AgentConstants.READ_FILE, + ...fileAuthoringToolNames, ]); let ptcOrchestratedToolNames = []; @@ -1352,9 +1549,22 @@ async function loadToolsForExecution({ } const requestedNonSpecialToolNames = toolNames.filter((name) => !specialToolNames.has(name)); + const allowedNonSpecialToolNames = requestedNonSpecialToolNames.filter((name) => { + if (name !== Tools.execute_code) { + return true; + } + const allowed = codeExecutionEnabled && toolRegistry?.has(Tools.execute_code) === true; + if (!allowed) { + logger.warn( + `[loadToolsForExecution] Skipping unregistered or unauthorized ${Tools.execute_code}. ` + + `User: ${req.user.id} | Agent: ${agent?.id ?? 'unknown'}`, + ); + } + return allowed; + }); const allToolNamesToLoad = isPTC - ? [...new Set([...requestedNonSpecialToolNames, ...ptcOrchestratedToolNames])] - : requestedNonSpecialToolNames; + ? [...new Set([...allowedNonSpecialToolNames, ...ptcOrchestratedToolNames])] + : allowedNonSpecialToolNames; const actionToolNames = []; const regularToolNames = []; @@ -1380,6 +1590,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 f1ebcf97961..c0e32ccd8f6 100644 --- a/api/server/services/Tools/mcp.js +++ b/api/server/services/Tools/mcp.js @@ -1,7 +1,11 @@ const { logger } = require('@librechat/data-schemas'); +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'); const { getLogStores } = require('~/cache'); @@ -17,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({ @@ -32,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( @@ -60,32 +73,54 @@ 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, + }; + } } } const customUserVars = userMCPAuthMap?.[`${Constants.mcp_prefix}${serverName}`]; + + const missingUserVars = getMissingCustomUserVars(serverConfig ?? {}, customUserVars); + if (missingUserVars.length > 0) { + logger.warn( + `[MCP Reinitialize] Skipping server '${serverName}': required user-provided variable(s) not set: ${missingUserVars.join( + ', ', + )}. Tools will not be exposed until the user configures them.`, + ); + return { + availableTools: null, + success: false, + message: `MCP server '${serverName}' requires user-provided variable(s) [${missingUserVars.join( + ', ', + )}] which are not set`, + oauthRequired: false, + serverName, + oauthUrl: null, + tools: null, + }; + } + const flowManager = _flowManager ?? getFlowStateManager(getLogStores(CacheKeys.FLOWS)); const mcpManager = getMCPManager(); const tokenMethods = { findToken, updateToken, createToken, deleteTokens }; @@ -108,9 +143,15 @@ async function reinitMCPServer({ flowManager, tokenMethods, returnOnOAuth, + oauthEnd, customUserVars, + requestBody, + requestScopedConnections, connectionTimeout, serverConfig, + graphTokenResolver: getGraphApiToken, + oboTokenResolver: exchangeOboToken, + oboTrustChecker: createOboTrustChecker(), }); logger.info(`[MCP Reinitialize] Successfully established connection for ${serverName}`); @@ -142,8 +183,12 @@ async function reinitMCPServer({ tokenMethods, oauthStart, customUserVars, + requestBody, connectionTimeout, configServers, + graphTokenResolver: getGraphApiToken, + oboTokenResolver: exchangeOboToken, + oboTrustChecker: createOboTrustChecker(), }); if (discoveryResult.tools && discoveryResult.tools.length > 0) { @@ -174,6 +219,7 @@ async function reinitMCPServer({ userId: user.id, serverName, tools, + serverConfig, }); } @@ -221,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 new file mode 100644 index 00000000000..ab8cd3f2811 --- /dev/null +++ b/api/server/services/Tools/mcp.spec.js @@ -0,0 +1,186 @@ +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, + discoverServerTools: mockDiscoverServerTools, + })), + getMCPServersRegistry: jest.fn(() => ({ getServerConfig: jest.fn() })), + getFlowStateManager: jest.fn(() => ({})), +})); +jest.mock('~/models', () => ({ + findToken: jest.fn(), + createToken: jest.fn(), + updateToken: jest.fn(), + deleteTokens: jest.fn(), +})); +jest.mock('~/server/services/Config', () => ({ + updateMCPServerTools: mockUpdateMCPServerTools, +})); +jest.mock('~/server/services/GraphTokenService', () => ({ + getGraphApiToken: mockGetGraphApiToken, +})); +jest.mock('~/cache', () => ({ + getLogStores: jest.fn(() => ({})), +})); + +const { reinitMCPServer } = require('./mcp'); + +describe('reinitMCPServer — customUserVars gating (issue #10969)', () => { + const user = { id: 'user-123' }; + const serverName = 'Thingy'; + const serverConfig = { + type: 'streamable-http', + url: 'https://thingy.example.com/mcp', + customUserVars: { + THINGY_TOKEN: { title: 'Thingy Access Token', description: 'Create this in Thingy' }, + }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockUpdateMCPServerTools.mockResolvedValue({}); + }); + + it('does not connect and exposes no tools when a required customUserVar is unset', async () => { + const result = await reinitMCPServer({ + user, + serverName, + serverConfig, + userMCPAuthMap: undefined, + }); + + expect(mockGetConnection).not.toHaveBeenCalled(); + expect(result).toMatchObject({ + availableTools: null, + success: false, + tools: null, + oauthRequired: false, + serverName, + }); + expect(result.message).toContain('THINGY_TOKEN'); + }); + + it('does not connect when the stored value for a required customUserVar is empty', async () => { + const result = await reinitMCPServer({ + user, + serverName, + serverConfig, + userMCPAuthMap: { [`${Constants.mcp_prefix}${serverName}`]: { THINGY_TOKEN: '' } }, + }); + + expect(mockGetConnection).not.toHaveBeenCalled(); + expect(result.success).toBe(false); + expect(result.availableTools).toBeNull(); + }); + + it('proceeds to connect once every required customUserVar is provided', async () => { + mockGetConnection.mockResolvedValue({ fetchTools: jest.fn().mockResolvedValue([]) }); + + await reinitMCPServer({ + user, + serverName, + serverConfig, + userMCPAuthMap: { + [`${Constants.mcp_prefix}${serverName}`]: { THINGY_TOKEN: 'secret-token' }, + }, + }); + + expect(mockGetConnection).toHaveBeenCalledTimes(1); + expect(mockGetConnection).toHaveBeenCalledWith( + expect.objectContaining({ + serverName, + customUserVars: { THINGY_TOKEN: 'secret-token' }, + }), + ); + }); + + 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([]) }); + + await reinitMCPServer({ + user, + serverName, + serverConfig: { type: 'streamable-http', url: 'https://thingy.example.com/mcp' }, + userMCPAuthMap: undefined, + }); + + expect(mockGetConnection).toHaveBeenCalledTimes(1); + }); +}); diff --git a/api/server/services/__tests__/MCP.spec.js b/api/server/services/__tests__/MCP.spec.js index 39e99d54ac9..ca3d5eea9a1 100644 --- a/api/server/services/__tests__/MCP.spec.js +++ b/api/server/services/__tests__/MCP.spec.js @@ -23,6 +23,17 @@ jest.mock('~/server/services/Config', () => ({ loadCustomConfig: jest.fn(), })); +jest.mock('@librechat/api', () => ({ + sendEvent: jest.fn(), + MCPOAuthHandler: jest.fn(), + isMCPDomainAllowed: jest.fn(), + normalizeServerName: jest.fn((name) => name), + normalizeJsonSchema: jest.fn((schema) => schema), + GenerationJobManager: jest.fn(), + resolveJsonSchemaRefs: jest.fn((schema) => schema), + buildOAuthToolCallName: jest.fn((name) => name), +})); + jest.mock('~/cache', () => ({ getLogStores: jest.fn() })); jest.mock('~/models', () => ({ findToken: jest.fn(), @@ -32,12 +43,18 @@ jest.mock('~/models', () => ({ jest.mock('~/server/services/GraphTokenService', () => ({ getGraphApiToken: jest.fn(), })); +jest.mock('~/server/services/OboTokenService', () => ({ + exchangeOboToken: jest.fn(), +})); +jest.mock('~/server/services/OboPolicyService', () => ({ + createOboTrustChecker: jest.fn(() => async () => true), +})); jest.mock('~/server/services/Tools/mcp', () => ({ reinitMCPServer: jest.fn(), })); const { getAppConfig } = require('~/server/services/Config'); -const { resolveConfigServers, resolveAllMcpConfigs } = require('../MCP'); +const { resolveConfigServers, resolveMcpConfigNames, resolveAllMcpConfigs } = require('../MCP'); describe('resolveConfigServers', () => { beforeEach(() => jest.clearAllMocks()); @@ -82,6 +99,35 @@ describe('resolveConfigServers', () => { }); }); +describe('resolveMcpConfigNames', () => { + beforeEach(() => jest.clearAllMocks()); + + it('resolves current request config server names', async () => { + getAppConfig.mockResolvedValue({ mcpConfig: { cfg_srv: {}, yaml_srv: {} } }); + + const result = await resolveMcpConfigNames({ user: { id: 'u1', role: 'admin' } }); + + expect(result).toEqual(['cfg_srv', 'yaml_srv']); + expect(getAppConfig).toHaveBeenCalledWith( + expect.objectContaining({ role: 'admin', userId: 'u1' }), + ); + }); + + it('returns [] when mcpConfig is absent', async () => { + getAppConfig.mockResolvedValue({}); + + const result = await resolveMcpConfigNames({ user: { id: 'u1' } }); + + expect(result).toEqual([]); + }); + + it('propagates getAppConfig failures for write-path callers', async () => { + getAppConfig.mockRejectedValue(new Error('db timeout')); + + await expect(resolveMcpConfigNames({ user: { id: 'u1' } })).rejects.toThrow('db timeout'); + }); +}); + describe('resolveAllMcpConfigs', () => { beforeEach(() => jest.clearAllMocks()); @@ -99,9 +145,13 @@ describe('resolveAllMcpConfigs', () => { cfg_srv: { name: 'cfg_srv' }, yaml_srv: { name: 'yaml_srv' }, }); - expect(mockRegistry.getAllServerConfigs).toHaveBeenCalledWith('u1', { - cfg_srv: { name: 'cfg_srv' }, - }); + expect(mockRegistry.getAllServerConfigs).toHaveBeenCalledWith( + 'u1', + { + cfg_srv: { name: 'cfg_srv' }, + }, + 'user', + ); }); it('continues with empty configServers when ensureConfigServers fails', async () => { diff --git a/api/server/services/__tests__/ToolService.spec.js b/api/server/services/__tests__/ToolService.spec.js index be066d67927..9f496c5f3af 100644 --- a/api/server/services/__tests__/ToolService.spec.js +++ b/api/server/services/__tests__/ToolService.spec.js @@ -1,3 +1,4 @@ +const { Constants: AgentConstants } = require('@librechat/agents'); const { Tools, Constants, @@ -11,6 +12,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 +26,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(); @@ -35,6 +42,10 @@ const mockDomainParser = jest.fn(); 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', () => ({ loadAuthValues: jest.fn().mockResolvedValue({}), })); @@ -68,10 +79,17 @@ jest.mock('~/models', () => ({ findPluginAuthsByKeys: jest.fn(), })); jest.mock('~/config', () => ({ - getFlowStateManager: jest.fn(() => ({})), + getFlowStateManager: jest.fn(() => mockFlowManager), + getMCPServersRegistry: jest.fn(() => ({ + getServerConfig: (...args) => mockGetServerConfig(...args), + })), })); jest.mock('~/server/services/MCP', () => ({ - resolveConfigServers: jest.fn().mockResolvedValue({}), + resolveConfigServers: (...args) => mockResolveConfigServers(...args), + createMCPPermissionContext: jest.fn((req) => ({ + canUseServers: (user) => mockUserCanUseMCPServers(user, req), + })), + userCanUseMCPServers: mockUserCanUseMCPServers, })); jest.mock('~/cache', () => ({ getLogStores: jest.fn(() => ({})), @@ -83,6 +101,8 @@ const { processRequiredActions, resolveAgentCapabilities, } = require('../ToolService'); +const { reinitMCPServer } = require('~/server/services/Tools/mcp'); +const { PENDING_STALE_MS } = require('@librechat/api'); function createMockReq(capabilities) { return { @@ -113,6 +133,12 @@ describe('ToolService - Action Capability Gating', () => { }); mockLoadToolsUtil.mockResolvedValue({ loadedTools: [], toolContextMap: {} }); mockLoadActionSets.mockResolvedValue([]); + mockGetMCPServerTools.mockResolvedValue(null); + mockGetCachedTools.mockResolvedValue(null); + mockGetUserMCPAuthMap.mockResolvedValue({}); + mockGetServerConfig.mockResolvedValue(undefined); + mockFlowManager.getFlowState.mockResolvedValue(undefined); + mockResolveConfigServers.mockResolvedValue({}); }); describe('resolveAgentCapabilities', () => { @@ -245,6 +271,28 @@ describe('ToolService - Action Capability Gating', () => { expect(callArgs.tools).toContain(regularTool); }); + it('should filter MCP tool definitions when user lacks MCP server use permission', async () => { + const { userCanUseMCPServers } = require('~/server/services/MCP'); + userCanUseMCPServers.mockResolvedValueOnce(false); + + const mcpTool = `search${Constants.mcp_delimiter}myserver`; + const capabilities = [AgentCapabilities.tools]; + const req = createMockReq(capabilities); + mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities)); + + await loadAgentTools({ + req, + res: {}, + agent: { id: 'agent_123', tools: [regularTool, mcpTool] }, + definitionsOnly: true, + }); + + expect(mockLoadToolDefinitions).toHaveBeenCalledTimes(1); + const [callArgs] = mockLoadToolDefinitions.mock.calls[0]; + expect(callArgs.tools).toContain(regularTool); + expect(callArgs.tools).not.toContain(mcpTool); + }); + it('should return actionsEnabled in the result', async () => { const capabilities = [AgentCapabilities.tools]; const req = createMockReq(capabilities); @@ -259,6 +307,623 @@ 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}`; + const capabilities = [AgentCapabilities.tools]; + const req = createMockReq(capabilities); + mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities)); + mockGetServerConfig.mockImplementation(() => { + throw new Error('MCPServersRegistry has not been initialized.'); + }); + mockGetMCPServerTools.mockResolvedValue({ + [mcpTool]: { + function: { + name: mcpTool, + description: 'Cached private search', + parameters: {}, + }, + }, + }); + mockLoadToolDefinitions.mockImplementation(async (params, deps) => { + const serverTools = await deps.getOrFetchMCPServerTools(params.userId, serverName); + return { + toolDefinitions: serverTools ? Object.keys(serverTools) : [], + toolRegistry: new Map(), + hasDeferredTools: false, + }; + }); + + const result = await loadAgentTools({ + req, + res: {}, + agent: { id: 'agent_123', tools: [mcpTool] }, + definitionsOnly: true, + }); + + expect(result.toolDefinitions).toEqual([]); + 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}`; + const capabilities = [AgentCapabilities.tools]; + const req = createMockReq(capabilities); + mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities)); + mockResolveConfigServers.mockResolvedValue({ + [serverName]: { + type: 'streamable-http', + url: 'https://config.example.com/mcp', + customUserVars: { + TOKEN: { title: 'Token', description: 'Token' }, + }, + }, + }); + mockGetUserMCPAuthMap.mockResolvedValue({ + [`${Constants.mcp_prefix}${serverName}`]: { TOKEN: 'secret' }, + }); + mockGetMCPServerTools.mockResolvedValue({ + [mcpTool]: { + function: { + name: mcpTool, + description: 'Config search', + parameters: {}, + }, + }, + }); + mockLoadToolDefinitions.mockImplementation(async (params, deps) => { + const serverTools = await deps.getOrFetchMCPServerTools(params.userId, serverName); + return { + toolDefinitions: serverTools ? Object.keys(serverTools) : [], + toolRegistry: new Map(), + hasDeferredTools: false, + }; + }); + + const result = await loadAgentTools({ + req, + res: {}, + agent: { id: 'agent_123', tools: [mcpTool] }, + definitionsOnly: true, + }); + + expect(result.toolDefinitions).toEqual([mcpTool]); + expect(mockGetServerConfig).not.toHaveBeenCalled(); + expect(mockGetMCPServerTools).toHaveBeenCalledWith( + req.user.id, + serverName, + expect.objectContaining({ url: 'https://config.example.com/mcp' }), + ); + }); }); describe('loadAgentTools (definitionsOnly=false) — action tool filtering', () => { @@ -300,6 +965,29 @@ describe('ToolService - Action Capability Gating', () => { const actionToolName = `get_weather${actionDelimiter}api_example_com`; const regularTool = Tools.web_search; + it('does not load code execution tools that were not registered for the agent', async () => { + const capabilities = [ + AgentCapabilities.tools, + AgentCapabilities.web_search, + AgentCapabilities.execute_code, + ]; + const req = createMockReq(capabilities); + const toolRegistry = new Map([[Tools.web_search, { name: Tools.web_search }]]); + mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities)); + + const result = await loadToolsForExecution({ + req, + res: {}, + agent: { id: 'agent_without_code', tools: [Tools.web_search] }, + toolNames: [AgentConstants.BASH_TOOL, Tools.execute_code], + toolRegistry, + actionsEnabled: false, + }); + + expect(result.loadedTools.map((tool) => tool.name)).toEqual([]); + expect(mockLoadToolsUtil).not.toHaveBeenCalled(); + }); + it('loads bash PTC under the legacy programmatic tool name when code capabilities are enabled', async () => { const capabilities = [ AgentCapabilities.tools, @@ -326,6 +1014,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 dfb03b4d37d..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, @@ -83,7 +100,7 @@ const configureSocialLogins = async (app) => { } if ( process.env.OPENID_CLIENT_ID && - process.env.OPENID_CLIENT_SECRET && + (isEnabled(process.env.OPENID_USE_PKCE) || process.env.OPENID_CLIENT_SECRET?.trim()) && process.env.OPENID_ISSUER && process.env.OPENID_SCOPE && process.env.OPENID_SESSION_SECRET @@ -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/telemetry.js b/api/server/telemetry.js new file mode 100644 index 00000000000..cb0058355b0 --- /dev/null +++ b/api/server/telemetry.js @@ -0,0 +1,40 @@ +require('dotenv').config(); + +function isTruthy(value) { + return value?.trim().toLowerCase() === 'true'; +} + +function isTelemetryEnabled() { + return isTruthy(process.env.OTEL_TRACING_ENABLED) && !isTruthy(process.env.OTEL_SDK_DISABLED); +} + +if (isTelemetryEnabled()) { + const { + initializeTelemetry, + telemetryMiddleware, + telemetryErrorMiddleware, + } = require('@librechat/api/telemetry'); + const controller = initializeTelemetry(); + + module.exports = { + get enabled() { + return controller.enabled; + }, + get status() { + return controller.status; + }, + shutdown: controller.shutdown, + telemetryMiddleware, + telemetryErrorMiddleware, + }; +} else { + module.exports = { + enabled: false, + get status() { + return 'disabled'; + }, + shutdown: async () => {}, + telemetryMiddleware: (_req, _res, next) => next(), + telemetryErrorMiddleware: (err, _req, _res, next) => next(err), + }; +} diff --git a/api/server/telemetry.spec.js b/api/server/telemetry.spec.js new file mode 100644 index 00000000000..8f848da8e76 --- /dev/null +++ b/api/server/telemetry.spec.js @@ -0,0 +1,91 @@ +describe('telemetry bootstrap', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.resetModules(); + process.env = { ...originalEnv }; + delete process.env.OTEL_SDK_DISABLED; + delete process.env.OTEL_TRACING_ENABLED; + jest.doMock('dotenv', () => ({ + config: jest.fn(), + })); + }); + + afterEach(() => { + process.env = originalEnv; + jest.dontMock('dotenv'); + jest.dontMock('@librechat/api/telemetry'); + jest.resetModules(); + }); + + it('does not load OpenTelemetry packages by default', () => { + jest.doMock( + '@librechat/api/telemetry', + () => { + throw new Error('telemetry package should not load when tracing is disabled'); + }, + { virtual: true }, + ); + + const telemetry = require('./telemetry'); + + expect(telemetry.enabled).toBe(false); + expect(telemetry.status).toBe('disabled'); + }); + + it('does not load OpenTelemetry packages when the SDK is disabled', () => { + process.env.OTEL_SDK_DISABLED = 'true'; + process.env.OTEL_TRACING_ENABLED = 'true'; + jest.doMock( + '@librechat/api/telemetry', + () => { + throw new Error('telemetry package should not load when the SDK is disabled'); + }, + { virtual: true }, + ); + + const telemetry = require('./telemetry'); + + expect(telemetry.enabled).toBe(false); + expect(telemetry.status).toBe('disabled'); + }); + + it('loads and exposes telemetry middleware when tracing is enabled', () => { + process.env.OTEL_TRACING_ENABLED = 'true'; + let enabled = true; + let status = 'starting'; + const telemetryMiddleware = jest.fn(); + const telemetryErrorMiddleware = jest.fn(); + const initializeTelemetry = jest.fn(() => ({ + get enabled() { + return enabled; + }, + get status() { + return status; + }, + shutdown: jest.fn(), + })); + jest.doMock( + '@librechat/api/telemetry', + () => ({ + initializeTelemetry, + telemetryMiddleware, + telemetryErrorMiddleware, + }), + { virtual: true }, + ); + + const telemetry = require('./telemetry'); + + expect(initializeTelemetry).toHaveBeenCalledTimes(1); + expect(telemetry.enabled).toBe(true); + expect(telemetry.status).toBe('starting'); + expect(telemetry.telemetryMiddleware).toBe(telemetryMiddleware); + expect(telemetry.telemetryErrorMiddleware).toBe(telemetryErrorMiddleware); + + enabled = false; + status = 'failed'; + expect(telemetry.enabled).toBe(false); + expect(telemetry.status).toBe('failed'); + }); +}); 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/importBatchBuilder.js b/api/server/utils/import/importBatchBuilder.js index be47cd3692b..b1856737cdf 100644 --- a/api/server/utils/import/importBatchBuilder.js +++ b/api/server/utils/import/importBatchBuilder.js @@ -1,16 +1,26 @@ const { v4: uuidv4 } = require('uuid'); -const { logger } = require('@librechat/data-schemas'); -const { EModelEndpoint, Constants, openAISettings } = require('librechat-data-provider'); +const { + logger, + createFallbackRetentionDate, + createTempChatExpirationDate, +} = require('@librechat/data-schemas'); +const { + EModelEndpoint, + Constants, + RetentionMode, + openAISettings, +} = require('librechat-data-provider'); const { bulkIncrementTagCounts, bulkSaveConvos, bulkSaveMessages } = require('~/models'); const { FALLBACK_MODEL_BY_ENDPOINT } = require('./defaults'); /** * Factory function for creating an instance of ImportBatchBuilder. * @param {string} requestUserId - The ID of the user making the request. + * @param {object} [interfaceConfig] - Runtime interface config for import retention. * @returns {ImportBatchBuilder} - The newly created ImportBatchBuilder instance. */ -function createImportBatchBuilder(requestUserId) { - return new ImportBatchBuilder(requestUserId); +function createImportBatchBuilder(requestUserId, interfaceConfig) { + return new ImportBatchBuilder(requestUserId, interfaceConfig); } /** @@ -20,11 +30,36 @@ class ImportBatchBuilder { /** * Creates an instance of ImportBatchBuilder. * @param {string} requestUserId - The ID of the user making the import request. + * @param {object} [interfaceConfig] - Runtime interface config for import retention. */ - constructor(requestUserId) { + constructor(requestUserId, interfaceConfig) { this.requestUserId = requestUserId; + this.interfaceConfig = interfaceConfig; this.conversations = []; this.messages = []; + this.retentionFields = undefined; + } + + getRetentionFields() { + if (this.retentionFields !== undefined) { + return this.retentionFields; + } + + if (this.interfaceConfig?.retentionMode !== RetentionMode.ALL) { + this.retentionFields = {}; + return this.retentionFields; + } + + try { + this.retentionFields = { + isTemporary: false, + expiredAt: createTempChatExpirationDate(this.interfaceConfig), + }; + } catch (error) { + logger.error('[ImportBatchBuilder] Error creating import expiration date:', error); + this.retentionFields = { isTemporary: false, expiredAt: createFallbackRetentionDate() }; + } + return this.retentionFields; } /** @@ -89,6 +124,7 @@ class ImportBatchBuilder { overrideTimestamp: true, endpoint: this.endpoint, model: originalConvo.model ?? fallbackModel, + ...this.getRetentionFields(), }; convo._id && delete convo._id; this.conversations.push(convo); @@ -161,6 +197,7 @@ class ImportBatchBuilder { error: false, sender, text, + ...this.getRetentionFields(), }; message._id && delete message._id; this.lastMessageId = newMessageId; diff --git a/api/server/utils/import/importConversations.js b/api/server/utils/import/importConversations.js index ad2d743f019..21bba86e3a7 100644 --- a/api/server/utils/import/importConversations.js +++ b/api/server/utils/import/importConversations.js @@ -2,15 +2,16 @@ const fs = require('fs').promises; const { resolveImportMaxFileSize } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); const { getImporter } = require('./importers'); +const { createImportBatchBuilder } = require('./importBatchBuilder'); const maxFileSize = resolveImportMaxFileSize(); /** * Job definition for importing a conversation. - * @param {{ filepath: string, requestUserId: string, userRole?: string }} job + * @param {{ filepath: string, requestUserId: string, userRole?: string, interfaceConfig?: object }} job */ const importConversations = async (job) => { - const { filepath, requestUserId, userRole } = job; + const { filepath, requestUserId, userRole, interfaceConfig } = job; try { logger.debug(`user: ${requestUserId} | Importing conversation(s) from file...`); @@ -24,7 +25,12 @@ const importConversations = async (job) => { const fileData = await fs.readFile(filepath, 'utf8'); const jsonData = JSON.parse(fileData); const importer = getImporter(jsonData); - await importer(jsonData, requestUserId, undefined, userRole); + await importer( + jsonData, + requestUserId, + (userId) => createImportBatchBuilder(userId, interfaceConfig), + userRole, + ); logger.debug(`user: ${requestUserId} | Finished importing conversations`); } catch (error) { logger.error(`user: ${requestUserId} | Failed to import conversation: `, error); 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 cbd39afb341..a9bd679f55f 100644 --- a/api/server/utils/import/importers.spec.js +++ b/api/server/utils/import/importers.spec.js @@ -3,6 +3,7 @@ const path = require('path'); const { EModelEndpoint, Constants, + RetentionMode, openAISettings, anthropicSettings, } = require('librechat-data-provider'); @@ -28,6 +29,7 @@ jest.mock('~/server/controllers/ModelController', () => ({ jest.mock('~/models', () => ({ bulkSaveConvos: jest.fn(), bulkSaveMessages: jest.fn(), + bulkIncrementTagCounts: jest.fn(), })); afterEach(() => { @@ -762,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', () => { @@ -1046,6 +1128,23 @@ describe('importLibreChatConvo', () => { expect(result.conversation.endpoint).toBe(EModelEndpoint.openAI); expect(result.conversation.model).toBe(openAISettings.model.default); }); + + it('applies all-data retention to imported conversations and messages', () => { + const requestUserId = 'user-123'; + const builder = new ImportBatchBuilder(requestUserId, { + retentionMode: RetentionMode.ALL, + temporaryChatRetention: 24, + }); + builder.startConversation(EModelEndpoint.openAI); + const message = builder.addUserMessage('Retained import'); + const result = builder.finishConversation('Imported retained chat'); + + expect(message.isTemporary).toBe(false); + expect(message.expiredAt).toBeInstanceOf(Date); + expect(result.conversation.isTemporary).toBe(false); + expect(result.conversation.expiredAt).toBeInstanceOf(Date); + expect(result.conversation.expiredAt).toBe(message.expiredAt); + }); }); }); @@ -1116,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 78145f1109b..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,12 +9,17 @@ const { getOpenIdEmail, getOpenIdIssuer, normalizeOpenIdIssuer, + getHttpsProxyAgent, math, } = require('@librechat/api'); const { updateUser, findUser } = require('~/models'); const getOpenIdJwtAudience = () => { - const audiences = [process.env.OPENID_CLIENT_ID, process.env.OPENID_AUDIENCE].filter(Boolean); + const parsedAudience = (process.env.OPENID_AUDIENCE ?? '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + const audiences = [process.env.OPENID_CLIENT_ID, ...parsedAudience].filter(Boolean); const uniqueAudiences = [...new Set(audiences)]; return uniqueAudiences.length > 1 ? uniqueAudiences : uniqueAudiences[0]; @@ -69,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 59229a3d159..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', () => ({ @@ -119,6 +120,52 @@ describe('openIdJwtStrategy – token validation', () => { }); }); + it('uses a single OPENID_AUDIENCE value when no client ID is configured', () => { + withEnv({ OPENID_CLIENT_ID: undefined, OPENID_AUDIENCE: 'librechat' }, () => { + openIdJwtLogin(mockOpenIdConfig); + }); + + expect(capturedStrategyOptions.audience).toBe('librechat'); + }); + + it('splits comma-separated OPENID_AUDIENCE values into multiple accepted audiences', () => { + withEnv({ OPENID_CLIENT_ID: undefined, OPENID_AUDIENCE: 'librechat,control-plane-web' }, () => { + openIdJwtLogin(mockOpenIdConfig); + }); + + expect(capturedStrategyOptions.audience).toEqual(['librechat', 'control-plane-web']); + }); + + it('trims whitespace around comma-separated OPENID_AUDIENCE values', () => { + withEnv( + { OPENID_CLIENT_ID: undefined, OPENID_AUDIENCE: ' librechat , control-plane-web ' }, + () => { + openIdJwtLogin(mockOpenIdConfig); + }, + ); + + expect(capturedStrategyOptions.audience).toEqual(['librechat', 'control-plane-web']); + }); + + it('falls back to OPENID_CLIENT_ID when OPENID_AUDIENCE is empty', () => { + withEnv({ OPENID_CLIENT_ID: 'client-id-only', OPENID_AUDIENCE: '' }, () => { + openIdJwtLogin(mockOpenIdConfig); + }); + + expect(capturedStrategyOptions.audience).toBe('client-id-only'); + }); + + it('combines OPENID_CLIENT_ID with comma-separated OPENID_AUDIENCE values and deduplicates', () => { + withEnv( + { OPENID_CLIENT_ID: 'librechat', OPENID_AUDIENCE: 'librechat,control-plane-web' }, + () => { + openIdJwtLogin(mockOpenIdConfig); + }, + ); + + expect(capturedStrategyOptions.audience).toEqual(['librechat', 'control-plane-web']); + }); + it('rejects OpenID JWTs whose issuer does not match the configured issuer', async () => { findOpenIDUser.mockResolvedValue({ user: null, error: null, migration: false }); openIdJwtLogin(mockOpenIdConfig); @@ -339,7 +386,7 @@ describe('openIdJwtStrategy – OPENID_EMAIL_CLAIM', () => { role: SystemRoles.USER, }; findUser.mockImplementation(async (query) => { - if (query.$or && query.$or.some((c) => c.openidId === payload.sub)) { + if (query.openidId === payload.sub && query.openidIssuer === 'https://issuer.example.com') { return existingUser; } return null; @@ -348,13 +395,10 @@ describe('openIdJwtStrategy – OPENID_EMAIL_CLAIM', () => { const req = { headers: { authorization: 'Bearer tok' }, session: {} }; await invokeVerify(req, payload); - expect(findUser).toHaveBeenCalledWith( - expect.objectContaining({ - $or: expect.arrayContaining([ - { openidId: payload.sub, openidIssuer: 'https://issuer.example.com' }, - ]), - }), - ); + expect(findUser).toHaveBeenCalledWith({ + openidId: payload.sub, + openidIssuer: 'https://issuer.example.com', + }); }); it('should use OPENID_EMAIL_CLAIM when set for email lookup', async () => { @@ -365,12 +409,13 @@ describe('openIdJwtStrategy – OPENID_EMAIL_CLAIM', () => { const { user } = await invokeVerify(req, payload); expect(findUser).toHaveBeenCalledTimes(2); - expect(findUser.mock.calls[0][0]).toMatchObject({ - $or: expect.arrayContaining([ - { openidId: payload.sub, openidIssuer: 'https://issuer.example.com' }, - ]), + expect(findUser.mock.calls[0][0]).toEqual({ + openidId: payload.sub, + openidIssuer: 'https://issuer.example.com', + }); + expect(findUser.mock.calls[1][0]).toEqual({ + email: 'test@corp.example.com', }); - expect(findUser.mock.calls[1][0]).toEqual({ email: 'test@corp.example.com' }); expect(user).toBe(false); }); diff --git a/api/strategies/openidStrategy.js b/api/strategies/openidStrategy.js index 595c2b535a4..d9c2684314f 100644 --- a/api/strategies/openidStrategy.js +++ b/api/strategies/openidStrategy.js @@ -1,11 +1,9 @@ const undici = require('undici'); const { get } = require('lodash'); -const fetch = require('node-fetch'); const passport = require('passport'); const client = require('openid-client'); const jwtDecode = require('jsonwebtoken/decode'); -const { HttpsProxyAgent } = require('https-proxy-agent'); -const { hashToken, logger } = require('@librechat/data-schemas'); +const { hashToken, logger, tenantStorage } = require('@librechat/data-schemas'); const { Strategy: OpenIDStrategy } = require('openid-client/passport'); const { CacheKeys, ErrorTypes, SystemRoles } = require('librechat-data-provider'); const { @@ -16,13 +14,19 @@ const { getOpenIdEmail, getOpenIdIssuer, getBalanceConfig, + selectOpenIdRole, + getAvatarSaveParams, isEmailDomainAllowed, getAvatarFileStrategy, - getAvatarSaveParams, resolveAppConfigForUser, + getOpenIdProxyDispatcher, + getOpenIdRoleSyncOptions, + getOpenIdRolesForOpenIdSync, + getLibreChatRolesForOpenIdSync, } = require('@librechat/api'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); -const { findUser, createUser, updateUser } = require('~/models'); +const { resizeAvatar } = require('~/server/services/Files/images/avatar'); +const { findUser, createUser, updateUser, findRolesByNames } = require('~/models'); const { getAppConfig } = require('~/server/services/Config'); const getLogStores = require('~/cache/getLogStores'); @@ -58,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, }; } @@ -104,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 * @@ -124,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}`, ); } @@ -200,43 +212,64 @@ const getUserInfo = async (config, accessToken, sub) => { } }; -/** - * Downloads an image from a URL using an access token. - * @param {string} url - * @param {Configuration} config - * @param {string} accessToken access token - * @param {string} sub - The subject identifier of the user. usually found as "sub" in the claims of the token - * @returns {Promise} The image buffer or an empty string if the download fails. - */ -const downloadImage = async (url, config, accessToken, sub) => { +function getUrlOrigin(value) { + try { + return new URL(value).origin; + } catch { + return null; + } +} + +function getOpenIDAvatarAuthorizedOrigins(config) { + const metadata = config?.serverMetadata?.() ?? {}; + const metadataOrigins = [metadata.issuer, metadata.userinfo_endpoint] + .map(getUrlOrigin) + .filter(Boolean); + const configuredOrigins = (process.env.OPENID_AVATAR_AUTHORIZED_ORIGINS ?? '') + .split(/[\s,]+/) + .map(getUrlOrigin) + .filter(Boolean); + + return new Set([...metadataOrigins, ...configuredOrigins]); +} + +function shouldAuthorizeOpenIDAvatar(url, config) { + const origin = getUrlOrigin(url); + if (!origin) { + return false; + } + + return getOpenIDAvatarAuthorizedOrigins(config).has(origin); +} + +async function getOpenIDAvatarFetchOptions(url, config, accessToken, sub) { + if (!shouldAuthorizeOpenIDAvatar(url, config)) { + return undefined; + } + const exchangedAccessToken = await exchangeAccessTokenIfNeeded(config, accessToken, sub, true); + return { + headers: { + Authorization: `Bearer ${exchangedAccessToken}`, + }, + }; +} + +const resizeIdentityProviderAvatar = async (url, userId, config, accessToken, sub) => { if (!url) { return ''; } try { - const options = { - method: 'GET', - headers: { - Authorization: `Bearer ${exchangedAccessToken}`, - }, - }; - - if (process.env.PROXY) { - options.agent = new HttpsProxyAgent(process.env.PROXY); - } - - const response = await fetch(url, options); - - if (response.ok) { - const buffer = await response.buffer(); - return buffer; - } else { - throw new Error(`${response.statusText} (HTTP ${response.status})`); + const fetchOptions = await getOpenIDAvatarFetchOptions(url, config, accessToken, sub); + const avatarParams = { userId, input: url }; + if (fetchOptions) { + avatarParams.fetchOptions = fetchOptions; } + return await resizeAvatar(avatarParams); } catch (error) { logger.error( - `[openidStrategy] downloadImage: Error downloading image at URL "${url}": ${error}`, + `[openidStrategy] resizeIdentityProviderAvatar: Error processing avatar at URL "${url}": ${error}`, ); return ''; } @@ -388,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); @@ -425,6 +458,104 @@ async function resolveGroupsFromOverage(accessToken, sub) { } } +/** + * Resolve the source object (decoded token or userinfo) for a role check + * based on the configured token kind. Throws on invalid configuration so + * misconfiguration surfaces loudly instead of silently denying every login. + * + * @param {string} kind - One of 'access', 'id', or 'userinfo' + * @param {string} label - Human-readable label for error messages (e.g. 'required role') + * @param {Object} tokenset - The OpenID tokenset + * @param {Object} userinfo - Merged userinfo (id-token claims + UserInfo endpoint response) + */ +function getRoleSource(kind, label, tokenset, userinfo) { + if (kind === 'access') { + return jwtDecode(tokenset.access_token); + } + if (kind === 'id') { + return jwtDecode(tokenset.id_token); + } + if (kind === 'userinfo') { + return userinfo; + } + logger.error( + `[openidStrategy] Invalid ${label} token kind: ${kind}. Must be one of 'access', 'id', or 'userinfo'.`, + ); + throw new Error(`Invalid ${label} token kind`); +} + +/** + * Applies generic OpenID role sync to the request-local user before the existing final update. + */ +async function applyOpenIdRoleSync({ + user, + username, + tokenset, + claims, + userinfo, + resolvedOverageGroups, +}) { + const options = getOpenIdRoleSyncOptions(); + if (!options.enabled) { + return; + } + + if (user.role === SystemRoles.ADMIN) { + logger.info( + `[openidStrategy] OpenID role sync skipped for ${username}; existing ADMIN role is not managed by generic role sync`, + ); + return; + } + + const resolveGroupOverage = async () => + resolvedOverageGroups || (await resolveGroupsFromOverage(tokenset.access_token, claims.sub)); + + const openIdRoleValues = await getOpenIdRolesForOpenIdSync({ + options, + accessToken: tokenset.access_token, + idToken: tokenset.id_token, + claims, + userinfo, + decodeToken: jwtDecode, + resolveGroupOverage, + }); + if (openIdRoleValues === undefined) { + logger.warn( + `[openidStrategy] OpenID role sync skipped; claim '${options.claim}' was not found, invalid, or unresolved`, + ); + return; + } + + const libreChatRoles = { + getRolesByNames: findRolesByNames, + rolePriority: options.rolePriority, + fallbackRole: options.fallbackRole, + logPrefix: '[openidStrategy]', + }; + + /** Role definitions are tenant-scoped, so validate configured roles in the matched user's tenant. */ + const { rolePriority, fallbackRole } = user?.tenantId + ? await tenantStorage.run({ tenantId: user.tenantId }, async () => + getLibreChatRolesForOpenIdSync(libreChatRoles), + ) + : await getLibreChatRolesForOpenIdSync(libreChatRoles); + const result = selectOpenIdRole({ + currentRole: user.role, + openIdRoleValues, + rolePriority, + fallbackRole, + }); + + if (!result.selectedRole || result.selectedRole === user.role) { + return; + } + + logger.info( + `[openidStrategy] OpenID role sync updated role for ${username}: ${user.role || 'unset'} -> ${result.selectedRole}`, + ); + user.role = result.selectedRole; +} + /** * Process OpenID authentication tokenset and userinfo * This is the core logic extracted from the passport strategy callback @@ -493,12 +624,7 @@ async function processOpenIDAuth(tokenset, existingUsersOnly = false) { const requiredRoleParameterPath = process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH; const requiredRoleTokenKind = process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND; - let decodedToken = ''; - if (requiredRoleTokenKind === 'access' && tokenset.access_token) { - decodedToken = jwtDecode(tokenset.access_token); - } else if (requiredRoleTokenKind === 'id' && tokenset.id_token) { - decodedToken = jwtDecode(tokenset.id_token); - } + const decodedToken = getRoleSource(requiredRoleTokenKind, 'required role', tokenset, userinfo); let roles = get(decodedToken, requiredRoleParameterPath); @@ -589,25 +715,10 @@ async function processOpenIDAuth(tokenset, existingUsersOnly = false) { const adminRole = process.env.OPENID_ADMIN_ROLE; const adminRoleParameterPath = process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH; const adminRoleTokenKind = process.env.OPENID_ADMIN_ROLE_TOKEN_KIND; + let adminRoleGranted = false; if (adminRole && adminRoleParameterPath && adminRoleTokenKind) { - let adminRoleObject; - switch (adminRoleTokenKind) { - case 'access': - adminRoleObject = jwtDecode(tokenset.access_token); - break; - case 'id': - adminRoleObject = jwtDecode(tokenset.id_token); - break; - case 'userinfo': - adminRoleObject = userinfo; - break; - default: - logger.error( - `[openidStrategy] Invalid admin role token kind: ${adminRoleTokenKind}. Must be one of 'access', 'id', or 'userinfo'.`, - ); - throw new Error('Invalid admin role token kind'); - } + const adminRoleObject = getRoleSource(adminRoleTokenKind, 'admin role', tokenset, userinfo); let adminRoles = get(adminRoleObject, adminRoleParameterPath); @@ -637,6 +748,7 @@ async function processOpenIDAuth(tokenset, existingUsersOnly = false) { if (adminRoles && (adminRoles === true || adminRoleValues.includes(adminRole))) { user.role = SystemRoles.ADMIN; + adminRoleGranted = true; logger.info(`[openidStrategy] User ${username} is an admin based on role: ${adminRole}`); } else if (user.role === SystemRoles.ADMIN) { user.role = SystemRoles.USER; @@ -646,6 +758,33 @@ async function processOpenIDAuth(tokenset, existingUsersOnly = false) { } } + if (!adminRoleGranted) { + const roleBeforeSync = user.role; + await applyOpenIdRoleSync({ + user, + username, + tokenset, + claims, + userinfo, + resolvedOverageGroups, + }); + /** + * The earlier login-policy check ran with the pre-sync role. If role sync moved a + * tenant user into a different role, re-resolve the tenant config and re-enforce + * `allowedDomains` so role-scoped overrides for the new role are honored and a token + * cannot complete login under the previous role's looser policy. + */ + if (user?.tenantId && user.role !== roleBeforeSync) { + const postSyncConfig = await resolveAppConfigForUser(getAppConfig, user); + if (!isEmailDomainAllowed(email, postSyncConfig?.registration?.allowedDomains)) { + logger.error( + `[OpenID Strategy] Authentication blocked after role sync - email domain not allowed [Identifier: ${email}]`, + ); + throw new Error('Email domain not allowed'); + } + } + } + if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) { /** @type {string | undefined} */ const imageUrl = userinfo.picture; @@ -657,8 +796,10 @@ async function processOpenIDAuth(tokenset, existingUsersOnly = false) { fileName = userinfo.sub + '.png'; } - const imageBuffer = await downloadImage( + const userId = user._id.toString(); + const imageBuffer = await resizeIdentityProviderAvatar( imageUrl, + userId, openidConfig, tokenset.access_token, userinfo.sub, @@ -669,7 +810,7 @@ async function processOpenIDAuth(tokenset, existingUsersOnly = false) { const imagePath = await saveBuffer( getAvatarSaveParams(fileStrategy, { fileName, - userId: user._id.toString(), + userId, buffer: imageBuffer, tenantId: user.tenantId, }), @@ -767,18 +908,25 @@ const setupOpenIdAdmin = (openidConfig) => { */ async function setupOpenId() { try { + const usePKCE = isEnabled(process.env.OPENID_USE_PKCE); const shouldGenerateNonce = isEnabled(process.env.OPENID_GENERATE_NONCE); /** @type {ClientMetadata} */ const clientMetadata = { client_id: process.env.OPENID_CLIENT_ID, - client_secret: process.env.OPENID_CLIENT_SECRET, + response_types: ['code'], + grant_types: ['authorization_code'], }; - if (shouldGenerateNonce) { - clientMetadata.response_types = ['code']; - clientMetadata.grant_types = ['authorization_code']; - clientMetadata.token_endpoint_auth_method = 'client_secret_post'; + const clientSecret = process.env.OPENID_CLIENT_SECRET?.trim(); + + if (clientSecret) { + clientMetadata.client_secret = clientSecret; + if (shouldGenerateNonce) { + clientMetadata.token_endpoint_auth_method = 'client_secret_post'; + } + } else if (usePKCE) { + clientMetadata.token_endpoint_auth_method = 'none'; } /** @type {Configuration} */ @@ -793,10 +941,10 @@ async function setupOpenId() { ); logger.info(`[openidStrategy] OpenID authentication configuration`, { + usePKCE, + hasClientSecret: !!clientSecret, + tokenEndpointAuthMethod: clientMetadata.token_endpoint_auth_method ?? '(library default)', generateNonce: shouldGenerateNonce, - reason: shouldGenerateNonce - ? 'OPENID_GENERATE_NONCE=true - Will generate nonce and use explicit metadata for federated providers' - : 'OPENID_GENERATE_NONCE=false - Standard flow without explicit nonce or metadata', }); const openidLogin = new CustomOpenIDStrategy( @@ -805,7 +953,7 @@ async function setupOpenId() { scope: process.env.OPENID_SCOPE, callbackURL: process.env.DOMAIN_SERVER + process.env.OPENID_CALLBACK_URL, clockTolerance: process.env.OPENID_CLOCK_TOLERANCE || 300, - usePKCE: isEnabled(process.env.OPENID_USE_PKCE), + usePKCE, }, createOpenIDCallback(), ); @@ -835,4 +983,5 @@ module.exports = { setupOpenId, getOpenIdConfig, getOpenIdEmail, + getRoleSource, }; diff --git a/api/strategies/openidStrategy.spec.js b/api/strategies/openidStrategy.spec.js index 15e507e9b56..36a491a0685 100644 --- a/api/strategies/openidStrategy.spec.js +++ b/api/strategies/openidStrategy.spec.js @@ -2,42 +2,104 @@ const undici = require('undici'); const fetch = require('node-fetch'); const jwtDecode = require('jsonwebtoken/decode'); const { ErrorTypes, FileSources } = require('librechat-data-provider'); -const { findUser, createUser, updateUser } = require('~/models'); -const { getOpenIdIssuer, resolveAppConfigForUser } = require('@librechat/api'); +const { findUser, createUser, updateUser, findRolesByNames } = require('~/models'); +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'); +const mockCloudfrontFileSource = FileSources.cloudfront ?? 'cloudfront'; + // --- Mocks --- 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(() => ({ saveBuffer: jest.fn().mockResolvedValue('/fake/path/to/avatar.png'), })), })); +jest.mock('~/server/services/Files/images/avatar', () => ({ + resizeAvatar: jest.fn().mockResolvedValue(Buffer.from('safe avatar')), +})); jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn().mockResolvedValue({}), })); -jest.mock('@librechat/api', () => ({ - ...jest.requireActual('@librechat/api'), - isEnabled: jest.fn(() => false), - isEmailDomainAllowed: jest.fn(() => true), - findOpenIDUser: jest.requireActual('@librechat/api').findOpenIDUser, - getOpenIdEmail: jest.requireActual('@librechat/api').getOpenIdEmail, - getBalanceConfig: jest.fn(() => ({ - enabled: false, - })), - getOpenIdIssuer: jest.fn(() => 'https://fake-issuer.com'), - resolveAppConfigForUser: jest.fn(async (_getAppConfig, _user) => ({})), -})); +jest.mock('@librechat/api', () => { + const actual = jest.requireActual('@librechat/api'); + const getStringClaim = (claims, claim) => { + const value = claims[claim]; + return typeof value === 'string' && value ? value : undefined; + }; + + return { + ...actual, + isEnabled: jest.fn(() => false), + isEmailDomainAllowed: jest.fn(() => true), + findOpenIDUser: actual.findOpenIDUser, + getOpenIdEmail: jest.fn((claims, strategyName = 'openidStrategy') => { + if (claims == null) { + return undefined; + } + + const claimKey = process.env.OPENID_EMAIL_CLAIM?.trim(); + if (claimKey) { + const value = claims[claimKey]; + if (typeof value === 'string' && value) { + return value; + } + + const { logger } = require('@librechat/data-schemas'); + if (value != null) { + logger.warn( + `[${strategyName}] OPENID_EMAIL_CLAIM="${claimKey}" resolved to a non-string value (type: ${typeof value}). Falling back to: email -> preferred_username -> upn.`, + ); + } else { + logger.warn( + `[${strategyName}] OPENID_EMAIL_CLAIM="${claimKey}" not present in userinfo. Falling back to: email -> preferred_username -> upn.`, + ); + } + } + + return ( + getStringClaim(claims, 'email') ?? + getStringClaim(claims, 'preferred_username') ?? + getStringClaim(claims, 'upn') + ); + }), + getBalanceConfig: jest.fn(() => ({ + 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) { + return config.fileStrategies.avatar ?? config.fileStrategies.default ?? config.fileStrategy; + } + return config?.fileStrategy ?? fallbackStrategy ?? FileSources.local; + }), + getAvatarSaveParams: jest.fn((strategy, params) => { + const { FileSources } = jest.requireActual('librechat-data-provider'); + return strategy === FileSources.s3 || strategy === mockCloudfrontFileSource + ? { ...params, basePath: 'avatars' } + : params; + }), + resolveAppConfigForUser: jest.fn(async (_getAppConfig, _user) => ({})), + }; +}); jest.mock('~/models', () => ({ findUser: jest.fn(), createUser: jest.fn(), updateUser: jest.fn(), + findRolesByNames: jest.fn(), })); jest.mock('@librechat/data-schemas', () => ({ ...jest.requireActual('@librechat/api'), @@ -47,6 +109,9 @@ jest.mock('@librechat/data-schemas', () => ({ debug: jest.fn(), error: jest.fn(), }, + tenantStorage: { + run: jest.fn((_context, fn) => fn()), + }, hashToken: jest.fn().mockResolvedValue('hashed-token'), })); jest.mock('~/cache/getLogStores', () => @@ -80,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], @@ -104,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); }), })); @@ -145,6 +216,17 @@ describe('setupOpenId', () => { beforeEach(async () => { // Clear previous mock calls and reset implementations jest.clearAllMocks(); + isEnabled.mockImplementation(jest.requireActual('@librechat/api').isEnabled); + require('~/cache/getLogStores').mockImplementation(() => ({ + get: jest.fn(), + set: jest.fn(), + })); + getOpenIdProxyDispatcher.mockReturnValue(undefined); + require('openid-client').genericGrantRequest.mockReset(); + require('openid-client').genericGrantRequest.mockResolvedValue({ + access_token: 'exchanged_graph_token', + expires_in: 3600, + }); // Reset environment variables needed by the strategy process.env.OPENID_ISSUER = 'https://fake-issuer.com'; @@ -162,8 +244,19 @@ 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; + delete process.env.OPENID_GENERATE_NONCE; + delete process.env.OPENID_ROLE_SYNC_ENABLED; + delete process.env.OPENID_ROLE_SYNC_API_ENABLED; + delete process.env.OPENID_ROLE_SYNC_SOURCE; + delete process.env.OPENID_ROLE_SYNC_CLAIM; + delete process.env.OPENID_ROLE_SYNC_ROLE_PRIORITY; + delete process.env.OPENID_ROLE_SYNC_FALLBACK_ROLE; + delete process.env.OPENID_ON_BEHALF_FLOW_FOR_USERINFO_REQUIRED; + delete process.env.OPENID_ON_BEHALF_FLOW_USERINFO_SCOPE; // Default jwtDecode mock returns a token that includes the required role. jwtDecode.mockReturnValue({ @@ -180,14 +273,11 @@ describe('setupOpenId', () => { updateUser.mockImplementation(async (id, userData) => { return { _id: id, ...userData }; }); + findRolesByNames.mockImplementation(async (roleNames) => + roleNames.map((roleName) => ({ name: roleName })), + ); - // For image download, simulate a successful response - const fakeBuffer = Buffer.from('fake image'); - const fakeResponse = { - ok: true, - buffer: jest.fn().mockResolvedValue(fakeBuffer), - }; - fetch.mockResolvedValue(fakeResponse); + resizeAvatar.mockResolvedValue(Buffer.from('safe avatar')); // Call the setup function and capture the verify callback for the regular 'openid' strategy // (not 'openidAdmin' which requires existing users) @@ -195,6 +285,126 @@ describe('setupOpenId', () => { verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); }); + describe('clientMetadata construction in setupOpenId', () => { + let openidClient; + + beforeEach(() => { + openidClient = require('openid-client'); + openidClient.discovery.mockClear(); + }); + + it('sets token_endpoint_auth_method to none for PKCE without a client secret', async () => { + process.env.OPENID_USE_PKCE = 'true'; + delete process.env.OPENID_CLIENT_SECRET; + + await setupOpenId(); + + const [, , metadata] = openidClient.discovery.mock.calls.at(-1); + expect(metadata.token_endpoint_auth_method).toBe('none'); + expect(metadata.client_secret).toBeUndefined(); + }); + + it('leaves token_endpoint_auth_method unset for secret-based clients without nonce', async () => { + process.env.OPENID_USE_PKCE = 'false'; + process.env.OPENID_CLIENT_SECRET = 'my-secret'; + + await setupOpenId(); + + const [, , metadata] = openidClient.discovery.mock.calls.at(-1); + expect(metadata.client_secret).toBe('my-secret'); + expect(metadata.token_endpoint_auth_method).toBeUndefined(); + }); + + it('sets client_secret and client_secret_post when nonce generation is enabled', async () => { + process.env.OPENID_USE_PKCE = 'false'; + process.env.OPENID_GENERATE_NONCE = 'true'; + process.env.OPENID_CLIENT_SECRET = 'my-secret'; + + await setupOpenId(); + + const [, , metadata] = openidClient.discovery.mock.calls.at(-1); + expect(metadata.client_secret).toBe('my-secret'); + expect(metadata.token_endpoint_auth_method).toBe('client_secret_post'); + }); + + it('treats whitespace-only secret as absent', async () => { + process.env.OPENID_USE_PKCE = 'true'; + process.env.OPENID_CLIENT_SECRET = ' '; + + await setupOpenId(); + + const [, , metadata] = openidClient.discovery.mock.calls.at(-1); + expect(metadata.client_secret).toBeUndefined(); + expect(metadata.token_endpoint_auth_method).toBe('none'); + }); + + it('does not force an auth method when PKCE and a client secret are both configured without nonce', async () => { + process.env.OPENID_USE_PKCE = 'true'; + process.env.OPENID_CLIENT_SECRET = 'my-secret'; + + await setupOpenId(); + + const [, , metadata] = openidClient.discovery.mock.calls.at(-1); + 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 () => { // Arrange – our userinfo already has preferred_username 'testusername' const userinfo = tokenset.claims(); @@ -503,6 +713,72 @@ describe('setupOpenId', () => { expect(createUser).toHaveBeenCalled(); }); + it('should allow login when required role is found in userinfo claims', async () => { + process.env.OPENID_REQUIRED_ROLE = 'requiredRole'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles'; + process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'userinfo'; + + // The role is intentionally absent from the id_token and only present in + // the userinfo response — exercises the userinfo branch of the switch. + jwtDecode.mockReturnValue({}); + require('openid-client').fetchUserInfo.mockResolvedValue({ + roles: ['requiredRole'], + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + const { user } = await validate(tokenset); + + expect(user).toBeTruthy(); + expect(user.email).toBe(tokenset.claims().email); + }); + + it('should reject login when required role is missing from userinfo claims', async () => { + const { logger } = require('@librechat/data-schemas'); + process.env.OPENID_REQUIRED_ROLE = 'requiredRole'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles'; + process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'userinfo'; + + jwtDecode.mockReturnValue({}); + require('openid-client').fetchUserInfo.mockResolvedValue({ + other_claim: 'value', + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + const { user, details } = await validate(tokenset); + + expect(user).toBe(false); + expect(details.message).toBe('You must have "requiredRole" role to log in.'); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining("Key 'roles' not found in userinfo token!"), + ); + }); + + it('should reject login with invalid required role token kind', async () => { + const { logger } = require('@librechat/data-schemas'); + process.env.OPENID_REQUIRED_ROLE = 'requiredRole'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'roles'; + process.env.OPENID_REQUIRED_ROLE_TOKEN_KIND = 'invalid'; + + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + }); + + await setupOpenId(); + verifyCallback = require('openid-client/passport').__getVerifyCallbackByName('openid'); + + await expect(validate(tokenset)).rejects.toThrow('Invalid required role token kind'); + + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining( + "Invalid required role token kind: invalid. Must be one of 'access', 'id', or 'userinfo'", + ), + ); + }); + describe('group overage and groups handling', () => { it.each([ ['groups array contains required group', ['group-required', 'other-group'], true, undefined], @@ -790,6 +1066,10 @@ describe('setupOpenId', () => { }); describe('OBO token exchange for overage', () => { + beforeEach(() => { + delete process.env.OPENID_ADMIN_ROLE; + }); + it('exchanges access token via OBO before calling Graph API', async () => { const openidClient = require('openid-client'); process.env.OPENID_REQUIRED_ROLE = 'group-required'; @@ -1096,7 +1376,7 @@ describe('setupOpenId', () => { }); }); - it('should attempt to download and save the avatar if picture is provided', async () => { + it('should process and save the avatar through the shared avatar path if picture is provided', async () => { const { getStrategyFunctions } = require('~/server/services/Files/strategies'); // Act @@ -1106,8 +1386,11 @@ describe('setupOpenId', () => { const { saveBuffer } = strategyResult.value; const [saveParams] = saveBuffer.mock.calls[0]; - // Assert – verify that download was attempted and the avatar field was set via updateUser - expect(fetch).toHaveBeenCalled(); + expect(resizeAvatar).toHaveBeenCalledWith({ + userId: 'newUserId', + input: 'https://example.com/avatar.png', + }); + expect(fetch).not.toHaveBeenCalled(); expect(saveParams).toEqual( expect.objectContaining({ fileName: 'hashed-token.png', @@ -1120,9 +1403,47 @@ describe('setupOpenId', () => { expect(user.avatar).toBe('/fake/path/to/avatar.png'); }); + it('uses only the shared avatar processor for OpenID picture URLs', async () => { + await validate(tokenset); + + expect(resizeAvatar).toHaveBeenCalledWith({ + userId: 'newUserId', + input: 'https://example.com/avatar.png', + }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('adds auth headers for configured OpenID avatar origins', async () => { + process.env.OPENID_AVATAR_AUTHORIZED_ORIGINS = 'https://example.com'; + + await validate(tokenset); + + expect(resizeAvatar).toHaveBeenCalledWith({ + userId: 'newUserId', + input: 'https://example.com/avatar.png', + fetchOptions: { + headers: { + Authorization: 'Bearer fake_access_token', + }, + }, + }); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('continues login when shared avatar processing rejects the picture URL', async () => { + const { getStrategyFunctions } = require('~/server/services/Files/strategies'); + resizeAvatar.mockRejectedValueOnce(new Error('avatar processing failed')); + + const { user } = await validate(tokenset); + + expect(user).toBeTruthy(); + expect(user.avatar).toBeUndefined(); + expect(getStrategyFunctions).not.toHaveBeenCalled(); + }); + it('should save CloudFront IdP avatars under the shared avatar prefix', async () => { const { getStrategyFunctions } = require('~/server/services/Files/strategies'); - getAppConfig.mockResolvedValueOnce({ fileStrategy: FileSources.cloudfront }); + getAppConfig.mockResolvedValueOnce({ fileStrategy: mockCloudfrontFileSource }); const { user } = await validate(tokenset); const strategyResult = @@ -1130,7 +1451,12 @@ describe('setupOpenId', () => { const { saveBuffer } = strategyResult.value; const [saveParams] = saveBuffer.mock.calls[0]; - expect(getStrategyFunctions).toHaveBeenLastCalledWith(FileSources.cloudfront); + expect(getStrategyFunctions).toHaveBeenLastCalledWith(mockCloudfrontFileSource); + expect(resizeAvatar).toHaveBeenCalledWith({ + userId: 'newUserId', + input: 'https://example.com/avatar.png', + }); + expect(fetch).not.toHaveBeenCalled(); expect(saveParams).toEqual( expect.objectContaining({ basePath: 'avatars', @@ -1151,6 +1477,7 @@ describe('setupOpenId', () => { // Assert – fetch should not be called and avatar should remain undefined or empty expect(fetch).not.toHaveBeenCalled(); + expect(resizeAvatar).not.toHaveBeenCalled(); // Depending on your implementation, user.avatar may be undefined or an empty string. }); @@ -1303,6 +1630,296 @@ describe('setupOpenId', () => { expect(user.role).toBeUndefined(); }); + describe('OpenID role sync', () => { + beforeEach(() => { + process.env.OPENID_ROLE_SYNC_ENABLED = 'true'; + process.env.OPENID_ROLE_SYNC_SOURCE = 'id'; + process.env.OPENID_ROLE_SYNC_CLAIM = 'roles'; + process.env.OPENID_ROLE_SYNC_ROLE_PRIORITY = 'STANDARD-USER,BASIC-USER'; + process.env.OPENID_ROLE_SYNC_FALLBACK_ROLE = 'USER'; + }); + + it('selects the highest configured matching role from the OpenID token', async () => { + jwtDecode.mockReturnValue({ + roles: ['requiredRole', 'BASIC-USER', 'STANDARD-USER'], + permissions: ['not-admin'], + }); + + const { user } = await validate(tokenset); + + expect(user.role).toBe('STANDARD-USER'); + expect(updateUser).toHaveBeenCalledWith( + 'newUserId', + expect.objectContaining({ role: 'STANDARD-USER' }), + ); + }); + + it('does not run when disabled', async () => { + delete process.env.OPENID_ROLE_SYNC_ENABLED; + jwtDecode.mockReturnValue({ + roles: ['requiredRole', 'STANDARD-USER'], + permissions: ['not-admin'], + }); + + const { user } = await validate(tokenset); + + expect(user.role).toBeUndefined(); + expect(findRolesByNames).not.toHaveBeenCalled(); + }); + + it('leaves ADMIN authoritative when OPENID_ADMIN_ROLE grants admin', async () => { + jwtDecode.mockReturnValue({ + roles: ['requiredRole', 'STANDARD-USER'], + permissions: ['admin'], + }); + + const { user } = await validate(tokenset); + + expect(user.role).toBe('ADMIN'); + }); + + it('preserves an existing ADMIN role when admin is manually assigned', async () => { + delete process.env.OPENID_ADMIN_ROLE; + delete process.env.OPENID_ADMIN_ROLE_PARAMETER_PATH; + delete process.env.OPENID_ADMIN_ROLE_TOKEN_KIND; + const existingAdminUser = { + _id: 'existingAdminId', + provider: 'openid', + email: tokenset.claims().email, + openidId: tokenset.claims().sub, + username: 'adminuser', + name: 'Admin User', + role: 'ADMIN', + }; + + findUser.mockImplementation(async (query) => { + if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) { + return existingAdminUser; + } + return null; + }); + jwtDecode.mockReturnValue({ + roles: ['requiredRole', 'STANDARD-USER'], + permissions: ['not-admin'], + }); + + const { user } = await validate(tokenset); + + expect(user.role).toBe('ADMIN'); + expect(findRolesByNames).not.toHaveBeenCalled(); + }); + + it('uses fallback when a valid role claim has no configured role match', async () => { + jwtDecode.mockReturnValue({ + roles: ['requiredRole', 'external-role'], + permissions: ['not-admin'], + }); + + const { user } = await validate(tokenset); + + expect(user.role).toBe('USER'); + }); + + it('uses fallback when the role claim is present but empty', async () => { + // The required-role gate reads the same `roles` claim this test empties, so + // disable it to model an IdP that authenticates the user yet emits no roles. + delete process.env.OPENID_REQUIRED_ROLE; + jwtDecode.mockReturnValue({ + roles: '', + permissions: ['not-admin'], + }); + + const { user } = await validate(tokenset); + + expect(user.role).toBe('USER'); + expect(updateUser).toHaveBeenCalledWith( + 'newUserId', + expect.objectContaining({ role: 'USER' }), + ); + }); + + it('applies fallback when the role claim is absent from the token', async () => { + // Required-role gate reads the same `roles` claim; disable it to model an IdP + // that authenticates the user but stops emitting the role claim entirely. + delete process.env.OPENID_REQUIRED_ROLE; + jwtDecode.mockReturnValue({ + permissions: ['not-admin'], + }); + + const { user } = await validate(tokenset); + + expect(user.role).toBe('USER'); + expect(updateUser).toHaveBeenCalledWith( + 'newUserId', + expect.objectContaining({ role: 'USER' }), + ); + }); + + it('rejects login when configured sync roles do not exist', async () => { + findRolesByNames.mockImplementation(async (roleNames) => + roleNames + .filter((roleName) => roleName !== 'STANDARD-USER') + .map((roleName) => ({ name: roleName })), + ); + jwtDecode.mockReturnValue({ + roles: ['requiredRole', 'STANDARD-USER'], + permissions: ['not-admin'], + }); + + await expect(validate(tokenset)).rejects.toThrow( + 'OpenID role sync configured roles do not exist: STANDARD-USER', + ); + }); + + it('can assign a non-admin role after the existing admin demotion path runs', async () => { + const existingAdminUser = { + _id: 'existingAdminId', + provider: 'openid', + email: tokenset.claims().email, + openidId: tokenset.claims().sub, + username: 'adminuser', + name: 'Admin User', + role: 'ADMIN', + }; + + findUser.mockImplementation(async (query) => { + if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) { + return existingAdminUser; + } + return null; + }); + jwtDecode.mockReturnValue({ + roles: ['requiredRole', 'STANDARD-USER'], + permissions: ['not-admin'], + }); + + const { user } = await validate(tokenset); + + expect(user.role).toBe('STANDARD-USER'); + expect(updateUser).toHaveBeenCalledWith( + existingAdminUser._id, + expect.objectContaining({ role: 'STANDARD-USER' }), + ); + }); + + it('wraps role lookup in tenant context for tenant users', async () => { + const existingUser = { + _id: 'existingTenantUserId', + provider: 'openid', + email: tokenset.claims().email, + openidId: tokenset.claims().sub, + username: 'tenantuser', + name: 'Tenant User', + tenantId: 'tenant-a', + role: 'USER', + }; + const { tenantStorage } = require('@librechat/data-schemas'); + + findUser.mockImplementation(async (query) => { + if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) { + return existingUser; + } + return null; + }); + jwtDecode.mockReturnValue({ + roles: ['requiredRole', 'BASIC-USER'], + permissions: ['not-admin'], + }); + + const { user } = await validate(tokenset); + + expect(user.role).toBe('BASIC-USER'); + expect(tenantStorage.run).toHaveBeenCalledWith( + { tenantId: 'tenant-a' }, + expect.any(Function), + ); + }); + + it('re-enforces tenant login policy after role sync changes the role', async () => { + const existingUser = { + _id: 'existingTenantUserId', + provider: 'openid', + email: tokenset.claims().email, + openidId: tokenset.claims().sub, + username: 'tenantuser', + name: 'Tenant User', + tenantId: 'tenant-a', + role: 'USER', + }; + const { isEmailDomainAllowed } = require('@librechat/api'); + + findUser.mockImplementation(async (query) => { + if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) { + return existingUser; + } + return null; + }); + jwtDecode.mockReturnValue({ + roles: ['requiredRole', 'BASIC-USER'], + permissions: ['not-admin'], + }); + // Pre-sync domain check passes; the post-sync re-resolved config rejects the domain. + isEmailDomainAllowed.mockReturnValueOnce(true).mockReturnValueOnce(false); + resolveAppConfigForUser.mockResolvedValue({ + registration: { allowedDomains: ['restricted.com'] }, + }); + + const { user, details } = await validate(tokenset); + + expect(user).toBe(false); + expect(details).toEqual({ message: 'Email domain not allowed' }); + }); + + it('reuses required-role overage groups for role sync', async () => { + process.env.OPENID_REQUIRED_ROLE = 'group-required'; + process.env.OPENID_REQUIRED_ROLE_PARAMETER_PATH = 'groups'; + process.env.OPENID_ROLE_SYNC_CLAIM = 'groups'; + + jwtDecode.mockReturnValue({ + hasgroups: true, + permissions: ['not-admin'], + }); + undici.fetch.mockResolvedValue({ + ok: true, + json: async () => ({ value: ['group-required', 'STANDARD-USER'] }), + }); + + const { user } = await validate(tokenset); + + expect(user.role).toBe('STANDARD-USER'); + expect(undici.fetch).toHaveBeenCalledTimes(1); + }); + + it('leaves the role unchanged when role-sync group overage cannot be resolved', async () => { + process.env.OPENID_ROLE_SYNC_CLAIM = 'groups'; + const existingUser = { + _id: 'existingUserId', + provider: 'openid', + email: tokenset.claims().email, + openidId: tokenset.claims().sub, + username: 'existinguser', + name: 'Existing User', + role: 'BASIC-USER', + }; + + findUser.mockImplementation(async (query) => { + if (query.openidId === tokenset.claims().sub || query.email === tokenset.claims().email) { + return existingUser; + } + return null; + }); + jwtDecode.mockReturnValue({ + roles: ['requiredRole'], + hasgroups: true, + permissions: ['not-admin'], + }); + + const { user } = await validate({ ...tokenset, access_token: undefined }); + + expect(user.role).toBe('BASIC-USER'); + }); + }); + it('should demote existing admin user when admin role is removed from token', async () => { // Arrange – simulate an existing user who is currently an admin const existingAdminUser = { @@ -1916,3 +2533,61 @@ describe('setupOpenId', () => { }); }); }); + +describe('getRoleSource', () => { + const { getRoleSource } = require('./openidStrategy'); + const { logger } = require('@librechat/data-schemas'); + + const accessClaims = { roles: ['from-access'] }; + const idClaims = { roles: ['from-id'] }; + const userinfo = { roles: ['from-userinfo'] }; + const tokenset = { access_token: 'access.jwt', id_token: 'id.jwt' }; + + beforeEach(() => { + jest.clearAllMocks(); + jwtDecode.mockImplementation((token) => { + if (token === 'access.jwt') return accessClaims; + if (token === 'id.jwt') return idClaims; + return {}; + }); + }); + + it.each([ + ['access', accessClaims], + ['id', idClaims], + ['userinfo', userinfo], + ])('returns the expected source object for kind=%s', (kind, expected) => { + expect(getRoleSource(kind, 'required role', tokenset, userinfo)).toEqual(expected); + }); + + it.each([ + ['undefined', undefined], + ['empty string', ''], + ['unknown kind', 'bogus'], + ])('throws and logs for invalid kind: %s', (_name, kind) => { + expect(() => getRoleSource(kind, 'required role', tokenset, userinfo)).toThrow( + 'Invalid required role token kind', + ); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining(`Invalid required role token kind: ${kind}`), + ); + }); + + it('uses the provided label in the error message and thrown error', () => { + expect(() => getRoleSource('bogus', 'admin role', tokenset, userinfo)).toThrow( + 'Invalid admin role token kind', + ); + expect(logger.error).toHaveBeenCalledWith( + expect.stringContaining('Invalid admin role token kind: bogus'), + ); + }); + + it('propagates jwtDecode errors when the requested token is missing', () => { + jwtDecode.mockImplementation(() => { + throw new Error('Invalid token specified'); + }); + expect(() => getRoleSource('access', 'required role', {}, userinfo)).toThrow( + 'Invalid token specified', + ); + }); +}); diff --git a/api/strategies/samlStrategy.js b/api/strategies/samlStrategy.js index b21a4a482b9..cab43044b98 100644 --- a/api/strategies/samlStrategy.js +++ b/api/strategies/samlStrategy.js @@ -1,6 +1,5 @@ const fs = require('fs'); const path = require('path'); -const fetch = require('node-fetch'); const passport = require('passport'); const { ErrorTypes } = require('librechat-data-provider'); const { hashToken, logger } = require('@librechat/data-schemas'); @@ -13,6 +12,7 @@ const { resolveAppConfigForUser, } = require('@librechat/api'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); +const { resizeAvatar } = require('~/server/services/Files/images/avatar'); const { findUser, createUser, updateUser } = require('~/models'); const { getAppConfig } = require('~/server/services/Config'); const paths = require('~/config/paths'); @@ -110,21 +110,17 @@ function getPicture(profile) { return getSamlClaim(profile, 'SAML_PICTURE_CLAIM', 'picture'); } -/** - * Downloads an image from a URL using an access token. - * @param {string} url - * @returns {Promise} - */ -const downloadImage = async (url) => { +const resizeIdentityProviderAvatar = async (url, userId) => { + if (!url) { + return null; + } + try { - const response = await fetch(url); - if (response.ok) { - return await response.buffer(); - } else { - throw new Error(`${response.statusText} (HTTP ${response.status})`); - } + return await resizeAvatar({ userId, input: url }); } catch (error) { - logger.error(`[samlStrategy] Error downloading image at URL "${url}": ${error}`); + logger.error( + `[samlStrategy] resizeIdentityProviderAvatar: Error processing avatar at URL "${url}": ${error}`, + ); return null; } }; @@ -264,7 +260,8 @@ function createSamlCallback(existingUsersOnly = false) { const picture = getPicture(profile); if (picture && !user.avatar?.includes('manual=true')) { - const imageBuffer = await downloadImage(profile.picture); + const userId = user._id.toString(); + const imageBuffer = await resizeIdentityProviderAvatar(picture, userId); if (imageBuffer) { let fileName; if (crypto) { @@ -278,7 +275,7 @@ function createSamlCallback(existingUsersOnly = false) { const imagePath = await saveBuffer( getAvatarSaveParams(fileStrategy, { fileName, - userId: user._id.toString(), + userId, buffer: imageBuffer, tenantId: user.tenantId, }), diff --git a/api/strategies/samlStrategy.spec.js b/api/strategies/samlStrategy.spec.js index dbf003e58c4..301fee3a875 100644 --- a/api/strategies/samlStrategy.spec.js +++ b/api/strategies/samlStrategy.spec.js @@ -53,6 +53,9 @@ jest.mock('~/server/services/Files/strategies', () => ({ saveBuffer: jest.fn().mockResolvedValue('/fake/path/to/avatar.png'), })), })); +jest.mock('~/server/services/Files/images/avatar', () => ({ + resizeAvatar: jest.fn().mockResolvedValue(Buffer.from('safe avatar')), +})); jest.mock('~/config/paths', () => ({ root: '/fake/root/path', })); @@ -64,6 +67,7 @@ const { Strategy: SamlStrategy } = require('@node-saml/passport-saml'); const { FileSources } = require('librechat-data-provider'); const { findUser } = require('~/models'); const { resolveAppConfigForUser } = require('@librechat/api'); +const { resizeAvatar } = require('~/server/services/Files/images/avatar'); const { getAppConfig } = require('~/server/services/Config'); const { setupSaml, getCertificateContent } = require('./samlStrategy'); @@ -288,12 +292,7 @@ u7wlOSk+oFzDIO/UILIA delete process.env.SAML_PICTURE_CLAIM; delete process.env.SAML_NAME_CLAIM; - // Simulate image download - const fakeBuffer = Buffer.from('fake image'); - fetch.mockResolvedValue({ - ok: true, - buffer: jest.fn().mockResolvedValue(fakeBuffer), - }); + resizeAvatar.mockResolvedValue(Buffer.from('safe avatar')); await setupSaml(); }); @@ -447,7 +446,7 @@ u7wlOSk+oFzDIO/UILIA expect(result.details.message).toBe(require('librechat-data-provider').ErrorTypes.AUTH_FAILED); }); - it('should attempt to download and save the avatar if picture is provided', async () => { + it('should process and save the avatar through the shared avatar path if picture is provided', async () => { const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const profile = { ...baseProfile }; @@ -457,7 +456,11 @@ u7wlOSk+oFzDIO/UILIA const { saveBuffer } = strategyResult.value; const [saveParams] = saveBuffer.mock.calls[0]; - expect(fetch).toHaveBeenCalled(); + expect(resizeAvatar).toHaveBeenCalledWith({ + userId: 'mock-user-id', + input: 'https://example.com/avatar.png', + }); + expect(fetch).not.toHaveBeenCalled(); expect(saveParams).toEqual( expect.objectContaining({ fileName: 'hashed-token.png', @@ -469,6 +472,38 @@ u7wlOSk+oFzDIO/UILIA expect(user.avatar).toBe('/fake/path/to/avatar.png'); }); + it('continues login when shared avatar processing rejects the picture URL', async () => { + const { getStrategyFunctions } = require('~/server/services/Files/strategies'); + const profile = { ...baseProfile }; + resizeAvatar.mockRejectedValueOnce(new Error('avatar processing failed')); + + const { user } = await validate(profile); + + expect(user).toBeTruthy(); + expect(user.avatar).toBeUndefined(); + expect(getStrategyFunctions).not.toHaveBeenCalled(); + }); + + it('uses the configured SAML picture claim for shared avatar processing', async () => { + process.env.SAML_PICTURE_CLAIM = 'avatar_url'; + verifyCallback = null; + await setupSaml(); + + const profile = { + ...baseProfile, + picture: 'https://example.com/ignored.png', + avatar_url: 'https://idp.example.com/custom-avatar.png', + }; + + await validate(profile); + + expect(resizeAvatar).toHaveBeenCalledWith({ + userId: 'mock-user-id', + input: 'https://idp.example.com/custom-avatar.png', + }); + expect(fetch).not.toHaveBeenCalled(); + }); + it('should save CloudFront SAML avatars under the shared avatar prefix', async () => { const { getStrategyFunctions } = require('~/server/services/Files/strategies'); getAppConfig.mockResolvedValueOnce({ fileStrategies: { avatar: FileSources.cloudfront } }); @@ -481,6 +516,11 @@ u7wlOSk+oFzDIO/UILIA const [saveParams] = saveBuffer.mock.calls[0]; expect(getStrategyFunctions).toHaveBeenLastCalledWith(FileSources.cloudfront); + expect(resizeAvatar).toHaveBeenCalledWith({ + userId: 'mock-user-id', + input: 'https://example.com/avatar.png', + }); + expect(fetch).not.toHaveBeenCalled(); expect(saveParams).toEqual( expect.objectContaining({ basePath: 'avatars', @@ -498,6 +538,7 @@ u7wlOSk+oFzDIO/UILIA await validate(profile); expect(fetch).not.toHaveBeenCalled(); + expect(resizeAvatar).not.toHaveBeenCalled(); }); it('should pass the found user to resolveAppConfigForUser', async () => { diff --git a/api/test/__mocks__/logger.js b/api/test/__mocks__/logger.js index 62f9bee93a6..699a94883f1 100644 --- a/api/test/__mocks__/logger.js +++ b/api/test/__mocks__/logger.js @@ -1,5 +1,13 @@ jest.mock('winston', () => { - const mockFormatFunction = jest.fn((fn) => fn); + // Real `winston.format(fn)` returns a Format constructor whose instances + // expose a `.transform(info, opts)` method that winston's pipeline calls. + // The previous mock `(fn) => fn` collapsed this — `parsers.redactFormat()` + // (called at @librechat/data-schemas dist module-load) ended up invoking + // the inner transform fn with no `info` argument, throwing on `info.level`. + // Returning a thunk that yields `{ transform: fn }` matches real winston's + // shape just enough that module-load completes cleanly; the inner fn is + // only ever invoked by winston's pipeline (never at load time). + const mockFormatFunction = jest.fn((fn) => () => ({ transform: fn })); mockFormatFunction.colorize = jest.fn(); mockFormatFunction.combine = jest.fn(); @@ -50,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/test/app/clients/tools/util/fileSearch.test.js b/api/test/app/clients/tools/util/fileSearch.test.js index 782e48f720b..d9b5edb64cf 100644 --- a/api/test/app/clients/tools/util/fileSearch.test.js +++ b/api/test/app/clients/tools/util/fileSearch.test.js @@ -230,3 +230,63 @@ describe('fileSearch.js - tuple return validation', () => { }); }); }); + +describe('entity_id scoping by file origin', () => { + const ORIGINAL_RAG_API_URL = process.env.RAG_API_URL; + + beforeEach(() => { + jest.clearAllMocks(); + process.env.RAG_API_URL = 'http://localhost:8000'; + generateShortLivedToken.mockReturnValue('mock-jwt-token'); + axios.post.mockResolvedValue({ data: [] }); + }); + + afterEach(() => { + if (ORIGINAL_RAG_API_URL === undefined) { + delete process.env.RAG_API_URL; + } else { + process.env.RAG_API_URL = ORIGINAL_RAG_API_URL; + } + }); + + function bodiesSent() { + return axios.post.mock.calls + .filter(([url]) => String(url).endsWith('/query')) + .map(([, body]) => body); + } + + it('sends entity_id only for agent knowledge-base files', async () => { + const tool = await createFileSearchTool({ + userId: 'user1', + entity_id: 'agent_123', + files: [ + { file_id: 'kb-1', filename: 'kb.pdf', fromAgent: true }, + { file_id: 'user-1', filename: 'attachment.txt', fromAgent: false }, + ], + }); + await tool.func({ query: 'q' }); + + const bodies = bodiesSent(); + expect(bodies.find((b) => b.file_id === 'kb-1').entity_id).toBe('agent_123'); + expect(bodies.find((b) => b.file_id === 'user-1').entity_id).toBeUndefined(); + }); + + it('omits entity_id when fromAgent is not set (safe default)', async () => { + const tool = await createFileSearchTool({ + userId: 'user1', + entity_id: 'agent_123', + files: [{ file_id: 'legacy-1', filename: 'legacy.pdf' }], + }); + await tool.func({ query: 'q' }); + expect(bodiesSent()[0].entity_id).toBeUndefined(); + }); + + it('sends no entity_id when none is provided', async () => { + const tool = await createFileSearchTool({ + userId: 'user1', + files: [{ file_id: 'f1', filename: 'a.txt', fromAgent: true }], + }); + await tool.func({ query: 'q' }); + expect(bodiesSent()[0].entity_id).toBeUndefined(); + }); +}); diff --git a/api/utils/logger.js b/api/utils/logger.js index 542a0a53275..36f23c25cd9 100644 --- a/api/utils/logger.js +++ b/api/utils/logger.js @@ -1,12 +1,18 @@ const winston = require('winston'); +const useFileLogging = + typeof process.env.LOG_TO_FILE !== 'string' || process.env.LOG_TO_FILE.toLowerCase() !== 'false'; + +const transports = [new winston.transports.Console()]; + +if (useFileLogging) { + transports.push(new winston.transports.File({ filename: 'login-logs.log' })); +} + const logger = winston.createLogger({ level: 'info', format: winston.format.combine(winston.format.timestamp(), winston.format.json()), - transports: [ - new winston.transports.Console(), - new winston.transports.File({ filename: 'login-logs.log' }), - ], + transports, }); module.exports = logger; diff --git a/api/utils/tokens.spec.js b/api/utils/tokens.spec.js index 143d3bb2462..82c5a8b31fb 100644 --- a/api/utils/tokens.spec.js +++ b/api/utils/tokens.spec.js @@ -333,6 +333,9 @@ describe('getModelMaxTokens', () => { expect(getModelMaxTokens('gemini-3.1-pro-preview-customtools', EModelEndpoint.google)).toBe( maxTokensMap[EModelEndpoint.google]['gemini-3.1'], ); + expect(getModelMaxTokens('gemini-3.5-flash', EModelEndpoint.google)).toBe( + maxTokensMap[EModelEndpoint.google]['gemini-3.5-flash'], + ); expect(getModelMaxTokens('gemini-2.5-pro', EModelEndpoint.google)).toBe( maxTokensMap[EModelEndpoint.google]['gemini-2.5-pro'], ); @@ -353,6 +356,37 @@ describe('getModelMaxTokens', () => { ); }); + test('should return correct context tokens for Gemma models', () => { + expect(maxTokensMap[EModelEndpoint.google].gemma).toBe(32768); + expect(getModelMaxTokens('gemma', EModelEndpoint.google)).toBe( + maxTokensMap[EModelEndpoint.google].gemma, + ); + expect(getModelMaxTokens('gemma-2-9b-it', EModelEndpoint.google)).toBe( + maxTokensMap[EModelEndpoint.google]['gemma-2'], + ); + expect(getModelMaxTokens('gemma-3-27b-it', EModelEndpoint.google)).toBe( + maxTokensMap[EModelEndpoint.google]['gemma-3-27b'], + ); + expect(getModelMaxTokens('gemma4:latest', EModelEndpoint.google)).toBe( + maxTokensMap[EModelEndpoint.google].gemma4, + ); + expect(getModelMaxTokens('gemma4:e4b', EModelEndpoint.google)).toBe( + maxTokensMap[EModelEndpoint.google].gemma4, + ); + expect(getModelMaxTokens('Gemma4:31B', EModelEndpoint.custom)).toBe( + maxTokensMap[EModelEndpoint.custom]['gemma4:31b'], + ); + expect(getModelMaxTokens('ollama/gemma4:31b', EModelEndpoint.custom)).toBe( + maxTokensMap[EModelEndpoint.custom]['gemma4:31b'], + ); + expect(getModelMaxTokens('google/gemma-4-31B-it', EModelEndpoint.google)).toBe( + maxTokensMap[EModelEndpoint.google]['gemma-4-31b'], + ); + expect(getModelMaxTokens('google/gemma-4-26B-A4B-it', EModelEndpoint.google)).toBe( + maxTokensMap[EModelEndpoint.google]['gemma-4-26b-a4b'], + ); + }); + test('should return correct tokens for partial match - Cohere models', () => { expect(getModelMaxTokens('command', EModelEndpoint.custom)).toBe( maxTokensMap[EModelEndpoint.custom]['command'], @@ -1456,6 +1490,99 @@ describe('Claude Model Tests', () => { }); }); + it('should return correct context length for Claude Opus 4.8 (1M)', () => { + expect(getModelMaxTokens('claude-opus-4-8', EModelEndpoint.anthropic)).toBe( + maxTokensMap[EModelEndpoint.anthropic]['claude-opus-4-8'], + ); + expect(getModelMaxTokens('claude-opus-4-8')).toBe( + maxTokensMap[EModelEndpoint.anthropic]['claude-opus-4-8'], + ); + }); + + it('should return correct max output tokens for Claude Opus 4.8 (128K)', () => { + const { getModelMaxOutputTokens } = require('@librechat/api'); + expect(getModelMaxOutputTokens('claude-opus-4-8', EModelEndpoint.anthropic)).toBe( + maxOutputTokensMap[EModelEndpoint.anthropic]['claude-opus-4-8'], + ); + }); + + it('should match model names correctly for Claude Opus 4.8', () => { + const modelVariations = [ + 'claude-opus-4-8', + 'claude-opus-4-8-20260528', + 'claude-opus-4-8-latest', + 'anthropic/claude-opus-4-8', + 'claude-opus-4-8/anthropic', + 'claude-opus-4-8-preview', + ]; + + modelVariations.forEach((model) => { + expect(matchModelName(model, EModelEndpoint.anthropic)).toBe('claude-opus-4-8'); + }); + }); + + 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 1414f98a71c..ab6db72c2f4 100644 --- a/bun.lock +++ b/bun.lock @@ -37,7 +37,7 @@ }, "api": { "name": "@librechat/backend", - "version": "0.8.6-rc1", + "version": "0.8.7", "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-rc1", + "version": "0.8.7", "dependencies": { "@ariakit/react": "^0.4.15", "@ariakit/react-core": "^0.4.17", @@ -264,7 +264,7 @@ }, "packages/api": { "name": "@librechat/api", - "version": "1.7.30", + "version": "1.7.34", "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.59", + "version": "0.4.63", "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.502", + "version": "0.8.509", "dependencies": { "axios": "^1.13.5", "dayjs": "^1.11.13", @@ -470,7 +470,7 @@ }, "packages/data-schemas": { "name": "@librechat/data-schemas", - "version": "0.0.51", + "version": "0.0.56", "devDependencies": { "@rollup/plugin-alias": "^5.1.0", "@rollup/plugin-commonjs": "^29.0.0", diff --git a/client/index.html b/client/index.html index c94c3981b41..d302fb250a7 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 41f9df59f46..c017263847b 100644 --- a/client/jest.config.cjs +++ b/client/jest.config.cjs @@ -1,4 +1,4 @@ -/** v0.8.6-rc1 */ +/** v0.8.7 */ module.exports = { roots: ['/src'], testEnvironment: 'jsdom', diff --git a/client/nginx.conf b/client/nginx.conf index 906b3af128a..3df5d7b92d3 100644 --- a/client/nginx.conf +++ b/client/nginx.conf @@ -2,6 +2,12 @@ # generated 2024-01-21, Mozilla Guideline v5.7, nginx 1.24.0, OpenSSL 3.1.4, intermediate configuration # https://ssl-config.mozilla.org/#server=nginx&version=1.24.0&config=intermediate&openssl=3.1.4&guideline=5.7 +# Map the Upgrade header so the admin-panel proxy can pass WebSocket/SSE connections. +map $http_upgrade $connection_upgrade { + default upgrade; + '' close; +} + server { listen 80 default_server; listen [::]:80 default_server; @@ -38,6 +44,31 @@ server { # } } +# Admin panel (ClickHouse) served on the admin.localhost subdomain. +# Uses Docker's embedded DNS (127.0.0.11) with a variable upstream so nginx +# still starts when the admin-panel service is not running. +server { + listen 80; + listen [::]:80; + server_name admin.localhost; + + client_max_body_size 25M; + + resolver 127.0.0.11 valid=30s; + set $admin_panel_upstream http://admin-panel:3000; + + location / { + proxy_pass $admin_panel_upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + } +} + #server { # listen 443 ssl http2; # listen [::]:443 ssl http2; @@ -98,3 +129,31 @@ server { # proxy_set_header Host $host; # } #} + +# SSL variant for the admin panel subdomain (mirror of the admin.localhost block above). +# Over HTTPS you can also set ADMIN_PANEL_SESSION_COOKIE_SECURE=true on the admin-panel service. +#server { +# listen 443 ssl http2; +# listen [::]:443 ssl http2; + +# ssl_certificate /etc/nginx/ssl/nginx.crt; +# ssl_certificate_key /etc/nginx/ssl/nginx.key; + +# server_name admin.localhost; + +# client_max_body_size 25M; + +# resolver 127.0.0.11 valid=30s; +# set $admin_panel_upstream http://admin-panel:3000; + +# location / { +# proxy_pass $admin_panel_upstream; +# proxy_set_header Host $host; +# proxy_set_header X-Real-IP $remote_addr; +# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +# proxy_set_header X-Forwarded-Proto $scheme; +# proxy_http_version 1.1; +# proxy_set_header Upgrade $http_upgrade; +# proxy_set_header Connection $connection_upgrade; +# } +#} diff --git a/client/package.json b/client/package.json index 80b46c2b4cf..5128591cbce 100644 --- a/client/package.json +++ b/client/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/frontend", - "version": "v0.8.6-rc1", + "version": "v0.8.7", "description": "", "type": "module", "scripts": { @@ -29,12 +29,13 @@ }, "homepage": "https://librechat.ai", "dependencies": { - "@ariakit/react": "^0.4.15", - "@ariakit/react-core": "^0.4.17", + "@ariakit/react": "^0.4.29", + "@ariakit/react-core": "^0.4.26", "@codesandbox/sandpack-react": "^2.19.10", "@dicebear/collection": "^9.4.1", "@dicebear/core": "^9.4.1", "@headlessui/react": "^2.1.2", + "@hyperdx/browser": "^0.24.0", "@librechat/client": "*", "@marsidev/react-turnstile": "^1.1.0", "@mcp-ui/client": "^5.7.0", @@ -69,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", @@ -81,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", @@ -110,7 +118,7 @@ "remark-gfm": "^4.0.0", "remark-math": "^6.0.0", "remark-supersub": "^1.0.0", - "sse.js": "^2.5.0", + "sse.js": "^2.8.0", "swr": "^2.3.8", "tailwind-merge": "^1.9.1", "tailwindcss-animate": "^1.0.5", @@ -132,10 +140,10 @@ "@types/jest": "^29.5.14", "@types/js-cookie": "^3.0.6", "@types/lodash": "^4.17.15", - "@types/node": "^20.19.35", + "@types/node": "^24.12.4", "@types/react": "^18.2.11", "@types/react-dom": "^18.2.4", - "@vitejs/plugin-react": "^5.1.4", + "@vitejs/plugin-react": "^6.0.2", "autoprefixer": "^10.4.20", "babel-plugin-replace-ts-export-assignment": "^0.0.2", "babel-plugin-root-import": "^6.6.0", @@ -148,15 +156,15 @@ "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", - "vite": "^7.3.1", - "vite-plugin-compression2": "^2.2.1", - "vite-plugin-node-polyfills": "^0.25.0", - "vite-plugin-pwa": "^1.2.0" + "typescript": "^5.9.3", + "vite": "^8.0.16", + "vite-plugin-compression2": "^2.5.3", + "vite-plugin-node-polyfills": "^0.28.0", + "vite-plugin-pwa": "^1.3.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/ArtifactsContext.tsx b/client/src/Providers/ArtifactsContext.tsx index fd67d5af948..2eb26e04b07 100644 --- a/client/src/Providers/ArtifactsContext.tsx +++ b/client/src/Providers/ArtifactsContext.tsx @@ -1,6 +1,6 @@ import React, { createContext, useContext, useMemo } from 'react'; import { useRecoilValue } from 'recoil'; -import type { TMessage } from 'librechat-data-provider'; +import { useLatestMessage } from '~/hooks/Messages/useLatestMessage'; import { getLatestText } from '~/utils'; import store from '~/store'; @@ -20,16 +20,12 @@ interface ArtifactsProviderProps { export function ArtifactsProvider({ children, value }: ArtifactsProviderProps) { const isSubmitting = useRecoilValue(store.isSubmittingFamily(0)); - const latestMessage = useRecoilValue(store.latestMessageFamily(0)); + const latestMessage = useLatestMessage(0); const conversationId = useRecoilValue(store.conversationIdByIndex(0)); const chatLatestMessageText = useMemo(() => { - return getLatestText({ - text: latestMessage?.text ?? null, - content: latestMessage?.content ?? null, - messageId: latestMessage?.messageId ?? null, - } as TMessage); - }, [latestMessage?.messageId, latestMessage?.text, latestMessage?.content]); + return getLatestText(latestMessage); + }, [latestMessage]); const defaultContextValue = useMemo( () => ({ diff --git a/client/src/Providers/BadgeRowContext.tsx b/client/src/Providers/BadgeRowContext.tsx index 025532f0c6a..448af4339f5 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, @@ -170,7 +170,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/Providers/MessagesViewContext.tsx b/client/src/Providers/MessagesViewContext.tsx index c44972918c3..1d9172c95be 100644 --- a/client/src/Providers/MessagesViewContext.tsx +++ b/client/src/Providers/MessagesViewContext.tsx @@ -20,7 +20,6 @@ interface MessagesViewContextValue { index: ReturnType['index']; latestMessageId: ReturnType['latestMessageId']; latestMessageDepth: ReturnType['latestMessageDepth']; - setLatestMessage: ReturnType['setLatestMessage']; getMessages: ReturnType['getMessages']; setMessages: ReturnType['setMessages']; } @@ -44,7 +43,6 @@ export function MessagesViewProvider({ children }: { children: React.ReactNode } latestMessageDepth, setAbortScroll, handleContinue, - setLatestMessage, abortScroll, getMessages, setMessages, @@ -87,9 +85,8 @@ export function MessagesViewProvider({ children }: { children: React.ReactNode } index, latestMessageId, latestMessageDepth, - setLatestMessage, }), - [index, latestMessageId, latestMessageDepth, setLatestMessage], + [index, latestMessageId, latestMessageDepth], ); /** Combine all values into final context value */ @@ -191,9 +188,9 @@ export function useOptionalMessagesConversation() { /** Hook for components that only need message state */ export function useMessagesState() { - const { index, latestMessageId, latestMessageDepth, setLatestMessage } = useMessagesViewContext(); + const { index, latestMessageId, latestMessageDepth } = useMessagesViewContext(); return useMemo( - () => ({ index, latestMessageId, latestMessageDepth, setLatestMessage }), - [index, latestMessageId, latestMessageDepth, setLatestMessage], + () => ({ index, latestMessageId, latestMessageDepth }), + [index, latestMessageId, latestMessageDepth], ); } diff --git a/client/src/Providers/ShareContext.tsx b/client/src/Providers/ShareContext.tsx index fc5a1db00aa..74caf07cb98 100644 --- a/client/src/Providers/ShareContext.tsx +++ b/client/src/Providers/ShareContext.tsx @@ -1,5 +1,5 @@ import { createContext, useContext } from 'react'; -type TShareContext = { isSharedConvo?: boolean }; +type TShareContext = { isSharedConvo?: boolean; shareId?: string }; export const ShareContext = createContext({} as TShareContext); export const useShareContext = () => useContext(ShareContext); 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 1db8ba3b3e7..a27743a8f0c 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -16,6 +16,8 @@ export function isEphemeralAgent(agentId: string | null | undefined): boolean { export interface ConfigFieldDetail { title: string; description: string; + /** Whether the field holds a secret and should be masked (defaults to masked when omitted). */ + sensitive?: boolean; } export type CodeBarProps = { @@ -349,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 @@ -357,11 +365,18 @@ export type TOptions = { * pills are still visible on the user bubble. */ overrideManualSkills?: string[]; + /** + * Carry forward a user message's quoted excerpts when resubmitting / + * regenerating that same message — the compose-time atom is drained on the + * original submit, so without this the second turn would lose the quoted + * context even though the references still show on the user bubble. + */ + overrideQuotes?: string[]; /** Added conversation for multi-convo feature - sent to server as part of submission payload */ 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) @@ -604,7 +619,6 @@ export type NewConversationParams = { preset?: Partial; modelsData?: t.TModelsConfig; buildDefault?: boolean; - keepLatestMessage?: boolean; keepAddedConvos?: boolean; disableParams?: boolean; }; @@ -651,5 +665,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 293bd8878e4..198ba4271fd 100644 --- a/client/src/components/Agents/tests/VirtualScrollingPerformance.test.tsx +++ b/client/src/components/Agents/tests/VirtualScrollingPerformance.test.tsx @@ -1,15 +1,55 @@ import React from 'react'; import { render, screen } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { jest } from '@jest/globals'; -import VirtualizedAgentGrid from '../VirtualizedAgentGrid'; import type * as t from 'librechat-data-provider'; +import VirtualizedAgentGrid from '../VirtualizedAgentGrid'; + +type RowRendererProps = { + index: number; + key: string; + style: React.CSSProperties; + parent: { props: { width: number } }; +}; + +type VirtualListMockProps = { + rowRenderer: (props: RowRendererProps) => React.ReactNode; + rowCount: number; + width?: number; + style?: React.CSSProperties; + 'aria-rowcount'?: number; + 'data-testid'?: string; + 'data-total-rows'?: number; +}; + +type WindowScrollerChildProps = { + height: number; + isScrolling: boolean; + registerChild: (ref: HTMLElement | null) => void; + onChildScroll: () => void; + scrollTop: number; +}; + +type LocalizeParams = { + count?: number; + category?: string; +}; + +type MockAgentCardProps = { + agent: { + id: string; + name?: string; + description?: string; + }; +}; // Mock react-virtualized for performance testing const mockRowRenderer = jest.fn(); jest.mock('react-virtualized', () => { - const mockRowRendererRef = { current: jest.fn() }; + const ReactActual = jest.requireActual('react'); + const mockRowRendererRef: { current: VirtualListMockProps['rowRenderer'] | null } = { + current: null, + }; return { AutoSizer: ({ @@ -24,62 +64,60 @@ jest.mock('react-virtualized', () => { } return children({ width: 1200, height: 800 }); }, - List: ({ - rowRenderer, - rowCount, - autoHeight, - height, - width, - rowHeight, - overscanRowCount, - scrollTop, - isScrolling, - onScroll, - style, - 'aria-rowcount': ariaRowCount, - 'data-testid': dataTestId, - 'data-total-rows': dataTotalRows, - }: { - rowRenderer: any; - rowCount: number; - [key: string]: any; - }) => { - // Store the row renderer for testing - if (typeof rowRenderer === 'function') { - mockRowRendererRef.current = rowRenderer; - mockRowRenderer.mockImplementation(rowRenderer); - } - // Only render visible rows to simulate virtualization - const visibleRows = Math.min(10, rowCount); // Simulate 10 visible rows - return ( -
- {Array.from({ length: visibleRows }, (_, index) => - rowRenderer({ - index, - key: `row-${index}`, - style: { height: 184 }, - parent: { props: { width: width || 1200 } }, - }), - )} -
- ); - }, + List: ReactActual.forwardRef( + ( + { + rowRenderer, + rowCount, + width, + style, + 'aria-rowcount': ariaRowCount, + 'data-testid': dataTestId, + 'data-total-rows': dataTotalRows, + }: VirtualListMockProps, + ref: React.ForwardedRef<{ forceUpdateGrid: () => void }>, + ) => { + ReactActual.useImperativeHandle(ref, () => ({ + forceUpdateGrid: () => {}, + })); + + // Store the row renderer for testing + if (typeof rowRenderer === 'function') { + mockRowRendererRef.current = rowRenderer; + mockRowRenderer.mockImplementation(rowRenderer); + } + // Only render visible rows to simulate virtualization + const visibleRows = Math.min(10, rowCount); // Simulate 10 visible rows + return ( +
+ {Array.from({ length: visibleRows }, (_, index) => + rowRenderer({ + index, + key: `row-${index}`, + style: { height: 184 }, + parent: { props: { width: width || 1200 } }, + }), + )} +
+ ); + }, + ), WindowScroller: ({ children, - scrollElement, + scrollElement: _scrollElement, }: { - children: (props: any) => React.ReactNode; + children: (props: WindowScrollerChildProps) => React.ReactNode; scrollElement?: HTMLElement | null; }) => { return children({ height: 800, isScrolling: false, - registerChild: (ref: any) => {}, + registerChild: (_ref: HTMLElement | null) => {}, onChildScroll: () => {}, scrollTop: 0, }); @@ -126,7 +164,7 @@ jest.mock('~/hooks', () => ({ { value: 'development', label: 'Development' }, ], }), - useLocalize: () => (key: string, params?: any) => { + useLocalize: () => (key: string, params?: LocalizeParams) => { if (key === 'com_agents_grid_announcement') { return `Found ${params?.count || 0} agents in ${params?.category || 'category'}`; } @@ -139,7 +177,7 @@ jest.mock('../SmartLoader', () => ({ })); jest.mock('../AgentCard', () => { - return function MockAgentCard({ agent }: { agent: any }) { + return function MockAgentCard({ agent }: MockAgentCardProps) { return (

{agent.name}

diff --git a/client/src/components/Agents/tests/VirtualizedAgentGrid.test.tsx b/client/src/components/Agents/tests/VirtualizedAgentGrid.test.tsx index eed35afa0eb..2aed774e819 100644 --- a/client/src/components/Agents/tests/VirtualizedAgentGrid.test.tsx +++ b/client/src/components/Agents/tests/VirtualizedAgentGrid.test.tsx @@ -1,82 +1,115 @@ import React from 'react'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { jest } from '@jest/globals'; -import VirtualizedAgentGrid from '../VirtualizedAgentGrid'; import type t from 'librechat-data-provider'; +import VirtualizedAgentGrid from '../VirtualizedAgentGrid'; + +type RowRendererProps = { + index: number; + key: string; + style: React.CSSProperties; + parent: { props: { width: number } }; +}; + +type VirtualListMockProps = { + rowRenderer: (props: RowRendererProps) => React.ReactNode; + rowCount: number; + width?: number; + style?: React.CSSProperties; + 'aria-rowcount'?: number; + 'data-testid'?: string; + 'data-total-rows'?: number; +}; + +type WindowScrollerChildProps = { + height: number; + isScrolling: boolean; + registerChild: (ref: HTMLElement | null) => void; + onChildScroll: () => void; + scrollTop: number; +}; + +type MarketplaceAgentsMock = { + useMarketplaceAgentsInfiniteQuery: jest.Mock; +}; + +type LocalizeParams = { + count?: number; + category?: string; +}; // Mock react-virtualized -jest.mock('react-virtualized', () => ({ - AutoSizer: ({ - children, - disableHeight, - }: { - children: (props: { width: number; height?: number }) => React.ReactNode; - disableHeight?: boolean; - }) => { - if (disableHeight) { - return children({ width: 800 }); - } - return children({ width: 800, height: 600 }); - }, - List: ({ - rowRenderer, - rowCount, - width, - style, - 'aria-rowcount': ariaRowCount, - 'data-testid': dataTestId, - 'data-total-rows': dataTotalRows, - }: { - rowRenderer: any; - rowCount: number; - autoHeight?: boolean; - height?: number; - width?: number; - rowHeight?: number; - overscanRowCount?: number; - scrollTop?: number; - isScrolling?: boolean; - onScroll?: any; - style?: any; - 'aria-rowcount'?: number; - 'data-testid'?: string; - 'data-total-rows'?: number; - }) => ( -
- {Array.from({ length: Math.min(rowCount, 5) }, (_, index) => - rowRenderer({ - index, - key: `row-${index}`, - style: {}, - parent: { props: { width: width || 800 } }, - }), - )} -
- ), - WindowScroller: ({ - children, - }: { - children: (props: any) => React.ReactNode; - scrollElement?: HTMLElement | null; - }) => { - return children({ - height: 600, - isScrolling: false, - registerChild: (_ref: any) => {}, - onChildScroll: () => {}, - scrollTop: 0, - }); - }, -})); +jest.mock('react-virtualized', () => { + const ReactActual = jest.requireActual('react'); + + return { + AutoSizer: ({ + children, + disableHeight, + }: { + children: (props: { width: number; height?: number }) => React.ReactNode; + disableHeight?: boolean; + }) => { + if (disableHeight) { + return children({ width: 800 }); + } + return children({ width: 800, height: 600 }); + }, + List: ReactActual.forwardRef( + ( + { + rowRenderer, + rowCount, + width, + style, + 'aria-rowcount': ariaRowCount, + 'data-testid': dataTestId, + 'data-total-rows': dataTotalRows, + }: VirtualListMockProps, + ref: React.ForwardedRef<{ forceUpdateGrid: () => void }>, + ) => { + ReactActual.useImperativeHandle(ref, () => ({ + forceUpdateGrid: () => {}, + })); + + return ( +
+ {Array.from({ length: Math.min(rowCount, 5) }, (_, index) => + rowRenderer({ + index, + key: `row-${index}`, + style: {}, + parent: { props: { width: width || 800 } }, + }), + )} +
+ ); + }, + ), + WindowScroller: ({ + children, + }: { + children: (props: WindowScrollerChildProps) => React.ReactNode; + scrollElement?: HTMLElement | null; + }) => { + return children({ + height: 600, + isScrolling: false, + registerChild: (_ref: HTMLElement | null) => {}, + onChildScroll: () => {}, + scrollTop: 0, + }); + }, + }; +}); // Mock the data provider -const mockInfiniteQuery = { +const createMockInfiniteQuery = (overrides = {}) => ({ data: { pages: [ { @@ -104,10 +137,11 @@ const mockInfiniteQuery = { hasNextPage: true, refetch: jest.fn(), isFetchingNextPage: false, -}; + ...overrides, +}); jest.mock('~/data-provider/Agents', () => ({ - useMarketplaceAgentsInfiniteQuery: jest.fn(() => mockInfiniteQuery), + useMarketplaceAgentsInfiniteQuery: jest.fn(), })); // Mock other hooks @@ -118,7 +152,7 @@ jest.mock('~/hooks', () => ({ { value: 'development', label: 'Development' }, ], }), - useLocalize: () => (key: string, params?: any) => { + useLocalize: () => (key: string, params?: LocalizeParams) => { if (key === 'com_agents_grid_announcement') { return `Found ${params?.count || 0} agents in ${params?.category || 'category'}`; } @@ -131,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}

@@ -151,9 +191,16 @@ describe('VirtualizedAgentGrid', () => { mutations: { retry: false }, }, }); + + const useMarketplaceAgentsInfiniteQuery = ( + jest.requireMock('~/data-provider/Agents') as MarketplaceAgentsMock + ).useMarketplaceAgentsInfiniteQuery; + useMarketplaceAgentsInfiniteQuery.mockImplementation(() => createMockInfiniteQuery()); }); - const renderComponent = (props = {}) => { + const renderComponent = ( + props: Partial> = {}, + ) => { const defaultProps = { category: 'all', searchQuery: '', @@ -167,33 +214,27 @@ describe('VirtualizedAgentGrid', () => { ); }; - it('renders virtual list container', async () => { + it('renders virtual list container', () => { renderComponent(); - await waitFor(() => { - expect(screen.getByTestId('virtual-list')).toBeInTheDocument(); - }); + expect(screen.getByTestId('virtual-list')).toBeInTheDocument(); }); - it('displays agent cards in virtual rows', async () => { + it('displays agent cards in virtual rows', () => { renderComponent(); - await waitFor(() => { - expect(screen.getByTestId('agent-card-1')).toBeInTheDocument(); - expect(screen.getByTestId('agent-card-2')).toBeInTheDocument(); - }); + expect(screen.getByTestId('agent-card-1')).toBeInTheDocument(); + expect(screen.getByTestId('agent-card-2')).toBeInTheDocument(); expect(screen.getByText('Test Agent 1')).toBeInTheDocument(); expect(screen.getByText('Test Agent 2')).toBeInTheDocument(); }); - it('calls onSelectAgent when agent card is clicked', async () => { + it('calls onSelectAgent when agent card is clicked', () => { const onSelectAgent = jest.fn(); renderComponent({ onSelectAgent }); - await waitFor(() => { - expect(screen.getByTestId('agent-card-1')).toBeInTheDocument(); - }); + expect(screen.getByTestId('agent-card-1')).toBeInTheDocument(); screen.getByTestId('agent-card-1').click(); @@ -205,15 +246,16 @@ describe('VirtualizedAgentGrid', () => { }); }); - it('shows loading spinner when loading', async () => { + it('shows loading spinner when loading', () => { const mockQuery = jest.fn(() => ({ - ...mockInfiniteQuery, + ...createMockInfiniteQuery(), isLoading: true, data: undefined, })); - const useMarketplaceAgentsInfiniteQuery = - jest.requireMock('~/data-provider/Agents').useMarketplaceAgentsInfiniteQuery; + const useMarketplaceAgentsInfiniteQuery = ( + jest.requireMock('~/data-provider/Agents') as MarketplaceAgentsMock + ).useMarketplaceAgentsInfiniteQuery; useMarketplaceAgentsInfiniteQuery.mockImplementation(mockQuery); renderComponent(); @@ -224,17 +266,10 @@ describe('VirtualizedAgentGrid', () => { expect(spinner).toHaveClass('h-8 w-8 text-primary'); }); - it('has proper accessibility attributes', async () => { - // Reset the mock to ensure we have data - const useMarketplaceAgentsInfiniteQuery = - jest.requireMock('~/data-provider/Agents').useMarketplaceAgentsInfiniteQuery; - useMarketplaceAgentsInfiniteQuery.mockImplementation(() => mockInfiniteQuery); - + it('has proper accessibility attributes', () => { renderComponent({ category: 'productivity' }); - await waitFor(() => { - expect(screen.getByTestId('virtual-list')).toBeInTheDocument(); - }); + expect(screen.getByTestId('virtual-list')).toBeInTheDocument(); const gridContainer = screen.getByRole('grid'); expect(gridContainer).toHaveAttribute('aria-label'); diff --git a/client/src/components/Artifacts/ArtifactCodeEditor.tsx b/client/src/components/Artifacts/ArtifactCodeEditor.tsx index d03397821dd..a349f30eda0 100644 --- a/client/src/components/Artifacts/ArtifactCodeEditor.tsx +++ b/client/src/components/Artifacts/ArtifactCodeEditor.tsx @@ -49,6 +49,23 @@ const TYPE_MAP: Record = { 'application/vnd.mermaid': 'markdown', }; +type ArtifactEditTarget = { + artifactId: string; + messageId: string; + index: number; +}; + +type PendingUpdate = ArtifactEditTarget & { + code: string; + original: string; +}; + +type ArtifactMutationVars = { + messageId: string; + index: number; + updated: string; +}; + function getMonacoLanguage(type?: string, language?: string): string { if (language && LANG_MAP[language]) { return LANG_MAP[language]; @@ -56,6 +73,30 @@ function getMonacoLanguage(type?: string, language?: string): string { return TYPE_MAP[type ?? ''] ?? 'plaintext'; } +function getArtifactEditTarget(artifact: Artifact): ArtifactEditTarget | null { + if (artifact.index == null) { + return null; + } + + return { + artifactId: artifact.id, + messageId: artifact.messageId ?? '', + index: artifact.index, + }; +} + +function isSameArtifactTarget(left: ArtifactEditTarget, right: ArtifactEditTarget): boolean { + return ( + left.artifactId === right.artifactId && + left.messageId === right.messageId && + left.index === right.index + ); +} + +function isSameMutationTarget(target: ArtifactEditTarget, vars: ArtifactMutationVars): boolean { + return target.messageId === vars.messageId && target.index === vars.index; +} + export const ArtifactCodeEditor = function ArtifactCodeEditor({ artifact, monacoRef, @@ -70,25 +111,68 @@ export const ArtifactCodeEditor = function ArtifactCodeEditor({ const { setCurrentCode } = useCodeState(); const [currentUpdate, setCurrentUpdate] = useState(null); const { isMutating, setIsMutating } = useMutationState(); + const artifactRef = useRef(artifact); + const isMutatingRef = useRef(isMutating); + const currentUpdateRef = useRef(currentUpdate); + const setCurrentCodeRef = useRef(setCurrentCode); + const pendingUpdateRef = useRef(null); + const runMutationRef = useRef<(code: string, original?: string) => void>(() => {}); + const editArtifact = useEditArtifact({ onMutate: (vars) => { + isMutatingRef.current = true; + currentUpdateRef.current = vars.updated; setIsMutating(true); setCurrentUpdate(vars.updated); }, - onSuccess: () => { + onSuccess: (_data, vars) => { + isMutatingRef.current = false; + currentUpdateRef.current = null; setIsMutating(false); setCurrentUpdate(null); + + const pending = pendingUpdateRef.current; + pendingUpdateRef.current = null; + const currentTarget = getArtifactEditTarget(artifactRef.current); + if ( + pending == null || + currentTarget == null || + !isSameArtifactTarget(pending, currentTarget) + ) { + return; + } + + const original = isSameMutationTarget(pending, vars) ? vars.updated : pending.original; + if (pending.code.trim() !== original.trim()) { + setCurrentCodeRef.current(pending.code); + runMutationRef.current(pending.code, original); + } }, onError: () => { + const pending = pendingUpdateRef.current; + pendingUpdateRef.current = null; + isMutatingRef.current = false; + currentUpdateRef.current = null; setIsMutating(false); + setCurrentUpdate(null); + + const currentTarget = getArtifactEditTarget(artifactRef.current); + if ( + pending == null || + currentTarget == null || + !isSameArtifactTarget(pending, currentTarget) + ) { + return; + } + + if (pending.code.trim() !== pending.original.trim()) { + setCurrentCodeRef.current(pending.code); + runMutationRef.current(pending.code, pending.original); + } }, }); - const artifactRef = useRef(artifact); - const isMutatingRef = useRef(isMutating); - const currentUpdateRef = useRef(currentUpdate); const editArtifactRef = useRef(editArtifact); - const setCurrentCodeRef = useRef(setCurrentCode); const prevContentRef = useRef(artifact.content ?? ''); const prevArtifactId = useRef(artifact.id); const prevReadOnly = useRef(readOnly); @@ -99,28 +183,51 @@ export const ArtifactCodeEditor = function ArtifactCodeEditor({ editArtifactRef.current = editArtifact; setCurrentCodeRef.current = setCurrentCode; + const runMutation = useCallback( + (code: string, originalOverride?: string) => { + const art = artifactRef.current; + const target = getArtifactEditTarget(art); + if (readOnly || target == null) { + return; + } + + const original = originalOverride ?? art.content ?? ''; + if (isMutatingRef.current) { + pendingUpdateRef.current = { + ...target, + code, + original, + }; + return; + } + + const isNotOriginal = code.trim() !== original.trim(); + const isNotRepeated = + currentUpdateRef.current == null ? true : code.trim() !== currentUpdateRef.current.trim(); + + if (!isNotOriginal || !isNotRepeated) { + return; + } + + setCurrentCodeRef.current(code); + editArtifactRef.current.mutate({ + index: target.index, + messageId: target.messageId, + original, + updated: code, + }); + }, + [readOnly], + ); + + runMutationRef.current = runMutation; + const debouncedMutation = useMemo( () => debounce((code: string) => { - if (readOnly || isMutatingRef.current || artifactRef.current.index == null) { - return; - } - const art = artifactRef.current; - const isNotOriginal = art.content != null && code.trim() !== art.content.trim(); - const isNotRepeated = - currentUpdateRef.current == null ? true : code.trim() !== currentUpdateRef.current.trim(); - - if (art.content != null && isNotOriginal && isNotRepeated && art.index != null) { - setCurrentCodeRef.current(code); - editArtifactRef.current.mutate({ - index: art.index, - messageId: art.messageId ?? '', - original: art.content, - updated: code, - }); - } + runMutationRef.current(code); }, 500), - [readOnly], + [], ); useEffect(() => { @@ -176,6 +283,7 @@ export const ArtifactCodeEditor = function ArtifactCodeEditor({ return; } prevArtifactId.current = artifact.id; + pendingUpdateRef.current = null; prevContentRef.current = artifact.content ?? ''; const ed = monacoRef.current; if (ed && artifact.content != null) { diff --git a/client/src/components/Artifacts/ArtifactPreview.tsx b/client/src/components/Artifacts/ArtifactPreview.tsx index 8257f76887f..c49c22772e3 100644 --- a/client/src/components/Artifacts/ArtifactPreview.tsx +++ b/client/src/components/Artifacts/ArtifactPreview.tsx @@ -4,7 +4,7 @@ import type { SandpackProviderProps, SandpackPreviewRef, } from '@codesandbox/sandpack-react/unstyled'; -import type { TStartupConfig } from 'librechat-data-provider'; +import type { SandpackStartupConfig } from '~/utils/artifacts'; import type { ArtifactFiles } from '~/common'; import { sharedFiles, buildSandpackOptions } from '~/utils/artifacts'; @@ -23,7 +23,7 @@ export const ArtifactPreview = memo(function ({ sharedProps: Partial; previewRef: MutableRefObject; currentCode?: string; - startupConfig?: TStartupConfig; + startupConfig?: SandpackStartupConfig; }) { const artifactFiles = useMemo(() => { if (Object.keys(files).length === 0) { diff --git a/client/src/components/Artifacts/ArtifactTabs.tsx b/client/src/components/Artifacts/ArtifactTabs.tsx index 32332215f03..3ebc98a3661 100644 --- a/client/src/components/Artifacts/ArtifactTabs.tsx +++ b/client/src/components/Artifacts/ArtifactTabs.tsx @@ -3,11 +3,12 @@ import * as Tabs from '@radix-ui/react-tabs'; import type { SandpackPreviewRef } from '@codesandbox/sandpack-react/unstyled'; import type { editor } from 'monaco-editor'; import type { Artifact } from '~/common'; -import { useCodeState } from '~/Providers/EditorContext'; +import { useGetSharedStartupConfig, useGetStartupConfig } from '~/data-provider'; import useArtifactProps from '~/hooks/Artifacts/useArtifactProps'; import { ArtifactCodeEditor } from './ArtifactCodeEditor'; -import { useGetStartupConfig } from '~/data-provider'; +import { useCodeState } from '~/Providers/EditorContext'; import { ArtifactPreview } from './ArtifactPreview'; +import { useShareContext } from '~/Providers'; export default function ArtifactTabs({ artifact, @@ -19,7 +20,14 @@ export default function ArtifactTabs({ isSharedConvo?: boolean; }) { const { currentCode, setCurrentCode } = useCodeState(); - const { data: startupConfig } = useGetStartupConfig(); + const { shareId } = useShareContext(); + const shouldUseSharedConfig = + isSharedConvo === true && typeof shareId === 'string' && shareId.length > 0; + const { data: startupConfig } = useGetStartupConfig({ enabled: !shouldUseSharedConfig }); + const { data: sharedStartupConfig } = useGetSharedStartupConfig(shareId, { + enabled: shouldUseSharedConfig, + }); + const resolvedStartupConfig = shouldUseSharedConfig ? sharedStartupConfig : startupConfig; const monacoRef = useRef(null); const lastIdRef = useRef(null); @@ -55,7 +63,7 @@ export default function ArtifactTabs({ previewRef={previewRef} sharedProps={sharedProps} currentCode={currentCode} - startupConfig={startupConfig} + startupConfig={resolvedStartupConfig} />
diff --git a/client/src/components/Artifacts/Mermaid.tsx b/client/src/components/Artifacts/Mermaid.tsx deleted file mode 100644 index 5eb55be3aef..00000000000 --- a/client/src/components/Artifacts/Mermaid.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import React, { useEffect, useRef, useState, useCallback } from 'react'; -import mermaid from 'mermaid'; -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; -} - -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(() => { - mermaid.initialize({ - startOnLoad: false, - theme, - securityLevel: 'sandbox', - flowchart: artifactFlowchartConfig, - }); - - const renderDiagram = async () => { - if (!mermaidRef.current) { - return; - } - - try { - 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%'; - } - setIsRendered(true); - } catch (error) { - console.error('Mermaid rendering error:', error); - if (mermaidRef.current) { - mermaidRef.current.innerHTML = 'Error rendering diagram'; - } - } - }; - - renderDiagram(); - }, [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/Audio/Voices.tsx b/client/src/components/Audio/Voices.tsx index f41d57ac26f..5d1d843b588 100644 --- a/client/src/components/Audio/Voices.tsx +++ b/client/src/components/Audio/Voices.tsx @@ -6,7 +6,7 @@ import { useLocalize, useTTSBrowser, useTTSExternal } from '~/hooks'; import { logger } from '~/utils'; import store from '~/store'; -export function BrowserVoiceDropdown() { +export function BrowserVoiceDropdown({ disabled = false }: { disabled?: boolean }) { const localize = useLocalize(); const { voices = [] } = useTTSBrowser(); const [voice, setVoice] = useRecoilState(store.voice); @@ -33,12 +33,13 @@ export function BrowserVoiceDropdown() { testId="BrowserVoiceDropdown" className="z-50" aria-labelledby={labelId} + disabled={disabled} />
); } -export function ExternalVoiceDropdown() { +export function ExternalVoiceDropdown({ disabled = false }: { disabled?: boolean }) { const localize = useLocalize(); const { voices = [] } = useTTSExternal(); const [voice, setVoice] = useRecoilState(store.voice); @@ -65,6 +66,7 @@ export function ExternalVoiceDropdown() { testId="ExternalVoiceDropdown" className="z-50" aria-labelledby={labelId} + disabled={disabled} />
); diff --git a/client/src/components/Auth/LoginForm.tsx b/client/src/components/Auth/LoginForm.tsx index c51c2002e32..3d0a2528d5f 100644 --- a/client/src/components/Auth/LoginForm.tsx +++ b/client/src/components/Auth/LoginForm.tsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useContext } from 'react'; import { useForm } from 'react-hook-form'; import { Turnstile } from '@marsidev/react-turnstile'; -import { ThemeContext, Spinner, Button, isDark } from '@librechat/client'; +import { ThemeContext, SecretInput, Spinner, Button, isDark } from '@librechat/client'; import type { TLoginUser, TStartupConfig } from 'librechat-data-provider'; import type { TAuthContext } from '~/common'; import { useResendVerificationEmail, useGetStartupConfig } from '~/data-provider'; @@ -31,6 +31,13 @@ const LoginForm: React.FC = ({ onSubmit, startupConfig, error, const useUsernameLogin = config?.ldap?.username; const validTheme = isDark(theme) ? 'dark' : 'light'; const requireCaptcha = Boolean(startupConfig.turnstile?.siteKey); + const authInputClassName = + 'webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 hover:border-border-light focus:border-green-500 focus:outline-none focus-visible:border-green-500'; + const authSecretInputClassName = `${authInputClassName} h-auto pr-12`; + const authLabelClassName = + 'absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-600 dark:peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4'; + const authSecretButtonClassName = + 'size-9 rounded-xl text-text-secondary-alt hover:bg-transparent hover:text-text-primary'; useEffect(() => { if (error && error.includes('422') && !showResendLink) { @@ -102,13 +109,10 @@ const LoginForm: React.FC = ({ onSubmit, startupConfig, error, : (value) => validateEmail(value, localize('com_auth_email_pattern')), })} aria-invalid={!!errors.email} - className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none" + className={authInputClassName} placeholder=" " /> -
- = ({ onSubmit, startupConfig, error, maxLength: { value: 128, message: localize('com_auth_password_max_length') }, })} aria-invalid={!!errors.password} - className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none" + className={authSecretInputClassName} placeholder=" " + label={localize('com_auth_password')} + labelClassName={authLabelClassName} + controlsClassName="right-2" + buttonClassName={authSecretButtonClassName} /> -
{renderError('password')}
diff --git a/client/src/components/Auth/Registration.tsx b/client/src/components/Auth/Registration.tsx index 3766bdff50d..e37e3a7a506 100644 --- a/client/src/components/Auth/Registration.tsx +++ b/client/src/components/Auth/Registration.tsx @@ -1,7 +1,7 @@ import { useForm } from 'react-hook-form'; import React, { useContext, useState } from 'react'; import { Turnstile } from '@marsidev/react-turnstile'; -import { ThemeContext, Spinner, Button, isDark } from '@librechat/client'; +import { ThemeContext, SecretInput, Spinner, Button, isDark } from '@librechat/client'; import { useNavigate, useOutletContext, useLocation } from 'react-router-dom'; import { useRegisterUserMutation } from 'librechat-data-provider/react-query'; import { loginPage } from 'librechat-data-provider'; @@ -36,6 +36,13 @@ const Registration: React.FC = () => { // only require captcha if we have a siteKey const requireCaptcha = Boolean(startupConfig?.turnstile?.siteKey); + const authInputClassName = + 'webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 hover:border-border-light focus:border-green-500 focus:outline-none focus-visible:border-green-500'; + const authSecretInputClassName = `${authInputClassName} h-auto pr-12`; + const authLabelClassName = + 'absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4'; + const authSecretButtonClassName = + 'size-9 rounded-xl text-text-secondary-alt hover:bg-transparent hover:text-text-primary'; const registerUser = useRegisterUserMutation({ onMutate: () => { @@ -64,37 +71,58 @@ const Registration: React.FC = () => { }, }); - const renderInput = (id: string, label: TranslationKeys, type: string, validation: object) => ( -
-
- { + const fieldLabel = localize(label); + const field = register( + id as 'name' | 'email' | 'username' | 'password' | 'confirm_password', + validation, + ); + + return ( +
+
+ {type === 'password' ? ( + + ) : ( + <> + + + )} - aria-invalid={!!errors[id]} - className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none" - placeholder=" " - data-testid={id} - /> - +
+ {errors[id] && ( + + {String(errors[id]?.message) ?? ''} + + )}
- {errors[id] && ( - - {String(errors[id]?.message) ?? ''} - - )} -
- ); + ); + }; return ( <> diff --git a/client/src/components/Auth/ResetPassword.tsx b/client/src/components/Auth/ResetPassword.tsx index 6bececb7fe7..e86a499adcf 100644 --- a/client/src/components/Auth/ResetPassword.tsx +++ b/client/src/components/Auth/ResetPassword.tsx @@ -1,5 +1,5 @@ import { useForm } from 'react-hook-form'; -import { Spinner, Button } from '@librechat/client'; +import { Spinner, Button, SecretInput } from '@librechat/client'; import { useOutletContext } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom'; import { useResetPasswordMutation } from 'librechat-data-provider/react-query'; @@ -20,6 +20,12 @@ function ResetPassword() { const password = watch('password'); const resetPassword = useResetPasswordMutation(); const { setError, setHeaderText, startupConfig } = useOutletContext(); + const authInputClassName = + 'webkit-dark-styles transition-color peer h-auto w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pr-12 pt-3 text-text-primary duration-200 hover:border-border-light focus:border-green-500 focus:outline-none focus-visible:border-green-500'; + const authLabelClassName = + 'absolute start-3 top-1.5 z-10 origin-[0] -translate-y-4 scale-75 transform bg-surface-primary px-2 text-sm text-text-secondary-alt duration-200 peer-placeholder-shown:top-1/2 peer-placeholder-shown:-translate-y-1/2 peer-placeholder-shown:scale-100 peer-focus:top-1.5 peer-focus:-translate-y-4 peer-focus:scale-75 peer-focus:px-2 peer-focus:text-green-500 rtl:peer-focus:left-auto rtl:peer-focus:translate-x-1/4'; + const authSecretButtonClassName = + 'size-9 rounded-xl text-text-secondary-alt hover:bg-transparent hover:text-text-primary'; const onSubmit = (data: TResetPassword) => { resetPassword.mutate(data, { @@ -75,8 +81,7 @@ function ResetPassword() { value={params.get('userId') ?? ''} {...register('userId', { required: 'Unable to process: No valid user id' })} /> - -
{errors.password && ( @@ -111,23 +114,20 @@ function ResetPassword() {
- value === password || localize('com_auth_password_not_match'), })} aria-invalid={!!errors.confirm_password} - className="webkit-dark-styles transition-color peer w-full rounded-2xl border border-border-light bg-surface-primary px-3.5 pb-2.5 pt-3 text-text-primary duration-200 focus:border-green-500 focus:outline-none" + className={authInputClassName} placeholder=" " + label={localize('com_auth_password_confirm')} + labelClassName={authLabelClassName} + controlsClassName="right-2" + buttonClassName={authSecretButtonClassName} /> -
{errors.confirm_password && ( 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/Banners/Banner.tsx b/client/src/components/Banners/Banner.tsx index a1e9056c078..72c1891683d 100644 --- a/client/src/components/Banners/Banner.tsx +++ b/client/src/components/Banners/Banner.tsx @@ -1,8 +1,12 @@ -import DOMPurify from 'dompurify'; import { XIcon } from 'lucide-react'; import { useRecoilState } from 'recoil'; import { Button, cn } from '@librechat/client'; import { useEffect, useMemo, useRef } from 'react'; +import { + CONFIG_HTML_TEXT_TAGS, + CONFIG_HTML_CLASS_ATTR, + createConfigHtmlSanitizer, +} from '~/utils/configHtml'; import { useGetBannerQuery } from '~/data-provider'; import store from '~/store'; @@ -10,25 +14,21 @@ export const Banner = ({ onHeightChange }: { onHeightChange?: (height: number) = const { data: banner } = useGetBannerQuery(); const [hideBannerHint, setHideBannerHint] = useRecoilState(store.hideBannerHint); const bannerRef = useRef(null); + const sanitize = useMemo( + () => + createConfigHtmlSanitizer({ + allowedTags: CONFIG_HTML_TEXT_TAGS, + allowedAttr: CONFIG_HTML_CLASS_ATTR, + }), + [], + ); const sanitizedMessage = useMemo(() => { if (!banner?.message) { return ''; } - const sanitizer = DOMPurify(); - sanitizer.addHook('afterSanitizeAttributes', (node) => { - if (node.tagName === 'A') { - node.setAttribute('target', '_blank'); - node.setAttribute('rel', 'noopener noreferrer'); - } - }); - return sanitizer.sanitize(banner.message, { - ALLOWED_TAGS: ['a', 'strong', 'b', 'em', 'i', 'br', 'code', 'span'], - ALLOWED_ATTR: ['href', 'class', 'target', 'rel'], - ALLOW_DATA_ATTR: false, - ALLOW_ARIA_ATTR: false, - }); - }, [banner?.message]); + return sanitize(banner.message); + }, [banner?.message, sanitize]); useEffect(() => { if (onHeightChange && bannerRef.current) { diff --git a/client/src/components/Chat/ChatView.tsx b/client/src/components/Chat/ChatView.tsx index d2bd7edf0da..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,9 +36,11 @@ 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); const methods = useForm({ @@ -40,16 +49,20 @@ function ChatView({ index = 0 }: { index?: number }) { const fileMap = useFileMapContext(); - const { data: messagesTree = null, isLoading } = useGetMessagesByConvoId(conversationId ?? '', { - select: useCallback( - (data: TMessage[]) => { - const dataTree = buildTree({ messages: data, fileMap }); - return dataTree?.length === 0 ? null : (dataTree ?? null); - }, - [fileMap], - ), - enabled: !!fileMap, - }); + const { data: messagesTree = null, isLoading } = useGetMessagesByConvoId( + conversationId ?? '', + { + select: useCallback( + (data: TMessage[]) => { + const dataTree = buildTree({ messages: data, fileMap }); + return dataTree?.length === 0 ? null : (dataTree ?? null); + }, + [fileMap], + ), + enabled: !!fileMap, + }, + { isStreaming: isSubmitting }, + ); const chatHelpers = useChatHelpers(index, conversationId); const addedChatHelpers = useAddedResponse(); @@ -65,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 = ; @@ -76,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 ( @@ -99,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) => - + {isEnabled && ( )} -
+ ); }, ); diff --git a/client/src/components/Chat/Input/AudioRecorder.tsx b/client/src/components/Chat/Input/AudioRecorder.tsx index e9e19d09047..3eaefcdcc87 100644 --- a/client/src/components/Chat/Input/AudioRecorder.tsx +++ b/client/src/components/Chat/Input/AudioRecorder.tsx @@ -2,8 +2,8 @@ import { memo, useCallback, useRef } from 'react'; import { MicOff } from 'lucide-react'; import { useToastContext, TooltipAnchor, ListeningIcon, Spinner } from '@librechat/client'; import { useLocalize, useSpeechToText, useGetAudioSettings } from '~/hooks'; +import { globalAudioId, type TAskFunction } from '~/common'; import { useChatFormContext } from '~/Providers'; -import { globalAudioId } from '~/common'; import { cn } from '~/utils'; const isExternalSTT = (speechToTextEndpoint: string) => speechToTextEndpoint === 'external'; @@ -11,13 +11,11 @@ export default memo(function AudioRecorder({ disabled, ask, methods, - textAreaRef, isSubmitting, }: { disabled: boolean; - ask: (data: { text: string }) => void; + ask: TAskFunction; methods: ReturnType; - textAreaRef: React.RefObject; isSubmitting: boolean; }) { const { setValue, reset, getValues } = methods; @@ -49,7 +47,10 @@ export default memo(function AudioRecorder({ isExternalSTT(speechToTextEndpoint) && existingTextRef.current ? `${existingTextRef.current} ${text}` : text; - ask({ text: finalText }); + const submitted = ask({ text: finalText }); + if (submitted === false) { + return; + } reset({ text: '' }); existingTextRef.current = ''; } @@ -79,10 +80,6 @@ export default memo(function AudioRecorder({ onTranscriptionComplete, ); - if (!textAreaRef.current) { - return null; - } - const handleStartRecording = async () => { existingTextRef.current = getValues('text') || ''; startRecording(); diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index a4f4f062f55..c02ee15e1a8 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -5,12 +5,6 @@ import { useRecoilState, useRecoilValue } from 'recoil'; import { Constants, isAssistantsEndpoint, isAgentsEndpoint } from 'librechat-data-provider'; import type { TConversation } from 'librechat-data-provider'; import type { ExtendedFile, FileSetter, ConvoGenerator } from '~/common'; -import { - useChatContext, - useChatFormContext, - useAddedChatContext, - useAssistantsMapContext, -} from '~/Providers'; import { useTextarea, useAutoSave, @@ -21,17 +15,27 @@ import { useSubmitMessage, useFocusChatEffect, } from '~/hooks'; +import { + useChatContext, + useChatFormContext, + useAddedChatContext, + useAssistantsMapContext, +} from '~/Providers'; +import PendingManualSkillsChips from './PendingManualSkillsChips'; +import { cn, getModelSpec, removeFocusRings } from '~/utils'; +import { useGetStartupConfig } from '~/data-provider'; import { mainTextareaId, BadgeItem } from '~/common'; +import PendingQuoteChips from './PendingQuoteChips'; import AttachFileChat from './Files/AttachFileChat'; import FileFormChat from './Files/FileFormChat'; -import { cn, removeFocusRings } from '~/utils'; import TextareaHeader from './TextareaHeader'; -import PendingManualSkillsChips from './PendingManualSkillsChips'; -import SkillsCommand from './SkillsCommand'; import PromptsCommand from './PromptsCommand'; +import SkillsCommand from './SkillsCommand'; import AudioRecorder from './AudioRecorder'; import CollapseChat from './CollapseChat'; +import QuoteButton from './QuoteButton'; import StreamAudio from './StreamAudio'; +import TokenUsage from './TokenUsage'; import StopButton from './StopButton'; import SendButton from './SendButton'; import EditBadges from './EditBadges'; @@ -41,6 +45,7 @@ import store from '~/store'; interface ChatFormProps { index: number; + placeholder?: string; /** From ChatContext — individual values so memo can compare them */ files: Map; setFiles: FileSetter; @@ -54,6 +59,7 @@ interface ChatFormProps { const ChatForm = memo(function ChatForm({ index, + placeholder, files, setFiles, conversation, @@ -96,15 +102,27 @@ const ChatForm = memo(function ChatForm({ setConversation: setAddedConvo, } = useAddedChatContext(); const assistantMap = useAssistantsMapContext(); + const { data: startupConfig } = useGetStartupConfig(); const endpoint = useMemo( () => conversation?.endpointType ?? conversation?.endpoint, [conversation?.endpointType, conversation?.endpoint], ); + const modelSpec = useMemo( + () => getModelSpec({ specName: conversation?.spec, startupConfig }), + [conversation?.spec, startupConfig], + ); + const hideBadgeRow = modelSpec?.hideBadgeRow === true; const conversationId = useMemo( () => conversation?.conversationId ?? Constants.NEW_CONVO, [conversation?.conversationId], ); + /** + * The quote feature merges excerpts server-side in `BaseClient.sendMessage`, + * which the Assistants endpoints bypass — so hide the UI there rather than + * letting users queue quotes the assistant never receives. + */ + const quotesEnabled = useMemo(() => !isAssistantsEndpoint(endpoint), [endpoint]); const isRTL = useMemo( () => (chatDirection != null ? chatDirection?.toLowerCase() === 'rtl' : false), @@ -170,6 +188,7 @@ const ChatForm = memo(function ChatForm({ submitButtonRef, setIsScrollable, disabled: disableInputs, + placeholder, }); useQueryParams({ textAreaRef }); @@ -239,6 +258,8 @@ const ChatForm = memo(function ChatForm({ )} >
+ {/* Primary composer owns the selection popup so split-view doesn't double it. */} + {index === 0 && quotesEnabled && }
+ {quotesEnabled && } {/* WIP */}
+ {SpeechToText && ( @@ -403,7 +428,7 @@ ChatForm.displayName = 'ChatForm'; * to the memo'd ChatForm. This prevents ChatForm from re-rendering on every * streaming chunk — it only re-renders when the specific values it uses change. */ -function ChatFormWrapper({ index = 0 }: { index?: number }) { +function ChatFormWrapper({ index = 0, placeholder }: { index?: number; placeholder?: string }) { const { files, setFiles, @@ -432,6 +457,7 @@ function ChatFormWrapper({ index = 0 }: { index?: number }) { conversation?.spec, conversation?.useResponsesApi, conversation?.model, + conversation?.maxContextTokens, hasMessages, ], ); @@ -455,6 +481,7 @@ function ChatFormWrapper({ index = 0 }: { index?: number }) { return ( { - const getIconComponent = (state) => { - switch (state) { - case ECallState.Thinking: - return ; - default: - return ( -
- -
- ); - } - }; - - const baseScale = isCameraOn ? 0.5 : 1; - const scaleMultiplier = - rmsLevel > 0.08 - ? 1.8 - : rmsLevel > 0.07 - ? 1.6 - : rmsLevel > 0.05 - ? 1.4 - : rmsLevel > 0.01 - ? 1.2 - : 1; - - const transformScale = baseScale * scaleMultiplier; - - return getIconComponent(state); -}; - -export default CircleRender; diff --git a/client/src/components/Chat/Input/ConversationStarters.tsx b/client/src/components/Chat/Input/ConversationStarters.tsx index bd78f4b2b43..ea9549e1808 100644 --- a/client/src/components/Chat/Input/ConversationStarters.tsx +++ b/client/src/components/Chat/Input/ConversationStarters.tsx @@ -1,8 +1,12 @@ import { useMemo, useCallback } from 'react'; import { EModelEndpoint, Constants } from 'librechat-data-provider'; +import { + useGetAssistantDocsQuery, + useGetEndpointsQuery, + useGetStartupConfig, +} from '~/data-provider'; import { useChatContext, useAgentsMapContext, useAssistantsMapContext } from '~/Providers'; -import { useGetAssistantDocsQuery, useGetEndpointsQuery } from '~/data-provider'; -import { getIconEndpoint, getEntity } from '~/utils'; +import { getIconEndpoint, getEntity, getModelSpec } from '~/utils'; import { useSubmitMessage } from '~/hooks'; const ConversationStarters = () => { @@ -10,6 +14,7 @@ const ConversationStarters = () => { const agentsMap = useAgentsMapContext(); const assistantMap = useAssistantsMapContext(); const { data: endpointsConfig } = useGetEndpointsQuery(); + const { data: startupConfig } = useGetStartupConfig(); const endpointType = useMemo(() => { let ep = conversation?.endpoint ?? ''; @@ -35,17 +40,26 @@ const ConversationStarters = () => { assistant_id: conversation?.assistant_id, }); + const modelSpec = useMemo( + () => getModelSpec({ specName: conversation?.spec, startupConfig }), + [conversation?.spec, startupConfig], + ); + const conversation_starters = useMemo(() => { if (entity?.conversation_starters?.length) { return entity.conversation_starters; } + if (modelSpec?.conversation_starters?.length) { + return modelSpec.conversation_starters; + } + if (isAgent) { return []; } return documentsMap.get(entity?.id ?? '')?.conversation_starters ?? []; - }, [documentsMap, isAgent, entity]); + }, [documentsMap, isAgent, entity, modelSpec]); const { submitMessage } = useSubmitMessage(); const sendConversationStarter = useCallback( @@ -58,18 +72,17 @@ const ConversationStarters = () => { } return ( -
+
{conversation_starters .slice(0, Constants.MAX_CONVO_STARTERS) .map((text: string, index: number) => ( ))}
diff --git a/client/src/components/Chat/Input/Files/AttachFile.tsx b/client/src/components/Chat/Input/Files/AttachFile.tsx index 098fa2c4c3f..413caa21b21 100644 --- a/client/src/components/Chat/Input/Files/AttachFile.tsx +++ b/client/src/components/Chat/Input/Files/AttachFile.tsx @@ -2,6 +2,7 @@ import React, { useRef } from 'react'; import { FileUpload, TooltipAnchor, AttachmentIcon } from '@librechat/client'; import type { TConversation } from 'librechat-data-provider'; import type { ExtendedFile, FileSetter } from '~/common'; +import { useShortcutAriaKey, useShortcutHint } from '~/hooks/useKeyboardShortcuts'; import { useFileHandlingNoChatContext, useLocalize } from '~/hooks'; import { cn } from '~/utils'; @@ -21,6 +22,8 @@ const AttachFile = ({ const localize = useLocalize(); const inputRef = useRef(null); const isUploadDisabled = disabled ?? false; + const tooltipDescription = useShortcutHint('uploadFile', localize('com_sidepanel_attach_files')); + const ariaKey = useShortcutAriaKey('uploadFile'); const { handleFileChange } = useFileHandlingNoChatContext(undefined, { files, @@ -32,13 +35,14 @@ const AttachFile = ({ return ( (null); const [isPopoverActive, setIsPopoverActive] = useState(false); + const uploadFileTooltip = useShortcutHint('uploadFile', localize('com_sidepanel_attach_files')); + const uploadFileAriaKey = useShortcutAriaKey('uploadFile'); const [ephemeralAgent, setEphemeralAgent] = useRecoilState( ephemeralAgentByConvoId(conversationId), ); @@ -277,6 +280,7 @@ const AttachFileMenu = ({ disabled={isUploadDisabled} id="attach-file-menu-button" aria-label="Attach File Options" + aria-keyshortcuts={uploadFileAriaKey} className={cn( 'flex size-9 items-center justify-center rounded-full p-1 hover:bg-surface-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-opacity-50', isPopoverActive && 'bg-surface-hover', @@ -288,7 +292,7 @@ const AttachFileMenu = ({ } id="attach-file-menu-button" - description={localize('com_sidepanel_attach_files')} + description={uploadFileTooltip} disabled={isUploadDisabled} /> ); diff --git a/client/src/components/Chat/Input/Files/FileRow.tsx b/client/src/components/Chat/Input/Files/FileRow.tsx index bf04b16ade0..27bef5526d0 100644 --- a/client/src/components/Chat/Input/Files/FileRow.tsx +++ b/client/src/components/Chat/Input/Files/FileRow.tsx @@ -115,7 +115,7 @@ export default function FileRow({ if (abortUpload && file.progress < 1) { abortUpload(); } - if (file.progress >= 1) { + if (file.progress >= 1 && !file.attached) { showToast({ message: localize('com_ui_deleting_file'), status: 'info', diff --git a/client/src/components/Chat/Input/Files/__tests__/AttachFileMenu.spec.tsx b/client/src/components/Chat/Input/Files/__tests__/AttachFileMenu.spec.tsx index 25b2014d516..3f86cb5102c 100644 --- a/client/src/components/Chat/Input/Files/__tests__/AttachFileMenu.spec.tsx +++ b/client/src/components/Chat/Input/Files/__tests__/AttachFileMenu.spec.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; import { RecoilRoot } from 'recoil'; +import { render, screen, fireEvent } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { EModelEndpoint, EToolResources, Providers } from 'librechat-data-provider'; import AttachFileMenu from '../AttachFileMenu'; diff --git a/client/src/components/Chat/Input/MCPConfigDialog.tsx b/client/src/components/Chat/Input/MCPConfigDialog.tsx index a4cb03251e5..0e054d7b91e 100644 --- a/client/src/components/Chat/Input/MCPConfigDialog.tsx +++ b/client/src/components/Chat/Input/MCPConfigDialog.tsx @@ -1,8 +1,12 @@ -import DOMPurify from 'dompurify'; import React, { useEffect, useMemo } from 'react'; import { useForm, Controller } from 'react-hook-form'; -import { Button, Input, Label, OGDialog, OGDialogTemplate } from '@librechat/client'; +import { Button, Input, Label, SecretInput, OGDialog, OGDialogTemplate } from '@librechat/client'; import type { ConfigFieldDetail } from '~/common'; +import { + CONFIG_HTML_BLOCK_TAGS, + CONFIG_HTML_CLASS_ATTR, + createConfigHtmlSanitizer, +} from '~/utils/configHtml'; import { useLocalize } from '~/hooks'; interface MCPConfigDialogProps { @@ -36,24 +40,14 @@ export default function MCPConfigDialog({ defaultValues: initialValues, }); - const sanitizer = useMemo(() => { - const instance = DOMPurify(); - instance.addHook('afterSanitizeAttributes', (node) => { - if (node.tagName === 'A') { - node.setAttribute('target', '_blank'); - node.setAttribute('rel', 'noopener noreferrer'); - } - }); - return instance; - }, []); - - const sanitize = (html: string) => - sanitizer.sanitize(html, { - ALLOWED_TAGS: ['a', 'strong', 'b', 'em', 'i', 'br', 'code', 'span', 'p'], - ALLOWED_ATTR: ['href', 'class', 'target', 'rel'], - ALLOW_DATA_ATTR: false, - ALLOW_ARIA_ATTR: false, - }); + const sanitize = useMemo( + () => + createConfigHtmlSanitizer({ + allowedTags: CONFIG_HTML_BLOCK_TAGS, + allowedAttr: CONFIG_HTML_CLASS_ATTR, + }), + [], + ); useEffect(() => { if (isOpen) { @@ -90,15 +84,34 @@ export default function MCPConfigDialog({ name={key} control={control} defaultValue={initialValues[key] || ''} - render={({ field }) => ( - - )} + render={({ field }) => { + const placeholder = localize('com_ui_mcp_enter_var', { 0: details.title }); + const className = + 'w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm'; + if (details.sensitive === false) { + return ( + + ); + } + return ( + + ); + }} /> {details.description && (

{ placeholder?: string; } -const MCPSubMenu = React.forwardRef( - ({ placeholder, ...props }, ref) => { +const MCPSubMenu = React.forwardRef( + ({ placeholder, className, ...props }, ref) => { const localize = useLocalize(); const context = useBadgeRowContext(); const { storageContextKey, mcpServerManager } = context ?? {}; @@ -48,20 +48,19 @@ const MCPSubMenu = React.forwardRef( const configDialogProps = getConfigDialogProps(); return ( -

+ <> - ) => { - e.stopPropagation(); - menuStore.toggle(); - }} - className="flex w-full cursor-pointer items-center justify-between rounded-lg p-2 hover:bg-surface-hover" - /> - } + onClick={(e: React.MouseEvent) => { + e.stopPropagation(); + menuStore.toggle(); + }} + className={cn( + 'flex w-full cursor-pointer items-center justify-between rounded-lg p-2 hover:bg-surface-hover', + className, + )} >
-
+ ( {configDialogProps && ( )} -
+ ); }, ); diff --git a/client/src/components/Chat/Input/Mention.tsx b/client/src/components/Chat/Input/Mention.tsx index c2d352a4e60..a188503478c 100644 --- a/client/src/components/Chat/Input/Mention.tsx +++ b/client/src/components/Chat/Input/Mention.tsx @@ -1,8 +1,8 @@ import { memo, useState, useRef, useEffect } from 'react'; -import { useRecoilValue, useSetRecoilState } from 'recoil'; import { AutoSizer, List } from 'react-virtualized'; import { Spinner, useCombobox } from '@librechat/client'; import { EModelEndpoint } from 'librechat-data-provider'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; import type { RecoilState } from 'recoil'; import type { MentionOption, ConvoGenerator } from '~/common'; import { useGetConversation, useLocalize, TranslationKeys } from '~/hooks'; @@ -128,6 +128,10 @@ function MentionContent({ } }, [open, options]); + useEffect(() => { + setActiveIndex((prev) => Math.min(prev, Math.max(matches.length - 1, 0))); + }, [matches.length]); + useEffect(() => { return () => { if (timeoutRef.current) { @@ -191,10 +195,23 @@ function MentionContent({ textAreaRef.current?.focus(); } if (e.key === 'ArrowDown') { + if (matches.length === 0) { + return; + } setActiveIndex((prevIndex) => (prevIndex + 1) % matches.length); } else if (e.key === 'ArrowUp') { + if (matches.length === 0) { + return; + } setActiveIndex((prevIndex) => (prevIndex - 1 + matches.length) % matches.length); } else if (e.key === 'Enter' || e.key === 'Tab') { + if (matches.length === 0) { + e.preventDefault(); + setOpen(false); + setShowPopover(false); + textAreaRef.current?.focus(); + return; + } const mentionOption = matches[activeIndex] as MentionOption | undefined; if (mentionOption?.type === 'endpoint') { e.preventDefault(); diff --git a/client/src/components/Chat/Input/PendingQuoteChips.tsx b/client/src/components/Chat/Input/PendingQuoteChips.tsx new file mode 100644 index 00000000000..7ba436c0621 --- /dev/null +++ b/client/src/components/Chat/Input/PendingQuoteChips.tsx @@ -0,0 +1,170 @@ +import { memo, useRef, useState, useEffect, useCallback } from 'react'; +import * as Ariakit from '@ariakit/react'; +import { TextQuote, X } from 'lucide-react'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { useLocalize } from '~/hooks'; +import store from '~/store'; + +const CHIP_CLASS = + 'inline-flex max-w-full items-center gap-1 rounded-2xl border border-border-light bg-surface-secondary px-2.5 py-1.5 text-sm text-text-secondary'; +const REMOVE_BTN_CLASS = + '-mr-0.5 shrink-0 rounded-full p-0.5 text-text-secondary hover:bg-surface-tertiary hover:text-text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy'; +const TRIGGER_CLASS = + 'inline-flex min-w-0 items-center gap-1.5 rounded-full focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-border-xheavy'; +/** Grace period so moving the pointer between the chip and the popup doesn't close it. */ +const CLOSE_DELAY_MS = 120; + +/** + * Chip row rendered above the textarea for excerpts the user quoted via the + * "Add to chat" selection popup. Shows a single chip: the excerpt text for one + * selection, or a collapsed "{n} selections" pill for multiple — so the + * composer never fills with a row of chips. + * + * The collapsed pill is an Ariakit `Popover` disclosure: it opens on + * click / Enter / Space (keyboard users tab through the excerpts, Escape closes + * and Ariakit restores focus to the trigger) and on hover for mouse users. + * `autoFocusOnShow` is disabled so opening never pulls focus off the composer; + * `autoFocusOnHide` still returns focus to the trigger when focus was inside. + * + * Reads + writes `pendingQuotesByConvoId` directly; the atom is drained in + * `useChatFunctions.ask` on submit, so chips disappear once the message is sent + * (the excerpts then re-render as `MessageQuotes` on the user bubble). + */ +function PendingQuoteChips({ conversationId }: { conversationId: string }) { + const localize = useLocalize(); + const quotes = useRecoilValue(store.pendingQuotesByConvoId(conversationId)); + const setQuotes = useSetRecoilState(store.pendingQuotesByConvoId(conversationId)); + + const popover = Ariakit.usePopoverStore({ placement: 'top-start' }); + const closeTimerRef = useRef | null>(null); + /** + * Ariakit only restores focus to the trigger on hide when it took focus on + * show, so keep `autoFocusOnShow` on for keyboard/click opens (Escape returns + * focus to the trigger) and off for hover so it never pulls focus off the + * composer mid-typing. + */ + const [focusOnShow, setFocusOnShow] = useState(true); + + const cancelClose = useCallback(() => { + if (closeTimerRef.current != null) { + clearTimeout(closeTimerRef.current); + closeTimerRef.current = null; + } + }, []); + const openByPointer = useCallback(() => { + cancelClose(); + setFocusOnShow(false); + popover.show(); + }, [cancelClose, popover]); + const scheduleClose = useCallback(() => { + cancelClose(); + closeTimerRef.current = setTimeout(() => popover.hide(), CLOSE_DELAY_MS); + }, [cancelClose, popover]); + + useEffect(() => cancelClose, [cancelClose]); + + const clearAll = useCallback(() => setQuotes([]), [setQuotes]); + const removeAt = useCallback( + (index: number) => setQuotes((prev) => prev.filter((_, i) => i !== index)), + [setQuotes], + ); + + if (quotes.length === 0) { + return null; + } + + const isMulti = quotes.length > 1; + + return ( +
+ {!isMulti ? ( + + + ) : ( + <> + + setFocusOnShow(true)} + > + + + + +
    + {quotes.map((text, index) => ( +
  • +
  • + ))} +
+
+ + )} +
+ ); +} + +export default memo(PendingQuoteChips); diff --git a/client/src/components/Chat/Input/PromptsCommand.tsx b/client/src/components/Chat/Input/PromptsCommand.tsx index 6db9eb62bc8..4efa7a1a7f4 100644 --- a/client/src/components/Chat/Input/PromptsCommand.tsx +++ b/client/src/components/Chat/Input/PromptsCommand.tsx @@ -138,10 +138,15 @@ function PromptsCommand({ useEffect(() => { if (!open) { setActiveIndex(0); + setSearchValue(''); } else { setVariableGroup(null); } - }, [open]); + }, [open, setSearchValue]); + + useEffect(() => { + setActiveIndex((prev) => Math.min(prev, Math.max(matches.length - 1, 0))); + }, [matches.length]); useEffect(() => { return () => { @@ -214,10 +219,23 @@ function PromptsCommand({ textAreaRef.current?.focus(); } if (e.key === 'ArrowDown') { + if (matches.length === 0) { + return; + } setActiveIndex((prevIndex) => (prevIndex + 1) % matches.length); } else if (e.key === 'ArrowUp') { + if (matches.length === 0) { + return; + } setActiveIndex((prevIndex) => (prevIndex - 1 + matches.length) % matches.length); } else if (e.key === 'Enter' || e.key === 'Tab') { + if (matches.length === 0) { + e.preventDefault(); + setOpen(false); + setShowPromptsPopover(false); + textAreaRef.current?.focus(); + return; + } if (e.key === 'Enter') { e.preventDefault(); } diff --git a/client/src/components/Chat/Input/QuoteButton.tsx b/client/src/components/Chat/Input/QuoteButton.tsx new file mode 100644 index 00000000000..01a391e192b --- /dev/null +++ b/client/src/components/Chat/Input/QuoteButton.tsx @@ -0,0 +1,185 @@ +import { memo, useRef, useState, useEffect, useCallback, useLayoutEffect } from 'react'; +import { createPortal } from 'react-dom'; +import { TextQuote } from 'lucide-react'; +import { useSetRecoilState } from 'recoil'; +import { mainTextareaId } from '~/common'; +import { useLocalize } from '~/hooks'; +import store from '~/store'; + +/** Only selections fully inside a rendered chat message get the popup. */ +const MESSAGE_SELECTOR = '.message-render'; +/** Max characters captured per excerpt (backend re-caps as defense-in-depth). */ +const MAX_QUOTE_LENGTH = 1500; +/** Max excerpts queued at once; mirrors the backend `QUOTE_MAX_COUNT` cap so + * the composer never shows more quotes than the model actually receives. */ +const MAX_QUOTE_COUNT = 10; +/** Vertical gap (px) between the selection and the popup. */ +const POPUP_OFFSET = 8; +/** Keep the popup this far (px) from the viewport edges. */ +const EDGE_MARGIN = 16; + +type SelectionState = { + text: string; + /** Viewport-relative anchor of the selection (used to place the button). */ + top: number; + bottom: number; + centerX: number; +}; + +const resolveMessageElement = (node: Node | null): HTMLElement | null => { + const element = node instanceof Element ? node : (node?.parentElement ?? null); + return (element?.closest(MESSAGE_SELECTOR) as HTMLElement | null) ?? null; +}; + +const readSelection = (): SelectionState | null => { + const selection = window.getSelection(); + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + return null; + } + + const anchorMessage = resolveMessageElement(selection.anchorNode); + const focusMessage = resolveMessageElement(selection.focusNode); + if (!anchorMessage || anchorMessage !== focusMessage) { + return null; + } + + const text = selection + .toString() + .replace(/\u00a0/g, ' ') + .trim(); + if (text.length === 0) { + return null; + } + + const rect = selection.getRangeAt(0).getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) { + return null; + } + + return { + text: text.slice(0, MAX_QUOTE_LENGTH), + top: rect.top, + bottom: rect.bottom, + centerX: rect.left + rect.width / 2, + }; +}; + +/** + * ChatGPT-style floating "Add to chat" button. Watches for text selections + * inside chat messages and, on click, appends the selected excerpt to the + * conversation's pending-quotes queue so it shows as a removable chip above + * the composer and rides along with the next submission. + * + * Rendered through a portal so the `fixed` positioning stays viewport-relative + * regardless of any transformed ancestor in the composer tree. The on-screen + * position is computed from the button's measured size (no CSS transform), so it + * is clamped accurately to the viewport — flipping below the selection when + * there is no room above and keeping its full width within the side margins. + */ +function QuoteButton({ conversationId }: { conversationId: string }) { + const localize = useLocalize(); + const [selection, setSelection] = useState(null); + const [pos, setPos] = useState<{ top: number; left: number } | null>(null); + const buttonRef = useRef(null); + const setQuotes = useSetRecoilState(store.pendingQuotesByConvoId(conversationId)); + + useEffect(() => { + const updateSelection = () => { + setSelection(readSelection()); + /** Recompute placement from scratch for the new selection. */ + setPos(null); + }; + const clearSelection = () => setSelection(null); + /** Hide the popup the instant the selection collapses or empties, including + * paths that fire no mouse/key event — e.g. a streaming markdown re-render + * replacing the selected text node, which would otherwise leave the button + * stranded over a now-collapsed caret. Only hides here; showing stays gated + * on mouseup/dblclick/keyup so an in-progress drag never flickers it. */ + const handleSelectionChange = () => { + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0 || sel.isCollapsed) { + setSelection(null); + } + }; + + document.addEventListener('mouseup', updateSelection); + /** Chromium commits a double-click word selection on `dblclick`, after + * `mouseup` has already read a still-collapsed range, so listen here too. */ + document.addEventListener('dblclick', updateSelection); + document.addEventListener('keyup', updateSelection); + document.addEventListener('selectionchange', handleSelectionChange); + document.addEventListener('scroll', clearSelection, true); + window.addEventListener('resize', clearSelection); + + return () => { + document.removeEventListener('mouseup', updateSelection); + document.removeEventListener('dblclick', updateSelection); + document.removeEventListener('keyup', updateSelection); + document.removeEventListener('selectionchange', handleSelectionChange); + document.removeEventListener('scroll', clearSelection, true); + window.removeEventListener('resize', clearSelection); + }; + }, []); + + /** Clamp using the button's real size so it never lands off-screen. Runs + * before paint, so the first visible frame is already in its final spot. */ + useLayoutEffect(() => { + if (!selection || !buttonRef.current) { + return; + } + const { width, height } = buttonRef.current.getBoundingClientRect(); + const maxLeft = Math.max(EDGE_MARGIN, window.innerWidth - width - EDGE_MARGIN); + const left = Math.min(Math.max(selection.centerX - width / 2, EDGE_MARGIN), maxLeft); + + const aboveTop = selection.top - POPUP_OFFSET - height; + const belowTop = selection.bottom + POPUP_OFFSET; + const maxTop = Math.max(EDGE_MARGIN, window.innerHeight - height - EDGE_MARGIN); + const top = aboveTop >= EDGE_MARGIN ? aboveTop : Math.min(belowTop, maxTop); + + setPos({ top: Math.max(top, EDGE_MARGIN), left }); + }, [selection]); + + const addQuote = useCallback(() => { + if (!selection) { + return; + } + setQuotes((prev) => + prev.includes(selection.text) || prev.length >= MAX_QUOTE_COUNT + ? prev + : [...prev, selection.text], + ); + setSelection(null); + setPos(null); + window.getSelection()?.removeAllRanges(); + document.getElementById(mainTextareaId)?.focus(); + }, [selection, setQuotes]); + + if (!selection) { + return null; + } + + return createPortal( + , + document.body, + ); +} + +export default memo(QuoteButton); diff --git a/client/src/components/Chat/Input/StopButton.tsx b/client/src/components/Chat/Input/StopButton.tsx index fd94ba806c9..cc45348072e 100644 --- a/client/src/components/Chat/Input/StopButton.tsx +++ b/client/src/components/Chat/Input/StopButton.tsx @@ -18,6 +18,7 @@ export default memo(function StopButton({ render={
{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/SpecIcon.tsx b/client/src/components/Chat/Menus/Endpoints/components/SpecIcon.tsx index 1a3d9ca4805..2e354db08b7 100644 --- a/client/src/components/Chat/Menus/Endpoints/components/SpecIcon.tsx +++ b/client/src/components/Chat/Menus/Endpoints/components/SpecIcon.tsx @@ -5,6 +5,7 @@ import type { IconMapProps } from '~/common'; import { getModelSpecIconURL, getIconKey } from '~/utils'; import { URLIcon } from '~/components/Endpoints/URLIcon'; import { icons } from '~/hooks/Endpoint/Icons'; +import { isImageURL } from '~/utils/icons'; interface SpecIconProps { currentSpec: TModelSpec; @@ -15,14 +16,15 @@ type IconType = (props: IconMapProps) => React.JSX.Element; const SpecIcon: React.FC = ({ currentSpec, endpointsConfig }) => { const iconURL = getModelSpecIconURL(currentSpec); - const { endpoint } = currentSpec.preset; + const endpoint = currentSpec.preset?.endpoint; const endpointIconURL = getEndpointField(endpointsConfig, endpoint, 'iconURL'); const iconKey = getIconKey({ endpoint, endpointsConfig, endpointIconURL }); + const shouldRenderURLIcon = isImageURL(iconURL); let Icon: IconType; - if (!iconURL.includes('http')) { + if (!shouldRenderURLIcon) { Icon = (icons[iconURL] ?? icons[iconKey] ?? icons.unknown) as IconType; - } else if (iconURL) { + } else { return ( = ({ currentSpec, endpointsConfig }) => endpoint={endpoint || undefined} /> ); - } else { - Icon = (icons[endpoint ?? ''] ?? icons[iconKey] ?? icons.unknown) as IconType; } 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/__tests__/SpecIcon.test.tsx b/client/src/components/Chat/Menus/Endpoints/components/__tests__/SpecIcon.test.tsx new file mode 100644 index 00000000000..e480a4bde70 --- /dev/null +++ b/client/src/components/Chat/Menus/Endpoints/components/__tests__/SpecIcon.test.tsx @@ -0,0 +1,90 @@ +import { render, screen } from '@testing-library/react'; +import { EModelEndpoint } from 'librechat-data-provider'; +import type { TModelSpec, TEndpointsConfig } from 'librechat-data-provider'; +import SpecIcon from '../SpecIcon'; + +jest.mock('~/hooks/Endpoint/Icons', () => { + const React = jest.requireActual('react'); + const createIcon = + (iconKey: string) => + ({ endpoint, iconURL }: { endpoint?: string | null; iconURL?: string }) => + React.createElement('span', { + 'data-testid': 'endpoint-icon', + 'data-icon-key': iconKey, + 'data-endpoint': endpoint ?? '', + 'data-icon-url': iconURL ?? '', + }); + + return { + icons: { + google: createIcon('google'), + openAI: createIcon('openAI'), + unknown: createIcon('unknown'), + }, + }; +}); + +jest.mock('~/components/Endpoints/URLIcon', () => { + const React = jest.requireActual('react'); + return { + URLIcon: ({ iconURL, endpoint }: { iconURL: string; endpoint?: string }) => + React.createElement('span', { + 'data-testid': 'url-icon', + 'data-icon-url': iconURL, + 'data-endpoint': endpoint ?? '', + }), + }; +}); + +describe('SpecIcon', () => { + const endpointsConfig = {} as TEndpointsConfig; + + it('renders the explicit spec icon when runtime spec data is missing preset', () => { + const currentSpec = { + name: 'gemini-test', + label: 'Gemini Test', + iconURL: EModelEndpoint.google, + } as TModelSpec; + + render(); + + expect(screen.getByTestId('endpoint-icon')).toHaveAttribute( + 'data-icon-key', + EModelEndpoint.google, + ); + expect(screen.getByTestId('endpoint-icon')).toHaveAttribute('data-endpoint', ''); + }); + + it('renders same-origin absolute spec icon URLs as images', () => { + const currentSpec = { + name: 'clickhouse-test', + label: 'ClickHouse Test', + iconURL: '/assets/clickhouse-logo.svg', + preset: { + endpoint: EModelEndpoint.anthropic, + }, + } as TModelSpec; + + render(); + + expect(screen.getByTestId('url-icon')).toHaveAttribute( + 'data-icon-url', + '/assets/clickhouse-logo.svg', + ); + expect(screen.getByTestId('url-icon')).toHaveAttribute( + 'data-endpoint', + EModelEndpoint.anthropic, + ); + }); + + it('falls back to the unknown icon when runtime spec data has no icon or preset', () => { + const currentSpec = { + name: 'gemini-test', + label: 'Gemini Test', + } as TModelSpec; + + render(); + + expect(screen.getByTestId('endpoint-icon')).toHaveAttribute('data-icon-key', 'unknown'); + }); +}); 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/Menus/OpenSidebar.tsx b/client/src/components/Chat/Menus/OpenSidebar.tsx index 0a932b2e448..e8a158a0014 100644 --- a/client/src/components/Chat/Menus/OpenSidebar.tsx +++ b/client/src/components/Chat/Menus/OpenSidebar.tsx @@ -1,6 +1,7 @@ import { startTransition } from 'react'; import { useSetRecoilState } from 'recoil'; import { TooltipAnchor, Button, Sidebar } from '@librechat/client'; +import { useShortcutAriaKey, useShortcutHint } from '~/hooks/useKeyboardShortcuts'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; import store from '~/store'; @@ -11,6 +12,8 @@ export const OPEN_SIDEBAR_ID = 'open-sidebar-button'; export default function OpenSidebar({ className }: { className?: string }) { const localize = useLocalize(); const setSidebarExpanded = useSetRecoilState(store.sidebarExpanded); + const tooltipDescription = useShortcutHint('toggleSidebar', localize('com_nav_open_sidebar')); + const ariaKey = useShortcutAriaKey('toggleSidebar'); const handleClick = () => { startTransition(() => { @@ -23,7 +26,7 @@ export default function OpenSidebar({ className }: { className?: string }) { return ( []; + onExpand?: () => void; }) { const localize = useLocalize(); const progress = useProgress(initialProgress); @@ -27,6 +29,16 @@ export default function CodeAnalyze({ } }, [autoExpand]); + const handleToggleCode = () => { + setShowCode((prev) => { + const next = !prev; + if (next) { + onExpand?.(); + } + return next; + }); + }; + const logs = outputs.reduce((acc, output) => { if (output['logs']) { return acc + output['logs'] + '\n'; @@ -42,7 +54,7 @@ export default function CodeAnalyze({
setShowCode((prev) => !prev)} + onClick={handleToggleCode} inProgressText={localize('com_ui_analyzing')} finishedText={localize('com_ui_analyzing_finished')} hasInput={!!code.length} diff --git a/client/src/components/Chat/Messages/Content/Container.tsx b/client/src/components/Chat/Messages/Content/Container.tsx index 7d5f8425115..042dc68c2f3 100644 --- a/client/src/components/Chat/Messages/Content/Container.tsx +++ b/client/src/components/Chat/Messages/Content/Container.tsx @@ -1,6 +1,7 @@ import { TMessage } from 'librechat-data-provider'; -import Files from './Files'; +import MessageQuotes from './MessageQuotes'; import SkillPills from './SkillPills'; +import Files from './Files'; const Container = ({ children, message }: { children: React.ReactNode; message?: TMessage }) => (
{message?.isCreatedByUser === true && ( <> + diff --git a/client/src/components/Chat/Messages/Content/ContentParts.tsx b/client/src/components/Chat/Messages/Content/ContentParts.tsx index 6a8a544d435..00569c11f54 100644 --- a/client/src/components/Chat/Messages/Content/ContentParts.tsx +++ b/client/src/components/Chat/Messages/Content/ContentParts.tsx @@ -1,4 +1,4 @@ -import { memo, useMemo, useCallback } from 'react'; +import { memo, useRef, useMemo, useCallback } from 'react'; import { ContentTypes } from 'librechat-data-provider'; import type { TMessageContentParts, @@ -6,11 +6,12 @@ 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 Container from './Container'; @@ -19,6 +20,18 @@ import Part from './Part'; const getToolCallId = (part: TMessageContentParts): string => (part?.[ContentTypes.TOOL_CALL] as Agents.ToolCall | undefined)?.id ?? ''; +const getToolGroupId = (parts: PartWithIndex[], fallbackScope: number): string => { + const firstPart = parts[0]; + if (!firstPart) { + return 'empty'; + } + const toolCallId = getToolCallId(firstPart.part); + if (toolCallId) { + return `tool:${toolCallId}`; + } + return `fallback:${fallbackScope}:${firstPart.idx}`; +}; + type PartWithContextProps = { part: TMessageContentParts; idx: number; @@ -32,6 +45,7 @@ type PartWithContextProps = { isLast: boolean; partAttachments: TAttachment[] | undefined; hideAttachments?: boolean; + onToolExpand?: () => void; }; const PartWithContext = memo(function PartWithContext({ @@ -47,6 +61,7 @@ const PartWithContext = memo(function PartWithContext({ isLast, partAttachments, hideAttachments, + onToolExpand, }: PartWithContextProps) { const contextValue = useMemo( () => ({ @@ -72,6 +87,7 @@ const PartWithContext = memo(function PartWithContext({ isLast={isLastPart} showCursor={isLastPart && isLast} hideAttachments={hideAttachments} + onToolExpand={onToolExpand} /> ); @@ -89,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 }; @@ -126,9 +144,31 @@ const ContentParts = memo(function ContentParts({ conversationId, isCreatedByUser, isLatestMessage, + createdAt, }: ContentPartsProps) { const attachmentMap = useMemo(() => mapAttachments(attachments ?? []), [attachments]); const effectiveIsSubmitting = isLatestMessage ? isSubmitting : false; + const toolGroupExpansionRef = useRef(new Map()); + const fallbackScopeRef = useRef({ messageId, scope: 0 }); + if (fallbackScopeRef.current.messageId !== messageId) { + if (!effectiveIsSubmitting) { + fallbackScopeRef.current.scope += 1; + toolGroupExpansionRef.current.clear(); + } + fallbackScopeRef.current.messageId = messageId; + } + const fallbackScope = fallbackScopeRef.current.scope; + + const handleGroupExpansionChange = useCallback( + (groupId: string, state: ToolCallGroupExpansionState) => { + if (!state.userOverride) { + toolGroupExpansionRef.current.delete(groupId); + return; + } + toolGroupExpansionRef.current.set(groupId, state); + }, + [], + ); /** * Interim skill cards — rendered in a separate slot ABOVE the Parts @@ -219,7 +259,7 @@ const ContentParts = memo(function ContentParts({ ); const renderGroupedPart = useCallback( - (part: TMessageContentParts, idx: number, isLastPart: boolean) => { + (part: TMessageContentParts, idx: number, isLastPart: boolean, onToolExpand?: () => void) => { return ( ); }, @@ -269,12 +310,13 @@ const ContentParts = memo(function ContentParts({ if (group.type === 'single') { return group; } + const groupId = getToolGroupId(group.parts, fallbackScope); const groupAttachments = group.parts.flatMap( ({ part }) => attachmentMap[getToolCallId(part)] ?? [], ); - return { ...group, groupAttachments }; + return { ...group, groupId, groupAttachments }; }), - [sequentialParts, attachmentMap], + [sequentialParts, attachmentMap, fallbackScope], ); // Early return: no content to render AND no pending skill cards @@ -336,6 +378,7 @@ const ContentParts = memo(function ContentParts({ p.idx === lastContentIdx)} renderPart={renderGroupedPart} lastContentIdx={lastContentIdx} groupAttachments={group.groupAttachments} + initialExpansionState={toolGroupExpansionRef.current.get(groupId)} + onExpansionChange={(state) => handleGroupExpansionChange(groupId, state)} /> ); })} diff --git a/client/src/components/Chat/Messages/Content/EditMessage.tsx b/client/src/components/Chat/Messages/Content/EditMessage.tsx index 53fa4f88009..2030c5ffc98 100644 --- a/client/src/components/Chat/Messages/Content/EditMessage.tsx +++ b/client/src/components/Chat/Messages/Content/EditMessage.tsx @@ -1,6 +1,6 @@ import { useRef, useEffect, useCallback } from 'react'; -import { useForm } from 'react-hook-form'; import { useRecoilValue } from 'recoil'; +import { useForm } from 'react-hook-form'; import { TextareaAutosize, TooltipAnchor } from '@librechat/client'; import { useUpdateMessageMutation } from 'librechat-data-provider/react-query'; import type { TEditProps } from '~/common'; @@ -65,6 +65,9 @@ const EditMessage = ({ * carry the picks forward so the new turn primes the same skills * instead of running unprimed. */ overrideManualSkills: message.manualSkills, + /** Carry the edited user message's quoted excerpts forward so the new + * turn sends the same referenced context the pills still show. */ + overrideQuotes: message.quotes, addedConvo: getAddedConvo() || undefined, }, ); @@ -88,6 +91,9 @@ const EditMessage = ({ * the same manual skills so the regenerated response is primed * identically. */ overrideManualSkills: parentMessage.manualSkills, + /** Replaying the parent user turn: keep its quoted excerpts so the + * regenerated response is sent the same referenced context. */ + overrideQuotes: parentMessage.quotes, addedConvo: getAddedConvo() || undefined, }, ); diff --git a/client/src/components/Chat/Messages/Content/FilePreviewDialog.tsx b/client/src/components/Chat/Messages/Content/FilePreviewDialog.tsx index 45881749f72..79886c5d73c 100644 --- a/client/src/components/Chat/Messages/Content/FilePreviewDialog.tsx +++ b/client/src/components/Chat/Messages/Content/FilePreviewDialog.tsx @@ -3,9 +3,10 @@ import copy from 'copy-to-clipboard'; import { useRecoilValue } from 'recoil'; import { Download } from 'lucide-react'; import { OGDialog, OGDialogContent, OGDialogTitle, OGDialogDescription } from '@librechat/client'; -import CopyButton from '~/components/Messages/Content/CopyButton'; +import { useFileDownload, useSharedFileDownload } from '~/data-provider'; import { logger, sortPagesByRelevance, triggerDownload } from '~/utils'; -import { useFileDownload } from '~/data-provider'; +import CopyButton from '~/components/Messages/Content/CopyButton'; +import { useShareContext } from '~/Providers'; import { useLocalize } from '~/hooks'; import store from '~/store'; @@ -14,6 +15,7 @@ interface FilePreviewDialogProps { onOpenChange: (open: boolean) => void; fileName: string; fileId?: string; + filePath?: string; relevance?: number; pages?: number[]; pageRelevance?: Record; @@ -128,6 +130,7 @@ export default function FilePreviewDialog({ onOpenChange, fileName, fileId, + filePath, relevance, pages, pageRelevance, @@ -136,7 +139,13 @@ export default function FilePreviewDialog({ }: FilePreviewDialogProps) { const localize = useLocalize(); const user = useRecoilValue(store.user); - const { refetch: downloadFile } = useFileDownload(user?.id ?? '', fileId, { direct: false }); + const { shareId } = useShareContext(); + const { refetch: downloadOwned } = useFileDownload(user?.id ?? '', fileId, { direct: false }); + const { refetch: downloadShared } = useSharedFileDownload(shareId, fileId); + // Use the share route only for snapshotted files (filepath rewritten to the + // share path); otherwise fall back to the owner route. + const useShared = !!shareId && (filePath?.startsWith('/api/share/') ?? false); + const downloadFile = useShared ? downloadShared : downloadOwned; const [fileContent, setFileContent] = useState(null); const [fileBlobUrl, setFileBlobUrl] = useState(null); diff --git a/client/src/components/Chat/Messages/Content/Files.tsx b/client/src/components/Chat/Messages/Content/Files.tsx index 176507edc1b..4e4d85060c2 100644 --- a/client/src/components/Chat/Messages/Content/Files.tsx +++ b/client/src/components/Chat/Messages/Content/Files.tsx @@ -46,6 +46,7 @@ const Files = ({ message }: { message?: TMessage }) => { onOpenChange={handleClose} fileName={selectedFile?.filename ?? ''} fileId={selectedFile?.file_id} + filePath={selectedFile?.filepath} fileType={selectedFile?.type ?? undefined} fileSize={(selectedFile as TFile)?.bytes} /> diff --git a/client/src/components/Chat/Messages/Content/Image.tsx b/client/src/components/Chat/Messages/Content/Image.tsx index 7e3e12e65bd..070d379c7c4 100644 --- a/client/src/components/Chat/Messages/Content/Image.tsx +++ b/client/src/components/Chat/Messages/Content/Image.tsx @@ -51,16 +51,17 @@ const Image = ({ const absoluteImageUrl = useMemo(() => { if (!imagePath) return imagePath; - if ( - imagePath.startsWith('http') || - imagePath.startsWith('data:') || - !imagePath.startsWith('/images/') - ) { + if (imagePath.startsWith('http') || imagePath.startsWith('data:')) { return imagePath; } - const baseURL = apiBaseUrl(); - return `${baseURL}${imagePath}`; + // Root-relative server paths (`/images/...` static, `/api/share/...` share + // routes) are resolved against the API base so they load under a subpath. + if (imagePath.startsWith('/images/') || imagePath.startsWith('/api/')) { + return `${apiBaseUrl()}${imagePath}`; + } + + return imagePath; }, [imagePath]); const downloadImage = async () => { diff --git a/client/src/components/Chat/Messages/Content/Markdown.tsx b/client/src/components/Chat/Messages/Content/Markdown.tsx index 1217869a2c3..54e01e11452 100644 --- a/client/src/components/Chat/Messages/Content/Markdown.tsx +++ b/client/src/components/Chat/Messages/Content/Markdown.tsx @@ -1,25 +1,9 @@ import React, { memo, useMemo } from 'react'; -import remarkGfm from 'remark-gfm'; -import remarkMath from 'remark-math'; -import supersub from 'remark-supersub'; -import rehypeKatex from 'rehype-katex'; import { useRecoilValue } from 'recoil'; -import ReactMarkdown from 'react-markdown'; -import rehypeHighlight from 'rehype-highlight'; -import remarkDirective from 'remark-directive'; -import type { Pluggable } from 'unified'; -import { Citation, CompositeCitation, HighlightedText } from '~/components/Web/Citation'; -import { - mcpUIResourcePlugin, - MCPUIResource, - MCPUIResourceCarousel, -} from '~/components/MCPUIResource'; -import { Artifact, artifactPlugin } from '~/components/Artifacts/Artifact'; -import { ArtifactProvider, CodeBlockProvider } from '~/Providers'; +import { getRemarkPlugins, getRehypePlugins, getMarkdownComponents } from './markdownConfig'; import MarkdownErrorBoundary from './MarkdownErrorBoundary'; -import { langSubset, preprocessLaTeX } from '~/utils'; -import { unicodeCitation } from '~/components/Web'; -import { code, a, p, img } from './MarkdownComponents'; +import MarkdownBlocks from './MarkdownBlocks'; +import { preprocessLaTeX } from '~/utils'; import store from '~/store'; type TContentProps = { @@ -38,31 +22,6 @@ const Markdown = memo(function Markdown({ content = '', isLatestMessage }: TCont return LaTeXParsing ? preprocessLaTeX(content) : content; }, [content, LaTeXParsing, isInitializing]); - const rehypePlugins = useMemo( - () => [ - [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( + {quotes.map((text, index) => ( +
+
+ ))} +
+ ); +} + +export default memo(MessageQuotes); diff --git a/client/src/components/Chat/Messages/Content/ParallelContent.tsx b/client/src/components/Chat/Messages/Content/ParallelContent.tsx index 2c56bc04e3e..b1d75c86ef9 100644 --- a/client/src/components/Chat/Messages/Content/ParallelContent.tsx +++ b/client/src/components/Chat/Messages/Content/ParallelContent.tsx @@ -1,10 +1,10 @@ import { memo, useMemo } from 'react'; import type { TMessageContentParts, SearchResultData, TAttachment } from 'librechat-data-provider'; -import { SearchContext } from '~/Providers'; import MemoryArtifacts from './MemoryArtifacts'; import Sources from '~/components/Web/Sources'; -import { EmptyText } from './Parts'; +import { SearchContext } from '~/Providers'; import SiblingHeader from './SiblingHeader'; +import { EmptyText } from './Parts'; import Container from './Container'; import { cn } from '~/utils'; @@ -137,6 +137,7 @@ type ParallelColumnsProps = { columns: ParallelColumn[]; groupId: number; messageId: string; + createdAt?: string | null; isSubmitting: boolean; lastContentIdx: number; conversationId?: string | null; @@ -150,6 +151,7 @@ export const ParallelColumns = memo(function ParallelColumns({ columns, groupId, messageId, + createdAt, conversationId, isSubmitting, lastContentIdx, @@ -169,6 +171,7 @@ export const ParallelColumns = memo(function ParallelColumns({ @@ -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 888298cdb83..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'; @@ -40,6 +41,7 @@ type PartProps = { isCreatedByUser: boolean; attachments?: TAttachment[]; hideAttachments?: boolean; + onToolExpand?: () => void; }; const Part = memo(function Part({ @@ -50,6 +52,7 @@ const Part = memo(function Part({ showCursor, isCreatedByUser, hideAttachments, + onToolExpand, }: PartProps) { if (!part) { return null; @@ -143,6 +146,7 @@ const Part = memo(function Part({ attachments={attachments} commandField="code" hideAttachments={hideAttachments} + onExpand={onToolExpand} /> ); } else if ( @@ -159,6 +163,7 @@ const Part = memo(function Part({ initialProgress={toolCall.progress ?? 0.1} args={toolCall.args} hideAttachments={hideAttachments} + onExpand={onToolExpand} /> ); } else if ( @@ -187,6 +192,7 @@ const Part = memo(function Part({ isSubmitting={isSubmitting} attachments={attachments} hideAttachments={hideAttachments} + onExpand={onToolExpand} /> ); } else if (isToolCall && toolCall.name === Constants.SUBAGENT) { @@ -222,6 +228,20 @@ const Part = memo(function Part({ isSubmitting={isSubmitting} attachments={attachments} hideAttachments={hideAttachments} + onExpand={onToolExpand} + /> + ); + } else if (isToolCall && (toolCall.name === 'create_file' || toolCall.name === 'edit_file')) { + return ( + ); } else if (isToolCall && toolCall.name === Tools.bash_tool) { @@ -233,6 +253,7 @@ const Part = memo(function Part({ isSubmitting={isSubmitting} attachments={attachments} hideAttachments={hideAttachments} + onExpand={onToolExpand} /> ); } else if (isToolCall && toolCall.name === Tools.web_search) { @@ -243,6 +264,7 @@ const Part = memo(function Part({ isSubmitting={isSubmitting} attachments={attachments} isLast={isLast} + onExpand={onToolExpand} /> ); } else if (isToolCall && (toolCall.name === 'file_search' || toolCall.name === 'retrieval')) { @@ -252,6 +274,7 @@ const Part = memo(function Part({ isSubmitting={isSubmitting} output={toolCall.output ?? undefined} attachments={attachments} + onExpand={onToolExpand} /> ); } else if (isToolCall && toolCall.name?.startsWith(Constants.LC_TRANSFER_TO_)) { @@ -268,6 +291,7 @@ const Part = memo(function Part({ auth={toolCall.auth} isLast={isLast} hideAttachments={hideAttachments} + onExpand={onToolExpand} /> ); } else if (toolCall.type === ToolCallTypes.CODE_INTERPRETER) { @@ -277,6 +301,7 @@ const Part = memo(function Part({ initialProgress={toolCall.progress ?? 0.1} code={code_interpreter.input} outputs={code_interpreter.outputs ?? []} + onExpand={onToolExpand} /> ); } else if ( @@ -289,6 +314,7 @@ const Part = memo(function Part({ isSubmitting={isSubmitting} output={(toolCall as { output?: string }).output} attachments={attachments} + onExpand={onToolExpand} /> ); } else if ( @@ -326,6 +352,7 @@ const Part = memo(function Part({ output={toolCall.function.output} isLast={isLast} hideAttachments={hideAttachments} + onExpand={onToolExpand} /> ); } diff --git a/client/src/components/Chat/Messages/Content/Parts/Attachment.tsx b/client/src/components/Chat/Messages/Content/Parts/Attachment.tsx index 65aa00ed1cc..e6525ff8c4f 100644 --- a/client/src/components/Chat/Messages/Content/Parts/Attachment.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/Attachment.tsx @@ -1,5 +1,5 @@ import { memo, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from 'react'; -import { Loader2, AlertCircle, Download } from 'lucide-react'; +import { Loader2, AlertCircle, Download, ChevronDown, Files as FilesIcon } from 'lucide-react'; import { Tools } from 'librechat-data-provider'; import type { TAttachment, TFile, TAttachmentMetadata } from 'librechat-data-provider'; import type { ToolArtifactType } from '~/utils/artifacts'; @@ -20,7 +20,7 @@ import Image from '~/components/Chat/Messages/Content/Image'; import ToolMermaidArtifact from './ToolMermaidArtifact'; import ToolArtifactCard from './ToolArtifactCard'; import { useAttachmentLink } from './LogLink'; -import { useLocalize, useAttachmentPreviewSync } from '~/hooks'; +import { useLocalize, useAttachmentPreviewSync, useExpandCollapse } from '~/hooks'; import { cn, getFileType } from '~/utils'; const COLLAPSED_MAX_HEIGHT = 320; @@ -197,92 +197,232 @@ const FileAttachment = memo(({ attachment }: { attachment: Partial ); }); -const TextAttachment = memo(({ attachment }: { attachment: Partial }) => { +const FileAttachmentGroup = memo(({ attachments }: { attachments: TAttachment[] }) => { const localize = useLocalize(); - const preId = useId(); - const preRef = useRef(null); - const [isVisible, setIsVisible] = useState(false); - const [expanded, setExpanded] = useState(false); - // Decided once after layout: does the text actually overflow the collapsed - // height? Char count is a poor proxy (a 100-char file with many newlines can - // overflow; 800 chars of dense single-line text may not), so we measure. - const [overflowed, setOverflowed] = useState(false); - const file = attachment as TFile & TAttachmentMetadata; - const { handleDownload } = useAttachmentLink({ - href: attachment.filepath ?? '', - filename: attachment.filename ?? '', - file_id: file.file_id, - user: file.user, - source: file.source, - }); - const extension = attachment.filename?.split('.').pop(); - const text = file.text ?? ''; + const panelId = useId(); + const [isExpanded, setIsExpanded] = useState(false); + const { style: expandStyle, ref: expandRef } = useExpandCollapse(isExpanded); + const visibleAttachments = useMemo( + () => attachments.filter((attachment) => Boolean(attachment.filepath)), + [attachments], + ); + const count = visibleAttachments.length; + const summary = useMemo(() => { + const names = visibleAttachments.map((attachment) => displayFilename(attachment.filename)); + if (names.length <= 2) { + return names.join(', '); + } + return `${names.slice(0, 2).join(', ')} ${localize('com_ui_plus_n_more', { + 0: String(names.length - 2), + })}`; + }, [visibleAttachments, localize]); + const groupedAttachments = useMemo(() => { + const files: TAttachment[] = []; + const textPreviews: TAttachment[] = []; + for (const attachment of visibleAttachments) { + if (isTextAttachment(attachment)) { + textPreviews.push(attachment); + continue; + } + files.push(attachment); + } + return { files, textPreviews }; + }, [visibleAttachments]); - useEffect(() => { - const timer = setTimeout(() => setIsVisible(true), 50); - return () => clearTimeout(timer); - }, []); + if (count === 0) { + return null; + } - useLayoutEffect(() => { - const el = preRef.current; - if (!el) { - return; + if (count === 1) { + const [attachment] = visibleAttachments; + if (!attachment) { + return null; } - setOverflowed(el.scrollHeight > COLLAPSED_MAX_HEIGHT + 1); - }, [text]); + return ( +
+ +
+ ); + } - const isClamped = overflowed && !expanded; + const fileCount = localize('com_ui_n_files', { 0: String(count) }); + const buttonLabel = isExpanded + ? localize('com_ui_hide_n_files', { 0: String(count) }) + : localize('com_ui_show_n_files', { 0: String(count) }); return ( -
- {attachment.filepath && ( - - )} -
-
+      
- {overflowed && ( - - )} + aria-hidden="true" + /> + +
+
+
+ {groupedAttachments.files.length > 0 && ( +
+ {groupedAttachments.files.map((attachment, index) => ( + + ))} +
+ )} + {groupedAttachments.textPreviews.map((attachment, index) => ( + + ))} +
+
); }); +FileAttachmentGroup.displayName = 'FileAttachmentGroup'; + +const TextAttachment = memo( + ({ + attachment, + showFileChip = true, + }: { + attachment: Partial; + showFileChip?: boolean; + }) => { + const localize = useLocalize(); + const preId = useId(); + const preRef = useRef(null); + const [isVisible, setIsVisible] = useState(false); + const [expanded, setExpanded] = useState(false); + // Decided once after layout: does the text actually overflow the collapsed + // height? Char count is a poor proxy (a 100-char file with many newlines can + // overflow; 800 chars of dense single-line text may not), so we measure. + const [overflowed, setOverflowed] = useState(false); + const file = attachment as TFile & TAttachmentMetadata; + const { handleDownload } = useAttachmentLink({ + href: attachment.filepath ?? '', + filename: attachment.filename ?? '', + file_id: file.file_id, + user: file.user, + source: file.source, + }); + const extension = attachment.filename?.split('.').pop(); + const text = file.text ?? ''; + const visibleFilename = displayFilename(attachment.filename); + + useEffect(() => { + const timer = setTimeout(() => setIsVisible(true), 50); + return () => clearTimeout(timer); + }, []); + + useLayoutEffect(() => { + const el = preRef.current; + if (!el) { + return; + } + setOverflowed(el.scrollHeight > COLLAPSED_MAX_HEIGHT + 1); + }, [text]); + + const isClamped = overflowed && !expanded; + + return ( +
+ {attachment.filepath && showFileChip && ( + + )} +
+ {!showFileChip && ( +
+ + {visibleFilename} + + {attachment.filepath && ( + + )} +
+ )} +
+
+              {text}
+            
+ {overflowed && ( + + )} +
+
+
+ ); + }, +); const ImageAttachment = memo(({ attachment }: { attachment: TAttachment }) => { const [isLoaded, setIsLoaded] = useState(false); @@ -449,19 +589,24 @@ export function AttachmentGroup({ attachments }: { attachments?: TAttachment[] } mermaidArtifacts.sort(bySalience); imageAttachments.sort(bySalience); + const downloadableFileAttachments = fileAttachments.filter((attachment) => + Boolean(attachment.filepath), + ); + const downloadableTextAttachments = textAttachments.filter((attachment) => + Boolean(attachment.filepath), + ); + const textOnlyAttachments = textAttachments.filter((attachment) => !attachment.filepath); + const groupDownloadableFiles = + downloadableFileAttachments.length + downloadableTextAttachments.length > 1; + const groupedFileAttachments = groupDownloadableFiles + ? [...downloadableFileAttachments, ...downloadableTextAttachments].sort(bySalience) + : downloadableFileAttachments; + const visibleTextAttachments = groupDownloadableFiles ? textOnlyAttachments : textAttachments; + return ( <> - {fileAttachments.length > 0 && ( -
- {fileAttachments.map((attachment, index) => - attachment.filepath ? ( - - ) : null, - )} -
+ {groupedFileAttachments.length > 0 && ( + )} {(resolvedPanel.length > 0 || pendingPanel.length > 0) && (
@@ -492,9 +637,9 @@ export function AttachmentGroup({ attachments }: { attachments?: TAttachment[] } ))}
)} - {textAttachments.length > 0 && ( + {visibleTextAttachments.length > 0 && (
- {textAttachments.map((attachment, index) => ( + {visibleTextAttachments.map((attachment, index) => ( void; }) { const localize = useLocalize(); const command = useMemo(() => parseJsonField(args, commandField), [args, commandField]); const isWritingCommand = !command || !areToolCallArgsComplete(args); const { showCode, toggleCode, expandStyle, expandRef, progress, cancelled, hasError, hasOutput } = - useToolCallState(initialProgress, isSubmitting, output, !!command); + useToolCallState(initialProgress, isSubmitting, output, !!command, onExpand); const highlighted = useLazyHighlight(command || undefined, 'bash'); const outputHasError = useMemo(() => ERROR_PATTERNS.test(output), [output]); diff --git a/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx b/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx index 018fe50ee74..c2ab2d575eb 100644 --- a/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx @@ -57,6 +57,7 @@ export default function ExecuteCode({ output = '', attachments, hideAttachments = false, + onExpand, }: { initialProgress: number; isSubmitting: boolean; @@ -64,12 +65,13 @@ export default function ExecuteCode({ output?: string; attachments?: TAttachment[]; hideAttachments?: boolean; + onExpand?: () => void; }) { const localize = useLocalize(); const { lang = 'py', code } = useParseArgs(args) ?? ({} as ParsedArgs); const { showCode, toggleCode, expandStyle, expandRef, progress, cancelled, hasError, hasOutput } = - useToolCallState(initialProgress, isSubmitting, output, !!code); + useToolCallState(initialProgress, isSubmitting, output, !!code, onExpand); const highlighted = useLazyHighlight(code, lang); const outputHasError = useMemo(() => ERROR_PATTERNS.test(output), [output]); diff --git a/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx b/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx new file mode 100644 index 00000000000..ae8b6fe993c --- /dev/null +++ b/client/src/components/Chat/Messages/Content/Parts/FileAuthoringCall.tsx @@ -0,0 +1,215 @@ +import { useMemo } from 'react'; +import { FilePenLine, FilePlus2 } from 'lucide-react'; +import type { TAttachment } from 'librechat-data-provider'; +import parseJsonField, { parseJsonFieldOccurrences } from './parseJsonField'; +import ProgressText from '~/components/Chat/Messages/Content/ProgressText'; +import useToolCallState from './useToolCallState'; +import useLazyHighlight from './useLazyHighlight'; +import CodeWindowHeader from './CodeWindowHeader'; +import { AttachmentGroup } from './Attachment'; +import { langFromPath } from './ReadFileCall'; +import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; + +type FileAuthoringToolName = 'create_file' | 'edit_file'; + +type ToolCallArgs = string | Record | 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/LogLink.tsx b/client/src/components/Chat/Messages/Content/Parts/LogLink.tsx index 50716f05c85..7d81a7c42e7 100644 --- a/client/src/components/Chat/Messages/Content/Parts/LogLink.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/LogLink.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import { FileSources } from 'librechat-data-provider'; import { useToastContext } from '@librechat/client'; +import { FileSources, sharedFileDownload } from 'librechat-data-provider'; import { useCodeOutputDownload, useFileDownload } from '~/data-provider'; import { isHttpDownloadTarget, triggerDownload } from '~/utils'; +import { useShareContext } from '~/Providers'; interface LogLinkProps { href: string; @@ -47,6 +48,7 @@ export const useAttachmentLink = ({ source, }: AttachmentLinkOptions) => { const { showToast } = useToastContext(); + const { shareId } = useShareContext(); const useLocalDownload = isLocallyStoredSource(source) && !!file_id && !!user; const { refetch: downloadFromApi } = useFileDownload(user, file_id, { source }); @@ -55,6 +57,15 @@ export const useAttachmentLink = ({ const handleDownload = async (event: React.MouseEvent) => { event.preventDefault(); try { + // In a shared view, a snapshotted file's href is rewritten to the share + // route; download it through the share-scoped path (authorized by share + // permission, not owner ACL). Non-snapshotted files fall through so the + // original href / code-output path still works when snapshots are disabled. + if (shareId && file_id && href.startsWith('/api/share/')) { + triggerDownload(sharedFileDownload(shareId, file_id), filename); + return; + } + if (!useLocalDownload && isHttpDownloadTarget(href)) { triggerDownload(href, filename); return; diff --git a/client/src/components/Chat/Messages/Content/Parts/ReadFileCall.tsx b/client/src/components/Chat/Messages/Content/Parts/ReadFileCall.tsx index 98bd7dfd9ff..da9d5e3806f 100644 --- a/client/src/components/Chat/Messages/Content/Parts/ReadFileCall.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/ReadFileCall.tsx @@ -68,6 +68,7 @@ export default function ReadFileCall({ output = '', attachments, hideAttachments = false, + onExpand, }: { initialProgress: number; isSubmitting: boolean; @@ -75,6 +76,7 @@ export default function ReadFileCall({ output?: string; attachments?: TAttachment[]; hideAttachments?: boolean; + onExpand?: () => void; }) { const localize = useLocalize(); const filePath = useMemo(() => parseJsonField(args, 'file_path'), [args]); @@ -82,7 +84,7 @@ export default function ReadFileCall({ const lang = useMemo(() => langFromPath(filePath), [filePath]); const { showCode, toggleCode, expandStyle, expandRef, progress, cancelled, hasError, hasOutput } = - useToolCallState(initialProgress, isSubmitting, output, !!filePath); + useToolCallState(initialProgress, isSubmitting, output, !!filePath, onExpand); const highlighted = useLazyHighlight(hasOutput ? output : undefined, lang); diff --git a/client/src/components/Chat/Messages/Content/Parts/SkillCall.tsx b/client/src/components/Chat/Messages/Content/Parts/SkillCall.tsx index 7f99ada76ee..fae460a3d6d 100644 --- a/client/src/components/Chat/Messages/Content/Parts/SkillCall.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/SkillCall.tsx @@ -16,6 +16,7 @@ export default function SkillCall({ output = '', attachments, hideAttachments = false, + onExpand, }: { initialProgress: number; isSubmitting: boolean; @@ -23,12 +24,13 @@ export default function SkillCall({ output?: string; attachments?: TAttachment[]; hideAttachments?: boolean; + onExpand?: () => void; }) { const localize = useLocalize(); const skillName = useMemo(() => parseJsonField(args, 'skillName'), [args]); const { showCode, toggleCode, expandStyle, expandRef, progress, cancelled, hasError, hasOutput } = - useToolCallState(initialProgress, isSubmitting, output, !!skillName); + useToolCallState(initialProgress, isSubmitting, output, !!skillName, onExpand); return ( <> diff --git a/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx b/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx index 6a94c079c83..23f5537e24a 100644 --- a/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx @@ -42,10 +42,10 @@ interface SubagentCallProps { } const TICKER_MAX_LINES = 3; -/** Trailing-edge throttle window for the live preview. Tuned down from - * the original 1.2s so the ticker feels snappy when the container is - * already full and frames are scrolling. */ -const TICKER_THROTTLE_MS = 800; +/** Trailing-edge refresh window for the live preview once the ticker has + * enough text to fill the row. Keeps long streaming lines from repainting + * every token while still letting the collapsed subagent UI feel responsive. */ +export const SUBAGENT_TICKER_THROTTLE_MS = 400; /** Below this live-buffer length we skip throttling entirely. Without * this the user would see "Reasoning: I" for ~1s while the model * streams the rest of the sentence — the pass-through lets early @@ -243,7 +243,7 @@ export default function SubagentCall({ const displayedTickerLines = useThrottledValue( tickerLines, - TICKER_THROTTLE_MS, + SUBAGENT_TICKER_THROTTLE_MS, shouldThrottleTicker, ); @@ -293,7 +293,12 @@ export default function SubagentCall({ * from routing through `Parts/index`. */ const renderDialogPart = useCallback( - (part: TMessageContentParts, idx: number, isLastPart: boolean): JSX.Element | null => { + ( + part: TMessageContentParts, + idx: number, + isLastPart: boolean, + onToolExpand?: () => void, + ): JSX.Element | null => { return ( ); }, @@ -811,11 +817,13 @@ function SubagentDialogPart({ isSubmitting, showCursor, isLast, + onToolExpand, }: { part: TMessageContentParts; isSubmitting: boolean; showCursor: boolean; isLast: boolean; + onToolExpand?: () => void; }): JSX.Element | null { if (part.type === ContentTypes.TEXT) { const text = (part as { text: string }).text; @@ -849,6 +857,7 @@ function SubagentDialogPart({ isSubmitting={isSubmitting} isLast={isLast} name={tc.name ?? ''} + onExpand={onToolExpand} /> ); } 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 ac9910bd0f2..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'; @@ -16,6 +16,10 @@ jest.mock('~/hooks', () => ({ * routing tests don't exercise the preview flow itself — stub it * to a no-op so it doesn't blow up jsdom rendering. */ useAttachmentPreviewSync: () => ({ status: 'ready', previewError: undefined, isPolling: false }), + useExpandCollapse: (isExpanded: boolean) => ({ + style: { display: 'grid', gridTemplateRows: isExpanded ? '1fr' : '0fr' }, + ref: { current: null }, + }), })); jest.mock('../LogLink', () => ({ @@ -194,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(); @@ -540,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); @@ -576,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); @@ -706,16 +707,15 @@ 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"]')); expect(chips.length).toBe(2); const filenames = chips.map((c) => c.textContent ?? ''); @@ -724,6 +724,46 @@ describe('AttachmentGroup routing', () => { expect(filenames[1]).toMatch(/placeholder\.zip/); }); + it('keeps multiple downloadable files in their own collapsed group while images render outwardly', () => { + const first = baseAttachment({ + file_id: 'file-a', + filename: 'a.zip', + }); + const second = baseAttachment({ + file_id: 'file-b', + filename: 'b.zip', + }); + const json = baseAttachment({ + file_id: 'file-c', + filename: 'c.json', + text: '{"c":true}', + }); + const image = baseAttachment({ + file_id: 'image-a', + filename: 'preview.png', + width: 16, + height: 16, + }); + + const { container } = renderWith( + , + ); + + const toggle = screen.getByRole('button', { name: 'com_ui_show_n_files' }); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + const panel = document.getElementById(toggle.getAttribute('aria-controls') ?? ''); + expect(panel?.firstElementChild).toHaveAttribute('aria-hidden', 'true'); + expect(screen.getByTestId('image')).toBeInTheDocument(); + expect(screen.getAllByTestId('file-container').map((chip) => chip.textContent)).not.toContain( + 'c.json', + ); + + fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + expect(screen.getByText('c.json')).toBeInTheDocument(); + expect(container.querySelector('pre')?.textContent).toBe('{"c":true}'); + }); + it('passes a non-dotfile filename through to FileContainer unchanged', () => { /** `displayFilename` deliberately leaves non-dotfile names alone — * the `-<6 hex>` tail on `archive-deadbe.zip` could be either a @@ -735,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'); @@ -753,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'); @@ -774,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({ @@ -810,7 +847,6 @@ describe('AttachmentGroup routing', () => { baseAttachment({ file_id: 'c', filename: 'data.json', - type: 'application/json', text: '{"a":1}', } as Partial), baseAttachment({ @@ -826,9 +862,15 @@ describe('AttachmentGroup routing', () => { expect(screen.getByText('index.html')).toBeInTheDocument(); // Mermaid render expect(screen.getByTestId('mermaid-render')).toBeInTheDocument(); - // Inline text fallback for JSON (CSV now goes to SPREADSHEET) - expect(container.querySelector('pre')).not.toBeNull(); - // FileContainer for the plain zip (and potentially others) - expect(screen.getAllByTestId('file-container').length).toBeGreaterThan(0); + // JSON and plain zip are both downloadable file outputs, so they collapse together. + const toggle = screen.getByRole('button', { name: 'com_ui_show_n_files' }); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + const chipLabels = screen.getAllByTestId('file-container').map((chip) => chip.textContent); + expect(chipLabels).toContain('archive.zip'); + expect(chipLabels).not.toContain('data.json'); + + fireEvent.click(toggle); + expect(screen.getByText('data.json')).toBeInTheDocument(); + expect(container.querySelector('pre')?.textContent).toBe('{"a":1}'); }); }); 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__/LogLink.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/LogLink.test.tsx index 6b408acb332..f4e1257a700 100644 --- a/client/src/components/Chat/Messages/Content/Parts/__tests__/LogLink.test.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/__tests__/LogLink.test.tsx @@ -1,12 +1,13 @@ import React from 'react'; -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import { FileSources } from 'librechat-data-provider'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; import LogLink from '../LogLink'; const mockShowToast = jest.fn(); const mockDownloadFromApi = jest.fn(); const mockDownloadFromUrl = jest.fn(); const mockTriggerDownload = jest.fn(); +let mockShareContext: { shareId?: string } = {}; jest.mock('@librechat/client', () => ({ useToastContext: () => ({ showToast: mockShowToast }), @@ -17,6 +18,10 @@ jest.mock('~/data-provider', () => ({ useCodeOutputDownload: () => ({ refetch: mockDownloadFromUrl }), })); +jest.mock('~/Providers', () => ({ + useShareContext: () => mockShareContext, +})); + jest.mock('~/utils', () => ({ isHttpDownloadTarget: (target?: string | null) => /^https?:\/\//i.test(target ?? ''), triggerDownload: (...args: Parameters) => @@ -26,6 +31,7 @@ jest.mock('~/utils', () => ({ describe('LogLink download routing', () => { beforeEach(() => { jest.clearAllMocks(); + mockShareContext = {}; }); it('navigates directly to http URLs when no stored file metadata is available', async () => { @@ -76,6 +82,28 @@ describe('LogLink download routing', () => { expect(mockDownloadFromUrl).not.toHaveBeenCalled(); }); + it('routes downloads through the share-scoped route in a shared view', async () => { + mockShareContext = { shareId: 'share-9' }; + const filename = 'file.pdf'; + + render( + + {filename} + , + ); + + fireEvent.click(screen.getByRole('link', { name: filename })); + + await waitFor(() => { + expect(mockTriggerDownload).toHaveBeenCalledWith( + '/api/share/share-9/files/file-1/download', + 'file.pdf', + ); + }); + expect(mockDownloadFromApi).not.toHaveBeenCalled(); + expect(mockDownloadFromUrl).not.toHaveBeenCalled(); + }); + it('keeps legacy code-output handles on the blob download path', async () => { const filename = 'legacy.txt'; mockDownloadFromUrl.mockResolvedValue({ data: 'blob:https://app.example.com/file' }); 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 47a9aa12cb7..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 @@ -1,6 +1,6 @@ import React from 'react'; import { RecoilRoot, useRecoilCallback } from 'recoil'; -import { render, screen, act, fireEvent, waitFor } from '@testing-library/react'; +import { render, screen, act, fireEvent, waitFor, within } from '@testing-library/react'; import type { SubagentUpdateEvent } from 'librechat-data-provider'; import type { SubagentContentPart, @@ -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 SubagentCall, { SUBAGENT_TICKER_THROTTLE_MS } from '../SubagentCall'; import { subagentProgressByToolCallId } from '~/store/subagents'; -import SubagentCall from '../SubagentCall'; jest.mock('~/hooks', () => ({ useLocalize: @@ -84,6 +83,7 @@ jest.mock('../Attachment', () => ({ })); jest.mock('@librechat/client', () => ({ + __esModule: true, OGDialog: ({ children }: { children: React.ReactNode }) => <>{children}, OGDialogContent: ({ children }: { children: React.ReactNode }) => (
{children}
@@ -131,6 +131,10 @@ jest.mock('~/utils', () => ({ cn: (...classes: unknown[]) => classes.filter(Boolean).join(' '), })); +afterEach(() => { + jest.useRealTimers(); +}); + /** The dialog wraps single parts in `Container` and grouped tool_calls in * `ToolCallGroup`. Stub both as transparent wrappers so the tests still * assert on the leaf renderers (Text/Reasoning/ToolCall) without pulling @@ -229,10 +233,20 @@ function renderWithState(args: { /> , ); - act(() => { - setter.current?.(args.progress ?? null); - }); - return rendered; + const setProgress = (next: SubagentProgress | null) => { + act(() => { + setter.current?.(next); + }); + }; + setProgress(args.progress ?? null); + return { ...rendered, setProgress }; +} + +/** Open the subagent dialog. Required when another test file in the same Jest + * worker has already loaded the real `@librechat/client` module; Radix only + * mounts dialog content while `open` is true. */ +function openSubagentDialog(headerLabel = 'Ran agent') { + fireEvent.click(screen.getByRole('button', { name: headerLabel })); } describe('SubagentCall — status resolution', () => { @@ -443,6 +457,52 @@ describe('SubagentCall — ticker', () => { /** Only one "Writing:" label, not three — deltas collapse into one live line. */ expect(screen.getAllByText('Writing:')).toHaveLength(1); }); + + it('refreshes long live previews after the subagent ticker throttle window', () => { + jest.useFakeTimers(); + const firstPreview = 'First live preview '.repeat(8).trim(); + const secondPreview = 'Second live preview '.repeat(8).trim(); + const eventForText = (text: string): SubagentUpdateEvent => ({ + runId: 'p', + subagentRunId: 'run_a', + subagentType: 'self', + subagentAgentId: 'child', + phase: 'message_delta', + data: { delta: { content: [{ type: 'text', text }] } }, + timestamp: '', + }); + const progressForText = (text: string): SubagentProgress => + progressFromEvents({ + subagentRunId: 'run_a', + subagentType: 'self', + status: 'message_delta', + events: [eventForText(text)], + }); + + const { setProgress } = renderWithState({ + toolCallId: 'call_throttled_writing', + initialProgress: 0.3, + isSubmitting: true, + progress: progressForText(firstPreview), + }); + const ticker = within(screen.getByRole('button', { name: 'Running agent' })); + + expect(ticker.getByText(firstPreview)).toBeInTheDocument(); + setProgress(progressForText(secondPreview)); + expect(ticker.getByText(firstPreview)).toBeInTheDocument(); + expect(ticker.queryByText(secondPreview)).not.toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(SUBAGENT_TICKER_THROTTLE_MS - 1); + }); + expect(ticker.getByText(firstPreview)).toBeInTheDocument(); + expect(ticker.queryByText(secondPreview)).not.toBeInTheDocument(); + + act(() => { + jest.advanceTimersByTime(1); + }); + expect(ticker.getByText(secondPreview)).toBeInTheDocument(); + }); }); describe('SubagentCall — dialog content', () => { @@ -462,6 +522,7 @@ describe('SubagentCall — dialog content', () => { , ); + openSubagentDialog(); expect(screen.getByTestId('prompt-markdown')).toHaveTextContent('# Review prompt'); expect(screen.getByText('final answer')).toBeInTheDocument(); @@ -537,8 +598,7 @@ describe('SubagentCall — dialog content', () => { }), }); - /** The mocked `OGDialog` always renders children, so dialog content is - * inspectable without simulating a click. */ + openSubagentDialog(); expect(screen.getByTestId('reasoning-part')).toHaveTextContent('Let me compute.'); expect(screen.getByTestId('tool-call-part')).toHaveAttribute('data-name', 'calculator'); expect(screen.getByTestId('tool-call-part')).toHaveTextContent('4'); @@ -546,15 +606,9 @@ describe('SubagentCall — dialog content', () => { }); it('falls back to the raw tool output when no content parts were recorded', () => { - renderWithState({ - toolCallId: 'call_fallback', - initialProgress: 1, - isSubmitting: false, - progress: null, - }); /** No events → no aggregated parts. The SubagentCall should still * render the raw final `output` that came back in the parent's - * tool_call (we pass it explicitly below). */ + * tool_call. */ const { rerender } = render( { /> , ); + openSubagentDialog(); expect(screen.getByText('raw final text')).toBeInTheDocument(); - rerender(); + rerender({null}); }); it('renders persistedContent parts when no live events are available (page-refresh flow)', () => { @@ -604,6 +659,7 @@ describe('SubagentCall — dialog content', () => { , ); + openSubagentDialog(); expect(screen.getByTestId('reasoning-part')).toHaveTextContent('Prior thinking.'); expect(screen.getByTestId('tool-call-part')).toHaveAttribute('data-name', 'calculator'); expect(screen.getByTestId('tool-call-part')).toHaveTextContent('2436'); @@ -637,6 +693,7 @@ describe('SubagentCall — dialog content', () => { /> , ); + openSubagentDialog(); expect(screen.getByText('Persisted answer.')).toBeInTheDocument(); }); 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 f97c05d281b..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 @@ -17,6 +17,10 @@ jest.mock('~/hooks', () => ({ * deferred-preview lifecycle. Stub to a no-op for tests that * don't exercise the preview flow. */ useAttachmentPreviewSync: () => ({ status: 'ready', previewError: undefined, isPolling: false }), + useExpandCollapse: (isExpanded: boolean) => ({ + style: { display: 'grid', gridTemplateRows: isExpanded ? '1fr' : '0fr' }, + ref: { current: null }, + }), })); const mockHandleDownload = jest.fn(); @@ -67,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; @@ -186,12 +189,69 @@ 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(); expect(container.querySelector('pre')).toBeNull(); expect(screen.getAllByTestId('file-container').length).toBeGreaterThan(0); }); + + it('does not collapse a single downloadable text preview with a non-downloadable placeholder', () => { + const attachments = [ + textAttachment({ + file_id: 'placeholder', + filename: 'placeholder.zip', + filepath: '', + text: undefined, + }), + textAttachment({ + file_id: 'json', + filename: 'output.json', + filepath: '/files/output.json', + text: '{"ok":true}', + }), + ] as TAttachment[]; + + const { container } = render(); + + expect(screen.queryByRole('button', { name: 'com_ui_show_n_files' })).not.toBeInTheDocument(); + expect(container.querySelector('pre')?.textContent).toBe('{"ok":true}'); + expect(screen.getByTestId('file-container')).toHaveTextContent('output.json'); + }); + + it('keeps long grouped text previews clamped until the nested preview is expanded', () => { + setScrollHeight(800); + const longJson = Array.from({ length: 1000 }, (_, index) => `{"line":${index}}`).join('\n'); + const attachments = [ + textAttachment({ + file_id: 'archive', + filename: 'archive.zip', + text: undefined, + }), + textAttachment({ + file_id: 'json', + filename: 'output.json', + filepath: '/files/output.json', + text: longJson, + }), + ] as TAttachment[]; + + const { container } = render(); + const groupToggle = screen.getByRole('button', { name: 'com_ui_show_n_files' }); + fireEvent.click(groupToggle); + + expect(screen.getByText('output.json')).toBeInTheDocument(); + const pre = container.querySelector('pre'); + expect(pre).not.toBeNull(); + expect(pre).toHaveStyle({ maxHeight: '320px' }); + const previewToggle = screen.getByRole('button', { name: 'Show all' }); + expect(previewToggle).toHaveAttribute('aria-expanded', 'false'); + + fireEvent.click(previewToggle); + expect(screen.getByRole('button', { name: 'Collapse' })).toHaveAttribute( + 'aria-expanded', + 'true', + ); + }); }); 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/Parts/useToolCallState.ts b/client/src/components/Chat/Messages/Content/Parts/useToolCallState.ts index 35992214441..7a487d4b7ee 100644 --- a/client/src/components/Chat/Messages/Content/Parts/useToolCallState.ts +++ b/client/src/components/Chat/Messages/Content/Parts/useToolCallState.ts @@ -21,6 +21,7 @@ export default function useToolCallState( isSubmitting: boolean, output: string, hasInput: boolean, + onExpand?: () => void, ): ToolCallState { const autoExpand = useRecoilValue(store.autoExpandTools); const hasOutput = output.length > 0; @@ -37,7 +38,15 @@ export default function useToolCallState( }, [autoExpand, hasContent]); const progress = useProgress(initialProgress); - const toggleCode = useCallback(() => setShowCode((prev) => !prev), []); + const toggleCode = useCallback(() => { + setShowCode((prev) => { + const next = !prev; + if (next) { + onExpand?.(); + } + return next; + }); + }, [onExpand]); const cancelled = !isSubmitting && progress < 1 && !hasError; return { diff --git a/client/src/components/Chat/Messages/Content/RetrievalCall.tsx b/client/src/components/Chat/Messages/Content/RetrievalCall.tsx index f367f07febe..97f86fdb754 100644 --- a/client/src/components/Chat/Messages/Content/RetrievalCall.tsx +++ b/client/src/components/Chat/Messages/Content/RetrievalCall.tsx @@ -326,11 +326,13 @@ export default function RetrievalCall({ isSubmitting, output, attachments, + onExpand, }: { initialProgress: number; isSubmitting: boolean; output?: string; attachments?: TAttachment[]; + onExpand?: () => void; }) { const progress = useProgress(initialProgress); const localize = useLocalize(); @@ -400,6 +402,16 @@ export default function RetrievalCall({ } }, [autoExpand, hasOutput]); + const handleToggleOutput = useCallback(() => { + setShowOutput((prev) => { + const next = !prev; + if (next) { + onExpand?.(); + } + return next; + }); + }, [onExpand]); + return (
@@ -416,7 +428,7 @@ export default function RetrievalCall({
setShowOutput((prev) => !prev) : undefined} + onClick={hasOutput ? handleToggleOutput : undefined} inProgressText={localize('com_ui_searching_files')} finishedText={localize('com_ui_retrieved_files')} errorSuffix={errorState && !cancelled ? localize('com_ui_tool_failed') : undefined} 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} +
-
-
-
- {parts.map(({ part, idx }) => renderPart(part, idx, isLast && idx === lastContentIdx))} +
+ {shouldRenderBody && ( +
+
+ {parts.map(({ part, idx }) => + renderPart(part, idx, isLast && idx === lastContentIdx, handleToolExpand), + )} +
-
+ )}
{groupAttachments && groupAttachments.length > 0 && ( diff --git a/client/src/components/Chat/Messages/Content/WebSearch.tsx b/client/src/components/Chat/Messages/Content/WebSearch.tsx index e4029193cc9..c8d76a04b21 100644 --- a/client/src/components/Chat/Messages/Content/WebSearch.tsx +++ b/client/src/components/Chat/Messages/Content/WebSearch.tsx @@ -82,12 +82,14 @@ export default function WebSearch({ isLast, output, attachments, + onExpand, }: { isLast?: boolean; isSubmitting: boolean; output?: string | null; initialProgress: number; attachments?: TAttachment[]; + onExpand?: () => void; }) { const localize = useLocalize(); const { searchResults } = useSearchContext(); @@ -139,26 +141,21 @@ export default function WebSearch({ return []; }, [searchResults, attachments, ownTurn]); - const processedSources = useMemo(() => { + // Show favicons from the raw SERP results immediately rather than waiting for + // each source to flip to `processed`; the agents scrape barrier would otherwise + // freeze the stack on "Searching the web" for the slowest scrape's duration. + const streamingSources = useMemo(() => { if (complete && !finalizing) { return []; } - if (!searchResults) { - return []; - } - const result = searchResults[ownTurn]; + const result = searchResults?.[ownTurn]; if (!result) { return []; } - if (finalizing) { - return [...(result.organic || []), ...(result.topStories || [])]; - } - return [...(result.organic || []), ...(result.topStories || [])].filter( - (source) => source.processed === true, - ); + return [...(result.organic || []), ...(result.topStories || [])]; }, [searchResults, complete, finalizing, ownTurn]); - const showSources = processedSources.length > 0; + const showSources = streamingSources.length > 0; const progressText = useMemo(() => { let text: ProgressKeys = ownTurn !== '0' ? 'com_ui_web_searching_again' : 'com_ui_web_searching'; @@ -182,6 +179,16 @@ export default function WebSearch({ } }, [autoExpand, sourceCount]); + const handleToggleSources = () => { + setShowSourceList((prev) => { + const next = !prev; + if (next) { + onExpand?.(); + } + return next; + }); + }; + if (cancelled) { return null; } @@ -204,7 +211,7 @@ export default function WebSearch({ : 'pointer-events-none text-text-secondary', )} disabled={!hasSourceData} - onClick={hasSourceData ? () => setShowSourceList((prev) => !prev) : undefined} + onClick={hasSourceData ? handleToggleSources : undefined} aria-expanded={hasSourceData ? showSourceList : undefined} aria-label={ hasSourceData @@ -266,7 +273,7 @@ export default function WebSearch({ {progressText} - {showSources && } + {showSources && }