From 947bfa4c40e6b8c84346d23c1678cd83071c5179 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 12 May 2026 21:41:09 -0400 Subject: [PATCH 001/395] =?UTF-8?q?=F0=9F=93=9A=20docs:=20Add=20Skills,=20?= =?UTF-8?q?Subagents,=20and=20CloudFront=20References=20(#13096)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 3 +++ README.md | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.env.example b/.env.example index 5d7f69a9def..816b4cd14de 100644 --- a/.env.example +++ b/.env.example @@ -679,6 +679,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 # diff --git a/README.md b/README.md index a7f68d9a920..86deec5b28e 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,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 @@ -137,6 +139,7 @@ - ⚙️ **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**: From e0a4e53b7f5aab69c0e50e006af8c7f4c1ad3eb5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 13 May 2026 13:14:00 -0400 Subject: [PATCH 002/395] =?UTF-8?q?=F0=9F=8C=8D=20i18n:=20Update=20transla?= =?UTF-8?q?tion.json=20with=20latest=20translations=20(#13107)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- client/src/locales/de/translation.json | 115 +++++++++++++++++++++++++ 1 file changed, 115 insertions(+) diff --git a/client/src/locales/de/translation.json b/client/src/locales/de/translation.json index 6514037d99d..f4c59f1697d 100644 --- a/client/src/locales/de/translation.json +++ b/client/src/locales/de/translation.json @@ -5,6 +5,9 @@ "com_a11y_end": "Die KI hat die Antwort fertiggestellt.", "com_a11y_selected": "ausgewählt", "com_a11y_start": "Die KI hat mit ihrer Antwort begonnen. ", + "com_a11y_summarize_completed": "Kontext zusammengefasst.", + "com_a11y_summarize_failed": "Zusammenfassung fehlgeschlagen, fahre mit vorhandenem Kontext fort.", + "com_a11y_summarize_started": "Kontext wird zusammengefasst.", "com_agents_agent_card_label": "{{name}} Agent. {{description}}", "com_agents_all": "Alle Agenten", "com_agents_all_category": "Alle", @@ -391,6 +394,7 @@ "com_info_heic_converting": "HEIC-Bild wird in JPEG konventiert...", "com_nav_2fa": "Zwei-Faktor-Authentifizierung (2FA)", "com_nav_account_settings": "Kontoeinstellungen", + "com_nav_advanced_prompts": "Erweiterter Prompt-Editor", "com_nav_always_make_prod": "Neue Versionen direkt produktiv nehmen", "com_nav_archive_created_at": "Archivierungsdatum", "com_nav_archive_name": "Name", @@ -638,10 +642,12 @@ "com_ui_add_first_bookmark": "Klicke auf einen Chat, um ihn hinzuzufügen", "com_ui_add_first_mcp_server": "Erstelle deinen ersten MCP-Server, um loszulegen", "com_ui_add_first_prompt": "Erstelle deinen ersten Prompt, um zu starten", + "com_ui_add_labels": "Labels hinzufügen", "com_ui_add_mcp": "MCP hinzufügen", "com_ui_add_mcp_server": "MCP Server hinzufügen", "com_ui_add_model_preset": "Ein KI-Modell oder eine Voreinstellung für eine zusätzliche Antwort hinzufügen", "com_ui_add_multi_conversation": "Mehrere Chats hinzufügen", + "com_ui_add_skills": "Skills hinzufügen", "com_ui_add_special_variables": "Spezielle Variablen hinzufügen", "com_ui_add_web_search_api_keys": "Web-Suche API-Schlüssel hinzufügen", "com_ui_adding_details": "Hinzufügen von Details\n", @@ -668,6 +674,7 @@ "com_ui_agent_chain_max": "Du hast die maximale Anzahl von {{0}} Agenten erreicht.", "com_ui_agent_delete_error": "Beim Löschen des Assistenten ist ein Fehler aufgetreten", "com_ui_agent_deleted": "Agent erfolgreich gelöscht", + "com_ui_agent_description": "Agentenbeschreibung", "com_ui_agent_duplicate_error": "Beim Duplizieren des Assistenten ist ein Fehler aufgetreten", "com_ui_agent_duplicated": "Agent wurde erfolgreich dupliziert", "com_ui_agent_handoff_add": "Übergabe-Agent hinzufügen", @@ -681,9 +688,14 @@ "com_ui_agent_handoff_prompt_key_placeholder": "Benenne den übergebenen Inhalt (Standard: 'instructions')\n", "com_ui_agent_handoff_prompt_placeholder": "Sag diesem Agenten, welchen Inhalt er generieren und an den Übergabe-Agenten weiterleiten soll. Du musst hier etwas eingeben, um diese Funktion zu aktivieren.", "com_ui_agent_handoffs": "Agenten-Übergaben", + "com_ui_agent_name": "Agentenname", "com_ui_agent_name_is_required": "Ein Agentenname ist erforderlich.", "com_ui_agent_recursion_limit": "Maximale Agenten-Schritte", "com_ui_agent_recursion_limit_info": "Begrenzt, wie viele Schritte der Agent in einem Durchlauf ausführen kann, bevor er eine endgültige Antwort gibt. Der Standardwert ist 25 Schritte. Ein Schritt ist entweder eine KI-API-Anfrage oder eine Werkzeugnutzungsrunde. Eine einfache Werkzeuginteraktion umfasst beispielsweise 3 Schritte: die ursprüngliche Anfrage, die Werkzeugnutzung und die Folgeanfrage.", + "com_ui_agent_subagents": "Unteragenten", + "com_ui_agent_subagents_add": "Unteragent hinzufügen", + "com_ui_agent_subagents_agents": "Zusätzliche Unteragenten", + "com_ui_agent_subagents_enable": "Unteragenten aktivieren", "com_ui_agent_url_copied": "Agenten-URL in die Zwischenablage kopiert", "com_ui_agent_var": "{{0}} Agent", "com_ui_agent_version": "Version", @@ -750,6 +762,7 @@ "com_ui_authentication": "Authentifizierung", "com_ui_authentication_type": "Authentifizierungstyp", "com_ui_auto": "Auto", + "com_ui_available_options": "Verfügbare Optionen", "com_ui_avatar": "Avatar", "com_ui_azure": "Azure", "com_ui_azure_ad": "Entra ID", @@ -815,9 +828,11 @@ "com_ui_code": "Code", "com_ui_collapse": "Einklappen", "com_ui_collapse_chat": "Chat einklappen", + "com_ui_collapse_summary": "Zusammenfassung einklappen", "com_ui_collapse_thoughts": "Gedanken einklappen", "com_ui_command_placeholder": "Optional: Gib einen speziellen Befehl ein, sonst wird der Name des Prompts verwendet.", "com_ui_command_usage_placeholder": "Wähle einen Prompt nach Befehl oder Name aus", + "com_ui_complete": "Fertig!", "com_ui_complete_setup": "Einrichtung abschließen", "com_ui_concise": "Prägnant", "com_ui_configure": "Konfigurieren", @@ -836,6 +851,7 @@ "com_ui_conversation": "Konversation", "com_ui_conversation_label": "{{title}} Konversation", "com_ui_conversation_not_found": "Chat nicht gefunden", + "com_ui_conversation_summarized": "Konversation zusammengefasst", "com_ui_conversations": "Konversationen", "com_ui_convo_archived": "Konversation archiviert", "com_ui_convo_delete_error": "Unterhaltung konnte nicht gelöscht werden.", @@ -858,6 +874,8 @@ "com_ui_create_new_agent": "Neuen Agenten erstellen", "com_ui_create_prompt": "Prompt erstellen", "com_ui_create_prompt_page": "Neue Prompt-Konfigurationsseite", + "com_ui_create_skill": "Skill erstellen", + "com_ui_create_skill_upload": "SKILL.md hochladen", "com_ui_created": "Erstellt", "com_ui_creating": "Wird erstellt...", "com_ui_creating_image": "Bild wird erstellt. Kann einen Moment dauern", @@ -919,6 +937,7 @@ "com_ui_description_placeholder": "Optional: Gib eine Beschreibung für den Prompt ein", "com_ui_deselect_all": "Alle abwählen", "com_ui_detailed": "Detailliert", + "com_ui_details": "Details", "com_ui_disabling": "Deaktiviere …", "com_ui_done": "Erledigt", "com_ui_download": "Herunterladen", @@ -941,6 +960,7 @@ "com_ui_edit_memory": "Erinnerung bearbeiten", "com_ui_edit_preset_title": "Voreinstellung bearbeiten - {{title}}", "com_ui_edit_prompt_page": "Prompt-Seite bearbeiten", + "com_ui_edit_skill": "Skill bearbeiten", "com_ui_editable_message": "Bearbeitbare Nachricht", "com_ui_editor_instructions": "Zieh das Bild, um es neu zu positionieren • Nutze den Zoom-Regler oder die Schaltflächen zur Größenanpassung.", "com_ui_empty_category": "-", @@ -966,6 +986,9 @@ "com_ui_expand_chat": "Chat erweitern", "com_ui_expand_thoughts": "Gedanken ausklappen", "com_ui_export_convo_modal": "Konversation exportieren", + "com_ui_failed": "Fehlgeschlagen", + "com_ui_favorite": "Zu Favoriten hinzufügen", + "com_ui_favorites": "Favoriten", "com_ui_feedback_more": "Mehr ...", "com_ui_feedback_more_information": "Zusätzliches Feedback", "com_ui_feedback_negative": "Muss verbessert werden", @@ -984,7 +1007,9 @@ "com_ui_feedback_tag_unjustified_refusal": "Mit anderer Begründung abgelehnt", "com_ui_field_max_length": "{{field}} darf maximal {{length}} Zeichen haben", "com_ui_field_required": "Dieses Feld ist erforderlich", + "com_ui_file": "Datei", "com_ui_file_input_avatar_label": "Dateiauswahl für Avatar", + "com_ui_file_modified": "Geändert", "com_ui_file_size": "Dateigröße", "com_ui_file_token_limit": "Datei-Token-Limit", "com_ui_file_token_limit_desc": "Lege ein maximales Token-Limit für die Dateiverarbeitung fest, um Kosten und Ressourcenverbrauch zu steuern.", @@ -992,6 +1017,7 @@ "com_ui_filter_mcp_servers": "MCP-Server nach Namen filtern", "com_ui_filter_prompts": "Prompts filtern", "com_ui_filter_prompts_name": "Prompts nach Namen filtern", + "com_ui_filter_skills_name": "Filtere Skills nach Namen", "com_ui_final_touch": "Letzter Schliff\n", "com_ui_finance": "Finanzen", "com_ui_fork": "Abzweigen", @@ -1023,6 +1049,7 @@ "com_ui_fork_visible": "Nur sichtbare Nachrichten", "com_ui_generate_qrcode": "QR-Code generieren", "com_ui_generating": "Generiere …", + "com_ui_generating_image": "Generiere Bild...", "com_ui_generation_settings": "Einstellungen für die Generierung", "com_ui_getting_started": "Erste Schritte\n", "com_ui_go_back": "Zurück", @@ -1047,6 +1074,7 @@ "com_ui_image_details": "Details zum Bild", "com_ui_image_edited": "Bild bearbeitet\n", "com_ui_image_gen": "Bildgenerierung", + "com_ui_image_gen_failed": "Bildgenerierung fehlgeschlagen", "com_ui_import": "Importieren", "com_ui_import_conversation_error": "Beim Importieren Ihrer Konversationen ist ein Fehler aufgetreten", "com_ui_import_conversation_file_type_error": "Nicht unterstützter Importtyp", @@ -1058,10 +1086,14 @@ "com_ui_initializing": "Initialisiere...", "com_ui_input": "Eingabe", "com_ui_instructions": "Anweisungen", + "com_ui_invocation_auto": "Auto", + "com_ui_invocation_both": "Beide", + "com_ui_invocation_manual": "Manuell", "com_ui_key": "Schlüssel", "com_ui_key_required": "API-Schlüssel ist erforderlich", "com_ui_last_used": "Zuletzt verwendet", "com_ui_late_night": "Schöne späte Nacht", + "com_ui_latest": "neueste", "com_ui_latest_footer": "Alle KIs für alle.", "com_ui_latest_version": "Neueste Version", "com_ui_leave_blank_to_keep": "Leer lassen, um beizubehalten", @@ -1161,14 +1193,18 @@ "com_ui_model_parameters": "Modell-Parameter", "com_ui_model_parameters_reset": "Modellparameter wurden zurückgesetzt.", "com_ui_model_selected": "{{0}} ausgewählt", + "com_ui_model_spec": "Modellspezifikation", "com_ui_more_info": "Mehr Infos", "com_ui_my_prompts": "Meine Prompts", + "com_ui_my_skills": "Meine Skills", "com_ui_name": "Name", "com_ui_name_sort": "Nach Namen sortieren", + "com_ui_navigate_results": "Ergebnisse durchsuchen", "com_ui_new": "Neu", "com_ui_new_chat": "Neuer Chat", "com_ui_new_conversation_title": "Neuer Titel des Chats", "com_ui_next": "Weiter", + "com_ui_next_result": "Nächstes Ergebnis", "com_ui_no": "Nein", "com_ui_no_api_keys": "Noch keine API-Schlüssel vorhanden. Erstelle einen, um loszulegen.", "com_ui_no_auth": "Keine (Automatische Erkennung)", @@ -1179,6 +1215,7 @@ "com_ui_no_category": "Keine Kategorie", "com_ui_no_changes": "Es wurden keine Änderungen vorgenommen", "com_ui_no_individual_access": "Keine einzelnen Benutzer oder Gruppen haben Zugriff auf diesen Agenten.", + "com_ui_no_labels": "Keine Labels", "com_ui_no_mcp_servers": "Noch keine MCP-Server", "com_ui_no_mcp_servers_match": "Keine MCP-Server entsprechen deinem Filter", "com_ui_no_memories": "Keine Erinnerungen. Erstelle sie manuell oder fordere die KI auf, sich etwas zu merken.\n", @@ -1188,6 +1225,7 @@ "com_ui_no_prompts_title": "Noch keine Prompts", "com_ui_no_read_access": "Du hast keine Berechtigung, Erinnerungen anzuzeigen.", "com_ui_no_results_found": "Keine Ergebnisse gefunden", + "com_ui_no_skills_found": "Keine Skills gefunden", "com_ui_no_terms_content": "Keine Inhalte der Allgemeinen Geschäftsbedingungen zum Anzeigen", "com_ui_none": "Keine", "com_ui_not_used": "Nicht verwendet", @@ -1212,7 +1250,10 @@ "com_ui_open_var": "{{0}} öffnen", "com_ui_openai": "OpenAI", "com_ui_optional": "(Optional)", + "com_ui_options": "Optionen", + "com_ui_output": "Ausgabe", "com_ui_page": "Seite", + "com_ui_parameters": "Parameter", "com_ui_people": "Personen", "com_ui_people_picker": "Personenauswahl", "com_ui_people_picker_allow_view_groups": "Anzeigen von Gruppen erlauben", @@ -1224,10 +1265,15 @@ "com_ui_pin": "Anpinnen", "com_ui_preferences_updated": "Einstellungen erfolgreich aktualisiert", "com_ui_prev": "Zurück", + "com_ui_prev_result": "Vorheriges Ergebnis", "com_ui_preview": "Vorschau", + "com_ui_preview_failed": "Vorschau nicht verfügbar", + "com_ui_preview_preparing": "Vorschau wird vorbereitet...", + "com_ui_preview_unavailable": "Vorschau ist für diesen Dateityp nicht verfügbar", "com_ui_privacy_policy": "Datenschutzerklärung", "com_ui_privacy_policy_url": "Datenschutzrichtlinie-URL", "com_ui_prompt": "Prompt", + "com_ui_prompt_delete_error": "Beim Löschen des Prompts ist ein Fehler aufgetreten", "com_ui_prompt_group_button": "{{name}}-Prompt, Kategorie {{category}}", "com_ui_prompt_group_button_no_category": "{{name}}-Prompt", "com_ui_prompt_groups": "Prompt-Gruppenliste", @@ -1260,6 +1306,7 @@ "com_ui_regenerating": "Generiere neu ...", "com_ui_region": "Region", "com_ui_reinitialize": "Neu initialisieren", + "com_ui_relevance": "Relevanz", "com_ui_remote_access": "Remote-Zugriff", "com_ui_remote_agent_role_editor": "Editor", "com_ui_remote_agent_role_editor_desc": "Kann den Agent über die API ansehen und bearbeiten", @@ -1311,6 +1358,7 @@ "com_ui_rotate_90": "Um 90 Grad drehen", "com_ui_run_code": "Code ausführen", "com_ui_run_code_error": "Bei der Ausführung des Codes ist ein Fehler aufgetreten", + "com_ui_running": "Läuft...", "com_ui_save": "Speichern", "com_ui_save_badge_changes": "Änderungen an Badges speichern?", "com_ui_save_changes": "Änderungen speichern", @@ -1321,6 +1369,7 @@ "com_ui_saving": "Sicherung läuft...", "com_ui_schema": "Schema", "com_ui_scope": "Umfang", + "com_ui_scroll_to_bottom": "Nach unten scrollen", "com_ui_search": "Suche", "com_ui_search_above_to_add": "Suche oben, um Benutzer oder Gruppen hinzuzufügen", "com_ui_search_above_to_add_all": "Suche oben, um Benutzer, Gruppen oder Rollen hinzuzufügen", @@ -1363,15 +1412,52 @@ "com_ui_show_all": "Alle anzeigen", "com_ui_show_code": "Code anzeigen", "com_ui_show_image_details": "Details zum Bild anzeigen", + "com_ui_show_less": "Weniger anzeigen", + "com_ui_show_more": "Mehr anzeigen", "com_ui_show_password": "Passwort anzeigen", "com_ui_show_qr": "QR-Code anzeigen", "com_ui_sign_in_to_domain": "Anmelden bei {{0}}", "com_ui_simple": "Einfach", "com_ui_size": "Größe", "com_ui_size_sort": "Nach Größe sortieren", + "com_ui_skill": "Skill", + "com_ui_skill_create_title": "Skill erstellen", + "com_ui_skill_created": "Skill erstellt", + "com_ui_skill_deleted": "Skill gelöscht", + "com_ui_skill_description_required": "Beschreibung ist erforderlich", + "com_ui_skill_edit_title": "Skill bearbeiten", + "com_ui_skill_file_download": "Herunterladen", + "com_ui_skill_instructions": "Anleitung", + "com_ui_skill_name_required": "Name ist erforderlich", + "com_ui_skill_new_file": "Neue Datei", + "com_ui_skill_new_folder": "Neuer Ordner", + "com_ui_skill_no_selection": "Skill wählen", + "com_ui_skill_not_found": "Skill nicht gefunden", + "com_ui_skill_running": "Läuft {{0}}", + "com_ui_skill_sr_public": "Öffentlicher Skill", + "com_ui_skill_update_error": "Speichern des Skills fehlgeschlagen", + "com_ui_skill_updated": "Skill gespeichert", + "com_ui_skill_upload": "Skill hochladen", + "com_ui_skill_upload_file": "Datei hochladen", + "com_ui_skill_upload_req_size": "Dateigröße darf {{0}} MB nicht überschreiten.", + "com_ui_skill_upload_req_zip": ".zip- oder .skill-Dateien müssen eine SKILL.md-Datei beinhalten", + "com_ui_skill_upload_title": "Skill hochladen", + "com_ui_skill_version": "v{{0}}", + "com_ui_skill_view_source": "Quelle ansehen", + "com_ui_skills": "Skills", + "com_ui_skills_allow_create": "Erlaube das Erstellen von Skills", + "com_ui_skills_allow_share": "Erlaube das Teilen von Skills", + "com_ui_skills_allow_share_public": "Erlaube das öffentliche Teilen von Skills", + "com_ui_skills_allow_use": "Erlaube das Benutzen von Skills", + "com_ui_skills_empty": "Noch keine Skills", + "com_ui_skills_enable_toggle": "Aktiviere Skills für diesen Agenten", + "com_ui_special": "Spezial", "com_ui_special_var_current_date": "Aktuelles Datum", "com_ui_special_var_current_datetime": "Aktuelles Datum & Uhrzeit", "com_ui_special_var_current_user": "Aktueller Nutzer", + "com_ui_special_var_desc_current_date": "Das heutige Datum und der Wochentag", + "com_ui_special_var_desc_current_user": "Dein Anzeigename", + "com_ui_special_var_desc_iso_datetime": "UTC-Zeitangabe im ISO 8601-Format", "com_ui_special_var_iso_datetime": "UTC ISO Datum/Zeit", "com_ui_special_variable_added": "Spezialvariable {{0}} hinzugefügt.", "com_ui_special_variables": "Spezielle Variablen:", @@ -1379,12 +1465,23 @@ "com_ui_speech_not_supported_use_external": "Dein Browser unterstützt keine Spracherkennung. Versuche in den Einstellungen unter „Sprache“ zu „Externes STT“ zu wechseln.", "com_ui_speech_while_submitting": "Spracheingabe nicht möglich während eine Antwort generiert wird", "com_ui_sr_global_prompt": "Globale Prompt-Gruppe", + "com_ui_sr_public_skill": "Öffentlicher Skill", "com_ui_stack_trace": "Stack Trace", "com_ui_status_prefix": "Status:", "com_ui_stop": "Stopp", "com_ui_storage": "Speicher", "com_ui_storage_filter_sort": "Nach Speicher filtern und sortieren", + "com_ui_subagent_cancelled": "Agent abgebrochen", + "com_ui_subagent_complete": "Agent gestartet", + "com_ui_subagent_dialog_title_self": "Agent", + "com_ui_subagent_no_result_yet": "Läuft noch - bisher kein Endergebnis.", + "com_ui_subagent_running": "Agent arbeitet", + "com_ui_subagent_ticker_error": "Fehler", + "com_ui_subagent_ticker_using": "Nutze", + "com_ui_subagent_ticker_writing": "Schreibe", "com_ui_submit": "Absenden", + "com_ui_summarized": "Zusammengefasst", + "com_ui_summarizing": "Wird zusammengefasst...", "com_ui_support_contact": "Ansprechpartner-Kontakt", "com_ui_support_contact_email": "E-Mail", "com_ui_support_contact_email_invalid": "Bitte gib eine gültige E-Mail-Adresse ein", @@ -1396,6 +1493,7 @@ "com_ui_temporary": "Privater Chat", "com_ui_terms_and_conditions": "Allgemeine Geschäftsbedingungen", "com_ui_terms_of_service": "Nutzungsbedingungen", + "com_ui_text_variables": "Textvariablen", "com_ui_thinking": "Nachdenken...", "com_ui_thoughts": "Gedanken", "com_ui_toggle_theme": "Design umschalten", @@ -1404,8 +1502,14 @@ "com_ui_token_url": "Token-URL", "com_ui_tokens": "Tokens", "com_ui_tool_collection_prefix": "Eine Tools Sammlung von", + "com_ui_tool_failed": "fehlgeschlagen", "com_ui_tool_list_collapse": "Werkzeugliste von {{serverName}} einklappen", "com_ui_tool_list_expand": "Werkzeugliste von {{serverName}} ausklappen", + "com_ui_tool_name_code": "Code", + "com_ui_tool_name_code_analysis": "Codeanalyse", + "com_ui_tool_name_file_search": "Dateisuche", + "com_ui_tool_name_image_edit": "Bild bearbeiten", + "com_ui_tool_name_image_gen": "Bildgenerierung", "com_ui_tools": "Werkzeuge", "com_ui_tools_and_actions": "Werkzeuge und Aktionen", "com_ui_transferred_to": "Übergeben an", @@ -1418,7 +1522,9 @@ "com_ui_unarchive_conversation": "Unterhaltung dearchivieren", "com_ui_unarchive_error": "Konversation konnte nicht aus dem Archiv geholt werden", "com_ui_unavailable": "Nicht verfügbar", + "com_ui_unfavorite": "Aus Favoriten entfernen", "com_ui_unknown": "Unbekannt", + "com_ui_unknown_file_type": "Unbekannter Dateityp", "com_ui_unpin": "Loslösen", "com_ui_unset": "Aufheben", "com_ui_untitled": "Unbenannt", @@ -1429,6 +1535,7 @@ "com_ui_upload_agent_avatar": "Agenten-Avatar erfolgreich aktualisiert", "com_ui_upload_agent_avatar_label": "Avatarbild des Agenten hochladen", "com_ui_upload_avatar_label": "Avatarbild hochladen", + "com_ui_upload_code_environment": "In die Code-Umgebung hochladen", "com_ui_upload_delay": "Das Hochladen von \"{{0}}\" dauert etwas länger. Bitte warte, während die Datei für den Abruf indexiert wird.", "com_ui_upload_error": "Beim Hochladen Ihrer Datei ist ein Fehler aufgetreten", "com_ui_upload_file_context": "Kontext der Datei hochladen", @@ -1448,7 +1555,9 @@ "com_ui_use_backup_code": "Stattdessen Backup-Code verwenden", "com_ui_use_memory": "Erinnerung nutzen", "com_ui_use_micrphone": "Mikrofon verwenden", + "com_ui_use_prompt": "Prompt verwenden", "com_ui_used": "Verwendet", + "com_ui_used_n_tools": "{{0}} Tools benutzt", "com_ui_user": "Benutzer", "com_ui_user_group_permissions": "Benutzer- & Gruppenberechtigungen", "com_ui_user_provides_key": "Benutzer gibt eigenen Schlüssel an", @@ -1457,6 +1566,7 @@ "com_ui_verify": "Überprüfen", "com_ui_version_var": "Version {{0}}", "com_ui_versions": "Versionen", + "com_ui_via_server": "in {{0}}", "com_ui_view_memory": "Erinnerung anzeigen", "com_ui_web_search": "Web-Suche\n", "com_ui_web_search_cohere_key": "Cohere API-Key eingeben", @@ -1468,6 +1578,7 @@ "com_ui_web_search_provider_searxng": "SearXNG", "com_ui_web_search_provider_serper": "Serper API\n", "com_ui_web_search_provider_serper_key": "Einen Serper API Schlüssel holen", + "com_ui_web_search_provider_tavily": "Tavily API", "com_ui_web_search_reading": "Lesen der Suchergebnisse", "com_ui_web_search_reranker": "Reranker", "com_ui_web_search_reranker_cohere": "Cohere", @@ -1482,10 +1593,14 @@ "com_ui_web_search_scraper_serper_key": "Hole einen Serper API Schlüssel", "com_ui_web_search_searxng_api_key": "SearXNG API Key (optional) einfügen", "com_ui_web_search_searxng_instance_url": "SearXNG Instanz URL", + "com_ui_web_search_source": "{{count}} Quelle", + "com_ui_web_search_sources": "{{count}} Quellen", + "com_ui_web_searched": "Im Internet gesucht", "com_ui_web_searching": "Internetsuche läuft", "com_ui_web_searching_again": "Sucht erneut im Internet", "com_ui_weekend_morning": "Schönes Wochenende", "com_ui_write": "Schreiben", + "com_ui_writing_command": "Schreibbefehl", "com_ui_x_selected": "{{0}} ausgewählt", "com_ui_xhigh": "Extra hoch", "com_ui_yes": "Ja", From 83ea3efbc136bcdcd58926187c1d78b216dc192c Mon Sep 17 00:00:00 2001 From: Dan Lew Date: Wed, 13 May 2026 13:54:50 -0500 Subject: [PATCH 003/395] =?UTF-8?q?=F0=9F=9A=A7=20feat:=20Support=20Guardr?= =?UTF-8?q?ail=20Config=20Option=20`streamProcessingMode`=20(#12815)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `streamProcessingMode` affects how guardrail processes the stream from the model. If it's in "sync" mode, it chunks up the response and processes them before returning them to the user. If it's in "async" mode, it both processes the chunk & sends it to the user at the same time, allowing for smoother streaming (at the cost of guardrail only reacting *after* offending content starts to stream, in some cases). --- packages/api/src/endpoints/bedrock/initialize.spec.ts | 1 + packages/api/src/types/bedrock.ts | 2 ++ 2 files changed, 3 insertions(+) diff --git a/packages/api/src/endpoints/bedrock/initialize.spec.ts b/packages/api/src/endpoints/bedrock/initialize.spec.ts index decf9a7df93..a06dbb779cb 100644 --- a/packages/api/src/endpoints/bedrock/initialize.spec.ts +++ b/packages/api/src/endpoints/bedrock/initialize.spec.ts @@ -129,6 +129,7 @@ describe('initializeBedrock', () => { guardrailIdentifier: 'test-guardrail-id', guardrailVersion: '1', trace: 'enabled' as const, + streamProcessingMode: 'async', }; const params = createMockParams({ diff --git a/packages/api/src/types/bedrock.ts b/packages/api/src/types/bedrock.ts index 8f34b2864d1..6608c52fcf5 100644 --- a/packages/api/src/types/bedrock.ts +++ b/packages/api/src/types/bedrock.ts @@ -19,6 +19,8 @@ export interface GuardrailConfiguration { guardrailVersion: string; /** The trace behavior for the guardrail */ trace?: 'enabled' | 'disabled' | 'enabled_full'; + /** The processing mode for guardrail; 'sync' is the default guardrail behavior if unset */ + streamProcessingMode?: 'sync' | 'async'; } /** From 34dd8d5f2ac8024c177be73fb1a0a5227b796c66 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 13 May 2026 16:49:25 -0400 Subject: [PATCH 004/395] =?UTF-8?q?=F0=9F=93=88=20feat:=20Add=20Prometheus?= =?UTF-8?q?=20Metrics=20Endpoint=20+=20AWS=20Credential=20Providers=20(#13?= =?UTF-8?q?111)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add prometheus metrics endpoint * fix: format metrics route spec * chore: update dependencies in package.json and package-lock.json correctly - Bump `@smithy/core` to version 3.24.1 - Update `@aws-sdk/credential-providers` to version 3.1045.0 - Reintroduce `prom-client` dependency in package.json - Remove unnecessary dependencies from package.json * chore: import order * fix: declare s3 presigner peer dependency * fix: normalize shared link metrics path * fix: bound metrics path labels * fix: tighten metrics auth and peers * fix: collapse partial metrics paths --- api/package.json | 2 + api/server/index.js | 11 +- api/server/index.metrics.spec.js | 164 +++++++++++++++++++++ package-lock.json | 212 ++++++++++++++++++++------- packages/api/package.json | 3 + packages/api/src/app/index.ts | 1 + packages/api/src/app/metrics.spec.ts | 47 ++++++ packages/api/src/app/metrics.ts | 159 ++++++++++++++++++++ 8 files changed, 549 insertions(+), 50 deletions(-) create mode 100644 api/server/index.metrics.spec.js create mode 100644 packages/api/src/app/metrics.spec.ts create mode 100644 packages/api/src/app/metrics.ts diff --git a/api/package.json b/api/package.json index eb4cf2a1917..80c3e5fd9bd 100644 --- a/api/package.json +++ b/api/package.json @@ -39,6 +39,7 @@ "@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", @@ -106,6 +107,7 @@ "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", diff --git a/api/server/index.js b/api/server/index.js index 6bc4a131e6a..a8146fbc1cf 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -13,6 +13,7 @@ const { logger, runAsSystem } = require('@librechat/data-schemas'); const { isEnabled, apiNotFound, + createMetrics, ErrorController, memoryDiagnostics, performStartupChecks, @@ -20,8 +21,8 @@ const { GenerationJobManager, createStreamServices, initializeFileStorage, - updateInterfacePermissions, preAuthTenantMiddleware, + updateInterfacePermissions, } = require('@librechat/api'); const { connectDb, indexSync } = require('~/db'); const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager'); @@ -53,6 +54,11 @@ const trusted_proxy = Number(TRUST_PROXY) || 1; /* trust first proxy by default const app = express(); 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'; } @@ -107,6 +113,7 @@ const startServer = async () => { app.get('/health', (_req, res) => 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' })); @@ -199,6 +206,8 @@ const startServer = async () => { app.use('/api/tags', routes.tags); app.use('/api/mcp', routes.mcp); + app.use('/metrics', metricsRouter); + /** 404 for unmatched API routes */ app.use('/api', apiNotFound); diff --git a/api/server/index.metrics.spec.js b/api/server/index.metrics.spec.js new file mode 100644 index 00000000000..3f29347292d --- /dev/null +++ b/api/server/index.metrics.spec.js @@ -0,0 +1,164 @@ +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'; + app = require('~/server'); + + await healthCheckPoll(app); + }); + + afterEach(() => { + delete process.env.METRICS_SECRET; + }); + + afterAll(async () => { + 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/package-lock.json b/package-lock.json index 51d6fa062a1..dbc9fb994ca 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "@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", @@ -121,6 +122,7 @@ "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", @@ -2248,6 +2250,85 @@ "node": ">=18.0.0" } }, + "node_modules/@aws-sdk/client-cognito-identity": { + "version": "3.1045.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cognito-identity/-/client-cognito-identity-3.1045.0.tgz", + "integrity": "sha512-3OEn8zvtfJoN0jFfjVJ9jF2GVRDL3IjDfk6CAgVTAqjfCVjajiUD0iFAGQ4cOzdcv1LGsZ0b/snJDWalY3OePQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/credential-provider-node": "^3.972.39", + "@aws-sdk/middleware-host-header": "^3.972.10", + "@aws-sdk/middleware-logger": "^3.972.10", + "@aws-sdk/middleware-recursion-detection": "^3.972.11", + "@aws-sdk/middleware-user-agent": "^3.972.38", + "@aws-sdk/region-config-resolver": "^3.972.13", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.8", + "@aws-sdk/util-user-agent-browser": "^3.972.10", + "@aws-sdk/util-user-agent-node": "^3.973.24", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/hash-node": "^4.2.14", + "@smithy/invalid-dependency": "^4.2.14", + "@smithy/middleware-content-length": "^4.2.14", + "@smithy/middleware-endpoint": "^4.4.32", + "@smithy/middleware-retry": "^4.5.7", + "@smithy/middleware-serde": "^4.2.20", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/node-http-handler": "^4.6.1", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.13", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.49", + "@smithy/util-defaults-mode-node": "^4.2.54", + "@smithy/util-endpoints": "^3.4.2", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.6", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity/node_modules/@aws-sdk/util-endpoints": { + "version": "3.996.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.8.tgz", + "integrity": "sha512-oOZHcRDihk5iEe5V25NVWg45b3qEA8OpHWVdU/XQh8Zj4heVPAJqWvMphQnU7LkufmUo10EpvFPZuQMiFLJK3g==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-endpoints": "^3.4.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/client-cognito-identity/node_modules/@smithy/util-utf8": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.3.1.tgz", + "integrity": "sha512-FtRrSnriXtOs4+J8/y9SbQ1xmN71hrOsN/YJr5PQQj5nR1l7YNkGS/TEk4gr0WN7gyrUqw8/RFaYVjI18732ZA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@aws-sdk/client-kendra": { "version": "3.1041.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-kendra/-/client-kendra-3.1041.0.tgz", @@ -2600,6 +2681,22 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/credential-provider-cognito-identity": { + "version": "3.972.31", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-cognito-identity/-/credential-provider-cognito-identity-3.972.31.tgz", + "integrity": "sha512-W5JtzDp3ejzhOOknXlnt+vJsNN2GZdAcBK+hR7HQ1DCacXqS0UpmnIyihIU7CK0IB+XYWeBaN3bBv4pXavp7Vg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/credential-provider-env": { "version": "3.972.34", "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.34.tgz", @@ -2758,6 +2855,37 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/credential-providers": { + "version": "3.1045.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-providers/-/credential-providers-3.1045.0.tgz", + "integrity": "sha512-J+it58HUGyMIAquB6pWtvmO4m0E/gQ/Tz9Xcoogk3Rety13likU5U8HioeIgE+aN1DDOAB//MARoIdLZS1Mpfw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/client-cognito-identity": "3.1045.0", + "@aws-sdk/core": "^3.974.8", + "@aws-sdk/credential-provider-cognito-identity": "^3.972.31", + "@aws-sdk/credential-provider-env": "^3.972.34", + "@aws-sdk/credential-provider-http": "^3.972.36", + "@aws-sdk/credential-provider-ini": "^3.972.38", + "@aws-sdk/credential-provider-login": "^3.972.38", + "@aws-sdk/credential-provider-node": "^3.972.39", + "@aws-sdk/credential-provider-process": "^3.972.34", + "@aws-sdk/credential-provider-sso": "^3.972.38", + "@aws-sdk/credential-provider-web-identity": "^3.972.38", + "@aws-sdk/nested-clients": "^3.997.6", + "@aws-sdk/types": "^3.973.8", + "@smithy/config-resolver": "^4.4.17", + "@smithy/core": "^3.23.17", + "@smithy/credential-provider-imds": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/eventstream-handler-node": { "version": "3.972.14", "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.14.tgz", @@ -19476,58 +19604,13 @@ } }, "node_modules/@smithy/core": { - "version": "3.23.17", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.17.tgz", - "integrity": "sha512-x7BlLbUFL8NWCGjMF9C+1N5cVCxcPa7g6Tv9B4A2luWx3be3oU8hQ96wIwxe/s7OhIzvoJH73HAUSg5JXVlEtQ==", + "version": "3.24.1", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.24.1.tgz", + "integrity": "sha512-3mT7o4qQyUWttYnVK3A0Z/u3Xha3E81tXn32Tz6vjZiUXhBrkEivpw1hBYfh84iFF9CSzkBU9Y1DJ3Q6RQ231g==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.14", + "@aws-crypto/crc32": "5.2.0", "@smithy/types": "^4.14.1", - "@smithy/url-parser": "^4.2.14", - "@smithy/util-base64": "^4.3.2", - "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-middleware": "^4.2.14", - "@smithy/util-stream": "^4.5.25", - "@smithy/util-utf8": "^4.2.2", - "@smithy/uuid": "^1.1.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/core/node_modules/@smithy/is-array-buffer": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-4.2.2.tgz", - "integrity": "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow==", - "license": "Apache-2.0", - "dependencies": { - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/core/node_modules/@smithy/util-buffer-from": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-4.2.2.tgz", - "integrity": "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/is-array-buffer": "^4.2.2", - "tslib": "^2.6.2" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@smithy/core/node_modules/@smithy/util-utf8": { - "version": "4.2.2", - "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-4.2.2.tgz", - "integrity": "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw==", - "license": "Apache-2.0", - "dependencies": { - "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -23208,6 +23291,12 @@ "node": ">=8" } }, + "node_modules/bintrees": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz", + "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==", + "license": "MIT" + }, "node_modules/bluebird": { "version": "3.4.7", "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", @@ -37995,6 +38084,19 @@ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "license": "MIT" }, + "node_modules/prom-client": { + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", + "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api": "^1.4.0", + "tdigest": "^0.1.1" + }, + "engines": { + "node": "^16 || ^18 || >=20" + } + }, "node_modules/promise.series": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/promise.series/-/promise.series-0.2.0.tgz", @@ -41740,6 +41842,15 @@ } } }, + "node_modules/tdigest": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz", + "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==", + "license": "MIT", + "dependencies": { + "bintrees": "1.0.2" + } + }, "node_modules/teex": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz", @@ -44571,6 +44682,8 @@ "@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", @@ -44603,6 +44716,7 @@ "mongoose": "^8.23.1", "node-fetch": "2.7.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", diff --git a/packages/api/package.json b/packages/api/package.json index 34a06401cb2..1168c21bc68 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -93,6 +93,8 @@ "@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", @@ -125,6 +127,7 @@ "mongoose": "^8.23.1", "node-fetch": "2.7.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", diff --git a/packages/api/src/app/index.ts b/packages/api/src/app/index.ts index 8d8802f016c..3bb619ab130 100644 --- a/packages/api/src/app/index.ts +++ b/packages/api/src/app/index.ts @@ -1,5 +1,6 @@ export * from './service'; export * from './config'; +export * from './metrics'; export * from './permissions'; export * from './cdn'; export * from './checks'; diff --git a/packages/api/src/app/metrics.spec.ts b/packages/api/src/app/metrics.spec.ts new file mode 100644 index 00000000000..6bb66ed2789 --- /dev/null +++ b/packages/api/src/app/metrics.spec.ts @@ -0,0 +1,47 @@ +/// +import { normalizePath } from './metrics'; + +describe('normalizePath', () => { + it.each([ + // Known high-cardinality routes + ['/api/messages/507f1f77bcf86cd799439011', '/api/messages/#id'], + ['/api/messages/507f1f77bcf86cd799439011/507f1f77bcf86cd799439012', '/api/messages/#id/#id'], + ['/api/messages/artifact/507f1f77bcf86cd799439012', '/api/messages/artifact/#id'], + ['/api/convos/507f1f77bcf86cd799439011', '/api/convos/#id'], + ['/api/files/507f1f77bcf86cd799439011', '/api/files/#id'], + ['/api/agents/507f1f77bcf86cd799439011', '/api/agents/#id'], + ['/api/assistants/507f1f77bcf86cd799439011', '/api/assistants/#id'], + ['/api/share/some-token-value', '/api/share/#token'], + ['/share/shareId-with_nanoidChars', '/share/#id'], + ['/share/shareId-with_nanoidChars/edit', '/share/#id/edit'], + // Known API routes with dynamic IDs + ['/api/tags/507f1f77bcf86cd799439011', '/api/tags/#id'], + ['/api/tags/507F1F77BCF86CD799439011', '/api/tags/#id'], + ['/api/tools/507f1f77bcf86cd799439011', '/api/tools/#id'], + ['/api/runs/507f1f77bcf86cd799439011', '/api/runs/#id'], + // Catch-all: UUID in unknown routes + ['/api/tools/123e4567-e89b-12d3-a456-426614174000', '/api/tools/#id'], + ['/api/sessions/123E4567-E89B-12D3-A456-426614174000', '/api/sessions/#id'], + // Multiple dynamic segments + [ + '/api/convos/507f1f77bcf86cd799439011/messages/507f1f77bcf86cd799439012', + '/api/convos/#id/messages/#id', + ], + // Static paths are not modified + ['/api/auth/login', '/api/auth/login'], + ['/api/config', '/api/config'], + ['/health', '/health'], + ['/metrics', '/metrics'], + ['/', '/'], + // Unknown/user-generated routes collapse into bounded label buckets + ['/api/not-a-real-route/user-generated-value', '/api/#path'], + ['/images/user-123/avatar-1700000000000.png', '/images/#path'], + ['/avatars/user-123/avatar-1700000000000.png', '/avatars/#path'], + ['/t/tenant-a/images/user-123/avatar-1700000000000.png', '/t/#path'], + ['/unknown/shareId-with_nanoidChars', '/#path'], + ['/api/messages/507f1f77bcf86cd799439011/user-generated-value/extra', '/api/#path'], + ['/api/messages/artifact/507f1f77bcf86cd799439012/user-generated-value', '/api/#path'], + ])('normalizes %s -> %s', (input: string, normalized: string) => { + expect(normalizePath(input)).toBe(normalized); + }); +}); diff --git a/packages/api/src/app/metrics.ts b/packages/api/src/app/metrics.ts new file mode 100644 index 00000000000..8089a9c3a14 --- /dev/null +++ b/packages/api/src/app/metrics.ts @@ -0,0 +1,159 @@ +import { timingSafeEqual } from 'crypto'; +import { Router } from 'express'; +import { Registry, collectDefaultMetrics, Counter, Histogram } from 'prom-client'; +import { logger } from '@librechat/data-schemas'; +import type { Request, Response, NextFunction, RequestHandler } from 'express'; + +const PATH_NORMALIZATIONS: [RegExp, string][] = [ + [/^\/api\/messages\/artifact\/[^/]+(?=\/|$)/, '/api/messages/artifact/#id'], + [/^\/api\/messages\/[^/]+\/[^/]+(?=\/|$)/, '/api/messages/#id/#id'], + [/^\/api\/convos\/[^/]+\/messages\/[^/]+(?=\/|$)/, '/api/convos/#id/messages/#id'], + [/^\/api\/messages\/[^/]+(?=\/|$)/, '/api/messages/#id'], + [/^\/api\/convos\/[^/]+(?=\/|$)/, '/api/convos/#id'], + [/^\/api\/files\/[^/]+(?=\/|$)/, '/api/files/#id'], + [/^\/api\/agents\/[^/]+(?=\/|$)/, '/api/agents/#id'], + [/^\/api\/assistants\/[^/]+(?=\/|$)/, '/api/assistants/#id'], + [/^\/api\/share\/[^/]+(?=\/|$)/, '/api/share/#token'], + [/^\/share\/[^/]+(?=\/|$)/, '/share/#id'], + [/^\/api\/(tags|tools|runs|sessions)\/[0-9a-f]{24}(?=\/|$)/i, '/api/$1/#id'], + [ + /^\/api\/(tools|sessions)\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}(?=\/|$)/i, + '/api/$1/#id', + ], +]; + +const STATIC_PATHS = new Set(['/', '/health', '/metrics', '/api/auth/login', '/api/config']); + +const LOW_CARDINALITY_PATHS: RegExp[] = [ + /^\/api\/messages\/#id$/, + /^\/api\/messages\/#id\/#id$/, + /^\/api\/messages\/artifact\/#id$/, + /^\/api\/convos\/#id$/, + /^\/api\/convos\/#id\/messages\/#id$/, + /^\/api\/(files|agents|assistants|tags|tools|runs|sessions)\/#id$/, + /^\/api\/share\/#token$/, + /^\/share\/#id(?:\/edit)?$/, +]; + +const isLowCardinalityPath = (path: string): boolean => + STATIC_PATHS.has(path) || LOW_CARDINALITY_PATHS.some((pattern) => pattern.test(path)); + +const normalizeKnownPath = (path: string): string => { + for (const [pattern, replacement] of PATH_NORMALIZATIONS) { + if (pattern.test(path)) { + return path.replace(pattern, replacement); + } + } + + return path; +}; + +const normalizeUnknownPath = (path: string): string => { + if (STATIC_PATHS.has(path)) { + return path; + } + + if (path === '/api' || path.startsWith('/api/')) { + return '/api/#path'; + } + + if (path === '/images' || path.startsWith('/images/')) { + return '/images/#path'; + } + + if (path === '/avatars' || path.startsWith('/avatars/')) { + return '/avatars/#path'; + } + + if (path === '/t' || path.startsWith('/t/')) { + return '/t/#path'; + } + + return '/#path'; +}; + +export const normalizePath = (rawPath: string): string => { + const [pathWithoutQuery] = rawPath.split('?'); + const path = pathWithoutQuery.startsWith('/') ? pathWithoutQuery : `/${pathWithoutQuery}`; + const normalized = normalizeKnownPath(path || '/'); + + if (isLowCardinalityPath(normalized)) { + return normalized; + } + + return normalizeUnknownPath(path); +}; + +export interface PrometheusMetrics { + metricsMiddleware: (req: Request, res: Response, next: NextFunction) => void; + metricsRouter: Router; +} + +export function createMetrics(): PrometheusMetrics { + const registry = new Registry(); + collectDefaultMetrics({ register: registry }); + + const httpRequests = new Counter({ + name: 'http_requests_total', + help: 'Total HTTP requests', + labelNames: ['method', 'path', 'status'] as const, + registers: [registry], + }); + + const httpDuration = new Histogram({ + name: 'http_request_duration_seconds', + help: 'HTTP request latency in seconds', + labelNames: ['method', 'path', 'status'] as const, + buckets: [0.05, 0.1, 0.3, 0.5, 1, 2, 5], + registers: [registry], + }); + + const metricsMiddleware = (req: Request, res: Response, next: NextFunction): void => { + const end = httpDuration.startTimer(); + res.on('finish', () => { + const labels = { method: req.method, path: normalizePath(req.path), status: res.statusCode }; + httpRequests.inc(labels); + end(labels); + }); + next(); + }; + + const metricsRouter = Router(); + const metricsHandler: RequestHandler = (req, res): void => { + const secret = process.env.METRICS_SECRET; + const auth = req.headers['authorization']; + if (!secret || !auth) { + res.status(401).end(); + return; + } + const bearerToken = auth.match(/^bearer\s+(.+)$/i); + if (!bearerToken) { + res.status(401).end(); + return; + } + + const token = bearerToken[1]; + const encode = (s: string) => new TextEncoder().encode(s); + const expected = encode(secret); + const actual = encode(token); + if (expected.byteLength !== actual.byteLength || !timingSafeEqual(expected, actual)) { + res.status(401).end(); + return; + } + + void registry + .metrics() + .then((metrics) => { + res.set('Content-Type', registry.contentType); + res.end(metrics); + }) + .catch((err) => { + logger.error('[metrics] Failed to collect metrics:', err); + res.status(500).end(); + }); + }; + + metricsRouter.get('/', metricsHandler); + + return { metricsMiddleware, metricsRouter }; +} From 9e8b8c37059c38c6f135f4b147d91941e6366a25 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 13 May 2026 16:50:07 -0400 Subject: [PATCH 005/395] =?UTF-8?q?=F0=9F=A7=B0=20fix:=20Scope=20MCP=20Reg?= =?UTF-8?q?istry=20Initialization=20To=20Config=20Fingerprints=20(#13115)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/mcp/registry/MCPServersInitializer.ts | 148 +++++++++++++++--- .../__tests__/MCPServersInitializer.test.ts | 138 ++++++++++++++++ .../mcp/registry/cache/RegistryStatusCache.ts | 38 ++++- 3 files changed, 296 insertions(+), 28 deletions(-) diff --git a/packages/api/src/mcp/registry/MCPServersInitializer.ts b/packages/api/src/mcp/registry/MCPServersInitializer.ts index a8b8e3ca8a4..56c6ef486b0 100644 --- a/packages/api/src/mcp/registry/MCPServersInitializer.ts +++ b/packages/api/src/mcp/registry/MCPServersInitializer.ts @@ -1,3 +1,4 @@ +import { createHash } from 'crypto'; import { logger } from '@librechat/data-schemas'; import type * as t from '~/mcp/types'; import { registryStatusCache as statusCache } from './cache/RegistryStatusCache'; @@ -6,8 +7,30 @@ import { sanitizeUrlForLogging } from '~/mcp/utils'; import { withTimeout } from '~/utils'; import { isLeader } from '~/cluster'; -const MCP_INIT_TIMEOUT_MS = - process.env.MCP_INIT_TIMEOUT_MS != null ? parseInt(process.env.MCP_INIT_TIMEOUT_MS) : 30_000; +const DEFAULT_MCP_INIT_TIMEOUT_MS = 30_000; +const DEFAULT_FOLLOWER_RETRY_MS = 3000; + +const parseDurationMs = ( + value: string | undefined, + fallback: number, + allowZero = false, +): number => { + if (value == null) { + return fallback; + } + const parsed = Number(value); + if (!Number.isFinite(parsed)) { + return fallback; + } + if (allowZero) { + return parsed >= 0 ? parsed : fallback; + } + return parsed > 0 ? parsed : fallback; +}; + +type InitializeRegistryOptions = { + skipStatusLeaderCheck?: boolean; +}; /** * Handles initialization of MCP servers at application startup with distributed coordination. @@ -37,37 +60,72 @@ export class MCPServersInitializer { } public static async initialize(rawConfigs: t.MCPServers): Promise { + const configHash = MCPServersInitializer.configHash(rawConfigs); // On first call in this process, always reset and re-initialize // This ensures we don't use stale Redis data from previous runs - const isFirstCallThisProcess = !MCPServersInitializer.hasInitializedThisProcess; - // Set flag immediately so recursive calls (from followers) use Redis cache for coordination + let isFirstAttemptThisProcess = !MCPServersInitializer.hasInitializedThisProcess; + // Set flag immediately so follower retries use Redis cache for coordination MCPServersInitializer.hasInitializedThisProcess = true; + const followerWaitStartedAt = performance.now(); + + while (true) { + if (!isFirstAttemptThisProcess && (await statusCache.isInitializedFor(configHash))) { + return; + } - if (!isFirstCallThisProcess && (await statusCache.isInitialized())) return; - - if (await isLeader()) { - // Leader performs initialization - always reset on first call - await statusCache.reset(); - await MCPServersRegistry.getInstance().reset(); - const serverNames = Object.keys(rawConfigs); - await Promise.allSettled( - serverNames.map((serverName) => - withTimeout( - MCPServersInitializer.initializeServer(serverName, rawConfigs[serverName]), - MCP_INIT_TIMEOUT_MS, - `${MCPServersInitializer.prefix(serverName)} Server initialization timed out`, - logger.error, - ), + if (await isLeader()) { + await MCPServersInitializer.initializeRegistry(rawConfigs, configHash); + return; + } + + const followerWaitMs = performance.now() - followerWaitStartedAt; + const followerMaxWaitMs = MCPServersInitializer.followerMaxWaitMs(); + if (followerWaitMs >= followerMaxWaitMs) { + logger.warn( + '[MCP] Timed out waiting for leader to initialize registry for current config fingerprint; initializing on this instance', + ); + await MCPServersInitializer.initializeRegistry(rawConfigs, configHash, { + skipStatusLeaderCheck: true, + }); + return; + } + + logger.debug( + '[MCP] Waiting for leader to initialize registry for current config fingerprint', + ); + isFirstAttemptThisProcess = false; + await new Promise((resolve) => + setTimeout( + resolve, + Math.min(MCPServersInitializer.followerRetryMs(), followerMaxWaitMs - followerWaitMs), ), ); - await statusCache.setInitialized(true); - } else { - // Followers try again after a delay if not initialized - await new Promise((resolve) => setTimeout(resolve, 3000)); - await this.initialize(rawConfigs); } } + private static async initializeRegistry( + rawConfigs: t.MCPServers, + configHash: string, + options?: InitializeRegistryOptions, + ): Promise { + await statusCache.reset(); + await MCPServersRegistry.getInstance().reset(); + const serverNames = Object.keys(rawConfigs); + await Promise.allSettled( + serverNames.map((serverName) => + withTimeout( + MCPServersInitializer.initializeServer(serverName, rawConfigs[serverName]), + MCPServersInitializer.initTimeoutMs(), + `${MCPServersInitializer.prefix(serverName)} Server initialization timed out`, + logger.error, + ), + ), + ); + await statusCache.setInitialized(true, configHash, { + skipLeaderCheck: options?.skipStatusLeaderCheck, + }); + } + /** Initializes a single server with all its metadata and adds it to appropriate collections */ public static async initializeServer(serverName: string, rawConfig: t.MCPOptions): Promise { try { @@ -110,4 +168,46 @@ export class MCPServersInitializer { private static prefix(serverName: string): string { return `[MCP][${serverName}]`; } + + private static configHash(rawConfigs: t.MCPServers): string { + const registry = MCPServersRegistry.getInstance(); + const fingerprint = { + rawConfigs, + allowedDomains: registry.getAllowedDomains() ?? null, + allowedAddresses: registry.getAllowedAddresses() ?? null, + }; + return createHash('sha256') + .update(JSON.stringify(MCPServersInitializer.sortForHash(fingerprint))) + .digest('hex'); + } + + private static followerRetryMs(): number { + return parseDurationMs(process.env.MCP_INIT_FOLLOWER_RETRY_MS, DEFAULT_FOLLOWER_RETRY_MS); + } + + private static followerMaxWaitMs(): number { + return parseDurationMs( + process.env.MCP_INIT_FOLLOWER_MAX_WAIT_MS, + MCPServersInitializer.initTimeoutMs() + MCPServersInitializer.followerRetryMs(), + true, + ); + } + + private static initTimeoutMs(): number { + return parseDurationMs(process.env.MCP_INIT_TIMEOUT_MS, DEFAULT_MCP_INIT_TIMEOUT_MS); + } + + private static sortForHash(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map((item) => MCPServersInitializer.sortForHash(item)); + } + if (value !== null && typeof value === 'object') { + return Object.fromEntries( + Object.entries(value as Record) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([key, item]) => [key, MCPServersInitializer.sortForHash(item)]), + ); + } + return value; + } } diff --git a/packages/api/src/mcp/registry/__tests__/MCPServersInitializer.test.ts b/packages/api/src/mcp/registry/__tests__/MCPServersInitializer.test.ts index dd372744e06..03cfa948fcf 100644 --- a/packages/api/src/mcp/registry/__tests__/MCPServersInitializer.test.ts +++ b/packages/api/src/mcp/registry/__tests__/MCPServersInitializer.test.ts @@ -1,5 +1,6 @@ import { logger } from '@librechat/data-schemas'; import * as t from '~/mcp/types'; +import { isLeader } from '~/cluster'; import { registryStatusCache } from '~/mcp/registry/cache/RegistryStatusCache'; import { MCPServersInitializer } from '~/mcp/registry/MCPServersInitializer'; import { MCPServerInspector } from '~/mcp/registry/MCPServerInspector'; @@ -47,6 +48,33 @@ const mockInspect = MCPServerInspector.inspect as jest.MockedFunction< typeof MCPServerInspector.inspect >; +const withFollowerWaitEnv = async ( + retryMs: string, + maxWaitMs: string, + callback: () => Promise, +): Promise => { + const originalRetryMs = process.env.MCP_INIT_FOLLOWER_RETRY_MS; + const originalMaxWaitMs = process.env.MCP_INIT_FOLLOWER_MAX_WAIT_MS; + process.env.MCP_INIT_FOLLOWER_RETRY_MS = retryMs; + process.env.MCP_INIT_FOLLOWER_MAX_WAIT_MS = maxWaitMs; + + try { + await callback(); + } finally { + if (originalRetryMs == null) { + delete process.env.MCP_INIT_FOLLOWER_RETRY_MS; + } else { + process.env.MCP_INIT_FOLLOWER_RETRY_MS = originalRetryMs; + } + + if (originalMaxWaitMs == null) { + delete process.env.MCP_INIT_FOLLOWER_MAX_WAIT_MS; + } else { + process.env.MCP_INIT_FOLLOWER_MAX_WAIT_MS = originalMaxWaitMs; + } + } +}; + describe('MCPServersInitializer', () => { let mockConnection: jest.Mocked; let registry: MCPServersRegistry; @@ -184,6 +212,7 @@ describe('MCPServersInitializer', () => { await registry.reset(); MCPServersInitializer.resetProcessFlag(); jest.clearAllMocks(); + (isLeader as jest.MockedFunction).mockResolvedValue(true); }); afterEach(() => { @@ -221,6 +250,115 @@ describe('MCPServersInitializer', () => { expect(mockInspect).not.toHaveBeenCalled(); }); + it('should re-initialize when the shared initialized status belongs to a different config', async () => { + await MCPServersInitializer.initialize(testConfigs); + expect(await registryStatusCache.isInitialized()).toBe(true); + const firstConfigHash = await registryStatusCache.getInitializedConfigHash(); + + const updatedConfigs: t.MCPServers = { + ...testConfigs, + new_server: { + type: 'stdio', + command: 'node', + args: ['new-server.js'], + }, + }; + + jest.clearAllMocks(); + + await MCPServersInitializer.initialize(updatedConfigs); + + expect(mockInspect).toHaveBeenCalledTimes(6); + expect(await registry.getServerConfig('new_server')).toBeDefined(); + expect(await registryStatusCache.getInitializedConfigHash()).not.toBe(firstConfigHash); + }); + + it('should not let a follower accept stale initialized status from another config', async () => { + await MCPServersInitializer.initialize(testConfigs); + + const updatedConfigs: t.MCPServers = { + ...testConfigs, + new_server: { + type: 'stdio', + command: 'node', + args: ['new-server.js'], + }, + }; + const mockIsLeader = isLeader as jest.MockedFunction; + mockIsLeader.mockResolvedValueOnce(false).mockResolvedValueOnce(true); + + jest.clearAllMocks(); + + await withFollowerWaitEnv('1', '50', async () => { + await MCPServersInitializer.initialize(updatedConfigs); + }); + + expect(mockInspect).toHaveBeenCalledTimes(6); + expect(mockIsLeader.mock.calls.length).toBeGreaterThanOrEqual(2); + expect(await registry.getServerConfig('new_server')).toBeDefined(); + }); + + it('should initialize locally when a stale follower wait is exhausted', async () => { + await MCPServersInitializer.initialize(testConfigs); + + const updatedConfigs: t.MCPServers = { + ...testConfigs, + new_server: { + type: 'stdio', + command: 'node', + args: ['new-server.js'], + }, + }; + const mockIsLeader = isLeader as jest.MockedFunction; + mockIsLeader.mockResolvedValue(false); + + jest.clearAllMocks(); + + await withFollowerWaitEnv('1', '1', async () => { + await MCPServersInitializer.initialize(updatedConfigs); + }); + + expect(mockInspect).toHaveBeenCalledTimes(6); + expect(mockIsLeader).toHaveBeenCalled(); + expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('Timed out waiting')); + expect(await registry.getServerConfig('new_server')).toBeDefined(); + }); + + it('should ignore invalid follower max wait env values', async () => { + await MCPServersInitializer.initialize(testConfigs); + + const updatedConfigs: t.MCPServers = { + ...testConfigs, + new_server: { + type: 'stdio', + command: 'node', + args: ['new-server.js'], + }, + }; + const mockIsLeader = isLeader as jest.MockedFunction; + mockIsLeader.mockResolvedValue(false); + const originalInitTimeoutMs = process.env.MCP_INIT_TIMEOUT_MS; + process.env.MCP_INIT_TIMEOUT_MS = '1'; + + jest.clearAllMocks(); + + try { + await withFollowerWaitEnv('1', 'not-a-number', async () => { + await MCPServersInitializer.initialize(updatedConfigs); + }); + } finally { + if (originalInitTimeoutMs == null) { + delete process.env.MCP_INIT_TIMEOUT_MS; + } else { + process.env.MCP_INIT_TIMEOUT_MS = originalInitTimeoutMs; + } + } + + expect(mockInspect).toHaveBeenCalledTimes(6); + expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('Timed out waiting')); + expect(await registry.getServerConfig('new_server')).toBeDefined(); + }); + it('should process all server configs through inspector', async () => { await MCPServersInitializer.initialize(testConfigs); diff --git a/packages/api/src/mcp/registry/cache/RegistryStatusCache.ts b/packages/api/src/mcp/registry/cache/RegistryStatusCache.ts index 5fbd18ffdfb..41e35ae99e8 100644 --- a/packages/api/src/mcp/registry/cache/RegistryStatusCache.ts +++ b/packages/api/src/mcp/registry/cache/RegistryStatusCache.ts @@ -3,6 +3,11 @@ import { BaseRegistryCache } from './BaseRegistryCache'; // Status keys const INITIALIZED = 'INITIALIZED'; +const INITIALIZED_CONFIG_HASH = 'INITIALIZED_CONFIG_HASH'; + +type StatusSetOptions = { + skipLeaderCheck?: boolean; +}; /** * Cache for tracking MCP Servers Registry global metadata and status across distributed instances. @@ -20,16 +25,41 @@ class RegistryStatusCache extends BaseRegistryCache { return (await this.get(INITIALIZED)) === true; } - public async setInitialized(value: boolean): Promise { - await this.set(INITIALIZED, value); + public async isInitializedFor(configHash: string): Promise { + if (!(await this.isInitialized())) { + return false; + } + return (await this.getInitializedConfigHash()) === configHash; + } + + public async getInitializedConfigHash(): Promise { + return this.get(INITIALIZED_CONFIG_HASH); + } + + public async setInitialized( + value: boolean, + configHash?: string, + options?: StatusSetOptions, + ): Promise { + if (configHash != null) { + await this.set(INITIALIZED_CONFIG_HASH, configHash, undefined, options); + } + await this.set(INITIALIZED, value, undefined, options); } private async get(key: string): Promise { return this.cache.get(key); } - private async set(key: string, value: string | number | boolean, ttl?: number): Promise { - await this.leaderCheck('set MCP Servers Registry status'); + private async set( + key: string, + value: string | number | boolean, + ttl?: number, + options?: StatusSetOptions, + ): Promise { + if (!options?.skipLeaderCheck) { + await this.leaderCheck('set MCP Servers Registry status'); + } const success = await this.cache.set(key, value, ttl); this.successCheck(`set status key "${key}"`, success); } From 7f58e4c2ede9b2938f49745386bd64fc0dab2719 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 13 May 2026 19:17:39 -0400 Subject: [PATCH 006/395] =?UTF-8?q?=F0=9F=A7=BE=20feat:=20Add=20Structured?= =?UTF-8?q?=20Logging=20Context=20(#13110)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add structured logging context * fix: reduce cloudfront disabled logging * fix: preserve strict reject logging context * chore: format auth middleware test * fix: omit system tenant from log context * fix: type parser spec formatter info * fix: normalize tenant guard before reject checks --- api/config/__tests__/parsers.spec.js | 72 ++++++++ api/config/parsers.js | 42 ++++- api/config/winston.js | 57 +++++- .../__tests__/requireJwtAuth.spec.js | 48 +++++- api/server/middleware/requireJwtAuth.js | 13 +- api/server/services/AuthService.js | 22 ++- api/server/services/AuthService.spec.js | 8 +- .../cdn/__tests__/cloudfront-cookies.test.ts | 8 + packages/api/src/cdn/cloudfront-cookies.ts | 22 ++- .../src/middleware/__tests__/tenant.spec.ts | 163 +++++++++++++++++- .../api/src/middleware/preAuthTenant.spec.ts | 13 +- packages/api/src/middleware/preAuthTenant.ts | 36 ++-- packages/api/src/middleware/tenant.ts | 128 +++++++++++--- .../data-schemas/src/config/parsers.spec.ts | 73 ++++++++ packages/data-schemas/src/config/parsers.ts | 32 +++- .../src/config/tenantContext.spec.ts | 19 +- .../data-schemas/src/config/tenantContext.ts | 15 +- packages/data-schemas/src/config/winston.ts | 49 +++++- packages/data-schemas/src/index.ts | 2 + 19 files changed, 727 insertions(+), 95 deletions(-) create mode 100644 packages/data-schemas/src/config/parsers.spec.ts diff --git a/api/config/__tests__/parsers.spec.js b/api/config/__tests__/parsers.spec.js index f54675ce3ae..4c783609531 100644 --- a/api/config/__tests__/parsers.spec.js +++ b/api/config/__tests__/parsers.spec.js @@ -30,6 +30,18 @@ describe('formatConsoleMeta', () => { expect(meta).toContain('"messagesToRefineCount":42'); }); + it('omits the system tenant sentinel from metadata trailers', () => { + const meta = formatConsoleMeta({ + level: 'warn', + message: 'system task', + timestamp: 'ts', + tenantId: '__SYSTEM__', + userId: 'user-1', + }); + + expect(meta).toBe('{"userId":"user-1"}'); + }); + it('ignores reserved winston keys but preserves legitimate fields like _id', () => { const meta = formatConsoleMeta({ level: 'error', @@ -315,6 +327,66 @@ describe('debugTraverse', () => { expect(tenantMatches.length).toBe(1); }); + it('appends request context metadata for non-debug lines', () => { + const out = runFormatter( + buildInfo('info', { + tenantId: 'tenant-1', + userId: 'user-1', + requestId: 'req-1', + }), + ); + + expect(out).toContain('"tenantId":"tenant-1"'); + expect(out).toContain('"userId":"user-1"'); + expect(out).toContain('"requestId":"req-1"'); + }); + + it('does not append the system tenant sentinel as tenantId', () => { + const out = runFormatter( + buildInfo('info', { + tenantId: '__SYSTEM__', + userId: 'user-1', + requestId: 'req-1', + }), + ); + + expect(out).not.toContain('__SYSTEM__'); + expect(out).not.toContain('"tenantId"'); + expect(out).toContain('"userId":"user-1"'); + expect(out).toContain('"requestId":"req-1"'); + }); + + it('omits the system tenant sentinel from debug object metadata', () => { + const out = runFormatter( + buildInfo('debug', { + tenantId: '__SYSTEM__', + userId: 'user-1', + }), + ); + + expect(out).not.toContain('__SYSTEM__'); + expect(out).not.toMatch(/tenantId:/); + expect(out).toContain('userId'); + }); + + it('appends request context metadata for debug lines without object metadata', () => { + const info = { + level: 'debug', + message: 'prefix:', + timestamp: 'ts', + tenantId: 'tenant-1', + userId: 'user-1', + requestId: 'req-1', + [SPLAT_SYMBOL]: ['detailValueXYZ'], + }; + const out = runFormatter(info); + + expect(out).toContain('detailValueXYZ'); + expect(out).toContain('"tenantId":"tenant-1"'); + expect(out).toContain('"userId":"user-1"'); + expect(out).toContain('"requestId":"req-1"'); + }); + it('omits numeric splat-artifact keys from the traversed output', () => { const info = { level: 'error', diff --git a/api/config/parsers.js b/api/config/parsers.js index 111f3f6a7db..477e3712535 100644 --- a/api/config/parsers.js +++ b/api/config/parsers.js @@ -18,6 +18,8 @@ const sensitiveKeys = [ ]; const NUMERIC_KEY_RE = /^\d+$/; +const LOG_CONTEXT_KEYS = ['tenantId', 'userId', 'requestId']; +const SYSTEM_TENANT_ID = '__SYSTEM__'; /** * Redacts sensitive information from a console message and trims it to a specified length if provided. @@ -122,6 +124,9 @@ function extractMetaObject(source) { continue; } const value = source[key]; + if (key === 'tenantId' && value === SYSTEM_TENANT_ID) { + continue; + } if (value === undefined || value === null || value === '') { continue; } @@ -197,6 +202,23 @@ function formatConsoleMeta(info) { } } +function formatRequestContext(info) { + if (info == null || typeof info !== 'object') { + return ''; + } + const context = {}; + for (const key of LOG_CONTEXT_KEYS) { + const value = info[key]; + if (key === 'tenantId' && value === SYSTEM_TENANT_ID) { + continue; + } + if (typeof value === 'string' && value) { + context[key] = value; + } + } + return Object.keys(context).length > 0 ? JSON.stringify(context) : ''; +} + /** * Formats log messages for file and debug-console transports. Three paths: * - `warn` / `error`: append a compact single-line JSON metadata trailer @@ -207,7 +229,7 @@ function formatConsoleMeta(info) { * Redaction on this path is not applied here (debug-file consumers * historically accept raw detail). * - Other levels: return the truncated `" : "` - * line with no metadata. + * line with request context metadata when present. * * @param {Object} options - The options for formatting log messages. * @param {string} options.level - The log level. @@ -245,31 +267,41 @@ const debugTraverse = winston.format.printf(({ level, message, timestamp, ...met try { if (level !== 'debug') { - return msg; + const trailer = formatRequestContext(metadata); + return trailer ? `${msg} ${trailer}` : msg; } if (!metadata) { return msg; } + const appendMetadataTrailer = (line) => { + const trailer = formatRequestContext(metadata); + return trailer ? `${line} ${trailer}` : line; + }; + const debugValue = metadata[SPLAT_SYMBOL]?.[0]; if (!debugValue) { - return msg; + return appendMetadataTrailer(msg); } if (debugValue && Array.isArray(debugValue)) { msg += `\n${JSON.stringify(debugValue.map(condenseArray))}`; - return msg; + return appendMetadataTrailer(msg); } if (typeof debugValue !== 'object') { - return (msg += ` ${debugValue}`); + msg += ` ${debugValue}`; + return appendMetadataTrailer(msg); } msg += '\n{'; const copy = klona(metadata); + if (copy.tenantId === SYSTEM_TENANT_ID) { + delete copy.tenantId; + } traverse(copy).forEach(function (value) { if (typeof this?.key === 'symbol') { return; diff --git a/api/config/winston.js b/api/config/winston.js index c077cf5bf84..163e0b4eab0 100644 --- a/api/config/winston.js +++ b/api/config/winston.js @@ -2,6 +2,12 @@ const path = require('path'); const fs = require('fs'); const winston = require('winston'); require('winston-daily-rotate-file'); +const { + getTenantId, + getUserId, + getRequestId, + SYSTEM_TENANT_ID, +} = require('@librechat/data-schemas'); const { redactFormat, redactMessage, @@ -63,6 +69,44 @@ const levels = { silly: 7, }; +const LOG_CONTEXT_KEYS = ['tenantId', 'userId', 'requestId']; + +const getLogTenantId = () => { + const tenantId = getTenantId(); + return tenantId === SYSTEM_TENANT_ID ? undefined : tenantId; +}; + +const requestContextFormat = winston.format((info) => { + if (info.tenantId === SYSTEM_TENANT_ID) { + delete info.tenantId; + } + const context = { + tenantId: getLogTenantId(), + userId: getUserId(), + requestId: getRequestId(), + }; + LOG_CONTEXT_KEYS.forEach((key) => { + if (context[key] && info[key] == null) { + info[key] = context[key]; + } + }); + return info; +}); + +const formatRequestContext = (info) => { + const context = {}; + LOG_CONTEXT_KEYS.forEach((key) => { + const value = info[key]; + if (key === 'tenantId' && value === SYSTEM_TENANT_ID) { + return; + } + if (typeof value === 'string' && value) { + context[key] = value; + } + }); + return Object.keys(context).length > 0 ? JSON.stringify(context) : ''; +}; + winston.addColors({ info: 'green', // fontStyle color warn: 'italic yellow', @@ -81,6 +125,7 @@ const fileFormat = winston.format.combine( winston.format.timestamp({ format: () => new Date().toISOString() }), winston.format.errors({ stack: true }), winston.format.splat(), + requestContextFormat(), // redactErrors(), ); @@ -112,20 +157,16 @@ if (useDebugLogging) { const consoleFormat = winston.format.combine( redactFormat(), + requestContextFormat(), winston.format.colorize({ all: true }), winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), // redactErrors(), winston.format.printf((info) => { const base = `${info.timestamp} ${info.level}: ${info.message}`; const isErrorOrWarn = info.level.includes('error') || info.level.includes('warn'); - - if (isErrorOrWarn) { - const metaTrailer = formatConsoleMeta(info); - const line = metaTrailer ? `${base} ${metaTrailer}` : base; - return redactMessage(line); - } - - return base; + const metaTrailer = isErrorOrWarn ? formatConsoleMeta(info) : formatRequestContext(info); + const line = metaTrailer ? `${base} ${metaTrailer}` : base; + return isErrorOrWarn ? redactMessage(line) : line; }), ); diff --git a/api/server/middleware/__tests__/requireJwtAuth.spec.js b/api/server/middleware/__tests__/requireJwtAuth.spec.js index 7f0963398d7..4059be24098 100644 --- a/api/server/middleware/__tests__/requireJwtAuth.spec.js +++ b/api/server/middleware/__tests__/requireJwtAuth.spec.js @@ -41,25 +41,42 @@ jest.mock('@librechat/data-schemas', () => { const tenantStorage = new AsyncLocalStorage(); return { getTenantId: () => tenantStorage.getStore()?.tenantId, + getUserId: () => tenantStorage.getStore()?.userId, + getRequestId: () => tenantStorage.getStore()?.requestId, 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 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), 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,7 +84,7 @@ jest.mock('@librechat/api', () => { // ── Helpers ───────────────────────────────────────────────────────────── const requireJwtAuth = require('../requireJwtAuth'); -const { getTenantId } = require('@librechat/data-schemas'); +const { getTenantId, getUserId } = require('@librechat/data-schemas'); const { isEnabled, maybeRefreshCloudFrontAuthCookiesMiddleware } = require('@librechat/api'); const passport = require('passport'); @@ -151,6 +168,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(); diff --git a/api/server/middleware/requireJwtAuth.js b/api/server/middleware/requireJwtAuth.js index e9abbc7fa89..935957e913d 100644 --- a/api/server/middleware/requireJwtAuth.js +++ b/api/server/middleware/requireJwtAuth.js @@ -35,8 +35,8 @@ const refreshCloudFrontCookies = * 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. + * `tenantContextMiddleware` to propagate request context into AsyncLocalStorage + * for downstream Mongoose tenant isolation and structured logging. */ const requireJwtAuth = (req, res, next) => { const cookieHeader = req.headers.cookie; @@ -71,12 +71,11 @@ const requireJwtAuth = (req, res, next) => { } req.user = user; req.authStrategy = strategy; - refreshCloudFrontCookies(req, res, (refreshErr) => { - if (refreshErr) { - return next(refreshErr); + 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); }; diff --git a/api/server/services/AuthService.js b/api/server/services/AuthService.js index 943c5a81f6f..24374ef4c68 100644 --- a/api/server/services/AuthService.js +++ b/api/server/services/AuthService.js @@ -448,6 +448,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 +479,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; } diff --git a/api/server/services/AuthService.spec.js b/api/server/services/AuthService.spec.js index c81b78e8e15..95a208c5c8b 100644 --- a/api/server/services/AuthService.spec.js +++ b/api/server/services/AuthService.spec.js @@ -576,13 +576,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), ); }); diff --git a/packages/api/src/cdn/__tests__/cloudfront-cookies.test.ts b/packages/api/src/cdn/__tests__/cloudfront-cookies.test.ts index b9d22048e3b..940dece049c 100644 --- a/packages/api/src/cdn/__tests__/cloudfront-cookies.test.ts +++ b/packages/api/src/cdn/__tests__/cloudfront-cookies.test.ts @@ -840,6 +840,10 @@ describe('maybeRefreshCloudFrontAuthCookies', () => { expect(result).toMatchObject({ enabled: false, attempted: false, refreshed: false }); expect(mockGetSignedCookies).not.toHaveBeenCalled(); + expect(mockLogger.debug).not.toHaveBeenCalledWith( + '[maybeRefreshCloudFrontAuthCookies] CloudFront auth cookies skipped', + expect.any(Object), + ); }); it('does not refresh when imageSigning is not cookies', () => { @@ -851,6 +855,10 @@ describe('maybeRefreshCloudFrontAuthCookies', () => { expect(result).toMatchObject({ enabled: false, attempted: false, refreshed: false }); expect(mockGetSignedCookies).not.toHaveBeenCalled(); + expect(mockLogger.debug).not.toHaveBeenCalledWith( + '[maybeRefreshCloudFrontAuthCookies] CloudFront auth cookies skipped', + expect.any(Object), + ); }); it('force-refreshes even when the scope cookie is fresh without calling OIDC refresh', () => { diff --git a/packages/api/src/cdn/cloudfront-cookies.ts b/packages/api/src/cdn/cloudfront-cookies.ts index 98de321043f..8f1add221b8 100644 --- a/packages/api/src/cdn/cloudfront-cookies.ts +++ b/packages/api/src/cdn/cloudfront-cookies.ts @@ -312,6 +312,10 @@ function getCloudFrontCookieSkipReason(scope: CloudFrontCookieScope): string | n return null; } +function shouldLogCloudFrontCookieSkip(reason: string): boolean { + return reason !== 'cloudfront_disabled'; +} + function getScopeRefreshReason( previousScope: CloudFrontCookieScope | null, currentScope: CloudFrontCookieScope, @@ -540,14 +544,16 @@ export function maybeRefreshCloudFrontAuthCookies( const timing = getCloudFrontCookieTiming(); if (skipReason) { - logger.debug('[maybeRefreshCloudFrontAuthCookies] CloudFront auth cookies skipped', { - attempted: false, - refreshed: false, - reason: skipReason, - has_user_id: Boolean(scope.userId), - has_tenant_scope: Boolean(scope.tenantId), - has_storage_region: Boolean(scope.storageRegion), - }); + if (shouldLogCloudFrontCookieSkip(skipReason)) { + logger.debug('[maybeRefreshCloudFrontAuthCookies] CloudFront auth cookies skipped', { + attempted: false, + refreshed: false, + reason: skipReason, + has_user_id: Boolean(scope.userId), + has_tenant_scope: Boolean(scope.tenantId), + has_storage_region: Boolean(scope.storageRegion), + }); + } return { enabled: false, attempted: false, diff --git a/packages/api/src/middleware/__tests__/tenant.spec.ts b/packages/api/src/middleware/__tests__/tenant.spec.ts index d393cdfe0d9..6a78ad3098c 100644 --- a/packages/api/src/middleware/__tests__/tenant.spec.ts +++ b/packages/api/src/middleware/__tests__/tenant.spec.ts @@ -1,5 +1,11 @@ import { unlink } from 'fs/promises'; -import { getTenantId, SYSTEM_TENANT_ID } from '@librechat/data-schemas'; +import { + getTenantId, + getUserId, + getRequestId, + SYSTEM_TENANT_ID, + logger, +} from '@librechat/data-schemas'; import type { Response, NextFunction } from 'express'; import type { ServerRequest } from '~/types/http'; // Import directly from source file — _resetTenantMiddlewareStrictCache is intentionally @@ -18,11 +24,18 @@ jest.mock('fs/promises', () => ({ const unlinkMock = unlink as jest.MockedFunction; function mockReq(user?: Record): ServerRequest { - return { user } as unknown as ServerRequest; + return { headers: {}, user } as unknown as ServerRequest; } function mockTenantReq(user?: Record, tenantId?: string): ServerRequest { - return { user, tenantId } as unknown as ServerRequest; + return { headers: {}, user, tenantId } as unknown as ServerRequest; +} + +function mockReqWithHeaders( + user: Record | undefined, + headers: Record, +): ServerRequest { + return { headers, user } as unknown as ServerRequest; } function mockRes(): Response { @@ -43,11 +56,32 @@ function runMiddleware(req: ServerRequest, res: Response): Promise { + return new Promise((resolve) => { + const next: NextFunction = () => { + resolve({ + tenantId: getTenantId(), + userId: getUserId(), + requestId: getRequestId(), + }); + }; + tenantContextMiddleware(req, res, next); + }); +} + describe('tenantContextMiddleware', () => { afterEach(() => { _resetTenantMiddlewareStrictCache(); delete process.env.TENANT_ISOLATION_STRICT; unlinkMock.mockClear(); + jest.restoreAllMocks(); }); it('sets ALS tenant context for authenticated requests with tenantId', async () => { @@ -58,6 +92,22 @@ describe('tenantContextMiddleware', () => { expect(tenantId).toBe('tenant-x'); }); + it('sets ALS user and request context for authenticated tenant requests', async () => { + const req = mockReqWithHeaders( + { id: 'user-123', tenantId: 'tenant-x', role: 'user' }, + { 'x-request-id': 'req-abc' }, + ); + const res = mockRes(); + + const context = await runMiddlewareContext(req, res); + + expect(context).toEqual({ + tenantId: 'tenant-x', + userId: 'user-123', + requestId: 'req-abc', + }); + }); + it('is a no-op for unauthenticated requests (no user)', async () => { const req = mockReq(); const res = mockRes(); @@ -74,6 +124,22 @@ describe('tenantContextMiddleware', () => { expect(tenantId).toBeUndefined(); }); + it('keeps user context in non-strict single-tenant mode', async () => { + const req = mockReqWithHeaders( + { id: 'single-user', role: 'user' }, + { 'x-request-id': 'req-1' }, + ); + const res = mockRes(); + + const context = await runMiddlewareContext(req, res); + + expect(context).toEqual({ + tenantId: undefined, + userId: 'single-user', + requestId: 'req-1', + }); + }); + it('returns 403 when user has no tenantId in strict mode', async () => { process.env.TENANT_ISOLATION_STRICT = 'true'; _resetTenantMiddlewareStrictCache(); @@ -122,6 +188,7 @@ describe('restoreTenantContextFromReq', () => { _resetTenantMiddlewareStrictCache(); delete process.env.TENANT_ISOLATION_STRICT; unlinkMock.mockClear(); + jest.restoreAllMocks(); }); it('restores ALS tenant context from req.user.tenantId', async () => { @@ -139,6 +206,34 @@ describe('restoreTenantContextFromReq', () => { expect(tenantId).toBe('tenant-user'); }); + it('restores user and request context alongside tenant context', async () => { + const req = mockReqWithHeaders( + { id: 'restore-user', tenantId: 'tenant-user', role: 'user' }, + { 'x-correlation-id': 'corr-123' }, + ); + const res = mockRes(); + + const context = await new Promise<{ + tenantId?: string; + userId?: string; + requestId?: string; + }>((resolve) => { + restoreTenantContextFromReq(req, res, () => { + resolve({ + tenantId: getTenantId(), + userId: getUserId(), + requestId: getRequestId(), + }); + }); + }); + + expect(context).toEqual({ + tenantId: 'tenant-user', + userId: 'restore-user', + requestId: 'corr-123', + }); + }); + it('prefers server-resolved req.tenantId over req.user.tenantId', async () => { const req = mockTenantReq({ tenantId: 'tenant-user', role: 'user' }, 'tenant-request'); const res = mockRes(); @@ -187,6 +282,37 @@ describe('restoreTenantContextFromReq', () => { expect(next).not.toHaveBeenCalled(); }); + it('keeps request context while cleaning up rejected strict-mode uploads', async () => { + process.env.TENANT_ISOLATION_STRICT = 'true'; + _resetTenantMiddlewareStrictCache(); + unlinkMock.mockRejectedValueOnce(new Error('unlink failed')); + let observedContext: { userId?: string; requestId?: string } | undefined; + jest.spyOn(logger, 'error').mockImplementation(() => { + observedContext = { + userId: getUserId(), + requestId: getRequestId(), + }; + return logger; + }); + + const req = { + ...mockReqWithHeaders({ id: 'strict-user', role: 'user' }, { 'x-request-id': 'req-strict' }), + file: { path: '/tmp/no-tenant-upload' }, + } as ServerRequest; + const res = mockRes(); + const next: NextFunction = jest.fn(); + + await restoreTenantContextFromReq(req, res, next); + + expect(logger.error).toHaveBeenCalledWith( + '[restoreTenantContextFromReq] Failed to delete rejected upload:', + expect.objectContaining({ path: '/tmp/no-tenant-upload' }), + ); + expect(observedContext).toEqual({ userId: 'strict-user', requestId: 'req-strict' }); + expect(res.status).toHaveBeenCalledWith(403); + expect(next).not.toHaveBeenCalled(); + }); + it('rejects the system tenant sentinel for request-owned work', async () => { const req = mockReq({ tenantId: SYSTEM_TENANT_ID, role: 'user' }); const res = mockRes(); @@ -198,6 +324,37 @@ describe('restoreTenantContextFromReq', () => { expect(next).not.toHaveBeenCalled(); }); + it('rejects a normalized system tenant sentinel for request-owned work', async () => { + const req = mockTenantReq({ role: 'user' }, ` ${SYSTEM_TENANT_ID} `); + const res = mockRes(); + const next: NextFunction = jest.fn(); + + await restoreTenantContextFromReq(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + error: 'System tenant is not allowed for request-scoped routes', + }); + expect(next).not.toHaveBeenCalled(); + }); + + it('rejects blank server-resolved tenant IDs in strict mode', async () => { + process.env.TENANT_ISOLATION_STRICT = 'true'; + _resetTenantMiddlewareStrictCache(); + + const req = mockTenantReq({ role: 'user' }, ' '); + const res = mockRes(); + const next: NextFunction = jest.fn(); + + await restoreTenantContextFromReq(req, res, next); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ error: expect.stringContaining('Tenant context required') }), + ); + expect(next).not.toHaveBeenCalled(); + }); + it('deletes uploaded temp files before rejecting system-tenant requests', async () => { const req = { ...mockReq({ tenantId: SYSTEM_TENANT_ID, role: 'user' }), diff --git a/packages/api/src/middleware/preAuthTenant.spec.ts b/packages/api/src/middleware/preAuthTenant.spec.ts index 669a43c84fd..8aeb8b93baf 100644 --- a/packages/api/src/middleware/preAuthTenant.spec.ts +++ b/packages/api/src/middleware/preAuthTenant.spec.ts @@ -1,4 +1,4 @@ -import { getTenantId, logger } from '@librechat/data-schemas'; +import { getTenantId, getRequestId, logger } from '@librechat/data-schemas'; import { preAuthTenantMiddleware } from './preAuthTenant'; import type { Request, Response, NextFunction } from 'express'; @@ -54,6 +54,17 @@ describe('preAuthTenantMiddleware', () => { expect(capturedTenantId).toBe('acme-corp'); }); + it('propagates request ID from pre-auth routes', () => { + req.headers = { 'x-request-id': 'req-preauth' }; + let capturedRequestId: string | undefined; + const capturedNext: NextFunction = () => { + capturedRequestId = getRequestId(); + }; + + preAuthTenantMiddleware(req as Request, res as Response, capturedNext); + expect(capturedRequestId).toBe('req-preauth'); + }); + it('ignores __SYSTEM__ sentinel and logs warning', () => { req.headers = { 'x-tenant-id': '__SYSTEM__' }; req.ip = '10.0.0.1'; diff --git a/packages/api/src/middleware/preAuthTenant.ts b/packages/api/src/middleware/preAuthTenant.ts index bab91f3a18d..5d59cd8ae9e 100644 --- a/packages/api/src/middleware/preAuthTenant.ts +++ b/packages/api/src/middleware/preAuthTenant.ts @@ -1,5 +1,6 @@ -import { tenantStorage, logger, SYSTEM_TENANT_ID } from '@librechat/data-schemas'; +import { logger, SYSTEM_TENANT_ID } from '@librechat/data-schemas'; import type { Request, Response, NextFunction } from 'express'; +import { buildTenantContext, runWithTenantContext } from './tenant'; /** * Pre-authentication tenant context middleware for unauthenticated routes. @@ -27,46 +28,49 @@ import type { Request, Response, NextFunction } from 'express'; * 3. Layer additional resolution on top (e.g., OpenID `tenant` claim → header). * * If no header is present, downstream runs without tenant ALS context (same as - * single-tenant mode). This preserves backward compatibility. + * single-tenant mode), while request logging context can still propagate. */ const MAX_TENANT_ID_LENGTH = 128; const VALID_TENANT_ID = /^[-a-zA-Z0-9_.]+$/; export function preAuthTenantMiddleware(req: Request, res: Response, next: NextFunction): void { const raw = req.headers['x-tenant-id']; + const requestContext = buildTenantContext({ headers: req.headers }); if (!raw || typeof raw !== 'string') { - next(); + runWithTenantContext(requestContext, next); return; } const tenantId = raw.trim(); if (!tenantId) { - next(); + runWithTenantContext(requestContext, next); return; } if (tenantId === SYSTEM_TENANT_ID) { - logger.warn('[preAuthTenant] Rejected __SYSTEM__ sentinel in X-Tenant-Id header', { - ip: req.ip, - path: req.path, + runWithTenantContext(requestContext, () => { + logger.warn('[preAuthTenant] Rejected __SYSTEM__ sentinel in X-Tenant-Id header', { + ip: req.ip, + path: req.path, + }); + next(); }); - next(); return; } if (tenantId.length > MAX_TENANT_ID_LENGTH || !VALID_TENANT_ID.test(tenantId)) { - logger.warn('[preAuthTenant] Rejected malformed X-Tenant-Id header', { - ip: req.ip, - length: tenantId.length, - path: req.path, + runWithTenantContext(requestContext, () => { + logger.warn('[preAuthTenant] Rejected malformed X-Tenant-Id header', { + ip: req.ip, + length: tenantId.length, + path: req.path, + }); + next(); }); - next(); return; } - return void tenantStorage.run({ tenantId }, async () => { - next(); - }); + runWithTenantContext(buildTenantContext({ headers: req.headers }, tenantId), next); } diff --git a/packages/api/src/middleware/tenant.ts b/packages/api/src/middleware/tenant.ts index d0907e0d653..41af195584d 100644 --- a/packages/api/src/middleware/tenant.ts +++ b/packages/api/src/middleware/tenant.ts @@ -1,9 +1,26 @@ import { unlink } from 'fs/promises'; import { isMainThread } from 'worker_threads'; -import { getTenantId, tenantStorage, logger, SYSTEM_TENANT_ID } from '@librechat/data-schemas'; +import { tenantStorage, logger, SYSTEM_TENANT_ID } from '@librechat/data-schemas'; +import type { TenantContext } from '@librechat/data-schemas'; import type { Response, NextFunction } from 'express'; import type { ServerRequest } from '~/types/http'; +type ContextUser = { + tenantId?: string; + id?: string; + _id?: { toString: () => string }; +} | null; + +type ContextRequest = { + headers: ServerRequest['headers']; + tenantId?: string; + user?: ContextUser; + id?: string; + requestId?: string; +}; + +const REQUEST_ID_HEADERS = ['x-request-id', 'x-correlation-id'] as const; + let _checkedThread = false; let _strictMode: boolean | undefined; @@ -17,20 +34,73 @@ export function _resetTenantMiddlewareStrictCache(): void { _strictMode = undefined; } +function normalizeContextValue(value?: string): string | undefined { + const trimmed = value?.trim(); + return trimmed || undefined; +} + +function getHeaderValue(value: string | string[] | undefined): string | undefined { + return normalizeContextValue(Array.isArray(value) ? value[0] : value); +} + +function getRequestId(req: ContextRequest): string | undefined { + const requestId = normalizeContextValue(req.requestId) ?? normalizeContextValue(req.id); + if (requestId) { + return requestId; + } + for (const header of REQUEST_ID_HEADERS) { + const value = getHeaderValue(req.headers[header]); + if (value) { + return value; + } + } + return undefined; +} + +function getUserId(user: ContextUser): string | undefined { + return normalizeContextValue(user?.id) ?? normalizeContextValue(user?._id?.toString()); +} + +function hasTenantContext(context: TenantContext): boolean { + return Boolean(context.tenantId || context.userId || context.requestId); +} + +export function buildTenantContext( + req: ContextRequest, + tenantId = req.tenantId ?? req.user?.tenantId, +): TenantContext { + return { + tenantId: normalizeContextValue(tenantId), + userId: getUserId(req.user ?? null), + requestId: getRequestId(req), + }; +} + +export function runWithTenantContext(context: TenantContext, next: NextFunction): void { + if (!hasTenantContext(context)) { + next(); + return; + } + return void tenantStorage.run(context, async () => { + next(); + }); +} + /** * Express middleware that propagates the authenticated user's `tenantId` into - * the AsyncLocalStorage context used by the Mongoose tenant-isolation plugin. + * the AsyncLocalStorage context used by the Mongoose tenant-isolation plugin + * and request-scoped logging. * * **Placement**: Chained automatically by `requireJwtAuth` after successful * passport authentication (req.user is populated). Must NOT be registered at * global `app.use()` scope — `req.user` is undefined at that stage. * * Behaviour: - * - Authenticated request with `tenantId` → wraps downstream in `tenantStorage.run({ tenantId })` + * - Authenticated request with context → wraps downstream in `tenantStorage.run(context)` * - Authenticated request **without** `tenantId`: * - Strict mode (`TENANT_ISOLATION_STRICT=true`) → responds 403 - * - Non-strict (default) → passes through without ALS context (backward compat) - * - Unauthenticated request → no-op (calls `next()` directly) + * - Non-strict (default) → passes through with user/request context only + * - Unauthenticated request → propagates request context when available */ export function tenantContextMiddleware( req: ServerRequest, @@ -47,27 +117,26 @@ export function tenantContextMiddleware( } } - const user = req.user as { tenantId?: string } | undefined; + const user = req.user; + const context = buildTenantContext(req); if (!user) { - next(); + runWithTenantContext(context, next); return; } - const tenantId = user.tenantId; + const { tenantId } = context; if (!tenantId) { if (isStrict()) { res.status(403).json({ error: 'Tenant context required in strict isolation mode' }); return; } - next(); + runWithTenantContext(context, next); return; } - return void tenantStorage.run({ tenantId }, async () => { - next(); - }); + runWithTenantContext(context, next); } export type RequestTenantSource = { @@ -137,6 +206,19 @@ async function rejectRequestWithUploadCleanup( res.status(403).json({ error: message }); } +function rejectRequestWithUploadCleanupInContext( + context: TenantContext, + req: ServerRequest, + res: Response, + message: string, +): Promise { + const rejectRequest = () => rejectRequestWithUploadCleanup(req, res, message); + if (!hasTenantContext(context)) { + return rejectRequest(); + } + return tenantStorage.run(context, rejectRequest); +} + /** * Re-enters tenant ALS from the server-resolved request tenant. * @@ -150,20 +232,23 @@ export function restoreTenantContextFromReq( next: NextFunction, ): void | Promise { const tenantId = resolveRequestTenantId(req as RequestTenantSource); + const context = buildTenantContext(req, tenantId); + const resolvedTenantId = context.tenantId; - if (!tenantId) { + if (!resolvedTenantId) { if (isStrict()) { - return rejectRequestWithUploadCleanup( + return rejectRequestWithUploadCleanupInContext( + context, req, res, 'Tenant context required in strict isolation mode', ); } - next(); + runWithTenantContext(context, next); return; } - if (tenantId === SYSTEM_TENANT_ID) { + if (resolvedTenantId === SYSTEM_TENANT_ID) { logger.warn('[restoreTenantContextFromReq] Rejected system tenant for request route', { path: req.path, }); @@ -174,12 +259,15 @@ export function restoreTenantContextFromReq( ); } - if (getTenantId() === tenantId) { + const currentContext = tenantStorage.getStore(); + if ( + currentContext?.tenantId === context.tenantId && + currentContext?.userId === context.userId && + currentContext?.requestId === context.requestId + ) { next(); return; } - return void tenantStorage.run({ tenantId }, async () => { - next(); - }); + return runWithTenantContext(context, next); } diff --git a/packages/data-schemas/src/config/parsers.spec.ts b/packages/data-schemas/src/config/parsers.spec.ts new file mode 100644 index 00000000000..188f42db3cc --- /dev/null +++ b/packages/data-schemas/src/config/parsers.spec.ts @@ -0,0 +1,73 @@ +import { debugTraverse } from './parsers'; + +const SPLAT_SYMBOL = Symbol.for('splat'); +const MESSAGE_SYMBOL = Symbol.for('message'); + +type FormatterInfo = Record & { + level: string; + message: string; + timestamp: string; +}; + +function runFormatter(info: FormatterInfo): string { + const transformed = debugTraverse.transform(info); + if (transformed && typeof transformed === 'object') { + const message = (transformed as Record)[MESSAGE_SYMBOL]; + return typeof message === 'string' ? message : String(transformed); + } + return String(transformed); +} + +function buildInfo(level: string, meta: Record): FormatterInfo { + return { + level, + message: 'test', + timestamp: 'ts', + ...meta, + [SPLAT_SYMBOL]: [meta], + }; +} + +describe('debugTraverse request context', () => { + it('appends request context metadata for non-debug lines', () => { + const out = runFormatter( + buildInfo('info', { + tenantId: 'tenant-1', + userId: 'user-1', + requestId: 'req-1', + }), + ); + + expect(out).toContain('"tenantId":"tenant-1"'); + expect(out).toContain('"userId":"user-1"'); + expect(out).toContain('"requestId":"req-1"'); + }); + + it('does not append the system tenant sentinel as tenantId', () => { + const out = runFormatter( + buildInfo('info', { + tenantId: '__SYSTEM__', + userId: 'user-1', + requestId: 'req-1', + }), + ); + + expect(out).not.toContain('__SYSTEM__'); + expect(out).not.toContain('"tenantId"'); + expect(out).toContain('"userId":"user-1"'); + expect(out).toContain('"requestId":"req-1"'); + }); + + it('omits the system tenant sentinel from debug object metadata', () => { + const out = runFormatter( + buildInfo('debug', { + tenantId: '__SYSTEM__', + userId: 'user-1', + }), + ); + + expect(out).not.toContain('__SYSTEM__'); + expect(out).not.toMatch(/tenantId:/); + expect(out).toContain('userId'); + }); +}); diff --git a/packages/data-schemas/src/config/parsers.ts b/packages/data-schemas/src/config/parsers.ts index 80dd9b767c3..e21927a72b3 100644 --- a/packages/data-schemas/src/config/parsers.ts +++ b/packages/data-schemas/src/config/parsers.ts @@ -1,6 +1,7 @@ import { klona } from 'klona'; import winston from 'winston'; import traverse from '../utils/object-traverse'; +import { SYSTEM_TENANT_ID } from './tenantContext'; import type { TraverseContext } from '../utils/object-traverse'; const SPLAT_SYMBOL = Symbol.for('splat'); @@ -8,6 +9,7 @@ const MESSAGE_SYMBOL = Symbol.for('message'); const CONSOLE_JSON_STRING_LENGTH: number = parseInt(process.env.CONSOLE_JSON_STRING_LENGTH || '', 10) || 255; const DEBUG_MESSAGE_LENGTH: number = parseInt(process.env.DEBUG_MESSAGE_LENGTH || '', 10) || 150; +const LOG_CONTEXT_KEYS = ['tenantId', 'userId', 'requestId'] as const; const sensitiveKeys: RegExp[] = [ /^(sk-)[^\s]+/, // OpenAI API key pattern @@ -104,6 +106,25 @@ const condenseArray = (item: unknown): string | unknown => { return item; }; +function formatRequestContext(metadata: Record): string { + const context: Partial> = {}; + LOG_CONTEXT_KEYS.forEach((key) => { + const value = metadata[key]; + if (key === 'tenantId' && value === SYSTEM_TENANT_ID) { + return; + } + if (typeof value === 'string' && value) { + context[key] = value; + } + }); + return Object.keys(context).length > 0 ? JSON.stringify(context) : ''; +} + +function appendRequestContext(line: string, metadata: Record): string { + const context = formatRequestContext(metadata); + return context ? `${line} ${context}` : line; +} + /** * Formats log messages for debugging purposes. * - Truncates long strings within log messages. @@ -131,7 +152,7 @@ const debugTraverse = winston.format.printf( try { if (level !== 'debug') { - return msgParts[0]; + return appendRequestContext(msgParts[0], metadata); } if (!metadata) { @@ -144,22 +165,25 @@ const debugTraverse = winston.format.printf( const debugValue = Array.isArray(splatArray) ? splatArray[0] : undefined; if (!debugValue) { - return msgParts[0]; + return appendRequestContext(msgParts[0], metadata); } if (debugValue && Array.isArray(debugValue)) { msgParts.push(`\n${JSON.stringify(debugValue.map(condenseArray))}`); - return msgParts.join(''); + return appendRequestContext(msgParts.join(''), metadata); } if (typeof debugValue !== 'object') { msgParts.push(` ${debugValue}`); - return msgParts.join(''); + return appendRequestContext(msgParts.join(''), metadata); } msgParts.push('\n{'); const copy = klona(metadata); + if (copy.tenantId === SYSTEM_TENANT_ID) { + delete copy.tenantId; + } try { const traversal = traverse(copy); traversal.forEach(function (this: TraverseContext, value: unknown) { diff --git a/packages/data-schemas/src/config/tenantContext.spec.ts b/packages/data-schemas/src/config/tenantContext.spec.ts index 7e6cc0748d1..a01e01ff76b 100644 --- a/packages/data-schemas/src/config/tenantContext.spec.ts +++ b/packages/data-schemas/src/config/tenantContext.spec.ts @@ -1,4 +1,4 @@ -import { tenantStorage, runAsSystem, scopedCacheKey } from './tenantContext'; +import { tenantStorage, getUserId, getRequestId, runAsSystem, scopedCacheKey } from './tenantContext'; describe('scopedCacheKey', () => { it('returns base key when no ALS context is set', () => { @@ -23,4 +23,21 @@ describe('scopedCacheKey', () => { }); expect(scopedCacheKey('KEY')).toBe('KEY'); }); + + it('reads user and request IDs from ALS context', async () => { + await tenantStorage.run({ userId: 'user-1', requestId: 'req-1' }, async () => { + expect(getUserId()).toBe('user-1'); + expect(getRequestId()).toBe('req-1'); + }); + }); + + it('preserves user and request context inside system tenant operations', async () => { + await tenantStorage.run({ tenantId: 'acme', userId: 'user-1', requestId: 'req-1' }, async () => { + await runAsSystem(async () => { + expect(getUserId()).toBe('user-1'); + expect(getRequestId()).toBe('req-1'); + expect(scopedCacheKey('KEY')).toBe('KEY'); + }); + }); + }); }); diff --git a/packages/data-schemas/src/config/tenantContext.ts b/packages/data-schemas/src/config/tenantContext.ts index eb77edb27d1..9dc73f972bf 100644 --- a/packages/data-schemas/src/config/tenantContext.ts +++ b/packages/data-schemas/src/config/tenantContext.ts @@ -2,6 +2,8 @@ import { AsyncLocalStorage } from 'async_hooks'; export interface TenantContext { tenantId?: string; + userId?: string; + requestId?: string; } /** Sentinel value for deliberate cross-tenant system operations */ @@ -19,12 +21,23 @@ export function getTenantId(): string | undefined { return tenantStorage.getStore()?.tenantId; } +/** Returns the current user ID from async context, or undefined if none is set */ +export function getUserId(): string | undefined { + return tenantStorage.getStore()?.userId; +} + +/** Returns the current request ID from async context, or undefined if none is set */ +export function getRequestId(): string | undefined { + return tenantStorage.getStore()?.requestId; +} + /** * Runs a function in an explicit cross-tenant system context (bypasses tenant filtering). * The callback MUST be async — sync callbacks returning Mongoose thenables will lose context. */ export function runAsSystem(fn: () => Promise): Promise { - return tenantStorage.run({ tenantId: SYSTEM_TENANT_ID }, fn); + const { requestId, userId } = tenantStorage.getStore() ?? {}; + return tenantStorage.run({ tenantId: SYSTEM_TENANT_ID, requestId, userId }, fn); } /** diff --git a/packages/data-schemas/src/config/winston.ts b/packages/data-schemas/src/config/winston.ts index 24a2c6c9871..9bc76524150 100644 --- a/packages/data-schemas/src/config/winston.ts +++ b/packages/data-schemas/src/config/winston.ts @@ -1,6 +1,7 @@ import winston from 'winston'; import 'winston-daily-rotate-file'; import { redactFormat, redactMessage, debugTraverse, jsonTruncateFormat } from './parsers'; +import { getTenantId, getUserId, getRequestId, SYSTEM_TENANT_ID } from './tenantContext'; import { getLogDirectory } from './utils'; const logDir = getLogDirectory(); @@ -24,6 +25,49 @@ const levels: winston.config.AbstractConfigSetLevels = { silly: 7, }; +const LOG_CONTEXT_KEYS = ['tenantId', 'userId', 'requestId'] as const; + +function getLogTenantId(): string | undefined { + const tenantId = getTenantId(); + return tenantId === SYSTEM_TENANT_ID ? undefined : tenantId; +} + +const requestContextFormat = winston.format((info: winston.Logform.TransformableInfo) => { + if (info.tenantId === SYSTEM_TENANT_ID) { + delete info.tenantId; + } + const context = { + tenantId: getLogTenantId(), + userId: getUserId(), + requestId: getRequestId(), + }; + LOG_CONTEXT_KEYS.forEach((key) => { + if (context[key] && info[key] == null) { + info[key] = context[key]; + } + }); + return info; +}); + +function formatRequestContext(info: winston.Logform.TransformableInfo): string { + const context: Partial> = {}; + LOG_CONTEXT_KEYS.forEach((key) => { + const value = info[key]; + if (key === 'tenantId' && value === SYSTEM_TENANT_ID) { + return; + } + if (typeof value === 'string' && value) { + context[key] = value; + } + }); + return Object.keys(context).length > 0 ? JSON.stringify(context) : ''; +} + +function appendRequestContext(line: string, info: winston.Logform.TransformableInfo): string { + const context = formatRequestContext(info); + return context ? `${line} ${context}` : line; +} + winston.addColors({ info: 'green', warn: 'italic yellow', @@ -41,6 +85,7 @@ const fileFormat = winston.format.combine( winston.format.timestamp({ format: () => new Date().toISOString() }), winston.format.errors({ stack: true }), winston.format.splat(), + requestContextFormat(), ); const transports: winston.transport[] = [ @@ -71,11 +116,13 @@ if (useDebugLogging) { const consoleFormat = winston.format.combine( redactFormat(), + requestContextFormat(), winston.format.colorize({ all: true }), winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), winston.format.printf((info) => { const message = `${info.timestamp} ${info.level}: ${info.message}`; - return info.level.includes('error') ? redactMessage(message) : message; + const line = appendRequestContext(message, info); + return info.level.includes('error') ? redactMessage(line) : line; }), ); diff --git a/packages/data-schemas/src/index.ts b/packages/data-schemas/src/index.ts index 4156559658d..8c95d7f9cc4 100644 --- a/packages/data-schemas/src/index.ts +++ b/packages/data-schemas/src/index.ts @@ -23,6 +23,8 @@ export { default as meiliLogger } from './config/meiliLogger'; export { tenantStorage, getTenantId, + getUserId, + getRequestId, runAsSystem, scopedCacheKey, SYSTEM_TENANT_ID, From ae75fb68a69a89ae6c565d264f41c310f2ca1a8d Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 13 May 2026 19:38:28 -0400 Subject: [PATCH 007/395] =?UTF-8?q?=F0=9F=93=B8=20refactor:=20Refresh=20Sh?= =?UTF-8?q?ared=20Links=20With=20Latest=20Snapshot=20(#13095)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: refresh shared links with latest target * fix: validate shared link refresh payload --- api/server/routes/share.js | 8 +- .../ConvoOptions/SharedLinkButton.tsx | 2 +- client/src/data-provider/mutations.ts | 8 +- packages/data-provider/src/data-service.ts | 7 +- .../src/react-query/react-query-service.ts | 5 +- packages/data-provider/src/schemas.ts | 1 + packages/data-provider/src/types.ts | 6 +- .../data-schemas/src/methods/share.test.ts | 103 ++++++++++++++++++ packages/data-schemas/src/methods/share.ts | 27 ++++- packages/data-schemas/src/types/share.ts | 3 + 10 files changed, 150 insertions(+), 20 deletions(-) diff --git a/api/server/routes/share.js b/api/server/routes/share.js index 296644afded..4c0427f197c 100644 --- a/api/server/routes/share.js +++ b/api/server/routes/share.js @@ -87,6 +87,7 @@ router.get('/link/:conversationId', requireJwtAuth, async (req, res) => { return res.status(200).json({ success: share.success, shareId: share.shareId, + targetMessageId: share.targetMessageId, conversationId: req.params.conversationId, }); } catch (error) { @@ -112,7 +113,12 @@ router.post('/:conversationId', requireJwtAuth, async (req, res) => { router.patch('/:shareId', requireJwtAuth, 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' }); + } + + const updatedShare = await updateSharedLink(req.user.id, req.params.shareId, targetMessageId); if (updatedShare) { res.status(200).json(updatedShare); } else { diff --git a/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx b/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx index 7c53cab64c0..724ee822e1c 100644 --- a/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx +++ b/client/src/components/Conversations/ConvoOptions/SharedLinkButton.tsx @@ -92,7 +92,7 @@ export default function SharedLinkButton({ if (!shareId) { return; } - const updateShare = await mutateAsync({ shareId }); + const updateShare = await mutateAsync({ shareId, targetMessageId }); const newLink = generateShareLink(updateShare.shareId); setSharedLink(newLink); setAnnouncement(localize('com_ui_link_refreshed')); diff --git a/client/src/data-provider/mutations.ts b/client/src/data-provider/mutations.ts index e10aff030a7..6edb8f35524 100644 --- a/client/src/data-provider/mutations.ts +++ b/client/src/data-provider/mutations.ts @@ -176,17 +176,17 @@ export const useCreateSharedLinkMutation = ( }; export const useUpdateSharedLinkMutation = ( - options?: t.MutationOptions, -): UseMutationResult => { + options?: t.MutationOptions, +): UseMutationResult => { const queryClient = useQueryClient(); const { onSuccess, ..._options } = options || {}; return useMutation( - ({ shareId }) => { + ({ shareId, targetMessageId }) => { if (!shareId) { throw new Error('Share ID is required'); } - return dataService.updateSharedLink(shareId); + return dataService.updateSharedLink(shareId, targetMessageId); }, { onSuccess: (_data: t.TSharedLinkResponse, vars, context) => { diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 15cf7ed17d8..4704a5bbe00 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -83,8 +83,11 @@ export function createSharedLink( return request.post(endpoints.createSharedLink(conversationId), { targetMessageId }); } -export function updateSharedLink(shareId: string): Promise { - return request.patch(endpoints.updateSharedLink(shareId)); +export function updateSharedLink( + shareId: string, + targetMessageId?: string, +): Promise { + return request.patch(endpoints.updateSharedLink(shareId), { targetMessageId }); } export function deleteSharedLink(shareId: string): Promise { diff --git a/packages/data-provider/src/react-query/react-query-service.ts b/packages/data-provider/src/react-query/react-query-service.ts index 571dce58308..b865ab3841b 100644 --- a/packages/data-provider/src/react-query/react-query-service.ts +++ b/packages/data-provider/src/react-query/react-query-service.ts @@ -51,10 +51,7 @@ export const useGetSharedLinkQuery = ( refetchOnReconnect: false, refetchOnMount: false, onSuccess: (data) => { - queryClient.setQueryData([QueryKeys.sharedLinks, conversationId], { - conversationId: data.conversationId, - shareId: data.shareId, - }); + queryClient.setQueryData([QueryKeys.sharedLinks, conversationId], data); }, ...config, }, diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index bf9c119eece..3d72940872c 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -1024,6 +1024,7 @@ export type TConversation = z.infer & { export const tSharedLinkSchema = z.object({ conversationId: z.string(), shareId: z.string(), + targetMessageId: z.string().optional(), messages: z.array(z.string()), isPublic: z.boolean(), title: z.string(), diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index b1c037c94fa..0cfb826819e 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -313,12 +313,14 @@ export type TSharedMessagesResponse = Omit & { export type TCreateShareLinkRequest = Pick; -export type TUpdateShareLinkRequest = Pick; +export type TUpdateShareLinkRequest = Pick; export type TSharedLinkResponse = Pick & + Pick & Pick; -export type TSharedLinkGetResponse = TSharedLinkResponse & { +export type TSharedLinkGetResponse = Omit & { + shareId: string | null; success: boolean; }; diff --git a/packages/data-schemas/src/methods/share.test.ts b/packages/data-schemas/src/methods/share.test.ts index 302b16811cb..4f045d5b974 100644 --- a/packages/data-schemas/src/methods/share.test.ts +++ b/packages/data-schemas/src/methods/share.test.ts @@ -26,6 +26,7 @@ describe('Share Methods', () => { user: { type: String, index: true }, messages: [{ type: mongoose.Schema.Types.ObjectId, ref: 'Message' }], shareId: { type: String, index: true }, + targetMessageId: { type: String, required: false, index: true }, isPublic: { type: Boolean, default: true }, }, { timestamps: true }, @@ -714,6 +715,108 @@ describe('Share Methods', () => { ); }); + test('should update branch target to the latest refreshed message', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + const shareId = `share_${nanoid()}`; + const rootMessageId = `msg_${nanoid()}`; + const oldAnswerId = `msg_${nanoid()}`; + const rerunPromptId = `msg_${nanoid()}`; + const rerunAnswerId = `msg_${nanoid()}`; + + await Conversation.create({ + conversationId, + title: 'Analysis Conversation', + user: userId, + }); + + const initialMessages = await Message.create([ + { + messageId: rootMessageId, + conversationId, + user: userId, + text: 'Analyze February 2023 to October 2025', + isCreatedByUser: true, + parentMessageId: Constants.NO_PARENT, + }, + { + messageId: oldAnswerId, + conversationId, + user: userId, + text: 'Old analysis result', + isCreatedByUser: false, + parentMessageId: rootMessageId, + }, + ]); + + await SharedLink.create({ + shareId, + conversationId, + user: userId, + messages: initialMessages.map((message) => message._id), + targetMessageId: oldAnswerId, + isPublic: true, + }); + + await Message.create([ + { + messageId: rerunPromptId, + conversationId, + user: userId, + text: 'Rerun for March 2023 to January 2026', + isCreatedByUser: true, + parentMessageId: oldAnswerId, + }, + { + messageId: rerunAnswerId, + conversationId, + user: userId, + text: 'Updated analysis result', + isCreatedByUser: false, + parentMessageId: rerunPromptId, + }, + ]); + + const result = await shareMethods.updateSharedLink(userId, shareId, rerunAnswerId); + const updatedShare = await SharedLink.findOne({ shareId: result.shareId }).populate( + 'messages', + ); + const sharedMessages = await shareMethods.getSharedMessages(result.shareId); + + expect(result.shareId).not.toBe(shareId); + expect(result.targetMessageId).toBe(rerunAnswerId); + expect(updatedShare?.targetMessageId).toBe(rerunAnswerId); + expect(updatedShare?.messages).toHaveLength(4); + expect(sharedMessages?.messages.map((message) => message.text)).toEqual([ + 'Analyze February 2023 to October 2025', + 'Old analysis result', + 'Rerun for March 2023 to January 2026', + 'Updated analysis result', + ]); + }); + + test('should preserve existing branch target when refresh has no target override', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + const shareId = `share_${nanoid()}`; + const targetMessageId = `msg_${nanoid()}`; + + await SharedLink.create({ + shareId, + conversationId, + user: userId, + messages: [], + targetMessageId, + isPublic: true, + }); + + const result = await shareMethods.updateSharedLink(userId, shareId); + const updatedShare = await SharedLink.findOne({ shareId: result.shareId }); + + expect(result.targetMessageId).toBe(targetMessageId); + expect(updatedShare?.targetMessageId).toBe(targetMessageId); + }); + test('should not allow user to update shared link they do not own', async () => { const ownerUserId = new mongoose.Types.ObjectId().toString(); const otherUserId = new mongoose.Types.ObjectId().toString(); diff --git a/packages/data-schemas/src/methods/share.ts b/packages/data-schemas/src/methods/share.ts index 2a0d2bc3bd8..c6b6400e64f 100644 --- a/packages/data-schemas/src/methods/share.ts +++ b/packages/data-schemas/src/methods/share.ts @@ -410,7 +410,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { ...(targetMessageId && { targetMessageId }), }); - return { shareId, conversationId }; + return { shareId, conversationId, targetMessageId }; } catch (error) { if (error instanceof ShareServiceError) { throw error; @@ -439,14 +439,19 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { try { const SharedLink = mongoose.models.SharedLink as Model; const share = (await SharedLink.findOne({ conversationId, user, isPublic: true }) - .select('shareId -_id') - .lean()) as { shareId?: string } | null; + .select('shareId targetMessageId -_id') + .sort({ updatedAt: -1 }) + .lean()) as { shareId?: string; targetMessageId?: string } | null; if (!share) { return { shareId: null, success: false }; } - return { shareId: share.shareId || null, success: true }; + return { + shareId: share.shareId || null, + targetMessageId: share.targetMessageId, + success: true, + }; } catch (error) { logger.error('[getSharedLink] Error getting shared link', { error: error instanceof Error ? error.message : 'Unknown error', @@ -460,7 +465,11 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { /** * Update a shared link with new messages */ - async function updateSharedLink(user: string, shareId: string): Promise { + async function updateSharedLink( + user: string, + shareId: string, + targetMessageId?: string, + ): Promise { if (!user || !shareId) { throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); } @@ -481,10 +490,12 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { .lean(); const newShareId = nanoid(); + const resolvedTargetMessageId = targetMessageId ?? share.targetMessageId; const update = { messages: updatedMessages, user, shareId: newShareId, + ...(resolvedTargetMessageId && { targetMessageId: resolvedTargetMessageId }), }; const updatedShare = (await SharedLink.findOneAndUpdate({ shareId, user }, update, { @@ -499,7 +510,11 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { anonymizeConvo(updatedShare); - return { shareId: newShareId, conversationId: updatedShare.conversationId }; + return { + shareId: newShareId, + conversationId: updatedShare.conversationId, + targetMessageId: updatedShare.targetMessageId, + }; } catch (error) { logger.error('[updateSharedLink] Error updating shared link', { error: error instanceof Error ? error.message : 'Unknown error', diff --git a/packages/data-schemas/src/types/share.ts b/packages/data-schemas/src/types/share.ts index 8b54990cf49..6ef16ad87a0 100644 --- a/packages/data-schemas/src/types/share.ts +++ b/packages/data-schemas/src/types/share.ts @@ -43,11 +43,13 @@ export interface SharedMessagesResult { export interface CreateShareResult { shareId: string; conversationId: string; + targetMessageId?: string; } export interface UpdateShareResult { shareId: string; conversationId: string; + targetMessageId?: string; } export interface DeleteShareResult { @@ -58,6 +60,7 @@ export interface DeleteShareResult { export interface GetShareLinkResult { shareId: string | null; + targetMessageId?: string; success: boolean; } From c582e87e3b8e80f24ec728991b7d3f3644e3a9ac Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 13 May 2026 22:13:36 -0500 Subject: [PATCH 008/395] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20feat:=20Bedrock?= =?UTF-8?q?=20Guardrail=20Config=20Environment=20Variable=20Resolution=20(?= =?UTF-8?q?#11717)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * bedrock config loading * Update packages/api/src/endpoints/bedrock/initialize.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * test refactor --------- Co-authored-by: Josh Fink Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../src/endpoints/bedrock/initialize.spec.ts | 116 ++++++++++++++++++ .../api/src/endpoints/bedrock/initialize.ts | 6 +- 2 files changed, 121 insertions(+), 1 deletion(-) diff --git a/packages/api/src/endpoints/bedrock/initialize.spec.ts b/packages/api/src/endpoints/bedrock/initialize.spec.ts index a06dbb779cb..f69da3d4960 100644 --- a/packages/api/src/endpoints/bedrock/initialize.spec.ts +++ b/packages/api/src/endpoints/bedrock/initialize.spec.ts @@ -210,6 +210,122 @@ describe('initializeBedrock', () => { expect(result.llmConfig.guardrailConfig).toEqual(guardrailConfig); expect(result.llmConfig.guardrailConfig?.trace).toBe('enabled_full'); }); + + it.each([ + { + description: 'guardrailIdentifier only', + envVars: { GUARDRAIL_ID: 'gr-abc123xyz' }, + input: { + guardrailIdentifier: '${GUARDRAIL_ID}', + guardrailVersion: '1', + }, + expected: { + guardrailIdentifier: 'gr-abc123xyz', + guardrailVersion: '1', + }, + }, + { + description: 'guardrailVersion only', + envVars: { GUARDRAIL_VERSION: 'DRAFT' }, + input: { + guardrailIdentifier: 'static-guardrail-id', + guardrailVersion: '${GUARDRAIL_VERSION}', + }, + expected: { + guardrailIdentifier: 'static-guardrail-id', + guardrailVersion: 'DRAFT', + }, + }, + { + description: 'both guardrailIdentifier and guardrailVersion', + envVars: { PROD_GUARDRAIL_ID: 'gr-production-123', PROD_GUARDRAIL_VERSION: '5' }, + input: { + guardrailIdentifier: '${PROD_GUARDRAIL_ID}', + guardrailVersion: '${PROD_GUARDRAIL_VERSION}', + trace: 'enabled' as const, + }, + expected: { + guardrailIdentifier: 'gr-production-123', + guardrailVersion: '5', + trace: 'enabled', + }, + }, + { + description: 'direct values when no env variable syntax is used', + envVars: {}, + input: { + guardrailIdentifier: 'direct-guardrail-id', + guardrailVersion: '3', + }, + expected: { + guardrailIdentifier: 'direct-guardrail-id', + guardrailVersion: '3', + }, + }, + { + description: 'fallback to original string when env variable is not set', + envVars: {}, + deleteEnvVars: ['NONEXISTENT_GUARDRAIL_ID'], + input: { + guardrailIdentifier: '${NONEXISTENT_GUARDRAIL_ID}', + guardrailVersion: '1', + }, + expected: { + guardrailIdentifier: '${NONEXISTENT_GUARDRAIL_ID}', + guardrailVersion: '1', + }, + }, + { + description: 'env variable with whitespace around it', + envVars: { TRIMMED_GUARDRAIL_ID: 'gr-trimmed-123' }, + input: { + guardrailIdentifier: ' ${TRIMMED_GUARDRAIL_ID} ', + guardrailVersion: '2', + }, + expected: { + guardrailIdentifier: 'gr-trimmed-123', + guardrailVersion: '2', + }, + }, + { + description: 'preserve trace field when resolving env variables', + envVars: { GUARDRAIL_WITH_TRACE: 'gr-with-trace' }, + input: { + guardrailIdentifier: '${GUARDRAIL_WITH_TRACE}', + guardrailVersion: '1', + trace: 'enabled_full' as const, + }, + expected: { + guardrailIdentifier: 'gr-with-trace', + guardrailVersion: '1', + trace: 'enabled_full', + }, + }, + ])('should resolve environment variables: $description', async ({ envVars, deleteEnvVars, input, expected }) => { + // Set up environment variables + Object.entries(envVars).forEach(([key, value]) => { + process.env[key] = value; + }); + + // Delete specified environment variables + deleteEnvVars?.forEach((key) => { + delete process.env[key]; + }); + + const params = createMockParams({ + config: { + endpoints: { + [EModelEndpoint.bedrock]: { + guardrailConfig: input, + }, + }, + }, + }); + + const result = (await initializeBedrock(params)) as BedrockLLMConfigResult; + + expect(result.llmConfig.guardrailConfig).toEqual(expected); + }); }); describe('Proxy Configuration', () => { diff --git a/packages/api/src/endpoints/bedrock/initialize.ts b/packages/api/src/endpoints/bedrock/initialize.ts index f3ba459ba58..8d61f773304 100644 --- a/packages/api/src/endpoints/bedrock/initialize.ts +++ b/packages/api/src/endpoints/bedrock/initialize.ts @@ -120,7 +120,11 @@ export async function initializeBedrock({ }; if (bedrockConfig?.guardrailConfig) { - llmConfig.guardrailConfig = bedrockConfig.guardrailConfig; + llmConfig.guardrailConfig = { + ...bedrockConfig.guardrailConfig, + guardrailIdentifier: extractEnvVariable(bedrockConfig.guardrailConfig.guardrailIdentifier), + guardrailVersion: extractEnvVariable(bedrockConfig.guardrailConfig.guardrailVersion), + }; } const model = model_parameters?.model as string | undefined; From 176e07755eddc4e4532a9b50ff291a3153f8270e Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 14 May 2026 07:47:05 -0400 Subject: [PATCH 009/395] =?UTF-8?q?=F0=9F=97=82=EF=B8=8F=20refactor:=20Col?= =?UTF-8?q?lapse=20Generated=20File=20Chips=20(#13116)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Collapse generated file chips * style: Apply file chip formatting * style: Sort grouped file locale key * fix: Collapse text-backed file outputs * style: Format text-backed file grouping * fix: Preview grouped text file outputs * fix: Count downloadable file outputs * test: Cover grouped text preview clamp --- .../Messages/Content/Parts/Attachment.tsx | 321 +++++++++++++----- .../Parts/__tests__/ArtifactRouting.test.tsx | 63 +++- .../Parts/__tests__/TextAttachment.test.tsx | 64 ++++ client/src/locales/en/translation.json | 4 + 4 files changed, 360 insertions(+), 92 deletions(-) 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) => ( ({ * 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', () => ({ @@ -716,6 +720,7 @@ describe('AttachmentGroup routing', () => { 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 +729,50 @@ 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', + type: 'application/zip', + } as Partial); + const second = baseAttachment({ + file_id: 'file-b', + filename: 'b.zip', + type: 'application/zip', + } as Partial); + const json = baseAttachment({ + file_id: 'file-c', + filename: 'c.json', + type: 'application/json', + text: '{"c":true}', + } as Partial); + const image = baseAttachment({ + file_id: 'image-a', + filename: 'preview.png', + type: 'image/png', + width: 16, + height: 16, + } as Partial); + + const { container } = renderWith( + , + ); + + 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 @@ -826,9 +875,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__/TextAttachment.test.tsx b/client/src/components/Chat/Messages/Content/Parts/__tests__/TextAttachment.test.tsx index f97c05d281b..6996550c523 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(); @@ -194,4 +198,64 @@ describe('AttachmentGroup', () => { 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: '', + type: 'application/zip', + text: undefined as unknown as string, + }), + 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', + type: 'application/zip', + text: undefined as unknown as string, + }), + 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/locales/en/translation.json b/client/src/locales/en/translation.json index c21aed0340f..16325fad642 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1088,6 +1088,7 @@ "com_ui_hide": "Hide", "com_ui_hide_code": "Hide Code", "com_ui_hide_image_details": "Hide Image Details", + "com_ui_hide_n_files": "Hide {{0}} files", "com_ui_hide_password": "Hide password", "com_ui_hide_qr": "Hide QR Code", "com_ui_high": "High", @@ -1236,6 +1237,7 @@ "com_ui_more_info": "More info", "com_ui_my_prompts": "My Prompts", "com_ui_my_skills": "My Skills", + "com_ui_n_files": "{{0}} files", "com_ui_name": "Name", "com_ui_name_sort": "Sort by Name", "com_ui_navigate_results": "Navigate results", @@ -1304,6 +1306,7 @@ "com_ui_permissions_failed_update": "Failed to update permissions. Please try again.", "com_ui_permissions_updated_success": "Permissions updated successfully", "com_ui_pin": "Pin", + "com_ui_plus_n_more": "+{{0}} more", "com_ui_preferences_updated": "Preferences updated successfully", "com_ui_prev": "Prev", "com_ui_prev_result": "Previous result", @@ -1471,6 +1474,7 @@ "com_ui_show_code": "Show Code", "com_ui_show_image_details": "Show Image Details", "com_ui_show_less": "Show less", + "com_ui_show_n_files": "Show {{0}} files", "com_ui_show_more": "Show more", "com_ui_show_password": "Show password", "com_ui_show_qr": "Show QR Code", From 7e4c5d9ded80ccd7cc551c7f1f277219aa525603 Mon Sep 17 00:00:00 2001 From: jingyeong <40864934+sand116@users.noreply.github.com> Date: Thu, 14 May 2026 21:45:50 +0900 Subject: [PATCH 010/395] =?UTF-8?q?=F0=9F=A7=B9=20fix:=20Reset=20Redis=20R?= =?UTF-8?q?eorder=20State=20After=20Last=20Unsubscribe=20(#13117)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: parkjingyeong --- ...-reorder-desync.stream_integration.spec.ts | 54 +++++++++++++++++++ .../implementations/RedisEventTransport.ts | 13 ++--- 2 files changed, 61 insertions(+), 6 deletions(-) diff --git a/packages/api/src/stream/__tests__/reconnect-reorder-desync.stream_integration.spec.ts b/packages/api/src/stream/__tests__/reconnect-reorder-desync.stream_integration.spec.ts index 6f7ad2f6eb0..2f363d049b8 100644 --- a/packages/api/src/stream/__tests__/reconnect-reorder-desync.stream_integration.spec.ts +++ b/packages/api/src/stream/__tests__/reconnect-reorder-desync.stream_integration.spec.ts @@ -234,6 +234,60 @@ describe('Reconnect Reorder Buffer Desync (Regression)', () => { sub.unsubscribe(); transport.destroy(); }); + + test('should not carry nextSeq into a new generation after last unsubscribe', async () => { + const mockPublisher = createMockPublisher(); + const mockSubscriber = { + on: jest.fn(), + subscribe: jest.fn().mockResolvedValue(undefined), + unsubscribe: jest.fn().mockResolvedValue(undefined), + }; + + const transport = new RedisEventTransport( + mockPublisher as unknown as Redis, + mockSubscriber as unknown as Redis, + ); + + const streamId = 'reorder-generation-reuse-test'; + const firstRunChunks: unknown[] = []; + + const firstSub = transport.subscribe(streamId, { + onChunk: (event) => firstRunChunks.push(event), + }); + + await transport.syncReorderBuffer(streamId); + + const messageHandler = mockSubscriber.on.mock.calls.find( + (call) => call[0] === 'message', + )?.[1] as (channel: string, message: string) => void; + const channel = `stream:{${streamId}}:events`; + + for (let i = 0; i < 5; i++) { + await transport.emitChunk(streamId, { index: i }); + messageHandler(channel, JSON.stringify({ type: 'chunk', seq: i, data: { index: i } })); + } + + expect(firstRunChunks.map((c) => (c as { index: number }).index)).toEqual([0, 1, 2, 3, 4]); + + firstSub.unsubscribe(); + + // Simulate another replica cleaning up the shared Redis sequence key + // before this replica's local preserved stream state is garbage-collected. + await mockPublisher.del(`stream:{${streamId}}:seq`); + + const secondRunChunks: unknown[] = []; + transport.subscribe(streamId, { + onChunk: (event) => secondRunChunks.push(event), + }); + + await transport.syncReorderBuffer(streamId); + + messageHandler(channel, JSON.stringify({ type: 'chunk', seq: 0, data: { index: 0 } })); + + expect(secondRunChunks.map((c) => (c as { index: number }).index)).toEqual([0]); + + transport.destroy(); + }); }); describe('syncReorderBuffer race: message arrives during async GET window (Unit)', () => { diff --git a/packages/api/src/stream/implementations/RedisEventTransport.ts b/packages/api/src/stream/implementations/RedisEventTransport.ts index 73c82aa0119..ffeb2c8e12d 100644 --- a/packages/api/src/stream/implementations/RedisEventTransport.ts +++ b/packages/api/src/stream/implementations/RedisEventTransport.ts @@ -470,12 +470,13 @@ export class RedisEventTransport implements IEventTransport { // If last subscriber left, unsubscribe from Redis and notify if (state.count === 0) { - // Clear any pending flush timeout and buffered messages - if (state.reorderBuffer.flushTimeout) { - clearTimeout(state.reorderBuffer.flushTimeout); - state.reorderBuffer.flushTimeout = null; - } - state.reorderBuffer.pending.clear(); + /** + * Preserve callbacks for reconnect, but drop ordering state from the + * previous attachment. Reconnects always call syncReorderBuffer(), so + * keeping nextSeq here only risks poisoning a later generation when + * the shared Redis sequence key has already been reset elsewhere. + */ + this.resetReorderBuffer(streamId); this.subscriber.unsubscribe(channel).catch((err) => { logger.error(`[RedisEventTransport] Failed to unsubscribe from ${channel}:`, err); From 050b7fd43ae0669ce47317d6b987ec3d6a9ec3f2 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 14 May 2026 09:08:55 -0400 Subject: [PATCH 011/395] =?UTF-8?q?=F0=9F=93=A1=20feat:=20Add=20Backend=20?= =?UTF-8?q?OpenTelemetry=20Tracing=20(#12909)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add backend OpenTelemetry tracing * fix: address telemetry type checks * fix: mark aborted telemetry requests as errors * fix: record telemetry identity after auth * fix: avoid forced telemetry signal exit * fix: harden telemetry request attribution * fix: record telemetry errors on request span * chore: order imports and reorganize middleware usage * fix: reduce telemetry startup overhead * fix: preserve live telemetry controller state * fix: redact telemetry URL attributes --- .env.example | 17 + api/package.json | 10 + api/server/index.js | 18 +- api/server/index.spec.js | 51 +++ api/server/telemetry.js | 40 ++ api/server/telemetry.spec.js | 91 ++++ package-lock.json | 281 ++++++++++++- packages/api/package.json | 14 + packages/api/rollup.config.js | 5 +- packages/api/src/telemetry/config.spec.ts | 46 ++ packages/api/src/telemetry/config.ts | 43 ++ packages/api/src/telemetry/index.ts | 5 + packages/api/src/telemetry/middleware.spec.ts | 395 ++++++++++++++++++ packages/api/src/telemetry/middleware.ts | 171 ++++++++ packages/api/src/telemetry/sdk.spec.ts | 357 ++++++++++++++++ packages/api/src/telemetry/sdk.ts | 328 +++++++++++++++ 16 files changed, 1864 insertions(+), 8 deletions(-) create mode 100644 api/server/telemetry.js create mode 100644 api/server/telemetry.spec.js create mode 100644 packages/api/src/telemetry/config.spec.ts create mode 100644 packages/api/src/telemetry/config.ts create mode 100644 packages/api/src/telemetry/index.ts create mode 100644 packages/api/src/telemetry/middleware.spec.ts create mode 100644 packages/api/src/telemetry/middleware.ts create mode 100644 packages/api/src/telemetry/sdk.spec.ts create mode 100644 packages/api/src/telemetry/sdk.ts diff --git a/.env.example b/.env.example index 816b4cd14de..e1608ec9073 100644 --- a/.env.example +++ b/.env.example @@ -110,6 +110,23 @@ 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 + #===================================================# # Endpoints # #===================================================# diff --git a/api/package.json b/api/package.json index 80c3e5fd9bd..8b3ff32a30a 100644 --- a/api/package.json +++ b/api/package.json @@ -52,6 +52,16 @@ "@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.207.0", + "@opentelemetry/semantic-conventions": "^1.39.0", "@smithy/node-http-handler": "^4.4.5", "ai-tokenizer": "^1.0.6", "axios": "^1.16.0", diff --git a/api/server/index.js b/api/server/index.js index a8146fbc1cf..9e094724f7a 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, '..') }); @@ -25,22 +25,22 @@ const { 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 { 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 { getAppConfig } = require('./services/Config'); const staticCache = require('./utils/staticCache'); -const optionalJwtAuth = require('./middleware/optionalJwtAuth'); const noIndex = require('./middleware/noIndex'); const routes = require('./routes'); @@ -146,6 +146,10 @@ const startServer = async () => { 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.'); } @@ -227,6 +231,10 @@ const startServer = async () => { res.send(updatedIndexHtml); }); + /** 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); diff --git a/api/server/index.spec.js b/api/server/index.spec.js index 7b3d062fce0..573770e2828 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,56 @@ 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('Server Configuration', () => { // Increase the default timeout to allow for Mongo cleanup jest.setTimeout(30_000); 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/package-lock.json b/package-lock.json index dbc9fb994ca..c25619187e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -67,6 +67,16 @@ "@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.207.0", + "@opentelemetry/semantic-conventions": "^1.39.0", "@smithy/node-http-handler": "^4.4.5", "ai-tokenizer": "^1.0.6", "axios": "^1.16.0", @@ -12866,7 +12876,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -14245,6 +14254,120 @@ "@opentelemetry/api": "^1.3.0" } }, + "node_modules/@opentelemetry/instrumentation-express": { + "version": "0.56.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-express/-/instrumentation-express-0.56.0.tgz", + "integrity": "sha512-rMV0WUTtAGEhHrHl3uDRIO97EkNUp4ewrW2iRVuP7kaV5qRT2b1pPV5PE75oR3GyLLSTooSAzGWl6CTm8eftKQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.207.0", + "@opentelemetry/semantic-conventions": "^1.27.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-http": { + "version": "0.207.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-http/-/instrumentation-http-0.207.0.tgz", + "integrity": "sha512-FC4i5hVixTzuhg4SV2ycTEAYx+0E2hm+GwbdoVPSA6kna0pPVI4etzaA9UkpJ9ussumQheFXP6rkGIaFJjMxsw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "2.2.0", + "@opentelemetry/instrumentation": "0.207.0", + "@opentelemetry/semantic-conventions": "^1.29.0", + "forwarded-parse": "2.1.2" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-http/node_modules/@opentelemetry/core": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", + "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/semantic-conventions": "^1.29.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": ">=1.0.0 <1.10.0" + } + }, + "node_modules/@opentelemetry/instrumentation-ioredis": { + "version": "0.55.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-ioredis/-/instrumentation-ioredis-0.55.0.tgz", + "integrity": "sha512-ASuBMzh0ImmfOnWj9vCPtBMqSjr54/r/HluUIylwZB7xzTU6gL2SfybxySJMzEL9+386gJJVApwQktVznAtrWA==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.207.0", + "@opentelemetry/redis-common": "^0.38.2" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mongodb": { + "version": "0.60.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongodb/-/instrumentation-mongodb-0.60.0.tgz", + "integrity": "sha512-8mKW2oyyWdYOKYpu70AGGAvLnExGlOoFT+Ylk9hJvWYHR5f6IrmVqwUMlzPM2WC4ihQ9crvqrtldMPmlVezTqg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/instrumentation": "^0.207.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-mongoose": { + "version": "0.54.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-mongoose/-/instrumentation-mongoose-0.54.0.tgz", + "integrity": "sha512-R2JzMrxiz3R9m+cO6iVC85bma3E3vdBr06F+D4zGcEkJS9FaCRw6+Hdb+aQ1AaDRwEMTaw+F28+6VdMpniBOXQ==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.207.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.3.0" + } + }, + "node_modules/@opentelemetry/instrumentation-undici": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation-undici/-/instrumentation-undici-0.18.0.tgz", + "integrity": "sha512-NalxLuZV621Xq4IhQkC+OXoZjAT8Xf6vYRdTjHitOXMU+4l/peRY05V7wGr4d7huf+vjyQry0XKlyhsEr4ouNw==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/core": "^2.0.0", + "@opentelemetry/instrumentation": "^0.207.0", + "@opentelemetry/semantic-conventions": "^1.24.0" + }, + "engines": { + "node": "^18.19.0 || >=20.6.0" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.7.0" + } + }, "node_modules/@opentelemetry/instrumentation/node_modules/@opentelemetry/api-logs": { "version": "0.207.0", "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.207.0.tgz", @@ -14504,12 +14627,20 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, + "node_modules/@opentelemetry/redis-common": { + "version": "0.38.3", + "resolved": "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.38.3.tgz", + "integrity": "sha512-VCghU1JYs/4gP6Gqf/xro9MEsZ7LrMv2uONVsaESKL38ZOB9BqnI98FfS23wjMnHlpuE+TTaWSoAVNpTwYXzjw==", + "license": "Apache-2.0", + "engines": { + "node": "^18.19.0 || >=20.6.0" + } + }, "node_modules/@opentelemetry/resources": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.6.1", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -28134,6 +28265,12 @@ "node": ">= 0.6" } }, + "node_modules/forwarded-parse": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/forwarded-parse/-/forwarded-parse-2.1.2.tgz", + "integrity": "sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==", + "license": "MIT" + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -28286,6 +28423,67 @@ "node": ">=14" } }, + "node_modules/gcp-metadata": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz", + "integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "gaxios": "^5.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/gcp-metadata/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/gcp-metadata/node_modules/gaxios": { + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz", + "integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==", + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^5.0.0", + "is-stream": "^2.0.0", + "node-fetch": "^2.6.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/gcp-metadata/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/generic-names": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/generic-names/-/generic-names-4.0.0.tgz", @@ -28585,6 +28783,7 @@ "version": "6.1.1", "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-6.1.1.tgz", "integrity": "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A==", + "license": "Apache-2.0", "dependencies": { "gaxios": "^6.1.1", "google-logging-utils": "^0.0.2", @@ -28598,6 +28797,7 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-0.0.2.tgz", "integrity": "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ==", + "license": "Apache-2.0", "engines": { "node": ">=14" } @@ -34726,6 +34926,52 @@ "node": ">=20.19.0" } }, + "node_modules/mongodb-memory-server-core/node_modules/gaxios": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", + "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/gcp-metadata": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-7.0.1.tgz", + "integrity": "sha512-UcO3kefx6dCcZkgcTGgVOTFb7b1LlQ02hY1omMjjrrBzkajRMCFgYOjs7J71WqnuG1k2b+9ppGL7FsOfhZMQKQ==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "peer": true, + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/mongodb-memory-server-core/node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "peer": true, + "engines": { + "node": ">=14" + } + }, "node_modules/mongodb-memory-server-core/node_modules/mongodb": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.1.1.tgz", @@ -34787,6 +35033,27 @@ "node": ">=20.19.0" } }, + "node_modules/mongodb-memory-server-core/node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dev": true, + "license": "MIT", + "optional": true, + "peer": true, + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, "node_modules/mongoose": { "version": "8.23.1", "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.23.1.tgz", @@ -44692,6 +44959,16 @@ "@librechat/agents": "^3.1.86", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.29.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.207.0", + "@opentelemetry/semantic-conventions": "^1.39.0", "@smithy/node-http-handler": "^4.4.5", "ai-tokenizer": "^1.0.6", "axios": "^1.16.0", diff --git a/packages/api/package.json b/packages/api/package.json index 1168c21bc68..048df30ba1b 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -10,6 +10,10 @@ ".": { "require": "./dist/index.js", "types": "./dist/types/index.d.ts" + }, + "./telemetry": { + "require": "./dist/telemetry.js", + "types": "./dist/types/telemetry/index.d.ts" } }, "scripts": { @@ -103,6 +107,16 @@ "@librechat/agents": "^3.1.86", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.29.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.207.0", + "@opentelemetry/semantic-conventions": "^1.39.0", "@smithy/node-http-handler": "^4.4.5", "ai-tokenizer": "^1.0.6", "axios": "^1.16.0", diff --git a/packages/api/rollup.config.js b/packages/api/rollup.config.js index 9a9de35e8fa..becc4822916 100644 --- a/packages/api/rollup.config.js +++ b/packages/api/rollup.config.js @@ -45,7 +45,10 @@ const plugins = [ ]; const cjsBuild = { - input: 'src/index.ts', + input: { + index: 'src/index.ts', + telemetry: 'src/telemetry/index.ts', + }, output: { dir: 'dist', format: 'cjs', diff --git a/packages/api/src/telemetry/config.spec.ts b/packages/api/src/telemetry/config.spec.ts new file mode 100644 index 00000000000..128f07a5a32 --- /dev/null +++ b/packages/api/src/telemetry/config.spec.ts @@ -0,0 +1,46 @@ +import { getTelemetryConfig } from './config'; + +describe('getTelemetryConfig', () => { + it('defaults tracing off', () => { + const config = getTelemetryConfig({}); + + expect(config.enabled).toBe(false); + expect(config.sdkDisabled).toBe(false); + expect(config.serviceName).toBe('librechat'); + expect(config.healthPath).toBe('/health'); + }); + + it('enables tracing only when OTEL_TRACING_ENABLED is true', () => { + expect(getTelemetryConfig({ OTEL_TRACING_ENABLED: 'true' }).enabled).toBe(true); + expect(getTelemetryConfig({ OTEL_TRACING_ENABLED: 'TRUE' }).enabled).toBe(true); + expect(getTelemetryConfig({ OTEL_TRACING_ENABLED: 'false' }).enabled).toBe(false); + }); + + it('lets OTEL_SDK_DISABLED override tracing enablement', () => { + const config = getTelemetryConfig({ + OTEL_SDK_DISABLED: 'true', + OTEL_TRACING_ENABLED: 'true', + }); + + expect(config.enabled).toBe(false); + expect(config.sdkDisabled).toBe(true); + }); + + it('uses standard service env vars when provided', () => { + const config = getTelemetryConfig({ + OTEL_SERVICE_NAME: ' librechat-api ', + OTEL_SERVICE_VERSION: ' 1.2.3 ', + }); + + expect(config.serviceName).toBe('librechat-api'); + expect(config.serviceVersion).toBe('1.2.3'); + }); + + it('falls back to npm package version when service version is absent', () => { + const config = getTelemetryConfig({ + npm_package_version: '0.8.5', + }); + + expect(config.serviceVersion).toBe('0.8.5'); + }); +}); diff --git a/packages/api/src/telemetry/config.ts b/packages/api/src/telemetry/config.ts new file mode 100644 index 00000000000..340197aa655 --- /dev/null +++ b/packages/api/src/telemetry/config.ts @@ -0,0 +1,43 @@ +const DEFAULT_SERVICE_NAME = 'librechat'; +export const DEFAULT_HEALTH_PATH = '/health'; + +export type TelemetryStatus = 'disabled' | 'failed' | 'started' | 'starting' | 'stopped'; + +export interface TelemetryConfig { + enabled: boolean; + healthPath: string; + sdkDisabled: boolean; + serviceName: string; + serviceVersion?: string; +} + +function isTruthy(value?: string | boolean | null): boolean { + if (typeof value === 'boolean') { + return value; + } + if (typeof value === 'string') { + return value.trim().toLowerCase() === 'true'; + } + return false; +} + +function normalizeEnvValue(value?: string): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} + +export function getTelemetryConfig(env: NodeJS.ProcessEnv = process.env): TelemetryConfig { + const sdkDisabled = isTruthy(env.OTEL_SDK_DISABLED); + const enabled = isTruthy(env.OTEL_TRACING_ENABLED) && !sdkDisabled; + const serviceName = normalizeEnvValue(env.OTEL_SERVICE_NAME) ?? DEFAULT_SERVICE_NAME; + const serviceVersion = + normalizeEnvValue(env.OTEL_SERVICE_VERSION) ?? normalizeEnvValue(env.npm_package_version); + + return { + enabled, + serviceName, + sdkDisabled, + serviceVersion, + healthPath: DEFAULT_HEALTH_PATH, + }; +} diff --git a/packages/api/src/telemetry/index.ts b/packages/api/src/telemetry/index.ts new file mode 100644 index 00000000000..2eb188bf84a --- /dev/null +++ b/packages/api/src/telemetry/index.ts @@ -0,0 +1,5 @@ +export { getTelemetryConfig } from './config'; +export { initializeTelemetry, shutdownTelemetry } from './sdk'; +export { telemetryErrorMiddleware, telemetryMiddleware } from './middleware'; +export type { TelemetryConfig, TelemetryStatus } from './config'; +export type { TelemetryController } from './sdk'; diff --git a/packages/api/src/telemetry/middleware.spec.ts b/packages/api/src/telemetry/middleware.spec.ts new file mode 100644 index 00000000000..271d8c0a114 --- /dev/null +++ b/packages/api/src/telemetry/middleware.spec.ts @@ -0,0 +1,395 @@ +import { EventEmitter } from 'node:events'; +import { SpanStatusCode, trace } from '@opentelemetry/api'; +import type { NextFunction, Response } from 'express'; +import type { Span } from '@opentelemetry/api'; +import type { ServerRequest } from '~/types'; +import { getTelemetryRequestSpan } from './sdk'; +import { telemetryErrorMiddleware, telemetryMiddleware } from './middleware'; + +jest.mock('./sdk', () => ({ + getTelemetryRequestSpan: jest.fn(), +})); + +const mockGetTelemetryRequestSpan = getTelemetryRequestSpan as jest.MockedFunction< + typeof getTelemetryRequestSpan +>; + +interface MockResponse extends EventEmitter { + statusCode: number; + writableEnded: boolean; +} + +function createSpan(): jest.Mocked { + const span = {} as jest.Mocked; + span.addEvent = jest.fn, Parameters>(() => span); + span.addLink = jest.fn, Parameters>(() => span); + span.addLinks = jest.fn, Parameters>(() => span); + span.end = jest.fn>(); + span.isRecording = jest.fn>(() => true); + span.recordException = jest.fn>(); + span.setAttribute = jest.fn, Parameters>(() => span); + span.setAttributes = jest.fn, Parameters>(() => span); + span.setStatus = jest.fn, Parameters>(() => span); + span.spanContext = jest.fn, Parameters>( + () => ({ + spanId: '0000000000000000', + traceFlags: 0, + traceId: '00000000000000000000000000000000', + }), + ); + span.updateName = jest.fn, Parameters>(() => span); + return span; +} + +function createResponse(statusCode = 200): MockResponse { + const res = new EventEmitter() as MockResponse; + res.statusCode = statusCode; + res.writableEnded = false; + return res; +} + +function createRequest(overrides: Partial = {}): ServerRequest { + return { + baseUrl: '/api/messages', + body: { + prompt: 'do not capture this prompt', + text: 'do not capture this body', + }, + headers: { + authorization: 'Bearer do-not-capture-this-auth-header', + cookie: 'session=do-not-capture-this-cookie', + 'x-api-key': 'do-not-capture-this-api-key', + }, + method: 'POST', + path: '/api/messages/conversation-1', + route: { path: '/:conversationId' }, + user: { + email: 'do-not-capture@example.com', + id: 'user-1', + tenantId: 'tenant-1', + } as ServerRequest['user'], + ...overrides, + } as ServerRequest; +} + +afterEach(() => { + mockGetTelemetryRequestSpan.mockReset(); +}); + +describe('telemetryMiddleware', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('passes through without an active span', () => { + const next = jest.fn(); + jest.spyOn(trace, 'getActiveSpan').mockReturnValue(undefined); + + telemetryMiddleware(createRequest(), createResponse() as Response, next); + + expect(next).toHaveBeenCalledTimes(1); + }); + + it('uses the stored request span for deferred completion attributes', () => { + const activeSpan = createSpan(); + const requestSpan = createSpan(); + const req = createRequest(); + const res = createResponse(202); + const next: NextFunction = jest.fn(); + mockGetTelemetryRequestSpan.mockReturnValue(requestSpan); + jest.spyOn(trace, 'getActiveSpan').mockReturnValue(activeSpan); + + telemetryMiddleware(req, res as Response, next); + res.emit('finish'); + + expect(next).toHaveBeenCalledTimes(1); + expect(trace.getActiveSpan).not.toHaveBeenCalled(); + expect(activeSpan.setAttributes).not.toHaveBeenCalled(); + expect(requestSpan.setAttributes).toHaveBeenCalledWith({ + 'http.request.method': 'POST', + }); + expect(requestSpan.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + 'http.response.status_code': 202, + 'http.route': '/api/messages/:conversationId', + }), + ); + }); + + it('records safe route and identity attributes without body content', () => { + const span = createSpan(); + const req = createRequest(); + const res = createResponse(201); + const next: NextFunction = jest.fn(); + jest.spyOn(trace, 'getActiveSpan').mockReturnValue(span); + + telemetryMiddleware(req, res as Response, next); + res.emit('finish'); + + expect(next).toHaveBeenCalledTimes(1); + expect(span.setAttributes).toHaveBeenCalledWith({ + 'http.request.method': 'POST', + }); + expect(span.setAttributes).toHaveBeenCalledWith({ + 'enduser.id': 'user-1', + 'librechat.tenant.id': 'tenant-1', + }); + expect(span.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + 'http.response.status_code': 201, + 'http.route': '/api/messages/:conversationId', + }), + ); + + const capturedAttributes = JSON.stringify(span.setAttributes.mock.calls); + expect(capturedAttributes).not.toContain('do not capture this prompt'); + expect(capturedAttributes).not.toContain('do not capture this body'); + expect(capturedAttributes).not.toContain('do-not-capture@example.com'); + expect(capturedAttributes).not.toContain('conversation-1'); + expect(capturedAttributes).not.toContain('do-not-capture-this-auth-header'); + expect(capturedAttributes).not.toContain('do-not-capture-this-cookie'); + expect(capturedAttributes).not.toContain('do-not-capture-this-api-key'); + }); + + it('records identity attributes populated by downstream middleware', () => { + const span = createSpan(); + const req = createRequest({ + headers: {}, + user: undefined, + }); + const res = createResponse(200); + const next: NextFunction = jest.fn(() => { + req.user = { + id: 'late-user', + tenantId: 'late-tenant', + } as ServerRequest['user']; + }); + jest.spyOn(trace, 'getActiveSpan').mockReturnValue(span); + + telemetryMiddleware(req, res as Response, next); + + expect(next).toHaveBeenCalledTimes(1); + expect(span.setAttributes).toHaveBeenCalledTimes(1); + + res.emit('finish'); + + expect(span.setAttributes).toHaveBeenCalledWith({ + 'enduser.id': 'late-user', + 'librechat.tenant.id': 'late-tenant', + }); + }); + + it('does not derive tenant identity from request headers', () => { + const span = createSpan(); + const req = createRequest({ + headers: { + 'x-tenant-id': 'spoofed-tenant', + }, + user: undefined, + }); + const res = createResponse(200); + jest.spyOn(trace, 'getActiveSpan').mockReturnValue(span); + + telemetryMiddleware(req, res as Response, jest.fn()); + res.emit('finish'); + + expect(span.setAttributes).not.toHaveBeenCalledWith( + expect.objectContaining({ + 'librechat.tenant.id': 'spoofed-tenant', + }), + ); + expect(JSON.stringify(span.setAttributes.mock.calls)).not.toContain('spoofed-tenant'); + }); + + it('ignores health checks', () => { + const span = createSpan(); + const next = jest.fn(); + jest.spyOn(trace, 'getActiveSpan').mockReturnValue(span); + + telemetryMiddleware( + createRequest({ + baseUrl: '', + path: '/health', + route: undefined, + }), + createResponse() as Response, + next, + ); + + expect(next).toHaveBeenCalledTimes(1); + expect(span.setAttributes).not.toHaveBeenCalled(); + }); + + it('uses a low-cardinality fallback for unmatched API routes', () => { + const span = createSpan(); + const req = createRequest({ + baseUrl: '', + path: '/api/nonexistent/123', + route: undefined, + }); + const res = createResponse(404); + jest.spyOn(trace, 'getActiveSpan').mockReturnValue(span); + + telemetryMiddleware(req, res as Response, jest.fn()); + res.emit('finish'); + + expect(span.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + 'http.route': '/api/*', + 'http.response.status_code': 404, + }), + ); + }); + + it('uses a low-cardinality fallback for unmatched SPA routes', () => { + const span = createSpan(); + const req = createRequest({ + baseUrl: '', + path: '/chat/conversation-id', + route: undefined, + }); + const res = createResponse(200); + jest.spyOn(trace, 'getActiveSpan').mockReturnValue(span); + + telemetryMiddleware(req, res as Response, jest.fn()); + res.emit('finish'); + + expect(span.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + 'http.route': 'spa_fallback', + 'http.response.status_code': 200, + }), + ); + }); + + it('marks server responses as errored', () => { + const span = createSpan(); + const req = createRequest(); + const res = createResponse(500); + jest.spyOn(trace, 'getActiveSpan').mockReturnValue(span); + + telemetryMiddleware(req, res as Response, jest.fn()); + res.emit('finish'); + + expect(span.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.ERROR }); + }); + + it('records completion attributes only once when finish and close both fire', () => { + const span = createSpan(); + const res = createResponse(200); + jest.spyOn(trace, 'getActiveSpan').mockReturnValue(span); + + telemetryMiddleware(createRequest(), res as Response, jest.fn()); + res.emit('finish'); + res.emit('close'); + + expect(span.setAttributes).toHaveBeenCalledTimes(3); + }); + + it('marks client disconnects before finish as aborted errors', () => { + const span = createSpan(); + const res = createResponse(200); + jest.spyOn(trace, 'getActiveSpan').mockReturnValue(span); + + telemetryMiddleware(createRequest(), res as Response, jest.fn()); + res.emit('close'); + + expect(span.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + 'http.response.status_code': 499, + 'librechat.request.aborted': true, + }), + ); + expect(span.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.ERROR }); + }); +}); + +describe('telemetryErrorMiddleware', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('records exceptions and forwards the error', () => { + const span = createSpan(); + const error = new TypeError('boom'); + const next = jest.fn(); + jest.spyOn(trace, 'getActiveSpan').mockReturnValue(span); + + telemetryErrorMiddleware(error, createRequest(), createResponse() as Response, next); + + expect(span.recordException).toHaveBeenCalledWith(error); + expect(span.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.ERROR }); + expect(span.setAttributes).toHaveBeenCalledWith({ + 'enduser.id': 'user-1', + 'librechat.tenant.id': 'tenant-1', + }); + expect(span.setAttributes).toHaveBeenCalledWith({ + 'error.type': 'TypeError', + 'http.route': '/api/messages/:conversationId', + }); + expect(next).toHaveBeenCalledWith(error); + }); + + it('records exceptions on the stored request span when available', () => { + const activeSpan = createSpan(); + const requestSpan = createSpan(); + const error = new TypeError('boom'); + const next = jest.fn(); + mockGetTelemetryRequestSpan.mockReturnValue(requestSpan); + jest.spyOn(trace, 'getActiveSpan').mockReturnValue(activeSpan); + + telemetryErrorMiddleware(error, createRequest(), createResponse() as Response, next); + + expect(trace.getActiveSpan).not.toHaveBeenCalled(); + expect(activeSpan.recordException).not.toHaveBeenCalled(); + expect(requestSpan.recordException).toHaveBeenCalledWith(error); + expect(requestSpan.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.ERROR }); + expect(next).toHaveBeenCalledWith(error); + }); + + it('handles non-Error values without throwing', () => { + const span = createSpan(); + const next = jest.fn(); + jest.spyOn(trace, 'getActiveSpan').mockReturnValue(span); + + telemetryErrorMiddleware('boom', createRequest(), createResponse() as Response, next); + + expect(span.recordException).toHaveBeenCalledWith('boom'); + expect(span.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.ERROR }); + expect(span.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + 'error.type': 'string', + 'http.route': '/api/messages/:conversationId', + }), + ); + expect(next).toHaveBeenCalledWith('boom'); + }); + + it('handles null error values without throwing', () => { + const span = createSpan(); + const next = jest.fn(); + jest.spyOn(trace, 'getActiveSpan').mockReturnValue(span); + + telemetryErrorMiddleware(null, createRequest(), createResponse() as Response, next); + + expect(span.recordException).not.toHaveBeenCalled(); + expect(span.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.ERROR }); + expect(span.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + 'error.type': 'null', + 'http.route': '/api/messages/:conversationId', + }), + ); + expect(next).toHaveBeenCalledWith(null); + }); + + it('forwards the error without an active span', () => { + const error = new Error('boom'); + const next = jest.fn(); + jest.spyOn(trace, 'getActiveSpan').mockReturnValue(undefined); + + telemetryErrorMiddleware(error, createRequest(), createResponse() as Response, next); + + expect(next).toHaveBeenCalledWith(error); + }); +}); diff --git a/packages/api/src/telemetry/middleware.ts b/packages/api/src/telemetry/middleware.ts new file mode 100644 index 00000000000..d9c110c1b9e --- /dev/null +++ b/packages/api/src/telemetry/middleware.ts @@ -0,0 +1,171 @@ +import { SpanStatusCode, trace } from '@opentelemetry/api'; +import type { Span, Attributes } from '@opentelemetry/api'; +import type { NextFunction, Response } from 'express'; +import type { ServerRequest } from '~/types'; +import { getTelemetryRequestSpan } from './sdk'; +import { DEFAULT_HEALTH_PATH } from './config'; + +const CLIENT_CLOSED_REQUEST_STATUS_CODE = 499; + +type ExpressErrorValue = + | Error + | string + | number + | boolean + | bigint + | symbol + | object + | null + | undefined; + +function getUserId(req: ServerRequest): string | undefined { + return req.user?.id; +} + +function getTenantId(req: ServerRequest): string | undefined { + return req.user?.tenantId; +} + +function isHealthPath(req: ServerRequest): boolean { + return req.path === DEFAULT_HEALTH_PATH; +} + +function isApiPath(req: ServerRequest): boolean { + return req.path === '/api' || req.path.startsWith('/api/'); +} + +function getRoutePath(req: ServerRequest): string { + const routePath = req.route?.path; + if (typeof routePath === 'string') { + return `${req.baseUrl}${routePath}`; + } + + if (isHealthPath(req)) { + return '/health'; + } + + if (isApiPath(req)) { + return '/api/*'; + } + + return 'spa_fallback'; +} + +function setIdentityAttributes(span: Span, req: ServerRequest): void { + const userId = getUserId(req); + const tenantId = getTenantId(req); + + if (!userId && !tenantId) { + return; + } + + const attributes: Attributes = {}; + + if (userId) { + attributes['enduser.id'] = userId; + } + + if (tenantId) { + attributes['librechat.tenant.id'] = tenantId; + } + + span.setAttributes(attributes); +} + +function setCompletionAttributes( + span: Span, + req: ServerRequest, + res: Response, + aborted = false, +): void { + const statusCode = aborted ? CLIENT_CLOSED_REQUEST_STATUS_CODE : res.statusCode; + const routePath = getRoutePath(req); + const attributes: Attributes = { + 'http.route': routePath, + 'http.response.status_code': statusCode, + }; + + if (aborted) { + attributes['librechat.request.aborted'] = true; + } + + setIdentityAttributes(span, req); + span.setAttributes(attributes); + + if (aborted || statusCode >= 500) { + span.setStatus({ code: SpanStatusCode.ERROR }); + } +} + +export function telemetryMiddleware(req: ServerRequest, res: Response, next: NextFunction): void { + if (isHealthPath(req)) { + next(); + return; + } + + const span = getTelemetryRequestSpan(req) ?? trace.getActiveSpan(); + if (!span) { + next(); + return; + } + + span.setAttributes({ + 'http.request.method': req.method, + }); + + let completed = false; + const complete = () => { + if (completed) { + return; + } + completed = true; + setCompletionAttributes(span, req, res); + }; + + const close = () => { + if (completed) { + return; + } + completed = true; + setCompletionAttributes(span, req, res, !res.writableEnded); + }; + + res.once('finish', complete); + res.once('close', close); + next(); +} + +export function telemetryErrorMiddleware( + err: ExpressErrorValue, + req: ServerRequest, + _res: Response, + next: NextFunction, +): void { + const span = getTelemetryRequestSpan(req) ?? trace.getActiveSpan(); + if (span) { + const routePath = getRoutePath(req); + if (err) { + span.recordException(err instanceof Error ? err : String(err)); + } + span.setStatus({ code: SpanStatusCode.ERROR }); + setIdentityAttributes(span, req); + span.setAttributes({ + 'error.type': getErrorType(err), + 'http.route': routePath, + }); + } + + next(err); +} + +function getErrorType(err: ExpressErrorValue): string { + if (err instanceof Error) { + return err.name || err.constructor.name; + } + + if (err === null) { + return 'null'; + } + + return typeof err; +} diff --git a/packages/api/src/telemetry/sdk.spec.ts b/packages/api/src/telemetry/sdk.spec.ts new file mode 100644 index 00000000000..548ecc6f2a7 --- /dev/null +++ b/packages/api/src/telemetry/sdk.spec.ts @@ -0,0 +1,357 @@ +import { Socket } from 'node:net'; +import { IncomingMessage } from 'node:http'; +import type { Span } from '@opentelemetry/api'; + +interface HttpInstrumentationOptions { + requestHook?: (span: Span, request: object) => void; + startIncomingSpanHook?: (request: IncomingMessage) => Record; +} + +const mockStart = jest.fn(); +const mockShutdown = jest.fn(); +const mockNodeSDK = jest.fn(() => ({ + start: mockStart, + shutdown: mockShutdown, +})); +const mockExpressInstrumentation = jest.fn(() => ({ name: 'express' })); +const mockHttpInstrumentation = jest.fn((options?: HttpInstrumentationOptions) => ({ + name: 'http', + options, +})); +const mockIORedisInstrumentation = jest.fn(() => ({ name: 'ioredis' })); +const mockMongoDBInstrumentation = jest.fn(() => ({ name: 'mongodb' })); +const mockMongooseInstrumentation = jest.fn(() => ({ name: 'mongoose' })); +const mockUndiciInstrumentation = jest.fn(() => ({ name: 'undici' })); +const mockResourceFromAttributes = jest.fn((attributes: object) => ({ attributes })); + +jest.mock( + '@opentelemetry/sdk-node', + () => ({ + NodeSDK: mockNodeSDK, + }), + { virtual: true }, +); + +jest.mock( + '@opentelemetry/instrumentation-express', + () => ({ + ExpressInstrumentation: mockExpressInstrumentation, + }), + { virtual: true }, +); + +jest.mock( + '@opentelemetry/instrumentation-http', + () => ({ + HttpInstrumentation: mockHttpInstrumentation, + }), + { virtual: true }, +); + +jest.mock( + '@opentelemetry/instrumentation-ioredis', + () => ({ + IORedisInstrumentation: mockIORedisInstrumentation, + }), + { virtual: true }, +); + +jest.mock( + '@opentelemetry/instrumentation-mongodb', + () => ({ + MongoDBInstrumentation: mockMongoDBInstrumentation, + }), + { virtual: true }, +); + +jest.mock( + '@opentelemetry/instrumentation-mongoose', + () => ({ + MongooseInstrumentation: mockMongooseInstrumentation, + }), + { virtual: true }, +); + +jest.mock( + '@opentelemetry/instrumentation-undici', + () => ({ + UndiciInstrumentation: mockUndiciInstrumentation, + }), + { virtual: true }, +); + +jest.mock( + '@opentelemetry/resources', + () => ({ + resourceFromAttributes: mockResourceFromAttributes, + }), + { virtual: true }, +); + +jest.mock( + '@opentelemetry/semantic-conventions', + () => ({ + ATTR_SERVICE_NAME: 'service.name', + ATTR_SERVICE_VERSION: 'service.version', + }), + { virtual: true }, +); + +async function flushSignalShutdown(): Promise { + await new Promise((resolve) => setImmediate(resolve)); +} + +describe('telemetry SDK lifecycle', () => { + let emitWarningSpy: jest.SpyInstance; + let getTelemetryRequestSpan: (typeof import('./sdk'))['getTelemetryRequestSpan']; + let initializeTelemetry: (typeof import('./sdk'))['initializeTelemetry']; + let resetTelemetryForTests: (typeof import('./sdk'))['resetTelemetryForTests']; + let shutdownTelemetry: (typeof import('./sdk'))['shutdownTelemetry']; + + beforeEach(async () => { + jest.clearAllMocks(); + ({ getTelemetryRequestSpan, initializeTelemetry, resetTelemetryForTests, shutdownTelemetry } = + await import('./sdk')); + await resetTelemetryForTests(); + Reflect.deleteProperty(globalThis, 'Bun'); + emitWarningSpy = jest.spyOn(process, 'emitWarning').mockImplementation(() => true); + }); + + afterEach(async () => { + await resetTelemetryForTests(); + emitWarningSpy.mockRestore(); + Reflect.deleteProperty(globalThis, 'Bun'); + }); + + it('does not initialize when tracing is disabled by default', () => { + const controller = initializeTelemetry({}); + + expect(controller.enabled).toBe(false); + expect(controller.status).toBe('disabled'); + expect(mockNodeSDK).not.toHaveBeenCalled(); + }); + + it('does not initialize when OTEL_SDK_DISABLED is true', () => { + const controller = initializeTelemetry({ + OTEL_SDK_DISABLED: 'true', + OTEL_TRACING_ENABLED: 'true', + }); + + expect(controller.enabled).toBe(false); + expect(controller.status).toBe('disabled'); + expect(mockNodeSDK).not.toHaveBeenCalled(); + }); + + it('does not initialize under Bun runtime', () => { + Object.defineProperty(globalThis, 'Bun', { + configurable: true, + value: {}, + }); + + const controller = initializeTelemetry({ OTEL_TRACING_ENABLED: 'true' }); + + expect(controller.enabled).toBe(false); + expect(controller.status).toBe('disabled'); + expect(mockNodeSDK).not.toHaveBeenCalled(); + }); + + it('starts the Node SDK once when enabled', () => { + const first = initializeTelemetry({ + OTEL_SERVICE_NAME: 'librechat-test', + OTEL_TRACING_ENABLED: 'true', + }); + const second = initializeTelemetry({ OTEL_TRACING_ENABLED: 'true' }); + + expect(first.enabled).toBe(true); + expect(first.status).toBe('started'); + expect(second.enabled).toBe(true); + expect(mockNodeSDK).toHaveBeenCalledTimes(1); + expect(mockStart).toHaveBeenCalledTimes(1); + expect(mockResourceFromAttributes).toHaveBeenCalledWith({ + 'service.name': 'librechat-test', + }); + expect(mockHttpInstrumentation).toHaveBeenCalledWith( + expect.objectContaining({ + headersToSpanAttributes: { + client: { requestHeaders: [], responseHeaders: [] }, + server: { requestHeaders: [], responseHeaders: [] }, + }, + }), + ); + expect(mockExpressInstrumentation).toHaveBeenCalledTimes(1); + expect(mockMongoDBInstrumentation).toHaveBeenCalledTimes(1); + expect(mockMongooseInstrumentation).toHaveBeenCalledTimes(1); + expect(mockIORedisInstrumentation).toHaveBeenCalledTimes(1); + expect(mockUndiciInstrumentation).toHaveBeenCalledTimes(1); + }); + + it('tracks HTTP server request spans for completion updates', () => { + const span = {} as Span; + const request = new IncomingMessage(new Socket()); + initializeTelemetry({ OTEL_TRACING_ENABLED: 'true' }); + const instrumentationOptions = mockHttpInstrumentation.mock.calls[0]?.[0]; + const requestHook = instrumentationOptions?.requestHook; + + if (!requestHook) { + throw new Error('HTTP instrumentation requestHook was not configured'); + } + + requestHook(span, request); + + expect(getTelemetryRequestSpan(request)).toBe(span); + }); + + it('redacts incoming URL attributes before HTTP spans are exported', () => { + const request = new IncomingMessage(new Socket()); + request.url = '/oauth/callback?code=secret-code&state=secret-state'; + initializeTelemetry({ OTEL_TRACING_ENABLED: 'true' }); + const instrumentationOptions = mockHttpInstrumentation.mock.calls[0]?.[0]; + const startIncomingSpanHook = instrumentationOptions?.startIncomingSpanHook; + + if (!startIncomingSpanHook) { + throw new Error('HTTP instrumentation startIncomingSpanHook was not configured'); + } + + const attributes = startIncomingSpanHook(request); + + expect(attributes).toEqual({ + 'http.target': 'spa_fallback?[REDACTED]', + 'http.url': 'spa_fallback?[REDACTED]', + 'url.full': 'spa_fallback?[REDACTED]', + 'url.path': 'spa_fallback', + 'url.query': '[REDACTED]', + }); + expect(JSON.stringify(attributes)).not.toContain('secret-code'); + expect(JSON.stringify(attributes)).not.toContain('secret-state'); + }); + + it('reflects lifecycle status from the controller getter', async () => { + const controller = initializeTelemetry({ OTEL_TRACING_ENABLED: 'true' }); + + expect(controller.status).toBe('started'); + await controller.shutdown(); + expect(controller.status).toBe('stopped'); + expect(controller.enabled).toBe(false); + }); + + it('handles async SDK start failures without throwing', async () => { + mockStart.mockRejectedValueOnce(new Error('async start failed')); + + const controller = initializeTelemetry({ OTEL_TRACING_ENABLED: 'true' }); + + expect(controller.enabled).toBe(true); + expect(controller.status).toBe('starting'); + await controller.shutdown(); + expect(controller.enabled).toBe(false); + expect(controller.status).toBe('failed'); + expect(emitWarningSpy).toHaveBeenCalledWith( + 'OpenTelemetry initialization failed: async start failed', + { code: 'LIBRECHAT_OTEL' }, + ); + }); + + it('returns failed status without throwing when SDK start fails', () => { + mockStart.mockImplementationOnce(() => { + throw new Error('start failed'); + }); + + const controller = initializeTelemetry({ OTEL_TRACING_ENABLED: 'true' }); + + expect(controller.enabled).toBe(false); + expect(controller.status).toBe('failed'); + expect(emitWarningSpy).toHaveBeenCalledWith( + 'OpenTelemetry initialization failed: start failed', + { code: 'LIBRECHAT_OTEL' }, + ); + }); + + it('shuts down the active SDK idempotently', async () => { + initializeTelemetry({ OTEL_TRACING_ENABLED: 'true' }); + await shutdownTelemetry(); + await shutdownTelemetry(); + + expect(mockShutdown).toHaveBeenCalledTimes(1); + }); + + it('coalesces concurrent shutdown calls', async () => { + let resolveShutdown: () => void = () => undefined; + mockShutdown.mockReturnValueOnce( + new Promise((resolve) => { + resolveShutdown = resolve; + }), + ); + initializeTelemetry({ OTEL_TRACING_ENABLED: 'true' }); + + const firstShutdown = shutdownTelemetry(); + const secondShutdown = shutdownTelemetry(); + expect(mockShutdown).toHaveBeenCalledTimes(1); + + resolveShutdown(); + await Promise.all([firstShutdown, secondShutdown]); + }); + + it('keeps the active SDK available when shutdown fails', async () => { + mockShutdown.mockRejectedValueOnce(new Error('shutdown failed')); + const controller = initializeTelemetry({ OTEL_TRACING_ENABLED: 'true' }); + + await expect(shutdownTelemetry()).rejects.toThrow('shutdown failed'); + expect(controller.status).toBe('started'); + + await shutdownTelemetry(); + expect(mockShutdown).toHaveBeenCalledTimes(2); + expect(controller.status).toBe('stopped'); + }); + + it.each(['SIGTERM', 'SIGINT'])( + 'does not force process exit when another %s handler is registered', + async (signal) => { + const otherHandler = jest.fn(); + const killSpy = jest.spyOn(process, 'kill').mockImplementation(() => true); + initializeTelemetry({ OTEL_TRACING_ENABLED: 'true' }); + process.once(signal, otherHandler); + + process.emit(signal, signal); + await flushSignalShutdown(); + + expect(mockShutdown).toHaveBeenCalledTimes(1); + expect(otherHandler).toHaveBeenCalledTimes(1); + expect(killSpy).not.toHaveBeenCalled(); + + killSpy.mockRestore(); + }, + ); + + it.each(['SIGTERM', 'SIGINT'])( + 'reraises the shutdown %s signal when telemetry is the only signal handler', + async (signal) => { + const killSpy = jest.spyOn(process, 'kill').mockImplementation(() => true); + initializeTelemetry({ OTEL_TRACING_ENABLED: 'true' }); + + process.emit(signal, signal); + await flushSignalShutdown(); + + expect(mockShutdown).toHaveBeenCalledTimes(1); + expect(killSpy).toHaveBeenCalledWith(process.pid, signal); + + killSpy.mockRestore(); + }, + ); + + it('warns and reraises the signal when shutdown rejects', async () => { + mockShutdown.mockRejectedValueOnce(new Error('signal shutdown failed')); + const killSpy = jest.spyOn(process, 'kill').mockImplementation(() => true); + initializeTelemetry({ OTEL_TRACING_ENABLED: 'true' }); + + process.emit('SIGTERM', 'SIGTERM'); + await flushSignalShutdown(); + + expect(mockShutdown).toHaveBeenCalledTimes(1); + expect(emitWarningSpy).toHaveBeenCalledWith( + 'OpenTelemetry shutdown failed: signal shutdown failed', + { code: 'LIBRECHAT_OTEL' }, + ); + expect(killSpy).toHaveBeenCalledWith(process.pid, 'SIGTERM'); + + killSpy.mockRestore(); + }); +}); diff --git a/packages/api/src/telemetry/sdk.ts b/packages/api/src/telemetry/sdk.ts new file mode 100644 index 00000000000..49bd8a7ffcb --- /dev/null +++ b/packages/api/src/telemetry/sdk.ts @@ -0,0 +1,328 @@ +import { IncomingMessage } from 'node:http'; +import { NodeSDK } from '@opentelemetry/sdk-node'; +import { resourceFromAttributes } from '@opentelemetry/resources'; +import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; +import { UndiciInstrumentation } from '@opentelemetry/instrumentation-undici'; +import { IORedisInstrumentation } from '@opentelemetry/instrumentation-ioredis'; +import { ExpressInstrumentation } from '@opentelemetry/instrumentation-express'; +import { MongoDBInstrumentation } from '@opentelemetry/instrumentation-mongodb'; +import { MongooseInstrumentation } from '@opentelemetry/instrumentation-mongoose'; +import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'; +import type { NodeSDKConfiguration } from '@opentelemetry/sdk-node'; +import type { Span, Attributes } from '@opentelemetry/api'; +import type { TelemetryConfig, TelemetryStatus } from './config'; +import { getTelemetryConfig } from './config'; + +export interface TelemetryController { + readonly enabled: boolean; + readonly status: TelemetryStatus; + shutdown: () => Promise; +} + +const WARNING_CODE = 'LIBRECHAT_OTEL'; +const REDACTED_QUERY_VALUE = '[REDACTED]'; +const SIGNAL_SHUTDOWN_TIMEOUT_MS = 5_000; + +interface RegisteredSignal { + signal: NodeJS.Signals; + listener: NodeJS.SignalsListener; +} + +let activeSdk: NodeSDK | undefined; +let pendingSdk: NodeSDK | undefined; +let startPromise: Promise | undefined; +let shutdownPromise: Promise | undefined; +let status: TelemetryStatus = 'stopped'; +let registeredSignals: RegisteredSignal[] = []; +let requestSpans = new WeakMap(); + +function isBunRuntime(): boolean { + return Reflect.get(globalThis, 'Bun') != null; +} + +function shouldIgnoreIncomingRequest(request: IncomingMessage, healthPath: string): boolean { + return request.url === healthPath || request.url?.startsWith(`${healthPath}?`) === true; +} + +function getIncomingUrlInfo(request: IncomingMessage): { hasQuery: boolean; pathname: string } { + const rawUrl = request.url ?? '/'; + + try { + const parsedUrl = new URL(rawUrl, 'http://localhost'); + return { + hasQuery: parsedUrl.search.length > 1, + pathname: parsedUrl.pathname || '/', + }; + } catch { + const queryIndex = rawUrl.indexOf('?'); + return { + hasQuery: queryIndex >= 0 && queryIndex < rawUrl.length - 1, + pathname: queryIndex >= 0 ? rawUrl.slice(0, queryIndex) || '/' : rawUrl || '/', + }; + } +} + +function getLowCardinalityUrlPath(pathname: string, healthPath: string): string { + if (pathname === healthPath) { + return healthPath; + } + + if (pathname === '/api' || pathname.startsWith('/api/')) { + return '/api/*'; + } + + return 'spa_fallback'; +} + +function getSanitizedIncomingUrlAttributes( + request: IncomingMessage, + healthPath: string, +): Attributes { + const { hasQuery, pathname } = getIncomingUrlInfo(request); + const safePath = getLowCardinalityUrlPath(pathname, healthPath); + const safeTarget = hasQuery ? `${safePath}?${REDACTED_QUERY_VALUE}` : safePath; + const attributes: Attributes = { + 'http.target': safeTarget, + 'http.url': safeTarget, + 'url.full': safeTarget, + 'url.path': safePath, + }; + + if (hasQuery) { + attributes['url.query'] = REDACTED_QUERY_VALUE; + } + + return attributes; +} + +function getResourceAttributes(config: TelemetryConfig): Attributes { + const attributes: Attributes = { + [ATTR_SERVICE_NAME]: config.serviceName, + }; + + if (config.serviceVersion) { + attributes[ATTR_SERVICE_VERSION] = config.serviceVersion; + } + + return attributes; +} + +function createSdk(config: TelemetryConfig): NodeSDK { + const sdkConfig: Partial = { + resource: resourceFromAttributes(getResourceAttributes(config)), + instrumentations: [ + new HttpInstrumentation({ + headersToSpanAttributes: { + client: { requestHeaders: [], responseHeaders: [] }, + server: { requestHeaders: [], responseHeaders: [] }, + }, + requestHook: (span: Span, request: object) => { + if (request instanceof IncomingMessage) { + requestSpans.set(request, span); + } + }, + startIncomingSpanHook: (request: IncomingMessage) => + getSanitizedIncomingUrlAttributes(request, config.healthPath), + ignoreIncomingRequestHook: (request: IncomingMessage) => + shouldIgnoreIncomingRequest(request, config.healthPath), + }), + new ExpressInstrumentation(), + new MongoDBInstrumentation(), + new MongooseInstrumentation(), + new IORedisInstrumentation(), + new UndiciInstrumentation(), + ], + }; + + return new NodeSDK(sdkConfig); +} + +export function getTelemetryRequestSpan(request: IncomingMessage): Span | undefined { + return requestSpans.get(request); +} + +/** + * NodeSDK.start has been synchronous in some supported OpenTelemetry versions + * and promise-returning in others, so the lifecycle wrapper accepts either form. + */ +function startSdk(sdk: NodeSDK): void | Promise { + return (sdk as NodeSDK & { start: () => void | Promise }).start(); +} + +function emitWarning(message: string): void { + process.emitWarning(message, { code: WARNING_CODE }); +} + +function getErrorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function isControllerEnabled(): boolean { + return status === 'starting' || status === 'started'; +} + +function makeController(): TelemetryController { + return { + get enabled() { + return isControllerEnabled(); + }, + get status() { + return status; + }, + shutdown: shutdownTelemetry, + }; +} + +function unregisterShutdownHandlers(): void { + for (const { signal, listener } of registeredSignals) { + process.removeListener(signal, listener); + } + registeredSignals = []; +} + +function registerShutdownHandlers(): void { + if (registeredSignals.length > 0) { + return; + } + + const signals: NodeJS.Signals[] = ['SIGTERM', 'SIGINT']; + registeredSignals = signals.map((signal) => { + const listener: NodeJS.SignalsListener = () => { + const shouldReraiseSignal = process.listenerCount(signal) === 0; + withTimeout(shutdownTelemetry(), SIGNAL_SHUTDOWN_TIMEOUT_MS) + .catch((error) => { + emitWarning(`OpenTelemetry shutdown failed: ${getErrorMessage(error)}`); + }) + .finally(() => { + if (shouldReraiseSignal) { + process.kill(process.pid, signal); + } + }); + }; + process.once(signal, listener); + return { signal, listener }; + }); +} + +function withTimeout(promise: Promise, timeoutMs: number): Promise { + let timeout: NodeJS.Timeout | undefined; + const timeoutPromise = new Promise((_, reject) => { + timeout = setTimeout(() => { + reject(new Error(`timed out after ${timeoutMs}ms`)); + }, timeoutMs); + timeout.unref?.(); + }); + + return Promise.race([promise, timeoutPromise]).finally(() => { + if (timeout) { + clearTimeout(timeout); + } + }); +} + +export function initializeTelemetry(env: NodeJS.ProcessEnv = process.env): TelemetryController { + if (activeSdk || pendingSdk) { + return makeController(); + } + + const config = getTelemetryConfig(env); + if (!config.enabled || isBunRuntime()) { + status = 'disabled'; + return makeController(); + } + + try { + const sdk = createSdk(config); + const result = startSdk(sdk); + if (result) { + pendingSdk = sdk; + status = 'starting'; + const pendingStart = result + .then(() => { + if (pendingSdk === sdk) { + pendingSdk = undefined; + activeSdk = sdk; + status = 'started'; + registerShutdownHandlers(); + } + }) + .catch((error) => { + if (pendingSdk === sdk) { + pendingSdk = undefined; + status = 'failed'; + emitWarning(`OpenTelemetry initialization failed: ${getErrorMessage(error)}`); + } + }); + startPromise = pendingStart; + void pendingStart.finally(() => { + if (startPromise === pendingStart) { + startPromise = undefined; + } + }); + return makeController(); + } + + activeSdk = sdk; + status = 'started'; + registerShutdownHandlers(); + return makeController(); + } catch (error) { + status = 'failed'; + emitWarning(`OpenTelemetry initialization failed: ${getErrorMessage(error)}`); + return makeController(); + } +} + +async function performShutdownTelemetry(): Promise { + if (startPromise) { + await startPromise; + } + + if (!activeSdk) { + status = status === 'started' ? 'stopped' : status; + return; + } + + const sdk = activeSdk; + try { + await sdk.shutdown(); + activeSdk = undefined; + status = 'stopped'; + unregisterShutdownHandlers(); + } catch (error) { + status = 'started'; + throw error; + } +} + +export function shutdownTelemetry(): Promise { + if (!shutdownPromise) { + shutdownPromise = performShutdownTelemetry().finally(() => { + shutdownPromise = undefined; + }); + } + + return shutdownPromise; +} + +export async function resetTelemetryForTests(): Promise { + try { + if (startPromise) { + await startPromise.catch(() => undefined); + } + + if (shutdownPromise) { + await shutdownPromise.catch(() => undefined); + } else if (activeSdk) { + await Promise.resolve(activeSdk.shutdown()).catch(() => undefined); + } + } finally { + activeSdk = undefined; + pendingSdk = undefined; + startPromise = undefined; + shutdownPromise = undefined; + status = 'stopped'; + requestSpans = new WeakMap(); + unregisterShutdownHandlers(); + } +} From 5f7092e0296414dae19f5570e681e333958c3e41 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 14 May 2026 09:24:34 -0400 Subject: [PATCH 012/395] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20fix:=20Harden?= =?UTF-8?q?=20Helm=20Chart=20Tag=20Parsing=20(#13123)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/helmcharts.yml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/.github/workflows/helmcharts.yml b/.github/workflows/helmcharts.yml index 2b9f7f45de6..b76f0887760 100644 --- a/.github/workflows/helmcharts.yml +++ b/.github/workflows/helmcharts.yml @@ -37,8 +37,15 @@ jobs: - name: Get Chart Version id: chart-version + env: + REF_NAME: ${{ github.ref_name }} run: | - CHART_VERSION=$(echo "${{ github.ref_name }}" | cut -d'-' -f2) + CHART_VERSION="${REF_NAME#chart-}" + SEMVER_REGEX='^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$' + if [[ ! "$CHART_VERSION" =~ $SEMVER_REGEX ]]; then + echo "Invalid chart version: $CHART_VERSION" >&2 + exit 1 + fi echo "CHART_VERSION=${CHART_VERSION}" >> "$GITHUB_OUTPUT" # Log in to GitHub Container Registry @@ -72,4 +79,4 @@ jobs: 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 }} From 62da4c28ed3a06ab0a8bd9ae5dbf693a46a48607 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 14 May 2026 09:27:41 -0400 Subject: [PATCH 013/395] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20fix:=20Sanitize?= =?UTF-8?q?=20Agent=20List=20Skill=20Scope=20(#13122)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/server/controllers/agents/v1.js | 49 ++++++ api/server/controllers/agents/v1.spec.js | 149 +++++++++++++++++- .../data-schemas/src/methods/agent.spec.ts | 34 +++- packages/data-schemas/src/methods/agent.ts | 19 ++- 4 files changed, 238 insertions(+), 13 deletions(-) diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index 536044e5cce..8dbe881fbb5 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -59,6 +59,39 @@ 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) { + delete agent.skills; + if (accessibleSkillSet.size > 0) { + 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 @@ -848,6 +881,7 @@ const getListAgentsHandler = async (req, res) => { } else if (typeof requiredPermission !== 'number') { requiredPermission = PermissionBits.VIEW; } + const canReturnSkillConfig = hasEditBit(requiredPermission); // Base filter const filter = {}; @@ -921,6 +955,7 @@ const getListAgentsHandler = async (req, res) => { otherParams: filter, limit, after: cursor, + includeSkillConfig: true, }); const agents = data?.data ?? []; @@ -928,10 +963,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; diff --git a/api/server/controllers/agents/v1.spec.js b/api/server/controllers/agents/v1.spec.js index 17904ad3fd6..f36152abec6 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,91 @@ 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 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/packages/data-schemas/src/methods/agent.spec.ts b/packages/data-schemas/src/methods/agent.spec.ts index 2d5a6666bb4..4efb76d3ac6 100644 --- a/packages/data-schemas/src/methods/agent.spec.ts +++ b/packages/data-schemas/src/methods/agent.spec.ts @@ -3334,10 +3334,7 @@ describe('Support Contact Field', () => { expect(result.data[0].name).toBe('Agent A1'); }); - test('should include the skills field in the list projection', async () => { - // Frontend popover scoping relies on `agent.skills` being present in - // list results so it can narrow the `$` catalog without refetching the - // full agent document. Locks in that projection contract. + test('should omit skill configuration from the default list projection', async () => { const targetSkillIds = [ new mongoose.Types.ObjectId().toString(), new mongoose.Types.ObjectId().toString(), @@ -3350,6 +3347,7 @@ describe('Support Contact Field', () => { model: 'gpt-4', author: userA, skills: targetSkillIds, + skills_enabled: true, }); const result = await getListAgentsByAccess({ @@ -3357,8 +3355,36 @@ describe('Support Contact Field', () => { otherParams: {}, }); + expect(result.data).toHaveLength(1); + expect(result.data[0].skills).toBeUndefined(); + expect(result.data[0].skills_enabled).toBeUndefined(); + }); + + test('should include skill configuration only when explicitly requested', async () => { + const targetSkillIds = [ + new mongoose.Types.ObjectId().toString(), + new mongoose.Types.ObjectId().toString(), + ]; + const scopedAgent = await createAgent({ + id: `agent_${uuidv4().slice(0, 12)}`, + name: 'Scoped Agent', + description: 'Agent with configured skill scope', + provider: 'openai', + model: 'gpt-4', + author: userA, + skills: targetSkillIds, + skills_enabled: true, + }); + + const result = await getListAgentsByAccess({ + accessibleIds: [scopedAgent._id] as mongoose.Types.ObjectId[], + otherParams: {}, + includeSkillConfig: true, + }); + expect(result.data).toHaveLength(1); expect(result.data[0].skills).toEqual(targetSkillIds); + expect(result.data[0].skills_enabled).toBe(true); }); test('should return multiple accessible agents when provided', async () => { diff --git a/packages/data-schemas/src/methods/agent.ts b/packages/data-schemas/src/methods/agent.ts index 833f0b9f63d..9078616c93c 100644 --- a/packages/data-schemas/src/methods/agent.ts +++ b/packages/data-schemas/src/methods/agent.ts @@ -648,11 +648,13 @@ export function createAgentMethods(mongoose: typeof import('mongoose'), deps: Ag otherParams = {}, limit = null, after = null, + includeSkillConfig = false, }: { accessibleIds?: Types.ObjectId[]; otherParams?: Record; limit?: number | null; after?: string | null; + includeSkillConfig?: boolean; }): Promise<{ object: string; data: Array>; @@ -700,7 +702,7 @@ export function createAgentMethods(mongoose: typeof import('mongoose'), deps: Ag } } - let query = Agent.find(baseQuery, { + const projection: Record = { id: 1, _id: 1, name: 1, @@ -711,13 +713,14 @@ export function createAgentMethods(mongoose: typeof import('mongoose'), deps: Ag category: 1, support_contact: 1, is_promoted: 1, - /* Needed so the client can scope the `$` skill popover to each agent's - configured catalog without refetching the full agent document. The - master toggle is required alongside the allowlist so the popover can - distinguish "enabled with full catalog" from "disabled". */ - skills: 1, - skills_enabled: 1, - }).sort({ updatedAt: -1, _id: 1 }); + }; + + if (includeSkillConfig) { + projection.skills = 1; + projection.skills_enabled = 1; + } + + let query = Agent.find(baseQuery, projection).sort({ updatedAt: -1, _id: 1 }); if (isPaginated && normalizedLimit) { query = query.limit(normalizedLimit + 1); From 738ed005b691ce804893ad28753eb9aeb1dce2ff Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 14 May 2026 09:39:55 -0400 Subject: [PATCH 014/395] =?UTF-8?q?=F0=9F=8F=B7=EF=B8=8F=20feat:=20Hide=20?= =?UTF-8?q?Model=20Spec=20Badge=20Rows=20(#13124)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: hide model spec badge row * chore: import order * feat: hide model spec badge row --- client/src/components/Chat/Input/ChatForm.tsx | 16 +++++++++++++--- librechat.example.yaml | 1 + .../data-provider/specs/config-schemas.spec.ts | 4 ++++ packages/data-provider/src/models.ts | 3 +++ 4 files changed, 21 insertions(+), 3 deletions(-) diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index a4f4f062f55..884a9251c42 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -21,12 +21,13 @@ import { useSubmitMessage, useFocusChatEffect, } from '~/hooks'; +import PendingManualSkillsChips from './PendingManualSkillsChips'; +import { cn, getModelSpec, removeFocusRings } from '~/utils'; +import { useGetStartupConfig } from '~/data-provider'; import { mainTextareaId, BadgeItem } from '~/common'; 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 AudioRecorder from './AudioRecorder'; @@ -96,11 +97,17 @@ 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], @@ -355,7 +362,10 @@ const ChatForm = memo(function ChatForm({
{ { name: 'spec-1', label: 'Spec 1', + hideBadgeRow: true, preset: { endpoint: EModelEndpoint.openAI }, }, ], }); expect(result.success).toBe(true); + if (result.success) { + expect(result.data.list[0].hideBadgeRow).toBe(true); + } }); it('still rejects null list', () => { diff --git a/packages/data-provider/src/models.ts b/packages/data-provider/src/models.ts index 6be55effded..85f455fe170 100644 --- a/packages/data-provider/src/models.ts +++ b/packages/data-provider/src/models.ts @@ -32,6 +32,8 @@ export type TModelSpec = { showIconInHeader?: boolean; iconURL?: string | EModelEndpoint; // Allow using project-included icons authType?: AuthType; + /** Hide the chat input tool badge row while this model spec is active. */ + hideBadgeRow?: boolean; webSearch?: boolean; fileSearch?: boolean; executeCode?: boolean; @@ -52,6 +54,7 @@ export const tModelSpecSchema = z.object({ showIconInHeader: z.boolean().optional(), iconURL: z.union([z.string(), eModelEndpointSchema]).optional(), authType: authTypeSchema.optional(), + hideBadgeRow: z.boolean().optional(), webSearch: z.boolean().optional(), fileSearch: z.boolean().optional(), executeCode: z.boolean().optional(), From b993d9fb28cbc7aad1960d6fec94c179ecaad371 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 14 May 2026 09:49:26 -0400 Subject: [PATCH 015/395] =?UTF-8?q?=F0=9F=9B=9F=20test:=20Restore=20Playwr?= =?UTF-8?q?ight=20Smoke=20E2E=20(#13020)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test: restore Playwright smoke e2e * test: harden e2e smoke setup * test: sync e2e server bindings * test: normalize e2e auth urls --- e2e/playwright.config.local.ts | 46 ++----- e2e/playwright.config.ts | 27 ++--- e2e/setup/authenticate.ts | 56 +++++---- e2e/setup/cleanupUser.ts | 37 ++++-- e2e/setup/env.ts | 134 +++++++++++++++++++++ e2e/setup/global-setup.local.ts | 4 +- e2e/setup/global-setup.ts | 9 +- e2e/setup/global-teardown.local.ts | 4 +- e2e/setup/global-teardown.ts | 8 +- e2e/setup/runtimeEnv.ts | 21 ++++ e2e/setup/start-server.js | 187 +++++++++++++++++++++++++++++ e2e/setup/user.ts | 15 +++ e2e/specs/landing.spec.ts | 41 ++----- e2e/specs/nav.spec.ts | 50 ++------ package.json | 13 +- 15 files changed, 473 insertions(+), 179 deletions(-) create mode 100644 e2e/setup/env.ts create mode 100644 e2e/setup/runtimeEnv.ts create mode 100644 e2e/setup/start-server.js create mode 100644 e2e/setup/user.ts diff --git a/e2e/playwright.config.local.ts b/e2e/playwright.config.local.ts index 5281836ac25..bd7d674e7d1 100644 --- a/e2e/playwright.config.local.ts +++ b/e2e/playwright.config.local.ts @@ -1,10 +1,15 @@ import { PlaywrightTestConfig } from '@playwright/test'; import mainConfig from './playwright.config'; +import { getLocalE2EEnv } from './setup/env'; import path from 'path'; -const absolutePath = path.resolve(process.cwd(), 'api/server/index.js'); +const rootPath = path.resolve(__dirname, '..'); +const serverPath = path.resolve(rootPath, 'e2e/setup/start-server.js'); import dotenv from 'dotenv'; dotenv.config(); +const e2eEnv = getLocalE2EEnv(); +Object.assign(process.env, e2eEnv); + const config: PlaywrightTestConfig = { ...mainConfig, retries: 0, @@ -12,43 +17,8 @@ const config: PlaywrightTestConfig = { globalTeardown: require.resolve('./setup/global-teardown.local'), webServer: { ...mainConfig.webServer, - command: `node ${absolutePath}`, - env: { - ...process.env, - SEARCH: 'false', - NODE_ENV: 'CI', - EMAIL_HOST: '', - TITLE_CONVO: 'false', - SESSION_EXPIRY: '60000', - REFRESH_TOKEN_EXPIRY: '300000', - LOGIN_VIOLATION_SCORE: '0', - REGISTRATION_VIOLATION_SCORE: '0', - CONCURRENT_VIOLATION_SCORE: '0', - MESSAGE_VIOLATION_SCORE: '0', - NON_BROWSER_VIOLATION_SCORE: '0', - FORK_VIOLATION_SCORE: '0', - IMPORT_VIOLATION_SCORE: '0', - TTS_VIOLATION_SCORE: '0', - STT_VIOLATION_SCORE: '0', - FILE_UPLOAD_VIOLATION_SCORE: '0', - RESET_PASSWORD_VIOLATION_SCORE: '0', - VERIFY_EMAIL_VIOLATION_SCORE: '0', - TOOL_CALL_VIOLATION_SCORE: '0', - CONVO_ACCESS_VIOLATION_SCORE: '0', - ILLEGAL_MODEL_REQ_SCORE: '0', - LOGIN_MAX: '20', - LOGIN_WINDOW: '1', - REGISTER_MAX: '20', - REGISTER_WINDOW: '1', - LIMIT_CONCURRENT_MESSAGES: 'false', - CONCURRENT_MESSAGE_MAX: '20', - LIMIT_MESSAGE_IP: 'false', - MESSAGE_IP_MAX: '100', - MESSAGE_IP_WINDOW: '1', - LIMIT_MESSAGE_USER: 'false', - MESSAGE_USER_MAX: '100', - MESSAGE_USER_WINDOW: '1', - }, + command: `node ${serverPath}`, + cwd: rootPath, }, fullyParallel: false, // if you are on Windows, keep this as `false`. On a Mac, `true` could make tests faster (maybe on some Windows too, just try) // workers: 1, diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts index 48b4d71101b..009d77f24ea 100644 --- a/e2e/playwright.config.ts +++ b/e2e/playwright.config.ts @@ -1,9 +1,15 @@ import { defineConfig, devices } from '@playwright/test'; import path from 'path'; -const absolutePath = path.resolve(process.cwd(), 'api/server/index.js'); +import { getBaseE2EEnv, getE2EBaseURL } from './setup/env'; +const rootPath = path.resolve(__dirname, '..'); +const serverPath = path.resolve(rootPath, 'e2e/setup/start-server.js'); import dotenv from 'dotenv'; dotenv.config(); +const baseURL = getE2EBaseURL(); +const e2eEnv = getBaseE2EEnv(); +Object.assign(process.env, e2eEnv); + export default defineConfig({ globalSetup: require.resolve('./setup/global-setup'), globalTeardown: require.resolve('./setup/global-teardown'), @@ -23,7 +29,7 @@ export default defineConfig({ reporter: [['html', { outputFolder: 'playwright-report' }]], /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { - baseURL: 'http://localhost:3080', + baseURL, video: 'on-first-retry', trace: 'retain-on-failure', ignoreHTTPSErrors: true, @@ -53,21 +59,12 @@ export default defineConfig({ /* Run your local dev server before starting the tests */ webServer: { - command: `node ${absolutePath}`, - port: 3080, + command: `node ${serverPath}`, + cwd: rootPath, + url: baseURL, stdout: 'pipe', ignoreHTTPSErrors: true, - // url: 'http://localhost:3080', - timeout: 30_000, + timeout: 120_000, reuseExistingServer: true, - env: { - ...process.env, - NODE_ENV: 'CI', - EMAIL_HOST: '', - SEARCH: 'false', - SESSION_EXPIRY: '60000', - ALLOW_REGISTRATION: 'true', - REFRESH_TOKEN_EXPIRY: '300000', - }, }, }); diff --git a/e2e/setup/authenticate.ts b/e2e/setup/authenticate.ts index 3d5b802c42f..8aa201c3778 100644 --- a/e2e/setup/authenticate.ts +++ b/e2e/setup/authenticate.ts @@ -1,17 +1,16 @@ -import { Page, FullConfig, chromium } from '@playwright/test'; +import { chromium } from '@playwright/test'; +import type { FullConfig, Page } from '@playwright/test'; import type { User } from '../types'; import cleanupUser from './cleanupUser'; import dotenv from 'dotenv'; dotenv.config(); -const timeout = 6000; +const timeout = Number(process.env.E2E_AUTH_TIMEOUT ?? 15000); async function register(page: Page, user: User) { await page.getByRole('link', { name: 'Sign up' }).click(); await page.getByLabel('Full name').click(); - await page.getByLabel('Full name').fill('test'); - await page.getByText('Username (optional)').click(); - await page.getByLabel('Username (optional)').fill('test'); + await page.getByLabel('Full name').fill(user.name); await page.getByLabel('Email').click(); await page.getByLabel('Email').fill(user.email); await page.getByLabel('Email').press('Tab'); @@ -22,32 +21,45 @@ async function register(page: Page, user: User) { await page.getByLabel('Submit registration').click(); } -async function logout(page: Page) { - await page.getByTestId('nav-user').click(); - await page.getByRole('button', { name: 'Log out' }).click(); +async function registrationErrorIsVisible(page: Page) { + return page + .getByTestId('registration-error') + .isVisible({ timeout: 500 }) + .catch(() => false); } async function login(page: Page, user: User) { - await page.locator('input[name="email"]').fill(user.email); - await page.locator('input[name="password"]').fill(user.password); - await page.locator('input[name="password"]').press('Enter'); + await page.getByLabel('Email').fill(user.email); + await page.getByLabel('Password').fill(user.password); + await page.getByTestId('login-button').click(); +} + +function appURL(baseURL: string, pathname = '') { + const normalizedBaseURL = baseURL.endsWith('/') ? baseURL : `${baseURL}/`; + return new URL(pathname.replace(/^\/+/, ''), normalizedBaseURL).toString(); } async function authenticate(config: FullConfig, user: User) { console.log('🤖: global setup has been started'); const { baseURL, storageState } = config.projects[0].use; console.log('🤖: using baseURL', baseURL); - console.dir(user, { depth: null }); + console.log('🤖: using E2E user:', user.email); + if (typeof storageState !== 'string') { + throw new Error('🤖: storageState must be a file path'); + } + const browser = await chromium.launch({ - headless: false, + headless: config.projects[0].use.headless ?? true, }); try { const page = await browser.newPage(); console.log('🤖: 🗝 authenticating user:', user.email); - if (!baseURL) { + if (typeof baseURL !== 'string') { throw new Error('🤖: baseURL is not defined'); } + const conversationURL = appURL(baseURL, 'c/new'); + const loginURL = appURL(baseURL, 'login'); // Set localStorage before navigating to the page await page.context().addInitScript(() => { @@ -58,31 +70,27 @@ async function authenticate(config: FullConfig, user: User) { await page.goto(baseURL, { timeout }); await register(page, user); try { - await page.waitForURL(`${baseURL}/c/new`, { timeout }); + await page.waitForURL(conversationURL, { timeout }); } catch (error) { console.error('Error:', error); - const userExists = page.getByTestId('registration-error'); - if (userExists) { + if (await registrationErrorIsVisible(page)) { console.log('🤖: 🚨 user already exists'); await cleanupUser(user); await page.goto(baseURL, { timeout }); await register(page, user); + await page.waitForURL(conversationURL, { timeout }); } else { throw new Error('🤖: 🚨 user failed to register'); } } console.log('🤖: ✔️ user successfully registered'); - // Logout - // await logout(page); - // await page.waitForURL(`${baseURL}/login`, { timeout }); - // console.log('🤖: ✔️ user successfully logged out'); - + await page.goto(loginURL, { timeout }); await login(page, user); - await page.waitForURL(`${baseURL}/c/new`, { timeout }); + await page.waitForURL(conversationURL, { timeout }); console.log('🤖: ✔️ user successfully authenticated'); - await page.context().storageState({ path: storageState as string }); + await page.context().storageState({ path: storageState }); console.log('🤖: ✔️ authentication state successfully saved in', storageState); // await browser.close(); // console.log('🤖: global setup has been finished'); diff --git a/e2e/setup/cleanupUser.ts b/e2e/setup/cleanupUser.ts index 2e3de7d7356..aea43cc9aba 100644 --- a/e2e/setup/cleanupUser.ts +++ b/e2e/setup/cleanupUser.ts @@ -1,15 +1,27 @@ -import { connectDb } from '@librechat/backend/db/connect'; -import { - findUser, - deleteConvos, - deleteMessages, - deleteAllUserSessions, -} from '@librechat/backend/models'; -import { User, Balance, Transaction, AclEntry, Token, Group } from '@librechat/backend/db/models'; +import { applyRuntimeEnv } from './runtimeEnv'; type TUser = { email: string; password: string }; export default async function cleanupUser(user: TUser) { + applyRuntimeEnv(); + /* eslint-disable @typescript-eslint/no-require-imports */ + const { connectDb } = require('@librechat/backend/db/connect'); + const { + findUser, + deleteConvos, + deleteMessages, + deleteAllUserSessions, + } = require('@librechat/backend/models'); + const { + User, + Balance, + Transaction, + AclEntry, + Token, + Group, + } = require('@librechat/backend/db/models'); + /* eslint-enable @typescript-eslint/no-require-imports */ + const { email } = user; try { console.log('🤖: global teardown has been started'); @@ -26,7 +38,14 @@ export default async function cleanupUser(user: TUser) { console.log('🤖: ✅ Found user in Database'); // Delete all conversations & associated messages - const { deletedCount, messages } = await deleteConvos(userId, {}); + const { deletedCount, messages } = await deleteConvos(userId, {}).catch((error) => { + if (error instanceof Error && error.message.includes('Conversation not found')) { + console.log('🤖: ⚠️ No conversations found for user'); + return { deletedCount: 0, messages: { deletedCount: 0 } }; + } + + throw error; + }); if (messages.deletedCount > 0 || deletedCount > 0) { console.log(`🤖: ✅ Deleted ${deletedCount} convos & ${messages.deletedCount} messages`); diff --git a/e2e/setup/env.ts b/e2e/setup/env.ts new file mode 100644 index 00000000000..a17f8829537 --- /dev/null +++ b/e2e/setup/env.ts @@ -0,0 +1,134 @@ +import crypto from 'crypto'; +import path from 'path'; + +const DEFAULT_BASE_URL = 'http://localhost:3080'; +const DEFAULT_MONGO_URI = 'mongodb://127.0.0.1:27017/LibreChat-e2e'; +const DEFAULT_RUNTIME_ENV_PATH = path.resolve(__dirname, '../specs/.test-results/runtime-env.json'); +const GENERATED_CREDS_KEY = crypto.randomBytes(32).toString('hex'); +const GENERATED_CREDS_IV = crypto.randomBytes(16).toString('hex'); +const GENERATED_JWT_SECRET = crypto.randomBytes(32).toString('hex'); +const GENERATED_JWT_REFRESH_SECRET = crypto.randomBytes(32).toString('hex'); +const PASSTHROUGH_ENV_KEYS = [ + 'APPDATA', + 'CI', + 'FORCE_COLOR', + 'HOME', + 'LOCALAPPDATA', + 'NO_COLOR', + 'NO_PROXY', + 'NODE_OPTIONS', + 'PATH', + 'PLAYWRIGHT_BROWSERS_PATH', + 'SHELL', + 'TEMP', + 'TMP', + 'TMPDIR', + 'USER', + 'USERNAME', + 'http_proxy', + 'https_proxy', + 'no_proxy', + 'HTTP_PROXY', + 'HTTPS_PROXY', +]; + +export function getE2EBaseURL() { + return process.env.E2E_BASE_URL ?? DEFAULT_BASE_URL; +} + +export function getE2EServerAddress(baseURL = getE2EBaseURL()) { + const url = new URL(baseURL); + const host = url.hostname.replace(/^\[(.*)\]$/, '$1'); + const port = url.port || (url.protocol === 'https:' ? '443' : '80'); + + return { host, port }; +} + +export function getRuntimeEnvPath() { + return process.env.E2E_RUNTIME_ENV_PATH ?? DEFAULT_RUNTIME_ENV_PATH; +} + +function getPassthroughEnv(): Record { + const env: Record = {}; + const passthroughKeys = [ + ...PASSTHROUGH_ENV_KEYS, + ...(process.env.E2E_PASSTHROUGH_ENV?.split(',') ?? []), + ]; + + for (const key of passthroughKeys) { + const value = process.env[key.trim()]; + if (value != null) { + env[key.trim()] = value; + } + } + + for (const [key, value] of Object.entries(process.env)) { + if (key.startsWith('MONGOMS_') && value != null) { + env[key] = value; + } + } + + return env; +} + +export function getBaseE2EEnv(): Record { + const baseURL = getE2EBaseURL(); + const { host, port } = getE2EServerAddress(baseURL); + + return { + ...getPassthroughEnv(), + NODE_ENV: 'CI', + HOST: process.env.E2E_HOST ?? host, + PORT: process.env.E2E_PORT ?? port, + MONGO_URI: process.env.MONGO_URI ?? DEFAULT_MONGO_URI, + DOMAIN_CLIENT: process.env.E2E_DOMAIN_CLIENT ?? baseURL, + DOMAIN_SERVER: process.env.E2E_DOMAIN_SERVER ?? baseURL, + E2E_RUNTIME_ENV_PATH: getRuntimeEnvPath(), + E2E_USE_MEMORY_MONGO: process.env.E2E_USE_MEMORY_MONGO ?? 'auto', + NO_INDEX: process.env.NO_INDEX ?? 'true', + OPENAI_API_KEY: process.env.OPENAI_API_KEY ?? 'user_provided', + CREDS_KEY: process.env.CREDS_KEY ?? GENERATED_CREDS_KEY, + CREDS_IV: process.env.CREDS_IV ?? GENERATED_CREDS_IV, + JWT_SECRET: process.env.JWT_SECRET ?? GENERATED_JWT_SECRET, + JWT_REFRESH_SECRET: process.env.JWT_REFRESH_SECRET ?? GENERATED_JWT_REFRESH_SECRET, + EMAIL_HOST: '', + SEARCH: 'false', + SESSION_EXPIRY: '60000', + ALLOW_REGISTRATION: 'true', + REFRESH_TOKEN_EXPIRY: '300000', + }; +} + +export function getLocalE2EEnv(): Record { + return { + ...getBaseE2EEnv(), + TITLE_CONVO: 'false', + LOGIN_VIOLATION_SCORE: '0', + REGISTRATION_VIOLATION_SCORE: '0', + CONCURRENT_VIOLATION_SCORE: '0', + MESSAGE_VIOLATION_SCORE: '0', + NON_BROWSER_VIOLATION_SCORE: '0', + FORK_VIOLATION_SCORE: '0', + IMPORT_VIOLATION_SCORE: '0', + TTS_VIOLATION_SCORE: '0', + STT_VIOLATION_SCORE: '0', + FILE_UPLOAD_VIOLATION_SCORE: '0', + RESET_PASSWORD_VIOLATION_SCORE: '0', + VERIFY_EMAIL_VIOLATION_SCORE: '0', + TOOL_CALL_VIOLATION_SCORE: '0', + CONVO_ACCESS_VIOLATION_SCORE: '0', + ILLEGAL_MODEL_REQ_SCORE: '0', + LOGIN_MAX: '20', + LOGIN_WINDOW: '1', + REGISTER_MAX: '20', + REGISTER_WINDOW: '1', + LIMIT_CONCURRENT_MESSAGES: 'false', + CONCURRENT_MESSAGE_MAX: '20', + LIMIT_MESSAGE_IP: 'false', + MESSAGE_IP_MAX: '100', + MESSAGE_IP_WINDOW: '1', + LIMIT_MESSAGE_USER: 'false', + MESSAGE_USER_MAX: '100', + MESSAGE_USER_WINDOW: '1', + }; +} diff --git a/e2e/setup/global-setup.local.ts b/e2e/setup/global-setup.local.ts index 2bbb1973899..c417ebc3409 100644 --- a/e2e/setup/global-setup.local.ts +++ b/e2e/setup/global-setup.local.ts @@ -1,9 +1,9 @@ import { FullConfig } from '@playwright/test'; -import localUser from '../config.local'; import authenticate from './authenticate'; +import { getE2EUser } from './user'; async function globalSetup(config: FullConfig) { - await authenticate(config, localUser); + await authenticate(config, getE2EUser()); } export default globalSetup; diff --git a/e2e/setup/global-setup.ts b/e2e/setup/global-setup.ts index 25c60e11af7..c417ebc3409 100644 --- a/e2e/setup/global-setup.ts +++ b/e2e/setup/global-setup.ts @@ -1,14 +1,9 @@ import { FullConfig } from '@playwright/test'; import authenticate from './authenticate'; +import { getE2EUser } from './user'; async function globalSetup(config: FullConfig) { - const user = { - name: 'test', - email: String(process.env.E2E_USER_EMAIL), - password: String(process.env.E2E_USER_PASSWORD), - }; - - await authenticate(config, user); + await authenticate(config, getE2EUser()); } export default globalSetup; diff --git a/e2e/setup/global-teardown.local.ts b/e2e/setup/global-teardown.local.ts index cef902cfc8a..c61c86c367a 100644 --- a/e2e/setup/global-teardown.local.ts +++ b/e2e/setup/global-teardown.local.ts @@ -1,9 +1,9 @@ -import localUser from '../config.local'; import cleanupUser from './cleanupUser'; +import { getE2EUser } from './user'; async function globalTeardown() { try { - await cleanupUser(localUser); + await cleanupUser(getE2EUser()); } catch (error) { console.error('Error:', error); } diff --git a/e2e/setup/global-teardown.ts b/e2e/setup/global-teardown.ts index c71e4d56a17..c61c86c367a 100644 --- a/e2e/setup/global-teardown.ts +++ b/e2e/setup/global-teardown.ts @@ -1,13 +1,9 @@ import cleanupUser from './cleanupUser'; +import { getE2EUser } from './user'; async function globalTeardown() { - const user = { - email: String(process.env.E2E_USER_EMAIL), - password: String(process.env.E2E_USER_PASSWORD), - }; - try { - await cleanupUser(user); + await cleanupUser(getE2EUser()); } catch (error) { console.error('Error:', error); } diff --git a/e2e/setup/runtimeEnv.ts b/e2e/setup/runtimeEnv.ts new file mode 100644 index 00000000000..889c299d878 --- /dev/null +++ b/e2e/setup/runtimeEnv.ts @@ -0,0 +1,21 @@ +import fs from 'fs'; +import { getRuntimeEnvPath } from './env'; + +export function applyRuntimeEnv() { + const runtimeEnvPath = getRuntimeEnvPath(); + + if (!fs.existsSync(runtimeEnvPath)) { + return; + } + + const runtimeEnv = JSON.parse(fs.readFileSync(runtimeEnvPath, 'utf8')) as Record< + string, + string | undefined + >; + + for (const [key, value] of Object.entries(runtimeEnv)) { + if (value != null) { + process.env[key] = value; + } + } +} diff --git a/e2e/setup/start-server.js b/e2e/setup/start-server.js new file mode 100644 index 00000000000..b81f01f35a1 --- /dev/null +++ b/e2e/setup/start-server.js @@ -0,0 +1,187 @@ +const fs = require('fs'); +const net = require('net'); +const path = require('path'); +require('dotenv').config(); + +const DEFAULT_MONGO_URI = 'mongodb://127.0.0.1:27017/LibreChat-e2e'; +const DEFAULT_RUNTIME_ENV_PATH = path.resolve(__dirname, '../specs/.test-results/runtime-env.json'); +let mongoServer; + +function decodeMongoValue(value) { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function getMongoScheme(uri) { + const schemeEnd = uri.indexOf('://'); + return schemeEnd === -1 ? '' : uri.slice(0, schemeEnd).toLowerCase(); +} + +function getMongoAuthority(uri) { + const schemeEnd = uri.indexOf('://'); + if (schemeEnd === -1) { + return ''; + } + + const withoutScheme = uri.slice(schemeEnd + 3); + return withoutScheme.split(/[/?#]/, 1)[0]; +} + +function getMongoDbName(uri) { + const schemeEnd = uri.indexOf('://'); + if (schemeEnd === -1) { + return 'LibreChat-e2e'; + } + + const withoutScheme = uri.slice(schemeEnd + 3); + const pathStart = withoutScheme.indexOf('/'); + if (pathStart === -1) { + return 'LibreChat-e2e'; + } + + const pathname = withoutScheme.slice(pathStart + 1).split(/[?#]/, 1)[0]; + const dbName = pathname.split('/', 1)[0]; + return dbName ? decodeMongoValue(dbName) : 'LibreChat-e2e'; +} + +function normalizeMongoPort(port) { + const parsed = Number(port); + return Number.isInteger(parsed) && parsed > 0 && parsed <= 65535 ? parsed : 27017; +} + +function parseMongoHost(hostEntry) { + if (!hostEntry) { + return null; + } + + if (hostEntry.startsWith('[')) { + const hostEnd = hostEntry.indexOf(']'); + if (hostEnd === -1) { + return null; + } + + const host = hostEntry.slice(1, hostEnd); + const port = hostEntry[hostEnd + 1] === ':' ? hostEntry.slice(hostEnd + 2) : ''; + return { host, port: normalizeMongoPort(port) }; + } + + const [host, port] = hostEntry.split(':'); + return host ? { host, port: normalizeMongoPort(port) } : null; +} + +function parseMongoUri(uri) { + const scheme = getMongoScheme(uri); + const authority = getMongoAuthority(uri); + const hosts = authority + .slice(authority.lastIndexOf('@') + 1) + .split(',') + .filter(Boolean); + const parsedHost = + scheme === 'mongodb+srv' || hosts.length !== 1 ? null : parseMongoHost(hosts[0]); + + return { + dbName: getMongoDbName(uri), + host: parsedHost?.host ?? '', + port: parsedHost?.port ?? 27017, + canProbe: Boolean(parsedHost), + }; +} + +function isLocalHost(host) { + return host === 'localhost' || host === '127.0.0.1' || host === '::1'; +} + +async function canConnect(host, port) { + return new Promise((resolve) => { + const socket = net.createConnection({ host, port }); + const done = (result) => { + socket.destroy(); + resolve(result); + }; + + socket.setTimeout(500); + socket.once('connect', () => done(true)); + socket.once('timeout', () => done(false)); + socket.once('error', () => done(false)); + }); +} + +function withDbName(uri, dbName) { + const parsed = new URL(uri); + parsed.pathname = `/${dbName}`; + return parsed.toString(); +} + +function writeRuntimeEnv() { + const runtimeEnvPath = process.env.E2E_RUNTIME_ENV_PATH || DEFAULT_RUNTIME_ENV_PATH; + fs.mkdirSync(path.dirname(runtimeEnvPath), { recursive: true }); + fs.writeFileSync(runtimeEnvPath, JSON.stringify({ MONGO_URI: process.env.MONGO_URI }, null, 2)); +} + +async function maybeStartMemoryMongo() { + const mongoUri = process.env.MONGO_URI ?? DEFAULT_MONGO_URI; + const mode = process.env.E2E_USE_MEMORY_MONGO ?? 'auto'; + + if (mode === 'false') { + process.env.MONGO_URI = mongoUri; + writeRuntimeEnv(); + return; + } + + const { dbName, host, port, canProbe } = parseMongoUri(mongoUri); + if (mode === 'auto' && (!canProbe || !isLocalHost(host) || (await canConnect(host, port)))) { + process.env.MONGO_URI = mongoUri; + writeRuntimeEnv(); + return; + } + + const { MongoMemoryServer } = require('mongodb-memory-server'); + mongoServer = await MongoMemoryServer.create({ + instance: { + dbName, + ip: '127.0.0.1', + }, + }); + process.env.MONGO_URI = withDbName(mongoServer.getUri(), dbName); + writeRuntimeEnv(); + console.log(`[e2e] Started memory MongoDB at ${process.env.MONGO_URI}`); +} + +async function shutdown() { + if (mongoServer) { + await mongoServer.stop(); + } +} + +process.once('SIGINT', async () => { + await shutdown(); + process.exit(130); +}); + +process.once('SIGTERM', async () => { + await shutdown(); + process.exit(143); +}); + +function startServer() { + return maybeStartMemoryMongo() + .then(() => { + require(path.resolve(__dirname, '../../api/server/index.js')); + }) + .catch((error) => { + console.error('[e2e] Failed to start test server:', error); + process.exit(1); + }); +} + +if (require.main === module) { + startServer(); +} + +module.exports = { + parseMongoUri, + startServer, +}; diff --git a/e2e/setup/user.ts b/e2e/setup/user.ts new file mode 100644 index 00000000000..53b0db609b3 --- /dev/null +++ b/e2e/setup/user.ts @@ -0,0 +1,15 @@ +import type { User } from '../types'; + +const DEFAULT_USER: User = { + email: 'testuser@example.com', + name: 'Test User', + password: 'securepassword123', +}; + +export function getE2EUser(): User { + return { + email: process.env.E2E_USER_EMAIL ?? DEFAULT_USER.email, + name: process.env.E2E_USER_NAME ?? DEFAULT_USER.name, + password: process.env.E2E_USER_PASSWORD ?? DEFAULT_USER.password, + }; +} diff --git a/e2e/specs/landing.spec.ts b/e2e/specs/landing.spec.ts index 86421cb6f1d..8fa0140a8e2 100644 --- a/e2e/specs/landing.spec.ts +++ b/e2e/specs/landing.spec.ts @@ -2,41 +2,18 @@ import { expect, test } from '@playwright/test'; test.describe('Landing suite', () => { test('Landing title', async ({ page }) => { - await page.goto('http://localhost:3080/', { timeout: 5000 }); - const pageTitle = await page.textContent('#landing-title'); - expect(pageTitle?.length).toBeGreaterThan(0); + await page.goto('/', { timeout: 5000 }); + + await expect(page.getByRole('main')).toBeVisible(); + await expect(page.getByRole('textbox', { name: 'Message input' })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Select a model' })).toBeVisible(); }); test('Create Conversation', async ({ page }) => { - await page.goto('http://localhost:3080/', { timeout: 5000 }); - - async function getItems() { - const navDiv = await page.waitForSelector('nav > div'); - if (!navDiv) { - return []; - } - - const items = await navDiv.$$('a.group'); - return items || []; - } - - // Wait for the page to load and the SVG loader to disappear - await page.waitForSelector('nav > div'); - await page.waitForSelector('nav > div > div > svg', { state: 'detached' }); - - const beforeAdding = (await getItems()).length; - - const input = await page.locator('form').getByRole('textbox'); - await input.click(); - await input.fill('Hi!'); - - // Send the message - await page.locator('form').getByRole('button').nth(1).click(); - - // Wait for the message to be sent - await page.waitForTimeout(3500); - const afterAdding = (await getItems()).length; + await page.goto('/c/new', { timeout: 5000 }); - expect(afterAdding).toBeGreaterThanOrEqual(beforeAdding); + await expect(page).toHaveURL(/\/c\/new$/); + await expect(page.getByRole('link', { name: 'New chat' })).toBeVisible(); + await expect(page.getByRole('textbox', { name: 'Message input' })).toBeVisible(); }); }); diff --git a/e2e/specs/nav.spec.ts b/e2e/specs/nav.spec.ts index e902c461cd1..0b29fa0ca49 100644 --- a/e2e/specs/nav.spec.ts +++ b/e2e/specs/nav.spec.ts @@ -2,7 +2,7 @@ import { expect, test } from '@playwright/test'; test.describe('Navigation suite', () => { test('Navigation bar', async ({ page }) => { - await page.goto('http://localhost:3080/', { timeout: 5000 }); + await page.goto('/', { timeout: 5000 }); await page.getByTestId('nav-user').click(); const navSettings = await page.getByTestId('nav-user').isVisible(); @@ -10,49 +10,23 @@ test.describe('Navigation suite', () => { }); test('Settings modal', async ({ page }) => { - await page.goto('http://localhost:3080/', { timeout: 5000 }); + await page.goto('/', { timeout: 5000 }); await page.getByTestId('nav-user').click(); - await page.getByText('Settings').click(); + await page.getByRole('menuitem', { name: 'Settings' }).click(); - const modal = await page.getByRole('dialog', { name: 'Settings' }).isVisible(); - expect(modal).toBeTruthy(); + const modal = page.getByRole('dialog', { name: /Settings/ }); - const modalTitle = await page.getByRole('heading', { name: 'Settings' }).textContent(); + const modalHeading = modal.getByRole('heading', { name: 'Settings' }); + await expect(modalHeading).toBeVisible(); + const modalTitle = await modalHeading.textContent(); expect(modalTitle?.length).toBeGreaterThan(0); expect(modalTitle).toEqual('Settings'); - const modalTabList = await page.getByRole('tablist', { name: 'Settings' }).isVisible(); - expect(modalTabList).toBeTruthy(); + await expect(modal.getByRole('tablist', { name: 'Settings' })).toBeVisible(); + await expect(modal.getByRole('tabpanel', { name: 'General' })).toBeVisible(); + await expect(modal.getByRole('combobox', { name: 'Theme' })).toBeVisible(); - const generalTabPanel = await page.getByRole('tabpanel', { name: 'General' }).isVisible(); - expect(generalTabPanel).toBeTruthy(); - - const modalClearConvos = await page.getByRole('button', { name: 'Clear' }).isVisible(); - expect(modalClearConvos).toBeTruthy(); - - const modalTheme = page.getByTestId('theme-selector'); - expect(modalTheme).toBeTruthy(); - - async function changeMode(theme: string) { - // Ensure Element Visibility: - await page.waitForSelector('[data-testid="theme-selector"]'); - await modalTheme.click(); - - await page.click(`[data-theme="${theme}"]`); - - // Wait for the theme change - await page.waitForTimeout(1000); - - // Check if the HTML element has the theme class - const html = await page.$eval( - 'html', - (element, selectedTheme) => element.classList.contains(selectedTheme.toLowerCase()), - theme, - ); - expect(html).toBeTruthy(); - } - - await changeMode('dark'); - await changeMode('light'); + await modal.getByRole('button', { name: 'Close Settings' }).click(); + await expect(modalHeading).toBeHidden(); }); }); diff --git a/package.json b/package.json index 597cf5b2b72..8d80fe1ca77 100644 --- a/package.json +++ b/package.json @@ -53,11 +53,12 @@ "frontend": "npm run build:data-provider && npm run build:data-schemas && npm run build:api && npm run build:client-package && cd client && npm run build", "frontend:ci": "npm run build:data-provider && npm run build:client-package && cd client && npm run build:ci", "frontend:dev": "cd client && npm run dev", - "e2e": "playwright test --config=e2e/playwright.config.local.ts", - "e2e:headed": "playwright test --config=e2e/playwright.config.local.ts --headed", - "e2e:a11y": "playwright test --config=e2e/playwright.config.a11y.ts --headed", - "e2e:ci": "playwright test --config=e2e/playwright.config.ts", - "e2e:debug": "cross-env PWDEBUG=1 playwright test --config=e2e/playwright.config.local.ts", + "e2e:prepare": "npm run frontend", + "e2e": "npm run e2e:prepare && playwright test --config=e2e/playwright.config.local.ts", + "e2e:headed": "npm run e2e:prepare && playwright test --config=e2e/playwright.config.local.ts --headed", + "e2e:a11y": "npm run e2e:prepare && playwright test --config=e2e/playwright.config.a11y.ts --headed", + "e2e:ci": "npm run e2e:prepare && playwright test --config=e2e/playwright.config.ts", + "e2e:debug": "npm run e2e:prepare && cross-env PWDEBUG=1 playwright test --config=e2e/playwright.config.local.ts", "e2e:codegen": "npx playwright codegen --load-storage=e2e/storageState.json http://localhost:3080/c/new", "e2e:login": "npx playwright codegen --save-storage=e2e/auth.json http://localhost:3080/login", "e2e:github": "act -W .github/workflows/playwright.yml --secret-file my.secrets", @@ -67,7 +68,7 @@ "test:packages:data-provider": "cd packages/data-provider && npm run test:ci", "test:packages:data-schemas": "cd packages/data-schemas && npm run test:ci", "test:all": "npm run test:client && npm run test:api && npm run test:packages:api && npm run test:packages:data-provider && npm run test:packages:data-schemas", - "e2e:update": "playwright test --config=e2e/playwright.config.js --update-snapshots", + "e2e:update": "npm run e2e:prepare && playwright test --config=e2e/playwright.config.local.ts --update-snapshots", "e2e:report": "npx playwright show-report e2e/playwright-report", "lint:fix": "eslint --fix \"{,!(node_modules|venv)/**/}*.{js,jsx,ts,tsx}\"", "lint": "eslint \"{,!(node_modules|venv)/**/}*.{js,jsx,ts,tsx}\"", From ca8c212c0dbbc52d3edca47ecdfe15ff4e0e11d6 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 14 May 2026 10:07:23 -0400 Subject: [PATCH 016/395] =?UTF-8?q?=F0=9F=97=9D=EF=B8=8F=20fix:=20Protect?= =?UTF-8?q?=20Model=20Spec=20Instructions=20(#13125)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: prevent instruction exposure * fix: tighten model spec preset restoration * refactor: type model spec preset handling --- api/server/middleware/buildEndpointOption.js | 61 ++++-- .../middleware/buildEndpointOption.spec.js | 184 ++++++++++++++++- api/server/routes/__tests__/config.spec.js | 37 ++++ api/server/routes/config.js | 9 +- .../api/src/agents/__tests__/load.spec.ts | 20 ++ packages/api/src/agents/load.ts | 9 +- packages/api/src/index.ts | 2 + .../src/mcp/registry/MCPServersInitializer.ts | 18 +- .../__tests__/MCPServersInitializer.test.ts | 11 + packages/api/src/modelSpecs/index.ts | 189 ++++++++++++++++++ .../api/src/modelSpecs/modelSpecs.test.ts | 149 ++++++++++++++ 11 files changed, 668 insertions(+), 21 deletions(-) create mode 100644 packages/api/src/modelSpecs/index.ts create mode 100644 packages/api/src/modelSpecs/modelSpecs.test.ts diff --git a/api/server/middleware/buildEndpointOption.js b/api/server/middleware/buildEndpointOption.js index cf4f773160b..1eaa1ef8d92 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,6 +56,7 @@ 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; @@ -57,41 +66,63 @@ async function buildEndpointOption(req, res, next) { 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: currentModelSpec.preset, 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]; diff --git a/api/server/middleware/buildEndpointOption.spec.js b/api/server/middleware/buildEndpointOption.spec.js index 5d93acd6bbb..9c353b498ab 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,7 +29,7 @@ jest.mock('~/server/services/Endpoints/assistants', () => ({ buildOptions: mockBuildOptions, })); jest.mock('~/server/services/Endpoints/agents', () => ({ - buildOptions: mockBuildOptions, + buildOptions: mockAgentBuildOptions, })); jest.mock('~/models', () => ({ @@ -38,6 +42,7 @@ jest.mock('~/server/services/Config', () => ({ })); jest.mock('@librechat/api', () => ({ + ...jest.requireActual('@librechat/api'), handleError: jest.fn(), })); @@ -207,6 +212,183 @@ describe('buildEndpointOption - defaultParamsEndpoint parsing', () => { expect(enforcedResult.maxContextTokens).toBe(50000); }); + 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 () => { mockGetEndpointsConfig.mockRejectedValue(new Error('Config unavailable')); diff --git a/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js index d92c56b8bbe..52a843116c6 100644 --- a/api/server/routes/__tests__/config.spec.js +++ b/api/server/routes/__tests__/config.spec.js @@ -322,6 +322,43 @@ 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', + 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', + }); + }); + it('should include full interface config', async () => { mockGetAppConfig.mockResolvedValue(baseAppConfig); const app = createApp(mockUser); diff --git a/api/server/routes/config.js b/api/server/routes/config.js index 46f4cc09dac..2b9fd6e7ad9 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -1,5 +1,10 @@ const express = require('express'); -const { isEnabled, getBalanceConfig, getCloudFrontConfig } = require('@librechat/api'); +const { + isEnabled, + getBalanceConfig, + getCloudFrontConfig, + sanitizeModelSpecs, +} = require('@librechat/api'); const { defaultSocialLogins } = require('librechat-data-provider'); const { logger, getTenantId, SystemCapabilities } = require('@librechat/data-schemas'); const { hasCapability } = require('~/server/middleware/roles/capabilities'); @@ -181,7 +186,7 @@ router.get('/', async function (req, res) { socialLogins: appConfig?.registration?.socialLogins ?? defaultSocialLogins, interface: appConfig?.interfaceConfig, 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, diff --git a/packages/api/src/agents/__tests__/load.spec.ts b/packages/api/src/agents/__tests__/load.spec.ts index b7c6142d695..2419310b832 100644 --- a/packages/api/src/agents/__tests__/load.spec.ts +++ b/packages/api/src/agents/__tests__/load.spec.ts @@ -233,6 +233,26 @@ describe('loadAgent', () => { } }); + test('should use parsed promptPrefix for ephemeral agent instructions', async () => { + const { EPHEMERAL_AGENT_ID } = Constants; + + const result = await loadAgent( + { + req: { user: { id: 'user123' }, body: {} }, + agent_id: EPHEMERAL_AGENT_ID as string, + endpoint: 'openai', + model_parameters: { + model: 'gpt-4', + promptPrefix: 'Server-side model spec instructions', + } as unknown as AgentModelParameters, + }, + deps, + ); + + expect(result?.instructions).toBe('Server-side model spec instructions'); + expect(result?.model_parameters).not.toHaveProperty('promptPrefix'); + }); + test('should handle ephemeral agent with undefined ephemeralAgent in body', async () => { const { EPHEMERAL_AGENT_ID } = Constants; diff --git a/packages/api/src/agents/load.ts b/packages/api/src/agents/load.ts index 05746d1195f..66f01d94402 100644 --- a/packages/api/src/agents/load.ts +++ b/packages/api/src/agents/load.ts @@ -16,6 +16,7 @@ import type { import { getCustomEndpointConfig } from '~/app/config'; const { mcp_all, mcp_delimiter } = Constants; +type ModelParametersWithPromptPrefix = AgentModelParameters & { promptPrefix?: string | null }; export interface LoadAgentDeps { getAgent: (searchParameter: { id: string }) => Promise; @@ -89,7 +90,11 @@ export async function loadEphemeralAgent( } } - const instructions = req.body?.promptPrefix; + const requestPromptPrefix = req.body?.promptPrefix; + const { promptPrefix: modelPromptPrefix, ...safeModelParameters } = + model_parameters as ModelParametersWithPromptPrefix; + const instructions = + typeof modelPromptPrefix === 'string' ? modelPromptPrefix : requestPromptPrefix; // Get endpoint config for modelDisplayLabel fallback const appConfig = req.config; @@ -122,7 +127,7 @@ export async function loadEphemeralAgent( id: ephemeralId, instructions, provider: endpoint, - model_parameters, + model_parameters: safeModelParameters as AgentModelParameters, model, tools, }; diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 6369ce6ed43..338e86d7cc8 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -33,6 +33,8 @@ export * from './flow/manager'; export * from './middleware'; /* Memory */ export * from './memory'; +/* Model Specs */ +export * from './modelSpecs'; /* Agents */ export * from './agents'; /* Prompts */ diff --git a/packages/api/src/mcp/registry/MCPServersInitializer.ts b/packages/api/src/mcp/registry/MCPServersInitializer.ts index 56c6ef486b0..7c19f6338f1 100644 --- a/packages/api/src/mcp/registry/MCPServersInitializer.ts +++ b/packages/api/src/mcp/registry/MCPServersInitializer.ts @@ -159,11 +159,27 @@ export class MCPServersInitializer { logger.info(`${prefix} OAuth Required: ${config.requiresOAuth}`); logger.info(`${prefix} Capabilities: ${config.capabilities}`); logger.info(`${prefix} Tools: ${config.tools}`); - logger.info(`${prefix} Server Instructions: ${config.serverInstructions}`); + logger.info( + `${prefix} Server Instructions: ${MCPServersInitializer.formatInstructionsForLogging( + config.serverInstructions, + )}`, + ); logger.info(`${prefix} Initialized in: ${config.initDuration ?? 'N/A'}ms`); logger.info(`${prefix} -------------------------------------------------┘`); } + private static formatInstructionsForLogging(instructions?: string | boolean): string { + if (!instructions) { + return 'N/A'; + } + + if (typeof instructions !== 'string') { + return 'configured'; + } + + return `configured (${instructions.length} chars)`; + } + // Returns formatted log prefix for server messages private static prefix(serverName: string): string { return `[MCP][${serverName}]`; diff --git a/packages/api/src/mcp/registry/__tests__/MCPServersInitializer.test.ts b/packages/api/src/mcp/registry/__tests__/MCPServersInitializer.test.ts index 03cfa948fcf..1e705cb79b7 100644 --- a/packages/api/src/mcp/registry/__tests__/MCPServersInitializer.test.ts +++ b/packages/api/src/mcp/registry/__tests__/MCPServersInitializer.test.ts @@ -458,6 +458,17 @@ describe('MCPServersInitializer', () => { ); }); + it('should not log raw server instructions', async () => { + await MCPServersInitializer.initialize(testConfigs); + + expect(mockLogger.info).not.toHaveBeenCalledWith( + expect.stringContaining('Instructions for file_tools_server'), + ); + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringContaining('[MCP][file_tools_server] Server Instructions: configured'), + ); + }); + it('should use Promise.allSettled for parallel server initialization', async () => { const allSettledSpy = jest.spyOn(Promise, 'allSettled'); diff --git a/packages/api/src/modelSpecs/index.ts b/packages/api/src/modelSpecs/index.ts new file mode 100644 index 00000000000..4e34dd0bac6 --- /dev/null +++ b/packages/api/src/modelSpecs/index.ts @@ -0,0 +1,189 @@ +import { + parseCompactConvo, + replaceSpecialVars, + type EModelEndpoint, + type TConversation, + type TModelSpec, + type TModelSpecPreset, + type TPreset, + type TSpecsConfig, + type TUser, +} from 'librechat-data-provider'; + +export const PRIVATE_MODEL_SPEC_PRESET_FIELDS = [ + 'promptPrefix', + 'instructions', + 'additional_instructions', + 'system', + 'context', + 'examples', +] as const satisfies readonly (keyof TModelSpecPreset)[]; + +export type PrivateModelSpecPresetField = (typeof PRIVATE_MODEL_SPEC_PRESET_FIELDS)[number]; +export type ModelSpecParsedBody = Partial & + Record; + +export type ApplyModelSpecPresetParams = { + modelSpec: TModelSpec; + parsedBody: ModelSpecParsedBody; + endpoint?: string | null; + endpointType?: string | null; + defaultParamsEndpoint?: string | null; + includePresetDefaults?: boolean; +}; + +export type ApplyModelSpecPresetResult = { + parsedBody: ModelSpecParsedBody; + appliedPrivateFields: Set; +}; + +function hasModelSpecValue(field: PrivateModelSpecPresetField, value: unknown): boolean { + if (value == null || value === '') { + return false; + } + + if (!Array.isArray(value)) { + return true; + } + + if (field === 'examples') { + return value.some((example) => { + const input = example?.input?.content; + const output = example?.output?.content; + return Boolean(input || output); + }); + } + + return value.length > 0; +} + +function mergeModelSpecPreset( + modelSpec: TModelSpec, + parsedBody: ModelSpecParsedBody, + { includePresetDefaults = false }: Pick = {}, +): ApplyModelSpecPresetResult { + const preset = modelSpec.preset; + const merged = { + ...(includePresetDefaults ? preset : {}), + ...parsedBody, + spec: modelSpec.name, + } as ModelSpecParsedBody; + const appliedPrivateFields = new Set(); + + for (const field of PRIVATE_MODEL_SPEC_PRESET_FIELDS) { + if (!Object.prototype.hasOwnProperty.call(preset, field)) { + continue; + } + + if (includePresetDefaults) { + appliedPrivateFields.add(field); + continue; + } + + if (!hasModelSpecValue(field, parsedBody[field])) { + (merged as Record)[field] = preset[field]; + appliedPrivateFields.add(field); + } + } + + return { parsedBody: merged, appliedPrivateFields }; +} + +export function findModelSpecByName( + modelSpecs: Pick | undefined, + spec: string | null | undefined, +): TModelSpec | undefined { + if (!spec) { + return undefined; + } + + return modelSpecs?.list?.find((modelSpec) => modelSpec.name === spec); +} + +export function isModelSpecEndpointMatch( + modelSpec: Pick | undefined, + endpoint: string | null | undefined, +): boolean { + return Boolean(modelSpec && endpoint === modelSpec.preset?.endpoint); +} + +export function applyModelSpecPreset({ + modelSpec, + parsedBody, + endpoint, + endpointType, + defaultParamsEndpoint, + includePresetDefaults, +}: ApplyModelSpecPresetParams): ApplyModelSpecPresetResult { + const { parsedBody: conversation, appliedPrivateFields } = mergeModelSpecPreset( + modelSpec, + parsedBody, + { + includePresetDefaults, + }, + ); + const reparsedBody = parseCompactConvo({ + endpoint: endpoint as EModelEndpoint | undefined, + endpointType: endpointType as EModelEndpoint | null | undefined, + conversation, + defaultParamsEndpoint, + }); + + if (!reparsedBody) { + throw new Error('Model spec preset produced an empty parsed body'); + } + + const modelSpecParsedBody = reparsedBody as ModelSpecParsedBody; + if (modelSpec.iconURL != null && modelSpec.iconURL !== '') { + modelSpecParsedBody.iconURL = modelSpec.iconURL; + } + + return { parsedBody: modelSpecParsedBody, appliedPrivateFields }; +} + +export function resolveModelSpecPromptPrefixVariables( + parsedBody: T, + user?: TUser | null, + now?: string | number | Date, +): T { + if (typeof parsedBody.promptPrefix !== 'string') { + return parsedBody; + } + + return { + ...parsedBody, + promptPrefix: replaceSpecialVars({ + text: parsedBody.promptPrefix, + user, + now, + }), + }; +} + +export function sanitizeModelSpecs | null | undefined>( + modelSpecs: T, +): T { + if (!modelSpecs?.list || !Array.isArray(modelSpecs.list)) { + return modelSpecs; + } + + return { + ...modelSpecs, + list: modelSpecs.list.map((modelSpec) => { + const preset = modelSpec?.preset; + if (!preset || typeof preset !== 'object') { + return modelSpec; + } + + const sanitizedPreset = { ...preset }; + for (const field of PRIVATE_MODEL_SPEC_PRESET_FIELDS) { + delete sanitizedPreset[field]; + } + + return { + ...modelSpec, + preset: sanitizedPreset, + }; + }), + } as T; +} diff --git a/packages/api/src/modelSpecs/modelSpecs.test.ts b/packages/api/src/modelSpecs/modelSpecs.test.ts new file mode 100644 index 00000000000..a617cd94429 --- /dev/null +++ b/packages/api/src/modelSpecs/modelSpecs.test.ts @@ -0,0 +1,149 @@ +import { EModelEndpoint } from 'librechat-data-provider'; +import type { TModelSpec } from 'librechat-data-provider'; +import { + applyModelSpecPreset, + findModelSpecByName, + isModelSpecEndpointMatch, + resolveModelSpecPromptPrefixVariables, + sanitizeModelSpecs, +} from './index'; + +describe('modelSpecs helpers', () => { + it('should strip private prompt fields from model spec presets', () => { + const modelSpecs = { + enforce: false, + prioritize: true, + list: [ + { + name: 'guarded-spec', + label: 'Guarded Spec', + preset: { + endpoint: EModelEndpoint.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', + }, + }, + ], + }; + + expect(sanitizeModelSpecs(modelSpecs).list[0].preset).toEqual({ + endpoint: EModelEndpoint.openAI, + model: 'gpt-4o', + greeting: 'Hello', + }); + }); + + it('should restore only private fields for non-enforced model specs', () => { + const modelSpec: TModelSpec = { + name: 'guarded-openai', + label: 'Guarded OpenAI', + iconURL: EModelEndpoint.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 { parsedBody, appliedPrivateFields } = applyModelSpecPreset({ + modelSpec, + parsedBody: { + endpoint: EModelEndpoint.openAI, + spec: 'guarded-openai', + model: 'gpt-4o', + temperature: 0.8, + }, + endpoint: EModelEndpoint.openAI, + }); + + expect(parsedBody.promptPrefix).toBe('private prompt prefix'); + expect(parsedBody.instructions).toBeUndefined(); + expect(parsedBody.additional_instructions).toBeUndefined(); + expect(parsedBody.temperature).toBe(0.8); + expect(parsedBody.maxContextTokens).toBeUndefined(); + expect(parsedBody.iconURL).toBe(EModelEndpoint.openAI); + expect(appliedPrivateFields.has('promptPrefix')).toBe(true); + }); + + it('should restore preset defaults when model specs are enforced', () => { + const modelSpec: TModelSpec = { + name: 'enforced-openai', + label: 'Enforced OpenAI', + preset: { + endpoint: EModelEndpoint.openAI, + model: 'gpt-4o', + promptPrefix: 'private prompt prefix', + temperature: 0.2, + }, + }; + + const { parsedBody } = applyModelSpecPreset({ + modelSpec, + parsedBody: modelSpec.preset, + endpoint: EModelEndpoint.openAI, + includePresetDefaults: true, + }); + + expect(parsedBody.spec).toBe('enforced-openai'); + expect(parsedBody.promptPrefix).toBe('private prompt prefix'); + expect(parsedBody.temperature).toBe(0.2); + }); + + it('should restore private examples when parser supplies an empty default', () => { + const examples = [{ input: { content: 'hello' }, output: { content: 'world' } }]; + const modelSpec: TModelSpec = { + name: 'guarded-google', + label: 'Guarded Google', + preset: { + endpoint: EModelEndpoint.google, + model: 'gemini-pro', + examples, + }, + }; + + const { parsedBody } = applyModelSpecPreset({ + modelSpec, + parsedBody: { + endpoint: EModelEndpoint.google, + spec: 'guarded-google', + model: 'gemini-pro', + }, + endpoint: EModelEndpoint.google, + }); + + expect(parsedBody.examples).toEqual(examples); + }); + + it('should find specs and validate endpoint matches', () => { + const modelSpec: TModelSpec = { + name: 'guarded-openai', + label: 'Guarded OpenAI', + preset: { + endpoint: EModelEndpoint.openAI, + model: 'gpt-4o', + }, + }; + + expect(findModelSpecByName({ list: [modelSpec] }, 'guarded-openai')).toBe(modelSpec); + expect(isModelSpecEndpointMatch(modelSpec, EModelEndpoint.openAI)).toBe(true); + expect(isModelSpecEndpointMatch(modelSpec, EModelEndpoint.google)).toBe(false); + }); + + it('should resolve special variables in model spec prompt prefixes', () => { + expect( + resolveModelSpecPromptPrefixVariables({ promptPrefix: 'Help {{current_user}}.' }, { + name: 'Ada', + } as never).promptPrefix, + ).toBe('Help Ada.'); + }); +}); From 5b11a5a0760ed8076e12fd29cc0074bed546b3e7 Mon Sep 17 00:00:00 2001 From: JorgeCosta87 Date: Fri, 15 May 2026 19:51:53 +0100 Subject: [PATCH 017/395] =?UTF-8?q?=F0=9F=AA=B5=20chore:=20Restore=20Winst?= =?UTF-8?q?on=20Format=20Factory=20Shape=20In=20Test=20Mocks=20(#13139)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four jest mocks for `winston` in the test suite return the wrong shape: api/test/__mocks__/logger.js (returns inner fn directly) packages/api/src/agents/__tests__/memory.test.ts (`format` is a plain object) packages/api/src/agents/__tests__/run-summarization.test.ts (same) packages/api/src/agents/__tests__/initialize.test.ts (same) Real `winston.format(fn)` returns a Format constructor whose instances expose a `.transform(info, opts)` method that winston's pipeline calls with the log info object. The current mocks collapse this: - `(fn) => fn` returns the inner transform fn directly. When module-load code in `@librechat/data-schemas/dist/config/parsers.cjs:52` does `const redactFormat = winston.format((info) => ...)`, `redactFormat` becomes the inner fn. The next line in `winston.cjs` calls `parsers.redactFormat()` which invokes the inner fn with no `info`, throwing `TypeError: Cannot read properties of undefined (reading 'level')`. - `format: { combine, colorize, simple }` makes `winston.format` not callable at all — `winston.format((info) => ...)` throws `TypeError: winston.format is not a function`. These currently pass in CI on GitHub Actions Ubuntu / Node 20.19, but fail reproducibly on Node 24.x and on some Linux distros (verified on WSL Ubuntu with Node 24.9.0). The CI passes appears to be environmental luck around jest's mock-hoisting interaction with the workspace symlink chain — the mocks are genuinely wrong against the data-schemas contract. The fix: return a thunk that yields `{ transform: fn }` — matches real winston's shape just enough that module-load completes; the inner fn is only ever invoked by winston's pipeline (never at load time). Also adds the full `winston.format.*` method surface (printf, timestamp, errors, splat, json) plus `addColors` and the `DailyRotateFile`/`File` transports that data-schemas's dist code references at module-load. Verification (Node 24.9.0): npm run build:data-provider && npm run build:data-schemas && npm run build:api cd packages/api && npx jest src/agents/__tests__/{memory,run-summarization,initialize}.test.ts → 3 suites, 106 tests, all pass No production code or behavior changes — test-only patch. Co-authored-by: Jorge Costa <8352477+JorgeCosta87@users.noreply.github.com> --- api/test/__mocks__/logger.js | 10 ++++++- .../src/agents/__tests__/initialize.test.ts | 26 ++++++++++++++----- .../api/src/agents/__tests__/memory.test.ts | 26 +++++++++++++++---- .../__tests__/run-summarization.test.ts | 25 +++++++++++++++--- 4 files changed, 72 insertions(+), 15 deletions(-) diff --git a/api/test/__mocks__/logger.js b/api/test/__mocks__/logger.js index 62f9bee93a6..94dd08bb1c4 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(); diff --git a/packages/api/src/agents/__tests__/initialize.test.ts b/packages/api/src/agents/__tests__/initialize.test.ts index b98af340aa1..6636c13af6f 100644 --- a/packages/api/src/agents/__tests__/initialize.test.ts +++ b/packages/api/src/agents/__tests__/initialize.test.ts @@ -39,20 +39,34 @@ import type { ServerRequest, InitializeResultBase, EndpointTokenConfig } from '~ import type { InitializeAgentDbMethods } from '../initialize'; import { DEFAULT_MAX_CONTEXT_TOKENS } from '../initialize'; -// Mock logger +// Mock logger — `format` must be a callable factory so @librechat/data-schemas +// dist module-load completes cleanly; see api/test/__mocks__/logger.js. jest.mock('winston', () => ({ createLogger: jest.fn(() => ({ debug: jest.fn(), warn: jest.fn(), error: jest.fn(), + info: jest.fn(), })), - format: { - combine: jest.fn(), - colorize: jest.fn(), - simple: jest.fn(), - }, + format: Object.assign( + jest.fn((fn) => () => ({ transform: fn })), + { + combine: jest.fn(), + colorize: jest.fn(), + simple: jest.fn(), + label: jest.fn(), + timestamp: jest.fn(), + printf: jest.fn(), + errors: jest.fn(), + splat: jest.fn(), + json: jest.fn(), + }, + ), + addColors: jest.fn(), transports: { Console: jest.fn(), + DailyRotateFile: jest.fn(), + File: jest.fn(), }, })); diff --git a/packages/api/src/agents/__tests__/memory.test.ts b/packages/api/src/agents/__tests__/memory.test.ts index dabe6de629a..d23a07aa7dd 100644 --- a/packages/api/src/agents/__tests__/memory.test.ts +++ b/packages/api/src/agents/__tests__/memory.test.ts @@ -5,19 +5,35 @@ import type { MemoryArtifact } from 'librechat-data-provider'; import { createMemoryTool, processMemory } from '../memory'; // Mock the logger +// `winston.format` must be a callable factory (real winston returns a Format +// constructor) so that `@librechat/data-schemas` dist code can complete its +// module-load — see api/test/__mocks__/logger.js for the canonical shape. jest.mock('winston', () => ({ createLogger: jest.fn(() => ({ debug: jest.fn(), warn: jest.fn(), error: jest.fn(), + info: jest.fn(), })), - format: { - combine: jest.fn(), - colorize: jest.fn(), - simple: jest.fn(), - }, + format: Object.assign( + jest.fn((fn) => () => ({ transform: fn })), + { + combine: jest.fn(), + colorize: jest.fn(), + simple: jest.fn(), + label: jest.fn(), + timestamp: jest.fn(), + printf: jest.fn(), + errors: jest.fn(), + splat: jest.fn(), + json: jest.fn(), + }, + ), + addColors: jest.fn(), transports: { Console: jest.fn(), + DailyRotateFile: jest.fn(), + File: jest.fn(), }, })); diff --git a/packages/api/src/agents/__tests__/run-summarization.test.ts b/packages/api/src/agents/__tests__/run-summarization.test.ts index 1aa8e0d58d5..7d22511c218 100644 --- a/packages/api/src/agents/__tests__/run-summarization.test.ts +++ b/packages/api/src/agents/__tests__/run-summarization.test.ts @@ -9,7 +9,8 @@ import { } from 'librechat-data-provider'; import { createRun } from '~/agents/run'; -// Mock winston logger +// Mock winston logger — `format` must be callable so @librechat/data-schemas +// dist module-load completes cleanly; see api/test/__mocks__/logger.js. jest.mock('winston', () => ({ createLogger: jest.fn(() => ({ debug: jest.fn(), @@ -17,8 +18,26 @@ jest.mock('winston', () => ({ error: jest.fn(), info: jest.fn(), })), - format: { combine: jest.fn(), colorize: jest.fn(), simple: jest.fn() }, - transports: { Console: jest.fn() }, + format: Object.assign( + jest.fn((fn) => () => ({ transform: fn })), + { + combine: jest.fn(), + colorize: jest.fn(), + simple: jest.fn(), + label: jest.fn(), + timestamp: jest.fn(), + printf: jest.fn(), + errors: jest.fn(), + splat: jest.fn(), + json: jest.fn(), + }, + ), + addColors: jest.fn(), + transports: { + Console: jest.fn(), + DailyRotateFile: jest.fn(), + File: jest.fn(), + }, })); // Mock env utilities so header resolution doesn't fail From 27266bbcdceac03da13a16dbd5dbb9dd58c5c843 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 15 May 2026 14:50:13 -0400 Subject: [PATCH 018/395] =?UTF-8?q?=F0=9F=9B=B0=EF=B8=8F=20fix:=20Redact?= =?UTF-8?q?=20Outbound=20Telemetry=20URL=20Queries=20(#13133)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: redact outbound telemetry URL queries * fix: handle telemetry redaction edge cases * fix: keep sanitized telemetry URLs absolute * fix: infer https telemetry URL scheme * fix: avoid port-only telemetry protocol inference * fix: bracket ipv6 telemetry hosts --- packages/api/src/telemetry/sdk.spec.ts | 194 +++++++++++++++++++++++- packages/api/src/telemetry/sdk.ts | 199 ++++++++++++++++++++++++- 2 files changed, 391 insertions(+), 2 deletions(-) diff --git a/packages/api/src/telemetry/sdk.spec.ts b/packages/api/src/telemetry/sdk.spec.ts index 548ecc6f2a7..951b1d91ef4 100644 --- a/packages/api/src/telemetry/sdk.spec.ts +++ b/packages/api/src/telemetry/sdk.spec.ts @@ -1,10 +1,17 @@ import { Socket } from 'node:net'; import { IncomingMessage } from 'node:http'; +import { Agent as HttpsAgent } from 'node:https'; import type { Span } from '@opentelemetry/api'; +import type { RequestOptions } from 'node:http'; interface HttpInstrumentationOptions { requestHook?: (span: Span, request: object) => void; startIncomingSpanHook?: (request: IncomingMessage) => Record; + startOutgoingSpanHook?: (request: RequestOptions) => Record; +} + +interface UndiciInstrumentationOptions { + startSpanHook?: (request: { origin?: string; path?: string }) => Record; } const mockStart = jest.fn(); @@ -21,7 +28,10 @@ const mockHttpInstrumentation = jest.fn((options?: HttpInstrumentationOptions) = const mockIORedisInstrumentation = jest.fn(() => ({ name: 'ioredis' })); const mockMongoDBInstrumentation = jest.fn(() => ({ name: 'mongodb' })); const mockMongooseInstrumentation = jest.fn(() => ({ name: 'mongoose' })); -const mockUndiciInstrumentation = jest.fn(() => ({ name: 'undici' })); +const mockUndiciInstrumentation = jest.fn((options?: UndiciInstrumentationOptions) => ({ + name: 'undici', + options, +})); const mockResourceFromAttributes = jest.fn((attributes: object) => ({ attributes })); jest.mock( @@ -225,6 +235,188 @@ describe('telemetry SDK lifecycle', () => { expect(JSON.stringify(attributes)).not.toContain('secret-state'); }); + it('redacts outgoing HTTP URL query attributes before client spans are exported', () => { + initializeTelemetry({ OTEL_TRACING_ENABLED: 'true' }); + const instrumentationOptions = mockHttpInstrumentation.mock.calls[0]?.[0]; + const startOutgoingSpanHook = instrumentationOptions?.startOutgoingSpanHook; + + if (!startOutgoingSpanHook) { + throw new Error('HTTP instrumentation startOutgoingSpanHook was not configured'); + } + + const attributes = startOutgoingSpanHook({ + protocol: 'http:', + hostname: '127.0.0.1', + port: 33169, + path: '/custom-action?api_key=LC_ACTION_QUERY_SECRET_67890&user_text=sensitive+prompt+words', + }); + + expect(attributes).toEqual({ + 'http.target': '/custom-action?api_key=[REDACTED]&user_text=[REDACTED]', + 'http.url': 'http://127.0.0.1:33169/custom-action?api_key=[REDACTED]&user_text=[REDACTED]', + 'url.full': 'http://127.0.0.1:33169/custom-action?api_key=[REDACTED]&user_text=[REDACTED]', + 'url.path': '/custom-action', + 'url.query': 'api_key=[REDACTED]&user_text=[REDACTED]', + }); + expect(JSON.stringify(attributes)).not.toContain('LC_ACTION_QUERY_SECRET_67890'); + expect(JSON.stringify(attributes)).not.toContain('sensitive+prompt+words'); + }); + + it('redacts delimiter-less outgoing query segments and preserves separate HTTP ports', () => { + initializeTelemetry({ OTEL_TRACING_ENABLED: 'true' }); + const instrumentationOptions = mockHttpInstrumentation.mock.calls[0]?.[0]; + const startOutgoingSpanHook = instrumentationOptions?.startOutgoingSpanHook; + + if (!startOutgoingSpanHook) { + throw new Error('HTTP instrumentation startOutgoingSpanHook was not configured'); + } + + const attributes = startOutgoingSpanHook({ + protocol: 'https:', + host: 'api.example.com', + port: 8443, + path: '/custom-action?LC_ACTION_QUERY_SECRET_67890&user_text=sensitive+prompt+words', + }); + + expect(attributes).toEqual({ + 'http.target': '/custom-action?[REDACTED]&user_text=[REDACTED]', + 'http.url': 'https://api.example.com:8443/custom-action?[REDACTED]&user_text=[REDACTED]', + 'url.full': 'https://api.example.com:8443/custom-action?[REDACTED]&user_text=[REDACTED]', + 'url.path': '/custom-action', + 'url.query': '[REDACTED]&user_text=[REDACTED]', + }); + expect(JSON.stringify(attributes)).not.toContain('LC_ACTION_QUERY_SECRET_67890'); + expect(JSON.stringify(attributes)).not.toContain('sensitive+prompt+words'); + }); + + it('keeps outgoing HTTP URLs absolute when request options omit an origin', () => { + initializeTelemetry({ OTEL_TRACING_ENABLED: 'true' }); + const instrumentationOptions = mockHttpInstrumentation.mock.calls[0]?.[0]; + const startOutgoingSpanHook = instrumentationOptions?.startOutgoingSpanHook; + + if (!startOutgoingSpanHook) { + throw new Error('HTTP instrumentation startOutgoingSpanHook was not configured'); + } + + const attributes = startOutgoingSpanHook({ + protocol: 'http:', + path: '/custom-action?api_key=LC_ACTION_QUERY_SECRET_67890', + }); + + expect(attributes).toEqual({ + 'http.target': '/custom-action?api_key=[REDACTED]', + 'http.url': 'http://localhost/custom-action?api_key=[REDACTED]', + 'url.full': 'http://localhost/custom-action?api_key=[REDACTED]', + 'url.path': '/custom-action', + 'url.query': 'api_key=[REDACTED]', + }); + expect(JSON.stringify(attributes)).not.toContain('LC_ACTION_QUERY_SECRET_67890'); + }); + + it('uses the agent protocol for outgoing URL attributes when request protocol is absent', () => { + initializeTelemetry({ OTEL_TRACING_ENABLED: 'true' }); + const instrumentationOptions = mockHttpInstrumentation.mock.calls[0]?.[0]; + const startOutgoingSpanHook = instrumentationOptions?.startOutgoingSpanHook; + + if (!startOutgoingSpanHook) { + throw new Error('HTTP instrumentation startOutgoingSpanHook was not configured'); + } + + const attributes = startOutgoingSpanHook({ + agent: new HttpsAgent(), + hostname: 'api.example.com', + path: '/custom-action?api_key=LC_ACTION_QUERY_SECRET_67890', + }); + + expect(attributes).toEqual({ + 'http.target': '/custom-action?api_key=[REDACTED]', + 'http.url': 'https://api.example.com/custom-action?api_key=[REDACTED]', + 'url.full': 'https://api.example.com/custom-action?api_key=[REDACTED]', + 'url.path': '/custom-action', + 'url.query': 'api_key=[REDACTED]', + }); + expect(JSON.stringify(attributes)).not.toContain('LC_ACTION_QUERY_SECRET_67890'); + }); + + it('does not infer HTTPS from port 443 without protocol context', () => { + initializeTelemetry({ OTEL_TRACING_ENABLED: 'true' }); + const instrumentationOptions = mockHttpInstrumentation.mock.calls[0]?.[0]; + const startOutgoingSpanHook = instrumentationOptions?.startOutgoingSpanHook; + + if (!startOutgoingSpanHook) { + throw new Error('HTTP instrumentation startOutgoingSpanHook was not configured'); + } + + const attributes = startOutgoingSpanHook({ + hostname: 'api.example.com', + port: 443, + path: '/custom-action?api_key=LC_ACTION_QUERY_SECRET_67890', + }); + + expect(attributes).toEqual({ + 'http.target': '/custom-action?api_key=[REDACTED]', + 'http.url': 'http://api.example.com:443/custom-action?api_key=[REDACTED]', + 'url.full': 'http://api.example.com:443/custom-action?api_key=[REDACTED]', + 'url.path': '/custom-action', + 'url.query': 'api_key=[REDACTED]', + }); + expect(JSON.stringify(attributes)).not.toContain('LC_ACTION_QUERY_SECRET_67890'); + }); + + it('brackets IPv6 hostnames in outgoing HTTP URL attributes', () => { + initializeTelemetry({ OTEL_TRACING_ENABLED: 'true' }); + const instrumentationOptions = mockHttpInstrumentation.mock.calls[0]?.[0]; + const startOutgoingSpanHook = instrumentationOptions?.startOutgoingSpanHook; + + if (!startOutgoingSpanHook) { + throw new Error('HTTP instrumentation startOutgoingSpanHook was not configured'); + } + + const attributes = startOutgoingSpanHook({ + protocol: 'http:', + hostname: '::1', + port: 8080, + path: '/custom-action?api_key=LC_ACTION_QUERY_SECRET_67890', + }); + + expect(attributes).toEqual({ + 'http.target': '/custom-action?api_key=[REDACTED]', + 'http.url': 'http://[::1]:8080/custom-action?api_key=[REDACTED]', + 'url.full': 'http://[::1]:8080/custom-action?api_key=[REDACTED]', + 'url.path': '/custom-action', + 'url.query': 'api_key=[REDACTED]', + }); + expect(JSON.stringify(attributes)).not.toContain('LC_ACTION_QUERY_SECRET_67890'); + }); + + it('redacts outgoing Undici URL query attributes before fetch spans are exported', () => { + initializeTelemetry({ OTEL_TRACING_ENABLED: 'true' }); + const instrumentationOptions = mockUndiciInstrumentation.mock.calls[0]?.[0]; + const startSpanHook = instrumentationOptions?.startSpanHook; + + if (!startSpanHook) { + throw new Error('Undici instrumentation startSpanHook was not configured'); + } + + const attributes = startSpanHook({ + origin: 'https://api.openweathermap.org', + path: '/data/3.0/onecall?appid=OPENWEATHER_SECRET_123&lat=40.71&lon=-74.01', + }); + + expect(attributes).toEqual({ + 'http.target': '/data/3.0/onecall?appid=[REDACTED]&lat=[REDACTED]&lon=[REDACTED]', + 'http.url': + 'https://api.openweathermap.org/data/3.0/onecall?appid=[REDACTED]&lat=[REDACTED]&lon=[REDACTED]', + 'url.full': + 'https://api.openweathermap.org/data/3.0/onecall?appid=[REDACTED]&lat=[REDACTED]&lon=[REDACTED]', + 'url.path': '/data/3.0/onecall', + 'url.query': 'appid=[REDACTED]&lat=[REDACTED]&lon=[REDACTED]', + }); + expect(JSON.stringify(attributes)).not.toContain('OPENWEATHER_SECRET_123'); + expect(JSON.stringify(attributes)).not.toContain('40.71'); + expect(JSON.stringify(attributes)).not.toContain('-74.01'); + }); + it('reflects lifecycle status from the controller getter', async () => { const controller = initializeTelemetry({ OTEL_TRACING_ENABLED: 'true' }); diff --git a/packages/api/src/telemetry/sdk.ts b/packages/api/src/telemetry/sdk.ts index 49bd8a7ffcb..90c3b1977c8 100644 --- a/packages/api/src/telemetry/sdk.ts +++ b/packages/api/src/telemetry/sdk.ts @@ -10,6 +10,7 @@ import { MongooseInstrumentation } from '@opentelemetry/instrumentation-mongoose import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from '@opentelemetry/semantic-conventions'; import type { NodeSDKConfiguration } from '@opentelemetry/sdk-node'; import type { Span, Attributes } from '@opentelemetry/api'; +import type { RequestOptions } from 'node:http'; import type { TelemetryConfig, TelemetryStatus } from './config'; import { getTelemetryConfig } from './config'; @@ -28,6 +29,21 @@ interface RegisteredSignal { listener: NodeJS.SignalsListener; } +interface RequestUrlParts { + href?: string; + search?: string; + pathname?: string; +} + +interface AgentProtocol { + protocol?: string; +} + +interface UndiciRequestInfo { + path?: string; + origin?: string; +} + let activeSdk: NodeSDK | undefined; let pendingSdk: NodeSDK | undefined; let startPromise: Promise | undefined; @@ -95,6 +111,183 @@ function getSanitizedIncomingUrlAttributes( return attributes; } +function getStringValue(value: string | number | null | undefined): string | undefined { + if (value == null) { + return undefined; + } + + const stringValue = String(value).trim(); + return stringValue || undefined; +} + +function getRedactedQuery(search: string): string | undefined { + const query = search.startsWith('?') ? search.slice(1) : search; + if (!query) { + return undefined; + } + + return query + .split('&') + .map((part) => { + const separatorIndex = part.indexOf('='); + if (separatorIndex < 0) { + return REDACTED_QUERY_VALUE; + } + + const key = part.slice(0, separatorIndex); + if (!key) { + return REDACTED_QUERY_VALUE; + } + + return `${key}=${REDACTED_QUERY_VALUE}`; + }) + .join('&'); +} + +function getSanitizedUrlAttributesFromParts( + origin: string | undefined, + pathname: string, + search: string, +): Attributes { + const path = pathname || '/'; + const redactedQuery = getRedactedQuery(search); + const target = redactedQuery ? `${path}?${redactedQuery}` : path; + const fullUrl = origin ? `${origin}${target}` : target; + const attributes: Attributes = { + 'http.target': target, + 'http.url': fullUrl, + 'url.full': fullUrl, + 'url.path': path, + }; + + if (redactedQuery) { + attributes['url.query'] = redactedQuery; + } + + return attributes; +} + +function getFallbackUrlParts(rawUrl: string): { pathname: string; search: string } { + const queryIndex = rawUrl.indexOf('?'); + if (queryIndex < 0) { + return { pathname: rawUrl || '/', search: '' }; + } + + return { + pathname: rawUrl.slice(0, queryIndex) || '/', + search: rawUrl.slice(queryIndex), + }; +} + +function getSanitizedOutgoingUrlAttributes(rawUrl: string, origin?: string): Attributes { + const hasOrigin = /^[a-z][a-z\d+\-.]*:\/\//i.test(rawUrl); + + try { + const parsedUrl = new URL(rawUrl, origin ?? 'http://localhost'); + const safeOrigin = hasOrigin || origin ? parsedUrl.origin : undefined; + return getSanitizedUrlAttributesFromParts(safeOrigin, parsedUrl.pathname, parsedUrl.search); + } catch { + const { pathname, search } = getFallbackUrlParts(rawUrl); + return getSanitizedUrlAttributesFromParts(origin, pathname, search); + } +} + +function normalizeProtocol(protocol: string): string { + return protocol.endsWith(':') ? protocol : `${protocol}:`; +} + +function getRequestAgentProtocol(request: RequestOptions): string | undefined { + const { agent } = request; + if (!agent || typeof agent === 'boolean') { + return undefined; + } + + return getStringValue((agent as AgentProtocol).protocol); +} + +function getOutgoingHttpProtocol(request: RequestOptions): string { + const protocol = getStringValue(request.protocol) ?? getRequestAgentProtocol(request); + if (!protocol) { + return 'http:'; + } + + return normalizeProtocol(protocol); +} + +function getUrlAuthorityHost(host: string): string { + try { + const parsedHost = new URL(`http://${host}`); + return parsedHost.host; + } catch { + if (host.includes(':') && !host.startsWith('[')) { + return `[${host}]`; + } + } + + return host; +} + +function getHostWithPort(host: string, port: string | undefined): string { + const authorityHost = getUrlAuthorityHost(host); + if (!port) { + return authorityHost; + } + + try { + const parsedHost = new URL(`http://${authorityHost}`); + if (parsedHost.port) { + return authorityHost; + } + } catch { + return authorityHost; + } + + return `${authorityHost}:${port}`; +} + +function getOutgoingHttpOrigin(request: RequestOptions): string | undefined { + const protocol = getOutgoingHttpProtocol(request); + const host = getStringValue(request.host); + const port = getStringValue(request.port); + if (host) { + return `${protocol}//${getHostWithPort(host, port)}`; + } + + const hostname = getStringValue(request.hostname); + const resolvedHostname = hostname ?? 'localhost'; + return `${protocol}//${getHostWithPort(resolvedHostname, port)}`; +} + +function getOutgoingHttpUrl(request: RequestOptions & RequestUrlParts): string { + if (request.path) { + return request.path; + } + + if (request.href) { + return request.href; + } + + const pathname = request.pathname || '/'; + if (!request.search) { + return pathname; + } + + const search = request.search.startsWith('?') ? request.search : `?${request.search}`; + return `${pathname}${search}`; +} + +function getSanitizedOutgoingHttpUrlAttributes(request: RequestOptions): Attributes { + const requestWithUrlParts = request as RequestOptions & RequestUrlParts; + return getSanitizedOutgoingUrlAttributes( + getOutgoingHttpUrl(requestWithUrlParts), + getOutgoingHttpOrigin(request), + ); +} + +function getSanitizedUndiciUrlAttributes(request: UndiciRequestInfo): Attributes { + return getSanitizedOutgoingUrlAttributes(request.path ?? '/', request.origin); +} + function getResourceAttributes(config: TelemetryConfig): Attributes { const attributes: Attributes = { [ATTR_SERVICE_NAME]: config.serviceName, @@ -123,6 +316,8 @@ function createSdk(config: TelemetryConfig): NodeSDK { }, startIncomingSpanHook: (request: IncomingMessage) => getSanitizedIncomingUrlAttributes(request, config.healthPath), + startOutgoingSpanHook: (request: RequestOptions) => + getSanitizedOutgoingHttpUrlAttributes(request), ignoreIncomingRequestHook: (request: IncomingMessage) => shouldIgnoreIncomingRequest(request, config.healthPath), }), @@ -130,7 +325,9 @@ function createSdk(config: TelemetryConfig): NodeSDK { new MongoDBInstrumentation(), new MongooseInstrumentation(), new IORedisInstrumentation(), - new UndiciInstrumentation(), + new UndiciInstrumentation({ + startSpanHook: (request: UndiciRequestInfo) => getSanitizedUndiciUrlAttributes(request), + }), ], }; From f3b165ea84cecdcc675b66e65bfbc79e53ed841d Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 15 May 2026 14:50:50 -0400 Subject: [PATCH 019/395] =?UTF-8?q?=E2=8F=A9=20refactor:=20Speed=20Up=20Su?= =?UTF-8?q?bagent=20Ticker=20Refresh=20(#13141)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Messages/Content/Parts/SubagentCall.tsx | 10 +- .../Parts/__tests__/SubagentCall.test.tsx | 93 +++++++++++++++---- 2 files changed, 80 insertions(+), 23 deletions(-) diff --git a/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx b/client/src/components/Chat/Messages/Content/Parts/SubagentCall.tsx index 6a94c079c83..d36bca7fc84 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, ); 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..38a82682a2c 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, @@ -16,7 +16,7 @@ import { initSubagentTickerState, } from '~/utils/subagentContent'; import { subagentProgressByToolCallId } from '~/store/subagents'; -import SubagentCall from '../SubagentCall'; +import SubagentCall, { SUBAGENT_TICKER_THROTTLE_MS } from '../SubagentCall'; jest.mock('~/hooks', () => ({ useLocalize: @@ -83,18 +83,22 @@ jest.mock('../Attachment', () => ({ ), })); -jest.mock('@librechat/client', () => ({ - OGDialog: ({ children }: { children: React.ReactNode }) => <>{children}, - OGDialogContent: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - OGDialogTitle: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - OGDialogDescription: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), -})); +jest.mock( + '@librechat/client', + () => ({ + OGDialog: ({ children }: { children: React.ReactNode }) => <>{children}, + OGDialogContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + OGDialogTitle: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + OGDialogDescription: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + }), + { virtual: true }, +); jest.mock('lucide-react', () => ({ // eslint-disable-next-line i18next/no-literal-string @@ -131,6 +135,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 +237,13 @@ 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 }; } describe('SubagentCall — status resolution', () => { @@ -443,6 +454,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', () => { From b549966e4ab79a4c4aee0f5dd6fd5c0e7591a897 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 15 May 2026 14:52:43 -0400 Subject: [PATCH 020/395] =?UTF-8?q?=F0=9F=A7=AD=20fix:=20Tighten=20Action?= =?UTF-8?q?=20OAuth=20Endpoint=20Validation=20(#13142)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: tighten action OAuth endpoint validation * fix: reuse action OAuth validation primitives * fix: preserve action OAuth address exemptions --- api/server/routes/actions.js | 1 + api/server/routes/agents/actions.js | 12 +- api/server/routes/assistants/actions.js | 8 +- api/server/services/ActionService.js | 5 + packages/api/src/oauth/index.ts | 1 + packages/api/src/oauth/tokens.spec.ts | 233 ++++++++++++++++++++++ packages/api/src/oauth/tokens.ts | 33 +++ packages/api/src/oauth/validation.spec.ts | 82 ++++++++ packages/api/src/oauth/validation.ts | 71 +++++++ 9 files changed, 444 insertions(+), 2 deletions(-) create mode 100644 packages/api/src/oauth/tokens.spec.ts create mode 100644 packages/api/src/oauth/validation.spec.ts create mode 100644 packages/api/src/oauth/validation.ts diff --git a/api/server/routes/actions.js b/api/server/routes/actions.js index 806edc66cc6..38d0dc8c949 100644 --- a/api/server/routes/actions.js +++ b/api/server/routes/actions.js @@ -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/agents/actions.js b/api/server/routes/agents/actions.js index cccccedfd85..a775f67f3f2 100644 --- a/api/server/routes/agents/actions.js +++ b/api/server/routes/agents/actions.js @@ -1,7 +1,11 @@ const express = require('express'); const { nanoid } = require('nanoid'); const { logger } = require('@librechat/data-schemas'); -const { generateCheckAccess, isActionDomainAllowed } = require('@librechat/api'); +const { + generateCheckAccess, + isActionDomainAllowed, + validateActionOAuthMetadata, +} = require('@librechat/api'); const { Permissions, ResourceType, @@ -155,6 +159,12 @@ router.post( metadata = { ...action.metadata, ...metadata }; } + try { + await validateActionOAuthMetadata(metadata.auth, appConfig?.actions?.allowedAddresses); + } catch (error) { + return res.status(400).json({ message: error.message }); + } + const { actions: _actions = [], author: agent_author } = agent ?? {}; const actions = []; for (const action of _actions) { 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/services/ActionService.js b/api/server/services/ActionService.js index 859496bf7c2..2e324b53124 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, @@ -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 () => { @@ -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, diff --git a/packages/api/src/oauth/index.ts b/packages/api/src/oauth/index.ts index 01be92b6e3d..f5a7a413345 100644 --- a/packages/api/src/oauth/index.ts +++ b/packages/api/src/oauth/index.ts @@ -1,2 +1,3 @@ export * from './csrf'; export * from './tokens'; +export * from './validation'; diff --git a/packages/api/src/oauth/tokens.spec.ts b/packages/api/src/oauth/tokens.spec.ts new file mode 100644 index 00000000000..f3e5b4a29e5 --- /dev/null +++ b/packages/api/src/oauth/tokens.spec.ts @@ -0,0 +1,233 @@ +import axios from 'axios'; +import { decryptV2 } from '@librechat/data-schemas'; +import { TokenExchangeMethodEnum } from 'librechat-data-provider'; +import type { AxiosRequestConfig } from 'axios'; +import { getAccessToken, refreshAccessToken } from './tokens'; + +jest.mock('axios'); + +jest.mock('@librechat/data-schemas', () => ({ + logger: { + debug: jest.fn(), + error: jest.fn(), + }, + encryptV2: jest.fn(async (value: string) => `encrypted:${value}`), + decryptV2: jest.fn(async (value: string) => { + if (value === 'encrypted-client-id') { + return 'client-id'; + } + if (value === 'encrypted-client-secret') { + return 'client-secret'; + } + if (value === 'encrypted-refresh-token') { + return 'refresh-token'; + } + return value; + }), +})); + +const mockedAxios = axios as jest.MockedFunction; +const mockedDecryptV2 = decryptV2 as jest.MockedFunction; + +function createTokenMethods() { + return { + findToken: jest.fn().mockResolvedValue(null), + updateToken: jest.fn().mockResolvedValue({}), + createToken: jest.fn().mockResolvedValue({}), + }; +} + +function getAxiosConfig(): AxiosRequestConfig { + const config = mockedAxios.mock.calls[0]?.[0] as AxiosRequestConfig | undefined; + if (!config) { + throw new Error('Expected axios to be called'); + } + return config; +} + +describe('action OAuth token exchange validation', () => { + const tokenResponse = { + access_token: 'access-token', + expires_in: 3600, + refresh_token: 'new-refresh-token', + refresh_token_expires_in: 7200, + }; + + const baseFields = { + userId: 'user-1', + identifier: 'user-1:action-1', + client_url: 'https://93.184.216.34/oauth/token', + encrypted_oauth_client_id: 'encrypted-client-id', + encrypted_oauth_client_secret: 'encrypted-client-secret', + }; + + beforeEach(() => { + jest.clearAllMocks(); + mockedAxios.mockResolvedValue({ data: tokenResponse }); + }); + + it.each([ + 'http://93.184.216.34/oauth/token', + 'https://localhost/oauth/token', + 'https://10.0.0.1/oauth/token', + 'https://169.254.169.254/latest/meta-data', + ])( + 'rejects unsafe client_url before decrypting secrets or calling axios: %s', + async (clientUrl) => { + await expect( + getAccessToken( + { + ...baseFields, + client_url: clientUrl, + code: 'authorization-code', + redirect_uri: 'https://chat.example.com/api/actions/action-1/oauth/callback', + token_exchange_method: TokenExchangeMethodEnum.DefaultPost, + }, + createTokenMethods(), + ), + ).rejects.toThrow(/Invalid action OAuth client_url/); + + expect(mockedDecryptV2).not.toHaveBeenCalled(); + expect(mockedAxios).not.toHaveBeenCalled(); + }, + ); + + it('posts authorization-code exchanges without following redirects', async () => { + await getAccessToken( + { + ...baseFields, + code: 'authorization-code', + redirect_uri: 'https://chat.example.com/api/actions/action-1/oauth/callback', + token_exchange_method: TokenExchangeMethodEnum.DefaultPost, + }, + createTokenMethods(), + ); + + const config = getAxiosConfig(); + const params = new URLSearchParams(config.data as string); + + expect(config).toEqual( + expect.objectContaining({ + method: 'POST', + url: baseFields.client_url, + maxRedirects: 0, + httpsAgent: expect.any(Object), + }), + ); + expect(params.get('client_id')).toBe('client-id'); + expect(params.get('client_secret')).toBe('client-secret'); + }); + + it('uses Basic auth for authorization-code exchanges without putting client secrets in the body', async () => { + await getAccessToken( + { + ...baseFields, + code: 'authorization-code', + redirect_uri: 'https://chat.example.com/api/actions/action-1/oauth/callback', + token_exchange_method: TokenExchangeMethodEnum.BasicAuthHeader, + }, + createTokenMethods(), + ); + + const config = getAxiosConfig(); + const headers = config.headers as Record; + const params = new URLSearchParams(config.data as string); + + expect(config.maxRedirects).toBe(0); + expect(headers.Authorization).toBe( + `Basic ${Buffer.from('client-id:client-secret').toString('base64')}`, + ); + expect(params.has('client_id')).toBe(false); + expect(params.has('client_secret')).toBe(false); + }); + + it('posts refresh-token exchanges without following redirects', async () => { + await refreshAccessToken( + { + ...baseFields, + refresh_token: 'refresh-token', + token_exchange_method: TokenExchangeMethodEnum.DefaultPost, + }, + createTokenMethods(), + ); + + const config = getAxiosConfig(); + const params = new URLSearchParams(config.data as string); + + expect(config).toEqual( + expect.objectContaining({ + method: 'POST', + url: baseFields.client_url, + maxRedirects: 0, + httpsAgent: expect.any(Object), + }), + ); + expect(params.get('grant_type')).toBe('refresh_token'); + expect(params.get('client_id')).toBe('client-id'); + expect(params.get('client_secret')).toBe('client-secret'); + }); + + it('uses Basic auth for refresh-token exchanges without putting client secrets in the body', async () => { + await refreshAccessToken( + { + ...baseFields, + refresh_token: 'refresh-token', + token_exchange_method: TokenExchangeMethodEnum.BasicAuthHeader, + }, + createTokenMethods(), + ); + + const config = getAxiosConfig(); + const headers = config.headers as Record; + const params = new URLSearchParams(config.data as string); + + expect(config.maxRedirects).toBe(0); + expect(headers.Authorization).toBe( + `Basic ${Buffer.from('client-id:client-secret').toString('base64')}`, + ); + expect(params.has('client_id')).toBe(false); + expect(params.has('client_secret')).toBe(false); + }); + + it('reuses the same HTTPS agent across token exchanges', async () => { + await getAccessToken( + { + ...baseFields, + code: 'authorization-code', + redirect_uri: 'https://chat.example.com/api/actions/action-1/oauth/callback', + token_exchange_method: TokenExchangeMethodEnum.DefaultPost, + }, + createTokenMethods(), + ); + await refreshAccessToken( + { + ...baseFields, + refresh_token: 'refresh-token', + token_exchange_method: TokenExchangeMethodEnum.DefaultPost, + }, + createTokenMethods(), + ); + + const [accessConfig, refreshConfig] = mockedAxios.mock.calls.map( + ([config]) => config as AxiosRequestConfig, + ); + + expect(accessConfig.httpsAgent).toBe(refreshConfig.httpsAgent); + }); + + it('allows explicitly exempted private token endpoints', async () => { + await getAccessToken( + { + ...baseFields, + client_url: 'https://10.0.0.1/oauth/token', + code: 'authorization-code', + redirect_uri: 'https://chat.example.com/api/actions/action-1/oauth/callback', + token_exchange_method: TokenExchangeMethodEnum.DefaultPost, + allowedAddresses: ['10.0.0.1:443'], + }, + createTokenMethods(), + ); + + expect(getAxiosConfig().url).toBe('https://10.0.0.1/oauth/token'); + }); +}); diff --git a/packages/api/src/oauth/tokens.ts b/packages/api/src/oauth/tokens.ts index e51e91b842a..596860bf580 100644 --- a/packages/api/src/oauth/tokens.ts +++ b/packages/api/src/oauth/tokens.ts @@ -3,7 +3,28 @@ import { logger, encryptV2, decryptV2 } from '@librechat/data-schemas'; import { TokenExchangeMethodEnum } from 'librechat-data-provider'; import type { TokenMethods } from '@librechat/data-schemas'; import type { AxiosError } from 'axios'; +import { createSSRFSafeAgents } from '~/auth'; import { logAxiosError } from '~/utils'; +import { validateActionOAuthEndpoint } from './validation'; + +const actionOAuthAgents = createSSRFSafeAgents(); +const actionOAuthAgentsByAddress = new Map>(); + +function getActionOAuthAgents(allowedAddresses?: string[] | null) { + if (!Array.isArray(allowedAddresses) || allowedAddresses.length === 0) { + return actionOAuthAgents; + } + + const cacheKey = allowedAddresses.join('\n'); + const cachedAgents = actionOAuthAgentsByAddress.get(cacheKey); + if (cachedAgents) { + return cachedAgents; + } + + const agents = createSSRFSafeAgents(allowedAddresses); + actionOAuthAgentsByAddress.set(cacheKey, agents); + return agents; +} export function createHandleOAuthToken({ findToken, @@ -143,6 +164,7 @@ export async function refreshAccessToken( token_exchange_method, encrypted_oauth_client_id, encrypted_oauth_client_secret, + allowedAddresses, }: { userId: string; client_url: string; @@ -151,6 +173,7 @@ export async function refreshAccessToken( token_exchange_method: TokenExchangeMethodEnum; encrypted_oauth_client_id: string; encrypted_oauth_client_secret: string; + allowedAddresses?: string[] | null; }, { findToken, @@ -167,6 +190,8 @@ export async function refreshAccessToken( refresh_token?: string; refresh_token_expires_in?: number; }> { + await validateActionOAuthEndpoint(client_url, 'client_url', allowedAddresses); + try { const oauth_client_id = await decryptV2(encrypted_oauth_client_id); const oauth_client_secret = await decryptV2(encrypted_oauth_client_secret); @@ -193,6 +218,8 @@ export async function refreshAccessToken( method: 'POST', url: client_url, headers, + maxRedirects: 0, + httpsAgent: getActionOAuthAgents(allowedAddresses).httpsAgent, data: params.toString(), }); await processAccessTokens( @@ -242,6 +269,7 @@ export async function getAccessToken( token_exchange_method, encrypted_oauth_client_id, encrypted_oauth_client_secret, + allowedAddresses, }: { code: string; userId: string; @@ -251,6 +279,7 @@ export async function getAccessToken( token_exchange_method: TokenExchangeMethodEnum; encrypted_oauth_client_id: string; encrypted_oauth_client_secret: string; + allowedAddresses?: string[] | null; }, { findToken, @@ -267,6 +296,8 @@ export async function getAccessToken( refresh_token?: string; refresh_token_expires_in?: number; }> { + await validateActionOAuthEndpoint(client_url, 'client_url', allowedAddresses); + const oauth_client_id = await decryptV2(encrypted_oauth_client_id); const oauth_client_secret = await decryptV2(encrypted_oauth_client_secret); @@ -294,6 +325,8 @@ export async function getAccessToken( method: 'POST', url: client_url, headers, + maxRedirects: 0, + httpsAgent: getActionOAuthAgents(allowedAddresses).httpsAgent, data: params.toString(), }); diff --git a/packages/api/src/oauth/validation.spec.ts b/packages/api/src/oauth/validation.spec.ts new file mode 100644 index 00000000000..fc28801efd6 --- /dev/null +++ b/packages/api/src/oauth/validation.spec.ts @@ -0,0 +1,82 @@ +import { AuthTypeEnum } from 'librechat-data-provider'; + +import { validateActionOAuthEndpoint, validateActionOAuthMetadata } from './validation'; + +describe('validateActionOAuthEndpoint', () => { + it('allows HTTPS endpoints on public addresses', async () => { + await expect( + validateActionOAuthEndpoint('https://93.184.216.34/oauth/token', 'client_url'), + ).resolves.toBeUndefined(); + }); + + it.each([ + ['HTTP endpoint', 'http://93.184.216.34/oauth/token'], + ['FTP endpoint', 'ftp://93.184.216.34/oauth/token'], + ['localhost', 'https://localhost/oauth/token'], + ['loopback IP', 'https://127.0.0.1/oauth/token'], + ['private IP', 'https://10.0.0.1/oauth/token'], + ['link-local IP', 'https://169.254.169.123/oauth/token'], + ['metadata IP', 'https://169.254.169.254/latest/meta-data'], + ['internal hostname', 'https://metadata/oauth/token'], + ['IPv6 loopback', 'https://[::1]/oauth/token'], + ])('rejects %s', async (_label, url) => { + await expect(validateActionOAuthEndpoint(url, 'client_url')).rejects.toThrow( + /Invalid action OAuth client_url/, + ); + }); + + it('rejects unparseable endpoint URLs', async () => { + await expect(validateActionOAuthEndpoint('not a url', 'authorization_url')).rejects.toThrow( + /Invalid action OAuth authorization_url/, + ); + }); + + it('allows restricted HTTPS endpoints when explicitly exempted by allowedAddresses', async () => { + await expect( + validateActionOAuthEndpoint('https://10.0.0.1/oauth/token', 'client_url', ['10.0.0.1:443']), + ).resolves.toBeUndefined(); + }); + + it('keeps allowedAddresses scoped to the endpoint port', async () => { + await expect( + validateActionOAuthEndpoint('https://10.0.0.1:8443/oauth/token', 'client_url', [ + '10.0.0.1:443', + ]), + ).rejects.toThrow(/Invalid action OAuth client_url/); + }); +}); + +describe('validateActionOAuthMetadata', () => { + it('validates both OAuth authorization and token endpoints', async () => { + await expect( + validateActionOAuthMetadata({ + type: AuthTypeEnum.OAuth, + authorization_url: 'https://93.184.216.34/oauth/authorize', + client_url: 'https://10.0.0.1/oauth/token', + }), + ).rejects.toThrow(/Invalid action OAuth client_url/); + }); + + it('passes allowedAddresses to both OAuth endpoints', async () => { + await expect( + validateActionOAuthMetadata( + { + type: AuthTypeEnum.OAuth, + authorization_url: 'https://10.0.0.1/oauth/authorize', + client_url: 'https://10.0.0.1/oauth/token', + }, + ['10.0.0.1:443'], + ), + ).resolves.toBeUndefined(); + }); + + it('ignores non-OAuth auth metadata', async () => { + await expect( + validateActionOAuthMetadata({ + type: AuthTypeEnum.ServiceHttp, + authorization_url: 'http://localhost/oauth/authorize', + client_url: 'http://localhost/oauth/token', + }), + ).resolves.toBeUndefined(); + }); +}); diff --git a/packages/api/src/oauth/validation.ts b/packages/api/src/oauth/validation.ts new file mode 100644 index 00000000000..d5512488004 --- /dev/null +++ b/packages/api/src/oauth/validation.ts @@ -0,0 +1,71 @@ +import { AuthTypeEnum } from 'librechat-data-provider'; + +import { validateEndpointURL } from '~/auth'; + +type ActionOAuthEndpointField = 'authorization_url' | 'client_url'; + +interface ActionOAuthAuthMetadata { + type?: AuthTypeEnum | string | null; + authorization_url?: string | null; + client_url?: string | null; +} + +function invalidActionOAuth(fieldName: ActionOAuthEndpointField, message: string): never { + throw new Error(`Invalid action OAuth ${fieldName}: ${message}`); +} + +function parseEndpointError(error: unknown): string { + if (!(error instanceof Error)) { + return 'endpoint URL is not permitted.'; + } + + try { + const parsed = JSON.parse(error.message) as { message?: unknown }; + if (typeof parsed.message === 'string') { + return parsed.message; + } + } catch { + return error.message; + } + + return error.message; +} + +export async function validateActionOAuthEndpoint( + url: string | null | undefined, + fieldName: ActionOAuthEndpointField, + allowedAddresses?: string[] | null, +): Promise { + if (!url || typeof url !== 'string') { + invalidActionOAuth(fieldName, 'endpoint URL is required.'); + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(url); + } catch { + invalidActionOAuth(fieldName, 'unable to parse endpoint URL.'); + } + + if (parsedUrl.protocol !== 'https:') { + invalidActionOAuth(fieldName, 'only HTTPS endpoint URLs are permitted.'); + } + + try { + await validateEndpointURL(url, `action OAuth ${fieldName}`, allowedAddresses); + } catch (error) { + invalidActionOAuth(fieldName, parseEndpointError(error)); + } +} + +export async function validateActionOAuthMetadata( + auth?: ActionOAuthAuthMetadata | null, + allowedAddresses?: string[] | null, +): Promise { + if (!auth || auth.type !== AuthTypeEnum.OAuth) { + return; + } + + await validateActionOAuthEndpoint(auth.authorization_url, 'authorization_url', allowedAddresses); + await validateActionOAuthEndpoint(auth.client_url, 'client_url', allowedAddresses); +} From d2958bcfea32fd83e66155f9567cc51f8ccfeddf Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 15 May 2026 14:54:13 -0400 Subject: [PATCH 021/395] =?UTF-8?q?=F0=9F=8C=8D=20i18n:=20Update=20transla?= =?UTF-8?q?tion.json=20with=20latest=20translations=20(#13128)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- client/src/locales/en/translation.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 16325fad642..04c3144e408 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -1474,8 +1474,8 @@ "com_ui_show_code": "Show Code", "com_ui_show_image_details": "Show Image Details", "com_ui_show_less": "Show less", - "com_ui_show_n_files": "Show {{0}} files", "com_ui_show_more": "Show more", + "com_ui_show_n_files": "Show {{0}} files", "com_ui_show_password": "Show password", "com_ui_show_qr": "Show QR Code", "com_ui_sign_in_to_domain": "Sign-in to {{0}}", From 89d10a0b837b603d9a4862762cb5e91fa2724966 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 18 May 2026 09:04:31 -0400 Subject: [PATCH 022/395] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20fix:=20Escape?= =?UTF-8?q?=20People=20Picker=20Search=20Regex=20(#13169)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controllers/PermissionsController.js | 17 +++-- .../__tests__/PermissionsController.spec.js | 74 ++++++++++++++++++- .../src/methods/user.methods.spec.ts | 28 +++++++ packages/data-schemas/src/methods/user.ts | 11 +-- .../src/methods/userGroup.spec.ts | 43 ++++++++++- .../data-schemas/src/methods/userGroup.ts | 18 ++--- 6 files changed, 165 insertions(+), 26 deletions(-) diff --git a/api/server/controllers/PermissionsController.js b/api/server/controllers/PermissionsController.js index 1f200fce83d..ffe159a82cc 100644 --- a/api/server/controllers/PermissionsController.js +++ b/api/server/controllers/PermissionsController.js @@ -385,15 +385,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 +412,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 +439,7 @@ const searchPrincipals = async (req, res) => { const graphResults = await searchEntraIdPrincipals( accessToken, req.user.openidId, - query.trim(), + query, graphType, searchLimit - localResults.length, ); @@ -466,7 +468,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 +480,7 @@ const searchPrincipals = async (req, res) => { }); res.status(200).json({ - query: query.trim(), + query, limit: searchLimit, types: typeFilters, results: finalResults, @@ -492,7 +494,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/__tests__/PermissionsController.spec.js b/api/server/controllers/__tests__/PermissionsController.spec.js index a8d95184550..6c42ccc59b5 100644 --- a/api/server/controllers/__tests__/PermissionsController.spec.js +++ b/api/server/controllers/__tests__/PermissionsController.spec.js @@ -43,7 +43,8 @@ jest.mock('~/server/services/GraphApiService', () => ({ searchEntraIdPrincipals: jest.fn(), })); -const { updateResourcePermissions } = require('../PermissionsController'); +const db = require('~/models'); +const { updateResourcePermissions, searchPrincipals } = require('../PermissionsController'); const createMockReq = (overrides = {}) => ({ params: { resourceType: ResourceType.AGENT, resourceId: '507f1f77bcf86cd799439011' }, @@ -67,6 +68,77 @@ describe('PermissionsController', () => { jest.clearAllMocks(); }); + 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('updateResourcePermissions — favorites cleanup', () => { const agentObjectId = new mongoose.Types.ObjectId().toString(); const revokedUserId = new mongoose.Types.ObjectId().toString(); diff --git a/packages/data-schemas/src/methods/user.methods.spec.ts b/packages/data-schemas/src/methods/user.methods.spec.ts index 091cdf4f763..9298447198b 100644 --- a/packages/data-schemas/src/methods/user.methods.spec.ts +++ b/packages/data-schemas/src/methods/user.methods.spec.ts @@ -476,6 +476,34 @@ describe('User Methods - Database Tests', () => { expect(results).toEqual([]); }); + test('should treat regex metacharacters as literal search text', async () => { + await User.create({ + name: 'Literal .* User', + email: 'literal-star@test.com', + username: 'literal-star', + provider: 'local', + }); + + const results = await methods.searchUsers({ searchPattern: '.*' }); + + expect(results).toHaveLength(1); + expect((results[0] as unknown as t.IUser).name).toBe('Literal .* User'); + }); + + test('should handle invalid regex syntax as literal search text', async () => { + await User.create({ + name: 'Regex [invalid User', + email: 'regex-invalid@test.com', + username: 'regex-invalid', + provider: 'local', + }); + + const results = await methods.searchUsers({ searchPattern: '[invalid' }); + + expect(results).toHaveLength(1); + expect((results[0] as unknown as t.IUser).name).toBe('Regex [invalid User'); + }); + test('should apply field selection', async () => { const results = await methods.searchUsers({ searchPattern: 'john', diff --git a/packages/data-schemas/src/methods/user.ts b/packages/data-schemas/src/methods/user.ts index d7aa3f90c90..7573a48f6de 100644 --- a/packages/data-schemas/src/methods/user.ts +++ b/packages/data-schemas/src/methods/user.ts @@ -1,6 +1,7 @@ import mongoose, { FilterQuery } from 'mongoose'; import type { RefillIntervalUnit } from 'librechat-data-provider'; import type { IUser, BalanceConfig, CreateUserRequest, UserDeleteResult } from '~/types'; +import { escapeRegExp } from '~/utils/string'; import { signPayload } from '~/crypto'; /** Default JWT session expiry: 15 minutes in milliseconds */ @@ -259,7 +260,8 @@ export function createUserMethods(mongoose: typeof import('mongoose')) { return []; } - const regex = new RegExp(searchPattern.trim(), 'i'); + const trimmedPattern = searchPattern.trim(); + const regex = new RegExp(escapeRegExp(trimmedPattern), 'i'); const User = mongoose.models.User; const query = User.find({ @@ -273,8 +275,7 @@ export function createUserMethods(mongoose: typeof import('mongoose')) { const users = await query.lean(); // Score results by relevance - const exactRegex = new RegExp(`^${searchPattern.trim()}$`, 'i'); - const startsWithPattern = searchPattern.trim().toLowerCase(); + const startsWithPattern = trimmedPattern.toLowerCase(); const scoredUsers = users.map((user) => { const searchableFields = [user.name, user.email, user.username].filter( @@ -287,7 +288,7 @@ export function createUserMethods(mongoose: typeof import('mongoose')) { let score = 0; // Exact match gets highest score - if (exactRegex.test(field)) { + if (fieldLower === startsWithPattern) { score = 100; } // Starts with query gets high score @@ -298,7 +299,7 @@ export function createUserMethods(mongoose: typeof import('mongoose')) { else if (fieldLower.includes(startsWithPattern)) { score = 50; } - // Default score for regex match + // Default score for database match else { score = 10; } diff --git a/packages/data-schemas/src/methods/userGroup.spec.ts b/packages/data-schemas/src/methods/userGroup.spec.ts index ca83ced7d91..2048fe65a50 100644 --- a/packages/data-schemas/src/methods/userGroup.spec.ts +++ b/packages/data-schemas/src/methods/userGroup.spec.ts @@ -100,6 +100,7 @@ describe('userGroup methods', () => { { name: 'Engineering', source: 'local', description: 'Eng team' }, { name: 'Design', source: 'local', email: 'design@co.com' }, { name: 'Entra Eng', source: 'entra', idOnTheSource: 'ext-1' }, + { name: 'Literal .* Group', source: 'local' }, ]); }); @@ -120,6 +121,12 @@ describe('userGroup methods', () => { expect(results[0].name).toBe('Engineering'); }); + it('treats regex metacharacters as literal text', async () => { + const results = await methods.findGroupsByNamePattern('.*'); + expect(results).toHaveLength(1); + expect(results[0].name).toBe('Literal .* Group'); + }); + it('filters by source when provided', async () => { const results = await methods.findGroupsByNamePattern('eng', 'entra'); expect(results).toHaveLength(1); @@ -525,7 +532,7 @@ describe('userGroup methods', () => { expect(score).toBe(50); }); - it('returns 10 (default) when no substring or exact match — regex fallback', () => { + it('returns 10 (default) when no substring or exact match', () => { const score = methods.calculateRelevanceScore( { type: PrincipalType.USER, name: 'bob', source: 'local' }, 'zzz', @@ -573,12 +580,12 @@ describe('userGroup methods', () => { expect(score).toBe(80); }); - it('returns 100 when regex pattern matches exactly via dot wildcard', () => { + it('does not treat regex metacharacters as wildcards', () => { const score = methods.calculateRelevanceScore( { type: PrincipalType.USER, name: 'xYz', source: 'local' }, 'x.z', ); - expect(score).toBe(100); + expect(score).toBe(10); }); }); @@ -671,6 +678,36 @@ describe('userGroup methods', () => { expect(results).toEqual([]); }); + it('treats regex metacharacters as literal search text', async () => { + await User.create({ + name: 'Literal .* User', + email: 'literal-star@test.com', + username: 'literal-star', + password: 'password123', + provider: 'local', + }); + + const results = await methods.searchPrincipals('.*', 10, [PrincipalType.USER]); + + expect(results).toHaveLength(1); + expect(results[0].name).toBe('Literal .* User'); + }); + + it('handles invalid regex syntax as literal search text', async () => { + await User.create({ + name: 'Regex [invalid User', + email: 'regex-invalid@test.com', + username: 'regex-invalid', + password: 'password123', + provider: 'local', + }); + + const results = await methods.searchPrincipals('[invalid', 10, [PrincipalType.USER]); + + expect(results).toHaveLength(1); + expect(results[0].name).toBe('Regex [invalid User'); + }); + it('finds matching users', async () => { const results = await methods.searchPrincipals('alice'); const userResults = results.filter((r) => r.type === PrincipalType.USER); diff --git a/packages/data-schemas/src/methods/userGroup.ts b/packages/data-schemas/src/methods/userGroup.ts index 5e7db57b62c..31228066277 100644 --- a/packages/data-schemas/src/methods/userGroup.ts +++ b/packages/data-schemas/src/methods/userGroup.ts @@ -86,7 +86,7 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) { session?: ClientSession, ): Promise { const Group = mongoose.models.Group as Model; - const regex = new RegExp(namePattern, 'i'); + const regex = new RegExp(escapeRegExp(namePattern), 'i'); const query: Record = { $or: [{ name: regex }, { email: regex }, { description: regex }], }; @@ -436,8 +436,7 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) { * @returns Relevance score (0-100) */ function calculateRelevanceScore(item: TPrincipalSearchResult, searchPattern: string): number { - const exactRegex = new RegExp(`^${searchPattern}$`, 'i'); - const startsWithPattern = searchPattern.toLowerCase(); + const normalizedPattern = searchPattern.toLowerCase(); /** Get searchable text based on type */ const searchableFields = @@ -453,16 +452,16 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) { let score = 0; /** Exact match gets highest score */ - if (exactRegex.test(field)) { + if (fieldLower === normalizedPattern) { score = 100; - } else if (fieldLower.startsWith(startsWithPattern)) { + } else if (fieldLower.startsWith(normalizedPattern)) { /** Starts with query gets high score */ score = 80; - } else if (fieldLower.includes(startsWithPattern)) { + } else if (fieldLower.includes(normalizedPattern)) { /** Contains query gets medium score */ score = 50; } else { - /** Default score for regex match */ + /** Default score for database match */ score = 10; } @@ -551,6 +550,7 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) { } const trimmedPattern = searchPattern.trim(); + const escapedPattern = escapeRegExp(trimmedPattern); const promises: Promise[] = []; if (!typeFilter || typeFilter.includes(PrincipalType.USER)) { @@ -558,7 +558,7 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) { const userFields = 'name email username avatar provider idOnTheSource'; /** For now, we'll use a direct query instead of searchUsers */ const User = mongoose.models.User as Model; - const regex = new RegExp(trimmedPattern, 'i'); + const regex = new RegExp(escapedPattern, 'i'); const userQuery = User.find({ $or: [{ name: regex }, { email: regex }, { username: regex }], }) @@ -601,7 +601,7 @@ export function createUserGroupMethods(mongoose: typeof import('mongoose')) { if (!typeFilter || typeFilter.includes(PrincipalType.ROLE)) { const Role = mongoose.models.Role as Model; if (Role) { - const regex = new RegExp(trimmedPattern, 'i'); + const regex = new RegExp(escapedPattern, 'i'); const roleQuery = Role.find({ name: regex }).select('name').limit(limitPerType); if (session) { From ac163c221219975d2824faa6f486f8041bd46dd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9E=A5=EB=B3=91=ED=9D=AC?= Date: Mon, 18 May 2026 22:34:58 +0900 Subject: [PATCH 023/395] =?UTF-8?q?=F0=9F=A4=9D=20fix:=20Honor=20OPENID=5F?= =?UTF-8?q?REUSE=5FTOKENS=20in=20Admin=20OAuth=20Exchange=20(#13154)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/server/controllers/auth/oauth.js | 4 +- api/server/controllers/auth/oauth.spec.js | 151 ++++++++++++++++++++++ 2 files changed, 154 insertions(+), 1 deletion(-) create mode 100644 api/server/controllers/auth/oauth.spec.js 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(); + }); +}); From c342e2345b2a9409f7fd876add996a49c0d3d54a Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 18 May 2026 10:16:20 -0400 Subject: [PATCH 024/395] =?UTF-8?q?=F0=9F=AA=AA=20fix:=20Resolve=20Group-S?= =?UTF-8?q?coped=20Config=20Overrides=20(#13176)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: resolve group-scoped config overrides * test: fix endpoint config request mock typing * fix: keep remote agent preauth config tenant-scoped * test: align config scoping expectations * test: reproduce group endpoint override resolution --- api/server/controllers/PluginController.js | 15 ++- .../controllers/PluginController.spec.js | 12 +- api/server/controllers/UserController.js | 16 ++- api/server/middleware/checkDomainAllowed.js | 1 + .../services/Config/loadConfigModels.spec.js | 35 ++++++ .../services/Config/loadDefaultModels.js | 7 +- api/server/services/Files/Audio/STTService.js | 1 + api/server/services/Files/Audio/TTSService.js | 2 + .../Files/Audio/getCustomConfigSpeech.js | 11 +- api/server/services/Files/Audio/getVoices.js | 1 + api/server/services/MCP.js | 12 +- api/server/services/MCP.spec.js | 38 ++++-- .../src/endpoints/config/endpoints.spec.ts | 118 +++++++++++++++++- .../api/src/endpoints/config/endpoints.ts | 13 +- packages/api/src/endpoints/config/models.ts | 17 ++- packages/api/src/middleware/balance.spec.ts | 28 +++++ packages/api/src/middleware/balance.ts | 2 + .../src/middleware/remoteAgentAuth.spec.ts | 18 ++- .../api/src/middleware/remoteAgentAuth.ts | 4 +- 19 files changed, 311 insertions(+), 40 deletions(-) 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/UserController.js b/api/server/controllers/UserController.js index 5b38d6d5624..340ebdbb4ca 100644 --- a/api/server/controllers/UserController.js +++ b/api/server/controllers/UserController.js @@ -26,7 +26,13 @@ const { getLogStores } = require('~/cache'); const db = require('~/models'); 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 }; /** @@ -165,7 +171,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 { 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/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/loadDefaultModels.js b/api/server/services/Config/loadDefaultModels.js index 85f2c42a333..cc8da0bbc05 100644 --- a/api/server/services/Config/loadDefaultModels.js +++ b/api/server/services/Config/loadDefaultModels.js @@ -17,7 +17,12 @@ 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; const [openAI, anthropic, azureOpenAI, assistants, azureAssistants, google, bedrock] = diff --git a/api/server/services/Files/Audio/STTService.js b/api/server/services/Files/Audio/STTService.js index 7329bf6ac22..2caea1ffe0b 100644 --- a/api/server/services/Files/Audio/STTService.js +++ b/api/server/services/Files/Audio/STTService.js @@ -142,6 +142,7 @@ class STTService { req.config ?? (await getAppConfig({ role: req?.user?.role, + userId: req?.user?.id, tenantId: req?.user?.tenantId, })); const sttSchema = appConfig?.speech?.stt; diff --git a/api/server/services/Files/Audio/TTSService.js b/api/server/services/Files/Audio/TTSService.js index 1125dd74ed9..80f4239cc64 100644 --- a/api/server/services/Files/Audio/TTSService.js +++ b/api/server/services/Files/Audio/TTSService.js @@ -297,6 +297,7 @@ class TTSService { req.config ?? (await getAppConfig({ role: req.user?.role, + userId: req.user?.id, tenantId: req.user?.tenantId, })); try { @@ -366,6 +367,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/MCP.js b/api/server/services/MCP.js index 9d27734d945..f2e845b927e 100644 --- a/api/server/services/MCP.js +++ b/api/server/services/MCP.js @@ -415,7 +415,11 @@ async function createMCPTools({ 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( @@ -503,7 +507,11 @@ async function createMCPTool({ 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( diff --git a/api/server/services/MCP.spec.js b/api/server/services/MCP.spec.js index 3288aadd906..8b9f055ff88 100644 --- a/api/server/services/MCP.spec.js +++ b/api/server/services/MCP.spec.js @@ -922,8 +922,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 +975,12 @@ 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 skip domain validation for stdio transports (no URL)', async () => { @@ -1047,8 +1055,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 +1112,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', + }); }); }); diff --git a/packages/api/src/endpoints/config/endpoints.spec.ts b/packages/api/src/endpoints/config/endpoints.spec.ts index b01b5c1c500..8d77c829601 100644 --- a/packages/api/src/endpoints/config/endpoints.spec.ts +++ b/packages/api/src/endpoints/config/endpoints.spec.ts @@ -1,17 +1,46 @@ +import { Types } from 'mongoose'; import { AgentCapabilities, EModelEndpoint, + PrincipalType, defaultAgentCapabilities, } from 'librechat-data-provider'; -import { createEndpointsConfigService } from './endpoints'; -import type { AppConfig } from '@librechat/data-schemas'; + +import type { AppConfig, IConfig } from '@librechat/data-schemas'; +import type { AppConfigServiceDeps } from '~/app/service'; import type { EndpointsConfigDeps } from './endpoints'; import type { ServerRequest } from '~/types'; +import { createEndpointsConfigService } from './endpoints'; +import { createAppConfigService } from '~/app/service'; + function appConfig(partial: Record): AppConfig { return partial as unknown as AppConfig; } +function configDoc(partial: Record): IConfig { + return partial as unknown as IConfig; +} + +function createAppConfigCache() { + const store = new Map(); + return { + get: jest.fn((key: string) => Promise.resolve(store.get(key))), + set: jest.fn((key: string, value: unknown) => { + store.set(key, value); + return Promise.resolve(undefined); + }), + delete: jest.fn((key: string) => { + store.delete(key); + return Promise.resolve(true); + }), + }; +} + +type ConfigPrincipals = NonNullable< + Parameters[0] +>; + function createMockDeps(overrides: Partial = {}): EndpointsConfigDeps { return { getAppConfig: jest.fn().mockResolvedValue(appConfig({ endpoints: {} })), @@ -23,7 +52,13 @@ function createMockDeps(overrides: Partial = {}): Endpoints }; } -function fakeReq(overrides: Partial = {}): ServerRequest { +type TestRequestOverrides = { + body?: Partial; + config?: AppConfig; + user?: { id?: string; role?: string; tenantId?: string }; +}; + +function fakeReq(overrides: TestRequestOverrides = {}): ServerRequest { return { user: { id: 'u1', role: 'USER' }, ...overrides } as ServerRequest; } @@ -172,6 +207,83 @@ describe('createEndpointsConfigService', () => { expect(mockGetAppConfig).not.toHaveBeenCalled(); }); + + it('passes userId when resolving scoped endpoint config', async () => { + const mockGetAppConfig = jest.fn().mockResolvedValue(appConfig({ endpoints: {} })); + const deps = createMockDeps({ getAppConfig: mockGetAppConfig }); + const { getEndpointsConfig } = createEndpointsConfigService(deps); + + await getEndpointsConfig( + fakeReq({ user: { id: 'u1', role: 'USER', tenantId: 'tenant-a' } }), + ); + + expect(mockGetAppConfig).toHaveBeenCalledWith({ + role: 'USER', + userId: 'u1', + tenantId: 'tenant-a', + }); + }); + + it('exposes custom endpoints from group-scoped overrides for grouped users', async () => { + const groupId = new Types.ObjectId('6a0aea2172e2d59d4658c9d2'); + const getUserPrincipals = jest.fn().mockResolvedValue([ + { principalType: PrincipalType.ROLE, principalId: 'USER' }, + { principalType: PrincipalType.USER, principalId: 'u1' }, + { principalType: PrincipalType.GROUP, principalId: groupId }, + ]); + const getApplicableConfigs = jest.fn((principals: ConfigPrincipals = []) => + Promise.resolve( + principals.some( + (principal) => + principal.principalType === PrincipalType.GROUP && + String(principal.principalId) === groupId.toString(), + ) + ? [ + configDoc({ + principalType: PrincipalType.GROUP, + principalId: groupId, + priority: 20, + isActive: true, + overrides: { + endpoints: { + custom: [ + { + name: 'FOO', + apiKey: '${FOO_KEY}', + baseURL: '${FOO_URL}', + models: { fetch: true }, + }, + ], + }, + }, + }), + ] + : [], + ), + ); + const { getAppConfig } = createAppConfigService({ + loadBaseConfig: jest.fn().mockResolvedValue(appConfig({ endpoints: {} })), + setCachedTools: jest.fn().mockResolvedValue(undefined), + getCache: jest.fn().mockReturnValue(createAppConfigCache()), + cacheKeys: { APP_CONFIG: 'app_config' }, + getApplicableConfigs, + getUserPrincipals, + }); + const { getEndpointsConfig } = createEndpointsConfigService({ + getAppConfig, + loadDefaultEndpointsConfig: jest.fn().mockResolvedValue({}), + }); + + const result = await getEndpointsConfig(fakeReq({ user: { id: 'u1', role: 'USER' } })); + + expect(getUserPrincipals).toHaveBeenCalledWith({ userId: 'u1', role: 'USER' }); + expect(getApplicableConfigs).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ principalType: PrincipalType.GROUP, principalId: groupId }), + ]), + ); + expect(result?.FOO).toEqual(expect.objectContaining({ type: EModelEndpoint.custom })); + }); }); describe('checkCapability', () => { diff --git a/packages/api/src/endpoints/config/endpoints.ts b/packages/api/src/endpoints/config/endpoints.ts index 1a6b98d195c..0d500219d92 100644 --- a/packages/api/src/endpoints/config/endpoints.ts +++ b/packages/api/src/endpoints/config/endpoints.ts @@ -14,7 +14,11 @@ type DefaultEndpointsResult = Record; export interface EndpointsConfigDeps { - getAppConfig: (params: { role?: string | null; tenantId?: string }) => Promise; + getAppConfig: (params: { + role?: string; + userId?: string; + tenantId?: string; + }) => Promise; loadDefaultEndpointsConfig: (appConfig: AppConfig) => Promise; loadCustomEndpointsConfig?: (custom: unknown) => TCustomEndpointsConfig | undefined; } @@ -28,7 +32,12 @@ export function createEndpointsConfigService(deps: EndpointsConfigDeps) { async function getEndpointsConfig(req: ServerRequest): Promise { 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 defaultEndpointsConfig = await loadDefaultEndpointsConfig(appConfig); const customEndpointsConfig = loadCustomEndpointsConfig(appConfig?.endpoints?.custom); diff --git a/packages/api/src/endpoints/config/models.ts b/packages/api/src/endpoints/config/models.ts index 2d87d5d242a..22c5207b1d6 100644 --- a/packages/api/src/endpoints/config/models.ts +++ b/packages/api/src/endpoints/config/models.ts @@ -22,7 +22,11 @@ interface ResolvedEndpoint { } export interface LoadConfigModelsDeps { - getAppConfig: (params: { role?: string | null; tenantId?: string }) => Promise; + getAppConfig: (params: { + role?: string; + userId?: string; + tenantId?: string; + }) => Promise; getUserKeyValues: GetUserKeyValuesFunction; fetchModels?: (params: FetchModelsParams) => Promise; } @@ -31,10 +35,13 @@ export function createLoadConfigModels(deps: LoadConfigModelsDeps) { const { getAppConfig, getUserKeyValues, fetchModels = defaultFetchModels } = deps; return async function loadConfigModels(req: ServerRequest): Promise { - 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 {}; } diff --git a/packages/api/src/middleware/balance.spec.ts b/packages/api/src/middleware/balance.spec.ts index 8badd2e1068..67ab0fe7f68 100644 --- a/packages/api/src/middleware/balance.spec.ts +++ b/packages/api/src/middleware/balance.spec.ts @@ -381,6 +381,34 @@ describe('createSetBalanceConfig', () => { // lastRefill should have default value from schema expect(balanceRecord?.lastRefill).toBeInstanceOf(Date); }); + + test('should pass user identity when resolving balance config', async () => { + const userId = new mongoose.Types.ObjectId(); + const getAppConfig = jest.fn().mockResolvedValue({ + balance: { enabled: false }, + }); + + const middleware = createSetBalanceConfig({ + getAppConfig, + findBalanceByUser, + upsertBalanceFields, + }); + + const req = createMockRequest(userId); + req.user = { + ...req.user, + role: 'USER', + tenantId: 'tenant-a', + } as typeof req.user; + + await middleware(req as ServerRequest, createMockResponse() as ServerResponse, mockNext); + + expect(getAppConfig).toHaveBeenCalledWith({ + role: 'USER', + userId: userId.toString(), + tenantId: 'tenant-a', + }); + }); }); describe('Update Scenarios', () => { diff --git a/packages/api/src/middleware/balance.ts b/packages/api/src/middleware/balance.ts index 4b746820f43..061fbcbf89a 100644 --- a/packages/api/src/middleware/balance.ts +++ b/packages/api/src/middleware/balance.ts @@ -14,6 +14,7 @@ import { getBalanceConfig } from '~/app/config'; export interface BalanceMiddlewareOptions { getAppConfig: (options?: { role?: string; + userId?: string; tenantId?: string; refresh?: boolean; }) => Promise; @@ -119,6 +120,7 @@ export function createSetBalanceConfig({ const user = req.user as IUser & { _id: string | ObjectId }; const appConfig = await getAppConfig({ role: user?.role, + userId: user?.id, tenantId: user?.tenantId, }); const balanceConfig = getBalanceConfig(appConfig); diff --git a/packages/api/src/middleware/remoteAgentAuth.spec.ts b/packages/api/src/middleware/remoteAgentAuth.spec.ts index 983f7bf706c..96b9f30e940 100644 --- a/packages/api/src/middleware/remoteAgentAuth.spec.ts +++ b/packages/api/src/middleware/remoteAgentAuth.spec.ts @@ -429,7 +429,11 @@ describe('createRemoteAgentAuth', () => { await createRemoteAgentAuth(deps)(req as Request, makeRes().res, mockNext); expect(deps.getAppConfig).toHaveBeenNthCalledWith(1, { baseOnly: true }); - expect(deps.getAppConfig).toHaveBeenNthCalledWith(2, { tenantId: 'tenant-strict' }); + expect(deps.getAppConfig).toHaveBeenNthCalledWith(2, { + role: 'user', + userId: 'uid123', + tenantId: 'tenant-strict', + }); expect(jwt.verify).toHaveBeenCalledTimes(2); expect((jwt.verify as jest.Mock).mock.calls[1][2]).toEqual( expect.objectContaining({ audience: 'tenant-audience' }), @@ -461,7 +465,11 @@ describe('createRemoteAgentAuth', () => { await createRemoteAgentAuth(deps)(req as Request, res, mockNext); expect(deps.getAppConfig).toHaveBeenNthCalledWith(1, { baseOnly: true }); - expect(deps.getAppConfig).toHaveBeenNthCalledWith(2, { tenantId: 'tenant-strict' }); + expect(deps.getAppConfig).toHaveBeenNthCalledWith(2, { + role: 'user', + userId: 'uid123', + tenantId: 'tenant-strict', + }); expect(jwt.verify).toHaveBeenCalledTimes(2); expect(status).toHaveBeenCalledWith(401); expect(json).toHaveBeenCalledWith({ error: 'Unauthorized' }); @@ -493,7 +501,11 @@ describe('createRemoteAgentAuth', () => { await createRemoteAgentAuth(deps)(req as Request, res, mockNext); expect(deps.getAppConfig).toHaveBeenNthCalledWith(1, { baseOnly: true }); - expect(deps.getAppConfig).toHaveBeenNthCalledWith(2, { tenantId: 'tenant-api-key-only' }); + expect(deps.getAppConfig).toHaveBeenNthCalledWith(2, { + role: 'user', + userId: 'uid123', + tenantId: 'tenant-api-key-only', + }); expect(jwt.verify).toHaveBeenCalledTimes(1); expect(status).toHaveBeenCalledWith(401); expect(json).toHaveBeenCalledWith({ error: 'Unauthorized' }); diff --git a/packages/api/src/middleware/remoteAgentAuth.ts b/packages/api/src/middleware/remoteAgentAuth.ts index e51c687c188..2ed7031fe81 100644 --- a/packages/api/src/middleware/remoteAgentAuth.ts +++ b/packages/api/src/middleware/remoteAgentAuth.ts @@ -247,7 +247,7 @@ function getConfigOptions(req: Request): GetAppConfigOptions { } function getUserConfigOptions(user: IUser): GetAppConfigOptions { - if (user.tenantId) return { tenantId: user.tenantId }; + if (user.tenantId) return { role: user.role, userId: user.id, tenantId: user.tenantId }; return { baseOnly: true }; } @@ -255,6 +255,8 @@ function isResolvedUserConfigScope(initialOptions: GetAppConfigOptions, user: IU const userOptions = getUserConfigOptions(user); return ( initialOptions.tenantId === userOptions.tenantId && + initialOptions.userId === userOptions.userId && + initialOptions.role === userOptions.role && initialOptions.baseOnly === userOptions.baseOnly ); } From 77c523ea35e794ef647d00aac8d40f3ed57f3d02 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 18 May 2026 15:27:19 -0400 Subject: [PATCH 025/395] =?UTF-8?q?=F0=9F=A7=BD=20fix:=20Strip=20Admin=20O?= =?UTF-8?q?Auth=20Redirect=20Params=20(#13181)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/api/src/auth/adminPkce.spec.ts | 83 +++++++++++++++++++++---- packages/api/src/auth/exchange.ts | 53 ++++++++++++---- 2 files changed, 113 insertions(+), 23 deletions(-) diff --git a/packages/api/src/auth/adminPkce.spec.ts b/packages/api/src/auth/adminPkce.spec.ts index 906b7d400ba..57d643a61af 100644 --- a/packages/api/src/auth/adminPkce.spec.ts +++ b/packages/api/src/auth/adminPkce.spec.ts @@ -21,6 +21,8 @@ function makeReq(overrides: Partial = {}): PkceStrippable describe('stripCodeChallenge', () => { const challenge = 'a'.repeat(64); + const callbackUrl = 'https://admin.example.com/auth/openid/callback'; + const encodedCallbackUrl = 'https%3A%2F%2Fadmin.example.com%2Fauth%2Fopenid%2Fcallback'; it('removes code_challenge from req.query and both URL strings (sole param)', () => { const req = makeReq({ @@ -36,6 +38,48 @@ describe('stripCodeChallenge', () => { expect(req.url).toBe('/oauth/openid'); }); + it('removes admin-panel-only params before the request reaches Passport', () => { + const originalUrl = + `/api/admin/oauth/openid?code_challenge=${challenge}` + + `&redirect_uri=${encodedCallbackUrl}&redirectTo=%2Fsettings&foo=bar`; + const url = + `/oauth/openid?code_challenge=${challenge}` + + `&redirect_uri=${encodedCallbackUrl}&redirectTo=%2Fsettings&foo=bar`; + const req = makeReq({ + query: { + code_challenge: challenge, + redirect_uri: callbackUrl, + redirectTo: '/settings', + foo: 'bar', + }, + originalUrl, + url, + }); + + stripCodeChallenge(req); + + expect(req.query.code_challenge).toBeUndefined(); + expect(req.query.redirect_uri).toBeUndefined(); + expect(req.query.redirectTo).toBeUndefined(); + expect(req.query.foo).toBe('bar'); + expect(req.originalUrl).toBe('/api/admin/oauth/openid?foo=bar'); + expect(req.url).toBe('/oauth/openid?foo=bar'); + }); + + it('removes redirect_uri when it is the only admin-panel-only param', () => { + const req = makeReq({ + query: { redirect_uri: callbackUrl }, + originalUrl: `/oauth/openid?redirect_uri=${encodedCallbackUrl}`, + url: `/oauth/openid?redirect_uri=${encodedCallbackUrl}`, + }); + + stripCodeChallenge(req); + + expect(req.query.redirect_uri).toBeUndefined(); + expect(req.originalUrl).toBe('/oauth/openid'); + expect(req.url).toBe('/oauth/openid'); + }); + it('preserves other params when code_challenge is last', () => { const req = makeReq({ query: { foo: 'bar', code_challenge: challenge }, @@ -123,14 +167,20 @@ describe('stripCodeChallenge', () => { describe('storeAndStripChallenge', () => { const challenge = 'a'.repeat(64); + const callbackUrl = 'https://admin.example.com/auth/openid/callback'; + const encodedCallbackUrl = 'https%3A%2F%2Fadmin.example.com%2Fauth%2Fopenid%2Fcallback'; it('stores valid challenge in cache and strips from request', async () => { const cache = new Keyv(); const setSpy = jest.spyOn(cache, 'set'); + const url = `/oauth/openid?code_challenge=${challenge}&redirect_uri=${encodedCallbackUrl}`; const req = makeReq({ - query: { code_challenge: challenge }, - originalUrl: `/oauth/openid?code_challenge=${challenge}`, - url: `/oauth/openid?code_challenge=${challenge}`, + query: { + code_challenge: challenge, + redirect_uri: callbackUrl, + }, + originalUrl: url, + url, }); const result = await storeAndStripChallenge(cache, req, 'test-state', 'openid'); @@ -138,6 +188,7 @@ describe('storeAndStripChallenge', () => { expect(result).toBe(true); expect(setSpy).toHaveBeenCalledWith(`pkce:test-state`, challenge, expect.any(Number)); expect(req.query.code_challenge).toBeUndefined(); + expect(req.query.redirect_uri).toBeUndefined(); expect(req.originalUrl).toBe('/oauth/openid'); expect(req.url).toBe('/oauth/openid'); }); @@ -162,10 +213,14 @@ describe('storeAndStripChallenge', () => { it('strips and returns true when code_challenge is invalid (not 64 hex)', async () => { const cache = new Keyv(); const setSpy = jest.spyOn(cache, 'set'); + const url = `/oauth/openid?code_challenge=too-short&redirect_uri=${encodedCallbackUrl}`; const req = makeReq({ - query: { code_challenge: 'too-short' }, - originalUrl: '/oauth/openid?code_challenge=too-short', - url: '/oauth/openid?code_challenge=too-short', + query: { + code_challenge: 'too-short', + redirect_uri: callbackUrl, + }, + originalUrl: url, + url, }); const result = await storeAndStripChallenge(cache, req, 'test-state', 'openid'); @@ -173,6 +228,7 @@ describe('storeAndStripChallenge', () => { expect(result).toBe(true); expect(setSpy).not.toHaveBeenCalled(); expect(req.query.code_challenge).toBeUndefined(); + expect(req.query.redirect_uri).toBeUndefined(); expect(req.originalUrl).toBe('/oauth/openid'); expect(req.url).toBe('/oauth/openid'); }); @@ -180,18 +236,23 @@ describe('storeAndStripChallenge', () => { it('returns false and does not strip on cache failure', async () => { const cache = new Keyv(); jest.spyOn(cache, 'set').mockRejectedValueOnce(new Error('cache down')); + const url = `/oauth/openid?code_challenge=${challenge}&redirect_uri=${encodedCallbackUrl}`; const req = makeReq({ - query: { code_challenge: challenge }, - originalUrl: `/oauth/openid?code_challenge=${challenge}`, - url: `/oauth/openid?code_challenge=${challenge}`, + query: { + code_challenge: challenge, + redirect_uri: callbackUrl, + }, + originalUrl: url, + url, }); const result = await storeAndStripChallenge(cache, req, 'test-state', 'openid'); expect(result).toBe(false); expect(req.query.code_challenge).toBe(challenge); - expect(req.originalUrl).toBe(`/oauth/openid?code_challenge=${challenge}`); - expect(req.url).toBe(`/oauth/openid?code_challenge=${challenge}`); + expect(req.query.redirect_uri).toBe(callbackUrl); + expect(req.originalUrl).toBe(url); + expect(req.url).toBe(url); }); it('reads code_challenge before stripping (ordering guarantee)', async () => { diff --git a/packages/api/src/auth/exchange.ts b/packages/api/src/auth/exchange.ts index 650b006e385..74d882bb099 100644 --- a/packages/api/src/auth/exchange.ts +++ b/packages/api/src/auth/exchange.ts @@ -182,9 +182,36 @@ export const PKCE_CHALLENGE_TTL = 5 * 60 * 1000; /** Regex pattern for valid PKCE challenges: 64 hex characters (SHA-256 hex digest) */ export const PKCE_CHALLENGE_PATTERN = /^[a-f0-9]{64}$/; -/** Removes `code_challenge` from a single URL string, preserving other query params. */ -const stripChallengeFromUrl = (url: string): string => - url.replace(/\?code_challenge=[^&]*&/, '?').replace(/[?&]code_challenge=[^&]*/, ''); +const ADMIN_OAUTH_STRIPPED_QUERY_PARAMS = new Set(['code_challenge', 'redirect_uri', 'redirectTo']); + +const getQueryParamName = (param: string): string => { + const separatorIndex = param.indexOf('='); + return separatorIndex === -1 ? param : param.slice(0, separatorIndex); +}; + +/** Removes admin-panel-only query params from a single URL string. */ +const stripAdminOAuthParamsFromUrl = (url: string): string => { + const hashIndex = url.indexOf('#'); + const urlWithoutHash = hashIndex === -1 ? url : url.slice(0, hashIndex); + const hash = hashIndex === -1 ? '' : url.slice(hashIndex); + const queryIndex = urlWithoutHash.indexOf('?'); + + if (queryIndex === -1) { + return url; + } + + const path = urlWithoutHash.slice(0, queryIndex); + const query = urlWithoutHash.slice(queryIndex + 1); + const params = query.split('&').filter((param) => { + if (!param) { + return false; + } + + return !ADMIN_OAUTH_STRIPPED_QUERY_PARAMS.has(getQueryParamName(param)); + }); + + return params.length > 0 ? `${path}?${params.join('&')}${hash}` : `${path}${hash}`; +}; /** Minimal request shape needed by {@link stripCodeChallenge}. */ export interface PkceStrippableRequest { @@ -194,29 +221,31 @@ export interface PkceStrippableRequest { } /** - * Strips `code_challenge` from the request query and URL strings. + * Strips admin-panel-only params from the request query and URL strings. * * openid-client v6's Passport Strategy uses `currentUrl.searchParams.size === 0` * to distinguish an initial authorization request from an OAuth callback. - * The admin-panel-specific `code_challenge` query parameter would cause the - * strategy to misclassify the request as a callback and return 401. + * Admin-panel-specific query params would cause the strategy to misclassify the + * request as a callback and return 401. * * Applied defensively to all providers to ensure the admin-panel-private - * `code_challenge` parameter never reaches any Passport strategy. + * parameters never reach any Passport strategy. */ export function stripCodeChallenge(req: PkceStrippableRequest): void { delete req.query.code_challenge; - req.originalUrl = stripChallengeFromUrl(req.originalUrl); - req.url = stripChallengeFromUrl(req.url); + delete req.query.redirect_uri; + delete req.query.redirectTo; + req.originalUrl = stripAdminOAuthParamsFromUrl(req.originalUrl); + req.url = stripAdminOAuthParamsFromUrl(req.url); } /** - * Stores the admin-panel PKCE challenge in cache, then strips `code_challenge` - * from the request so it doesn't interfere with the Passport strategy. + * Stores the admin-panel PKCE challenge in cache, then strips admin-panel-only + * params from the request so they don't interfere with the Passport strategy. * * Must be called before `passport.authenticate()` — the two operations are * logically atomic: read the challenge from the query, persist it, then remove - * the parameter from the request URL. + * those parameters from the request URL. * @param cache - The Keyv cache instance for storing PKCE challenges. * @param req - The Express request to read and mutate. * @param state - The OAuth state value (cache key). From 646626349340808aa8b85c5399c4eb4247adc73f Mon Sep 17 00:00:00 2001 From: Gaurav Dubey Date: Tue, 19 May 2026 01:02:43 +0530 Subject: [PATCH 026/395] =?UTF-8?q?=F0=9F=A7=B9=20chore:=20Type=20Agent=20?= =?UTF-8?q?MCP=20lean=20projection=20in=20ServerConfigsDB=20(#13171)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the (a: any) cast and its eslint-disable directive in ServerConfigsDB.getAll() with a precise lean-projection generic. The Mongoose query already projects only mcpServerNames, so we can use Pick and let .lean() carry the shape through without resorting to any. Switches the empty-array guard from || to ?? for clarity; both behave identically for string[] | undefined. Aligns with the project's "Never use any" rule (CLAUDE.md -> Type Safety). No runtime change. --- packages/api/src/mcp/registry/db/ServerConfigsDB.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/packages/api/src/mcp/registry/db/ServerConfigsDB.ts b/packages/api/src/mcp/registry/db/ServerConfigsDB.ts index b1649c66ca5..d027fc273e3 100644 --- a/packages/api/src/mcp/registry/db/ServerConfigsDB.ts +++ b/packages/api/src/mcp/registry/db/ServerConfigsDB.ts @@ -6,7 +6,7 @@ import { PermissionBits, } from 'librechat-data-provider'; import { logger, encryptV2, decryptV2, createMethods } from '@librechat/data-schemas'; -import type { AllMethods, MCPServerDocument } from '@librechat/data-schemas'; +import type { AllMethods, IAgent, MCPServerDocument } from '@librechat/data-schemas'; import type { IServerConfigsRepositoryInterface } from '~/mcp/registry/ServerConfigsRepositoryInterface'; import type { ParsedServerConfig, AddServerResult } from '~/mcp/types'; import { AccessControlService } from '~/acl/accessControlService'; @@ -370,14 +370,9 @@ export class ServerConfigsDB implements IServerConfigsRepositoryInterface { mcpServerNames: { $exists: true, $not: { $size: 0 } }, }, { mcpServerNames: 1 }, - ).lean(); - - agentMCPServerNames = [ - ...new Set( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - agentsWithMCP.flatMap((a: any) => a.mcpServerNames || []), - ), - ]; + ).lean[]>(); + + agentMCPServerNames = [...new Set(agentsWithMCP.flatMap((a) => a.mcpServerNames ?? []))]; } const directResults = await this._dbMethods.getListMCPServersByIds({ From 394839a76bfbeca049cb388d9933cef1bba62280 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 18 May 2026 15:35:29 -0400 Subject: [PATCH 027/395] =?UTF-8?q?=F0=9F=94=8D=20fix:=20Prefer=20LibreCha?= =?UTF-8?q?t=20Web=20Search=20over=20Anthropic's=20when=20Both=20Selected?= =?UTF-8?q?=20(#13166)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/agents/__tests__/initialize.test.ts | 155 +++++++++++++++++- packages/api/src/agents/initialize.ts | 63 ++++++- 2 files changed, 210 insertions(+), 8 deletions(-) diff --git a/packages/api/src/agents/__tests__/initialize.test.ts b/packages/api/src/agents/__tests__/initialize.test.ts index 6636c13af6f..d7fa3960247 100644 --- a/packages/api/src/agents/__tests__/initialize.test.ts +++ b/packages/api/src/agents/__tests__/initialize.test.ts @@ -33,7 +33,7 @@ jest.mock('@librechat/agents', () => ({ })); import { Providers } from '@librechat/agents'; -import { EModelEndpoint } from 'librechat-data-provider'; +import { EModelEndpoint, Tools } from 'librechat-data-provider'; import type { Agent } from 'librechat-data-provider'; import type { ServerRequest, InitializeResultBase, EndpointTokenConfig } from '~/types'; import type { InitializeAgentDbMethods } from '../initialize'; @@ -126,6 +126,13 @@ function createMocks(overrides?: { maxOutputTokens?: number; endpointTokenConfig?: EndpointTokenConfig; useRealTokenLookup?: boolean; + providerTools?: unknown[]; + loadedToolDefinitions?: Array<{ + name: string; + description?: string; + parameters?: object; + }>; + structuredTools?: unknown[]; }) { const { provider = Providers.OPENAI, @@ -136,6 +143,9 @@ function createMocks(overrides?: { maxOutputTokens = 4096, endpointTokenConfig, useRealTokenLookup = false, + providerTools, + loadedToolDefinitions = [], + structuredTools = [], } = overrides ?? {}; const resolvedOverrideProvider = overrideProvider ?? provider; @@ -158,6 +168,7 @@ function createMocks(overrides?: { const mockGetOptions = jest.fn().mockResolvedValue({ llmConfig: { model, maxTokens: maxOutputTokens }, endpointTokenConfig, + ...(providerTools !== undefined ? { tools: providerTools } : {}), } satisfies InitializeResultBase); mockGetProviderConfig.mockReturnValue({ @@ -181,12 +192,12 @@ function createMocks(overrides?: { mockOptionalChainWithEmptyCheck.mockImplementation(realUtils.optionalChainWithEmptyCheck); const loadTools = jest.fn().mockResolvedValue({ - tools: [], + tools: structuredTools, toolContextMap: {}, dynamicToolContextMap: {}, userMCPAuthMap: undefined, toolRegistry: undefined, - toolDefinitions: [], + toolDefinitions: loadedToolDefinitions, hasDeferredTools: false, }); @@ -202,6 +213,27 @@ function createMocks(overrides?: { return { agent, req, res, loadTools, db }; } +function countNamedWebSearchTools(tools: unknown[] | undefined): number { + return ( + tools?.filter((tool) => { + if (tool == null || typeof tool !== 'object') { + return false; + } + const { name } = tool as { name?: unknown }; + return name === Tools.web_search; + }).length ?? 0 + ); +} + +function countWebSearchDefinitions( + toolDefinitions: Array<{ name: string }> | undefined, +): number { + return ( + toolDefinitions?.filter((toolDefinition) => toolDefinition.name === Tools.web_search).length ?? + 0 + ); +} + describe('initializeAgent — custom provider token lookup', () => { const CUSTOM_PROVIDER = 'EduGPT'; @@ -276,6 +308,123 @@ describe('initializeAgent — custom provider token lookup', () => { }); }); +describe('initializeAgent — Anthropic web_search precedence', () => { + const nativeWebSearchTool = { + type: 'web_search_20250305', + name: Tools.web_search, + }; + const libreChatWebSearchDefinition = { + name: Tools.web_search, + description: 'Search the web', + parameters: { type: 'object', properties: {} }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('keeps Anthropic native web_search when LibreChat search is not selected', async () => { + const { agent, req, res, loadTools, db } = createMocks({ + provider: Providers.ANTHROPIC, + providerTools: [nativeWebSearchTool], + }); + + const result = await initializeAgent( + { + req, + res, + agent, + loadTools, + endpointOption: { endpoint: EModelEndpoint.agents }, + allowedProviders: new Set([Providers.ANTHROPIC]), + isInitialAgent: true, + }, + db, + ); + + expect(result.tools).toEqual([nativeWebSearchTool]); + expect(countNamedWebSearchTools(result.tools)).toBe(1); + expect(countWebSearchDefinitions(result.toolDefinitions)).toBe(0); + }); + + it('keeps LibreChat web_search definitions when native Anthropic search is not enabled', async () => { + const { agent, req, res, loadTools, db } = createMocks({ + provider: Providers.ANTHROPIC, + loadedToolDefinitions: [libreChatWebSearchDefinition], + }); + agent.tools = [Tools.web_search]; + + const result = await initializeAgent( + { + req, + res, + agent, + loadTools, + endpointOption: { endpoint: EModelEndpoint.agents }, + allowedProviders: new Set([Providers.ANTHROPIC]), + isInitialAgent: true, + }, + db, + ); + + expect(result.tools).toEqual([]); + expect(countNamedWebSearchTools(result.tools)).toBe(0); + expect(countWebSearchDefinitions(result.toolDefinitions)).toBe(1); + }); + + it('prefers LibreChat web_search when Anthropic native search is also enabled', async () => { + const { agent, req, res, loadTools, db } = createMocks({ + provider: Providers.ANTHROPIC, + providerTools: [nativeWebSearchTool], + loadedToolDefinitions: [libreChatWebSearchDefinition], + }); + agent.tools = [Tools.web_search]; + + const result = await initializeAgent( + { + req, + res, + agent, + loadTools, + endpointOption: { endpoint: EModelEndpoint.agents }, + allowedProviders: new Set([Providers.ANTHROPIC]), + isInitialAgent: true, + }, + db, + ); + + expect(result.tools).toEqual([]); + expect(countNamedWebSearchTools(result.tools)).toBe(0); + expect(countWebSearchDefinitions(result.toolDefinitions)).toBe(1); + }); + + it('leaves non-Anthropic providers unchanged', async () => { + const { agent, req, res, loadTools, db } = createMocks({ + provider: Providers.OPENAI, + providerTools: [nativeWebSearchTool], + loadedToolDefinitions: [libreChatWebSearchDefinition], + }); + agent.tools = [Tools.web_search]; + + const result = await initializeAgent( + { + req, + res, + agent, + loadTools, + endpointOption: { endpoint: EModelEndpoint.agents }, + allowedProviders: new Set([Providers.OPENAI]), + isInitialAgent: true, + }, + db, + ); + + expect(result.tools).toEqual([nativeWebSearchTool]); + expect(countNamedWebSearchTools(result.tools)).toBe(1); + expect(countWebSearchDefinitions(result.toolDefinitions)).toBe(1); + }); +}); + describe('initializeAgent — stable and dynamic instruction fields', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index b4985d356e8..1b668a9eb0a 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -65,6 +65,53 @@ function appendAdditionalInstructions(agent: Agent, text?: string | null): void .join('\n\n'); } +function getToolName(tool: unknown): string | undefined { + if (tool == null || typeof tool !== 'object') { + return undefined; + } + const { name } = tool as { name?: unknown }; + return typeof name === 'string' ? name : undefined; +} + +function hasToolDefinition(toolDefinitions: LCTool[] | undefined, name: string): boolean { + return toolDefinitions?.some((toolDefinition) => toolDefinition.name === name) === true; +} + +function resolveAnthropicToolConflicts({ + provider, + tools, + toolDefinitions, +}: { + provider?: string; + tools?: unknown[]; + toolDefinitions?: LCTool[]; +}): unknown[] | undefined { + if (provider !== Providers.ANTHROPIC || !tools?.length) { + return tools; + } + + if (!hasToolDefinition(toolDefinitions, Tools.web_search)) { + return tools; + } + + let removed = 0; + const resolvedTools = tools.filter((tool) => { + const shouldRemove = getToolName(tool) === Tools.web_search; + if (shouldRemove) { + removed += 1; + } + return !shouldRemove; + }); + + if (removed > 0) { + logger.debug( + `[initializeAgent] Removed ${removed} Anthropic native web_search tool(s); LibreChat web_search is enabled.`, + ); + } + + return resolvedTools; +} + /** * Extended agent type with additional fields needed after initialization */ @@ -825,14 +872,20 @@ export async function initializeAgent( /** Check for tool presence from either full instances or definitions (event-driven mode) */ const hasAgentTools = (structuredTools?.length ?? 0) > 0 || (toolDefinitions?.length ?? 0) > 0; + const providerTools = resolveAnthropicToolConflicts({ + provider: agent.provider, + tools: options.tools, + toolDefinitions, + }); + const hasProviderTools = (providerTools?.length ?? 0) > 0; - let tools: GenericTool[] = options.tools?.length - ? (options.tools as GenericTool[]) + let tools: GenericTool[] = hasProviderTools + ? (providerTools as GenericTool[]) : (structuredTools ?? []); if ( (agent.provider === Providers.GOOGLE || agent.provider === Providers.VERTEXAI) && - options.tools?.length && + hasProviderTools && hasAgentTools ) { throw new Error(`{ "type": "${ErrorTypes.GOOGLE_TOOL_CONFLICT}"}`); @@ -840,10 +893,10 @@ export async function initializeAgent( (agent.provider === Providers.OPENAI || agent.provider === Providers.AZURE || agent.provider === Providers.ANTHROPIC) && - options.tools?.length && + hasProviderTools && structuredTools?.length ) { - tools = structuredTools.concat(options.tools as GenericTool[]); + tools = structuredTools.concat(providerTools as GenericTool[]); } agent.model_parameters = { ...options.llmConfig } as Agent['model_parameters']; From 68eac104adbab4f7d62fb64da7e2bb14d35e3d0b Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 18 May 2026 15:36:22 -0400 Subject: [PATCH 028/395] =?UTF-8?q?=F0=9F=97=82=EF=B8=8F=20fix:=20Scope=20?= =?UTF-8?q?Handoff=20Agent=20Context=20Docs=20(#13167)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Scope agent context docs to handoff agents * fix: Deduplicate scoped request context * refactor: Extract agent attachment helpers --- api/server/controllers/agents/client.js | 30 ++- api/server/controllers/agents/client.test.js | 179 ++++++++++++++++++ .../services/Endpoints/agents/initialize.js | 9 +- .../Endpoints/agents/initialize.spec.js | 21 +- .../src/agents/__tests__/initialize.test.ts | 39 ++++ packages/api/src/agents/attachments.test.ts | 81 ++++++++ packages/api/src/agents/attachments.ts | 112 +++++++++++ packages/api/src/agents/index.ts | 1 + packages/api/src/agents/initialize.ts | 30 ++- packages/api/src/agents/resources.test.ts | 8 + packages/api/src/agents/resources.ts | 33 +++- packages/api/src/files/context.ts | 3 +- 12 files changed, 529 insertions(+), 17 deletions(-) create mode 100644 packages/api/src/agents/attachments.test.ts create mode 100644 packages/api/src/agents/attachments.ts diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 0338918412c..d621b830f57 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -12,6 +12,7 @@ const { resolveHeaders, createSafeUser, initializeAgent, + countTokens, getBalanceConfig, omitTitleOptions, getProviderConfig, @@ -31,6 +32,8 @@ const { hydrateMissingIndexTokenCounts, injectSkillPrimes, isSkillPrimeMessage, + collectFileIds, + buildAgentScopedContext, buildSkillPrimeContentParts, buildInitialToolSessions, } = require('@librechat/api'); @@ -270,10 +273,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 { @@ -402,6 +410,14 @@ class AgentClient extends BaseClient { const sharedRunContext = sharedRunContextParts.join('\n\n'); const memoryAgentEnabled = isMemoryAgentEnabled(this.options.req.config?.memory); + const agentScopedContext = await buildAgentScopedContext({ + agentIds: allAgents.map(({ agentId }) => agentId), + attachmentsByAgentId: this.options.agentContextAttachmentsByAgentId, + sharedRunAttachmentIds, + req: this.options.req, + tokenCountFn: (text) => countTokens(text), + }); + /** Preserve canonical pre-format token counts for all history entering graph formatting */ this.indexTokenCountMap = canonicalTokenCountMap; @@ -439,10 +455,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 +470,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, }); }), diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index 31bd5227d5f..873cba9c582 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -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) => { @@ -1429,6 +1431,183 @@ 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, + }); + + beforeEach(() => { + jest.clearAllMocks(); + mockFormatInstructions.mockResolvedValue(''); + + 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("applies shared request context plus each agent's own context docs only", 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]]); + + await client.buildMessages( + [ + { + messageId: 'msg-1', + parentMessageId: null, + sender: 'User', + text: 'Use the available context.', + isCreatedByUser: true, + }, + ], + 'msg-1', + {}, + ); + + expect(mockAgent.additional_instructions).toContain('Shared request context'); + expect(mockAgent.additional_instructions).toContain('Primary private context'); + expect(mockAgent.additional_instructions).not.toContain('Handoff private context'); + + expect(handoffAgent.additional_instructions).toContain('Shared request context'); + expect(handoffAgent.additional_instructions).toContain('Handoff private context'); + expect(handoffAgent.additional_instructions).not.toContain('Primary private context'); + }); + + 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(); + + await client.buildMessages( + [ + { + messageId: 'msg-1', + parentMessageId: null, + sender: 'User', + text: 'Use the available context.', + isCreatedByUser: true, + }, + ], + 'msg-1', + {}, + ); + + const occurrences = ( + mockAgent.additional_instructions.match(/Shared duplicate context/g) ?? [] + ).length; + expect(occurrences).toBe(1); + }); + + 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; diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index f1db6d325ae..bbbcd535ce9 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -10,6 +10,7 @@ const { getCustomEndpointConfig, discoverConnectedAgents, resolveAgentScopedSkillIds, + buildAgentContextAttachmentsByAgentId, } = require('@librechat/api'); const { ResourceType, @@ -796,6 +797,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 { @@ -851,7 +857,8 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { agent: primaryConfig, spec: endpointOption.spec, iconURL: endpointOption.iconURL, - attachments: primaryConfig.attachments, + attachments: primaryConfig.requestAttachments ?? primaryConfig.attachments, + agentContextAttachmentsByAgentId, endpointType: endpointOption.endpointType, resendFiles: primaryConfig.resendFiles ?? true, maxContextTokens: primaryConfig.maxContextTokens, diff --git a/api/server/services/Endpoints/agents/initialize.spec.js b/api/server/services/Endpoints/agents/initialize.spec.js index 3bf6a67e50c..fd1eb7c897a 100644 --- a/api/server/services/Endpoints/agents/initialize.spec.js +++ b/api/server/services/Endpoints/agents/initialize.spec.js @@ -183,6 +183,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 +199,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 +218,13 @@ 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, + ]); }); }); diff --git a/packages/api/src/agents/__tests__/initialize.test.ts b/packages/api/src/agents/__tests__/initialize.test.ts index d7fa3960247..5b229b1f582 100644 --- a/packages/api/src/agents/__tests__/initialize.test.ts +++ b/packages/api/src/agents/__tests__/initialize.test.ts @@ -502,6 +502,45 @@ describe('initializeAgent — stable and dynamic instruction fields', () => { }); }); +describe('initializeAgent — attachment scoping', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('keeps request attachments separate from agent context attachments', async () => { + const { primeResources } = jest.requireMock('../resources') as { + primeResources: jest.Mock; + }; + const requestFile = { file_id: 'request-file', filename: 'request.txt' }; + const agentContextFile = { file_id: 'agent-context-file', filename: 'agent-context.txt' }; + primeResources.mockResolvedValueOnce({ + attachments: [agentContextFile, requestFile], + requestAttachments: [requestFile], + agentContextAttachments: [agentContextFile], + tool_resources: undefined, + }); + + const { agent, req, res, loadTools, db } = createMocks(); + + const result = await initializeAgent( + { + req, + res, + agent, + loadTools, + endpointOption: { endpoint: EModelEndpoint.agents }, + allowedProviders: new Set([Providers.OPENAI]), + isInitialAgent: true, + }, + db, + ); + + expect(result.attachments).toEqual([agentContextFile, requestFile]); + expect(result.requestAttachments).toEqual([requestFile]); + expect(result.agentContextAttachments).toEqual([agentContextFile]); + }); +}); + describe('initializeAgent — maxContextTokens', () => { beforeEach(() => { jest.clearAllMocks(); diff --git a/packages/api/src/agents/attachments.test.ts b/packages/api/src/agents/attachments.test.ts new file mode 100644 index 00000000000..88bc4d1105d --- /dev/null +++ b/packages/api/src/agents/attachments.test.ts @@ -0,0 +1,81 @@ +import { FileSources } from 'librechat-data-provider'; +import type { IMongoFile } from '@librechat/data-schemas'; +import type { ServerRequest } from '~/types'; +import { + collectFileIds, + buildAgentScopedContext, + getAgentContextAttachments, + buildAgentContextAttachmentsByAgentId, +} from './attachments'; + +const makeTextFile = (file_id: string, filename: string, text: string): IMongoFile => + ({ + file_id, + filename, + text, + source: FileSources.text, + }) as IMongoFile; + +describe('agent attachment helpers', () => { + it('collects file ids from attachment-like files', () => { + const fileIds = collectFileIds([ + { file_id: 'file-1' }, + null, + { file_id: '' }, + { file_id: 'file-2' }, + { file_id: 'file-1' }, + ]); + + expect(Array.from(fileIds)).toEqual(['file-1', 'file-2']); + }); + + it('builds an agent context attachment map from initialized configs', () => { + const file = makeTextFile('context-file', 'context.txt', 'context'); + const attachmentsByAgentId = buildAgentContextAttachmentsByAgentId([ + { id: 'agent-a', agentContextAttachments: [file] }, + { id: 'agent-b', agentContextAttachments: [] }, + { id: null, agentContextAttachments: [file] }, + undefined, + ]); + + expect(attachmentsByAgentId.size).toBe(1); + expect(attachmentsByAgentId.get('agent-a')).toEqual([file]); + }); + + it('filters shared request files out of scoped context attachments', () => { + const shared = makeTextFile('shared-file', 'shared.txt', 'shared'); + const scoped = makeTextFile('scoped-file', 'scoped.txt', 'scoped'); + + const attachments = getAgentContextAttachments({ + agentId: 'agent-a', + attachmentsByAgentId: new Map([['agent-a', [shared, scoped]]]), + excludeFileIds: new Set(['shared-file']), + }); + + expect(attachments).toEqual([scoped]); + }); + + it('builds scoped context only from non-shared context documents', async () => { + const shared = makeTextFile('shared-file', 'shared.txt', 'Shared duplicate context'); + const scoped = makeTextFile('scoped-file', 'scoped.txt', 'Scoped private context'); + const req = { + body: { fileTokenLimit: 1000 }, + config: {}, + } as ServerRequest; + + const scopedContext = await buildAgentScopedContext({ + agentIds: ['agent-a', 'agent-b'], + attachmentsByAgentId: new Map([ + ['agent-a', [shared, scoped]], + ['agent-b', [shared]], + ]), + sharedRunAttachmentIds: new Set(['shared-file']), + req, + tokenCountFn: (text) => text.length, + }); + + expect(scopedContext.get('agent-a')).toContain('Scoped private context'); + expect(scopedContext.get('agent-a')).not.toContain('Shared duplicate context'); + expect(scopedContext.has('agent-b')).toBe(false); + }); +}); diff --git a/packages/api/src/agents/attachments.ts b/packages/api/src/agents/attachments.ts new file mode 100644 index 00000000000..4513d5b6e0c --- /dev/null +++ b/packages/api/src/agents/attachments.ts @@ -0,0 +1,112 @@ +import type { IMongoFile } from '@librechat/data-schemas'; +import type { ServerRequest } from '~/types'; +import type { TokenCountFn } from '~/utils/text'; +import { countTokens } from '~/utils/tokenizer'; +import { extractFileContext } from '~/files'; + +type FileWithId = { + file_id?: string | null; +}; + +export type AgentContextAttachmentCarrier = { + id?: string | null; + agentContextAttachments?: TFile[] | null; +}; + +export type AgentContextAttachmentsByAgentId = + | Map + | Record + | null + | undefined; + +export function collectFileIds( + files?: Array | null, +): Set { + const fileIds = new Set(); + for (const file of files ?? []) { + if (file?.file_id) { + fileIds.add(file.file_id); + } + } + return fileIds; +} + +export function buildAgentContextAttachmentsByAgentId( + configs: Iterable | null | undefined>, +): Map { + const attachmentsByAgentId = new Map(); + + for (const config of configs) { + if (!config?.id || !Array.isArray(config.agentContextAttachments)) { + continue; + } + if (config.agentContextAttachments.length === 0) { + continue; + } + attachmentsByAgentId.set(config.id, config.agentContextAttachments); + } + + return attachmentsByAgentId; +} + +export function getAgentContextAttachments({ + agentId, + attachmentsByAgentId, + excludeFileIds, +}: { + agentId: string; + attachmentsByAgentId: AgentContextAttachmentsByAgentId; + excludeFileIds?: Set; +}): TFile[] { + if (!attachmentsByAgentId) { + return []; + } + + const attachments: TFile[] = + attachmentsByAgentId instanceof Map + ? (attachmentsByAgentId.get(agentId) ?? []) + : (attachmentsByAgentId[agentId] ?? []); + + if (!excludeFileIds || excludeFileIds.size === 0) { + return attachments; + } + + return attachments.filter((file) => !file?.file_id || !excludeFileIds.has(file.file_id)); +} + +export async function buildAgentScopedContext({ + agentIds, + attachmentsByAgentId, + sharedRunAttachmentIds, + req, + tokenCountFn = countTokens, +}: { + agentIds: string[]; + attachmentsByAgentId: AgentContextAttachmentsByAgentId; + sharedRunAttachmentIds?: Set; + req?: ServerRequest; + tokenCountFn?: TokenCountFn; +}): Promise> { + const uniqueAgentIds = Array.from(new Set(agentIds.filter(Boolean))); + const entries = await Promise.all( + uniqueAgentIds.map(async (agentId) => { + const attachments = getAgentContextAttachments({ + agentId, + attachmentsByAgentId, + excludeFileIds: sharedRunAttachmentIds, + }); + if (attachments.length === 0) { + return [agentId, ''] as const; + } + + const context = await extractFileContext({ + attachments, + req, + tokenCountFn, + }); + return [agentId, context ?? ''] as const; + }), + ); + + return new Map(entries.filter(([, context]) => Boolean(context))); +} diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index 1a6ae696a03..31d2485a01a 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -1,4 +1,5 @@ export * from './avatars'; +export * from './attachments'; export * from './chain'; export * from './client'; export * from './config'; diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index 1b668a9eb0a..cfe32fedd3b 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -117,7 +117,12 @@ function resolveAnthropicToolConflicts({ */ export type InitializedAgent = Agent & { tools: GenericTool[]; + /** @deprecated use requestAttachments or agentContextAttachments based on sharing semantics. */ attachments: IMongoFile[]; + /** Files attached to the current user message/run and safe to share across run agents. */ + requestAttachments: IMongoFile[]; + /** Files attached to this agent's permanent context via tool_resources. */ + agentContextAttachments: IMongoFile[]; toolContextMap: Record; dynamicToolContextMap?: Record; maxContextTokens: number; @@ -535,7 +540,12 @@ export async function initializeAgent( }); } - const { attachments: primedAttachments, tool_resources } = await primeResources({ + const { + attachments: primedAttachments, + requestAttachments: primedRequestAttachments, + agentContextAttachments: primedAgentContextAttachments, + tool_resources, + } = await primeResources({ req: req as never, getFiles: db.getFiles as never, filterFiles: db.filterFilesByAgentAccess, @@ -959,9 +969,17 @@ export async function initializeAgent( const maxOutputTokensNum = Number(maxOutputTokens) || 0; const baseContextTokens = Math.max(0, agentMaxContextNum - maxOutputTokensNum); - const finalAttachments: IMongoFile[] = (primedAttachments ?? []) - .filter((a): a is TFile => a != null) - .map((a) => a as unknown as IMongoFile); + const toMongoFiles = (files: Array | undefined): IMongoFile[] => + (files ?? []).filter((a): a is TFile => a != null).map((a) => a as unknown as IMongoFile); + + const finalAttachments: IMongoFile[] = toMongoFiles(primedAttachments); + const requestAttachments: IMongoFile[] = toMongoFiles(primedRequestAttachments); + const agentContextAttachments: IMongoFile[] = toMongoFiles(primedAgentContextAttachments); + + const compatibilityAttachments = + finalAttachments.length > 0 + ? finalAttachments + : requestAttachments.concat(agentContextAttachments); const endpointConfigs = req.config?.endpoints; const providerConfig = @@ -992,7 +1010,9 @@ export async function initializeAgent( activeSkillNames, manualSkillPrimes, alwaysApplySkillPrimes, - attachments: finalAttachments, + attachments: compatibilityAttachments, + requestAttachments, + agentContextAttachments, toolContextMap: toolContextMap ?? {}, dynamicToolContextMap: dynamicToolContextMap ?? {}, useLegacyContent: !!options.useLegacyContent, diff --git a/packages/api/src/agents/resources.test.ts b/packages/api/src/agents/resources.test.ts index 718e5cbdec7..04849a51774 100644 --- a/packages/api/src/agents/resources.test.ts +++ b/packages/api/src/agents/resources.test.ts @@ -84,6 +84,8 @@ describe('primeResources', () => { agentId: 'agent_test', }); expect(result.attachments).toEqual(mockOcrFiles); + expect(result.agentContextAttachments).toEqual(mockOcrFiles); + expect(result.requestAttachments).toBeUndefined(); expect(result.tool_resources).toEqual({}); }); }); @@ -423,6 +425,8 @@ describe('primeResources', () => { expect(result.attachments).toHaveLength(2); expect(result.attachments?.[0]?.file_id).toBe('ocr-file-1'); expect(result.attachments?.[1]?.file_id).toBe('file1'); + expect(result.agentContextAttachments).toEqual(mockOcrFiles); + expect(result.requestAttachments).toEqual(mockAttachmentFiles); }); it('should include both context (as `ocr` resource) files and attachment files', async () => { @@ -475,6 +479,8 @@ describe('primeResources', () => { expect(result.attachments).toHaveLength(2); expect(result.attachments?.[0]?.file_id).toBe('ocr-file-1'); expect(result.attachments?.[1]?.file_id).toBe('file1'); + expect(result.agentContextAttachments).toEqual(mockOcrFiles); + expect(result.requestAttachments).toEqual(mockAttachmentFiles); }); it('should prevent duplicate files when same file exists in context tool_resource and attachments', async () => { @@ -528,6 +534,8 @@ describe('primeResources', () => { expect(result.attachments).toHaveLength(2); expect(result.attachments?.filter((f) => f?.file_id === 'shared-file-id')).toHaveLength(1); expect(result.attachments?.find((f) => f?.file_id === 'unique-file')).toBeDefined(); + expect(result.agentContextAttachments).toEqual(mockOcrFiles); + expect(result.requestAttachments).toEqual(mockAttachmentFiles); }); it('should still categorize duplicate files for tool_resources', async () => { diff --git a/packages/api/src/agents/resources.ts b/packages/api/src/agents/resources.ts index 56cd9b6c0d4..47239e54bbf 100644 --- a/packages/api/src/agents/resources.ts +++ b/packages/api/src/agents/resources.ts @@ -174,8 +174,12 @@ export const primeResources = async ({ agentId?: string; }): Promise<{ attachments: Array | undefined; + requestAttachments: Array | undefined; + agentContextAttachments: Array | undefined; tool_resources: AgentToolResources | undefined; }> => { + const requestAttachments: Array = []; + const agentContextAttachments: Array = []; try { /** * Array to collect all unique files that will be returned as attachments @@ -269,6 +273,7 @@ export const primeResources = async ({ // Add to attachments attachments.push(file); + agentContextAttachments.push(file); attachmentFileIds.add(file.file_id); // Categorize for tool resources @@ -282,10 +287,17 @@ export const primeResources = async ({ } if (!_attachments) { - return { attachments: attachments.length > 0 ? attachments : undefined, tool_resources }; + return { + attachments: attachments.length > 0 ? attachments : undefined, + requestAttachments: undefined, + agentContextAttachments: + agentContextAttachments.length > 0 ? agentContextAttachments : undefined, + tool_resources, + }; } const files = await _attachments; + const requestAttachmentFileIds = new Set(); for (const file of files) { if (!file) { @@ -300,16 +312,30 @@ export const primeResources = async ({ }); if (file.file_id && attachmentFileIds.has(file.file_id)) { + if (!requestAttachmentFileIds.has(file.file_id)) { + requestAttachments.push(file); + requestAttachmentFileIds.add(file.file_id); + } continue; } attachments.push(file); + if (!file.file_id || !requestAttachmentFileIds.has(file.file_id)) { + requestAttachments.push(file); + } if (file.file_id) { attachmentFileIds.add(file.file_id); + requestAttachmentFileIds.add(file.file_id); } } - return { attachments: attachments.length > 0 ? attachments : [], tool_resources }; + return { + attachments: attachments.length > 0 ? attachments : [], + requestAttachments, + agentContextAttachments: + agentContextAttachments.length > 0 ? agentContextAttachments : undefined, + tool_resources, + }; } catch (error) { logger.error('Error priming resources', error); @@ -328,6 +354,9 @@ export const primeResources = async ({ return { attachments: safeAttachments, + requestAttachments: safeAttachments, + agentContextAttachments: + agentContextAttachments.length > 0 ? agentContextAttachments : undefined, tool_resources: _tool_resources, }; } diff --git a/packages/api/src/files/context.ts b/packages/api/src/files/context.ts index 1da6c387ce0..36209f34c11 100644 --- a/packages/api/src/files/context.ts +++ b/packages/api/src/files/context.ts @@ -3,6 +3,7 @@ import { FileSources, mergeFileConfig } from 'librechat-data-provider'; import type { IMongoFile } from '@librechat/data-schemas'; import type { ServerRequest } from '~/types'; import { processTextWithTokenLimit } from '~/utils/text'; +import type { TokenCountFn } from '~/utils/text'; /** * Extracts text context from attachments and returns formatted text. @@ -20,7 +21,7 @@ export async function extractFileContext({ }: { attachments: IMongoFile[]; req?: ServerRequest; - tokenCountFn: (text: string) => number; + tokenCountFn: TokenCountFn; }): Promise { if (!attachments || attachments.length === 0) { return undefined; From 21574f02cab665447e025d68cf2ec519b9e36be7 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 18 May 2026 16:55:25 -0400 Subject: [PATCH 029/395] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20chore:=20Harden?= =?UTF-8?q?=20CI=20Supply=20Chain=20Workflows=20(#13090)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: harden CI supply chain workflows * chore: address CI hardening review feedback * chore: tighten GitNexus dispatch hardening * chore: use app token for Locize PR automation * chore: use dedicated token for Locize PR automation --- .github/workflows/a11y.yml | 4 + .github/workflows/backend-review.yml | 3 + .github/workflows/build.yml | 15 ++-- .github/workflows/cache-integration-tests.yml | 3 + .github/workflows/client.yml | 62 ++++++++++---- .github/workflows/data-provider.yml | 42 ++++++--- .github/workflows/data-schemas.yml | 62 ++++++++++---- .github/workflows/deploy-dev.yml | 5 +- .github/workflows/deploy.yml | 5 +- .github/workflows/dev-branch-images.yml | 6 +- .github/workflows/dev-images.yml | 6 +- .github/workflows/dev-staging-images.yml | 7 +- .github/workflows/frontend-review.yml | 3 + .github/workflows/generate_embeddings.yml | 7 +- .github/workflows/gitnexus-index.yml | 85 +++++++++++++------ .github/workflows/gitnexus-pr-command.yml | 26 +++++- .github/workflows/helmcharts.yml | 9 +- .github/workflows/i18n-unused-keys.yml | 5 +- .github/workflows/locize-i18n-sync.yml | 25 ++++-- .github/workflows/main-image-workflow.yml | 19 ++++- .github/workflows/tag-images.yml | 53 ++++++++++-- .github/workflows/unused-packages.yml | 3 +- 22 files changed, 347 insertions(+), 108 deletions(-) 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..1de3a68f842 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 }}' 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..c8f10f388bf 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 diff --git a/.github/workflows/client.yml b/.github/workflows/client.yml index e3e3e445e46..881efa0ebea 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 - + - 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: '20.x' + 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/data-provider.yml b/.github/workflows/data-provider.yml index 9a514b00762..3a9db4d8e56 100644 --- a/.github/workflows/data-provider.yml +++ b/.github/workflows/data-provider.yml @@ -14,11 +14,10 @@ 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 @@ -27,21 +26,42 @@ jobs: node-version: 20 - 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 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..977a6eb4c33 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 - + - 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: '20.x' + 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..464f6ce55a6 100644 --- a/.github/workflows/dev-branch-images.yml +++ b/.github/workflows/dev-branch-images.yml @@ -10,6 +10,10 @@ on: - 'client/**' - 'packages/**' +permissions: + contents: read + packages: write + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true @@ -43,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 }} diff --git a/.github/workflows/dev-images.yml b/.github/workflows/dev-images.yml index a6417556aa6..a9fbef89293 100644 --- a/.github/workflows/dev-images.yml +++ b/.github/workflows/dev-images.yml @@ -10,6 +10,10 @@ on: - 'client/**' - 'packages/**' +permissions: + contents: read + packages: write + jobs: build: runs-on: ubuntu-latest @@ -39,7 +43,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 }} diff --git a/.github/workflows/dev-staging-images.yml b/.github/workflows/dev-staging-images.yml index e63dc5f0af0..7bb06e5298d 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 }} @@ -63,4 +67,3 @@ jobs: ${{ secrets.DOCKERHUB_USERNAME }}/${{ matrix.image_name }}:latest platforms: linux/amd64,linux/arm64 target: ${{ matrix.target }} - diff --git a/.github/workflows/frontend-review.yml b/.github/workflows/frontend-review.yml index 05b3f4154f4..0021124192e 100644 --- a/.github/workflows/frontend-review.yml +++ b/.github/workflows/frontend-review.yml @@ -6,6 +6,9 @@ on: - 'client/**' - 'packages/data-provider/**' +permissions: + contents: read + env: NODE_OPTIONS: '--max-old-space-size=${{ secrets.NODE_MAX_OLD_SPACE_SIZE || 6144 }}' 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-index.yml b/.github/workflows/gitnexus-index.yml index ac7de2973b4..d3b8ca95e22 100644 --- a/.github/workflows/gitnexus-index.yml +++ b/.github/workflows/gitnexus-index.yml @@ -25,9 +25,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 @@ -61,10 +65,35 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 25 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 +108,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 @@ -145,13 +173,15 @@ 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 - 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 NPM_CONFIG_AUDIT: false NPM_CONFIG_CACHE: ${{ runner.temp }}/gitnexus-npm-cache @@ -163,10 +193,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,27 +236,25 @@ 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). Command-triggered + # index runs opt into a deploy by setting deploy_after=true. + - name: Trigger deploy workflow after command-triggered runs + if: inputs.deploy_after && needs.index.result == 'success' uses: actions/github-script@v7 + env: + PR_NUMBER: ${{ inputs.pr_number }} with: script: | - core.info('Triggering actor is github-actions[bot]; workflow_run would not fire. Dispatching gitnexus-deploy.yml manually.'); + core.info('deploy_after=true; dispatching gitnexus-deploy.yml manually.'); // Pass pr_number through so the deploy workflow knows which // PR to post its completion comment on (for /gitnexus // command runs this will be set; for other bot dispatches @@ -237,7 +265,7 @@ jobs: workflow_id: 'gitnexus-deploy.yml', ref: 'main', inputs: { - pr_number: '${{ inputs.pr_number }}', + pr_number: process.env.PR_NUMBER || '', }, }); @@ -249,19 +277,28 @@ jobs: - 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' + indexSucceeded ? '⏳ Waiting for deploy to serve the fresh index…' : '_Index run failed — the previous index (if any) continues to be served._', ].join('\n'); 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 b76f0887760..9e08c189a71 100644 --- a/.github/workflows/helmcharts.yml +++ b/.github/workflows/helmcharts.yml @@ -40,13 +40,14 @@ jobs: env: REF_NAME: ${{ github.ref_name }} run: | + set -euo pipefail CHART_VERSION="${REF_NAME#chart-}" - SEMVER_REGEX='^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?$' - if [[ ! "$CHART_VERSION" =~ $SEMVER_REGEX ]]; then - echo "Invalid chart version: $CHART_VERSION" >&2 + SEMVER_REGEX='^[0-9]+[.][0-9]+[.][0-9]+(-[0-9A-Za-z.-]+)?([+][0-9A-Za-z.-]+)?$' + if [[ "$REF_NAME" != chart-* || ! "$CHART_VERSION" =~ $SEMVER_REGEX ]]; then + echo "::error::Chart tags must use the form chart-, for example chart-2.0.3" exit 1 fi - echo "CHART_VERSION=${CHART_VERSION}" >> "$GITHUB_OUTPUT" + printf 'CHART_VERSION=%s\n' "$CHART_VERSION" >> "$GITHUB_OUTPUT" # Log in to GitHub Container Registry - name: Log in to GitHub Container Registry 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..a745414460b 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,6 +16,8 @@ jobs: steps: - name: Checkout Repository uses: actions/checkout@v4 + with: + persist-credentials: false - name: Set Up Node.js uses: actions/setup-node@v4 @@ -25,9 +30,12 @@ jobs: # 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 --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,12 +62,14 @@ 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 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 @@ -69,4 +80,4 @@ jobs: - 🎯 **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" diff --git a/.github/workflows/main-image-workflow.yml b/.github/workflows/main-image-workflow.yml index 43c9d957534..348012de223 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,20 @@ 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" # Set up QEMU - name: Set up QEMU @@ -35,7 +48,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 }} diff --git a/.github/workflows/tag-images.yml b/.github/workflows/tag-images.yml index e90f43978ab..4477a89c130 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,26 @@ jobs: run: | cp .env.example .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 + { + 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 +101,6 @@ 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 }} diff --git a/.github/workflows/unused-packages.yml b/.github/workflows/unused-packages.yml index f67c1d23be9..a957bed2e8d 100644 --- a/.github/workflows/unused-packages.yml +++ b/.github/workflows/unused-packages.yml @@ -14,6 +14,7 @@ jobs: detect-unused-packages: runs-on: ubuntu-latest permissions: + contents: read pull-requests: write steps: @@ -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 }} From 5b66196f5839e304699f0b54d76bf631a406dad5 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 18 May 2026 17:34:30 -0400 Subject: [PATCH 030/395] =?UTF-8?q?=F0=9F=AA=AA=20fix:=20Scope=20Message?= =?UTF-8?q?=20Conversation=20Access=20(#13183)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Scope message conversation access * style: Format message route query --- api/app/clients/BaseClient.js | 2 +- api/app/clients/specs/BaseClient.test.js | 25 +++++- .../__tests__/validateMessageReq.spec.js | 74 ++++++++++++++++ api/server/middleware/validateMessageReq.js | 20 +++-- .../routes/__tests__/messages-delete.spec.js | 85 +++++++++++++++++++ api/server/routes/messages.js | 11 ++- api/server/services/Threads/manage.js | 2 +- .../src/methods/conversation.spec.ts | 2 +- .../data-schemas/src/methods/conversation.ts | 2 +- 9 files changed, 208 insertions(+), 15 deletions(-) create mode 100644 api/server/middleware/__tests__/validateMessageReq.spec.js diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index e641017c74f..d36e54f0157 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -707,7 +707,7 @@ class BaseClient { 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 []; diff --git a/api/app/clients/specs/BaseClient.test.js b/api/app/clients/specs/BaseClient.test.js index eb6ae656e99..1e8fc13ba2d 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, 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. diff --git a/api/server/middleware/__tests__/validateMessageReq.spec.js b/api/server/middleware/__tests__/validateMessageReq.spec.js new file mode 100644 index 00000000000..861fe969c24 --- /dev/null +++ b/api/server/middleware/__tests__/validateMessageReq.spec.js @@ -0,0 +1,74 @@ +jest.mock('~/models', () => ({ + getConvo: jest.fn(), +})); + +const validateMessageReq = require('../validateMessageReq'); +const { getConvo } = require('~/models'); + +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); + }); +}); diff --git a/api/server/middleware/validateMessageReq.js b/api/server/middleware/validateMessageReq.js index 430444a1727..2e3ed56bcd5 100644 --- a/api/server/middleware/validateMessageReq.js +++ b/api/server/middleware/validateMessageReq.js @@ -2,16 +2,26 @@ const { getConvo } = require('~/models'); // 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 ( + (paramConversationId && + ((bodyConversationId && paramConversationId !== bodyConversationId) || + (nestedConversationId && paramConversationId !== nestedConversationId))) || + (bodyConversationId && nestedConversationId && bodyConversationId !== nestedConversationId) + ) { + return res.status(400).json({ error: 'Conversation ID mismatch' }); + } + + const conversationId = paramConversationId || bodyConversationId || nestedConversationId; if (conversationId === 'new') { return res.status(200).send([]); } - if (!conversationId && req.body.message) { - conversationId = req.body.message.conversationId; - } - const conversation = await getConvo(req.user.id, conversationId); if (!conversation) { 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/messages.js b/api/server/routes/messages.js index 21b2b23feaf..a07293c0e2c 100644 --- a/api/server/routes/messages.js +++ b/api/server/routes/messages.js @@ -269,7 +269,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 +279,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 +304,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 +334,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' }); 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/packages/data-schemas/src/methods/conversation.spec.ts b/packages/data-schemas/src/methods/conversation.spec.ts index 0b6e0857cdc..166e5b51c89 100644 --- a/packages/data-schemas/src/methods/conversation.spec.ts +++ b/packages/data-schemas/src/methods/conversation.spec.ts @@ -136,7 +136,7 @@ describe('Conversation Operations', () => { // Verify that getMessages was called with correct parameters expect(getMessages).toHaveBeenCalledWith( - { conversationId: mockConversationData.conversationId }, + { conversationId: mockConversationData.conversationId, user: mockCtx.userId }, '_id', ); }); diff --git a/packages/data-schemas/src/methods/conversation.ts b/packages/data-schemas/src/methods/conversation.ts index f02ea8dbe0c..82c22f3947b 100644 --- a/packages/data-schemas/src/methods/conversation.ts +++ b/packages/data-schemas/src/methods/conversation.ts @@ -178,7 +178,7 @@ export function createConversationMethods( logger.debug(`[saveConvo] ${metadata.context}`); } - const messages = await getMessages({ conversationId }, '_id'); + const messages = await getMessages({ conversationId, user: userId }, '_id'); const update: Record = { ...convo, messages, user: userId }; if (newConversationId) { From d80f7f030e8d6b5783ab2668c60ced0e7ca46057 Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Mon, 18 May 2026 16:26:50 -0700 Subject: [PATCH 031/395] =?UTF-8?q?=F0=9F=95=B5=F0=9F=8F=BB=20ci:=20Improv?= =?UTF-8?q?e=20Flaky=20Subagents=20Test=20(#13185)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Parts/__tests__/SubagentCall.test.tsx | 51 ++++++++++--------- 1 file changed, 26 insertions(+), 25 deletions(-) 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 38a82682a2c..f78f71d75b3 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 @@ -83,22 +83,19 @@ jest.mock('../Attachment', () => ({ ), })); -jest.mock( - '@librechat/client', - () => ({ - OGDialog: ({ children }: { children: React.ReactNode }) => <>{children}, - OGDialogContent: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - OGDialogTitle: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - OGDialogDescription: ({ children }: { children: React.ReactNode }) => ( -
{children}
- ), - }), - { virtual: true }, -); +jest.mock('@librechat/client', () => ({ + __esModule: true, + OGDialog: ({ children }: { children: React.ReactNode }) => <>{children}, + OGDialogContent: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + OGDialogTitle: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + OGDialogDescription: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), +})); jest.mock('lucide-react', () => ({ // eslint-disable-next-line i18next/no-literal-string @@ -246,6 +243,13 @@ function renderWithState(args: { 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', () => { it('renders "Running agent" while streaming and no terminal envelope has arrived', () => { renderWithState({ @@ -519,6 +523,7 @@ describe('SubagentCall — dialog content', () => { , ); + openSubagentDialog(); expect(screen.getByTestId('prompt-markdown')).toHaveTextContent('# Review prompt'); expect(screen.getByText('final answer')).toBeInTheDocument(); @@ -594,8 +599,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'); @@ -603,15 +607,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(); }); @@ -661,6 +660,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'); @@ -694,6 +694,7 @@ describe('SubagentCall — dialog content', () => { /> , ); + openSubagentDialog(); expect(screen.getByText('Persisted answer.')).toBeInTheDocument(); }); From fdffa9ac96d44293a52e5eb6b6b913bd7f4a1db3 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 18 May 2026 19:34:10 -0400 Subject: [PATCH 032/395] =?UTF-8?q?=F0=9F=93=A6=20chore:=20npm=20audit=20f?= =?UTF-8?q?ix,=20bump=20otel=20&=20`@librechat/agents`=20(#13186)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 📦 chore: npm audit fix 2026-05-18 - Added @js-sdsl/ordered-map version 4.4.2 - Updated @librechat/agents to version 3.1.87 - Upgraded @opentelemetry/sdk-node to version 0.218.0 - Added new dependencies for gRPC and OpenTelemetry exporters * 🔧 chore: Update @librechat/agents to version 3.1.87 in package-lock.json and package.json files * 🔧 chore: Upgrade @opentelemetry/sdk-node to version 0.218.0 in package.json and package-lock.json --- api/package.json | 4 +- package-lock.json | 2084 ++++++------------------------------- packages/api/package.json | 4 +- 3 files changed, 330 insertions(+), 1762 deletions(-) diff --git a/api/package.json b/api/package.json index 8b3ff32a30a..ac86606ffdb 100644 --- a/api/package.json +++ b/api/package.json @@ -46,7 +46,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.0.1", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.1.86", + "@librechat/agents": "^3.1.87", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -60,7 +60,7 @@ "@opentelemetry/instrumentation-mongoose": "^0.54.0", "@opentelemetry/instrumentation-undici": "^0.18.0", "@opentelemetry/resources": "^2.6.1", - "@opentelemetry/sdk-node": "^0.207.0", + "@opentelemetry/sdk-node": "^0.218.0", "@opentelemetry/semantic-conventions": "^1.39.0", "@smithy/node-http-handler": "^4.4.5", "ai-tokenizer": "^1.0.6", diff --git a/package-lock.json b/package-lock.json index c25619187e8..01bedbc0085 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,7 +61,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.0.1", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.1.86", + "@librechat/agents": "^3.1.87", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -75,7 +75,7 @@ "@opentelemetry/instrumentation-mongoose": "^0.54.0", "@opentelemetry/instrumentation-undici": "^0.18.0", "@opentelemetry/resources": "^2.6.1", - "@opentelemetry/sdk-node": "^0.207.0", + "@opentelemetry/sdk-node": "^0.218.0", "@opentelemetry/semantic-conventions": "^1.39.0", "@smithy/node-http-handler": "^4.4.5", "ai-tokenizer": "^1.0.6", @@ -11642,6 +11642,16 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-sdsl/ordered-map": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@js-sdsl/ordered-map/-/ordered-map-4.4.2.tgz", + "integrity": "sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/js-sdsl" + } + }, "node_modules/@keyv/redis": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@keyv/redis/-/redis-4.3.3.tgz", @@ -12211,9 +12221,9 @@ } }, "node_modules/@librechat/agents": { - "version": "3.1.86", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.86.tgz", - "integrity": "sha512-cUr9AQxyWRr4NUh4p2Ynei/6SN96wdjwGp//XOf6I9TDNKhlavVdeKcOuTmLfs6Pzs7YDmbdYjYPY72SIiAiTw==", + "version": "3.1.87", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.87.tgz", + "integrity": "sha512-PPtIjPD2Px84sbQj5kuFxg7/OsDfSrzgmwn5g01XM0Y1O3HLEuK3ynPI1hDB3L++Kt1d2vihLToybMC3L8Y6Tw==", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.92.0", @@ -12234,7 +12244,7 @@ "@langfuse/langchain": "^4.3.0", "@langfuse/otel": "^4.3.0", "@langfuse/tracing": "^4.3.0", - "@opentelemetry/sdk-node": "^0.207.0", + "@opentelemetry/sdk-node": "^0.218.0", "@scarf/scarf": "^1.4.0", "@types/diff": "^7.0.2", "ai-tokenizer": "^1.0.6", @@ -12847,11 +12857,10 @@ } }, "node_modules/@opentelemetry/api-logs": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.214.0.tgz", - "integrity": "sha512-40lSJeqYO8Uz2Yj7u94/SJWE/wONa7rmMKjI1ZcIjgf3MHNHv1OZUCrCETGuaRF62d5pQD1wKIW+L4lmSMTzZA==", + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.218.0.tgz", + "integrity": "sha512-fmEWp5kXlGEc3i/lR698Hz41DfGyN4Tbe4g7L1AxSc7fF8Xeh/FQ9Quqpa9dVA413Q1Ad43QOLzU4JoXgbFPWw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/api": "^1.3.0" }, @@ -12859,69 +12868,38 @@ "node": ">=8.0.0" } }, - "node_modules/@opentelemetry/context-async-hooks": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.2.0.tgz", - "integrity": "sha512-qRkLWiUEZNAmYapZ7KGS5C4OmBLcP/H2foXeOEaowYCR0wi89fHejrfYfbuLVCMLp/dWZXKvQusdbUEZjERfwQ==", - "license": "Apache-2.0", - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/core": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.6.1.tgz", - "integrity": "sha512-8xHSGWpJP9wBxgBpnqGL0R3PbdWQndL1Qp50qrg71+B28zK5OQmUgcDKLJgzyAAV38t4tOyLMGDD60LneR5W8g==", + "node_modules/@opentelemetry/configuration": { + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/configuration/-/configuration-0.218.0.tgz", + "integrity": "sha512-W8wIz7H2R1pufR5jfjb3gU2XkMpm2x/7b1RJcsuzvd70Il/rWWE+g5/Od7hQKrxRTSrTrOWlru101PWXz5I1EQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" + "@opentelemetry/core": "2.7.1", + "yaml": "^2.0.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" + "@opentelemetry/api": "^1.9.0" } }, - "node_modules/@opentelemetry/exporter-logs-otlp-grpc": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-grpc/-/exporter-logs-otlp-grpc-0.207.0.tgz", - "integrity": "sha512-K92RN+kQGTMzFDsCzsYNGqOsXRUnko/Ckk+t/yPJao72MewOLgBUTWVHhebgkNfRCYqDz1v3K0aPT9OJkemvgg==", + "node_modules/@opentelemetry/context-async-hooks": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.7.1.tgz", + "integrity": "sha512-OPFBYuXEn1E4ja3Y6eeA7O+ZnLBNcXTV5Cgsn1VaqBZ6hC5FnpZPLBNme1LJY8ZtF4aOujPKFoeWN4ik487KuQ==", "license": "Apache-2.0", - "dependencies": { - "@grpc/grpc-js": "^1.7.1", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/otlp-exporter-base": "0.207.0", - "@opentelemetry/otlp-grpc-exporter-base": "0.207.0", - "@opentelemetry/otlp-transformer": "0.207.0", - "@opentelemetry/sdk-logs": "0.207.0" - }, "engines": { "node": "^18.19.0 || >=20.6.0" }, "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/exporter-logs-otlp-grpc/node_modules/@opentelemetry/api-logs": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.207.0.tgz", - "integrity": "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" + "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/exporter-logs-otlp-grpc/node_modules/@opentelemetry/core": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", - "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", + "node_modules/@opentelemetry/core": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz", + "integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==", "license": "Apache-2.0", "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" @@ -12933,35 +12911,18 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/exporter-logs-otlp-grpc/node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.207.0.tgz", - "integrity": "sha512-4RQluMVVGMrHok/3SVeSJ6EnRNkA2MINcX88sh+d/7DjGUrewW/WT88IsMEci0wUM+5ykTpPPNbEOoW+jwHnbw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/otlp-transformer": "0.207.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/exporter-logs-otlp-grpc/node_modules/@opentelemetry/otlp-transformer": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.207.0.tgz", - "integrity": "sha512-+6DRZLqM02uTIY5GASMZWUwr52sLfNiEe20+OEaZKhztCs3+2LxoTjb6JxFRd9q1qNqckXKYlUKjbH/AhG8/ZA==", + "node_modules/@opentelemetry/exporter-logs-otlp-grpc": { + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-grpc/-/exporter-logs-otlp-grpc-0.218.0.tgz", + "integrity": "sha512-hoxrNH1l/Xy6F9WTJ5IK+6j1r9nQFlPOmrnTlhYHTySdunfXLmUCPv3bQtKYntxag9h3wLYBZQ2HI6FOx+BT2g==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.207.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/sdk-logs": "0.207.0", - "@opentelemetry/sdk-metrics": "2.2.0", - "@opentelemetry/sdk-trace-base": "2.2.0", - "protobufjs": "^7.3.0" + "@grpc/grpc-js": "^1.14.3", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/otlp-exporter-base": "0.218.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.218.0", + "@opentelemetry/otlp-transformer": "0.218.0", + "@opentelemetry/sdk-logs": "0.218.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -12970,147 +12931,48 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/exporter-logs-otlp-grpc/node_modules/@opentelemetry/resources": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", - "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-logs-otlp-grpc/node_modules/@opentelemetry/sdk-logs": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.207.0.tgz", - "integrity": "sha512-4MEQmn04y+WFe6cyzdrXf58hZxilvY59lzZj2AccuHW/+BxLn/rGVN/Irsi/F0qfBOpMOrrCLKTExoSL2zoQmg==", + "node_modules/@opentelemetry/exporter-logs-otlp-grpc/node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.207.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0" + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" }, "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.4.0 <1.10.0" + "node": ">=12.10.0" } }, - "node_modules/@opentelemetry/exporter-logs-otlp-grpc/node_modules/@opentelemetry/sdk-metrics": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz", - "integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==", + "node_modules/@opentelemetry/exporter-logs-otlp-grpc/node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" }, - "peerDependencies": { - "@opentelemetry/api": ">=1.9.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-logs-otlp-grpc/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", - "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" }, "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" + "node": ">=6" } }, "node_modules/@opentelemetry/exporter-logs-otlp-http": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.207.0.tgz", - "integrity": "sha512-JpOh7MguEUls8eRfkVVW3yRhClo5b9LqwWTOg8+i4gjr/+8eiCtquJnC7whvpTIGyff06cLZ2NsEj+CVP3Mjeg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.207.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/otlp-exporter-base": "0.207.0", - "@opentelemetry/otlp-transformer": "0.207.0", - "@opentelemetry/sdk-logs": "0.207.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/api-logs": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.207.0.tgz", - "integrity": "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==", + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.218.0.tgz", + "integrity": "sha512-Qx+4rpVHzgg89dawcWRHyt+XRXeLnhFz/qBtvggmjkcgPUdr+NAB0/u/eIPA8yAeJV0J80Vz43JZCh/XFvZFGw==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/core": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", - "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.207.0.tgz", - "integrity": "sha512-4RQluMVVGMrHok/3SVeSJ6EnRNkA2MINcX88sh+d/7DjGUrewW/WT88IsMEci0wUM+5ykTpPPNbEOoW+jwHnbw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/otlp-transformer": "0.207.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/otlp-transformer": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.207.0.tgz", - "integrity": "sha512-+6DRZLqM02uTIY5GASMZWUwr52sLfNiEe20+OEaZKhztCs3+2LxoTjb6JxFRd9q1qNqckXKYlUKjbH/AhG8/ZA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.207.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/sdk-logs": "0.207.0", - "@opentelemetry/sdk-metrics": "2.2.0", - "@opentelemetry/sdk-trace-base": "2.2.0", - "protobufjs": "^7.3.0" + "@opentelemetry/api-logs": "0.218.0", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/otlp-exporter-base": "0.218.0", + "@opentelemetry/otlp-transformer": "0.218.0", + "@opentelemetry/sdk-logs": "0.218.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -13119,128 +12981,19 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/resources": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", - "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/sdk-logs": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.207.0.tgz", - "integrity": "sha512-4MEQmn04y+WFe6cyzdrXf58hZxilvY59lzZj2AccuHW/+BxLn/rGVN/Irsi/F0qfBOpMOrrCLKTExoSL2zoQmg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.207.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.4.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/sdk-metrics": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz", - "integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.9.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", - "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, "node_modules/@opentelemetry/exporter-logs-otlp-proto": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-proto/-/exporter-logs-otlp-proto-0.207.0.tgz", - "integrity": "sha512-RQJEV/K6KPbQrIUbsrRkEe0ufks1o5OGLHy6jbDD8tRjeCsbFHWfg99lYBRqBV33PYZJXsigqMaAbjWGTFYzLw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.207.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/otlp-exporter-base": "0.207.0", - "@opentelemetry/otlp-transformer": "0.207.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/sdk-logs": "0.207.0", - "@opentelemetry/sdk-trace-base": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/api-logs": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.207.0.tgz", - "integrity": "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==", + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-proto/-/exporter-logs-otlp-proto-0.218.0.tgz", + "integrity": "sha512-1/noQNsp9gXD75HPzgjBrcF1+XTtry7pFAUfxVEJgg7mPv2AawKQuYkhMmJ8qjxz4Ubc3Y8bwvfxevXsKTq4cg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/core": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", - "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.207.0.tgz", - "integrity": "sha512-4RQluMVVGMrHok/3SVeSJ6EnRNkA2MINcX88sh+d/7DjGUrewW/WT88IsMEci0wUM+5ykTpPPNbEOoW+jwHnbw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/otlp-transformer": "0.207.0" + "@opentelemetry/api-logs": "0.218.0", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/otlp-exporter-base": "0.218.0", + "@opentelemetry/otlp-transformer": "0.218.0", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-logs": "0.218.0", + "@opentelemetry/sdk-trace-base": "2.7.1" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -13249,150 +13002,20 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/otlp-transformer": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.207.0.tgz", - "integrity": "sha512-+6DRZLqM02uTIY5GASMZWUwr52sLfNiEe20+OEaZKhztCs3+2LxoTjb6JxFRd9q1qNqckXKYlUKjbH/AhG8/ZA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.207.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/sdk-logs": "0.207.0", - "@opentelemetry/sdk-metrics": "2.2.0", - "@opentelemetry/sdk-trace-base": "2.2.0", - "protobufjs": "^7.3.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/resources": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", - "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/sdk-logs": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.207.0.tgz", - "integrity": "sha512-4MEQmn04y+WFe6cyzdrXf58hZxilvY59lzZj2AccuHW/+BxLn/rGVN/Irsi/F0qfBOpMOrrCLKTExoSL2zoQmg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.207.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.4.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/sdk-metrics": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz", - "integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.9.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-logs-otlp-proto/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", - "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, "node_modules/@opentelemetry/exporter-metrics-otlp-grpc": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-grpc/-/exporter-metrics-otlp-grpc-0.207.0.tgz", - "integrity": "sha512-6flX89W54gkwmqYShdcTBR1AEF5C1Ob0O8pDgmLPikTKyEv27lByr9yBmO5WrP0+5qJuNPHrLfgFQFYi6npDGA==", - "license": "Apache-2.0", - "dependencies": { - "@grpc/grpc-js": "^1.7.1", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/exporter-metrics-otlp-http": "0.207.0", - "@opentelemetry/otlp-exporter-base": "0.207.0", - "@opentelemetry/otlp-grpc-exporter-base": "0.207.0", - "@opentelemetry/otlp-transformer": "0.207.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/sdk-metrics": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-grpc/node_modules/@opentelemetry/api-logs": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.207.0.tgz", - "integrity": "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-grpc/node_modules/@opentelemetry/core": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", - "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-grpc/-/exporter-metrics-otlp-grpc-0.218.0.tgz", + "integrity": "sha512-YapQ9vNMX0NSZF6LK5pWAFfjpJleV2O9uYWfYGeb/5F1Kb9rPGK8tZDMJFa/sOksgdFuflDvYuA0B4qjDB4fjQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-grpc/node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.207.0.tgz", - "integrity": "sha512-4RQluMVVGMrHok/3SVeSJ6EnRNkA2MINcX88sh+d/7DjGUrewW/WT88IsMEci0wUM+5ykTpPPNbEOoW+jwHnbw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/otlp-transformer": "0.207.0" + "@grpc/grpc-js": "^1.14.3", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/exporter-metrics-otlp-http": "0.218.0", + "@opentelemetry/otlp-exporter-base": "0.218.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.218.0", + "@opentelemetry/otlp-transformer": "0.218.0", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-metrics": "2.7.1" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -13401,168 +13024,48 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/exporter-metrics-otlp-grpc/node_modules/@opentelemetry/otlp-transformer": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.207.0.tgz", - "integrity": "sha512-+6DRZLqM02uTIY5GASMZWUwr52sLfNiEe20+OEaZKhztCs3+2LxoTjb6JxFRd9q1qNqckXKYlUKjbH/AhG8/ZA==", + "node_modules/@opentelemetry/exporter-metrics-otlp-grpc/node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.207.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/sdk-logs": "0.207.0", - "@opentelemetry/sdk-metrics": "2.2.0", - "@opentelemetry/sdk-trace-base": "2.2.0", - "protobufjs": "^7.3.0" + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" }, "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" + "node": ">=12.10.0" } }, - "node_modules/@opentelemetry/exporter-metrics-otlp-grpc/node_modules/@opentelemetry/resources": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", - "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-grpc/node_modules/@opentelemetry/sdk-logs": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.207.0.tgz", - "integrity": "sha512-4MEQmn04y+WFe6cyzdrXf58hZxilvY59lzZj2AccuHW/+BxLn/rGVN/Irsi/F0qfBOpMOrrCLKTExoSL2zoQmg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.207.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.4.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-grpc/node_modules/@opentelemetry/sdk-metrics": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz", - "integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==", + "node_modules/@opentelemetry/exporter-metrics-otlp-grpc/node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" }, - "peerDependencies": { - "@opentelemetry/api": ">=1.9.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-grpc/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", - "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" }, "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" + "node": ">=6" } }, "node_modules/@opentelemetry/exporter-metrics-otlp-http": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.207.0.tgz", - "integrity": "sha512-fG8FAJmvXOrKXGIRN8+y41U41IfVXxPRVwyB05LoMqYSjugx/FSBkMZUZXUT/wclTdmBKtS5MKoi0bEKkmRhSw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/otlp-exporter-base": "0.207.0", - "@opentelemetry/otlp-transformer": "0.207.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/sdk-metrics": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/api-logs": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.207.0.tgz", - "integrity": "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/core": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", - "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.207.0.tgz", - "integrity": "sha512-4RQluMVVGMrHok/3SVeSJ6EnRNkA2MINcX88sh+d/7DjGUrewW/WT88IsMEci0wUM+5ykTpPPNbEOoW+jwHnbw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/otlp-transformer": "0.207.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/otlp-transformer": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.207.0.tgz", - "integrity": "sha512-+6DRZLqM02uTIY5GASMZWUwr52sLfNiEe20+OEaZKhztCs3+2LxoTjb6JxFRd9q1qNqckXKYlUKjbH/AhG8/ZA==", + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-http/-/exporter-metrics-otlp-http-0.218.0.tgz", + "integrity": "sha512-bV7d2OuMpZu2+gAaxUAhzfZ0h3WVZk8ETQUEE3DNSntbTaMpuITjtm8I0rNyHFdm7Ax57K6ty7SgFXlBmOLIvQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.207.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/sdk-logs": "0.207.0", - "@opentelemetry/sdk-metrics": "2.2.0", - "@opentelemetry/sdk-trace-base": "2.2.0", - "protobufjs": "^7.3.0" + "@opentelemetry/core": "2.7.1", + "@opentelemetry/otlp-exporter-base": "0.218.0", + "@opentelemetry/otlp-transformer": "0.218.0", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-metrics": "2.7.1" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -13571,148 +13074,18 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/resources": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", - "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/sdk-logs": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.207.0.tgz", - "integrity": "sha512-4MEQmn04y+WFe6cyzdrXf58hZxilvY59lzZj2AccuHW/+BxLn/rGVN/Irsi/F0qfBOpMOrrCLKTExoSL2zoQmg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.207.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.4.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/sdk-metrics": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz", - "integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.9.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-http/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", - "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, "node_modules/@opentelemetry/exporter-metrics-otlp-proto": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-proto/-/exporter-metrics-otlp-proto-0.207.0.tgz", - "integrity": "sha512-kDBxiTeQjaRlUQzS1COT9ic+et174toZH6jxaVuVAvGqmxOkgjpLOjrI5ff8SMMQE69r03L3Ll3nPKekLopLwg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/exporter-metrics-otlp-http": "0.207.0", - "@opentelemetry/otlp-exporter-base": "0.207.0", - "@opentelemetry/otlp-transformer": "0.207.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/sdk-metrics": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/api-logs": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.207.0.tgz", - "integrity": "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/core": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", - "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.207.0.tgz", - "integrity": "sha512-4RQluMVVGMrHok/3SVeSJ6EnRNkA2MINcX88sh+d/7DjGUrewW/WT88IsMEci0wUM+5ykTpPPNbEOoW+jwHnbw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/otlp-transformer": "0.207.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/otlp-transformer": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.207.0.tgz", - "integrity": "sha512-+6DRZLqM02uTIY5GASMZWUwr52sLfNiEe20+OEaZKhztCs3+2LxoTjb6JxFRd9q1qNqckXKYlUKjbH/AhG8/ZA==", + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-metrics-otlp-proto/-/exporter-metrics-otlp-proto-0.218.0.tgz", + "integrity": "sha512-ubLddKjWULhla9YZRCj/rTBeppjJYE4e9w0icx5mTu3eFhWjQzbV75NYjXuIlEG+NJsBl6d+sTFw5Qu+oej4oQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.207.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/sdk-logs": "0.207.0", - "@opentelemetry/sdk-metrics": "2.2.0", - "@opentelemetry/sdk-trace-base": "2.2.0", - "protobufjs": "^7.3.0" + "@opentelemetry/core": "2.7.1", + "@opentelemetry/exporter-metrics-otlp-http": "0.218.0", + "@opentelemetry/otlp-exporter-base": "0.218.0", + "@opentelemetry/otlp-transformer": "0.218.0", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-metrics": "2.7.1" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -13721,213 +13094,37 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/resources": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", - "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/sdk-logs": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.207.0.tgz", - "integrity": "sha512-4MEQmn04y+WFe6cyzdrXf58hZxilvY59lzZj2AccuHW/+BxLn/rGVN/Irsi/F0qfBOpMOrrCLKTExoSL2zoQmg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.207.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.4.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/sdk-metrics": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz", - "integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.9.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-metrics-otlp-proto/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", - "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, "node_modules/@opentelemetry/exporter-prometheus": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-prometheus/-/exporter-prometheus-0.207.0.tgz", - "integrity": "sha512-Y5p1s39FvIRmU+F1++j7ly8/KSqhMmn6cMfpQqiDCqDjdDHwUtSq0XI0WwL3HYGnZeaR/VV4BNmsYQJ7GAPrhw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/sdk-metrics": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/exporter-prometheus/node_modules/@opentelemetry/core": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", - "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-prometheus/node_modules/@opentelemetry/resources": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", - "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-prometheus/node_modules/@opentelemetry/sdk-metrics": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz", - "integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.9.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-trace-otlp-grpc": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.207.0.tgz", - "integrity": "sha512-7u2ZmcIx6D4KG/+5np4X2qA0o+O0K8cnUDhR4WI/vr5ZZ0la9J9RG+tkSjC7Yz+2XgL6760gSIM7/nyd3yaBLA==", - "license": "Apache-2.0", - "dependencies": { - "@grpc/grpc-js": "^1.7.1", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/otlp-exporter-base": "0.207.0", - "@opentelemetry/otlp-grpc-exporter-base": "0.207.0", - "@opentelemetry/otlp-transformer": "0.207.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/sdk-trace-base": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@opentelemetry/api-logs": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.207.0.tgz", - "integrity": "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@opentelemetry/core": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", - "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-prometheus/-/exporter-prometheus-0.218.0.tgz", + "integrity": "sha512-RT5oEyu1kddZJ1vt7/BUo5wV+P7hpNAESsR3dUd3+8deHuX7gWNoCOZn+SfDT+hJHlIJ5h/AxiCLXIrutswDJg==", "license": "Apache-2.0", "dependencies": { + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-metrics": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.207.0.tgz", - "integrity": "sha512-4RQluMVVGMrHok/3SVeSJ6EnRNkA2MINcX88sh+d/7DjGUrewW/WT88IsMEci0wUM+5ykTpPPNbEOoW+jwHnbw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/otlp-transformer": "0.207.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, "peerDependencies": { "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@opentelemetry/otlp-transformer": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.207.0.tgz", - "integrity": "sha512-+6DRZLqM02uTIY5GASMZWUwr52sLfNiEe20+OEaZKhztCs3+2LxoTjb6JxFRd9q1qNqckXKYlUKjbH/AhG8/ZA==", + "node_modules/@opentelemetry/exporter-trace-otlp-grpc": { + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-grpc/-/exporter-trace-otlp-grpc-0.218.0.tgz", + "integrity": "sha512-3fXxVQEj9TNAFaCi79JeFKfeLd0sDtInaR3gaZDVlzNSPHtz8PZuCV34JKWjD4XXzT20IdMe8IpX6mRVNDA4Tw==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.207.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/sdk-logs": "0.207.0", - "@opentelemetry/sdk-metrics": "2.2.0", - "@opentelemetry/sdk-trace-base": "2.2.0", - "protobufjs": "^7.3.0" + "@grpc/grpc-js": "^1.14.3", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/otlp-exporter-base": "0.218.0", + "@opentelemetry/otlp-grpc-exporter-base": "0.218.0", + "@opentelemetry/otlp-transformer": "0.218.0", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-trace-base": "2.7.1" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -13936,84 +13133,48 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@opentelemetry/resources": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", - "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@opentelemetry/sdk-logs": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.207.0.tgz", - "integrity": "sha512-4MEQmn04y+WFe6cyzdrXf58hZxilvY59lzZj2AccuHW/+BxLn/rGVN/Irsi/F0qfBOpMOrrCLKTExoSL2zoQmg==", + "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.207.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0" + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" }, "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.4.0 <1.10.0" + "node": ">=12.10.0" } }, - "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@opentelemetry/sdk-metrics": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz", - "integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==", + "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" }, - "peerDependencies": { - "@opentelemetry/api": ">=1.9.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-trace-otlp-grpc/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", - "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" }, "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" + "node": ">=6" } }, "node_modules/@opentelemetry/exporter-trace-otlp-http": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.214.0.tgz", - "integrity": "sha512-kIN8nTBMgV2hXzV/a20BCFilPZdAIMYYJGSgfMMRm/Xa+07y5hRDS2Vm12A/z8Cdu3Sq++ZvJfElokX2rkgGgw==", + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.218.0.tgz", + "integrity": "sha512-8dqezsmPhtKitIK/eTipZhYl9EX2/gNQ5zUMhaz3uxEURwfkNf8IPvo6yNfrzbxdtpAOybS/+h7wmIWYqFSpiw==", "license": "Apache-2.0", - "peer": true, "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/otlp-exporter-base": "0.214.0", - "@opentelemetry/otlp-transformer": "0.214.0", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/sdk-trace-base": "2.6.1" + "@opentelemetry/core": "2.7.1", + "@opentelemetry/otlp-exporter-base": "0.218.0", + "@opentelemetry/otlp-transformer": "0.218.0", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-trace-base": "2.7.1" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -14023,59 +13184,16 @@ } }, "node_modules/@opentelemetry/exporter-trace-otlp-proto": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.207.0.tgz", - "integrity": "sha512-ruUQB4FkWtxHjNmSXjrhmJZFvyMm+tBzHyMm7YPQshApy4wvZUTcrpPyP/A/rCl/8M4BwoVIZdiwijMdbZaq4w==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/otlp-exporter-base": "0.207.0", - "@opentelemetry/otlp-transformer": "0.207.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/sdk-trace-base": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/api-logs": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.207.0.tgz", - "integrity": "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/core": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", - "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.207.0.tgz", - "integrity": "sha512-4RQluMVVGMrHok/3SVeSJ6EnRNkA2MINcX88sh+d/7DjGUrewW/WT88IsMEci0wUM+5ykTpPPNbEOoW+jwHnbw==", + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-proto/-/exporter-trace-otlp-proto-0.218.0.tgz", + "integrity": "sha512-r1Msf8SNLRmwh9J6XQ5uh82D7CdDWMNHnPB7LAVHjzut0TkSeKc5KcIvr4SvHvfk/xwN5gxC+VLKQ1k0o8PSPw==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/otlp-transformer": "0.207.0" + "@opentelemetry/core": "2.7.1", + "@opentelemetry/otlp-exporter-base": "0.218.0", + "@opentelemetry/otlp-transformer": "0.218.0", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-trace-base": "2.7.1" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -14084,102 +13202,15 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/otlp-transformer": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.207.0.tgz", - "integrity": "sha512-+6DRZLqM02uTIY5GASMZWUwr52sLfNiEe20+OEaZKhztCs3+2LxoTjb6JxFRd9q1qNqckXKYlUKjbH/AhG8/ZA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.207.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/sdk-logs": "0.207.0", - "@opentelemetry/sdk-metrics": "2.2.0", - "@opentelemetry/sdk-trace-base": "2.2.0", - "protobufjs": "^7.3.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/resources": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", - "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/sdk-logs": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.207.0.tgz", - "integrity": "sha512-4MEQmn04y+WFe6cyzdrXf58hZxilvY59lzZj2AccuHW/+BxLn/rGVN/Irsi/F0qfBOpMOrrCLKTExoSL2zoQmg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.207.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.4.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/sdk-metrics": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz", - "integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.9.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-trace-otlp-proto/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", - "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, "node_modules/@opentelemetry/exporter-zipkin": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-zipkin/-/exporter-zipkin-2.2.0.tgz", - "integrity": "sha512-VV4QzhGCT7cWrGasBWxelBjqbNBbyHicWWS/66KoZoe9BzYwFB72SH2/kkc4uAviQlO8iwv2okIJy+/jqqEHTg==", + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-zipkin/-/exporter-zipkin-2.7.1.tgz", + "integrity": "sha512-mfsD9bKAxcKrh5+y08TPodvClBO0CznBE3p79YAGnO81WI4LrdsGA65T53e4iTSbCalW4WaUpkbeJcbpyIUHfg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/sdk-trace-base": "2.2.0", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-trace-base": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -14189,54 +13220,6 @@ "@opentelemetry/api": "^1.0.0" } }, - "node_modules/@opentelemetry/exporter-zipkin/node_modules/@opentelemetry/core": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", - "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-zipkin/node_modules/@opentelemetry/resources": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", - "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/exporter-zipkin/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", - "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, "node_modules/@opentelemetry/instrumentation": { "version": "0.207.0", "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.207.0.tgz", @@ -14381,14 +13364,13 @@ } }, "node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.214.0.tgz", - "integrity": "sha512-u1Gdv0/E9wP+apqWf7Wv2npXmgJtxsW2XL0TEv9FZloTZRuMBKmu8cYVXwS4Hm3q/f/3FuCnPTgiwYvIqRSpRg==", + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.218.0.tgz", + "integrity": "sha512-ZwqpkNL5W7RyGJPDZ9g06DvKp8KFTWPJPN12anpMQYSKpTSU0z3EIZuPq9vPGpS8siFyOqDYDAuCwlNO9FqgbA==", "license": "Apache-2.0", - "peer": true, "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/otlp-transformer": "0.214.0" + "@opentelemetry/core": "2.7.1", + "@opentelemetry/otlp-transformer": "0.218.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -14398,79 +13380,15 @@ } }, "node_modules/@opentelemetry/otlp-grpc-exporter-base": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.207.0.tgz", - "integrity": "sha512-eKFjKNdsPed4q9yYqeI5gBTLjXxDM/8jwhiC0icw3zKxHVGBySoDsed5J5q/PGY/3quzenTr3FiTxA3NiNT+nw==", - "license": "Apache-2.0", - "dependencies": { - "@grpc/grpc-js": "^1.7.1", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/otlp-exporter-base": "0.207.0", - "@opentelemetry/otlp-transformer": "0.207.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/otlp-grpc-exporter-base/node_modules/@opentelemetry/api-logs": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.207.0.tgz", - "integrity": "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/otlp-grpc-exporter-base/node_modules/@opentelemetry/core": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", - "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/otlp-grpc-exporter-base/node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.207.0.tgz", - "integrity": "sha512-4RQluMVVGMrHok/3SVeSJ6EnRNkA2MINcX88sh+d/7DjGUrewW/WT88IsMEci0wUM+5ykTpPPNbEOoW+jwHnbw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/otlp-transformer": "0.207.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/otlp-grpc-exporter-base/node_modules/@opentelemetry/otlp-transformer": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.207.0.tgz", - "integrity": "sha512-+6DRZLqM02uTIY5GASMZWUwr52sLfNiEe20+OEaZKhztCs3+2LxoTjb6JxFRd9q1qNqckXKYlUKjbH/AhG8/ZA==", + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-grpc-exporter-base/-/otlp-grpc-exporter-base-0.218.0.tgz", + "integrity": "sha512-H/lCGJ536N98VpYJOaWTQOkv4Dx6TnmStK6Rqfu1W7KkFbPAx04hjdYEMZF/YbnHzPUSIK4kM6OE2GKGBTpV9A==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.207.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/sdk-logs": "0.207.0", - "@opentelemetry/sdk-metrics": "2.2.0", - "@opentelemetry/sdk-trace-base": "2.2.0", - "protobufjs": "^7.3.0" + "@grpc/grpc-js": "^1.14.3", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/otlp-exporter-base": "0.218.0", + "@opentelemetry/otlp-transformer": "0.218.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -14479,86 +13397,49 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/otlp-grpc-exporter-base/node_modules/@opentelemetry/resources": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", - "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/otlp-grpc-exporter-base/node_modules/@opentelemetry/sdk-logs": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.207.0.tgz", - "integrity": "sha512-4MEQmn04y+WFe6cyzdrXf58hZxilvY59lzZj2AccuHW/+BxLn/rGVN/Irsi/F0qfBOpMOrrCLKTExoSL2zoQmg==", + "node_modules/@opentelemetry/otlp-grpc-exporter-base/node_modules/@grpc/grpc-js": { + "version": "1.14.3", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.3.tgz", + "integrity": "sha512-Iq8QQQ/7X3Sac15oB6p0FmUg/klxQvXLeileoqrTRGJYLV+/9tubbr9ipz0GKHjmXVsgFPo/+W+2cA8eNcR+XA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api-logs": "0.207.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0" + "@grpc/proto-loader": "^0.8.0", + "@js-sdsl/ordered-map": "^4.4.2" }, "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.4.0 <1.10.0" + "node": ">=12.10.0" } }, - "node_modules/@opentelemetry/otlp-grpc-exporter-base/node_modules/@opentelemetry/sdk-metrics": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz", - "integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==", + "node_modules/@opentelemetry/otlp-grpc-exporter-base/node_modules/@grpc/proto-loader": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.8.1.tgz", + "integrity": "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.5.5", + "yargs": "^17.7.2" }, - "peerDependencies": { - "@opentelemetry/api": ">=1.9.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/otlp-grpc-exporter-base/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", - "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" }, "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" + "node": ">=6" } }, "node_modules/@opentelemetry/otlp-transformer": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.214.0.tgz", - "integrity": "sha512-DSaYcuBRh6uozfsWN3R8HsN0yDhCuWP7tOFdkUOVaWD1KVJg8m4qiLUsg/tNhTLS9HUYUcwNpwL2eroLtsZZ/w==", + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.218.0.tgz", + "integrity": "sha512-CFaKH87WAzjuJ4awowTTLzUvMfaRfiOFG5+qm5S5ncyalRtN4ecQ+YmuANJSCrVPuvZFEkUgKhBPBndxi3rHsQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { - "@opentelemetry/api-logs": "0.214.0", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1", - "@opentelemetry/sdk-logs": "0.214.0", - "@opentelemetry/sdk-metrics": "2.6.1", - "@opentelemetry/sdk-trace-base": "2.6.1", - "protobufjs": "^7.0.0" + "@opentelemetry/api-logs": "0.218.0", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-logs": "0.218.0", + "@opentelemetry/sdk-metrics": "2.7.1", + "@opentelemetry/sdk-trace-base": "2.7.1" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -14568,27 +13449,12 @@ } }, "node_modules/@opentelemetry/propagator-b3": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-b3/-/propagator-b3-2.2.0.tgz", - "integrity": "sha512-9CrbTLFi5Ee4uepxg2qlpQIozoJuoAZU5sKMx0Mn7Oh+p7UrgCiEV6C02FOxxdYVRRFQVCinYR8Kf6eMSQsIsw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/propagator-b3/node_modules/@opentelemetry/core": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", - "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-b3/-/propagator-b3-2.7.1.tgz", + "integrity": "sha512-RJid6E2CKyeGfKBzXKF21ejabGMHypFkPAh3qZ+NvI+SGjuIye79t3PmiqcDgtRzdKH6ynXzbfslQ8DfpRUg2A==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" + "@opentelemetry/core": "2.7.1" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -14598,27 +13464,12 @@ } }, "node_modules/@opentelemetry/propagator-jaeger": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-jaeger/-/propagator-jaeger-2.2.0.tgz", - "integrity": "sha512-FfeOHOrdhiNzecoB1jZKp2fybqmqMPJUXe2ZOydP7QzmTPYcfPeuaclTLYVhK3HyJf71kt8sTl92nV4YIaLaKA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/propagator-jaeger/node_modules/@opentelemetry/core": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", - "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/propagator-jaeger/-/propagator-jaeger-2.7.1.tgz", + "integrity": "sha512-KMjVBHzP4N60bOzxja76M1F1hZZ43lGPga5ix+mkv9+kk1nx9SbkxSvJsMbuVUxdPQmsPTqGShmhN8ulrMOg6Q==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" + "@opentelemetry/core": "2.7.1" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -14637,12 +13488,12 @@ } }, "node_modules/@opentelemetry/resources": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.6.1.tgz", - "integrity": "sha512-lID/vxSuKWXM55XhAKNoYXu9Cutoq5hFdkbTdI/zDKQktXzcWBVhNsOkiZFTMU9UtEWuGRNe0HUgmsFldIdxVA==", + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz", + "integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.6.1", + "@opentelemetry/core": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -14653,15 +13504,14 @@ } }, "node_modules/@opentelemetry/sdk-logs": { - "version": "0.214.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.214.0.tgz", - "integrity": "sha512-zf6acnScjhsaBUU22zXZ/sLWim1dfhUAbGXdMmHmNG3LfBnQ3DKsOCITb2IZwoUsNNMTogqFKBnlIPPftUgGwA==", + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.218.0.tgz", + "integrity": "sha512-QvnNdugatFTVCJXH0Mcu7GOOJSylA9j127kIezOE4YwTI4YbowRons2K4WZTv5FMS8T4q9P0NdaRHdkSmeAIag==", "license": "Apache-2.0", - "peer": true, "dependencies": { - "@opentelemetry/api-logs": "0.214.0", - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1", + "@opentelemetry/api-logs": "0.218.0", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -14672,14 +13522,13 @@ } }, "node_modules/@opentelemetry/sdk-metrics": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.6.1.tgz", - "integrity": "sha512-9t9hJHX15meBy2NmTJxL+NJfXmnausR2xUDvE19XQce0Qi/GBtDGamU8nS1RMbdgDmhgpm3VaOu2+fiS/SfTpQ==", + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.1.tgz", + "integrity": "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ==", "license": "Apache-2.0", - "peer": true, "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1" + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -14689,32 +13538,35 @@ } }, "node_modules/@opentelemetry/sdk-node": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-node/-/sdk-node-0.207.0.tgz", - "integrity": "sha512-hnRsX/M8uj0WaXOBvFenQ8XsE8FLVh2uSnn1rkWu4mx+qu7EKGUZvZng6y/95cyzsqOfiaDDr08Ek4jppkIDNg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.207.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/exporter-logs-otlp-grpc": "0.207.0", - "@opentelemetry/exporter-logs-otlp-http": "0.207.0", - "@opentelemetry/exporter-logs-otlp-proto": "0.207.0", - "@opentelemetry/exporter-metrics-otlp-grpc": "0.207.0", - "@opentelemetry/exporter-metrics-otlp-http": "0.207.0", - "@opentelemetry/exporter-metrics-otlp-proto": "0.207.0", - "@opentelemetry/exporter-prometheus": "0.207.0", - "@opentelemetry/exporter-trace-otlp-grpc": "0.207.0", - "@opentelemetry/exporter-trace-otlp-http": "0.207.0", - "@opentelemetry/exporter-trace-otlp-proto": "0.207.0", - "@opentelemetry/exporter-zipkin": "2.2.0", - "@opentelemetry/instrumentation": "0.207.0", - "@opentelemetry/propagator-b3": "2.2.0", - "@opentelemetry/propagator-jaeger": "2.2.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/sdk-logs": "0.207.0", - "@opentelemetry/sdk-metrics": "2.2.0", - "@opentelemetry/sdk-trace-base": "2.2.0", - "@opentelemetry/sdk-trace-node": "2.2.0", + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-node/-/sdk-node-0.218.0.tgz", + "integrity": "sha512-tPMjHrLV5gsfNdYqoRHjeGbCAZBXXD9c1Qo/2ut7VwnUABDNh76xNxrT0SEhkIIJuCN45bbN1vZnYL1gY0IkOg==", + "license": "Apache-2.0", + "dependencies": { + "@opentelemetry/api-logs": "0.218.0", + "@opentelemetry/configuration": "0.218.0", + "@opentelemetry/context-async-hooks": "2.7.1", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/exporter-logs-otlp-grpc": "0.218.0", + "@opentelemetry/exporter-logs-otlp-http": "0.218.0", + "@opentelemetry/exporter-logs-otlp-proto": "0.218.0", + "@opentelemetry/exporter-metrics-otlp-grpc": "0.218.0", + "@opentelemetry/exporter-metrics-otlp-http": "0.218.0", + "@opentelemetry/exporter-metrics-otlp-proto": "0.218.0", + "@opentelemetry/exporter-prometheus": "0.218.0", + "@opentelemetry/exporter-trace-otlp-grpc": "0.218.0", + "@opentelemetry/exporter-trace-otlp-http": "0.218.0", + "@opentelemetry/exporter-trace-otlp-proto": "0.218.0", + "@opentelemetry/exporter-zipkin": "2.7.1", + "@opentelemetry/instrumentation": "0.218.0", + "@opentelemetry/otlp-exporter-base": "0.218.0", + "@opentelemetry/propagator-b3": "2.7.1", + "@opentelemetry/propagator-jaeger": "2.7.1", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-logs": "0.218.0", + "@opentelemetry/sdk-metrics": "2.7.1", + "@opentelemetry/sdk-trace-base": "2.7.1", + "@opentelemetry/sdk-trace-node": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -14724,81 +13576,15 @@ "@opentelemetry/api": ">=1.3.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/api-logs": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.207.0.tgz", - "integrity": "sha512-lAb0jQRVyleQQGiuuvCOTDVspc14nx6XJjP4FspJ1sNARo3Regq4ZZbrc3rN4b1TYSuUCvgH+UXUPug4SLOqEQ==", + "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/instrumentation": { + "version": "0.218.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.218.0.tgz", + "integrity": "sha512-mIZil8Es+sYDK5m+DQiwAwF57F14TF2YlEqvIjZ/RQWcxDBwRGsKfdK2Tv65OU9meQKCMzSIFS9mxAcnAb6Bkg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/api": "^1.3.0" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/core": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", - "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/exporter-trace-otlp-http": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.207.0.tgz", - "integrity": "sha512-HSRBzXHIC7C8UfPQdu15zEEoBGv0yWkhEwxqgPCHVUKUQ9NLHVGXkVrf65Uaj7UwmAkC1gQfkuVYvLlD//AnUQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/otlp-exporter-base": "0.207.0", - "@opentelemetry/otlp-transformer": "0.207.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/sdk-trace-base": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/otlp-exporter-base": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.207.0.tgz", - "integrity": "sha512-4RQluMVVGMrHok/3SVeSJ6EnRNkA2MINcX88sh+d/7DjGUrewW/WT88IsMEci0wUM+5ykTpPPNbEOoW+jwHnbw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/otlp-transformer": "0.207.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": "^1.3.0" - } - }, - "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/otlp-transformer": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.207.0.tgz", - "integrity": "sha512-+6DRZLqM02uTIY5GASMZWUwr52sLfNiEe20+OEaZKhztCs3+2LxoTjb6JxFRd9q1qNqckXKYlUKjbH/AhG8/ZA==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.207.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/sdk-logs": "0.207.0", - "@opentelemetry/sdk-metrics": "2.2.0", - "@opentelemetry/sdk-trace-base": "2.2.0", - "protobufjs": "^7.3.0" + "@opentelemetry/api-logs": "0.218.0", + "import-in-the-middle": "^3.0.0", + "require-in-the-middle": "^8.0.0" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -14807,81 +13593,29 @@ "@opentelemetry/api": "^1.3.0" } }, - "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/resources": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", - "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/sdk-logs": { - "version": "0.207.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.207.0.tgz", - "integrity": "sha512-4MEQmn04y+WFe6cyzdrXf58hZxilvY59lzZj2AccuHW/+BxLn/rGVN/Irsi/F0qfBOpMOrrCLKTExoSL2zoQmg==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/api-logs": "0.207.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.4.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/sdk-metrics": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.2.0.tgz", - "integrity": "sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.9.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-node/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", - "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", + "node_modules/@opentelemetry/sdk-node/node_modules/import-in-the-middle": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.0.1.tgz", + "integrity": "sha512-pYkiyXVL2Mf3pozdlDGV6NAObxQx13Ae8knZk1UJRJ6uRW/ZRmTGHlQYtrsSl7ubuE5F8CD1z+s1n4RHNuTtuA==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" + "acorn": "^8.15.0", + "acorn-import-attributes": "^1.9.5", + "cjs-module-lexer": "^2.2.0", + "module-details-from-path": "^1.0.4" }, "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" + "node": ">=18" } }, "node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.6.1.tgz", - "integrity": "sha512-r86ut4T1e8vNwB35CqCcKd45yzqH6/6Wzvpk2/cZB8PsPLlZFTvrh8yfOS3CYZYcUmAx4hHTZJ8AO8Dj8nrdhw==", + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz", + "integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==", "license": "Apache-2.0", - "peer": true, "dependencies": { - "@opentelemetry/core": "2.6.1", - "@opentelemetry/resources": "2.6.1", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/resources": "2.7.1", "@opentelemetry/semantic-conventions": "^1.29.0" }, "engines": { @@ -14892,29 +13626,14 @@ } }, "node_modules/@opentelemetry/sdk-trace-node": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.2.0.tgz", - "integrity": "sha512-+OaRja3f0IqGG2kptVeYsrZQK9nKRSpfFrKtRBq4uh6nIB8bTBgaGvYQrQoRrQWQMA5dK5yLhDMDc0dvYvCOIQ==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/context-async-hooks": "2.2.0", - "@opentelemetry/core": "2.2.0", - "@opentelemetry/sdk-trace-base": "2.2.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.0.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/core": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", - "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.7.1.tgz", + "integrity": "sha512-pCpQxU68lV+I9s9svqMyVu5iHdDDUnqUpSxqwyCU8A9ejEsSnMPCbearwsUO4yk08ZJzAIUCFuReMdVQvHrdvg==", "license": "Apache-2.0", "dependencies": { - "@opentelemetry/semantic-conventions": "^1.29.0" + "@opentelemetry/context-async-hooks": "2.7.1", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/sdk-trace-base": "2.7.1" }, "engines": { "node": "^18.19.0 || >=20.6.0" @@ -14923,39 +13642,6 @@ "@opentelemetry/api": ">=1.0.0 <1.10.0" } }, - "node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/resources": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", - "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, - "node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/sdk-trace-base": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", - "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", - "license": "Apache-2.0", - "dependencies": { - "@opentelemetry/core": "2.2.0", - "@opentelemetry/resources": "2.2.0", - "@opentelemetry/semantic-conventions": "^1.29.0" - }, - "engines": { - "node": "^18.19.0 || >=20.6.0" - }, - "peerDependencies": { - "@opentelemetry/api": ">=1.3.0 <1.10.0" - } - }, "node_modules/@opentelemetry/semantic-conventions": { "version": "1.39.0", "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.39.0.tgz", @@ -28423,67 +27109,6 @@ "node": ">=14" } }, - "node_modules/gcp-metadata": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-5.3.0.tgz", - "integrity": "sha512-FNTkdNEnBdlqF2oatizolQqNANMrcqJt6AAYt99B3y1aLLC8Hc5IOBb+ZnnzllodEEf6xMBp6wRcBbc16fa65w==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "gaxios": "^5.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/gcp-metadata/node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/gcp-metadata/node_modules/gaxios": { - "version": "5.1.3", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-5.1.3.tgz", - "integrity": "sha512-95hVgBRgEIRQQQHIbnxBXeHbW4TqFk4ZDJW7wmVtvYar72FdhRIo1UGOLS2eRAKCPEdPBWu+M7+A33D9CdX9rA==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^5.0.0", - "is-stream": "^2.0.0", - "node-fetch": "^2.6.9" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/gcp-metadata/node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/generic-names": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/generic-names/-/generic-names-4.0.0.tgz", @@ -28677,9 +27302,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -32320,6 +30945,15 @@ "node": ">=0.10" } }, + "node_modules/launder": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz", + "integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==", + "license": "MIT", + "dependencies": { + "dayjs": "^1.11.7" + } + }, "node_modules/layout-base": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz", @@ -34926,52 +33560,6 @@ "node": ">=20.19.0" } }, - "node_modules/mongodb-memory-server-core/node_modules/gaxios": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.1.4.tgz", - "integrity": "sha512-bTIgTsM2bWn3XklZISBTQX7ZSddGW+IO3bMdGaemHZ3tbqExMENHLx6kKZ/KlejgrMtj8q7wBItt51yegqalrA==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "extend": "^3.0.2", - "https-proxy-agent": "^7.0.1", - "node-fetch": "^3.3.2" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/mongodb-memory-server-core/node_modules/gcp-metadata": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-7.0.1.tgz", - "integrity": "sha512-UcO3kefx6dCcZkgcTGgVOTFb7b1LlQ02hY1omMjjrrBzkajRMCFgYOjs7J71WqnuG1k2b+9ppGL7FsOfhZMQKQ==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "dependencies": { - "gaxios": "^7.0.0", - "google-logging-utils": "^1.0.0", - "json-bigint": "^1.0.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/mongodb-memory-server-core/node_modules/google-logging-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", - "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "engines": { - "node": ">=14" - } - }, "node_modules/mongodb-memory-server-core/node_modules/mongodb": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-7.1.1.tgz", @@ -35033,27 +33621,6 @@ "node": ">=20.19.0" } }, - "node_modules/mongodb-memory-server-core/node_modules/node-fetch": { - "version": "3.3.2", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", - "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "data-uri-to-buffer": "^4.0.0", - "fetch-blob": "^3.1.4", - "formdata-polyfill": "^4.0.10" - }, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/node-fetch" - } - }, "node_modules/mongoose": { "version": "8.23.1", "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.23.1.tgz", @@ -40655,15 +39222,16 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "node_modules/sanitize-html": { - "version": "2.17.3", - "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.3.tgz", - "integrity": "sha512-Kn4srCAo2+wZyvCNKCSyB2g8RQ8IkX/gQs2uqoSRNu5t9I2qvUyAVvRDiFUVAiX3N3PNuwStY0eNr+ooBHVWEg==", + "version": "2.17.4", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.4.tgz", + "integrity": "sha512-2HW7v2ol/uAM7sX4hbD8Z59OGWmAPrvjL8E71UWlBcj6m+kcF6ilQBLny+cIgY214QJeJT5tQuxKKqX0SQqjGQ==", "license": "MIT", "dependencies": { "deepmerge": "^4.2.2", "escape-string-regexp": "^4.0.0", "htmlparser2": "^10.1.0", "is-plain-object": "^5.0.0", + "launder": "^1.7.1", "parse-srcset": "^1.0.2", "postcss": "^8.3.11" } @@ -44112,9 +42680,9 @@ } }, "node_modules/workbox-build/node_modules/brace-expansion": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", - "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", "dev": true, "license": "MIT", "dependencies": { @@ -44608,9 +43176,9 @@ } }, "node_modules/ws": { - "version": "8.19.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", - "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz", + "integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==", "license": "MIT", "engines": { "node": ">=10.0.0" @@ -44956,7 +43524,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.0.1", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.1.86", + "@librechat/agents": "^3.1.87", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.29.0", "@opentelemetry/api": "^1.9.0", @@ -44967,7 +43535,7 @@ "@opentelemetry/instrumentation-mongoose": "^0.54.0", "@opentelemetry/instrumentation-undici": "^0.18.0", "@opentelemetry/resources": "^2.6.1", - "@opentelemetry/sdk-node": "^0.207.0", + "@opentelemetry/sdk-node": "^0.218.0", "@opentelemetry/semantic-conventions": "^1.39.0", "@smithy/node-http-handler": "^4.4.5", "ai-tokenizer": "^1.0.6", diff --git a/packages/api/package.json b/packages/api/package.json index 048df30ba1b..ed28ec32ce0 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -104,7 +104,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.0.1", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.1.86", + "@librechat/agents": "^3.1.87", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.29.0", "@opentelemetry/api": "^1.9.0", @@ -115,7 +115,7 @@ "@opentelemetry/instrumentation-mongoose": "^0.54.0", "@opentelemetry/instrumentation-undici": "^0.18.0", "@opentelemetry/resources": "^2.6.1", - "@opentelemetry/sdk-node": "^0.207.0", + "@opentelemetry/sdk-node": "^0.218.0", "@opentelemetry/semantic-conventions": "^1.39.0", "@smithy/node-http-handler": "^4.4.5", "ai-tokenizer": "^1.0.6", From 75d196f31264f729960e1743cc4c61665a9afbc7 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Mon, 18 May 2026 21:39:21 -0400 Subject: [PATCH 033/395] =?UTF-8?q?=F0=9F=93=A6=20chore:=20Bump=20`@librec?= =?UTF-8?q?hat/agents`=20to=20v3.1.88=20(#13187)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/package.json | 2 +- package-lock.json | 10 +++++----- packages/api/package.json | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/api/package.json b/api/package.json index ac86606ffdb..86de35e9dda 100644 --- a/api/package.json +++ b/api/package.json @@ -46,7 +46,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.0.1", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.1.87", + "@librechat/agents": "^3.1.88", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/package-lock.json b/package-lock.json index 01bedbc0085..4c806b318f0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,7 +61,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.0.1", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.1.87", + "@librechat/agents": "^3.1.88", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -12221,9 +12221,9 @@ } }, "node_modules/@librechat/agents": { - "version": "3.1.87", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.87.tgz", - "integrity": "sha512-PPtIjPD2Px84sbQj5kuFxg7/OsDfSrzgmwn5g01XM0Y1O3HLEuK3ynPI1hDB3L++Kt1d2vihLToybMC3L8Y6Tw==", + "version": "3.1.88", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.88.tgz", + "integrity": "sha512-ROEKzO90bkSh5Bx1iUy8WRUiwvQZp1kKF48e12kI9BPSwbzn730wotvsnsekV2ECIHEWFYXgq8sk1CGmzzMrrA==", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.92.0", @@ -43524,7 +43524,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.0.1", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.1.87", + "@librechat/agents": "^3.1.88", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.29.0", "@opentelemetry/api": "^1.9.0", diff --git a/packages/api/package.json b/packages/api/package.json index ed28ec32ce0..bffda086be6 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -104,7 +104,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.0.1", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.1.87", + "@librechat/agents": "^3.1.88", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.29.0", "@opentelemetry/api": "^1.9.0", From 9107000161cb049cd403130be9d7c91369ff6b32 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 19 May 2026 09:43:03 -0400 Subject: [PATCH 034/395] =?UTF-8?q?=F0=9F=91=9F=20feat:=20Eager=20Executio?= =?UTF-8?q?n=20of=20Tool=20Calls=20(#13192)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 📦 chore: Bump `@librechat/agents` to v3.1.89 * feat: enable eagerEventToolExecution in createRun function --- api/package.json | 2 +- package-lock.json | 10 +++++----- packages/api/package.json | 2 +- packages/api/src/agents/run.ts | 3 ++- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/api/package.json b/api/package.json index 86de35e9dda..1b0e3651c7a 100644 --- a/api/package.json +++ b/api/package.json @@ -46,7 +46,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.0.1", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.1.88", + "@librechat/agents": "^3.1.89", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/package-lock.json b/package-lock.json index 4c806b318f0..f6b57a3865c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,7 +61,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.0.1", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.1.88", + "@librechat/agents": "^3.1.89", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -12221,9 +12221,9 @@ } }, "node_modules/@librechat/agents": { - "version": "3.1.88", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.88.tgz", - "integrity": "sha512-ROEKzO90bkSh5Bx1iUy8WRUiwvQZp1kKF48e12kI9BPSwbzn730wotvsnsekV2ECIHEWFYXgq8sk1CGmzzMrrA==", + "version": "3.1.89", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.89.tgz", + "integrity": "sha512-CQRaLdCjyE4Pw6FowfSEBuq5K1B7YZ4cK3JpD/EW/LHl2SCU/Un9S1woiVpw05eIKaMwv28RfcW7yC1JS27PpQ==", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.92.0", @@ -43524,7 +43524,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.0.1", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.1.88", + "@librechat/agents": "^3.1.89", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.29.0", "@opentelemetry/api": "^1.9.0", diff --git a/packages/api/package.json b/packages/api/package.json index bffda086be6..78629b76aef 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -104,7 +104,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.0.1", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.1.88", + "@librechat/agents": "^3.1.89", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.29.0", "@opentelemetry/api": "^1.9.0", diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index d90a623d27a..b0cc3105b61 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -987,9 +987,10 @@ export async function createRun({ graphConfig, tokenCounter, customHandlers, - indexTokenCountMap, initialSessions, calibrationRatio, + indexTokenCountMap, + eagerEventToolExecution: { enabled: true }, ...(enableToolOutputReferences && { toolOutputReferences: { enabled: true }, }), From 909329a7e86d2f002159a0a59cd80dbadf6ccb90 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 19 May 2026 09:44:14 -0400 Subject: [PATCH 035/395] =?UTF-8?q?=F0=9F=8D=AA=20feat:=20Add=20Session=20?= =?UTF-8?q?Cookie=20Secure=20Override=20(#13189)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: add session cookie secure override * chore: remove empty whitespace --- .env.example | 4 ++++ packages/api/src/oauth/csrf.spec.ts | 29 +++++++++++++++++++++++++++++ packages/api/src/oauth/csrf.ts | 11 +++++++++-- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/.env.example b/.env.example index e1608ec9073..3d4e372338d 100644 --- a/.env.example +++ b/.env.example @@ -488,6 +488,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 diff --git a/packages/api/src/oauth/csrf.spec.ts b/packages/api/src/oauth/csrf.spec.ts index b56f1fd38fb..1199dea2976 100644 --- a/packages/api/src/oauth/csrf.spec.ts +++ b/packages/api/src/oauth/csrf.spec.ts @@ -5,6 +5,7 @@ describe('shouldUseSecureCookie', () => { beforeEach(() => { process.env = { ...originalEnv }; + delete process.env.SESSION_COOKIE_SECURE; }); afterAll(() => { @@ -29,6 +30,34 @@ describe('shouldUseSecureCookie', () => { expect(shouldUseSecureCookie()).toBe(false); }); + it('should return true when SESSION_COOKIE_SECURE=true', () => { + process.env.NODE_ENV = 'development'; + process.env.DOMAIN_SERVER = 'http://localhost:3080'; + process.env.SESSION_COOKIE_SECURE = 'true'; + expect(shouldUseSecureCookie()).toBe(true); + }); + + it('should return false when SESSION_COOKIE_SECURE=false', () => { + process.env.NODE_ENV = 'production'; + process.env.DOMAIN_SERVER = 'http://10.0.0.5:3080'; + process.env.SESSION_COOKIE_SECURE = 'false'; + expect(shouldUseSecureCookie()).toBe(false); + }); + + it('should trim and normalize SESSION_COOKIE_SECURE values', () => { + process.env.NODE_ENV = 'development'; + process.env.DOMAIN_SERVER = 'http://localhost:3080'; + process.env.SESSION_COOKIE_SECURE = ' TRUE '; + expect(shouldUseSecureCookie()).toBe(true); + }); + + it('should ignore invalid SESSION_COOKIE_SECURE values', () => { + process.env.NODE_ENV = 'production'; + process.env.DOMAIN_SERVER = 'https://myapp.example.com'; + process.env.SESSION_COOKIE_SECURE = 'yes'; + expect(shouldUseSecureCookie()).toBe(true); + }); + describe('localhost detection in production', () => { beforeEach(() => { process.env.NODE_ENV = 'production'; diff --git a/packages/api/src/oauth/csrf.ts b/packages/api/src/oauth/csrf.ts index 6ed63968d1b..d2e0540c5d8 100644 --- a/packages/api/src/oauth/csrf.ts +++ b/packages/api/src/oauth/csrf.ts @@ -1,5 +1,6 @@ import crypto from 'crypto'; import type { Request, Response, NextFunction } from 'express'; +import { isEnabled } from '~/utils/common'; export const OAUTH_CSRF_COOKIE = 'oauth_csrf'; export const OAUTH_CSRF_MAX_AGE = 10 * 60 * 1000; @@ -10,11 +11,17 @@ export const OAUTH_SESSION_COOKIE_PATH = '/api'; /** * Determines if secure cookies should be used. - * Returns `true` in production unless the server is running on localhost (HTTP). - * This allows cookies to work on `http://localhost` during local development + * SESSION_COOKIE_SECURE=true/false explicitly overrides the environment heuristic. + * Returns `true` in production unless DOMAIN_SERVER uses a localhost-style hostname. + * This allows cookies to work on localhost during local development * even when `NODE_ENV=production` (common in Docker Compose setups). */ export function shouldUseSecureCookie(): boolean { + const secureOverride = process.env.SESSION_COOKIE_SECURE?.trim().toLowerCase(); + if (secureOverride === 'true' || secureOverride === 'false') { + return isEnabled(secureOverride); + } + const isProduction = process.env.NODE_ENV === 'production'; const domainServer = process.env.DOMAIN_SERVER || ''; From 2414e9c7d2159e193ddeb21affb1306727cfbb44 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 19 May 2026 09:48:12 -0400 Subject: [PATCH 036/395] =?UTF-8?q?=F0=9F=9B=9F=20refactor:=20Gracefully?= =?UTF-8?q?=20Skip=20Unavailable=20Web=20Search=20Rerankers=20(#13191)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/api/src/web/web.spec.ts | 47 +++++++++++++++++++++++++++++--- packages/api/src/web/web.ts | 13 ++++----- 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/packages/api/src/web/web.spec.ts b/packages/api/src/web/web.spec.ts index a33a6e1eb11..4ba9e70e480 100644 --- a/packages/api/src/web/web.spec.ts +++ b/packages/api/src/web/web.spec.ts @@ -689,8 +689,7 @@ describe('web.ts', () => { const originalEnv = process.env; process.env = { ...originalEnv, - SERPER_API_KEY: 'test-key', - // Missing other keys to force authentication failure + JINA_API_KEY: 'test-key', }; // Initialize webSearchConfig with environment variable references @@ -710,10 +709,9 @@ describe('web.ts', () => { mockLoadAuthValues.mockImplementation(({ authFields }) => { const result: Record = {}; authFields.forEach((field: string) => { - if (field === 'SERPER_API_KEY') { + if (field === 'JINA_API_KEY') { result[field] = 'test-key'; } - // Other fields are intentionally missing }); return Promise.resolve(result); }); @@ -1106,6 +1104,47 @@ describe('web.ts', () => { expect(cohereCalls.length).toBe(0); }); + it('should fallback to no reranker when rerankerType is omitted and no reranker is authenticated', async () => { + const webSearchConfig: TCustomConfig['webSearch'] = { + searxngInstanceUrl: '${SEARXNG_INSTANCE_URL}', + searxngApiKey: '${SEARXNG_API_KEY}', + firecrawlApiKey: '${FIRECRAWL_API_KEY}', + firecrawlApiUrl: '${FIRECRAWL_API_URL}', + jinaApiKey: '${JINA_API_KEY}', + jinaApiUrl: '${JINA_API_URL}', + cohereApiKey: '${COHERE_API_KEY}', + safeSearch: SafeSearchTypes.MODERATE, + searchProvider: 'searxng' as SearchProviders, + scraperProvider: 'firecrawl' as ScraperProviders, + }; + + mockLoadAuthValues.mockImplementation(({ authFields }) => { + const result: Record = {}; + authFields.forEach((field: string) => { + if (field === 'SEARXNG_INSTANCE_URL') { + result[field] = 'https://search.example'; + } else if (field === 'SEARXNG_API_KEY') { + result[field] = 'searxng-api-key'; + } else if (field === 'FIRECRAWL_API_KEY') { + result[field] = 'firecrawl-api-key'; + } else if (field === 'FIRECRAWL_API_URL') { + result[field] = 'https://api.firecrawl.dev'; + } + }); + return Promise.resolve(result); + }); + + const result = await loadWebSearchAuth({ + userId, + webSearchConfig, + loadAuthValues: mockLoadAuthValues, + }); + + expect(result.authenticated).toBe(true); + expect(result.authResult.rerankerType).toBe('none'); + expect(result.authTypes).toContainEqual(['rerankers', AuthType.SYSTEM_DEFINED]); + }); + it('should handle invalid specified service gracefully', async () => { // Initialize a webSearchConfig with an invalid searchProvider const webSearchConfig: TCustomConfig['webSearch'] = { diff --git a/packages/api/src/web/web.ts b/packages/api/src/web/web.ts index 06d65d4d985..d43649947dc 100644 --- a/packages/api/src/web/web.ts +++ b/packages/api/src/web/web.ts @@ -7,11 +7,7 @@ import { extractVariableName, } from 'librechat-data-provider'; import { webSearchAuth } from '@librechat/data-schemas'; -import type { - RerankerTypes, - TCustomConfig, - TWebSearchConfig, -} from 'librechat-data-provider'; +import type { RerankerTypes, TCustomConfig, TWebSearchConfig } from 'librechat-data-provider'; import type { TWebSearchKeys, TWebSearchCategories } from '@librechat/data-schemas'; import { isSSRFTarget, resolveHostnameSSRF } from '../auth'; @@ -209,8 +205,7 @@ export async function loadWebSearchAuth({ const isUserProvidedOptInUrlKey = originalKey != null && USER_PROVIDED_OPT_IN_URL_KEYS.has(originalKey); const isUserProvidedUrlEnabled = - isUserProvidedUrlKey || - (isUserProvidedOptInUrlKey && isUserProvidedEnabled(field)); + isUserProvidedUrlKey || (isUserProvidedOptInUrlKey && isUserProvidedEnabled(field)); let contributed = false; if (isUserProvidedOptInUrlKey && isFieldUserProvided && !isUserProvidedUrlEnabled) { @@ -253,6 +248,10 @@ export async function loadWebSearchAuth({ continue; } } + if (category === SearchCategories.RERANKERS && !webSearchConfig?.rerankerType) { + authResult.rerankerType = 'none' as RerankerTypes; + return [true, false]; + } return [false, isUserProvided]; } From 749eb06e67481f440995f0b6390867582c619bf2 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 19 May 2026 17:16:37 -0400 Subject: [PATCH 037/395] =?UTF-8?q?=F0=9F=A7=AD=20fix:=20Reduce=20MCP=20Re?= =?UTF-8?q?gistry=20ACL=20Lookups=20(#13195)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/server/controllers/agents/v1.js | 10 +- api/server/services/MCP.js | 8 +- api/server/services/__tests__/MCP.spec.js | 21 ++++- packages/api/src/acl/accessControlService.ts | 60 ++++++++++-- .../src/mcp/registry/MCPServersRegistry.ts | 11 ++- .../ServerConfigsRepositoryInterface.ts | 2 +- .../__tests__/ServerConfigsDB.test.ts | 11 +++ .../src/mcp/registry/db/ServerConfigsDB.ts | 91 +++++++++---------- packages/data-schemas/src/methods/agent.ts | 46 ++++++++++ 9 files changed, 190 insertions(+), 70 deletions(-) diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index 8dbe881fbb5..997ced213f9 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -189,6 +189,7 @@ 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 {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 @@ -197,6 +198,7 @@ const isSubagentsCapabilityEnabled = (req) => { const filterAuthorizedTools = async ({ tools, userId, + role, availableTools, existingTools, configServers, @@ -219,7 +221,9 @@ const filterAuthorizedTools = async ({ 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', @@ -387,6 +391,7 @@ const createAgentHandler = async (req, res) => { agentData.tools = await filterAuthorizedTools({ tools, userId, + role: req.user.role, availableTools, configServers, }); @@ -620,6 +625,7 @@ const updateAgentHandler = async (req, res) => { const approvedNew = await filterAuthorizedTools({ tools: newMCPTools, userId: req.user.id, + role: req.user.role, availableTools, configServers, }); @@ -781,6 +787,7 @@ const duplicateAgentHandler = async (req, res) => { newAgentData.tools = await filterAuthorizedTools({ tools: newAgentData.tools, userId, + role: req.user.role, availableTools, existingTools: newAgentData.tools, configServers, @@ -1155,6 +1162,7 @@ const revertAgentVersionHandler = async (req, res) => { const filteredTools = await filterAuthorizedTools({ tools: updatedAgent.tools, userId: req.user.id, + role: req.user.role, availableTools, existingTools: updatedAgent.tools, configServers, diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js index f2e845b927e..c691c4330a4 100644 --- a/api/server/services/MCP.js +++ b/api/server/services/MCP.js @@ -98,6 +98,10 @@ async function resolveAllMcpConfigs(userId, user) { error, ); } + if (user?.role) { + return await registry.getAllServerConfigs(userId, configServers, user.role); + } + return await registry.getAllServerConfigs(userId, configServers); } @@ -728,7 +732,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(); diff --git a/api/server/services/__tests__/MCP.spec.js b/api/server/services/__tests__/MCP.spec.js index 39e99d54ac9..2de88cfcd05 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(), @@ -99,9 +110,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/packages/api/src/acl/accessControlService.ts b/packages/api/src/acl/accessControlService.ts index 57d072561ac..eeed3a6a2f5 100644 --- a/packages/api/src/acl/accessControlService.ts +++ b/packages/api/src/acl/accessControlService.ts @@ -1,13 +1,16 @@ -import { Types, ClientSession, DeleteResult } from 'mongoose'; -import { AllMethods, IAclEntry, createMethods, logger } from '@librechat/data-schemas'; +import { Types } from 'mongoose'; +import { createMethods, logger } from '@librechat/data-schemas'; import { AccessRoleIds, PrincipalType, ResourceType } from 'librechat-data-provider'; +import type { AllMethods, IAclEntry } from '@librechat/data-schemas'; +import type { ClientSession, DeleteResult } from 'mongoose'; + +import type { ResolvedPrincipal } from '~/types/principal'; export class AccessControlService { private _dbMethods: AllMethods; - private _aclModel; + constructor(mongoose: typeof import('mongoose')) { this._dbMethods = createMethods(mongoose); - this._aclModel = mongoose.models.AclEntry; } /** @@ -122,6 +125,44 @@ export class AccessControlService { role?: string; resourceType: string; requiredPermissions: number; + }): Promise { + try { + const principalsList = await this.getUserPrincipals({ userId, role }); + return await this.findAccessibleResourcesForPrincipals({ + principalsList, + resourceType, + requiredPermissions, + }); + } catch (error) { + if (error instanceof Error) { + logger.error(`[PermissionService.findAccessibleResources] Error: ${error.message}`); + // Re-throw validation errors + if (error.message.includes('requiredPermissions must be')) { + throw error; + } + } + return []; + } + } + + public async getUserPrincipals({ + userId, + role, + }: { + userId: string | Types.ObjectId; + role?: string; + }): Promise { + return await this._dbMethods.getUserPrincipals({ userId, role }); + } + + public async findAccessibleResourcesForPrincipals({ + principalsList, + resourceType, + requiredPermissions, + }: { + principalsList: ResolvedPrincipal[]; + resourceType: string; + requiredPermissions: number; }): Promise { try { if (typeof requiredPermissions !== 'number' || requiredPermissions < 1) { @@ -130,12 +171,10 @@ export class AccessControlService { this.validateResourceType(resourceType as ResourceType); - // Get all principals for the user (user + groups + public) - const principalsList = await this._dbMethods.getUserPrincipals({ userId, role }); - if (principalsList.length === 0) { return []; } + return await this._dbMethods.findAccessibleResources( principalsList, resourceType, @@ -143,8 +182,9 @@ export class AccessControlService { ); } catch (error) { if (error instanceof Error) { - logger.error(`[PermissionService.findAccessibleResources] Error: ${error.message}`); - // Re-throw validation errors + logger.error( + `[PermissionService.findAccessibleResourcesForPrincipals] Error: ${error.message}`, + ); if (error.message.includes('requiredPermissions must be')) { throw error; } @@ -266,7 +306,7 @@ export class AccessControlService { throw new Error(`Invalid resource ID: ${resourceId}`); } - const result = await this._aclModel.deleteMany({ + const result = await this._dbMethods.deleteAclEntries({ resourceType, resourceId, }); diff --git a/packages/api/src/mcp/registry/MCPServersRegistry.ts b/packages/api/src/mcp/registry/MCPServersRegistry.ts index 1063071ac61..534cb3fd8b2 100644 --- a/packages/api/src/mcp/registry/MCPServersRegistry.ts +++ b/packages/api/src/mcp/registry/MCPServersRegistry.ts @@ -170,11 +170,12 @@ export class MCPServersRegistry { public async getAllServerConfigs( userId?: string, configServers?: Record, + role?: string, ): Promise> { if (configServers == null || !Object.keys(configServers).length) { - return this.getBaseServerConfigs(userId); + return this.getBaseServerConfigs(userId, role); } - const base = await this.getBaseServerConfigs(userId); + const base = await this.getBaseServerConfigs(userId, role); return { ...configServers, ...base }; } @@ -185,6 +186,7 @@ export class MCPServersRegistry { */ private async getBaseServerConfigs( userId?: string, + role?: string, ): Promise> { const cacheKey = userId ?? '__no_user__'; @@ -197,7 +199,7 @@ export class MCPServersRegistry { return pending; } - const fetchPromise = this.fetchBaseServerConfigs(cacheKey, userId); + const fetchPromise = this.fetchBaseServerConfigs(cacheKey, userId, role); this.pendingGetAllPromises.set(cacheKey, fetchPromise); try { @@ -210,10 +212,11 @@ export class MCPServersRegistry { private async fetchBaseServerConfigs( cacheKey: string, userId?: string, + role?: string, ): Promise> { const result = { ...(await this.cacheConfigsRepo.getAll()), - ...(await this.dbConfigsRepo.getAll(userId)), + ...(await this.dbConfigsRepo.getAll(userId, role)), }; await this.readThroughCacheAll.set(cacheKey, result); diff --git a/packages/api/src/mcp/registry/ServerConfigsRepositoryInterface.ts b/packages/api/src/mcp/registry/ServerConfigsRepositoryInterface.ts index 4bf0fdd615c..e55f66faa70 100644 --- a/packages/api/src/mcp/registry/ServerConfigsRepositoryInterface.ts +++ b/packages/api/src/mcp/registry/ServerConfigsRepositoryInterface.ts @@ -19,7 +19,7 @@ export interface IServerConfigsRepositoryInterface { get(serverName: string, userId?: string): Promise; //ACL Entry get all accessible mcp config definitions + any mcp configured with agents - getAll(userId?: string): Promise>; + getAll(userId?: string, role?: string): Promise>; reset(): Promise; } diff --git a/packages/api/src/mcp/registry/__tests__/ServerConfigsDB.test.ts b/packages/api/src/mcp/registry/__tests__/ServerConfigsDB.test.ts index 38ed51cd997..e8c089df340 100644 --- a/packages/api/src/mcp/registry/__tests__/ServerConfigsDB.test.ts +++ b/packages/api/src/mcp/registry/__tests__/ServerConfigsDB.test.ts @@ -963,6 +963,17 @@ describe('ServerConfigsDB', () => { }); describe('user access', () => { + it('should reuse resolved principals for direct and agent access lookups', async () => { + const findByIdSpy = jest.spyOn(mongoose.models.User, 'findById'); + + try { + await serverConfigsDB.getAll(userId, 'USER'); + expect(findByIdSpy).toHaveBeenCalledTimes(1); + } finally { + findByIdSpy.mockRestore(); + } + }); + it('should return servers directly accessible by user', async () => { const config1 = createSSEConfig('User Server 1'); const config2 = createSSEConfig('User Server 2'); diff --git a/packages/api/src/mcp/registry/db/ServerConfigsDB.ts b/packages/api/src/mcp/registry/db/ServerConfigsDB.ts index d027fc273e3..30e01857210 100644 --- a/packages/api/src/mcp/registry/db/ServerConfigsDB.ts +++ b/packages/api/src/mcp/registry/db/ServerConfigsDB.ts @@ -1,12 +1,13 @@ import { Types } from 'mongoose'; +import { logger, encryptV2, decryptV2, createMethods } from '@librechat/data-schemas'; import { ResourceType, AccessRoleIds, PrincipalType, PermissionBits, } from 'librechat-data-provider'; -import { logger, encryptV2, decryptV2, createMethods } from '@librechat/data-schemas'; -import type { AllMethods, IAgent, MCPServerDocument } from '@librechat/data-schemas'; +import type { AllMethods, MCPServerDocument } from '@librechat/data-schemas'; + import type { IServerConfigsRepositoryInterface } from '~/mcp/registry/ServerConfigsRepositoryInterface'; import type { ParsedServerConfig, AddServerResult } from '~/mcp/types'; import { AccessControlService } from '~/acl/accessControlService'; @@ -59,13 +60,11 @@ function sanitizeCredentialPlaceholders( export class ServerConfigsDB implements IServerConfigsRepositoryInterface { private _dbMethods: AllMethods; private _aclService: AccessControlService; - private _mongoose: typeof import('mongoose'); constructor(mongoose: typeof import('mongoose')) { if (!mongoose) { throw new Error('ServerConfigsDB requires mongoose instance'); } - this._mongoose = mongoose; this._dbMethods = createMethods(mongoose); this._aclService = new AccessControlService(mongoose); } @@ -98,13 +97,10 @@ export class ServerConfigsDB implements IServerConfigsRepositoryInterface { return false; } - const Agent = this._mongoose.model('Agent'); - const exists = await Agent.exists({ - _id: { $in: accessibleAgentIds }, - mcpServerNames: serverName, + return await this._dbMethods.hasAgentWithMCPServerName({ + agentIds: accessibleAgentIds, + serverName, }); - - return exists !== null; } /** @@ -327,57 +323,52 @@ export class ServerConfigsDB implements IServerConfigsRepositoryInterface { * @param userId optional user id. if not provided only publicly shared mcp configs will be returned * @returns record of parsed configs */ - public async getAll(userId?: string): Promise> { + public async getAll(userId?: string, role?: string): Promise> { let directlyAccessibleMCPIds: Types.ObjectId[] = []; + let accessibleAgentIds: Types.ObjectId[] = []; + if (!userId) { logger.debug(`[ServerConfigsDB.getAll] fetching all publicly shared mcp servers`); - directlyAccessibleMCPIds = await this._aclService.findPubliclyAccessibleResources({ - resourceType: ResourceType.MCPSERVER, - requiredPermissions: PermissionBits.VIEW, - }); + [directlyAccessibleMCPIds, accessibleAgentIds] = await Promise.all([ + this._aclService.findPubliclyAccessibleResources({ + resourceType: ResourceType.MCPSERVER, + requiredPermissions: PermissionBits.VIEW, + }), + this._aclService.findPubliclyAccessibleResources({ + resourceType: ResourceType.AGENT, + requiredPermissions: PermissionBits.VIEW, + }), + ]); } else { logger.debug( `[ServerConfigsDB.getAll] fetching mcp servers directly shared with the user with ID: ${userId}`, ); - directlyAccessibleMCPIds = await this._aclService.findAccessibleResources({ - userId, - requiredPermissions: PermissionBits.VIEW, - resourceType: ResourceType.MCPSERVER, - }); - } - - let agentMCPServerNames: string[] = []; - let accessibleAgentIds: Types.ObjectId[] = []; - - if (!userId) { - accessibleAgentIds = await this._aclService.findPubliclyAccessibleResources({ - resourceType: ResourceType.AGENT, - requiredPermissions: PermissionBits.VIEW, - }); - } else { - accessibleAgentIds = await this._aclService.findAccessibleResources({ - userId, - requiredPermissions: PermissionBits.VIEW, - resourceType: ResourceType.AGENT, - }); - } - - if (accessibleAgentIds.length > 0) { - const Agent = this._mongoose.model('Agent'); - const agentsWithMCP = await Agent.find( - { - _id: { $in: accessibleAgentIds }, - mcpServerNames: { $exists: true, $not: { $size: 0 } }, - }, - { mcpServerNames: 1 }, - ).lean[]>(); - - agentMCPServerNames = [...new Set(agentsWithMCP.flatMap((a) => a.mcpServerNames ?? []))]; + const principalsList = await this._aclService.getUserPrincipals({ userId, role }); + [directlyAccessibleMCPIds, accessibleAgentIds] = await Promise.all([ + this._aclService.findAccessibleResourcesForPrincipals({ + principalsList, + requiredPermissions: PermissionBits.VIEW, + resourceType: ResourceType.MCPSERVER, + }), + this._aclService.findAccessibleResourcesForPrincipals({ + principalsList, + requiredPermissions: PermissionBits.VIEW, + resourceType: ResourceType.AGENT, + }), + ]); } - const directResults = await this._dbMethods.getListMCPServersByIds({ + const agentMCPServerNamesPromise: Promise = + accessibleAgentIds.length > 0 + ? this._dbMethods.getMCPServerNamesByAgentIds(accessibleAgentIds) + : Promise.resolve([]); + const directResultsPromise = this._dbMethods.getListMCPServersByIds({ ids: directlyAccessibleMCPIds, }); + const [agentMCPServerNames, directResults] = await Promise.all([ + agentMCPServerNamesPromise, + directResultsPromise, + ]); const parsedConfigs: Record = {}; const directData = directResults.data || []; diff --git a/packages/data-schemas/src/methods/agent.ts b/packages/data-schemas/src/methods/agent.ts index 9078616c93c..37015c21c4c 100644 --- a/packages/data-schemas/src/methods/agent.ts +++ b/packages/data-schemas/src/methods/agent.ts @@ -282,6 +282,50 @@ export function createAgentMethods(mongoose: typeof import('mongoose'), deps: Ag return await Agent.find(searchParameter).lean(); } + async function hasAgentWithMCPServerName({ + agentIds, + serverName, + }: { + agentIds: Types.ObjectId[]; + serverName: string; + }): Promise { + if (agentIds.length === 0) { + return false; + } + + const Agent = mongoose.models.Agent as Model; + const agent = await Agent.exists({ + _id: { $in: agentIds }, + mcpServerNames: serverName, + }); + + return agent !== null; + } + + async function getMCPServerNamesByAgentIds(agentIds: Types.ObjectId[]): Promise { + if (agentIds.length === 0) { + return []; + } + + const Agent = mongoose.models.Agent as Model; + const agents = await Agent.find( + { + _id: { $in: agentIds }, + mcpServerNames: { $exists: true, $not: { $size: 0 } }, + }, + { mcpServerNames: 1 }, + ).lean>>(); + + const serverNames = new Set(); + for (const agent of agents) { + for (const serverName of agent.mcpServerNames ?? []) { + serverNames.add(serverName); + } + } + + return Array.from(serverNames); + } + /** * Update an agent with new data without overwriting existing properties, * or create a new agent if it doesn't exist. @@ -823,6 +867,8 @@ export function createAgentMethods(mongoose: typeof import('mongoose'), deps: Ag getAgent, getAgents, createAgent, + hasAgentWithMCPServerName, + getMCPServerNamesByAgentIds, updateAgent, deleteAgent, deleteUserAgents, From 0b5530be41e69c45f25648219654e5e9fe69a077 Mon Sep 17 00:00:00 2001 From: Vinicius Dittgen Date: Tue, 19 May 2026 18:17:02 -0300 Subject: [PATCH 038/395] =?UTF-8?q?=E2=9B=B4=EF=B8=8F=20fix:=20Stop=20Doub?= =?UTF-8?q?le-Wrapping=20configYamlContent=20in=20Helm=20ConfigMap=20(#131?= =?UTF-8?q?98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- helm/librechat/templates/configmap.yaml | 2 +- helm/librechat/tests/configmap_render_test.sh | 131 ++++++++++++++++++ 2 files changed, 132 insertions(+), 1 deletion(-) create mode 100755 helm/librechat/tests/configmap_render_test.sh diff --git a/helm/librechat/templates/configmap.yaml b/helm/librechat/templates/configmap.yaml index dd80ce1bbb1..fe8d5b86d77 100755 --- a/helm/librechat/templates/configmap.yaml +++ b/helm/librechat/templates/configmap.yaml @@ -5,6 +5,6 @@ metadata: name: {{ include "librechat.fullname" $ }}-config data: librechat.yaml: | -{{ .Values.librechat.configYamlContent | toYaml | indent 4 }} +{{ .Values.librechat.configYamlContent | indent 4 }} {{- end }} \ No newline at end of file diff --git a/helm/librechat/tests/configmap_render_test.sh b/helm/librechat/tests/configmap_render_test.sh new file mode 100755 index 00000000000..d11175695e2 --- /dev/null +++ b/helm/librechat/tests/configmap_render_test.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Regression test for the librechat ConfigMap template. +# +# Background: at one point templates/configmap.yaml ran +# `.Values.librechat.configYamlContent | toYaml | indent 4`. Because the +# value is already a YAML literal string, toYaml re-wrapped it in another `|` +# block scalar, producing a mounted /app/librechat.yaml whose first line was +# a bare `|`. js-yaml "recovered" by returning the body as a string, so +# LibreChat silently fell back to internal defaults for every config block +# (endpoints, interface, modelSpecs, ...). +# +# This test renders the ConfigMap with a sample configYamlContent and asserts +# that the rendered librechat.yaml is a proper YAML object preserving nested +# keys. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +CHART_DIR="$(cd "${SCRIPT_DIR}/.." && pwd)" +VALUES_FILE="$(mktemp -t librechat-configmap-values.XXXXXX)" +RENDERED_FILE="$(mktemp -t librechat-configmap-render.XXXXXX)" +trap 'rm -f "${VALUES_FILE}" "${RENDERED_FILE}"' EXIT + +cat > "${VALUES_FILE}" <<'YAML' +librechat: + configYamlContent: | + version: 1.3.11 + endpoints: + agents: + disableBuilder: false +YAML + +if ! command -v helm >/dev/null 2>&1; then + echo "FAIL: helm not on PATH" >&2 + exit 1 +fi + +# Render only the chart's templates so dependent sub-charts don't need network access. +helm template librechat "${CHART_DIR}" \ + --show-only templates/configmap.yaml \ + -f "${VALUES_FILE}" > "${RENDERED_FILE}" + +# Pull the block scalar body that lives under `librechat.yaml: |`. +# awk: enter the block on the header line, then emit subsequent lines while +# they are indented (the body) and stop at the next unindented line. +BODY="$(awk ' + /^ librechat\.yaml: \|/ { in_block = 1; next } + in_block { + if ($0 ~ /^ /) { sub(/^ /, ""); print; next } + if ($0 ~ /^$/) { print; next } + exit + } +' "${RENDERED_FILE}")" + +if [[ -z "${BODY}" ]]; then + echo "FAIL: could not extract librechat.yaml body from rendered ConfigMap" >&2 + cat "${RENDERED_FILE}" >&2 + exit 1 +fi + +FIRST_LINE="$(printf '%s\n' "${BODY}" | awk 'NF { print; exit }')" +if [[ "${FIRST_LINE}" == "|" ]]; then + echo "FAIL: rendered librechat.yaml starts with a bare '|' — configYamlContent is double-wrapped" >&2 + echo "----- rendered body -----" >&2 + printf '%s\n' "${BODY}" >&2 + exit 1 +fi + +if ! printf '%s\n' "${BODY}" | grep -qE '^version: 1\.3\.11$'; then + echo "FAIL: expected top-level 'version: 1.3.11' in rendered librechat.yaml" >&2 + printf '%s\n' "${BODY}" >&2 + exit 1 +fi + +if ! printf '%s\n' "${BODY}" | grep -qE '^endpoints:$'; then + echo "FAIL: expected top-level 'endpoints:' key in rendered librechat.yaml" >&2 + printf '%s\n' "${BODY}" >&2 + exit 1 +fi + +if ! printf '%s\n' "${BODY}" | grep -qE '^ agents:$'; then + echo "FAIL: expected nested 'endpoints.agents' key in rendered librechat.yaml" >&2 + printf '%s\n' "${BODY}" >&2 + exit 1 +fi + +# If a YAML parser is available, do a real parse to confirm the body loads as +# an object (not a string) and that endpoints.agents survives the round trip. +PARSER="" +if command -v python3 >/dev/null 2>&1 && python3 -c 'import yaml' >/dev/null 2>&1; then + PARSER="python3" +elif command -v node >/dev/null 2>&1 && node -e 'require("js-yaml")' >/dev/null 2>&1; then + PARSER="node" +fi + +case "${PARSER}" in + python3) + BODY="${BODY}" python3 - <<'PY' +import os, sys, yaml +body = os.environ["BODY"] +doc = yaml.safe_load(body) +if not isinstance(doc, dict): + sys.stderr.write(f"FAIL: parsed librechat.yaml is {type(doc).__name__}, expected dict\n") + sys.exit(1) +agents = (doc.get("endpoints") or {}).get("agents") +if not isinstance(agents, dict) or "disableBuilder" not in agents: + sys.stderr.write(f"FAIL: endpoints.agents missing or malformed after parse: {agents!r}\n") + sys.exit(1) +PY + ;; + node) + BODY="${BODY}" node -e ' + const yaml = require("js-yaml"); + const doc = yaml.load(process.env.BODY); + if (doc === null || typeof doc !== "object" || Array.isArray(doc)) { + console.error("FAIL: parsed librechat.yaml is " + typeof doc + ", expected object"); + process.exit(1); + } + const agents = doc.endpoints && doc.endpoints.agents; + if (!agents || typeof agents.disableBuilder === "undefined") { + console.error("FAIL: endpoints.agents missing after parse: " + JSON.stringify(agents)); + process.exit(1); + } + ' + ;; + *) + echo "NOTE: skipping YAML-parser assertion (install python3+pyyaml or node+js-yaml to enable)" + ;; +esac + +echo "PASS: rendered librechat.yaml is a proper YAML document with nested keys preserved" From 2418f854df413ceeab625d3fb2f46dd7fdaeaa29 Mon Sep 17 00:00:00 2001 From: Peter Nancarrow <37298202+pnancarrow@users.noreply.github.com> Date: Tue, 19 May 2026 19:59:45 -0500 Subject: [PATCH 039/395] =?UTF-8?q?=F0=9F=93=A1=20fix:=20Handle=20Pre-Sess?= =?UTF-8?q?ion=20406=20for=20Optional=20SSE=20MCP=20Stream=20(#13202)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MCPConnectionAgentLifecycle.test.ts | 36 ++++++++++++++++--- packages/api/src/mcp/connection.ts | 2 +- 2 files changed, 32 insertions(+), 6 deletions(-) diff --git a/packages/api/src/mcp/__tests__/MCPConnectionAgentLifecycle.test.ts b/packages/api/src/mcp/__tests__/MCPConnectionAgentLifecycle.test.ts index c7b6b273ba1..99cfa2363a7 100644 --- a/packages/api/src/mcp/__tests__/MCPConnectionAgentLifecycle.test.ts +++ b/packages/api/src/mcp/__tests__/MCPConnectionAgentLifecycle.test.ts @@ -538,11 +538,15 @@ describe('MCPConnection SSE 404 handling – session-aware', () => { }); } - function fire404(conn: MCPConnection, transport: ReturnType) { + function fireSSEError( + conn: MCPConnection, + transport: ReturnType, + code = 404, + ) { ( conn as unknown as { setupTransportErrorHandlers: (t: unknown) => void } ).setupTransportErrorHandlers(transport); - const sseError = Object.assign(new Error('Failed to open SSE stream'), { code: 404 }); + const sseError = Object.assign(new Error('Failed to open SSE stream'), { code }); transport.onerror?.(sseError); } @@ -556,7 +560,7 @@ describe('MCPConnection SSE 404 handling – session-aware', () => { const transport = makeTransportStub(); const emitSpy = jest.spyOn(conn, 'emit'); - fire404(conn, transport); + fireSSEError(conn, transport); expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('no session')); expect(emitSpy).not.toHaveBeenCalledWith('connectionChange', 'error'); @@ -567,7 +571,7 @@ describe('MCPConnection SSE 404 handling – session-aware', () => { const transport = makeTransportStub('existing-session-id'); const emitSpy = jest.spyOn(conn, 'emit'); - fire404(conn, transport); + fireSSEError(conn, transport); expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('session lost')); expect(emitSpy).toHaveBeenCalledWith('connectionChange', 'error'); @@ -578,10 +582,32 @@ describe('MCPConnection SSE 404 handling – session-aware', () => { const transport = makeTransportStub(''); const emitSpy = jest.spyOn(conn, 'emit'); - fire404(conn, transport); + fireSSEError(conn, transport); expect(emitSpy).not.toHaveBeenCalledWith('connectionChange', 'error'); }); + + it('treats a 406 before session establishment as an unsupported optional SSE stream', () => { + const conn = makeConn(); + const transport = makeTransportStub(); + const emitSpy = jest.spyOn(conn, 'emit'); + + fireSSEError(conn, transport, 406); + + expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('no session')); + expect(emitSpy).not.toHaveBeenCalledWith('connectionChange', 'error'); + }); + + it('falls through on a 406 when a session already exists', () => { + const conn = makeConn(); + const transport = makeTransportStub('existing-session-id'); + const emitSpy = jest.spyOn(conn, 'emit'); + + fireSSEError(conn, transport, 406); + + expect(mockLogger.warn).toHaveBeenCalledWith(expect.stringContaining('session lost')); + expect(emitSpy).toHaveBeenCalledWith('connectionChange', 'error'); + }); }); describe('MCPConnection SSE stream disconnect handling', () => { diff --git a/packages/api/src/mcp/connection.ts b/packages/api/src/mcp/connection.ts index 9f779d7e878..fb9481b8b1d 100644 --- a/packages/api/src/mcp/connection.ts +++ b/packages/api/src/mcp/connection.ts @@ -1283,7 +1283,7 @@ export class MCPConnection extends EventEmitter { isTransient, } = extractSSEErrorMessage(error); - if (errorCode === 400 || errorCode === 404 || errorCode === 405) { + if (errorCode === 400 || errorCode === 404 || errorCode === 405 || errorCode === 406) { const hasSession = 'sessionId' in transport && (transport as { sessionId?: string }).sessionId != null && From 9dd062e42ef640c638711a533254fd2fa1e0f95d Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 19 May 2026 21:58:42 -0400 Subject: [PATCH 040/395] =?UTF-8?q?=F0=9F=A7=AF=20fix:=20Harden=20Data=20R?= =?UTF-8?q?etention=20Semantics=20(#13049)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: support data retention for normal chats Add retentionMode config variable supporting "all" and "temporary" values. When "all" is set, data retention applies to all chats, not just temporary ones. Adds isTemporary field to conversations for proper filtering. Adapted to new TS method files in packages/data-schemas since upstream moved models out of api/models/. Based on danny-avila/LibreChat#10532 Co-Authored-By: WhammyLeaf <233105313+WhammyLeaf@users.noreply.github.com> (cherry picked from commit 30109e90b04c52a7a033986b72fb054b045accf1) * feat: extend data retention to files, tool calls, and shared links Add expiredAt field and TTL indexes to file, toolCall, and share schemas. Set expiredAt on tool calls, shared links, and file uploads when retentionMode is "all" or chat is temporary. (cherry picked from commit 48973752d353fcef5c68dee9d17344afd54aee9c) Co-Authored-By: Claude Opus 4.6 (1M context) * fix: lint/test (cherry picked from commit 310c514e6ae846a58c305ca8d899492f829e64cc) * fix: address code review feedback for data retention PR Critical: - Fix BookmarkMenu crash: restore optional chaining on conversation - Fix migration hazard: backward-compatible sidebar filter that also checks expiredAt for documents without isTemporary field Major: - Add logging to getRetentionExpiry error path, align with tools.js - Add tests for retentionMode: ALL in saveConvo and saveMessage - Fix share route: apply expiredAt for temporary chats too by querying the conversation's isTemporary flag server-side - Add assertions for getRetentionExpiry mocks in process tests Minor: - Fix ChatRoute isTemporaryChat to be strictly boolean via Boolean() - Fix stale test description (expired -> temporary) - Comment out retentionMode default in example yaml - Simplify verbose if/else to isTemporary === true - Add compound index on { user: 1, isTemporary: 1 } - Remove narrating comment from process.spec.js Co-Authored-By: Claude Opus 4.6 (1M context) (cherry picked from commit 6bad535f901922e4a89e08fb925ac06c1f0bd03b) * chore: fix typescript (cherry picked from commit 826527a46b95e769f5ee8095289a309968a68562) * fix: lint (cherry picked from commit 77817e80ea2e1c06e1321b9c85b38786022f039e) * fix: use mockSanitizeArtifactPath in retention test The 'getRetentionExpiry is called with the request object' test referenced an undefined `mockSanitizeFilename` identifier, breaking both lint (no-undef) and the test suite. Use the existing `mockSanitizeArtifactPath` mock that the surrounding tests already use, since `processCodeOutput` calls `sanitizeArtifactPath` (not `sanitizeFilename`) before invoking `getRetentionExpiry`. Co-Authored-By: Claude Opus 4.7 (1M context) (cherry picked from commit 52ea2da66d700fb4f6db91871c9d6d9acf1aab69) * fix: forward isTemporary from client for retention on file uploads and tool calls Server-side `getRetentionExpiry` (file uploads) and the tool-call controller both read `req.body.isTemporary`, but the file upload multipart form and the tool-call payload did not include that field. In `retentionMode: temporary` (default), files uploaded and tool calls created from temporary chats were therefore retained indefinitely. Forward the Recoil `isTemporary` flag in both client paths so the existing server checks can fire correctly. `ToolParams` gains an optional `isTemporary` field. Addresses Codex P1 review feedback on PR #29. Co-Authored-By: Claude Opus 4.7 (1M context) (cherry picked from commit 7e937df05ad3bd4f958e53c21b42084f80a151d2) * test: stub store.isTemporary in useFileHandling test mocks Previous commit added `useRecoilValue(store.isTemporary)` to the hook. The test file mocks `~/store` with only `ephemeralAgentByConvoId` and does not stub `useRecoilValue`, so all 7 cases threw "Invalid argument to useRecoilValue: expected an atom or selector but got undefined". Add a stub default export with `isTemporary` and a `useRecoilValue` mock returning `false`. Co-Authored-By: Claude Opus 4.7 (1M context) (cherry picked from commit eb1609537d7d8a13be20880d7c13ecff37b52686) * fix: harden data retention semantics * fix: provide sweep request context for expired files * fix: preserve temporary flags in all-retention updates * fix: honor assistant versions in retention sweeps * fix: retain non-temporary flags in all mode * fix: hide expired retained records * fix: propagate retained conversation expiry * fix: refresh meili retention cutoff * fix: prevent overlapping file sweeps * fix: show legacy retained conversations * fix: index legacy retained records * fix: harden retention cleanup edge cases * fix: count failed file storage sweeps * fix: preserve legacy temporary retention * fix: assign retention sweep worker deterministically * fix: hide expired shared links on reads * fix: prevent retention refresh after parent expiry * fix: break code output retention import cycle * fix: harden retention review findings * fix: ignore expired share duplicates * fix: reject expired retained share creation * fix: harden retention review edge cases * fix: address retention audit findings * fix: enforce expired conversation shares in all retention * fix: scope temporary upload flag to chat files * fix: address retention review findings * fix: address codex retention review findings * fix: tighten missing storage detection * test: remove unused file process spec bindings --------- Co-authored-by: WhammyLeaf <233105313+WhammyLeaf@users.noreply.github.com> Co-authored-by: Aron Gates Co-authored-by: Claude Opus 4.6 (1M context) --- api/app/clients/tools/structured/DALLE3.js | 8 +- api/app/clients/tools/structured/FluxAPI.js | 4 + .../structured/specs/imageTools-agent.spec.js | 93 +++++- api/db/utils.js | 7 +- api/db/utils.spec.js | 54 ++++ api/server/controllers/UserController.spec.js | 2 +- .../__tests__/UserController.mcpOAuth.spec.js | 2 +- .../controllers/__tests__/deleteUser.spec.js | 2 +- .../__tests__/maybeUninstallOAuthMCP.spec.js | 2 +- api/server/controllers/tools.js | 2 + api/server/experimental.js | 90 ++++-- api/server/index.js | 2 + api/server/routes/__tests__/share.spec.js | 262 +++++++++++++++ api/server/routes/convos.js | 1 + api/server/routes/files/files.agents.test.js | 2 +- api/server/routes/files/files.test.js | 2 +- api/server/routes/files/preview.spec.js | 2 +- api/server/routes/share.js | 53 ++- .../Code/__tests__/process-traversal.spec.js | 11 + api/server/services/Files/Code/process.js | 3 + .../services/Files/Code/process.spec.js | 6 + api/server/services/Files/process.js | 207 +++++++++--- api/server/services/Files/process.spec.js | 304 +++++++++++++++++- api/server/services/Files/retention.js | 21 ++ api/server/utils/import/importBatchBuilder.js | 47 ++- .../utils/import/importConversations.js | 12 +- api/server/utils/import/importers.spec.js | 19 ++ .../components/Chat/Menus/BookmarkMenu.tsx | 5 +- .../components/Messages/Content/RunCode.tsx | 21 +- .../Files/__tests__/useFileHandling.test.ts | 87 +++++ client/src/hooks/Files/useFileHandling.ts | 16 +- client/src/routes/ChatRoute.tsx | 3 +- client/src/utils/conversation.ts | 5 + client/src/utils/index.ts | 1 + librechat.example.yaml | 9 + packages/api/src/files/index.ts | 2 + packages/api/src/files/retention.spec.ts | 273 ++++++++++++++++ packages/api/src/files/retention.ts | 231 +++++++++++++ packages/api/src/files/sweep.spec.ts | 96 ++++++ packages/api/src/files/sweep.ts | 286 ++++++++++++++++ packages/data-provider/src/config.ts | 7 + packages/data-provider/src/schemas.ts | 3 + packages/data-provider/src/types/mutations.ts | 1 + packages/data-schemas/src/app/assistants.ts | 4 + packages/data-schemas/src/app/interface.ts | 1 + packages/data-schemas/src/app/service.spec.ts | 70 +++- .../src/methods/conversation.spec.ts | 247 ++++++++++++-- .../data-schemas/src/methods/conversation.ts | 67 +++- .../data-schemas/src/methods/file.spec.ts | 63 ++++ packages/data-schemas/src/methods/file.ts | 9 + .../data-schemas/src/methods/message.spec.ts | 76 ++++- packages/data-schemas/src/methods/message.ts | 34 +- .../data-schemas/src/methods/share.test.ts | 153 +++++++++ packages/data-schemas/src/methods/share.ts | 37 ++- .../src/models/plugins/mongoMeili.spec.ts | 274 +++++++++++++++- .../src/models/plugins/mongoMeili.ts | 97 +++++- packages/data-schemas/src/schema/convo.ts | 7 +- packages/data-schemas/src/schema/file.ts | 9 + packages/data-schemas/src/schema/message.ts | 6 +- packages/data-schemas/src/schema/share.ts | 5 + packages/data-schemas/src/schema/toolCall.ts | 5 + packages/data-schemas/src/types/convo.ts | 1 + packages/data-schemas/src/types/file.ts | 1 + packages/data-schemas/src/types/message.ts | 1 + packages/data-schemas/src/types/share.ts | 1 + packages/data-schemas/src/utils/index.ts | 1 + packages/data-schemas/src/utils/retention.ts | 33 ++ 67 files changed, 3298 insertions(+), 170 deletions(-) create mode 100644 api/server/routes/__tests__/share.spec.js create mode 100644 api/server/services/Files/retention.js create mode 100644 client/src/utils/conversation.ts create mode 100644 packages/api/src/files/retention.spec.ts create mode 100644 packages/api/src/files/retention.ts create mode 100644 packages/api/src/files/sweep.spec.ts create mode 100644 packages/api/src/files/sweep.ts create mode 100644 packages/data-schemas/src/utils/retention.ts diff --git a/api/app/clients/tools/structured/DALLE3.js b/api/app/clients/tools/structured/DALLE3.js index 8ce46323afd..0f885ed6361 100644 --- a/api/app/clients/tools/structured/DALLE3.js +++ b/api/app/clients/tools/structured/DALLE3.js @@ -4,7 +4,11 @@ const { v4: uuidv4 } = require('uuid'); const { ProxyAgent, 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, + createMinimalRetentionRequest, +} = require('@librechat/api'); const { FileContext, ContentTypes } = require('librechat-data-provider'); const dalle3JsonSchema = { @@ -49,6 +53,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; @@ -230,6 +235,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..e251b2da650 100644 --- a/api/app/clients/tools/structured/FluxAPI.js +++ b/api/app/clients/tools/structured/FluxAPI.js @@ -4,6 +4,7 @@ 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 { createMinimalRetentionRequest } = require('@librechat/api'); const { FileContext, ContentTypes } = require('librechat-data-provider'); const fluxApiJsonSchema = { @@ -110,6 +111,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} **/ @@ -343,6 +345,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); @@ -574,6 +577,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/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/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/server/controllers/UserController.spec.js b/api/server/controllers/UserController.spec.js index 30e6190e286..5e1419bde32 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', () => ({ diff --git a/api/server/controllers/__tests__/UserController.mcpOAuth.spec.js b/api/server/controllers/__tests__/UserController.mcpOAuth.spec.js index 2d23b4b02c3..ef605beaab7 100644 --- a/api/server/controllers/__tests__/UserController.mcpOAuth.spec.js +++ b/api/server/controllers/__tests__/UserController.mcpOAuth.spec.js @@ -67,7 +67,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', () => ({ diff --git a/api/server/controllers/__tests__/deleteUser.spec.js b/api/server/controllers/__tests__/deleteUser.spec.js index bc6acde53d7..1d7c8521531 100644 --- a/api/server/controllers/__tests__/deleteUser.spec.js +++ b/api/server/controllers/__tests__/deleteUser.spec.js @@ -127,7 +127,7 @@ function stubDeletionMocks() { mockDeleteUserById.mockResolvedValue(); mockDeleteAllSharedLinks.mockResolvedValue(); mockGetFiles.mockResolvedValue([]); - mockProcessDeleteRequest.mockResolvedValue(); + mockProcessDeleteRequest.mockResolvedValue({ deletedFileIds: [], failedFileIds: [] }); mockDeleteFiles.mockResolvedValue(); mockDeleteToolCalls.mockResolvedValue(); mockDeleteUserAgents.mockResolvedValue(); diff --git a/api/server/controllers/__tests__/maybeUninstallOAuthMCP.spec.js b/api/server/controllers/__tests__/maybeUninstallOAuthMCP.spec.js index 65b9cf75b21..fd591aa440f 100644 --- a/api/server/controllers/__tests__/maybeUninstallOAuthMCP.spec.js +++ b/api/server/controllers/__tests__/maybeUninstallOAuthMCP.spec.js @@ -81,7 +81,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', () => ({ 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..8fd94c67232 100644 --- a/api/server/experimental.js +++ b/api/server/experimental.js @@ -24,6 +24,7 @@ const { const { connectDb, indexSync } = require('~/db'); const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager'); const createValidateImageRequest = require('./middleware/validateImageRequest'); +const { startExpiredFileSweep } = require('./services/Files/process'); const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies'); const { updateInterfacePermissions: updateInterfacePerms } = require('@librechat/api'); const { @@ -139,8 +140,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 +187,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 +237,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 +294,8 @@ if (cluster.isMaster) { /** Initialize app configuration */ const appConfig = await getAppConfig(); initializeFileStorage(appConfig); + expiredFileSweepOptions = { appConfig, loadAppConfig: getAppConfig }; + startExpiredFileSweepOnce(); await performStartupChecks(appConfig); await updateInterfacePerms({ appConfig, getRoleByName, updateAccessPermissions }); @@ -390,19 +453,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 9e094724f7a..60b1a96b3d4 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -34,6 +34,7 @@ const { const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager'); const { capabilityContextMiddleware } = require('./middleware/roles/capabilities'); const createValidateImageRequest = require('./middleware/validateImageRequest'); +const { startExpiredFileSweep } = require('./services/Files/process'); const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies'); const { checkMigrations } = require('./services/start/migration'); const optionalJwtAuth = require('./middleware/optionalJwtAuth'); @@ -89,6 +90,7 @@ const startServer = async () => { }); const appConfig = await getAppConfig({ baseOnly: true }); initializeFileStorage(appConfig); + startExpiredFileSweep({ appConfig, loadAppConfig: getAppConfig }); await runAsSystem(async () => { await performStartupChecks(appConfig); await updateInterfacePermissions({ appConfig, getRoleByName, updateAccessPermissions }); diff --git a/api/server/routes/__tests__/share.spec.js b/api/server/routes/__tests__/share.spec.js new file mode 100644 index 00000000000..541ae451c64 --- /dev/null +++ b/api/server/routes/__tests__/share.spec.js @@ -0,0 +1,262 @@ +const express = require('express'); +const request = require('supertest'); +const mongoose = require('mongoose'); + +const mockGetSharedLinkExpiration = jest.fn(); + +jest.mock('@librechat/api', () => ({ + isEnabled: jest.fn(() => true), + getSharedLinkExpiration: (...args) => mockGetSharedLinkExpiration(...args), + isActiveExpirationDate: jest.fn((expiredAt) => expiredAt > new Date()), +})); + +jest.mock('@librechat/data-schemas', () => ({ + logger: { error: jest.fn() }, + createTempChatExpirationDate: jest.fn(() => new Date('2030-01-01T00:00:00.000Z')), +})); + +jest.mock('librechat-data-provider', () => ({ + RetentionMode: { + ALL: 'all', + TEMPORARY: 'temporary', + }, +})); + +jest.mock('mongoose', () => ({ + models: { + Conversation: { + findOne: jest.fn(), + }, + SharedLink: { + findOne: jest.fn(), + }, + }, +})); + +jest.mock('~/models', () => ({ + getSharedMessages: jest.fn(), + createSharedLink: jest.fn(), + updateSharedLink: jest.fn(), + deleteSharedLink: jest.fn(), + getSharedLinks: jest.fn(), + getSharedLink: jest.fn(), +})); + +jest.mock('~/server/middleware/requireJwtAuth', () => (req, res, next) => next()); + +const { RetentionMode } = require('librechat-data-provider'); +const { createTempChatExpirationDate, logger } = require('@librechat/data-schemas'); +const { createSharedLink, updateSharedLink } = require('~/models'); +const 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 retention', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('expires new shares for retained non-temporary conversations', async () => { + mockGetSharedLinkExpiration.mockResolvedValue(activeExpiration); + createSharedLink.mockResolvedValue({ 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'), + ); + }); + + it('rejects new shares when the retained conversation expired', async () => { + mockGetSharedLinkExpiration.mockResolvedValue(expiredExpiration); + createSharedLink.mockResolvedValue({ 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({ 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({ 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'), + ); + }); + + 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({ 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); + }); + + 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); + }); + + 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({ 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); + }); + + 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'), + ); + }); + + 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(); + }); +}); diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index 3d65343648d..dc59482afa7 100644 --- a/api/server/routes/convos.js +++ b/api/server/routes/convos.js @@ -276,6 +276,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/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/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/share.js b/api/server/routes/share.js index 4c0427f197c..ce4dee1a1f4 100644 --- a/api/server/routes/share.js +++ b/api/server/routes/share.js @@ -1,6 +1,7 @@ +const mongoose = require('mongoose'); const express = require('express'); -const { isEnabled } = require('@librechat/api'); -const { logger } = require('@librechat/data-schemas'); +const { isEnabled, isActiveExpirationDate, getSharedLinkExpiration } = require('@librechat/api'); +const { logger, createTempChatExpirationDate } = require('@librechat/data-schemas'); const { getSharedMessages, createSharedLink, @@ -12,6 +13,22 @@ const { const requireJwtAuth = require('~/server/middleware/requireJwtAuth'); const router = express.Router(); +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 */ @@ -99,7 +116,17 @@ 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); + const expiredAt = await resolveSharedLinkExpiration(req, req.params.conversationId); + if (expiredAt != null && !isActiveExpirationDate(expiredAt)) { + return res.status(404).end(); + } + + const created = await createSharedLink( + req.user.id, + req.params.conversationId, + targetMessageId, + expiredAt, + ); if (created) { res.status(200).json(created); } else { @@ -118,7 +145,25 @@ router.patch('/:shareId', requireJwtAuth, async (req, res) => { return res.status(400).json({ message: 'targetMessageId must be a string' }); } - const updatedShare = await updateSharedLink(req.user.id, req.params.shareId, targetMessageId); + 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, + ); if (updatedShare) { res.status(200).json(updatedShare); } else { 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/process.js b/api/server/services/Files/Code/process.js index a04c6329c66..d9940da05b0 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) { diff --git a/api/server/services/Files/Code/process.spec.js b/api/server/services/Files/Code/process.spec.js index 6ade00b1f71..b1a11a44a4e 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'); @@ -233,6 +238,7 @@ describe('Code Process', () => { expect(result.file_id).toBe('mock-uuid-1234'); expect(result.usage).toBe(1); + expect(getRetentionExpiry).toHaveBeenCalledWith(baseParams.req); }); }); diff --git a/api/server/services/Files/process.js b/api/server/services/Files/process.js index ea8ee14840a..6f2ee2b6198 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 } = require('./retention'); const { getStrategyFunctions } = require('./strategies'); const { determineFileType } = require('~/server/utils'); const { STTService } = require('./Audio/STTService'); @@ -64,6 +67,17 @@ const createSanitizedUploadWrapper = (uploadFunction) => { }; }; +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 +86,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 +108,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 +129,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); }), @@ -121,11 +157,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 +205,7 @@ const processDeleteRequest = async ({ req, files }) => { } if (source === FileSources.text) { - resolvedFileIds.push(file.file_id); + resolvedFileIds.add(file.file_id); continue; } @@ -198,6 +236,7 @@ const processDeleteRequest = async ({ req, files }) => { deleteFile: deletionMethods[source], promises, resolvedFileIds, + failedFileIds, openai, }); continue; @@ -209,7 +248,15 @@ const processDeleteRequest = async ({ req, files }) => { } deletionMethods[source] = deleteFile; - enqueueDeleteOperation({ req, file, deleteFile, promises, resolvedFileIds, openai }); + enqueueDeleteOperation({ + req, + file, + deleteFile, + promises, + resolvedFileIds, + failedFileIds, + openai, + }); } if (agentFiles.length > 0) { @@ -222,17 +269,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 +341,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 +353,7 @@ const processFileURL = async ({ basePath, context, tenantId, + req, }) => { const { saveURL, getFileURL } = getStrategyFunctions(fileStrategy); try { @@ -305,6 +397,7 @@ const processFileURL = async ({ source: fileStrategy, type, context, + ...(await getRetentionExpiry(req)), tenantId, width: dimensions.width, height: dimensions.height, @@ -355,6 +448,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 +509,7 @@ const uploadImageBuffer = async ({ req, context, metadata = {}, resize = true }) source, type, width, + ...(await getRetentionExpiry(req)), height, tenantId: req.user.tenantId, }, @@ -517,6 +612,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 +727,24 @@ 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 getRetentionExpiry(req); + 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 +925,28 @@ 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 getRetentionExpiry(req); + 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 +991,7 @@ const processOpenAIFile = async ({ source, model: openai.req.body.model, filename: originalName ?? file_id, + ...(await getRetentionExpiry(openai.req)), tenantId: openai.req?.user?.tenantId, }; @@ -931,9 +1036,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 +1201,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 +1293,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..01cfb2f1e56 100644 --- a/api/server/services/Files/process.spec.js +++ b/api/server/services/Files/process.spec.js @@ -1,22 +1,41 @@ 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, + mergeFileConfig: jest.fn(), + }; +}); + +jest.mock('@librechat/api', () => { + return { + sanitizeFilename: jest.fn((n) => n), + parseText: jest.fn().mockResolvedValue({ text: '', bytes: 0 }), + processAudioFile: jest.fn(), + getStorageMetadata: jest.fn(() => ({})), + getRetentionExpiry: jest.fn(() => ({})), + 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 +60,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', () => ({ @@ -69,17 +92,29 @@ jest.mock('~/server/services/Files/Audio/STTService', () => ({ STTService: { getInstance: jest.fn() }, })); +const { + getRetentionExpiry, + 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 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'; @@ -534,6 +569,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' } }, + }, + }); + + 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 +741,142 @@ 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(); + }); +}); + +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..2895d5764aa --- /dev/null +++ b/api/server/services/Files/retention.js @@ -0,0 +1,21 @@ +const { getRetentionExpiry: getRetentionExpiryWithDeps } = require('@librechat/api'); +const { logger, createTempChatExpirationDate } = require('@librechat/data-schemas'); +const db = require('~/models'); + +/** + * 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, { + getConvo: db.getConvoRetention ?? db.getConvo, + createExpirationDate: createTempChatExpirationDate, + logger, + }); +} + +module.exports = { + getRetentionExpiry, +}; 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.spec.js b/api/server/utils/import/importers.spec.js index cbd39afb341..6ccd2f37288 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(() => { @@ -1046,6 +1048,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); + }); }); }); diff --git a/client/src/components/Chat/Menus/BookmarkMenu.tsx b/client/src/components/Chat/Menus/BookmarkMenu.tsx index d66fccd24b7..a42a917fbd2 100644 --- a/client/src/components/Chat/Menus/BookmarkMenu.tsx +++ b/client/src/components/Chat/Menus/BookmarkMenu.tsx @@ -14,7 +14,7 @@ import { BookmarkContext } from '~/Providers/BookmarkContext'; import { BookmarkEditDialog } from '~/components/Bookmarks'; import { useBookmarkSuccess, useLocalize } from '~/hooks'; import { NotificationSeverity } from '~/common'; -import { cn, logger } from '~/utils'; +import { cn, isTemporaryConversation, logger } from '~/utils'; import store from '~/store'; const BookmarkMenu: FC = () => { @@ -26,8 +26,7 @@ const BookmarkMenu: FC = () => { const conversationId = conversation?.conversationId ?? ''; const updateConvoTags = useBookmarkSuccess(conversationId); const tags = conversation?.tags; - const isTemporary = conversation?.expiredAt != null; - + const isTemporary = isTemporaryConversation(conversation); const menuId = useId(); const [isMenuOpen, setIsMenuOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false); diff --git a/client/src/components/Messages/Content/RunCode.tsx b/client/src/components/Messages/Content/RunCode.tsx index da7db2739aa..15c31bb85be 100644 --- a/client/src/components/Messages/Content/RunCode.tsx +++ b/client/src/components/Messages/Content/RunCode.tsx @@ -1,5 +1,6 @@ import React, { useState, useMemo, useCallback, useEffect, useRef } from 'react'; import debounce from 'lodash/debounce'; +import { useRecoilCallback } from 'recoil'; import { Tools } from 'librechat-data-provider'; import { TerminalSquareIcon, Check, X } from 'lucide-react'; import { Spinner, TooltipAnchor, useToastContext } from '@librechat/client'; @@ -8,6 +9,7 @@ import { useToolCallMutation } from '~/data-provider'; import { useLocalize } from '~/hooks'; import { cn, normalizeLanguage } from '~/utils'; import { useMessageContext } from '~/Providers'; +import store from '~/store'; type RunState = 'idle' | 'loading' | 'success' | 'error'; @@ -23,6 +25,13 @@ const RunCode: React.FC = React.memo( const { messageId, conversationId, partIndex } = useMessageContext(); const normalizedLang = useMemo(() => normalizeLanguage(lang), [lang]); + // Read at click time so retention context is current without re-rendering every code block. + const getIsTemporary = useRecoilCallback( + ({ snapshot }) => + () => + snapshot.getPromise(store.isTemporary), + [], + ); const handleExecute = useCallback(async () => { const codeString: string = codeRef.current?.textContent ?? ''; @@ -42,8 +51,18 @@ const RunCode: React.FC = React.memo( conversationId: conversationId ?? '', lang: normalizedLang, code: codeString, + isTemporary: await getIsTemporary(), }); - }, [codeRef, execute, partIndex, messageId, blockIndex, conversationId, normalizedLang]); + }, [ + codeRef, + execute, + partIndex, + messageId, + blockIndex, + conversationId, + normalizedLang, + getIsTemporary, + ]); const debouncedExecute = useMemo( () => debounce(handleExecute, 1000, { leading: true }), diff --git a/client/src/hooks/Files/__tests__/useFileHandling.test.ts b/client/src/hooks/Files/__tests__/useFileHandling.test.ts index 0a07c5f2b44..fdb098e2d59 100644 --- a/client/src/hooks/Files/__tests__/useFileHandling.test.ts +++ b/client/src/hooks/Files/__tests__/useFileHandling.test.ts @@ -11,6 +11,7 @@ const mockSetFilesLoading = jest.fn(); const mockMutate = jest.fn(); let mockConversation: Record = {}; +let mockIsTemporary = false; jest.mock('~/Providers/ChatContext', () => ({ useChatContext: jest.fn(() => ({ @@ -30,9 +31,12 @@ jest.mock('@librechat/client', () => ({ jest.mock('recoil', () => ({ ...jest.requireActual('recoil'), useSetRecoilState: jest.fn(() => jest.fn()), + useRecoilValue: jest.fn(() => mockIsTemporary), })); jest.mock('~/store', () => ({ + __esModule: true, + default: { isTemporary: { key: 'isTemporary' } }, ephemeralAgentByConvoId: jest.fn(() => ({ key: 'mock' })), })); @@ -99,6 +103,7 @@ describe('useFileHandling', () => { beforeEach(() => { jest.clearAllMocks(); mockConversation = {}; + mockIsTemporary = false; }); const loadHook = async () => (await import('../useFileHandling')).default; @@ -209,6 +214,7 @@ describe('useFileHandling', () => { const formData: FormData = mockMutate.mock.calls[0][0]; expect(formData.get('endpoint')).toBe(EModelEndpoint.agents); expect(formData.get('endpointType')).toBe(EModelEndpoint.agents); + expect(formData.get('conversationId')).toBeNull(); }); it('does not enter assistants upload path when override is agents', async () => { @@ -284,6 +290,87 @@ describe('useFileHandling', () => { expect(mockMutate).toHaveBeenCalledTimes(1); const formData: FormData = mockMutate.mock.calls[0][0]; expect(formData.get('endpoint')).toBe('default'); + expect(formData.get('conversationId')).toBeNull(); + }); + + it('sends temporary flag for temporary chat uploads', async () => { + mockIsTemporary = true; + mockConversation = { + conversationId: Constants.NEW_CONVO as string, + endpoint: 'openAI', + endpointType: 'custom', + }; + + const useFileHandling = await loadHook(); + const { result } = renderHook(() => useFileHandling()); + + const textFile = new File(['hello'], 'test.txt', { type: 'text/plain' }); + + await act(async () => { + await result.current.handleFiles([textFile]); + }); + + expect(mockMutate).toHaveBeenCalledTimes(1); + const formData: FormData = mockMutate.mock.calls[0][0]; + expect(formData.get('conversationId')).toBeNull(); + expect(formData.get('isTemporary')).toBe('true'); + }); + + it('does not send temporary flag for assistant builder uploads', async () => { + mockIsTemporary = true; + mockConversation = { + conversationId: 'temporary-convo', + endpoint: 'openAI', + endpointType: 'custom', + }; + + const useFileHandling = await loadHook(); + const { result } = renderHook(() => + useFileHandling({ + additionalMetadata: { assistant_id: 'asst-123' }, + }), + ); + + const textFile = new File(['hello'], 'test.txt', { type: 'text/plain' }); + + await act(async () => { + await result.current.handleFiles([textFile]); + }); + + expect(mockMutate).toHaveBeenCalledTimes(1); + const formData: FormData = mockMutate.mock.calls[0][0]; + expect(formData.get('assistant_id')).toBe('asst-123'); + expect(formData.get('conversationId')).toBeNull(); + expect(formData.get('isTemporary')).toBeNull(); + }); + + it('does not send temporary flag for agent builder uploads', async () => { + mockIsTemporary = true; + mockConversation = { + conversationId: 'temporary-convo', + endpoint: 'openAI', + endpointType: 'custom', + }; + + const useFileHandling = await loadHook(); + const { result } = renderHook(() => + useFileHandling({ + endpointOverride: EModelEndpoint.agents, + additionalMetadata: { agent_id: 'agent-123' }, + }), + ); + + const textFile = new File(['hello'], 'test.txt', { type: 'text/plain' }); + + await act(async () => { + await result.current.handleFiles([textFile]); + }); + + expect(mockMutate).toHaveBeenCalledTimes(1); + const formData: FormData = mockMutate.mock.calls[0][0]; + expect(formData.get('agent_id')).toBe('agent-123'); + expect(formData.get('conversationId')).toBeNull(); + expect(formData.get('isTemporary')).toBeNull(); }); }); }); diff --git a/client/src/hooks/Files/useFileHandling.ts b/client/src/hooks/Files/useFileHandling.ts index 635937a6fa4..94f26039b67 100644 --- a/client/src/hooks/Files/useFileHandling.ts +++ b/client/src/hooks/Files/useFileHandling.ts @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useRef, useMemo, useState } from 'react'; import { v4 } from 'uuid'; -import { useSetRecoilState } from 'recoil'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; import { useToastContext } from '@librechat/client'; import { useQueryClient } from '@tanstack/react-query'; import { @@ -22,7 +22,7 @@ import useLocalize, { TranslationKeys } from '~/hooks/useLocalize'; import { useDelayedUploadToast } from './useDelayedUploadToast'; import { processFileForUpload } from '~/utils/heicConverter'; import { useChatContext } from '~/Providers/ChatContext'; -import { ephemeralAgentByConvoId } from '~/store'; +import store, { ephemeralAgentByConvoId } from '~/store'; import useClientResize from './useClientResize'; import useUpdateFiles from './useUpdateFiles'; @@ -57,6 +57,7 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil const setEphemeralAgent = useSetRecoilState( ephemeralAgentByConvoId(conversation?.conversationId ?? Constants.NEW_CONVO), ); + const isTemporary = useRecoilValue(store.isTemporary); const setError = (error: string) => setErrors((prevErrors) => [...prevErrors, error]); const { addFile, replaceFile, updateFileById, deleteFileById } = useUpdateFiles( params?.fileSetter ?? setFiles, @@ -65,6 +66,7 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil const agent_id = params?.additionalMetadata?.agent_id ?? ''; const assistant_id = params?.additionalMetadata?.assistant_id ?? ''; + const isConversationUpload = !agent_id && !assistant_id; const endpointOverride = params?.endpointOverride; const endpointTypeOverride = params?.endpointTypeOverride; const endpointType = useMemo( @@ -192,6 +194,16 @@ const useFileHandlingCore = (params: UseFileHandling | undefined, fileState: Fil formData.append('endpointType', endpointType ?? ''); formData.append('file', extendedFile.file as File, encodeURIComponent(filename)); formData.append('file_id', extendedFile.file_id); + if ( + isConversationUpload && + conversation?.conversationId && + conversation.conversationId !== Constants.NEW_CONVO + ) { + formData.append('conversationId', conversation.conversationId); + } + if (isTemporary && isConversationUpload) { + formData.append('isTemporary', 'true'); + } const width = extendedFile.width ?? 0; const height = extendedFile.height ?? 0; diff --git a/client/src/routes/ChatRoute.tsx b/client/src/routes/ChatRoute.tsx index a17d349037d..374e2330cdf 100644 --- a/client/src/routes/ChatRoute.tsx +++ b/client/src/routes/ChatRoute.tsx @@ -11,6 +11,7 @@ import { getDefaultModelSpec, getModelSpecPreset, isNotFoundError, + isTemporaryConversation, logger, } from '~/utils'; import { @@ -62,7 +63,7 @@ export default function ChatRoute() { const endpointsQuery = useGetEndpointsQuery({ enabled: isAuthenticated }); const assistantListMap = useAssistantListMap(); - const isTemporaryChat = conversation && conversation.expiredAt ? true : false; + const isTemporaryChat = isTemporaryConversation(conversation); useEffect(() => { if (conversationId === Constants.NEW_CONVO) { diff --git a/client/src/utils/conversation.ts b/client/src/utils/conversation.ts new file mode 100644 index 00000000000..31cdc39ebe1 --- /dev/null +++ b/client/src/utils/conversation.ts @@ -0,0 +1,5 @@ +import type { TConversation } from 'librechat-data-provider'; + +export const isTemporaryConversation = (conversation?: Partial | null): boolean => + conversation?.isTemporary === true || + (conversation?.isTemporary === undefined && conversation?.expiredAt != null); diff --git a/client/src/utils/index.ts b/client/src/utils/index.ts index 643cb76183e..befcef81a1f 100644 --- a/client/src/utils/index.ts +++ b/client/src/utils/index.ts @@ -22,6 +22,7 @@ export * from './textarea'; export * from './messages'; export * from './redirect'; export * from './languages'; +export * from './conversation'; export * from './endpoints'; export * from './resources'; export * from './downloadFile'; diff --git a/librechat.example.yaml b/librechat.example.yaml index b88fff9a602..d3e0d006ccf 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -167,6 +167,15 @@ interface: # Temporary chat retention period in hours (default: 720, min: 1, max: 8760) # temporaryChatRetention: 1 + # Retention mode: "all" applies expiry to all data types, "temporary" (default) only to temporary chats + # Before switching from "all" back to "temporary", remove retention deadlines from non-temporary data + # that should stop expiring: + # db.conversations.updateMany({ isTemporary: false, expiredAt: { $ne: null } }, { $unset: { expiredAt: 1 } }) + # db.messages.updateMany({ isTemporary: false, expiredAt: { $ne: null } }, { $unset: { expiredAt: 1 } }) + # MongoDB does not drop superseded indexes automatically. After upgrading, old Meili indexes + # such as "_meiliIndex_1_expiredAt_1" can be dropped from conversations/messages once the new + # "_meiliIndex_1_isTemporary_1_expiredAt_1" indexes exist. + # retentionMode: "temporary" # Example Cloudflare turnstile (optional) #turnstile: diff --git a/packages/api/src/files/index.ts b/packages/api/src/files/index.ts index 8200f21195f..0e9a23ff59d 100644 --- a/packages/api/src/files/index.ts +++ b/packages/api/src/files/index.ts @@ -9,5 +9,7 @@ export * from './mistral/crud'; export * from './ocr'; export * from './parse'; export * from './rag'; +export * from './retention'; +export * from './sweep'; export * from './validation'; export * from './text'; diff --git a/packages/api/src/files/retention.spec.ts b/packages/api/src/files/retention.spec.ts new file mode 100644 index 00000000000..65ede6a5b16 --- /dev/null +++ b/packages/api/src/files/retention.spec.ts @@ -0,0 +1,273 @@ +import { RetentionMode } from 'librechat-data-provider'; +import { + createMinimalRetentionRequest, + getConversationExpirationDate, + getRetentionExpiry, + getSharedLinkExpiration, + isActiveExpirationDate, + isBooleanOrStringTrue, + type RetentionDependencies, + type RetentionRequest, +} from './retention'; + +describe('retention helpers', () => { + const expirationDate = new Date('2030-01-01T00:00:00.000Z'); + let dependencies: jest.Mocked; + + beforeEach(() => { + dependencies = { + getConvo: jest.fn(), + createExpirationDate: jest.fn().mockReturnValue(expirationDate), + logger: { + error: jest.fn(), + }, + }; + }); + + const request = (overrides: RetentionRequest = {}): RetentionRequest => ({ + user: { + id: 'user-1', + tenantId: 'tenant-1', + ...overrides.user, + }, + body: { + conversationId: 'convo-1', + ...overrides.body, + }, + config: { + interfaceConfig: { + ...overrides.config?.interfaceConfig, + }, + }, + }); + + it('returns expiry when retentionMode is ALL', async () => { + const result = await getRetentionExpiry( + request({ config: { interfaceConfig: { retentionMode: RetentionMode.ALL } } }), + dependencies, + ); + + expect(result).toEqual({ expiredAt: expirationDate }); + expect(dependencies.getConvo).not.toHaveBeenCalled(); + }); + + it('returns a fresh expiry when the conversation has an active expiration', async () => { + dependencies.getConvo.mockResolvedValue({ + expiredAt: new Date(Date.now() + 60 * 60 * 1000), + }); + + const result = await getRetentionExpiry(request(), dependencies); + + expect(result).toEqual({ expiredAt: expirationDate }); + }); + + it('returns the conversation expiration when the conversation is already expired', async () => { + const expiredAt = new Date(Date.now() - 60 * 60 * 1000); + dependencies.getConvo.mockResolvedValue({ expiredAt }); + + const result = await getRetentionExpiry(request(), dependencies); + + expect(result).toEqual({ expiredAt }); + expect(dependencies.createExpirationDate).not.toHaveBeenCalled(); + }); + + it('returns no retention fields when the conversation has no expiration', async () => { + dependencies.getConvo.mockResolvedValue({ expiredAt: null }); + + await expect(getRetentionExpiry(request(), dependencies)).resolves.toEqual({}); + }); + + it('returns expiry when the conversation has no expiration but explicit temporary intent is present', async () => { + dependencies.getConvo.mockResolvedValue({ expiredAt: null }); + + const result = await getRetentionExpiry( + request({ body: { conversationId: 'convo-1', isTemporary: true } }), + dependencies, + ); + + expect(result).toEqual({ expiredAt: expirationDate }); + }); + + it('returns no retention fields when conversation is missing and isTemporary is false', async () => { + dependencies.getConvo.mockResolvedValue(null); + + const result = await getRetentionExpiry( + request({ body: { conversationId: 'convo-1', isTemporary: false } }), + dependencies, + ); + + expect(result).toEqual({}); + }); + + it('returns expiry when isTemporary is true', async () => { + dependencies.getConvo.mockResolvedValue(null); + + const result = await getRetentionExpiry( + request({ body: { conversationId: 'convo-1', isTemporary: true } }), + dependencies, + ); + + expect(result).toEqual({ expiredAt: expirationDate }); + }); + + it('returns expiry when isTemporary is the string "true"', async () => { + dependencies.getConvo.mockResolvedValue(null); + + const result = await getRetentionExpiry( + request({ body: { conversationId: 'convo-1', isTemporary: 'true' } }), + dependencies, + ); + + expect(result).toEqual({ expiredAt: expirationDate }); + }); + + it('returns no retention fields when conversation lookup throws without explicit temporary intent', async () => { + const error = new Error('database unavailable'); + dependencies.getConvo.mockRejectedValue(error); + + const result = await getRetentionExpiry(request(), dependencies); + + expect(result).toEqual({}); + expect(dependencies.logger?.error).toHaveBeenCalledWith( + '[getRetentionExpiry] Error checking conversation retention:', + error, + ); + }); + + it('applies retention when explicit temporary intent is present and conversation lookup throws', async () => { + const error = new Error('database unavailable'); + dependencies.getConvo.mockRejectedValue(error); + + const result = await getRetentionExpiry( + request({ body: { conversationId: 'convo-1', isTemporary: true } }), + dependencies, + ); + + expect(result).toEqual({ expiredAt: expirationDate }); + expect(dependencies.logger?.error).toHaveBeenCalledWith( + '[getRetentionExpiry] Error checking conversation retention:', + error, + ); + }); + + it('returns a fallback expiration when expiration creation throws', async () => { + const error = new Error('bad config'); + const nowSpy = jest + .spyOn(Date, 'now') + .mockReturnValue(new Date('2026-01-01T00:00:00.000Z').getTime()); + dependencies.createExpirationDate.mockImplementation(() => { + throw error; + }); + + const result = await getRetentionExpiry( + request({ body: { conversationId: undefined, isTemporary: true } }), + dependencies, + ); + + expect(result).toEqual({ expiredAt: new Date('2026-01-31T00:00:00.000Z') }); + expect(dependencies.logger?.error).toHaveBeenCalledWith( + '[getRetentionExpiry] Error creating file expiration date:', + error, + ); + nowSpy.mockRestore(); + }); + + it('memoizes retention lookup per request object', async () => { + dependencies.getConvo.mockResolvedValue({ + expiredAt: new Date(Date.now() + 60 * 60 * 1000), + }); + const req = request(); + + const first = await getRetentionExpiry(req, dependencies); + const second = await getRetentionExpiry(req, dependencies); + + expect(first).toEqual({ expiredAt: expirationDate }); + expect(second).toEqual({ expiredAt: expirationDate }); + expect(dependencies.getConvo).toHaveBeenCalledTimes(1); + }); + + it('returns no retention fields when req is null or undefined', async () => { + await expect(getRetentionExpiry(null, dependencies)).resolves.toEqual({}); + await expect(getRetentionExpiry(undefined, dependencies)).resolves.toEqual({}); + }); + + it('parses valid conversation expiration dates and ignores invalid ones', () => { + expect(getConversationExpirationDate({ expiredAt: expirationDate })).toBe(expirationDate); + expect(getConversationExpirationDate({ expiredAt: expirationDate.toISOString() })).toEqual( + expirationDate, + ); + expect(getConversationExpirationDate({ expiredAt: 'not-a-date' })).toBeNull(); + expect(getConversationExpirationDate({ expiredAt: null })).toBeNull(); + }); + + it('compares active expiration dates against the provided clock', () => { + const now = new Date('2026-01-01T00:00:00.000Z'); + + expect(isActiveExpirationDate(new Date('2026-01-01T00:00:01.000Z'), now)).toBe(true); + expect(isActiveExpirationDate(new Date('2025-12-31T23:59:59.000Z'), now)).toBe(false); + }); + + it('uses strict temporary truthiness semantics', () => { + expect(isBooleanOrStringTrue(true)).toBe(true); + expect(isBooleanOrStringTrue('true')).toBe(true); + expect(isBooleanOrStringTrue(1)).toBe(false); + expect(isBooleanOrStringTrue('1')).toBe(false); + }); + + it('creates minimal retention requests for tool calls', () => { + expect( + createMinimalRetentionRequest({ + user: { id: 'user-1', tenantId: 'tenant-1' }, + body: { conversationId: 'convo-1', isTemporary: 'true' }, + config: { interfaceConfig: { retentionMode: RetentionMode.TEMPORARY } }, + }), + ).toEqual({ + user: { id: 'user-1', tenantId: 'tenant-1' }, + body: { conversationId: 'convo-1', isTemporary: 'true' }, + config: { interfaceConfig: { retentionMode: RetentionMode.TEMPORARY } }, + }); + + expect(createMinimalRetentionRequest()).toBeUndefined(); + }); + + describe('getSharedLinkExpiration', () => { + it('returns undefined when the conversation id is missing', async () => { + await expect( + getSharedLinkExpiration({ req: request() }, dependencies), + ).resolves.toBeUndefined(); + expect(dependencies.getConvo).not.toHaveBeenCalled(); + }); + + it('returns null for non-retained conversations in temporary retention mode', async () => { + dependencies.getConvo.mockResolvedValue({ expiredAt: null }); + + await expect( + getSharedLinkExpiration({ req: request(), conversationId: 'convo-1' }, dependencies), + ).resolves.toBeNull(); + }); + + it('returns a fresh expiry for retentionMode ALL conversations without an expiration', async () => { + dependencies.getConvo.mockResolvedValue({ expiredAt: null }); + + await expect( + getSharedLinkExpiration( + { + req: request({ config: { interfaceConfig: { retentionMode: RetentionMode.ALL } } }), + conversationId: 'convo-1', + }, + dependencies, + ), + ).resolves.toBe(expirationDate); + }); + + it('returns an expired source conversation date so callers can reject the share', async () => { + const expiredAt = new Date(Date.now() - 60 * 60 * 1000); + dependencies.getConvo.mockResolvedValue({ expiredAt }); + + await expect( + getSharedLinkExpiration({ req: request(), conversationId: 'convo-1' }, dependencies), + ).resolves.toBe(expiredAt); + expect(dependencies.createExpirationDate).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/api/src/files/retention.ts b/packages/api/src/files/retention.ts new file mode 100644 index 00000000000..edbebadd90d --- /dev/null +++ b/packages/api/src/files/retention.ts @@ -0,0 +1,231 @@ +import { RetentionMode } from 'librechat-data-provider'; +import { createFallbackRetentionDate } from '@librechat/data-schemas'; +import type { AppConfig } from '@librechat/data-schemas'; + +type InterfaceConfig = AppConfig['interfaceConfig']; + +const retentionExpiryCache = new WeakMap< + RetentionRequest, + { + key: string; + promise: Promise; + } +>(); + +export type RetentionConversation = { + expiredAt?: Date | string | number | null; +}; + +export type RetentionRequest = { + user?: { + id?: string; + tenantId?: string; + }; + body?: { + conversationId?: string; + isTemporary?: boolean | string | null; + }; + config?: { + interfaceConfig?: InterfaceConfig; + }; +}; + +export type RetentionExpiry = { + expiredAt?: Date | null; +}; + +export type RetentionLogger = { + error: (message: string, error?: unknown) => void; +}; + +export type RetentionDependencies = { + getConvo: ( + userId: string, + conversationId: string, + ) => Promise; + createExpirationDate: (interfaceConfig?: InterfaceConfig) => Date; + logger?: RetentionLogger; +}; + +export type SharedLinkRetentionDependencies = { + getConvo: ( + userId: string, + conversationId: string, + ) => Promise; + createExpirationDate: (interfaceConfig?: InterfaceConfig) => Date; + logger?: RetentionLogger; +}; + +export const isBooleanOrStringTrue = (value: unknown): boolean => + value === true || value === 'true'; + +export const getConversationExpirationDate = ( + convo?: RetentionConversation | null, +): Date | null => { + if (convo?.expiredAt == null) { + return null; + } + + const expiredAt = convo.expiredAt instanceof Date ? convo.expiredAt : new Date(convo.expiredAt); + return Number.isNaN(expiredAt.getTime()) ? null : expiredAt; +}; + +export const isActiveExpirationDate = (expiredAt: Date, now = new Date()): boolean => + expiredAt > now; + +const createRetentionExpiry = ( + req: RetentionRequest | null | undefined, + { createExpirationDate, logger }: RetentionDependencies, +): RetentionExpiry => { + try { + return { expiredAt: createExpirationDate(req?.config?.interfaceConfig) }; + } catch (err) { + logger?.error('[getRetentionExpiry] Error creating file expiration date:', err); + return { expiredAt: createFallbackRetentionDate() }; + } +}; + +const getRetentionCacheKey = (req: RetentionRequest): string => + [ + req.config?.interfaceConfig?.retentionMode ?? '', + req.user?.id ?? '', + req.body?.conversationId ?? '', + String(req.body?.isTemporary ?? ''), + ].join('|'); + +async function computeRetentionExpiry( + req: RetentionRequest | null | undefined, + dependencies: RetentionDependencies, +): Promise { + if (req?.config?.interfaceConfig?.retentionMode === RetentionMode.ALL) { + return createRetentionExpiry(req, dependencies); + } + + const conversationId = req?.body?.conversationId; + const userId = req?.user?.id; + + if (conversationId && userId) { + try { + const convo = await dependencies.getConvo(userId, conversationId); + if (convo) { + const expiredAt = getConversationExpirationDate(convo); + if (expiredAt == null) { + if (isBooleanOrStringTrue(req?.body?.isTemporary)) { + return createRetentionExpiry(req, dependencies); + } + return {}; + } + + if (!isActiveExpirationDate(expiredAt)) { + return { expiredAt }; + } + + return createRetentionExpiry(req, dependencies); + } + } catch (err) { + dependencies.logger?.error( + '[getRetentionExpiry] Error checking conversation retention:', + err, + ); + if (isBooleanOrStringTrue(req?.body?.isTemporary)) { + return createRetentionExpiry(req, dependencies); + } + return {}; + } + } + + if (!isBooleanOrStringTrue(req?.body?.isTemporary)) { + return {}; + } + + return createRetentionExpiry(req, dependencies); +} + +export async function getRetentionExpiry( + req: RetentionRequest | null | undefined, + dependencies: RetentionDependencies, +): Promise { + if (!req) { + return {}; + } + + const key = getRetentionCacheKey(req); + const cached = retentionExpiryCache.get(req); + if (cached?.key === key) { + return cached.promise; + } + + const promise = computeRetentionExpiry(req, dependencies); + retentionExpiryCache.set(req, { key, promise }); + return promise; +} + +/** + * Resolves the retention deadline for a shared link derived from a conversation. + * + * Return values are intentionally tri-state: + * - `undefined`: no decision can be made because the conversation id or row is missing. + * - `null`: the share should be stored without an expiration. + * - `Date`: the share should expire at that date; callers reject already-expired dates. + */ +export async function getSharedLinkExpiration( + { + req, + conversationId, + }: { + req: RetentionRequest | null | undefined; + conversationId?: string | null; + }, + dependencies: SharedLinkRetentionDependencies, +): Promise { + const userId = req?.user?.id; + if (!conversationId || !userId) { + return undefined; + } + + const isRetentionAll = req?.config?.interfaceConfig?.retentionMode === RetentionMode.ALL; + const convo = await dependencies.getConvo(userId, conversationId); + if (!convo) { + return undefined; + } + + const conversationExpiredAt = getConversationExpirationDate(convo); + if (conversationExpiredAt == null) { + if (!isRetentionAll) { + return null; + } + } else if (!isActiveExpirationDate(conversationExpiredAt)) { + return conversationExpiredAt; + } + + try { + return dependencies.createExpirationDate(req?.config?.interfaceConfig); + } catch (err) { + dependencies.logger?.error('[getSharedLinkExpiration] Error creating expiration date:', err); + return null; + } +} + +export const createMinimalRetentionRequest = ( + req?: RetentionRequest | null, +): RetentionRequest | undefined => { + if (!req) { + return undefined; + } + + return { + user: req.user + ? { + id: req.user.id, + tenantId: req.user.tenantId, + } + : undefined, + body: { + conversationId: req.body?.conversationId, + isTemporary: req.body?.isTemporary, + }, + config: { + interfaceConfig: req.config?.interfaceConfig, + }, + }; +}; diff --git a/packages/api/src/files/sweep.spec.ts b/packages/api/src/files/sweep.spec.ts new file mode 100644 index 00000000000..4c5465f97e1 --- /dev/null +++ b/packages/api/src/files/sweep.spec.ts @@ -0,0 +1,96 @@ +import { EModelEndpoint, FileSources } from 'librechat-data-provider'; +import type { AppConfig } from '@librechat/data-schemas'; +import { getFileRetentionSweepInterval, startExpiredFileSweep, sweepExpiredFiles } from './sweep'; + +describe('expired file sweep helpers', () => { + const logger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }; + + beforeEach(() => { + jest.clearAllMocks(); + delete process.env.FILE_RETENTION_SWEEP_INTERVAL_MS; + }); + + afterEach(() => { + jest.useRealTimers(); + delete process.env.FILE_RETENTION_SWEEP_INTERVAL_MS; + }); + + it('loads endpoint config and deletes expired OpenAI storage files', async () => { + const getExpiredFiles = jest.fn().mockResolvedValue([ + { + file_id: 'expired-openai-file', + source: FileSources.openai, + user: { toString: () => 'user-123' }, + tenantId: 'tenant-a', + }, + ]); + const processDeleteRequest = jest.fn().mockResolvedValue({ + deletedFileIds: ['expired-openai-file'], + failedFileIds: [], + }); + const loadAppConfig = jest.fn().mockResolvedValue({ + endpoints: { + [EModelEndpoint.assistants]: { version: 'v3' }, + }, + } as AppConfig); + + const result = await sweepExpiredFiles( + { appConfig: {} as AppConfig, loadAppConfig, limit: 1 }, + { getExpiredFiles, processDeleteRequest, logger }, + ); + + expect(loadAppConfig).toHaveBeenCalledTimes(1); + expect(processDeleteRequest).toHaveBeenCalledWith({ + req: expect.objectContaining({ + baseUrl: '/api/assistants/v3', + originalUrl: '/api/assistants/v3/files', + body: { endpoint: EModelEndpoint.assistants, version: '3' }, + user: { id: 'user-123', tenantId: 'tenant-a' }, + }), + files: [expect.objectContaining({ file_id: 'expired-openai-file' })], + }); + expect(result).toEqual({ scanned: 1, deleted: 1, failed: 0 }); + }); + + it('counts files without owners as failed without deleting them', async () => { + const getExpiredFiles = jest.fn().mockResolvedValue([{ file_id: 'orphaned-file' }]); + const processDeleteRequest = jest.fn(); + + const result = await sweepExpiredFiles( + { appConfig: {} as AppConfig, limit: 1 }, + { getExpiredFiles, processDeleteRequest, logger }, + ); + + expect(processDeleteRequest).not.toHaveBeenCalled(); + expect(logger.warn).toHaveBeenCalledWith( + '[sweepExpiredFiles] Skipping expired file without user: orphaned-file', + ); + expect(result).toEqual({ scanned: 1, deleted: 0, failed: 1 }); + }); + + it('falls back to the default interval for sub-millisecond values', () => { + expect(getFileRetentionSweepInterval('0.5')).toBe(60 * 60 * 1000); + }); + + it('does not start the interval when the sweep is disabled', () => { + process.env.FILE_RETENTION_SWEEP_INTERVAL_MS = '0'; + + const interval = startExpiredFileSweep( + { appConfig: {} as AppConfig }, + { + sweepExpiredFiles: jest.fn(), + runAsSystem: jest.fn((fn) => fn()), + logger, + }, + ); + + expect(interval).toBeNull(); + expect(logger.info).toHaveBeenCalledWith( + '[sweepExpiredFiles] Disabled by FILE_RETENTION_SWEEP_INTERVAL_MS=0', + ); + }); +}); diff --git a/packages/api/src/files/sweep.ts b/packages/api/src/files/sweep.ts new file mode 100644 index 00000000000..29562ffac55 --- /dev/null +++ b/packages/api/src/files/sweep.ts @@ -0,0 +1,286 @@ +import { + FileSources, + EModelEndpoint, + checkOpenAIStorage, + defaultAssistantsVersion, +} from 'librechat-data-provider'; +import type { AppConfig } from '@librechat/data-schemas'; + +const DEFAULT_FILE_RETENTION_SWEEP_INTERVAL_MS = 60 * 60 * 1000; + +type ExpiredFile = { + file_id: string; + source?: string; + user?: string | { toString?: () => string }; + tenantId?: string; +}; + +type SweepRequest = { + baseUrl: string; + originalUrl: string; + path: string; + method: string; + headers: Record; + query: Record; + params: Record; + config?: AppConfig; + body: { + endpoint: string; + version: string; + }; + user: { + id: string; + tenantId?: string; + }; +}; + +type SweepLogger = { + info: (message: string) => void; + warn: (message: string) => void; + error: (message: string, error?: unknown) => void; +}; + +type VersionedEndpointConfig = { + version?: unknown; + assistants?: { version?: unknown } | boolean; +}; + +type SweepDependencies = { + getExpiredFiles: (limit: number) => Promise; + processDeleteRequest: (params: { + req: SweepRequest; + files: ExpiredFile[]; + }) => Promise<{ deletedFileIds: string[]; failedFileIds: string[] }>; + logger: SweepLogger; +}; + +type StartSweepDependencies = { + sweepExpiredFiles: (options?: ExpiredFileSweepOptions) => Promise; + runAsSystem: (fn: () => Promise) => Promise; + logger: SweepLogger; +}; + +export type ExpiredFileSweepOptions = { + appConfig?: AppConfig; + limit?: number; + loadAppConfig?: () => Promise; +}; + +export type ExpiredFileSweepResult = { + scanned: number; + deleted: number; + failed: number; +}; + +export function getFileRetentionSweepInterval( + interval = process.env.FILE_RETENTION_SWEEP_INTERVAL_MS, +): number { + if (interval == null || interval.trim() === '') { + return DEFAULT_FILE_RETENTION_SWEEP_INTERVAL_MS; + } + + const value = Number(interval); + if (!Number.isFinite(value) || value < 0 || (value > 0 && value < 1)) { + return DEFAULT_FILE_RETENTION_SWEEP_INTERVAL_MS; + } + return value; +} + +export function getExpiredFileEndpoint(source?: string): string { + return source === FileSources.azure ? EModelEndpoint.azureAssistants : EModelEndpoint.assistants; +} + +export function hasExpiredFileEndpointConfig(appConfig: AppConfig | undefined, source?: string) { + if (source === FileSources.azure) { + return Boolean(appConfig?.endpoints?.[EModelEndpoint.azureOpenAI]?.assistants); + } + + return Boolean(appConfig?.endpoints?.[EModelEndpoint.assistants]); +} + +export function getConfiguredExpiredFileAssistantVersion({ + appConfig, + source, + endpoint, +}: { + appConfig?: AppConfig; + source?: string; + endpoint: string; +}): unknown { + const endpoints = appConfig?.endpoints as + | Record + | undefined; + const endpointVersion = endpoints?.[endpoint]?.version; + if (endpointVersion != null) { + return endpointVersion; + } + + if (source === FileSources.azure) { + const azureAssistantsConfig = endpoints?.[EModelEndpoint.azureOpenAI]?.assistants; + if (typeof azureAssistantsConfig === 'object' && azureAssistantsConfig?.version != null) { + return azureAssistantsConfig.version; + } + } + + return undefined; +} + +export function getExpiredFileAssistantVersion({ + appConfig, + source, + endpoint, +}: { + appConfig?: AppConfig; + source?: string; + endpoint: string; +}): string { + const configuredVersion = getConfiguredExpiredFileAssistantVersion({ + appConfig, + source, + endpoint, + }); + const assistantVersions = defaultAssistantsVersion as Record; + const fallbackVersion = assistantVersions[endpoint] ?? defaultAssistantsVersion.assistants ?? 2; + + return String(configuredVersion ?? fallbackVersion).replace(/^v/, ''); +} + +export function createExpiredFileSweepRequest({ + appConfig, + file, + userId, +}: { + appConfig?: AppConfig; + file: ExpiredFile; + userId: string; +}): SweepRequest { + const source = file.source ?? FileSources.local; + const endpoint = getExpiredFileEndpoint(source); + const version = getExpiredFileAssistantVersion({ appConfig, source, endpoint }); + const baseUrl = `/api/assistants/v${version}`; + + return { + baseUrl, + originalUrl: `${baseUrl}/files`, + path: '/files', + method: 'DELETE', + headers: {}, + query: {}, + params: {}, + config: appConfig, + body: { + endpoint, + version, + }, + user: { + id: userId, + tenantId: file.tenantId, + }, + }; +} + +export async function resolveExpiredFileSweepConfig({ + appConfig, + file, + loadAppConfig, +}: { + appConfig?: AppConfig; + file: ExpiredFile; + loadAppConfig?: () => Promise; +}): Promise { + const source = file.source ?? FileSources.local; + if ( + !checkOpenAIStorage(source) || + hasExpiredFileEndpointConfig(appConfig, source) || + typeof loadAppConfig !== 'function' + ) { + return appConfig; + } + + return (await loadAppConfig()) ?? appConfig; +} + +export async function sweepExpiredFiles( + { appConfig, limit = 100, loadAppConfig }: ExpiredFileSweepOptions = {}, + { getExpiredFiles, processDeleteRequest, logger }: SweepDependencies, +): Promise { + const files = (await getExpiredFiles(limit)) ?? []; + let resolvedAppConfig = appConfig; + let deleted = 0; + let failed = 0; + + for (const file of files) { + const userId = typeof file.user === 'string' ? file.user : file.user?.toString?.(); + if (!userId) { + logger.warn(`[sweepExpiredFiles] Skipping expired file without user: ${file.file_id}`); + failed++; + continue; + } + + try { + resolvedAppConfig = await resolveExpiredFileSweepConfig({ + appConfig: resolvedAppConfig, + file, + loadAppConfig, + }); + const req = createExpiredFileSweepRequest({ appConfig: resolvedAppConfig, file, userId }); + const { deletedFileIds, failedFileIds } = await processDeleteRequest({ req, files: [file] }); + if (failedFileIds.includes(file.file_id)) { + failed++; + continue; + } + + if (deletedFileIds.includes(file.file_id)) { + deleted++; + } else { + failed++; + logger.error( + `[sweepExpiredFiles] Delete request finished without resolving expired file ${file.file_id}`, + ); + } + } catch (error) { + failed++; + logger.error(`[sweepExpiredFiles] Error deleting expired file ${file.file_id}:`, error); + } + } + + if (deleted > 0 || failed > 0) { + logger.info( + `[sweepExpiredFiles] Processed ${files.length} expired files: ${deleted} deleted, ${failed} failed`, + ); + } + + return { scanned: files.length, deleted, failed }; +} + +export function startExpiredFileSweep( + options: ExpiredFileSweepOptions = {}, + { sweepExpiredFiles, runAsSystem, logger }: StartSweepDependencies, +): NodeJS.Timeout | null { + const intervalMs = getFileRetentionSweepInterval(); + if (intervalMs === 0) { + logger.info('[sweepExpiredFiles] Disabled by FILE_RETENTION_SWEEP_INTERVAL_MS=0'); + return null; + } + + let isSweeping = false; + const runSweep = async () => { + if (isSweeping) { + return; + } + + isSweeping = true; + try { + await runAsSystem(() => sweepExpiredFiles(options)); + } catch (error) { + logger.error('[sweepExpiredFiles] Background sweep failed:', error); + } finally { + isSweeping = false; + } + }; + + runSweep(); + const interval = setInterval(runSweep, intervalMs); + interval.unref?.(); + return interval; +} diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index ebc86122d19..f5a2be1fe9e 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -45,6 +45,7 @@ export const excludedKeys = new Set([ 'createdAt', 'updatedAt', 'expiredAt', + 'isTemporary', 'messages', 'isArchived', 'tags', @@ -895,6 +896,11 @@ const mcpServersSchema = z export type TMcpServersConfig = z.infer; +export enum RetentionMode { + ALL = 'all', + TEMPORARY = 'temporary', +} + export const interfaceSchema = z .object({ privacyPolicy: z @@ -937,6 +943,7 @@ export const interfaceSchema = z temporaryChat: z.boolean().optional(), temporaryChatRetention: z.number().min(1).max(8760).optional(), autoSubmitFromUrl: z.boolean().optional(), + retentionMode: z.nativeEnum(RetentionMode).default(RetentionMode.TEMPORARY), runCode: z.boolean().optional(), webSearch: z.boolean().optional(), peoplePicker: z diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index 3d72940872c..7a8d7e47b7b 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -648,6 +648,8 @@ export const tMessageSchema = z.object({ /** @deprecated */ generation: z.string().nullable().optional(), isCreatedByUser: z.boolean(), + isTemporary: z.boolean().optional(), + expiredAt: z.string().nullable().optional(), error: z.boolean().optional(), clientTimestamp: z.string().optional(), createdAt: z @@ -855,6 +857,7 @@ export const tConversationSchema = z.object({ iconURL: z.string().nullable().optional(), /* temporary chat */ expiredAt: z.string().nullable().optional(), + isTemporary: z.boolean().optional(), /* file token limits */ fileTokenLimit: coerceNumber.optional(), /** @deprecated */ diff --git a/packages/data-provider/src/types/mutations.ts b/packages/data-provider/src/types/mutations.ts index 4f40794527a..d0f69cac189 100644 --- a/packages/data-provider/src/types/mutations.ts +++ b/packages/data-provider/src/types/mutations.ts @@ -446,6 +446,7 @@ export type ToolParams = ToolParamsMap[T] & { partIndex?: number; blockIndex?: number; conversationId: string; + isTemporary?: boolean; }; export type ToolCallResponse = { result: unknown; attachments?: types.TAttachment[] }; export type ToolCallMutationOptions = MutationOptions< diff --git a/packages/data-schemas/src/app/assistants.ts b/packages/data-schemas/src/app/assistants.ts index c41a8d603ed..ddc56cf495f 100644 --- a/packages/data-schemas/src/app/assistants.ts +++ b/packages/data-schemas/src/app/assistants.ts @@ -52,6 +52,10 @@ export function assistantsConfigSetup( return { ...prevConfig, + version: + assistantsConfig?.version != null + ? parsedConfig.version + : (prevConfig.version ?? parsedConfig.version), retrievalModels: parsedConfig.retrievalModels, disableBuilder: parsedConfig.disableBuilder, pollIntervalMs: parsedConfig.pollIntervalMs, diff --git a/packages/data-schemas/src/app/interface.ts b/packages/data-schemas/src/app/interface.ts index 4e719e8ac52..2dbe387ccf1 100644 --- a/packages/data-schemas/src/app/interface.ts +++ b/packages/data-schemas/src/app/interface.ts @@ -48,6 +48,7 @@ export async function loadDefaultInterface({ agents: interfaceConfig?.agents, temporaryChat: interfaceConfig?.temporaryChat, temporaryChatRetention: interfaceConfig?.temporaryChatRetention, + retentionMode: interfaceConfig?.retentionMode, runCode: interfaceConfig?.runCode, webSearch: interfaceConfig?.webSearch, fileSearch: interfaceConfig?.fileSearch, diff --git a/packages/data-schemas/src/app/service.spec.ts b/packages/data-schemas/src/app/service.spec.ts index 80298b3e182..2a2a8ffd1d8 100644 --- a/packages/data-schemas/src/app/service.spec.ts +++ b/packages/data-schemas/src/app/service.spec.ts @@ -1,5 +1,6 @@ import type { DeepPartial, TCustomConfig } from 'librechat-data-provider'; -import { loadSummarizationConfig } from './service'; +import { EModelEndpoint, defaultAssistantsVersion } from 'librechat-data-provider'; +import { AppService, loadSummarizationConfig } from './service'; import logger from '~/config/winston'; jest.mock('~/config/winston', () => ({ @@ -78,3 +79,70 @@ describe('loadSummarizationConfig', () => { expect(String(warnSpy.mock.calls[0][0])).toContain('Invalid summarization config'); }); }); + +describe('AppService assistants config', () => { + it('preserves configured Assistants API versions', async () => { + const config = { + endpoints: { + [EModelEndpoint.assistants]: { + version: 'v3', + }, + [EModelEndpoint.azureOpenAI]: { + assistants: true, + groups: [ + { + group: 'azure-assistants-test', + apiKey: 'test-key', + instanceName: 'azure-assistants-test', + assistants: true, + version: '2024-02-15-preview', + models: { + 'gpt-4': { + deploymentName: 'gpt-4', + }, + }, + }, + ], + }, + [EModelEndpoint.azureAssistants]: { + version: 4, + }, + }, + } as DeepPartial; + + const result = await AppService({ config }); + + expect(result.endpoints?.[EModelEndpoint.assistants]?.version).toBe('v3'); + expect(result.endpoints?.[EModelEndpoint.azureAssistants]?.version).toBe(4); + }); + + it('keeps Azure Assistants default version when only Azure OpenAI enables assistants', async () => { + const config = { + endpoints: { + [EModelEndpoint.azureOpenAI]: { + assistants: true, + groups: [ + { + group: 'azure-assistants-test', + apiKey: 'test-key', + instanceName: 'azure-assistants-test', + assistants: true, + version: '2024-02-15-preview', + models: { + 'gpt-4': { + deploymentName: 'gpt-4', + }, + }, + }, + ], + }, + }, + } as DeepPartial; + + const result = await AppService({ config }); + + expect(result.endpoints?.[EModelEndpoint.azureAssistants]?.version).toBe( + defaultAssistantsVersion.azureAssistants, + ); + }); +}); diff --git a/packages/data-schemas/src/methods/conversation.spec.ts b/packages/data-schemas/src/methods/conversation.spec.ts index 166e5b51c89..5646d7dfff2 100644 --- a/packages/data-schemas/src/methods/conversation.spec.ts +++ b/packages/data-schemas/src/methods/conversation.spec.ts @@ -1,6 +1,6 @@ import mongoose from 'mongoose'; import { v4 as uuidv4 } from 'uuid'; -import { EModelEndpoint } from 'librechat-data-provider'; +import { EModelEndpoint, RetentionMode } from 'librechat-data-provider'; import type { IConversation } from '../types'; import { MongoMemoryServer } from 'mongodb-memory-server'; import { ConversationMethods, createConversationMethods } from './conversation'; @@ -58,6 +58,8 @@ const saveConvo = (...args: Parameters) => methods.saveConvo(...args) as Promise; const getConvo = (...args: Parameters) => methods.getConvo(...args); +const getConvoRetention = (...args: Parameters) => + methods.getConvoRetention(...args); const getConvoTitle = (...args: Parameters) => methods.getConvoTitle(...args); const getConvoFiles = (...args: Parameters) => @@ -78,7 +80,7 @@ describe('Conversation Operations', () => { let mockCtx: { userId: string; isTemporary?: boolean; - interfaceConfig?: { temporaryChatRetention?: number }; + interfaceConfig?: { temporaryChatRetention?: number; retentionMode?: RetentionMode }; }; let mockConversationData: { conversationId: string; @@ -263,7 +265,7 @@ describe('Conversation Operations', () => { const result = await saveConvo(mockCtx, mockConversationData); expect(result?.conversationId).toBe(mockConversationData.conversationId); - expect(result?.expiredAt).toBeNull(); + expect(result?.expiredAt).toBeUndefined(); }); it('should use custom retention period from config', async () => { @@ -401,6 +403,21 @@ describe('Conversation Operations', () => { ); }); + it('should preserve temporary retention when saving without isTemporary', async () => { + mockCtx.interfaceConfig = { temporaryChatRetention: 24 }; + mockCtx.isTemporary = true; + const firstSave = await saveConvo(mockCtx, mockConversationData); + const originalExpiredAt = firstSave?.expiredAt; + + mockCtx.isTemporary = undefined; + const updatedData = { ...mockConversationData, title: 'Updated Title' }; + const secondSave = await saveConvo(mockCtx, updatedData); + + expect(secondSave?.title).toBe('Updated Title'); + expect(secondSave?.isTemporary).toBe(true); + expect(secondSave?.expiredAt).toEqual(originalExpiredAt); + }); + it('should not set expiredAt when updating non-temporary conversation', async () => { // First save a non-temporary conversation mockCtx.isTemporary = false; @@ -416,23 +433,124 @@ describe('Conversation Operations', () => { expect(secondSave?.expiredAt).toBeNull(); }); - it('should filter out expired conversations in getConvosByCursor', async () => { + it('should set expiredAt for non-temporary conversation when retentionMode is ALL', async () => { + mockCtx.isTemporary = false; + mockCtx.interfaceConfig = { + temporaryChatRetention: 24, + retentionMode: RetentionMode.ALL, + }; + const result = await saveConvo(mockCtx, mockConversationData); + expect(result?.expiredAt).toBeDefined(); + expect(result?.isTemporary).toBe(false); + }); + + it('should mark retained conversation non-temporary when retentionMode is ALL and isTemporary is omitted', async () => { + mockCtx.isTemporary = undefined; + mockCtx.interfaceConfig = { + temporaryChatRetention: 24, + retentionMode: RetentionMode.ALL, + }; + + const result = await saveConvo(mockCtx, mockConversationData); + + expect(result?.expiredAt).toBeDefined(); + expect(result?.isTemporary).toBe(false); + }); + + it('should preserve existing temporary flag when retentionMode is ALL and isTemporary is omitted', async () => { + mockCtx.isTemporary = true; + mockCtx.interfaceConfig = { + temporaryChatRetention: 24, + retentionMode: RetentionMode.ALL, + }; + + const firstSave = await saveConvo(mockCtx, mockConversationData); + + mockCtx.isTemporary = undefined; + const secondSave = await saveConvo(mockCtx, { + ...mockConversationData, + title: 'Updated Title', + }); + + expect(firstSave?.isTemporary).toBe(true); + expect(secondSave?.title).toBe('Updated Title'); + expect(secondSave?.isTemporary).toBe(true); + expect(secondSave?.expiredAt).toBeDefined(); + }); + + it('should not set expiredAt when retentionMode is temporary and not isTemporary', async () => { + mockCtx.isTemporary = false; + mockCtx.interfaceConfig = { + temporaryChatRetention: 24, + retentionMode: RetentionMode.TEMPORARY, + }; + const result = await saveConvo(mockCtx, mockConversationData); + expect(result?.expiredAt).toBeNull(); + expect(result?.isTemporary).toBe(false); + }); + + it('should filter out temporary conversations in getConvosByCursor', async () => { // Create some test conversations - const nonExpiredConvo = await Conversation.create({ + const newNonTemporaryConvo = await Conversation.create({ + conversationId: uuidv4(), + user: 'user123', + title: 'New Non-temporary Conversation', + endpoint: EModelEndpoint.openAI, + isTemporary: false, + expiredAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + updatedAt: new Date(), + }); + + const oldNonTemporaryConvo = await Conversation.create({ conversationId: uuidv4(), user: 'user123', - title: 'Non-expired', + title: 'Old Non-Temporary Conversation', + endpoint: EModelEndpoint.openAI, + isTemporary: undefined, + expiredAt: null, + updatedAt: new Date(), + }); + + const legacyNullNonTemporaryConvoId = uuidv4(); + await Conversation.collection.insertOne({ + conversationId: legacyNullNonTemporaryConvoId, + user: 'user123', + title: 'Legacy Null Non-Temporary Conversation', endpoint: EModelEndpoint.openAI, + isTemporary: null, expiredAt: null, updatedAt: new Date(), + createdAt: new Date(), + }); + + const legacyTemporaryConvoId = uuidv4(); + await Conversation.collection.insertOne({ + conversationId: legacyTemporaryConvoId, + user: 'user123', + title: 'Legacy Temporary Conversation', + endpoint: EModelEndpoint.openAI, + expiredAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + updatedAt: new Date(), + createdAt: new Date(), + }); + + const expiredRetainedConvo = await Conversation.create({ + conversationId: uuidv4(), + user: 'user123', + title: 'Expired Retained Conversation', + endpoint: EModelEndpoint.openAI, + isTemporary: false, + expiredAt: new Date(Date.now() - 60 * 60 * 1000), + updatedAt: new Date(), }); await Conversation.create({ conversationId: uuidv4(), user: 'user123', - title: 'Future expired', + title: 'Temporary conversation', endpoint: EModelEndpoint.openAI, - expiredAt: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 hours from now + isTemporary: true, + expiredAt: new Date(Date.now() + 24 * 60 * 60 * 1000), updatedAt: new Date(), }); @@ -441,41 +559,104 @@ describe('Conversation Operations', () => { const result = await getConvosByCursor('user123'); - // Should only return conversations with null or non-existent expiredAt - expect(result?.conversations).toHaveLength(1); - expect(result?.conversations[0]?.conversationId).toBe(nonExpiredConvo.conversationId); + // Should return both non-temporary conversations, not the temporary one + expect(result?.conversations).toHaveLength(3); + const convoIds = result?.conversations.map((c) => c.conversationId); + expect(convoIds).toContain(newNonTemporaryConvo.conversationId); + expect(convoIds).toContain(oldNonTemporaryConvo.conversationId); + expect(convoIds).toContain(legacyNullNonTemporaryConvoId); + expect(convoIds).not.toContain(legacyTemporaryConvoId); + expect(convoIds).not.toContain(expiredRetainedConvo.conversationId); }); - it('should filter out expired conversations in getConvosQueried', async () => { - // Create test conversations - const nonExpiredConvo = await Conversation.create({ + it('should filter out temporary conversations in getConvosQueried', async () => { + const newNonTemporaryConvo = await Conversation.create({ + conversationId: uuidv4(), + user: 'user123', + title: 'New Non-temporary Conversation', + endpoint: EModelEndpoint.openAI, + isTemporary: false, + expiredAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + updatedAt: new Date(), + }); + + const oldNonTemporaryConvo = await Conversation.create({ conversationId: uuidv4(), user: 'user123', - title: 'Non-expired', + title: 'Old Non-Temporary Conversation', + endpoint: EModelEndpoint.openAI, + isTemporary: undefined, + expiredAt: null, + updatedAt: new Date(), + }); + + const legacyNullNonTemporaryConvoId = uuidv4(); + await Conversation.collection.insertOne({ + conversationId: legacyNullNonTemporaryConvoId, + user: 'user123', + title: 'Legacy Null Non-Temporary Conversation', endpoint: EModelEndpoint.openAI, + isTemporary: null, expiredAt: null, + updatedAt: new Date(), + createdAt: new Date(), + }); + + const legacyTemporaryConvoId = uuidv4(); + await Conversation.collection.insertOne({ + conversationId: legacyTemporaryConvoId, + user: 'user123', + title: 'Legacy Temporary Conversation', + endpoint: EModelEndpoint.openAI, + expiredAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + updatedAt: new Date(), + createdAt: new Date(), + }); + + const expiredRetainedConvo = await Conversation.create({ + conversationId: uuidv4(), + user: 'user123', + title: 'Expired Retained Conversation', + endpoint: EModelEndpoint.openAI, + isTemporary: false, + expiredAt: new Date(Date.now() - 60 * 60 * 1000), + updatedAt: new Date(), }); - const expiredConvo = await Conversation.create({ + const tempConvo = await Conversation.create({ conversationId: uuidv4(), user: 'user123', - title: 'Expired', + title: 'Temporary conversation', endpoint: EModelEndpoint.openAI, + isTemporary: true, expiredAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + updatedAt: new Date(), }); const convoIds = [ - { conversationId: nonExpiredConvo.conversationId }, - { conversationId: expiredConvo.conversationId }, + { conversationId: newNonTemporaryConvo.conversationId }, + { conversationId: oldNonTemporaryConvo.conversationId }, + { conversationId: legacyNullNonTemporaryConvoId }, + { conversationId: legacyTemporaryConvoId }, + { conversationId: expiredRetainedConvo.conversationId }, + { conversationId: tempConvo.conversationId }, ]; const result = await getConvosQueried('user123', convoIds); - // Should only return the non-expired conversation - expect(result?.conversations).toHaveLength(1); - expect(result?.conversations[0].conversationId).toBe(nonExpiredConvo.conversationId); - expect(result?.convoMap[nonExpiredConvo.conversationId]).toBeDefined(); - expect(result?.convoMap[expiredConvo.conversationId]).toBeUndefined(); + // Should only return the non-temporary conversations + expect(result?.conversations).toHaveLength(3); + + const resultIds = result?.conversations.map((c) => c.conversationId); + expect(resultIds).toContain(newNonTemporaryConvo.conversationId); + expect(resultIds).toContain(oldNonTemporaryConvo.conversationId); + expect(resultIds).toContain(legacyNullNonTemporaryConvoId); + expect(result?.convoMap[newNonTemporaryConvo.conversationId]).toBeDefined(); + expect(result?.convoMap[oldNonTemporaryConvo.conversationId]).toBeDefined(); + expect(result?.convoMap[legacyNullNonTemporaryConvoId]).toBeDefined(); + expect(result?.convoMap[legacyTemporaryConvoId]).toBeUndefined(); + expect(result?.convoMap[expiredRetainedConvo.conversationId]).toBeUndefined(); + expect(result?.convoMap[tempConvo.conversationId]).toBeUndefined(); }); }); @@ -524,6 +705,24 @@ describe('Conversation Operations', () => { }); }); + describe('getConvoRetention', () => { + it('should retrieve only retention fields for a user conversation', async () => { + await Conversation.create({ + conversationId: mockConversationData.conversationId, + user: 'user123', + title: 'Test Conversation', + endpoint: EModelEndpoint.openAI, + expiredAt: new Date('2030-01-01T00:00:00.000Z'), + }); + + const result = await getConvoRetention('user123', mockConversationData.conversationId); + + expect(result?.expiredAt).toEqual(new Date('2030-01-01T00:00:00.000Z')); + expect(result).not.toHaveProperty('title'); + expect(result).not.toHaveProperty('messages'); + }); + }); + describe('getConvoTitle', () => { it('should return the conversation title', async () => { await Conversation.create({ diff --git a/packages/data-schemas/src/methods/conversation.ts b/packages/data-schemas/src/methods/conversation.ts index 82c22f3947b..c46487e519c 100644 --- a/packages/data-schemas/src/methods/conversation.ts +++ b/packages/data-schemas/src/methods/conversation.ts @@ -1,5 +1,7 @@ import type { FilterQuery, Model, SortOrder } from 'mongoose'; +import { RetentionMode } from 'librechat-data-provider'; import { createTempChatExpirationDate } from '~/utils/tempChatRetention'; +import { buildRetentionVisibilityFilter, createFallbackRetentionDate } from '~/utils/retention'; import { tenantSafeBulkWrite } from '~/utils/tenantBulkWrite'; import logger from '~/config/winston'; import type { AppConfig, IConversation } from '~/types'; @@ -47,6 +49,10 @@ export interface ConversationMethods { convoMap: Record; }>; getConvo(user: string, conversationId: string): Promise; + getConvoRetention( + user: string, + conversationId: string, + ): Promise | null>; getConvoTitle(user: string, conversationId: string): Promise; deleteConvos( user: string, @@ -65,6 +71,10 @@ export function createConversationMethods( return messageMethods; } + function getVisibleConversationRetentionFilter(): FilterQuery { + return buildRetentionVisibilityFilter(); + } + /** * Searches for a conversation by conversationId and returns a lean document with only conversationId and user. */ @@ -94,6 +104,24 @@ export function createConversationMethods( } } + /** + * Retrieves only the retention deadline for a conversation. + */ + async function getConvoRetention( + user: string, + conversationId: string, + ): Promise | null> { + try { + const Conversation = mongoose.models.Conversation as Model; + return await Conversation.findOne({ user, conversationId }, 'expiredAt').lean< + Pick + >(); + } catch (error) { + logger.error('[getConvoRetention] Error getting conversation retention fields', error); + throw new Error('Error getting conversation retention fields'); + } + } + /** * Deletes conversations and messages with null or empty IDs. */ @@ -185,15 +213,28 @@ export function createConversationMethods( update.conversationId = newConversationId; } - if (isTemporary) { + if (interfaceConfig?.retentionMode === RetentionMode.ALL) { + if (typeof isTemporary === 'boolean') { + update.isTemporary = isTemporary; + } try { update.expiredAt = createTempChatExpirationDate(interfaceConfig); } catch (err) { logger.error('Error creating temporary chat expiration date:', err); logger.info(`---\`saveConvo\` context: ${metadata?.context}`); - update.expiredAt = null; + update.expiredAt = createFallbackRetentionDate(); } - } else { + } else if (isTemporary === true) { + update.isTemporary = true; + try { + update.expiredAt = createTempChatExpirationDate(interfaceConfig); + } catch (err) { + logger.error('Error creating temporary chat expiration date:', err); + logger.info(`---\`saveConvo\` context: ${metadata?.context}`); + update.expiredAt = createFallbackRetentionDate(); + } + } else if (isTemporary === false) { + update.isTemporary = false; update.expiredAt = null; } @@ -229,6 +270,19 @@ export function createConversationMethods( return null; } + if ( + interfaceConfig?.retentionMode === RetentionMode.ALL && + typeof isTemporary !== 'boolean' && + (conversation.isTemporary == null || + (conversation.isTemporary === false && conversation.$isDefault('isTemporary'))) + ) { + await Conversation.updateOne( + { _id: conversation._id, isTemporary: { $ne: false } }, + { $set: { isTemporary: false } }, + ); + conversation.isTemporary = false; + } + return conversation.toObject(); } catch (error) { logger.error('[saveConvo] Error saving conversation', error); @@ -302,9 +356,7 @@ export function createConversationMethods( filters.push({ tags: { $in: tags } } as FilterQuery); } - filters.push({ - $or: [{ expiredAt: null }, { expiredAt: { $exists: false } }], - } as FilterQuery); + filters.push(getVisibleConversationRetentionFilter()); if (search) { try { @@ -429,7 +481,7 @@ export function createConversationMethods( const results = await Conversation.find({ user, conversationId: { $in: conversationIds }, - $or: [{ expiredAt: { $exists: false } }, { expiredAt: null }], + ...getVisibleConversationRetentionFilter(), }).lean(); results.sort( @@ -516,6 +568,7 @@ export function createConversationMethods( getConvosByCursor, getConvosQueried, getConvo, + getConvoRetention, getConvoTitle, deleteConvos, }; diff --git a/packages/data-schemas/src/methods/file.spec.ts b/packages/data-schemas/src/methods/file.spec.ts index 9bb3895d27c..08dd02326c1 100644 --- a/packages/data-schemas/src/methods/file.spec.ts +++ b/packages/data-schemas/src/methods/file.spec.ts @@ -233,6 +233,69 @@ describe('File Methods', () => { }); }); + describe('getExpiredFiles', () => { + it('returns only files whose expiredAt date has passed', async () => { + const userId = new mongoose.Types.ObjectId(); + const now = new Date('2030-01-01T00:00:00.000Z'); + const expiredFileId = uuidv4(); + const futureFileId = uuidv4(); + const permanentFileId = uuidv4(); + const missingExpiryFileId = uuidv4(); + + await fileMethods.createFile( + { + file_id: expiredFileId, + user: userId, + filename: 'expired.txt', + filepath: '/uploads/expired.txt', + type: 'text/plain', + bytes: 100, + expiredAt: new Date('2029-12-31T23:59:59.000Z'), + }, + true, + ); + await fileMethods.createFile( + { + file_id: futureFileId, + user: userId, + filename: 'future.txt', + filepath: '/uploads/future.txt', + type: 'text/plain', + bytes: 100, + expiredAt: new Date('2030-01-01T00:00:01.000Z'), + }, + true, + ); + await fileMethods.createFile( + { + file_id: permanentFileId, + user: userId, + filename: 'permanent.txt', + filepath: '/uploads/permanent.txt', + type: 'text/plain', + bytes: 100, + expiredAt: null, + }, + true, + ); + await fileMethods.createFile( + { + file_id: missingExpiryFileId, + user: userId, + filename: 'missing-expiry.txt', + filepath: '/uploads/missing-expiry.txt', + type: 'text/plain', + bytes: 100, + }, + true, + ); + + const files = await fileMethods.getExpiredFiles(100, now); + + expect(files.map((file) => file.file_id)).toEqual([expiredFileId]); + }); + }); + describe('getToolFilesByIds', () => { it('should retrieve files for file_search tool (embedded files)', async () => { const userId = new mongoose.Types.ObjectId(); diff --git a/packages/data-schemas/src/methods/file.ts b/packages/data-schemas/src/methods/file.ts index e1b49b2c673..a76cbc13155 100644 --- a/packages/data-schemas/src/methods/file.ts +++ b/packages/data-schemas/src/methods/file.ts @@ -47,6 +47,14 @@ export function createFileMethods(mongoose: typeof import('mongoose')) { return await query.sort(sortOptions).lean(); } + async function getExpiredFiles(limit = 100, now = new Date()): Promise { + const File = mongoose.models.File as Model; + return await File.find({ expiredAt: { $ne: null, $lte: now } }) + .sort({ expiredAt: 1 }) + .limit(limit) + .lean(); + } + /** * Retrieves tool files (files that are embedded or have a fileIdentifier) from an array of file IDs. * Note: execute_code files are handled separately by getCodeGeneratedFiles. @@ -457,6 +465,7 @@ export function createFileMethods(mongoose: typeof import('mongoose')) { return { findFileById, getFiles, + getExpiredFiles, getToolFilesByIds, getCodeGeneratedFiles, getUserCodeFiles, diff --git a/packages/data-schemas/src/methods/message.spec.ts b/packages/data-schemas/src/methods/message.spec.ts index 14fe28596d0..98b02dd19d5 100644 --- a/packages/data-schemas/src/methods/message.spec.ts +++ b/packages/data-schemas/src/methods/message.spec.ts @@ -1,5 +1,6 @@ import mongoose from 'mongoose'; import { v4 as uuidv4 } from 'uuid'; +import { RetentionMode } from 'librechat-data-provider'; import { MongoMemoryServer } from 'mongodb-memory-server'; import type { IMessage } from '..'; import { createMessageMethods } from './message'; @@ -54,7 +55,7 @@ describe('Message Operations', () => { let mockCtx: { userId: string; isTemporary?: boolean; - interfaceConfig?: { temporaryChatRetention?: number }; + interfaceConfig?: { temporaryChatRetention?: number; retentionMode?: RetentionMode }; }; let mockMessageData: Partial = { messageId: 'msg123', @@ -403,7 +404,7 @@ describe('Message Operations', () => { const result = await saveMessage(mockCtx, mockMessageData); expect(result?.messageId).toBe('msg123'); - expect(result?.expiredAt).toBeNull(); + expect(result?.expiredAt).toBeUndefined(); }); it('should use custom retention period from config', async () => { @@ -475,6 +476,61 @@ describe('Message Operations', () => { ); }); + it('should set expiredAt for non-temporary message when retentionMode is ALL', async () => { + mockCtx.isTemporary = false; + mockCtx.interfaceConfig = { + temporaryChatRetention: 24, + retentionMode: RetentionMode.ALL, + }; + const result = await saveMessage(mockCtx, mockMessageData); + expect(result?.expiredAt).toBeDefined(); + expect(result?.expiredAt).toBeInstanceOf(Date); + }); + + it('should mark retained message non-temporary when retentionMode is ALL and isTemporary is omitted', async () => { + mockCtx.isTemporary = undefined; + mockCtx.interfaceConfig = { + temporaryChatRetention: 24, + retentionMode: RetentionMode.ALL, + }; + + const result = await saveMessage(mockCtx, mockMessageData); + + expect(result?.expiredAt).toBeDefined(); + expect(result?.isTemporary).toBe(false); + }); + + it('should preserve existing temporary flag when retentionMode is ALL and isTemporary is omitted', async () => { + mockCtx.isTemporary = true; + mockCtx.interfaceConfig = { + temporaryChatRetention: 24, + retentionMode: RetentionMode.ALL, + }; + + const firstSave = await saveMessage(mockCtx, mockMessageData); + + mockCtx.isTemporary = undefined; + const secondSave = await saveMessage(mockCtx, { + ...mockMessageData, + text: 'Updated text', + }); + + expect(firstSave?.isTemporary).toBe(true); + expect(secondSave?.text).toBe('Updated text'); + expect(secondSave?.isTemporary).toBe(true); + expect(secondSave?.expiredAt).toBeDefined(); + }); + + it('should not set expiredAt when retentionMode is temporary and not isTemporary', async () => { + mockCtx.isTemporary = false; + mockCtx.interfaceConfig = { + temporaryChatRetention: 24, + retentionMode: RetentionMode.TEMPORARY, + }; + const result = await saveMessage(mockCtx, mockMessageData); + expect(result?.expiredAt).toBeNull(); + }); + it('should handle missing config gracefully', async () => { // Simulate missing config - should use default retention period delete mockCtx.interfaceConfig; @@ -571,6 +627,22 @@ describe('Message Operations', () => { ); }); + it('should preserve temporary retention when saving without isTemporary', async () => { + mockCtx.interfaceConfig = { temporaryChatRetention: 24 }; + + mockCtx.isTemporary = true; + const firstSave = await saveMessage(mockCtx, mockMessageData); + const originalExpiredAt = firstSave?.expiredAt; + + mockCtx.isTemporary = undefined; + const updatedData = { ...mockMessageData, text: 'Updated text' }; + const secondSave = await saveMessage(mockCtx, updatedData); + + expect(secondSave?.text).toBe('Updated text'); + expect(secondSave?.isTemporary).toBe(true); + expect(secondSave?.expiredAt).toEqual(originalExpiredAt); + }); + it('should handle bulk operations with temporary messages', async () => { // This test verifies bulkSaveMessages doesn't interfere with expiredAt const messages = [ diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts index b4b5038ca82..acaff7c7784 100644 --- a/packages/data-schemas/src/methods/message.ts +++ b/packages/data-schemas/src/methods/message.ts @@ -1,6 +1,8 @@ import type { DeleteResult, FilterQuery, Model } from 'mongoose'; +import { RetentionMode } from 'librechat-data-provider'; import logger from '~/config/winston'; import { createTempChatExpirationDate } from '~/utils/tempChatRetention'; +import { createFallbackRetentionDate } from '~/utils/retention'; import { tenantSafeBulkWrite } from '~/utils/tenantBulkWrite'; import type { AppConfig, IMessage } from '~/types'; @@ -91,15 +93,28 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa messageId: params.newMessageId || params.messageId, }; - if (isTemporary) { + if (interfaceConfig?.retentionMode === RetentionMode.ALL) { + if (typeof isTemporary === 'boolean') { + update.isTemporary = isTemporary; + } + try { + update.expiredAt = createTempChatExpirationDate(interfaceConfig); + } catch (err) { + logger.error('Error creating temporary chat expiration date:', err); + logger.info(`---\`saveMessage\` context: ${metadata?.context}`); + update.expiredAt = createFallbackRetentionDate(); + } + } else if (isTemporary === true) { + update.isTemporary = true; try { update.expiredAt = createTempChatExpirationDate(interfaceConfig); } catch (err) { logger.error('Error creating temporary chat expiration date:', err); logger.info(`---\`saveMessage\` context: ${metadata?.context}`); - update.expiredAt = null; + update.expiredAt = createFallbackRetentionDate(); } - } else { + } else if (isTemporary === false) { + update.isTemporary = false; update.expiredAt = null; } @@ -116,6 +131,19 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa { upsert: true, new: true }, ); + if ( + interfaceConfig?.retentionMode === RetentionMode.ALL && + typeof isTemporary !== 'boolean' && + (message.isTemporary == null || + (message.isTemporary === false && message.$isDefault('isTemporary'))) + ) { + await Message.updateOne( + { _id: message._id, isTemporary: { $ne: false } }, + { $set: { isTemporary: false } }, + ); + message.isTemporary = false; + } + return message.toObject(); } catch (err: unknown) { logger.error('Error saving message:', err); diff --git a/packages/data-schemas/src/methods/share.test.ts b/packages/data-schemas/src/methods/share.test.ts index 4f045d5b974..60b789c3dd0 100644 --- a/packages/data-schemas/src/methods/share.test.ts +++ b/packages/data-schemas/src/methods/share.test.ts @@ -28,6 +28,7 @@ describe('Share Methods', () => { shareId: { type: String, index: true }, targetMessageId: { type: String, required: false, index: true }, isPublic: { type: Boolean, default: true }, + expiredAt: { type: Date }, }, { timestamps: true }, ); @@ -154,6 +155,41 @@ describe('Share Methods', () => { ); }); + test('should ignore expired public shares when checking for duplicates', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + const expiredShareId = `share_${nanoid()}`; + + await Conversation.create({ + conversationId, + title: 'Test Conversation', + user: userId, + }); + + const message = await Message.create({ + messageId: `msg_${nanoid()}`, + conversationId, + user: userId, + text: 'Test message', + isCreatedByUser: true, + }); + + await SharedLink.create({ + shareId: expiredShareId, + conversationId, + user: userId, + messages: [message._id], + isPublic: true, + expiredAt: new Date(Date.now() - 60 * 60 * 1000), + }); + + const result = await shareMethods.createSharedLink(userId, conversationId); + + expect(result.shareId).toBeDefined(); + expect(result.shareId).not.toBe(expiredShareId); + expect(result.conversationId).toBe(conversationId); + }); + test('should throw error with missing parameters', async () => { await expect(shareMethods.createSharedLink('', 'conv123')).rejects.toThrow( 'Missing required parameters', @@ -329,6 +365,21 @@ describe('Share Methods', () => { expect(result).toBeNull(); }); + test('should return null for expired share', async () => { + const shareId = `share_${nanoid()}`; + + await SharedLink.create({ + shareId, + conversationId: 'conv123', + user: 'user123', + isPublic: true, + expiredAt: new Date(Date.now() - 60 * 60 * 1000), + }); + + const result = await shareMethods.getSharedMessages(shareId); + expect(result).toBeNull(); + }); + test('should handle messages with attachments', async () => { const userId = new mongoose.Types.ObjectId().toString(); const conversationId = `conv_${nanoid()}`; @@ -428,6 +479,34 @@ describe('Share Methods', () => { expect(privateResults.links[0].title).toBe('Private Share'); }); + test('should exclude expired shares', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + + await SharedLink.create([ + { + shareId: 'active_share', + conversationId: 'conv1', + user: userId, + title: 'Active Share', + isPublic: true, + expiredAt: new Date(Date.now() + 60 * 60 * 1000), + }, + { + shareId: 'expired_share', + conversationId: 'conv2', + user: userId, + title: 'Expired Share', + isPublic: true, + expiredAt: new Date(Date.now() - 60 * 60 * 1000), + }, + ]); + + const result = await shareMethods.getSharedLinks(userId, undefined, 10, true); + + expect(result.links).toHaveLength(1); + expect(result.links[0].shareId).toBe('active_share'); + }); + test('should handle search with mocked meiliSearch and user filter', async () => { const userId = new mongoose.Types.ObjectId().toString(); @@ -659,6 +738,62 @@ describe('Share Methods', () => { expect(updatedShare?.messages).toHaveLength(2); }); + test('should preserve stale expiration when updating without an expiration decision', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + const shareId = `share_${nanoid()}`; + const expiresAt = new Date('2030-01-01T00:00:00.000Z'); + + await SharedLink.create({ + shareId, + conversationId, + user: userId, + messages: [], + isPublic: true, + expiredAt: expiresAt, + }); + await Message.create({ + messageId: `msg_${nanoid()}`, + conversationId, + user: userId, + text: 'Retained no longer applies', + isCreatedByUser: true, + }); + + const result = await shareMethods.updateSharedLink(userId, shareId); + const updatedShare = await SharedLink.findOne({ shareId: result.shareId }).lean(); + + expect(updatedShare?.expiredAt?.toISOString()).toBe(expiresAt.toISOString()); + }); + + test('should clear stale expiration when updating with null expiration', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + const shareId = `share_${nanoid()}`; + const expiresAt = new Date('2030-01-01T00:00:00.000Z'); + + await SharedLink.create({ + shareId, + conversationId, + user: userId, + messages: [], + isPublic: true, + expiredAt: expiresAt, + }); + await Message.create({ + messageId: `msg_${nanoid()}`, + conversationId, + user: userId, + text: 'Retained no longer applies', + isCreatedByUser: true, + }); + + const result = await shareMethods.updateSharedLink(userId, shareId, undefined, null); + const updatedShare = await SharedLink.findOne({ shareId: result.shareId }).lean(); + + expect(updatedShare?.expiredAt).toBeUndefined(); + }); + test('should throw error if share not found', async () => { await expect(shareMethods.updateSharedLink('user123', 'non_existent')).rejects.toThrow( 'Share not found', @@ -929,6 +1064,24 @@ describe('Share Methods', () => { expect(result.shareId).toBeNull(); }); + test('should return null shareId for expired shares', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const conversationId = `conv_${nanoid()}`; + + await SharedLink.create({ + shareId: 'share123', + conversationId, + user: userId, + isPublic: true, + expiredAt: new Date(Date.now() - 60 * 60 * 1000), + }); + + const result = await shareMethods.getSharedLink(userId, conversationId); + + expect(result.success).toBe(false); + expect(result.shareId).toBeNull(); + }); + test('should not return share from different user', async () => { const userId1 = new mongoose.Types.ObjectId().toString(); const userId2 = new mongoose.Types.ObjectId().toString(); diff --git a/packages/data-schemas/src/methods/share.ts b/packages/data-schemas/src/methods/share.ts index c6b6400e64f..0551b349c45 100644 --- a/packages/data-schemas/src/methods/share.ts +++ b/packages/data-schemas/src/methods/share.ts @@ -4,6 +4,7 @@ import type { FilterQuery, Model } from 'mongoose'; import type { SchemaWithMeiliMethods } from '~/models/plugins/mongoMeili'; import type * as t from '~/types'; import logger from '~/config/winston'; +import { activeExpirationFilter } from '~/utils/retention'; class ShareServiceError extends Error { code: string; @@ -161,7 +162,11 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { async function getSharedMessages(shareId: string): Promise { try { const SharedLink = mongoose.models.SharedLink as Model; - const share = (await SharedLink.findOne({ shareId, isPublic: true }) + const share = (await SharedLink.findOne({ + shareId, + isPublic: true, + ...activeExpirationFilter(), + }) .populate({ path: 'messages', select: '-_id -__v -user', @@ -215,7 +220,11 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { try { const SharedLink = mongoose.models.SharedLink as Model; const Conversation = mongoose.models.Conversation as SchemaWithMeiliMethods; - const query: FilterQuery = { user, isPublic }; + const query: FilterQuery = { + user, + isPublic, + ...activeExpirationFilter(), + }; if (pageParam) { if (sortDirection === 'desc') { @@ -345,6 +354,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { user: string, conversationId: string, targetMessageId?: string, + expiredAt?: Date, ): Promise { if (!user || !conversationId) { throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); @@ -359,6 +369,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { conversationId, user, isPublic: true, + ...activeExpirationFilter(), ...(targetMessageId && { targetMessageId }), }) .select('-_id -__v -user') @@ -408,6 +419,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { title, user, ...(targetMessageId && { targetMessageId }), + ...(expiredAt && { expiredAt }), }); return { shareId, conversationId, targetMessageId }; @@ -438,7 +450,12 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { try { const SharedLink = mongoose.models.SharedLink as Model; - const share = (await SharedLink.findOne({ conversationId, user, isPublic: true }) + const share = (await SharedLink.findOne({ + conversationId, + user, + isPublic: true, + ...activeExpirationFilter(), + }) .select('shareId targetMessageId -_id') .sort({ updatedAt: -1 }) .lean()) as { shareId?: string; targetMessageId?: string } | null; @@ -469,6 +486,7 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { user: string, shareId: string, targetMessageId?: string, + expiredAt?: Date | null, ): Promise { if (!user || !shareId) { throw new ShareServiceError('Missing required parameters', 'INVALID_PARAMS'); @@ -490,12 +508,17 @@ export function createShareMethods(mongoose: typeof import('mongoose')) { .lean(); const newShareId = nanoid(); + const hasNewExpiration = expiredAt instanceof Date; const resolvedTargetMessageId = targetMessageId ?? share.targetMessageId; const update = { - messages: updatedMessages, - user, - shareId: newShareId, - ...(resolvedTargetMessageId && { targetMessageId: resolvedTargetMessageId }), + $set: { + messages: updatedMessages, + user, + shareId: newShareId, + ...(resolvedTargetMessageId && { targetMessageId: resolvedTargetMessageId }), + ...(hasNewExpiration && { expiredAt }), + }, + ...(expiredAt === null ? { $unset: { expiredAt: 1 } } : {}), }; const updatedShare = (await SharedLink.findOneAndUpdate({ shareId, user }, update, { diff --git a/packages/data-schemas/src/models/plugins/mongoMeili.spec.ts b/packages/data-schemas/src/models/plugins/mongoMeili.spec.ts index 1d341a7939e..b4084fa936c 100644 --- a/packages/data-schemas/src/models/plugins/mongoMeili.spec.ts +++ b/packages/data-schemas/src/models/plugins/mongoMeili.spec.ts @@ -3,7 +3,55 @@ import mongoose from 'mongoose'; import { EModelEndpoint } from 'librechat-data-provider'; import { createConversationModel } from '~/models/convo'; import { createMessageModel } from '~/models/message'; -import { SchemaWithMeiliMethods } from '~/models/plugins/mongoMeili'; +import mongoMeili, { type SchemaWithMeiliMethods } from '~/models/plugins/mongoMeili'; + +interface DynamicMeiliDocument extends mongoose.Document { + docId: string; + user: string; + title: string; + isTemporary?: boolean; + expiredAt?: Date | null; + _meiliIndex?: boolean; +} + +type DynamicMeiliModel = mongoose.Model & SchemaWithMeiliMethods; + +const createDynamicMeiliModel = (modelName: string): DynamicMeiliModel => { + const schema = new mongoose.Schema({ + docId: { + type: String, + required: true, + meiliIndex: true, + }, + title: { + type: String, + meiliIndex: true, + }, + user: { + type: String, + meiliIndex: true, + }, + isTemporary: { + type: Boolean, + default: false, + }, + expiredAt: { + type: Date, + }, + }); + + schema.plugin(mongoMeili, { + mongoose, + host: 'foo', + apiKey: 'bar', + indexName: modelName.toLowerCase(), + primaryKey: 'docId', + }); + + return mongoose.model(modelName, schema) as unknown as DynamicMeiliModel; +}; + +const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const mockAddDocuments = jest.fn(); const mockAddDocumentsInBatches = jest.fn(); @@ -90,12 +138,37 @@ describe('Meilisearch Mongoose plugin', () => { expect(mockAddDocuments).toHaveBeenCalled(); }); - test('saving TTL conversation does NOT index w/ meilisearch', async () => { + test('saving retained non-temporary conversation indexes w/ meilisearch', async () => { + await createConversationModel(mongoose).create({ + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + title: 'Test Conversation', + endpoint: EModelEndpoint.openAI, + isTemporary: false, + expiredAt: new Date(Date.now() + 60 * 60 * 1000), + }); + expect(mockAddDocuments).toHaveBeenCalled(); + }); + + test('saving expired retained non-temporary conversation does NOT index w/ meilisearch', async () => { await createConversationModel(mongoose).create({ conversationId: new mongoose.Types.ObjectId(), user: new mongoose.Types.ObjectId(), title: 'Test Conversation', endpoint: EModelEndpoint.openAI, + isTemporary: false, + expiredAt: new Date(Date.now() - 60 * 60 * 1000), + }); + expect(mockAddDocuments).not.toHaveBeenCalled(); + }); + + test('saving temporary conversation does NOT index w/ meilisearch', async () => { + await createConversationModel(mongoose).create({ + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + title: 'Test Conversation', + endpoint: EModelEndpoint.openAI, + isTemporary: true, expiredAt: new Date(), }); expect(mockAddDocuments).not.toHaveBeenCalled(); @@ -125,12 +198,37 @@ describe('Meilisearch Mongoose plugin', () => { expect(mockAddDocuments).toHaveBeenCalled(); }); - test('saving TTL messages does NOT index w/ meilisearch', async () => { + test('saving retained non-temporary messages indexes w/ meilisearch', async () => { + await createMessageModel(mongoose).create({ + messageId: new mongoose.Types.ObjectId(), + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + isCreatedByUser: true, + isTemporary: false, + expiredAt: new Date(Date.now() + 60 * 60 * 1000), + }); + expect(mockAddDocuments).toHaveBeenCalled(); + }); + + test('saving expired retained non-temporary message does NOT index w/ meilisearch', async () => { + await createMessageModel(mongoose).create({ + messageId: new mongoose.Types.ObjectId(), + conversationId: new mongoose.Types.ObjectId(), + user: new mongoose.Types.ObjectId(), + isCreatedByUser: true, + isTemporary: false, + expiredAt: new Date(Date.now() - 60 * 60 * 1000), + }); + expect(mockAddDocuments).not.toHaveBeenCalled(); + }); + + test('saving temporary messages does NOT index w/ meilisearch', async () => { await createMessageModel(mongoose).create({ messageId: new mongoose.Types.ObjectId(), conversationId: new mongoose.Types.ObjectId(), user: new mongoose.Types.ObjectId(), isCreatedByUser: true, + isTemporary: true, expiredAt: new Date(), }); expect(mockAddDocuments).not.toHaveBeenCalled(); @@ -224,6 +322,7 @@ describe('Meilisearch Mongoose plugin', () => { user: new mongoose.Types.ObjectId(), title: 'Test Conversation', endpoint: EModelEndpoint.openAI, + isTemporary: true, expiredAt: new Date(), }); @@ -232,6 +331,173 @@ describe('Meilisearch Mongoose plugin', () => { expect(mockAddDocuments).not.toHaveBeenCalled(); }); + test('sync w/ meili excludes legacy temporary conversations without isTemporary', async () => { + const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods; + await conversationModel.deleteMany({}); + mockAddDocumentsInBatches.mockClear(); + const conversationId = new mongoose.Types.ObjectId().toString(); + + await conversationModel.collection.insertOne({ + conversationId, + user: new mongoose.Types.ObjectId().toString(), + title: 'Legacy Temporary Conversation', + endpoint: EModelEndpoint.openAI, + expiredAt: new Date(Date.now() + 60 * 60 * 1000), + _meiliIndex: false, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await conversationModel.syncWithMeili(); + const storedDoc = await conversationModel.collection.findOne({ conversationId }); + + expect(mockAddDocumentsInBatches).not.toHaveBeenCalled(); + expect(storedDoc?._meiliIndex).toBe(false); + }); + + test('saving hydrated legacy temporary conversations without isTemporary does NOT index', async () => { + const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods; + await conversationModel.deleteMany({}); + mockAddDocuments.mockClear(); + mockUpdateDocuments.mockClear(); + const conversationId = new mongoose.Types.ObjectId().toString(); + + await conversationModel.collection.insertOne({ + conversationId, + user: new mongoose.Types.ObjectId().toString(), + title: 'Legacy Temporary Conversation', + endpoint: EModelEndpoint.openAI, + expiredAt: new Date(Date.now() + 60 * 60 * 1000), + _meiliIndex: false, + createdAt: new Date(), + updatedAt: new Date(), + }); + + const legacyConvo = await conversationModel.findOne({ conversationId }); + expect(legacyConvo).toBeTruthy(); + + legacyConvo!.title = 'Updated Legacy Temporary Conversation'; + await legacyConvo!.save(); + const storedDoc = await conversationModel.collection.findOne({ conversationId }); + + expect(mockAddDocuments).not.toHaveBeenCalled(); + expect(mockUpdateDocuments).not.toHaveBeenCalled(); + expect(storedDoc?._meiliIndex).toBe(false); + }); + + test('findOneAndUpdate on legacy temporary conversations without isTemporary does NOT index', async () => { + const conversationModel = createConversationModel(mongoose) as SchemaWithMeiliMethods; + await conversationModel.deleteMany({}); + mockAddDocuments.mockClear(); + mockUpdateDocuments.mockClear(); + const conversationId = new mongoose.Types.ObjectId().toString(); + + await conversationModel.collection.insertOne({ + conversationId, + user: new mongoose.Types.ObjectId().toString(), + title: 'Legacy Temporary Conversation', + endpoint: EModelEndpoint.openAI, + expiredAt: new Date(Date.now() + 60 * 60 * 1000), + _meiliIndex: false, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await conversationModel.findOneAndUpdate( + { conversationId }, + { $set: { title: 'Updated via findOneAndUpdate' } }, + { new: true }, + ); + const storedDoc = await conversationModel.collection.findOne({ conversationId }); + + expect(mockAddDocuments).not.toHaveBeenCalled(); + expect(mockUpdateDocuments).not.toHaveBeenCalled(); + expect(storedDoc?._meiliIndex).toBe(false); + }); + + test('sync w/ meili excludes legacy temporary messages without isTemporary', async () => { + const messageModel = createMessageModel(mongoose) as SchemaWithMeiliMethods; + await messageModel.deleteMany({}); + mockAddDocumentsInBatches.mockClear(); + const messageId = new mongoose.Types.ObjectId().toString(); + + await messageModel.collection.insertOne({ + messageId, + conversationId: new mongoose.Types.ObjectId().toString(), + user: new mongoose.Types.ObjectId().toString(), + isCreatedByUser: true, + text: 'Legacy temporary message', + expiredAt: new Date(Date.now() + 60 * 60 * 1000), + _meiliIndex: false, + createdAt: new Date(), + updatedAt: new Date(), + }); + + await messageModel.syncWithMeili(); + const storedDoc = await messageModel.collection.findOne({ messageId }); + + expect(mockAddDocumentsInBatches).not.toHaveBeenCalled(); + expect(storedDoc?._meiliIndex).toBe(false); + }); + + test('sync w/ meili treats null isTemporary with no expiration like missing legacy fields', async () => { + const modelName = `DynamicMeiliNullTemporary${new mongoose.Types.ObjectId().toString()}`; + const dynamicModel = createDynamicMeiliModel(modelName); + mockAddDocumentsInBatches.mockClear(); + + try { + await dynamicModel.collection.insertOne({ + docId: 'legacy-null-temporary', + user: 'user-123', + title: 'Legacy Null Temporary', + isTemporary: null as unknown as boolean, + expiredAt: null, + _meiliIndex: false, + }); + + const progress = await dynamicModel.getSyncProgress(); + await dynamicModel.syncWithMeili(); + const storedDoc = await dynamicModel.collection.findOne({ docId: 'legacy-null-temporary' }); + + expect(progress.totalDocuments).toBe(1); + expect(mockAddDocumentsInBatches).toHaveBeenCalled(); + expect(storedDoc?._meiliIndex).toBe(true); + } finally { + await mongoose.connection.dropCollection(modelName.toLowerCase()).catch(() => undefined); + delete mongoose.models[modelName]; + } + }); + + test('sync queries use a fresh expiration cutoff after plugin initialization', async () => { + const modelName = `DynamicMeiliCutoff${new mongoose.Types.ObjectId().toString()}`; + const dynamicModel = createDynamicMeiliModel(modelName); + mockAddDocumentsInBatches.mockClear(); + + try { + await dynamicModel.collection.insertOne({ + docId: 'expires-soon', + user: 'user-123', + title: 'Expires Soon', + isTemporary: false, + expiredAt: new Date(Date.now() + 25), + _meiliIndex: false, + }); + + await wait(100); + + const progress = await dynamicModel.getSyncProgress(); + await dynamicModel.syncWithMeili(); + const storedDoc = await dynamicModel.collection.findOne({ docId: 'expires-soon' }); + + expect(progress.totalDocuments).toBe(0); + expect(mockAddDocumentsInBatches).not.toHaveBeenCalled(); + expect(storedDoc?._meiliIndex).toBe(false); + } finally { + await dynamicModel.deleteMany({}); + mongoose.deleteModel(modelName); + } + }); + describe('estimatedDocumentCount usage in syncWithMeili', () => { test('syncWithMeili completes successfully with estimatedDocumentCount', async () => { // Clear any previous documents @@ -335,6 +601,7 @@ describe('Meilisearch Mongoose plugin', () => { conversationId: new mongoose.Types.ObjectId(), user: new mongoose.Types.ObjectId(), isCreatedByUser: true, + isTemporary: true, expiredAt: new Date(), }); @@ -343,6 +610,7 @@ describe('Meilisearch Mongoose plugin', () => { conversationId: new mongoose.Types.ObjectId(), user: new mongoose.Types.ObjectId(), isCreatedByUser: false, + isTemporary: true, expiredAt: new Date(), }); diff --git a/packages/data-schemas/src/models/plugins/mongoMeili.ts b/packages/data-schemas/src/models/plugins/mongoMeili.ts index 125e7bab712..0594c272b17 100644 --- a/packages/data-schemas/src/models/plugins/mongoMeili.ts +++ b/packages/data-schemas/src/models/plugins/mongoMeili.ts @@ -13,6 +13,7 @@ import type { } from 'mongoose'; import type { IConversation, IMessage } from '~/types'; import logger from '~/config/meiliLogger'; +import { buildRetentionVisibilityFilter, legacyPermanentExpirationFilter } from '~/utils/retention'; interface MongoMeiliOptions { host: string; @@ -38,6 +39,8 @@ interface SyncProgress { interface _DocumentWithMeiliIndex extends Document { _meiliIndex?: boolean; + isTemporary?: boolean; + expiredAt?: Date | null; preprocessObjectForIndex?: () => Record; addObjectToMeili?: (next: CallbackWithoutResultAndOptionalError) => Promise; updateObjectToMeili?: (next: CallbackWithoutResultAndOptionalError) => Promise; @@ -90,6 +93,49 @@ const getSyncConfig = () => ({ delayMs: parseInt(process.env.MEILI_SYNC_DELAY_MS || '100', 10), }); +const hasSchemaPath = (schema: Schema, path: string): boolean => + Object.prototype.hasOwnProperty.call(schema.obj, path); + +const explicitTemporaryFlagKey = 'meiliExplicitTemporaryFlag'; + +const buildIndexableQuery = (schema: Schema): FilterQuery => { + if (!hasSchemaPath(schema, 'isTemporary')) { + return hasSchemaPath(schema, 'expiredAt') ? legacyPermanentExpirationFilter() : {}; + } + + return buildRetentionVisibilityFilter(); +}; + +const hasActiveExpiration = (expiredAt?: Date | null): boolean => + _.isNil(expiredAt) || new Date(expiredAt).getTime() > Date.now(); + +/** + * `isTemporary` defaults to `false` on the schema, so hydrated legacy documents + * can appear non-temporary even when the field is absent from MongoDB. `$isDefault` + * lets us distinguish that schema default from an explicit stored flag, and + * `$locals` carries the pre-save answer into post hooks after Mongoose mutates + * document state. + */ +const hasExplicitTemporaryFlag = (doc: DocumentWithMeiliIndex): boolean => + typeof doc.$locals?.[explicitTemporaryFlagKey] === 'boolean' + ? (doc.$locals[explicitTemporaryFlagKey] as boolean) + : doc.isTemporary != null && !doc.$isDefault('isTemporary'); + +const captureExplicitTemporaryFlag = (doc: DocumentWithMeiliIndex): void => { + doc.$locals[explicitTemporaryFlagKey] = doc.isTemporary != null && !doc.$isDefault('isTemporary'); +}; + +/** + * Index only retained non-temporary records whose flag was explicitly stored, + * plus legacy permanent records that have no retention deadline. Legacy records + * with an expiration are treated as temporary and stay out of search. + */ +const isIndexableDocument = (doc: DocumentWithMeiliIndex): boolean => + (doc.isTemporary === false && + hasExplicitTemporaryFlag(doc) && + hasActiveExpiration(doc.expiredAt)) || + (!hasExplicitTemporaryFlag(doc) && _.isNil(doc.expiredAt)); + /** * Validates the required options for configuring the mongoMeili plugin. */ @@ -136,11 +182,13 @@ const processBatch = async ( */ const createMeiliMongooseModel = ({ index, + getIndexableQuery, attributesToIndex, primaryKey, syncOptions, }: { index: Index; + getIndexableQuery: () => FilterQuery; attributesToIndex: string[]; primaryKey: string; syncOptions: { batchSize: number; delayMs: number }; @@ -152,8 +200,12 @@ const createMeiliMongooseModel = ({ * Get the current sync progress */ static async getSyncProgress(this: SchemaWithMeiliMethods): Promise { - const totalDocuments = await this.countDocuments({ expiredAt: null }); - const indexedDocuments = await this.countDocuments({ expiredAt: null, _meiliIndex: true }); + const indexableQuery = getIndexableQuery(); + const totalDocuments = await this.countDocuments(indexableQuery); + const indexedDocuments = await this.countDocuments({ + ...indexableQuery, + _meiliIndex: true, + }); return { totalProcessed: indexedDocuments, @@ -164,8 +216,7 @@ const createMeiliMongooseModel = ({ /** * Synchronizes data between the MongoDB collection and the MeiliSearch index by - * incrementally indexing only documents where `expiredAt` is `null` and `_meiliIndex` is not `true` - * (i.e., non-expired documents that have not yet been indexed, including those with missing or null `_meiliIndex`). + * incrementally indexing only non-temporary documents where `_meiliIndex` is not `true`. * */ static async syncWithMeili(this: SchemaWithMeiliMethods): Promise { const startTime = Date.now(); @@ -196,8 +247,9 @@ const createMeiliMongooseModel = ({ let hasMore = true; while (hasMore) { + const indexableQuery = getIndexableQuery(); const query: FilterQuery = { - expiredAt: null, + ...indexableQuery, _meiliIndex: { $ne: true }, }; @@ -299,8 +351,9 @@ const createMeiliMongooseModel = ({ const query: Record = {}; query[primaryKey] = { $in: meiliIds }; - // Find which documents exist in MongoDB - const existingDocs = await this.find(query).select(primaryKey).lean(); + const existingDocs = await this.find({ ...query, ...getIndexableQuery() }) + .select(primaryKey) + .lean(); const existingIds = new Set( existingDocs.map((doc: Record) => doc[primaryKey]), @@ -413,8 +466,7 @@ const createMeiliMongooseModel = ({ this: DocumentWithMeiliIndex, next: CallbackWithoutResultAndOptionalError, ): Promise { - // If this conversation or message has a TTL, don't index it - if (!_.isNil(this.expiredAt)) { + if (!isIndexableDocument(this)) { return next(); } @@ -459,6 +511,16 @@ const createMeiliMongooseModel = ({ next: CallbackWithoutResultAndOptionalError, ): Promise { try { + if (!isIndexableDocument(this)) { + await index.deleteDocument(String(this[primaryKey as keyof DocumentWithMeiliIndex])); + const model = this.constructor as Model; + await model.updateOne( + { _id: this._id as Types.ObjectId }, + { $set: { _meiliIndex: false } }, + ); + return next(); + } + const object = this.preprocessObjectForIndex!(); await index.updateDocuments([object], { primaryKey }); next(); @@ -644,9 +706,24 @@ export default function mongoMeili(schema: Schema, options: MongoMeiliOptions): logger.debug(`[mongoMeili] Added 'user' field to ${indexName} index attributes`); } - schema.loadClass(createMeiliMongooseModel({ index, attributesToIndex, primaryKey, syncOptions })); + schema.loadClass( + createMeiliMongooseModel({ + index, + getIndexableQuery: () => buildIndexableQuery(schema), + attributesToIndex, + primaryKey, + syncOptions, + }), + ); // Register Mongoose hooks + schema.pre('save', function (this: DocumentWithMeiliIndex, next) { + if (hasSchemaPath(schema, 'isTemporary')) { + captureExplicitTemporaryFlag(this); + } + next(); + }); + schema.post('save', function (doc: DocumentWithMeiliIndex, next) { doc.postSaveHook?.(next); }); diff --git a/packages/data-schemas/src/schema/convo.ts b/packages/data-schemas/src/schema/convo.ts index c8f394935a6..24915af8a56 100644 --- a/packages/data-schemas/src/schema/convo.ts +++ b/packages/data-schemas/src/schema/convo.ts @@ -21,6 +21,10 @@ const convoSchema: Schema = new Schema( meiliIndex: true, }, messages: [{ type: Schema.Types.ObjectId, ref: 'Message' }], + isTemporary: { + type: Boolean, + default: false, + }, ...conversationPreset, agent_id: { type: String, @@ -48,7 +52,8 @@ convoSchema.index({ expiredAt: 1 }, { expireAfterSeconds: 0 }); convoSchema.index({ createdAt: 1, updatedAt: 1 }); convoSchema.index({ conversationId: 1, user: 1, tenantId: 1 }, { unique: true }); +convoSchema.index({ user: 1, isTemporary: 1, expiredAt: 1 }); // index for MeiliSearch sync operations -convoSchema.index({ _meiliIndex: 1, expiredAt: 1 }); +convoSchema.index({ _meiliIndex: 1, isTemporary: 1, expiredAt: 1 }); export default convoSchema; diff --git a/packages/data-schemas/src/schema/file.ts b/packages/data-schemas/src/schema/file.ts index c8e7c72f522..b1b6eb31699 100644 --- a/packages/data-schemas/src/schema/file.ts +++ b/packages/data-schemas/src/schema/file.ts @@ -137,6 +137,9 @@ const file: Schema = new Schema( }, }, expiresAt: { + /* Short-lived upload TTL managed by MongoDB. This is separate from + * retention-scoped `expiredAt`, which is swept by application code + * after storage cleanup succeeds. */ type: Date, expires: 3600, // 1 hour in seconds }, @@ -144,12 +147,18 @@ const file: Schema = new Schema( type: String, index: true, }, + expiredAt: { + /* Retention deadline for persisted files. The file sweep deletes the + * backing storage first, then removes this metadata record. */ + type: Date, + }, }, { timestamps: true, }, ); +file.index({ expiredAt: 1 }); file.index({ createdAt: 1, updatedAt: 1 }); file.index( { filename: 1, conversationId: 1, context: 1, tenantId: 1 }, diff --git a/packages/data-schemas/src/schema/message.ts b/packages/data-schemas/src/schema/message.ts index d68edd0df3a..f4002d076c9 100644 --- a/packages/data-schemas/src/schema/message.ts +++ b/packages/data-schemas/src/schema/message.ts @@ -63,6 +63,10 @@ const messageSchema: Schema = new Schema( required: true, default: false, }, + isTemporary: { + type: Boolean, + default: false, + }, unfinished: { type: Boolean, default: false, @@ -180,6 +184,6 @@ messageSchema.index({ createdAt: 1 }); messageSchema.index({ messageId: 1, user: 1, tenantId: 1 }, { unique: true }); // index for MeiliSearch sync operations -messageSchema.index({ _meiliIndex: 1, expiredAt: 1 }); +messageSchema.index({ _meiliIndex: 1, isTemporary: 1, expiredAt: 1 }); export default messageSchema; diff --git a/packages/data-schemas/src/schema/share.ts b/packages/data-schemas/src/schema/share.ts index 3238084889a..616536fe527 100644 --- a/packages/data-schemas/src/schema/share.ts +++ b/packages/data-schemas/src/schema/share.ts @@ -8,6 +8,7 @@ export interface ISharedLink extends Document { shareId?: string; targetMessageId?: string; isPublic: boolean; + expiredAt?: Date; createdAt?: Date; updatedAt?: Date; tenantId?: string; @@ -45,10 +46,14 @@ const shareSchema: Schema = new Schema( type: String, index: true, }, + expiredAt: { + type: Date, + }, }, { timestamps: true }, ); +shareSchema.index({ expiredAt: 1 }, { expireAfterSeconds: 0 }); shareSchema.index({ conversationId: 1, user: 1, targetMessageId: 1, tenantId: 1 }); export default shareSchema; diff --git a/packages/data-schemas/src/schema/toolCall.ts b/packages/data-schemas/src/schema/toolCall.ts index d36d6b758ab..cde2163ca3c 100644 --- a/packages/data-schemas/src/schema/toolCall.ts +++ b/packages/data-schemas/src/schema/toolCall.ts @@ -10,6 +10,7 @@ export interface IToolCallData extends Document { attachments?: TAttachment[]; blockIndex?: number; partIndex?: number; + expiredAt?: Date; createdAt?: Date; updatedAt?: Date; tenantId?: string; @@ -50,10 +51,14 @@ const toolCallSchema: Schema = new Schema( type: String, index: true, }, + expiredAt: { + type: Date, + }, }, { timestamps: true }, ); +toolCallSchema.index({ expiredAt: 1 }, { expireAfterSeconds: 0 }); toolCallSchema.index({ messageId: 1, user: 1, tenantId: 1 }); toolCallSchema.index({ conversationId: 1, user: 1, tenantId: 1 }); diff --git a/packages/data-schemas/src/types/convo.ts b/packages/data-schemas/src/types/convo.ts index c7888efba2c..5cd828e9d6d 100644 --- a/packages/data-schemas/src/types/convo.ts +++ b/packages/data-schemas/src/types/convo.ts @@ -6,6 +6,7 @@ export interface IConversation extends Document { title?: string; user?: string; messages?: Types.ObjectId[]; + isTemporary?: boolean; // Fields provided by conversationPreset (adjust types as needed) endpoint?: string; endpointType?: string; diff --git a/packages/data-schemas/src/types/file.ts b/packages/data-schemas/src/types/file.ts index b47a18abb64..9ccdf160349 100644 --- a/packages/data-schemas/src/types/file.ts +++ b/packages/data-schemas/src/types/file.ts @@ -72,6 +72,7 @@ export interface IMongoFile extends Omit { codeEnvRef?: CodeEnvRef; }; expiresAt?: Date; + expiredAt?: Date | null; createdAt?: Date; updatedAt?: Date; tenantId?: string; diff --git a/packages/data-schemas/src/types/message.ts b/packages/data-schemas/src/types/message.ts index cf2213d88b9..16ec2a5ffb3 100644 --- a/packages/data-schemas/src/types/message.ts +++ b/packages/data-schemas/src/types/message.ts @@ -18,6 +18,7 @@ export interface IMessage extends Document { text?: string; summary?: string; isCreatedByUser: boolean; + isTemporary?: boolean; unfinished?: boolean; error?: boolean; finish_reason?: string; diff --git a/packages/data-schemas/src/types/share.ts b/packages/data-schemas/src/types/share.ts index 6ef16ad87a0..285818c35a7 100644 --- a/packages/data-schemas/src/types/share.ts +++ b/packages/data-schemas/src/types/share.ts @@ -10,6 +10,7 @@ export interface ISharedLink { shareId?: string; targetMessageId?: string; isPublic: boolean; + expiredAt?: Date; createdAt?: Date; updatedAt?: Date; } diff --git a/packages/data-schemas/src/utils/index.ts b/packages/data-schemas/src/utils/index.ts index cdc69ce6b34..1cdf337be0f 100644 --- a/packages/data-schemas/src/utils/index.ts +++ b/packages/data-schemas/src/utils/index.ts @@ -1,6 +1,7 @@ export * from './principal'; export * from './string'; export * from './tempChatRetention'; +export * from './retention'; export { tenantSafeBulkWrite } from './tenantBulkWrite'; export * from './transactions'; export * from './objectId'; diff --git a/packages/data-schemas/src/utils/retention.ts b/packages/data-schemas/src/utils/retention.ts new file mode 100644 index 00000000000..c25896fbf9d --- /dev/null +++ b/packages/data-schemas/src/utils/retention.ts @@ -0,0 +1,33 @@ +import type { FilterQuery } from 'mongoose'; +import { DEFAULT_RETENTION_HOURS } from './tempChatRetention'; + +export type RetentionFilterDocument = { + isTemporary?: boolean | null; + expiredAt?: Date | null; +}; + +export const activeExpirationFilter = < + T extends RetentionFilterDocument = RetentionFilterDocument, +>(): FilterQuery => + ({ + $or: [{ expiredAt: null }, { expiredAt: { $gt: new Date() } }], + }) as FilterQuery; + +export const legacyPermanentExpirationFilter = < + T extends RetentionFilterDocument = RetentionFilterDocument, +>(): FilterQuery => + ({ expiredAt: null }) as FilterQuery; + +export const buildRetentionVisibilityFilter = < + T extends RetentionFilterDocument = RetentionFilterDocument, +>(): FilterQuery => + ({ + $or: [ + { isTemporary: false, expiredAt: null }, + { isTemporary: false, expiredAt: { $gt: new Date() } }, + { isTemporary: null, expiredAt: null }, + ], + }) as FilterQuery; + +export const createFallbackRetentionDate = (now = Date.now()): Date => + new Date(now + DEFAULT_RETENTION_HOURS * 60 * 60 * 1000); From 679672ad15a1fce6adcc664854e19fb4104bf9f2 Mon Sep 17 00:00:00 2001 From: Pete Hampton Date: Wed, 20 May 2026 18:33:53 +0100 Subject: [PATCH 041/395] =?UTF-8?q?=F0=9F=AA=82=20feat:=20Graceful=20HTTP?= =?UTF-8?q?=20shutdown=20on=20SIGTERM/SIGINT=20(#13211)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🪂 feat: Graceful HTTP shutdown on SIGTERM/SIGINT * Address feedback * don't treat ERR_SERVER_NOT_RUNNING as fatal; route telemetry shutdown through coordinator --- api/cache/getLogStores.js | 22 +-- api/server/index.js | 5 +- packages/api/src/app/index.ts | 1 + packages/api/src/app/shutdown.spec.ts | 257 +++++++++++++++++++++++++ packages/api/src/app/shutdown.ts | 114 +++++++++++ packages/api/src/flow/manager.ts | 14 +- packages/api/src/telemetry/sdk.spec.ts | 63 ++---- packages/api/src/telemetry/sdk.ts | 56 ++---- 8 files changed, 426 insertions(+), 106 deletions(-) create mode 100644 packages/api/src/app/shutdown.spec.ts create mode 100644 packages/api/src/app/shutdown.ts 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/server/index.js b/api/server/index.js index 60b1a96b3d4..e7ce041fa47 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -22,6 +22,7 @@ const { createStreamServices, initializeFileStorage, preAuthTenantMiddleware, + setupGracefulShutdown, updateInterfacePermissions, } = require('@librechat/api'); const { connectDb, indexSync } = require('~/db'); @@ -240,7 +241,7 @@ const startServer = async () => { /** Error handler (must be last - Express identifies error middleware by its 4-arg signature) */ app.use(ErrorController); - app.listen(port, host, async (err) => { + const server = app.listen(port, host, async (err) => { if (err) { logger.error('Failed to start server:', err); process.exit(1); @@ -283,6 +284,8 @@ const startServer = async () => { process.exit(1); } }); + + setupGracefulShutdown(server); }; /** diff --git a/packages/api/src/app/index.ts b/packages/api/src/app/index.ts index 3bb619ab130..a2eef88ae11 100644 --- a/packages/api/src/app/index.ts +++ b/packages/api/src/app/index.ts @@ -5,3 +5,4 @@ export * from './permissions'; export * from './cdn'; export * from './checks'; export * from './resolve'; +export * from './shutdown'; diff --git a/packages/api/src/app/shutdown.spec.ts b/packages/api/src/app/shutdown.spec.ts new file mode 100644 index 00000000000..baabd8aa8b5 --- /dev/null +++ b/packages/api/src/app/shutdown.spec.ts @@ -0,0 +1,257 @@ +/// +import http from 'http'; +import { + setupGracefulShutdown, + registerShutdownTask, + __resetShutdownStateForTests, +} from './shutdown'; + +jest.mock('@librechat/data-schemas', () => ({ + logger: { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, +})); + +const triggerSignal = (signal: NodeJS.Signals): void => { + const listeners = process.listeners(signal) as NodeJS.SignalsListener[]; + listeners.forEach((listener) => listener(signal)); +}; + +const flush = (): Promise => new Promise((resolve) => setImmediate(resolve)); + +describe('setupGracefulShutdown', () => { + let server: http.Server; + let exitSpy: jest.SpyInstance; + let originalSigterm: NodeJS.SignalsListener[]; + let originalSigint: NodeJS.SignalsListener[]; + let originalSigquit: NodeJS.SignalsListener[]; + let originalSighup: NodeJS.SignalsListener[]; + + beforeEach(() => { + server = http.createServer(); + // Most tests exercise the close path, so we fake `listening` to true. + // Tests that want to exercise the not-listening short-circuit override + // this back to false. + Object.defineProperty(server, 'listening', { value: true, configurable: true }); + exitSpy = jest.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + originalSigterm = process.listeners('SIGTERM').slice() as NodeJS.SignalsListener[]; + originalSigint = process.listeners('SIGINT').slice() as NodeJS.SignalsListener[]; + originalSigquit = process.listeners('SIGQUIT').slice() as NodeJS.SignalsListener[]; + originalSighup = process.listeners('SIGHUP').slice() as NodeJS.SignalsListener[]; + process.removeAllListeners('SIGTERM'); + process.removeAllListeners('SIGINT'); + process.removeAllListeners('SIGQUIT'); + process.removeAllListeners('SIGHUP'); + __resetShutdownStateForTests(); + }); + + afterEach(() => { + process.removeAllListeners('SIGTERM'); + process.removeAllListeners('SIGINT'); + process.removeAllListeners('SIGQUIT'); + process.removeAllListeners('SIGHUP'); + originalSigterm.forEach((listener) => process.on('SIGTERM', listener)); + originalSigint.forEach((listener) => process.on('SIGINT', listener)); + originalSigquit.forEach((listener) => process.on('SIGQUIT', listener)); + originalSighup.forEach((listener) => process.on('SIGHUP', listener)); + exitSpy.mockRestore(); + if (server.listening) { + server.close(); + } + __resetShutdownStateForTests(); + jest.useRealTimers(); + }); + + it('registers handlers for SIGTERM, SIGINT, SIGQUIT, and SIGHUP', () => { + for (const signal of ['SIGTERM', 'SIGINT', 'SIGQUIT', 'SIGHUP'] as NodeJS.Signals[]) { + expect(process.listenerCount(signal)).toBe(0); + } + setupGracefulShutdown(server); + for (const signal of ['SIGTERM', 'SIGINT', 'SIGQUIT', 'SIGHUP'] as NodeJS.Signals[]) { + expect(process.listenerCount(signal)).toBe(1); + } + }); + + it('closes the server and exits 0 on SIGTERM', async () => { + const closeSpy = jest.spyOn(server, 'close').mockImplementation((cb?: (err?: Error) => void) => { + if (cb) { + setImmediate(() => cb()); + } + return server; + }); + setupGracefulShutdown(server); + triggerSignal('SIGTERM'); + await flush(); + await flush(); + expect(closeSpy).toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('closes the server and exits 0 on SIGINT', async () => { + const closeSpy = jest.spyOn(server, 'close').mockImplementation((cb?: (err?: Error) => void) => { + if (cb) { + setImmediate(() => cb()); + } + return server; + }); + setupGracefulShutdown(server); + triggerSignal('SIGINT'); + await flush(); + await flush(); + expect(closeSpy).toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('exits 1 if server.close yields an error', async () => { + const closeErr = new Error('close failed'); + jest.spyOn(server, 'close').mockImplementation((cb?: (err?: Error) => void) => { + if (cb) { + setImmediate(() => cb(closeErr)); + } + return server; + }); + setupGracefulShutdown(server); + triggerSignal('SIGTERM'); + await flush(); + await flush(); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('treats ERR_SERVER_NOT_RUNNING from close() as a successful shutdown', async () => { + const notRunning: NodeJS.ErrnoException = Object.assign(new Error('Server is not running.'), { + code: 'ERR_SERVER_NOT_RUNNING', + }); + // Force listening=true so the pre-check doesn't short-circuit; + // we want to exercise the post-check that ignores ERR_SERVER_NOT_RUNNING. + Object.defineProperty(server, 'listening', { value: true, configurable: true }); + jest.spyOn(server, 'close').mockImplementation((cb?: (err?: Error) => void) => { + if (cb) { + setImmediate(() => cb(notRunning)); + } + return server; + }); + setupGracefulShutdown(server); + triggerSignal('SIGTERM'); + await flush(); + await flush(); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('skips close() entirely when the server is not listening yet', async () => { + // SIGTERM during the startup window (before app.listen finishes binding + // the socket) must not be treated as a failed shutdown. + Object.defineProperty(server, 'listening', { value: false, configurable: true }); + const closeSpy = jest.spyOn(server, 'close'); + setupGracefulShutdown(server); + triggerSignal('SIGTERM'); + await flush(); + expect(closeSpy).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('is idempotent — a second signal does not trigger another shutdown', async () => { + const closeSpy = jest.spyOn(server, 'close'); + setupGracefulShutdown(server); + triggerSignal('SIGTERM'); + triggerSignal('SIGTERM'); + await flush(); + expect(closeSpy).toHaveBeenCalledTimes(1); + }); + + it('force-exits with code 1 if shutdown exceeds the timeout', () => { + jest.useFakeTimers(); + jest.spyOn(server, 'close').mockImplementation(() => server); + setupGracefulShutdown(server); + triggerSignal('SIGTERM'); + jest.advanceTimersByTime(60_000); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('runs registered tasks after server.close and before exit', async () => { + const calls: string[] = []; + jest.spyOn(server, 'close').mockImplementation((cb?: (err?: Error) => void) => { + calls.push('server.close'); + if (cb) { + setImmediate(() => cb()); + } + return server; + }); + registerShutdownTask('task-a', () => { + calls.push('task-a'); + }); + registerShutdownTask('task-b', () => { + calls.push('task-b'); + }); + setupGracefulShutdown(server); + triggerSignal('SIGTERM'); + await flush(); + await flush(); + expect(calls).toEqual(['server.close', 'task-a', 'task-b']); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('runs tasks in registration order', async () => { + const order: string[] = []; + jest.spyOn(server, 'close').mockImplementation((cb?: (err?: Error) => void) => { + if (cb) { + setImmediate(() => cb()); + } + return server; + }); + ['first', 'second', 'third'].forEach((name) => { + registerShutdownTask(name, () => { + order.push(name); + }); + }); + setupGracefulShutdown(server); + triggerSignal('SIGTERM'); + await flush(); + await flush(); + expect(order).toEqual(['first', 'second', 'third']); + }); + + it('continues subsequent tasks and still exits if one task throws', async () => { + const calls: string[] = []; + jest.spyOn(server, 'close').mockImplementation((cb?: (err?: Error) => void) => { + if (cb) { + setImmediate(() => cb()); + } + return server; + }); + registerShutdownTask('ok-before', () => { + calls.push('ok-before'); + }); + registerShutdownTask('throws', () => { + calls.push('throws'); + throw new Error('boom'); + }); + registerShutdownTask('ok-after', () => { + calls.push('ok-after'); + }); + setupGracefulShutdown(server); + triggerSignal('SIGTERM'); + await flush(); + await flush(); + expect(calls).toEqual(['ok-before', 'throws', 'ok-after']); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('awaits async tasks before exiting', async () => { + const calls: string[] = []; + jest.spyOn(server, 'close').mockImplementation((cb?: (err?: Error) => void) => { + if (cb) { + setImmediate(() => cb()); + } + return server; + }); + registerShutdownTask('async-task', async () => { + await new Promise((resolve) => setImmediate(resolve)); + calls.push('async-done'); + }); + setupGracefulShutdown(server); + triggerSignal('SIGTERM'); + await flush(); + await flush(); + await flush(); + expect(calls).toEqual(['async-done']); + expect(exitSpy).toHaveBeenCalledWith(0); + }); +}); diff --git a/packages/api/src/app/shutdown.ts b/packages/api/src/app/shutdown.ts new file mode 100644 index 00000000000..3fc0a9d1056 --- /dev/null +++ b/packages/api/src/app/shutdown.ts @@ -0,0 +1,114 @@ +import { logger } from '@librechat/data-schemas'; +import type { Server } from 'http'; + +const SHUTDOWN_TIMEOUT_MS = 60_000; +const SIGNALS: NodeJS.Signals[] = ['SIGTERM', 'SIGINT', 'SIGQUIT', 'SIGHUP']; + +type ShutdownTask = { + name: string; + fn: () => void | Promise; +}; + +const tasks: ShutdownTask[] = []; +let isShuttingDown = false; +let httpServer: Server | null = null; + +/** + * Register a cleanup task to run after the HTTP server has closed. + * Tasks run in registration order; if one throws, subsequent tasks + * and the final exit are not blocked. Use this instead of attaching + * `process.on('SIGTERM', ...)` handlers directly — multiple competing + * signal handlers race with the HTTP drain because Node dispatches + * listeners in registration order and any one of them can call + * `process.exit` before the HTTP server has finished closing. + */ +export function registerShutdownTask( + name: string, + fn: () => void | Promise, +): void { + tasks.push({ name, fn }); +} + +/** + * Wires SIGTERM, SIGINT, SIGQUIT, and SIGHUP to a graceful shutdown + * sequence: close the HTTP server (stop accepting new connections, let + * in-flight requests finish), run any tasks registered via + * `registerShutdownTask`, then `process.exit(0)`. After + * SHUTDOWN_TIMEOUT_MS the process is force-exited with code 1 — a + * safety net for long-lived connections such as SSE streams that may + * not finish in time. + */ +export function setupGracefulShutdown(server: Server): void { + httpServer = server; + for (const signal of SIGNALS) { + process.on(signal, () => { + void shutdown(signal); + }); + } +} + +/** + * @internal Reset module state for tests. Not part of the public API. + */ +export function __resetShutdownStateForTests(): void { + tasks.length = 0; + isShuttingDown = false; + httpServer = null; +} + +async function shutdown(signal: NodeJS.Signals): Promise { + if (isShuttingDown) { + return; + } + isShuttingDown = true; + logger.info(`Received ${signal}, draining HTTP server...`); + + const forceExit = setTimeout(() => { + logger.warn(`Graceful shutdown exceeded ${SHUTDOWN_TIMEOUT_MS}ms, forcing exit`); + process.exit(1); + }, SHUTDOWN_TIMEOUT_MS); + forceExit.unref(); + + let exitCode = 0; + + try { + await closeHttpServer(); + } catch (err) { + logger.error('Error closing HTTP server during graceful shutdown:', err); + exitCode = 1; + } + + for (const task of tasks) { + try { + logger.info(`Running shutdown task: ${task.name}`); + await task.fn(); + } catch (err) { + logger.error(`Shutdown task "${task.name}" failed:`, err); + } + } + + clearTimeout(forceExit); + logger.info('Graceful shutdown complete, exiting'); + process.exit(exitCode); +} + +function closeHttpServer(): Promise { + return new Promise((resolve, reject) => { + if (!httpServer || !httpServer.listening) { + // SIGTERM can arrive during startup before the listen socket is open, + // in which case there is nothing to drain. Node also surfaces this as + // an ERR_SERVER_NOT_RUNNING error in the close callback — treated + // below as a successful close so a routine shutdown doesn't trip + // orchestrator restart/backoff with exit code 1. + resolve(); + return; + } + httpServer.close((err) => { + if (!err || (err as NodeJS.ErrnoException).code === 'ERR_SERVER_NOT_RUNNING') { + resolve(); + return; + } + reject(err); + }); + }); +} diff --git a/packages/api/src/flow/manager.ts b/packages/api/src/flow/manager.ts index 544cba95601..f775222282c 100644 --- a/packages/api/src/flow/manager.ts +++ b/packages/api/src/flow/manager.ts @@ -2,6 +2,7 @@ import { Keyv } from 'keyv'; import { logger } from '@librechat/data-schemas'; import type { StoredDataNoRaw } from 'keyv'; import type { FlowState, FlowMetadata, FlowManagerOptions } from './types'; +import { registerShutdownTask } from '../app/shutdown'; export const PENDING_STALE_MS = 2 * 60 * 1000; @@ -40,17 +41,14 @@ export class FlowStateManager { } private setupCleanupHandlers() { - const cleanup = () => { + // Register cleanup with the centralized graceful-shutdown coordinator + // (see ../app/shutdown.ts) rather than attaching direct signal + // handlers — multiple competing handlers race the HTTP drain. + registerShutdownTask('flow manager cleanup', () => { logger.info('Cleaning up FlowStateManager intervals...'); this.intervals.forEach((interval) => clearInterval(interval)); this.intervals.clear(); - process.exit(0); - }; - - process.on('SIGTERM', cleanup); - process.on('SIGINT', cleanup); - process.on('SIGQUIT', cleanup); - process.on('SIGHUP', cleanup); + }); } /** diff --git a/packages/api/src/telemetry/sdk.spec.ts b/packages/api/src/telemetry/sdk.spec.ts index 951b1d91ef4..32b33cb5662 100644 --- a/packages/api/src/telemetry/sdk.spec.ts +++ b/packages/api/src/telemetry/sdk.spec.ts @@ -107,9 +107,9 @@ jest.mock( { virtual: true }, ); -async function flushSignalShutdown(): Promise { - await new Promise((resolve) => setImmediate(resolve)); -} +jest.mock('../app/shutdown', () => ({ + registerShutdownTask: jest.fn(), +})); describe('telemetry SDK lifecycle', () => { let emitWarningSpy: jest.SpyInstance; @@ -494,56 +494,27 @@ describe('telemetry SDK lifecycle', () => { expect(controller.status).toBe('stopped'); }); - it.each(['SIGTERM', 'SIGINT'])( - 'does not force process exit when another %s handler is registered', - async (signal) => { - const otherHandler = jest.fn(); - const killSpy = jest.spyOn(process, 'kill').mockImplementation(() => true); - initializeTelemetry({ OTEL_TRACING_ENABLED: 'true' }); - process.once(signal, otherHandler); - - process.emit(signal, signal); - await flushSignalShutdown(); - - expect(mockShutdown).toHaveBeenCalledTimes(1); - expect(otherHandler).toHaveBeenCalledTimes(1); - expect(killSpy).not.toHaveBeenCalled(); - - killSpy.mockRestore(); - }, - ); - - it.each(['SIGTERM', 'SIGINT'])( - 'reraises the shutdown %s signal when telemetry is the only signal handler', - async (signal) => { - const killSpy = jest.spyOn(process, 'kill').mockImplementation(() => true); - initializeTelemetry({ OTEL_TRACING_ENABLED: 'true' }); - - process.emit(signal, signal); - await flushSignalShutdown(); - - expect(mockShutdown).toHaveBeenCalledTimes(1); - expect(killSpy).toHaveBeenCalledWith(process.pid, signal); + it('registers a shutdown task with the coordinator when initialized', async () => { + const { registerShutdownTask } = await import('../app/shutdown'); + initializeTelemetry({ OTEL_TRACING_ENABLED: 'true' }); - killSpy.mockRestore(); - }, - ); + expect(registerShutdownTask).toHaveBeenCalledWith('telemetry', expect.any(Function)); + }); - it('warns and reraises the signal when shutdown rejects', async () => { - mockShutdown.mockRejectedValueOnce(new Error('signal shutdown failed')); - const killSpy = jest.spyOn(process, 'kill').mockImplementation(() => true); + it('the registered shutdown task warns when telemetry shutdown rejects', async () => { + const { registerShutdownTask } = await import('../app/shutdown'); + mockShutdown.mockRejectedValueOnce(new Error('flush failed')); initializeTelemetry({ OTEL_TRACING_ENABLED: 'true' }); - process.emit('SIGTERM', 'SIGTERM'); - await flushSignalShutdown(); + const taskFn = (registerShutdownTask as jest.Mock).mock.calls.at(-1)?.[1] as + | (() => Promise) + | undefined; + expect(taskFn).toBeDefined(); + await taskFn?.(); - expect(mockShutdown).toHaveBeenCalledTimes(1); expect(emitWarningSpy).toHaveBeenCalledWith( - 'OpenTelemetry shutdown failed: signal shutdown failed', + 'OpenTelemetry shutdown failed: flush failed', { code: 'LIBRECHAT_OTEL' }, ); - expect(killSpy).toHaveBeenCalledWith(process.pid, 'SIGTERM'); - - killSpy.mockRestore(); }); }); diff --git a/packages/api/src/telemetry/sdk.ts b/packages/api/src/telemetry/sdk.ts index 90c3b1977c8..ea5e0a3de21 100644 --- a/packages/api/src/telemetry/sdk.ts +++ b/packages/api/src/telemetry/sdk.ts @@ -13,6 +13,7 @@ import type { Span, Attributes } from '@opentelemetry/api'; import type { RequestOptions } from 'node:http'; import type { TelemetryConfig, TelemetryStatus } from './config'; import { getTelemetryConfig } from './config'; +import { registerShutdownTask } from '../app/shutdown'; export interface TelemetryController { readonly enabled: boolean; @@ -24,11 +25,6 @@ const WARNING_CODE = 'LIBRECHAT_OTEL'; const REDACTED_QUERY_VALUE = '[REDACTED]'; const SIGNAL_SHUTDOWN_TIMEOUT_MS = 5_000; -interface RegisteredSignal { - signal: NodeJS.Signals; - listener: NodeJS.SignalsListener; -} - interface RequestUrlParts { href?: string; search?: string; @@ -49,7 +45,7 @@ let pendingSdk: NodeSDK | undefined; let startPromise: Promise | undefined; let shutdownPromise: Promise | undefined; let status: TelemetryStatus = 'stopped'; -let registeredSignals: RegisteredSignal[] = []; +let shutdownTaskRegistered = false; let requestSpans = new WeakMap(); function isBunRuntime(): boolean { @@ -370,35 +366,22 @@ function makeController(): TelemetryController { }; } -function unregisterShutdownHandlers(): void { - for (const { signal, listener } of registeredSignals) { - process.removeListener(signal, listener); - } - registeredSignals = []; -} - -function registerShutdownHandlers(): void { - if (registeredSignals.length > 0) { +function ensureShutdownTaskRegistered(): void { + if (shutdownTaskRegistered) { return; } - - const signals: NodeJS.Signals[] = ['SIGTERM', 'SIGINT']; - registeredSignals = signals.map((signal) => { - const listener: NodeJS.SignalsListener = () => { - const shouldReraiseSignal = process.listenerCount(signal) === 0; - withTimeout(shutdownTelemetry(), SIGNAL_SHUTDOWN_TIMEOUT_MS) - .catch((error) => { - emitWarning(`OpenTelemetry shutdown failed: ${getErrorMessage(error)}`); - }) - .finally(() => { - if (shouldReraiseSignal) { - process.kill(process.pid, signal); - } - }); - }; - process.once(signal, listener); - return { signal, listener }; - }); + shutdownTaskRegistered = true; + // Register with the centralized graceful-shutdown coordinator + // (see ../app/shutdown.ts) rather than attaching SIGTERM/SIGINT + // listeners directly — signal listener return values are ignored + // by Node, so a separate signal handler can let the coordinator + // exit before the async OpenTelemetry flush completes, dropping + // final spans during pod shutdowns. + registerShutdownTask('telemetry', () => + withTimeout(shutdownTelemetry(), SIGNAL_SHUTDOWN_TIMEOUT_MS).catch((error) => { + emitWarning(`OpenTelemetry shutdown failed: ${getErrorMessage(error)}`); + }), + ); } function withTimeout(promise: Promise, timeoutMs: number): Promise { @@ -440,7 +423,7 @@ export function initializeTelemetry(env: NodeJS.ProcessEnv = process.env): Telem pendingSdk = undefined; activeSdk = sdk; status = 'started'; - registerShutdownHandlers(); + ensureShutdownTaskRegistered(); } }) .catch((error) => { @@ -461,7 +444,7 @@ export function initializeTelemetry(env: NodeJS.ProcessEnv = process.env): Telem activeSdk = sdk; status = 'started'; - registerShutdownHandlers(); + ensureShutdownTaskRegistered(); return makeController(); } catch (error) { status = 'failed'; @@ -485,7 +468,6 @@ async function performShutdownTelemetry(): Promise { await sdk.shutdown(); activeSdk = undefined; status = 'stopped'; - unregisterShutdownHandlers(); } catch (error) { status = 'started'; throw error; @@ -520,6 +502,6 @@ export async function resetTelemetryForTests(): Promise { shutdownPromise = undefined; status = 'stopped'; requestSpans = new WeakMap(); - unregisterShutdownHandlers(); + shutdownTaskRegistered = false; } } From 9cb650d1d8ee9b59528cb55bbea86d180176416f Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 20 May 2026 13:34:26 -0400 Subject: [PATCH 042/395] =?UTF-8?q?=F0=9F=A9=BA=20feat:=20Add=20Explicit?= =?UTF-8?q?=20Readiness=20Endpoints=20(#13212)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/server/index.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/api/server/index.js b/api/server/index.js index e7ce041fa47..a36484c87a9 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -54,6 +54,7 @@ const host = HOST || 'localhost'; const trusted_proxy = Number(TRUST_PROXY) || 1; /* trust first proxy by default */ const app = express(); +let serverReady = false; const startServer = async () => { const { metricsMiddleware, metricsRouter } = createMetrics(); @@ -114,6 +115,13 @@ const startServer = async () => { } 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); @@ -279,7 +287,10 @@ const startServer = async () => { 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); } From 8310e9a8402c1712e9cc9d19ef2eadb2187d8e7c Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 20 May 2026 14:41:36 -0400 Subject: [PATCH 043/395] =?UTF-8?q?=F0=9F=A7=AA=20ci:=20Stabilize=20Virtua?= =?UTF-8?q?lized=20Agent=20Grid=20Tests=20(#13214)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../VirtualScrollingPerformance.test.tsx | 139 +++++++---- .../tests/VirtualizedAgentGrid.test.tsx | 232 ++++++++++-------- 2 files changed, 219 insertions(+), 152 deletions(-) diff --git a/client/src/components/Agents/tests/VirtualScrollingPerformance.test.tsx b/client/src/components/Agents/tests/VirtualScrollingPerformance.test.tsx index 293bd8878e4..43a5be20d5e 100644 --- a/client/src/components/Agents/tests/VirtualScrollingPerformance.test.tsx +++ b/client/src/components/Agents/tests/VirtualScrollingPerformance.test.tsx @@ -2,13 +2,52 @@ 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 ReactActual = jest.requireActual('react'); const mockRowRendererRef = { current: jest.fn() }; return { @@ -24,62 +63,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 +163,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 +176,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..9fb6c402094 100644 --- a/client/src/components/Agents/tests/VirtualizedAgentGrid.test.tsx +++ b/client/src/components/Agents/tests/VirtualizedAgentGrid.test.tsx @@ -1,82 +1,116 @@ 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 +138,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 +153,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'}`; } @@ -151,9 +186,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 +209,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 +241,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 +261,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'); From 799a08047976ebb7b562afcee36b11c0db6e1ce9 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Wed, 20 May 2026 23:16:56 -0400 Subject: [PATCH 044/395] =?UTF-8?q?=F0=9F=97=82=EF=B8=8F=20feat:=20Allow?= =?UTF-8?q?=20Disabling=20File=20Log=20Transports=20(#13215)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: allow disabling file log transports * fix: defer log directory setup when file logging disabled --- .env.example | 2 + api/config/__tests__/logToFile.spec.js | 72 +++++++++++++++++++ api/config/meiliLogger.js | 36 ++++++---- api/config/winston.js | 50 ++++++++----- api/utils/logger.js | 14 ++-- .../data-schemas/src/config/meiliLogger.ts | 34 +++++---- packages/data-schemas/src/config/winston.ts | 42 ++++++----- 7 files changed, 182 insertions(+), 68 deletions(-) create mode 100644 api/config/__tests__/logToFile.spec.js diff --git a/.env.example b/.env.example index 3d4e372338d..6d5195f0167 100644 --- a/.env.example +++ b/.env.example @@ -68,6 +68,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 diff --git a/api/config/__tests__/logToFile.spec.js b/api/config/__tests__/logToFile.spec.js new file mode 100644 index 00000000000..4b3170f95ac --- /dev/null +++ b/api/config/__tests__/logToFile.spec.js @@ -0,0 +1,72 @@ +const fs = require('fs'); + +const ORIGINAL_ENV = process.env; + +const mockDataSchemas = () => { + jest.doMock('@librechat/data-schemas', () => ({ + getTenantId: jest.fn(), + getUserId: jest.fn(), + getRequestId: jest.fn(), + SYSTEM_TENANT_ID: 'system', + })); +}; + +const mockReadOnlyDockerLogDir = () => { + const originalExistsSync = fs.existsSync; + const originalMkdirSync = fs.mkdirSync; + + jest.spyOn(process, 'cwd').mockReturnValue('/app'); + jest + .spyOn(fs, 'existsSync') + .mockImplementation((target) => + target === '/app/logs' ? false : originalExistsSync.call(fs, target), + ); + + return jest.spyOn(fs, 'mkdirSync').mockImplementation((target, options) => { + if (target === '/app/logs') { + throw new Error('Attempted to create Docker log directory'); + } + return originalMkdirSync.call(fs, target, options); + }); +}; + +const prepareLoggerWithoutFileLogging = () => { + jest.resetModules(); + jest.clearAllMocks(); + mockDataSchemas(); + + process.env = { + ...ORIGINAL_ENV, + DEBUG_LOGGING: 'true', + LOG_TO_FILE: 'false', + }; + + return mockReadOnlyDockerLogDir(); +}; + +describe('LOG_TO_FILE', () => { + afterEach(() => { + process.env = ORIGINAL_ENV; + jest.restoreAllMocks(); + }); + + it('does not create the API log directory when winston file logging is disabled', () => { + const mkdirSyncSpy = prepareLoggerWithoutFileLogging(); + + expect(() => require('../winston')).not.toThrow(); + + const winston = require('winston'); + expect(winston.transports.DailyRotateFile).not.toHaveBeenCalled(); + expect(mkdirSyncSpy).not.toHaveBeenCalledWith('/app/logs', expect.anything()); + }); + + it('does not create the API log directory when Meili file logging is disabled', () => { + const mkdirSyncSpy = prepareLoggerWithoutFileLogging(); + + expect(() => require('../meiliLogger')).not.toThrow(); + + const winston = require('winston'); + expect(winston.transports.DailyRotateFile).not.toHaveBeenCalled(); + expect(mkdirSyncSpy).not.toHaveBeenCalledWith('/app/logs', expect.anything()); + }); +}); diff --git a/api/config/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/config/winston.js b/api/config/winston.js index 163e0b4eab0..983205fc709 100644 --- a/api/config/winston.js +++ b/api/config/winston.js @@ -42,9 +42,13 @@ const getLogDir = () => { return path.join(__dirname, '..', 'logs'); }; -const logDir = getLogDir(); - -const { NODE_ENV, DEBUG_LOGGING = true, CONSOLE_JSON = false, DEBUG_CONSOLE = false } = process.env; +const { + NODE_ENV, + DEBUG_LOGGING = true, + CONSOLE_JSON = false, + DEBUG_CONSOLE = false, + LOG_TO_FILE = true, +} = process.env; const useConsoleJson = (typeof CONSOLE_JSON === 'string' && CONSOLE_JSON?.toLowerCase() === 'true') || @@ -58,6 +62,10 @@ 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, @@ -129,30 +137,36 @@ const fileFormat = winston.format.combine( // redactErrors(), ); -const transports = [ - new winston.transports.DailyRotateFile({ - level: 'error', - filename: `${logDir}/error-%DATE%.log`, - datePattern: 'YYYY-MM-DD', - zippedArchive: true, - maxSize: '20m', - maxFiles: '14d', - format: fileFormat, - }), -]; +const transports = []; + +if (useFileLogging) { + const logDir = getLogDir(); -if (useDebugLogging) { transports.push( new winston.transports.DailyRotateFile({ - level: 'debug', - filename: `${logDir}/debug-%DATE%.log`, + level: 'error', + filename: `${logDir}/error-%DATE%.log`, datePattern: 'YYYY-MM-DD', zippedArchive: true, maxSize: '20m', maxFiles: '14d', - format: winston.format.combine(fileFormat, debugTraverse), + format: fileFormat, }), ); + + if (useDebugLogging) { + transports.push( + new winston.transports.DailyRotateFile({ + level: 'debug', + filename: `${logDir}/debug-%DATE%.log`, + datePattern: 'YYYY-MM-DD', + zippedArchive: true, + maxSize: '20m', + maxFiles: '14d', + format: winston.format.combine(fileFormat, debugTraverse), + }), + ); + } } const consoleFormat = winston.format.combine( 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/packages/data-schemas/src/config/meiliLogger.ts b/packages/data-schemas/src/config/meiliLogger.ts index 572e8770716..243b3bed691 100644 --- a/packages/data-schemas/src/config/meiliLogger.ts +++ b/packages/data-schemas/src/config/meiliLogger.ts @@ -2,14 +2,14 @@ import winston from 'winston'; import 'winston-daily-rotate-file'; import { getLogDirectory } from './utils'; -const logDir = getLogDirectory(); - -const { NODE_ENV, DEBUG_LOGGING = 'false' } = process.env; +const { NODE_ENV, DEBUG_LOGGING = 'false', LOG_TO_FILE } = 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'; + const levels: winston.config.AbstractConfigSetLevels = { error: 0, warn: 1, @@ -41,17 +41,23 @@ const fileFormat = winston.format.combine( ); const logLevel = useDebugLogging ? 'debug' : 'error'; -const transports: winston.transport[] = [ - 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: winston.transport[] = []; + +if (useFileLogging) { + const logDir = getLogDirectory(); + + 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/packages/data-schemas/src/config/winston.ts b/packages/data-schemas/src/config/winston.ts index 9bc76524150..9c4a7b78007 100644 --- a/packages/data-schemas/src/config/winston.ts +++ b/packages/data-schemas/src/config/winston.ts @@ -4,9 +4,7 @@ import { redactFormat, redactMessage, debugTraverse, jsonTruncateFormat } from ' import { getTenantId, getUserId, getRequestId, SYSTEM_TENANT_ID } from './tenantContext'; import { getLogDirectory } from './utils'; -const logDir = getLogDirectory(); - -const { NODE_ENV, DEBUG_LOGGING, CONSOLE_JSON, DEBUG_CONSOLE } = process.env; +const { NODE_ENV, DEBUG_LOGGING, CONSOLE_JSON, DEBUG_CONSOLE, LOG_TO_FILE } = process.env; const useConsoleJson = typeof CONSOLE_JSON === 'string' && CONSOLE_JSON.toLowerCase() === 'true'; @@ -14,6 +12,8 @@ const useDebugConsole = typeof DEBUG_CONSOLE === 'string' && DEBUG_CONSOLE.toLow const useDebugLogging = typeof DEBUG_LOGGING === 'string' && DEBUG_LOGGING.toLowerCase() === 'true'; +const useFileLogging = typeof LOG_TO_FILE !== 'string' || LOG_TO_FILE.toLowerCase() !== 'false'; + const levels: winston.config.AbstractConfigSetLevels = { error: 0, warn: 1, @@ -88,30 +88,36 @@ const fileFormat = winston.format.combine( requestContextFormat(), ); -const transports: winston.transport[] = [ - new winston.transports.DailyRotateFile({ - level: 'error', - filename: `${logDir}/error-%DATE%.log`, - datePattern: 'YYYY-MM-DD', - zippedArchive: true, - maxSize: '20m', - maxFiles: '14d', - format: winston.format.combine(fileFormat, winston.format.json()), - }), -]; +const transports: winston.transport[] = []; + +if (useFileLogging) { + const logDir = getLogDirectory(); -if (useDebugLogging) { transports.push( new winston.transports.DailyRotateFile({ - level: 'debug', - filename: `${logDir}/debug-%DATE%.log`, + level: 'error', + filename: `${logDir}/error-%DATE%.log`, datePattern: 'YYYY-MM-DD', zippedArchive: true, maxSize: '20m', maxFiles: '14d', - format: winston.format.combine(fileFormat, debugTraverse), + format: winston.format.combine(fileFormat, winston.format.json()), }), ); + + if (useDebugLogging) { + transports.push( + new winston.transports.DailyRotateFile({ + level: 'debug', + filename: `${logDir}/debug-%DATE%.log`, + datePattern: 'YYYY-MM-DD', + zippedArchive: true, + maxSize: '20m', + maxFiles: '14d', + format: winston.format.combine(fileFormat, debugTraverse), + }), + ); + } } const consoleFormat = winston.format.combine( From 830d124e4d3adeb25bfd96fda2ce3d58faa8708c Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 21 May 2026 00:54:57 -0400 Subject: [PATCH 045/395] =?UTF-8?q?=F0=9F=AA=AA=20fix:=20Add=20Admin=20Pan?= =?UTF-8?q?el=20SSO=20URL=20Config=20(#13220)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Add admin panel URL Helm configuration * fix: Clarify admin panel URL configuration * fix: Avoid duplicate admin panel URL env --- .env.example | 8 ++++++++ helm/librechat/Chart.yaml | 2 +- helm/librechat/readme.md | 20 +++++++++++++++++++- helm/librechat/templates/configmap-env.yaml | 18 +++++++++++++++--- helm/librechat/values.yaml | 6 ++++++ 5 files changed, 49 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 6d5195f0167..a2e3de8bf16 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,12 @@ 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= + 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. @@ -535,6 +541,8 @@ 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= diff --git a/helm/librechat/Chart.yaml b/helm/librechat/Chart.yaml index 92bee492524..3f5a156db78 100755 --- a/helm/librechat/Chart.yaml +++ b/helm/librechat/Chart.yaml @@ -15,7 +15,7 @@ type: application # This is the chart version. This version number should be incremented each time you make changes # to the chart and its templates, including the app version. # Versions are expected to follow Semantic Versioning (https://semver.org/) -version: 2.0.3 +version: 2.0.4 # This is the version number of the application being deployed. This version number should be # incremented each time you make changes to the application. Versions are not expected to diff --git a/helm/librechat/readme.md b/helm/librechat/readme.md index b557825c3c5..685c41e1e76 100755 --- a/helm/librechat/readme.md +++ b/helm/librechat/readme.md @@ -34,4 +34,22 @@ kind: Secret 3. Apply the Secret to the Cluster -4. Fill out values.yaml and apply the Chart to the Cluster \ No newline at end of file +4. Fill out values.yaml and apply the Chart to the Cluster + +## Admin Panel SSO + +When deploying the admin panel at a separate URL, set `librechat.adminPanelUrl` +to the external admin panel base URL. It may include a path, but it should not +end with a trailing `/` because LibreChat appends `/auth/...` callback paths. + +```yaml +librechat: + adminPanelUrl: https://admin.example.com/admin +``` + +This renders `ADMIN_PANEL_URL` for LibreChat's admin OAuth flow. For OpenID SSO, +also register this LibreChat callback URL with your identity provider: + +```text +https:///api/admin/oauth/openid/callback +``` diff --git a/helm/librechat/templates/configmap-env.yaml b/helm/librechat/templates/configmap-env.yaml index ed5ac822dac..5fd43940d20 100755 --- a/helm/librechat/templates/configmap-env.yaml +++ b/helm/librechat/templates/configmap-env.yaml @@ -3,6 +3,9 @@ apiVersion: v1 metadata: name: {{ include "librechat.fullname" $ }}-configenv data: + {{- $configEnv := default dict .Values.librechat.configEnv }} + {{- $adminPanelUrl := .Values.librechat.adminPanelUrl }} + {{- $configAdminPanelUrl := dig "configEnv" "ADMIN_PANEL_URL" "" .Values.librechat }} {{- if (index .Values "librechat-rag-api" "enabled") }} RAG_API_URL: http://{{ include "rag.fullname" (index .Subcharts "librechat-rag-api") | lower }}.{{ .Release.Namespace | lower }}.svc.cluster.local:8000 {{- end }} @@ -18,6 +21,15 @@ data: {{- if and (not (dig "configEnv" "REDIS_URI" "" .Values.librechat)) .Values.redis.enabled }} REDIS_URI: redis://{{ include "common.names.fullname" .Subcharts.redis }}-master.{{ .Release.Namespace | lower }}.svc.cluster.local:6379 {{- end }} - {{- if .Values.librechat.configEnv }} - {{- toYaml .Values.librechat.configEnv | nindent 2 }} - {{- end }} \ No newline at end of file + {{- if and $adminPanelUrl (not $configAdminPanelUrl) }} + ADMIN_PANEL_URL: {{ $adminPanelUrl | quote }} + {{- end }} + {{- if $configEnv }} + {{- $renderedConfigEnv := $configEnv }} + {{- if and $adminPanelUrl (hasKey $configEnv "ADMIN_PANEL_URL") (not $configAdminPanelUrl) }} + {{- $renderedConfigEnv = omit $configEnv "ADMIN_PANEL_URL" }} + {{- end }} + {{- if $renderedConfigEnv }} + {{- toYaml $renderedConfigEnv | nindent 2 }} + {{- end }} + {{- end }} diff --git a/helm/librechat/values.yaml b/helm/librechat/values.yaml index 25b61e82d7f..e5321c2a933 100755 --- a/helm/librechat/values.yaml +++ b/helm/librechat/values.yaml @@ -33,6 +33,12 @@ global: # key: client_id librechat: + # External admin panel base URL used for admin OAuth/SSO redirects. + # Required when deploying the admin panel on a separate URL. + # May include a path. Do not include a trailing slash. + # Example: https://admin.example.com/admin + adminPanelUrl: "" + configEnv: # IMPORTANT -- GENERATE your own: openssl rand -hex 32 and openssl rand -hex 16 for CREDS_IV. Best Practise: Put into Secret. See global.librechat.existingSecretName CREDS_KEY: 9e95d9894da7e68dd69c0046caf5343c8b1e80c89609b5a1e40e6568b5b23ce6 From 1ed84ee4eb8ad2d63802d5bd063db934a7279512 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 21 May 2026 01:29:43 -0400 Subject: [PATCH 046/395] =?UTF-8?q?=F0=9F=A6=A3=20fix:=20Response=20Size?= =?UTF-8?q?=20Limits=20for=20Streamable=20HTTP=20MCP=20Responses=20(#13219?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: implement response size limits for streamable HTTP MCP responses - Added environment variables for maximum response and line sizes in streamable HTTP responses. - Introduced functions to handle response size validation and error logging. - Updated MCP connection logic to enforce these limits, ensuring safe handling of large responses. * fix: address MCP response guard review findings * fix: satisfy logger spy typings * fix: clarify blocked MCP response errors * fix: harden MCP guard review edge cases * test: cover MCP oversized SSE call failures --- .env.example | 8 + packages/api/src/agents/handlers.spec.ts | 123 ++++++ packages/api/src/agents/handlers.ts | 74 +++- .../mcp/__tests__/MCPConnectionSSRF.test.ts | 224 +++++++++++ packages/api/src/mcp/connection.ts | 363 +++++++++++++++++- 5 files changed, 786 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index a2e3de8bf16..1aa95fd5bea 100644 --- a/.env.example +++ b/.env.example @@ -893,6 +893,14 @@ OPENWEATHER_API_KEY= # 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: 1048576 (1 MiB) +# MCP_STREAMABLE_HTTP_MAX_LINE_BYTES=1048576 + # 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/packages/api/src/agents/handlers.spec.ts b/packages/api/src/agents/handlers.spec.ts index 41200f31cd5..dca3a850055 100644 --- a/packages/api/src/agents/handlers.spec.ts +++ b/packages/api/src/agents/handlers.spec.ts @@ -1,4 +1,5 @@ import { Constants } from '@librechat/agents'; +import { logger } from '@librechat/data-schemas'; import type { ToolExecuteBatchRequest, ToolExecuteResult, @@ -278,6 +279,128 @@ describe('createToolExecuteHandler', () => { }); }); + describe('tool error handling', () => { + it('truncates oversized tool errors in the result and log context', async () => { + const oversizedMessage = `tool failed: ${'x'.repeat(15_000)}`; + const thrown = new Error(oversizedMessage); + thrown.stack = `Error: ${oversizedMessage}\n${'stack-line\n'.repeat(600)}`; + const loadTools: ToolExecuteOptions['loadTools'] = jest.fn(async () => ({ + loadedTools: [ + { + name: 'bad_tool', + invoke: jest.fn(async () => { + throw thrown; + }), + }, + ] as never[], + })); + const errorSpy = jest.spyOn(logger, 'error').mockReturnValue(logger); + try { + const handler = createToolExecuteHandler({ loadTools }); + const [result] = await invokeHandler(handler, [ + { + id: 'call_bad', + name: 'bad_tool', + args: {}, + }, + ]); + + expect(result.status).toBe('error'); + expect(result.errorMessage).toContain('truncated'); + expect(result.errorMessage!.length).toBeLessThanOrEqual(12_000); + expect(errorSpy).toHaveBeenCalledWith( + '[ON_TOOL_EXECUTE] Tool bad_tool error', + expect.objectContaining({ + messageTruncated: true, + messageLength: oversizedMessage.length, + }), + ); + const [, logContext] = errorSpy.mock.calls[0] as unknown as [string, { stack?: string }]; + expect(logContext.stack!.length).toBeLessThanOrEqual(4_000); + } finally { + errorSpy.mockRestore(); + } + }); + + it('returns a per-tool error when thrown value stringification fails', async () => { + const thrown = { + toString() { + throw new Error('toString failed'); + }, + }; + const loadTools: ToolExecuteOptions['loadTools'] = jest.fn(async () => ({ + loadedTools: [ + { + name: 'bad_to_string_tool', + invoke: jest.fn(async () => { + throw thrown; + }), + }, + ] as never[], + })); + const errorSpy = jest.spyOn(logger, 'error').mockReturnValue(logger); + try { + const handler = createToolExecuteHandler({ loadTools }); + const [result] = await invokeHandler(handler, [ + { + id: 'call_bad_to_string', + name: 'bad_to_string_tool', + args: {}, + }, + ]); + + expect(result.status).toBe('error'); + expect(result.errorMessage).toBe('[Thrown value could not be converted to string]'); + expect(errorSpy).toHaveBeenCalledWith( + '[ON_TOOL_EXECUTE] Tool bad_to_string_tool error', + expect.objectContaining({ + name: 'object', + messageTruncated: false, + }), + ); + } finally { + errorSpy.mockRestore(); + } + }); + + it('preserves message from thrown plain objects', async () => { + const thrown = { message: 'plain object timeout' }; + const loadTools: ToolExecuteOptions['loadTools'] = jest.fn(async () => ({ + loadedTools: [ + { + name: 'plain_object_tool', + invoke: jest.fn(async () => { + throw thrown; + }), + }, + ] as never[], + })); + const errorSpy = jest.spyOn(logger, 'error').mockReturnValue(logger); + try { + const handler = createToolExecuteHandler({ loadTools }); + const [result] = await invokeHandler(handler, [ + { + id: 'call_plain_object', + name: 'plain_object_tool', + args: {}, + }, + ]); + + expect(result.status).toBe('error'); + expect(result.errorMessage).toBe('plain object timeout'); + expect(errorSpy).toHaveBeenCalledWith( + '[ON_TOOL_EXECUTE] Tool plain_object_tool error', + expect.objectContaining({ + message: 'plain object timeout', + messageTruncated: false, + }), + ); + } finally { + errorSpy.mockRestore(); + } + }); + }); + describe('skill tool model-invocation gate', () => { function createSkillHandler(getSkillByName: ToolExecuteOptions['getSkillByName']) { const loadTools: ToolExecuteOptions['loadTools'] = jest.fn(async () => ({ diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index 5a6e3834bf0..4690cf73e43 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -154,9 +154,77 @@ export interface ToolExecuteOptions { const MAX_READABLE_BYTES = 262_144; const MAX_BINARY_BYTES = 5 * 1024 * 1024; const MAX_CACHE_BYTES = 512 * 1024; +const MAX_TOOL_ERROR_MESSAGE_CHARS = 12_000; +const MAX_TOOL_ERROR_STACK_CHARS = 4_000; const IMAGE_MIMES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']); +function truncateMiddle(value: string, maxChars: number): string { + if (value.length <= maxChars) { + return value; + } + + const indicator = `\n\n... [truncated: ${value.length} chars exceeded ${maxChars} limit] ...\n\n`; + const available = maxChars - indicator.length; + if (available <= 0) { + return value.slice(0, maxChars); + } + + const headSize = Math.ceil(available * 0.7); + const tailSize = available - headSize; + return value.slice(0, headSize) + indicator + value.slice(value.length - tailSize); +} + +function stringifyThrownValue(error: unknown): string { + try { + return String(error); + } catch { + return '[Thrown value could not be converted to string]'; + } +} + +function getThrownValueMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + if (error != null && typeof error === 'object') { + try { + const message = (error as { message?: unknown }).message; + if (typeof message === 'string') { + return message; + } + if (message != null) { + return stringifyThrownValue(message); + } + } catch { + // Fall through to whole-value stringification. + } + } + + return stringifyThrownValue(error); +} + +function getSafeToolError(error: unknown): { + message: string; + logContext: Record; +} { + const rawMessage = getThrownValueMessage(error); + const message = truncateMiddle(rawMessage, MAX_TOOL_ERROR_MESSAGE_CHARS); + const stack = error instanceof Error && error.stack ? error.stack : undefined; + + return { + message, + logContext: { + name: error instanceof Error ? error.name : typeof error, + message, + messageLength: rawMessage.length, + messageTruncated: message.length !== rawMessage.length, + stack: stack ? truncateMiddle(stack, MAX_TOOL_ERROR_STACK_CHARS) : undefined, + }, + }; +} + function addLineNumbers(content: string): string { const lines = content.split('\n'); const w = String(lines.length).length; @@ -1195,13 +1263,13 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand status: 'success' as const, }; } catch (toolError) { - const error = toolError as Error; - logger.error(`[ON_TOOL_EXECUTE] Tool ${tc.name} error:`, error); + const { message, logContext } = getSafeToolError(toolError); + logger.error(`[ON_TOOL_EXECUTE] Tool ${tc.name} error`, logContext); return { toolCallId: tc.id, status: 'error' as const, content: '', - errorMessage: error.message, + errorMessage: message, }; } }), diff --git a/packages/api/src/mcp/__tests__/MCPConnectionSSRF.test.ts b/packages/api/src/mcp/__tests__/MCPConnectionSSRF.test.ts index 98f698c9cd4..fcc6b72dcf7 100644 --- a/packages/api/src/mcp/__tests__/MCPConnectionSSRF.test.ts +++ b/packages/api/src/mcp/__tests__/MCPConnectionSSRF.test.ts @@ -258,6 +258,45 @@ async function createStreamableServer(): Promise }; } +async function createOversizedToolResultStreamableServer( + payloadSize: number, +): Promise> { + const sessions = new Map(); + + const httpServer = http.createServer(async (req, res) => { + const sid = req.headers['mcp-session-id'] as string | undefined; + let transport = sid ? sessions.get(sid) : undefined; + + if (!transport) { + transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() }); + const mcp = new McpServer({ name: 'oversized-tool-result', version: '0.0.1' }); + mcp.tool('oversized', 'Returns an oversized text payload', {}, async () => ({ + content: [{ type: 'text', text: 'x'.repeat(payloadSize) }], + })); + await mcp.connect(transport); + } + + await transport.handleRequest(req, res); + + if (transport.sessionId && !sessions.has(transport.sessionId)) { + sessions.set(transport.sessionId, transport); + transport.onclose = () => sessions.delete(transport!.sessionId!); + } + }); + + const destroySockets = trackSockets(httpServer); + const port = await getFreePort(); + await new Promise((resolve) => httpServer.listen(port, '127.0.0.1', resolve)); + + return { + url: `http://127.0.0.1:${port}/`, + close: async () => { + await closeMCPSessions(sessions); + await destroySockets(); + }, + }; +} + describe('MCP SSRF protection – redirect blocking', () => { let redirectServer: TestServer; let conn: MCPConnection | null; @@ -457,6 +496,11 @@ interface HeaderCaptureServer { close: () => Promise; } +interface RawResponseServer { + url: string; + close: () => Promise; +} + /** * Captures every incoming request's headers, method, and body, then replies * with a benign 200 so tests can assert what actually crossed a redirect @@ -490,6 +534,17 @@ async function createHeaderCaptureServer(): Promise { }; } +async function createRawResponseServer(handler: http.RequestListener): Promise { + const server = http.createServer(handler); + const destroySockets = trackSockets(server); + const port = await getFreePort(); + await new Promise((resolve) => server.listen(port, '127.0.0.1', resolve)); + return { + url: `http://127.0.0.1:${port}/`, + close: destroySockets, + }; +} + /** * Issues a single 307/308 redirect to `redirectTarget` (which lives on a * different port and is therefore a different origin). Used to verify @@ -823,8 +878,20 @@ describe('MCP SSRF protection – cross-origin credential stripping on redirect' describe('MCP SSRF protection – customFetch input shapes', () => { let target: Omit | undefined; let conn: MCPConnection | null; + const originalMaxResponseBytes = process.env.MCP_STREAMABLE_HTTP_MAX_RESPONSE_BYTES; + const originalMaxLineBytes = process.env.MCP_STREAMABLE_HTTP_MAX_LINE_BYTES; afterEach(async () => { + if (originalMaxResponseBytes == null) { + delete process.env.MCP_STREAMABLE_HTTP_MAX_RESPONSE_BYTES; + } else { + process.env.MCP_STREAMABLE_HTTP_MAX_RESPONSE_BYTES = originalMaxResponseBytes; + } + if (originalMaxLineBytes == null) { + delete process.env.MCP_STREAMABLE_HTTP_MAX_LINE_BYTES; + } else { + process.env.MCP_STREAMABLE_HTTP_MAX_LINE_BYTES = originalMaxLineBytes; + } await safeDisconnect(conn); conn = null; if (target) { @@ -846,12 +913,33 @@ describe('MCP SSRF protection – customFetch input shapes', () => { connection as unknown as { createFetchFunction: ( getHeaders: () => Record | null | undefined, + timeout?: number, + sseBodyTimeout?: number, + configuredSecretHeaderKeys?: ReadonlySet, + baseUrl?: string, + guardStreamableHTTPResponses?: boolean, ) => CustomFetch; } ).createFetchFunction; return factory.call(connection, () => null); } + function getGuardedStreamableHTTPCustomFetch(connection: MCPConnection): CustomFetch { + const factory = ( + connection as unknown as { + createFetchFunction: ( + getHeaders: () => Record | null | undefined, + timeout?: number, + sseBodyTimeout?: number, + configuredSecretHeaderKeys?: ReadonlySet, + baseUrl?: string, + guardStreamableHTTPResponses?: boolean, + ) => CustomFetch; + } + ).createFetchFunction; + return factory.call(connection, () => null, undefined, undefined, undefined, undefined, true); + } + it.each<['string' | 'URL' | 'Request']>([['string'], ['URL'], ['Request']])( 'should accept a %s input without throwing on URL derivation', async (shape) => { @@ -1015,6 +1103,142 @@ describe('MCP SSRF protection – customFetch input shapes', () => { } } }); + + it('should not apply streamable HTTP response caps unless the transport opts in', async () => { + process.env.MCP_STREAMABLE_HTTP_MAX_RESPONSE_BYTES = '8'; + const server = await createRawResponseServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{"jsonrpc":"2.0","id":1,"result":{"too":"large"}}'); + }); + try { + conn = new MCPConnection({ + serverName: 'customfetch-unguarded-byte-limit', + serverConfig: { type: 'sse', url: server.url }, + useSSRFProtection: false, + }); + + const customFetch = getCustomFetch(conn); + const response = await customFetch(server.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'ping', id: 1 }), + }); + + await expect(response.text()).resolves.toContain('"too":"large"'); + } finally { + await server.close(); + } + }); + + it('should reject oversized JSON POST responses with the streamable HTTP byte cap', async () => { + process.env.MCP_STREAMABLE_HTTP_MAX_RESPONSE_BYTES = '8'; + const server = await createRawResponseServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end('{"jsonrpc":"2.0","id":1,"result":{"too":"large"}}'); + }); + try { + conn = new MCPConnection({ + serverName: 'customfetch-json-byte-limit', + serverConfig: { type: 'streamable-http', url: server.url }, + useSSRFProtection: false, + }); + + const customFetch = getGuardedStreamableHTTPCustomFetch(conn); + const response = await customFetch(server.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'ping', id: 1 }), + }); + + await expect(response.text()).rejects.toThrow( + /MCP response exceeded byte limit.*limit=8 bytes/, + ); + } finally { + await server.close(); + } + }); + + it('should reject a POST response with an oversized SSE line before the SSE parser can grow it', async () => { + process.env.MCP_STREAMABLE_HTTP_MAX_LINE_BYTES = '16'; + const server = await createRawResponseServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + res.end(`data: ${'x'.repeat(64)}\n\n`); + }); + try { + conn = new MCPConnection({ + serverName: 'customfetch-sse-line-limit', + serverConfig: { type: 'streamable-http', url: server.url }, + useSSRFProtection: false, + }); + + const customFetch = getGuardedStreamableHTTPCustomFetch(conn); + const response = await customFetch(server.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/cancelled' }), + }); + await expect(response.text()).rejects.toThrow( + /MCP response contained an oversized SSE line.*lineLimit=16 bytes.*observedLine=17 bytes/, + ); + } finally { + await server.close(); + } + }); + + it('should fail an actual streamable HTTP tool call promptly with a clear oversized SSE line error', async () => { + process.env.MCP_STREAMABLE_HTTP_MAX_LINE_BYTES = '512'; + target = await createOversizedToolResultStreamableServer(2048); + conn = new MCPConnection({ + serverName: 'streamable-http-tool-call-sse-line-limit', + serverConfig: { type: 'streamable-http', url: target.url }, + useSSRFProtection: false, + }); + + await conn.connect(); + const startedAt = Date.now(); + + await expect( + conn.client.callTool({ name: 'oversized', arguments: {} }, undefined, { timeout: 3000 }), + ).rejects.toThrow(/MCP response contained an oversized SSE line/); + expect(Date.now() - startedAt).toBeLessThan(1500); + }); + + it('should stream valid SSE POST responses without waiting for EOF', async () => { + process.env.MCP_STREAMABLE_HTTP_MAX_LINE_BYTES = '4096'; + let finish!: () => void; + const finished = new Promise((resolve) => { + finish = resolve; + }); + const server = await createRawResponseServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + res.write('data: {"jsonrpc":"2.0","id":1,"result":{}}\n\n'); + finished.then(() => res.end()).catch(() => res.end()); + }); + try { + conn = new MCPConnection({ + serverName: 'customfetch-sse-streaming', + serverConfig: { type: 'streamable-http', url: server.url }, + useSSRFProtection: false, + }); + + const customFetch = getGuardedStreamableHTTPCustomFetch(conn); + const response = await customFetch(server.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'ping', id: 1 }), + }); + const reader = response.body!.getReader(); + const { value, done } = await reader.read(); + + expect(done).toBe(false); + expect(Buffer.from(value as Uint8Array).toString('utf8')).toContain('"result":{}'); + await reader.cancel().catch(() => undefined); + finish(); + } finally { + finish(); + await server.close(); + } + }); }); describe('MCP SSRF protection – WebSocket DNS resolution', () => { diff --git a/packages/api/src/mcp/connection.ts b/packages/api/src/mcp/connection.ts index fb9481b8b1d..3eddf26a917 100644 --- a/packages/api/src/mcp/connection.ts +++ b/packages/api/src/mcp/connection.ts @@ -74,6 +74,351 @@ const SSE_CONNECT_TIMEOUT = 120000; const DEFAULT_INIT_TIMEOUT = 30000; /** Max 307/308 redirects to follow per request (prevents redirect loops) */ const MAX_REDIRECTS = 5; +const DEFAULT_MCP_STREAMABLE_HTTP_MAX_RESPONSE_BYTES = 16 * 1024 * 1024; +const DEFAULT_MCP_STREAMABLE_HTTP_MAX_LINE_BYTES = 1024 * 1024; + +function getNonNegativeIntegerEnv(name: string, defaultValue: number): number { + const raw = process.env[name]; + if (raw == null || raw.trim() === '') { + return defaultValue; + } + + const trimmed = raw.trim(); + if (!/^\d+$/.test(trimmed)) { + return defaultValue; + } + + const parsed = Number(trimmed); + return Number.isSafeInteger(parsed) ? parsed : defaultValue; +} + +function bytesToMiB(bytes: number): string { + return `${(bytes / 1024 / 1024).toFixed(2)} MiB`; +} + +function getMemoryDebugSnapshot(): Record { + const mem = process.memoryUsage(); + return { + rss: bytesToMiB(mem.rss), + heapUsed: bytesToMiB(mem.heapUsed), + heapTotal: bytesToMiB(mem.heapTotal), + external: bytesToMiB(mem.external), + arrayBuffers: bytesToMiB(mem.arrayBuffers ?? 0), + }; +} + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); +type JSONRPCRequestId = string | number; + +function getChunkBytes(chunk: unknown): Uint8Array { + if (typeof chunk === 'string') { + return textEncoder.encode(chunk); + } + if (chunk instanceof ArrayBuffer) { + return new Uint8Array(chunk); + } + if (ArrayBuffer.isView(chunk)) { + const view = new Uint8Array(chunk.buffer, chunk.byteOffset, chunk.byteLength); + return new Uint8Array(view); + } + return new Uint8Array(); +} + +function copyBytes(bytes: Uint8Array): Uint8Array { + const copy = new Uint8Array(bytes.byteLength); + copy.set(bytes); + return copy; +} + +function concatBytes(chunks: Uint8Array[]): Uint8Array { + if (chunks.length === 0) { + return new Uint8Array(); + } + if (chunks.length === 1) { + return chunks[0]; + } + const totalLength = chunks.reduce((total, chunk) => total + chunk.byteLength, 0); + const combined = new Uint8Array(totalLength); + let offset = 0; + for (const chunk of chunks) { + combined.set(chunk, offset); + offset += chunk.byteLength; + } + return combined; +} + +function getBodyText(body: unknown): string | null { + if (typeof body === 'string') { + return body; + } + if (body instanceof ArrayBuffer) { + return textDecoder.decode(new Uint8Array(body)); + } + if (ArrayBuffer.isView(body)) { + return textDecoder.decode(new Uint8Array(body.buffer, body.byteOffset, body.byteLength)); + } + return null; +} + +function getJSONRPCRequestIds(body: unknown): JSONRPCRequestId[] { + const bodyText = getBodyText(body); + if (!bodyText) { + return []; + } + + let parsed: unknown; + try { + parsed = JSON.parse(bodyText); + } catch { + return []; + } + + const messages = Array.isArray(parsed) ? parsed : [parsed]; + return messages.flatMap((message) => { + if (!message || typeof message !== 'object') { + return []; + } + const jsonrpcMessage = message as { id?: unknown; method?: unknown }; + const { id } = jsonrpcMessage; + if (typeof jsonrpcMessage.method !== 'string') { + return []; + } + if (typeof id !== 'string' && typeof id !== 'number') { + return []; + } + return [id]; + }); +} + +function buildBlockedMCPResponseMessage( + reason: string, + details: { + maxResponseBytes: number; + maxLineBytes: number; + totalBytes: number; + currentLineBytes: number; + chunkCount: number; + }, +): string { + const limitDetails = + reason === 'MCP response exceeded byte limit' + ? `limit=${details.maxResponseBytes} bytes, observed=${details.totalBytes} bytes` + : `lineLimit=${details.maxLineBytes} bytes, observedLine=${details.currentLineBytes} bytes, observedTotal=${details.totalBytes} bytes`; + + return `[MCP] ${reason} (${limitDetails}, chunks=${details.chunkCount}). The MCP server returned an unsafe streamable HTTP response; narrow the tool result or retry after the server response is fixed.`; +} + +function buildBlockedMCPResponseSSE(requestIds: JSONRPCRequestId[], message: string): Uint8Array { + const events = requestIds + .map((id) => { + const payload = { + jsonrpc: '2.0', + id, + error: { + code: -32000, + message, + }, + }; + return `data: ${JSON.stringify(payload)}\n\n`; + }) + .join(''); + return textEncoder.encode(events); +} + +function getMCPStreamableHTTPResponseLimits(): { + maxResponseBytes: number; + maxLineBytes: number; +} { + return { + maxResponseBytes: getNonNegativeIntegerEnv( + 'MCP_STREAMABLE_HTTP_MAX_RESPONSE_BYTES', + DEFAULT_MCP_STREAMABLE_HTTP_MAX_RESPONSE_BYTES, + ), + maxLineBytes: getNonNegativeIntegerEnv( + 'MCP_STREAMABLE_HTTP_MAX_LINE_BYTES', + DEFAULT_MCP_STREAMABLE_HTTP_MAX_LINE_BYTES, + ), + }; +} + +async function guardMCPStreamableHTTPResponse( + response: UndiciResponse, + context: { + logPrefix: string; + method: string; + url: string; + requestIds?: JSONRPCRequestId[]; + }, +): Promise { + if (context.method === 'GET' || !response.body) { + return response; + } + + const contentType = response.headers.get('content-type') ?? ''; + const isEventStream = contentType.toLowerCase().includes('text/event-stream'); + const { maxResponseBytes, maxLineBytes } = getMCPStreamableHTTPResponseLimits(); + const canEmitFallbackSSEError = isEventStream && maxLineBytes > 0; + if (!isEventStream && maxResponseBytes === 0) { + return response; + } + if (maxResponseBytes === 0 && maxLineBytes === 0) { + return response; + } + + let totalBytes = 0; + let currentLineBytes = 0; + let chunkCount = 0; + let pendingSSELineChunks: Uint8Array[] = []; + const sseEventDataLines: string[] = []; + const unresolvedRequestIds = new Set(context.requestIds ?? []); + + const buildAndLogBlockedError = ( + reason: string, + details: Record, + ): Error => { + const message = buildBlockedMCPResponseMessage(reason, { + maxResponseBytes, + maxLineBytes, + totalBytes, + currentLineBytes, + chunkCount, + }); + logger.warn(`${context.logPrefix} MCP streamable HTTP response blocked: ${reason}`, { + method: context.method, + url: sanitizeUrlForLogging(context.url), + status: response.status, + contentType, + maxResponseBytes, + maxLineBytes, + totalBytes, + currentLineBytes, + chunkCount, + ...details, + memory: getMemoryDebugSnapshot(), + }); + return new Error(message); + }; + + const trackSSELineForResolvedIds = (lineBytes: Uint8Array): void => { + if (unresolvedRequestIds.size === 0) { + return; + } + + const rawLine = textDecoder.decode(lineBytes).replace(/[\r\n]+$/, ''); + if (rawLine === '') { + if (sseEventDataLines.length === 0) { + return; + } + const data = sseEventDataLines.join('\n'); + sseEventDataLines.length = 0; + try { + const parsed = JSON.parse(data) as { id?: unknown }; + if (typeof parsed.id === 'string' || typeof parsed.id === 'number') { + unresolvedRequestIds.delete(parsed.id); + } + } catch { + /** Ignore malformed SSE data here; the SDK parser will report it. */ + } + return; + } + + const separatorIndex = rawLine.indexOf(':'); + const field = separatorIndex === -1 ? rawLine : rawLine.slice(0, separatorIndex); + if (field !== 'data') { + return; + } + let value = separatorIndex === -1 ? '' : rawLine.slice(separatorIndex + 1); + if (value.startsWith(' ')) { + value = value.slice(1); + } + sseEventDataLines.push(value); + }; + + const enqueuePendingSSELine = (controller: TransformStreamDefaultController) => { + if (pendingSSELineChunks.length === 0) { + return; + } + const lineBytes = concatBytes(pendingSSELineChunks); + pendingSSELineChunks = []; + trackSSELineForResolvedIds(lineBytes); + controller.enqueue(lineBytes); + }; + + const blockResponse = ( + controller: TransformStreamDefaultController, + reason: string, + details: Record, + ) => { + const error = buildAndLogBlockedError(reason, details); + const fallbackRequestIds = [...unresolvedRequestIds]; + if (canEmitFallbackSSEError && fallbackRequestIds.length > 0) { + controller.enqueue(buildBlockedMCPResponseSSE(fallbackRequestIds, error.message)); + controller.terminate(); + return; + } + throw error; + }; + + const guardedBody = (response.body as unknown as ReadableStream).pipeThrough( + new TransformStream({ + transform(chunk, controller) { + const bytes = getChunkBytes(chunk); + if (bytes.byteLength === 0) { + return; + } + + chunkCount += 1; + totalBytes += bytes.byteLength; + + if (maxResponseBytes > 0 && totalBytes > maxResponseBytes) { + blockResponse(controller, 'MCP response exceeded byte limit', { + chunkBytes: bytes.byteLength, + }); + return; + } + + if (isEventStream && maxLineBytes > 0) { + let segmentStart = 0; + for (let i = 0; i < bytes.byteLength; i++) { + const byte = bytes[i]; + if (byte === 10 || byte === 13) { + if (i + 1 > segmentStart) { + pendingSSELineChunks.push(copyBytes(bytes.subarray(segmentStart, i + 1))); + } + enqueuePendingSSELine(controller); + segmentStart = i + 1; + currentLineBytes = 0; + continue; + } + currentLineBytes += 1; + if (currentLineBytes > maxLineBytes) { + blockResponse(controller, 'MCP response contained an oversized SSE line', { + chunkBytes: bytes.byteLength, + }); + return; + } + } + if (segmentStart < bytes.byteLength) { + pendingSSELineChunks.push(copyBytes(bytes.subarray(segmentStart))); + } + return; + } + + controller.enqueue(bytes); + }, + flush(controller) { + enqueuePendingSSELine(controller); + }, + }), + ); + + return new Response(guardedBody as unknown as BodyInit, { + status: response.status, + statusText: response.statusText, + headers: response.headers as unknown as HeadersInit, + }) as unknown as UndiciResponse; +} /** * Headers stripped before forwarding a request across an origin boundary on @@ -579,6 +924,7 @@ export class MCPConnection extends EventEmitter { sseBodyTimeout?: number, configuredSecretHeaderKeys?: ReadonlySet, baseUrl?: string, + guardStreamableHTTPResponses = false, ): (input: UndiciRequestInfo, init?: UndiciRequestInit) => Promise { const basePort = baseUrl ? getUrlPort(baseUrl) : ''; const ssrfConnect = this.useSSRFProtection @@ -587,6 +933,7 @@ export class MCPConnection extends EventEmitter { const connectOpts = ssrfConnect != null ? { connect: ssrfConnect } : {}; /** Capture only the fields needed by the fetch closure; see factory note above. */ const agents = this.agents; + const logPrefix = this.getLogPrefix(); const effectiveTimeout = timeout || DEFAULT_TIMEOUT; const postAgent = new Agent({ bodyTimeout: effectiveTimeout, @@ -664,18 +1011,27 @@ export class MCPConnection extends EventEmitter { let currentInit = buildFetchInit(resolvedInit, dispatcher, requestHeaders); let currentUrlString = urlString; const originalOrigin = new URL(currentUrlString).origin; - for (let redirects = 0; ; redirects++) { const response = await undiciFetch(currentUrlString, currentInit); const isMethodPreservingRedirect = response.status === 307 || response.status === 308; + const responseContext = { + logPrefix, + method: (currentInit?.method ?? 'GET').toUpperCase(), + url: currentUrlString, + requestIds: getJSONRPCRequestIds(currentInit?.body), + }; if (!isMethodPreservingRedirect || redirects >= MAX_REDIRECTS) { - return response; + return guardStreamableHTTPResponses + ? guardMCPStreamableHTTPResponse(response, responseContext) + : response; } const location = response.headers.get('location'); if (!location) { - return response; + return guardStreamableHTTPResponses + ? guardMCPStreamableHTTPResponse(response, responseContext) + : response; } const targetUrl = new URL(location, currentUrlString); @@ -904,6 +1260,7 @@ export class MCPConnection extends EventEmitter { this.sseReadTimeout || DEFAULT_SSE_READ_TIMEOUT, httpConfiguredSecretHeaderKeys, options.url, + true, ) as unknown as FetchLike, }); From f34150e8e89dc8981a3e8c1e48da0c80ca990886 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 21 May 2026 09:06:02 -0400 Subject: [PATCH 047/395] =?UTF-8?q?=F0=9F=A7=B5=20chore:=20Raise=20MCP=20S?= =?UTF-8?q?SE=20Line=20Default=20(#13224)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 4 +-- .../mcp/__tests__/MCPConnectionSSRF.test.ts | 27 +++++++++++++++++++ packages/api/src/mcp/connection.ts | 2 +- 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 1aa95fd5bea..bf992b67cce 100644 --- a/.env.example +++ b/.env.example @@ -898,8 +898,8 @@ OPENWEATHER_API_KEY= # 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: 1048576 (1 MiB) -# MCP_STREAMABLE_HTTP_MAX_LINE_BYTES=1048576 +# 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 diff --git a/packages/api/src/mcp/__tests__/MCPConnectionSSRF.test.ts b/packages/api/src/mcp/__tests__/MCPConnectionSSRF.test.ts index fcc6b72dcf7..60de0db5e4a 100644 --- a/packages/api/src/mcp/__tests__/MCPConnectionSSRF.test.ts +++ b/packages/api/src/mcp/__tests__/MCPConnectionSSRF.test.ts @@ -1185,6 +1185,33 @@ describe('MCP SSRF protection – customFetch input shapes', () => { } }); + it('should allow SSE lines above the old 1 MiB default when no line override is set', async () => { + delete process.env.MCP_STREAMABLE_HTTP_MAX_LINE_BYTES; + const payload = 'x'.repeat(2 * 1024 * 1024); + const server = await createRawResponseServer((_req, res) => { + res.writeHead(200, { 'Content-Type': 'text/event-stream' }); + res.end(`data: ${payload}\n\n`); + }); + try { + conn = new MCPConnection({ + serverName: 'customfetch-sse-default-line-limit', + serverConfig: { type: 'streamable-http', url: server.url }, + useSSRFProtection: false, + }); + + const customFetch = getGuardedStreamableHTTPCustomFetch(conn); + const response = await customFetch(server.url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', method: 'notifications/cancelled' }), + }); + + await expect(response.text()).resolves.toContain(payload.slice(0, 128)); + } finally { + await server.close(); + } + }); + it('should fail an actual streamable HTTP tool call promptly with a clear oversized SSE line error', async () => { process.env.MCP_STREAMABLE_HTTP_MAX_LINE_BYTES = '512'; target = await createOversizedToolResultStreamableServer(2048); diff --git a/packages/api/src/mcp/connection.ts b/packages/api/src/mcp/connection.ts index 3eddf26a917..0bb4c033d4a 100644 --- a/packages/api/src/mcp/connection.ts +++ b/packages/api/src/mcp/connection.ts @@ -75,7 +75,7 @@ const DEFAULT_INIT_TIMEOUT = 30000; /** Max 307/308 redirects to follow per request (prevents redirect loops) */ const MAX_REDIRECTS = 5; const DEFAULT_MCP_STREAMABLE_HTTP_MAX_RESPONSE_BYTES = 16 * 1024 * 1024; -const DEFAULT_MCP_STREAMABLE_HTTP_MAX_LINE_BYTES = 1024 * 1024; +const DEFAULT_MCP_STREAMABLE_HTTP_MAX_LINE_BYTES = 5 * 1024 * 1024; function getNonNegativeIntegerEnv(name: string, defaultValue: number): number { const raw = process.env[name]; From c345fd6bdb42ab191581ef665e38a1fffa346107 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 21 May 2026 13:52:31 -0400 Subject: [PATCH 048/395] =?UTF-8?q?=F0=9F=8C=8D=20i18n:=20Update=20transla?= =?UTF-8?q?tion.json=20with=20latest=20translations=20(#13230)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/locales/lv/translation.json | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/client/src/locales/lv/translation.json b/client/src/locales/lv/translation.json index 5a5857f9d2a..689a3c82538 100644 --- a/client/src/locales/lv/translation.json +++ b/client/src/locales/lv/translation.json @@ -90,6 +90,8 @@ "com_agents_not_available": "Aģents nav pieejams", "com_agents_recommended": "Mūsu rekomendētie aģenti", "com_agents_results_for": "Rezultāti par '{{query}}'", + "com_agents_run_code_files": "Tālāk esošie faili ir paredzēti tikai koda videi:", + "com_agents_run_code_info": "Ja iespējots, ļauj aģentam droši palaist ģenerēto kodu, tostarp failu apstrādi.", "com_agents_search_aria": "Meklēt aģentus", "com_agents_search_empty_heading": "Nav meklēšanas rezultātu", "com_agents_search_info": "Ja šī opcija ir iespējota, jūsu aģents var meklēt jaunāko informāciju tīmeklī. Nepieciešama derīga API atslēga.", @@ -1088,6 +1090,7 @@ "com_ui_hide": "Paslēpt", "com_ui_hide_code": "Slēpt kodu", "com_ui_hide_image_details": "Slēpt attēla detaļas", + "com_ui_hide_n_files": "Paslēpt {{0}} Failus", "com_ui_hide_password": "Paslēpt paroli", "com_ui_hide_qr": "Slēpt QR kodu", "com_ui_high": "Augsts", @@ -1236,6 +1239,7 @@ "com_ui_more_info": "Vairāk informācijas", "com_ui_my_prompts": "Manas uzvednes", "com_ui_my_skills": "Manas prasmes", + "com_ui_n_files": "{{0}} faili", "com_ui_name": "Vārds", "com_ui_name_sort": "Kārtot pēc nosaukuma", "com_ui_navigate_results": "Pārskatīt rezultātus", @@ -1304,6 +1308,7 @@ "com_ui_permissions_failed_update": "Neizdevās atjaunināt pieejas tiesības. Lūdzu, mēģiniet vēlreiz.", "com_ui_permissions_updated_success": "Pieejas tiesības ir veiksmīgi atjauninātas.", "com_ui_pin": "Piespraust", + "com_ui_plus_n_more": "+{{0}} vairāk", "com_ui_preferences_updated": "Preferences veiksmīgi atjauninātas", "com_ui_prev": "Iepriekšējais", "com_ui_prev_result": "Iepriekšējais rezultāts", @@ -1472,6 +1477,7 @@ "com_ui_show_image_details": "Rādīt attēla detaļas", "com_ui_show_less": "Rādīt mazāk", "com_ui_show_more": "Rādīt vairāk", + "com_ui_show_n_files": "Rādīt {{0}} failus", "com_ui_show_password": "Rādīt paroli", "com_ui_show_qr": "Rādīt QR kodu", "com_ui_sign_in_to_domain": "Pierakstīties {{0}}", @@ -1523,8 +1529,10 @@ "com_ui_skill_upload_drag": "Velciet un nometiet vai noklikšķiniet, lai augšupielādētu", "com_ui_skill_upload_file": "Augšupielādēt Failu", "com_ui_skill_upload_req_md": ".md failā jābūt prasmes nosaukumam un aprakstam, kas formatēts YAML", + "com_ui_skill_upload_req_size": "Faila lielums nedrīkst pārsniegt {{0}} MB", "com_ui_skill_upload_req_zip": ".zip vai .skill failā jābūt SKILL.md failam", "com_ui_skill_upload_requirements": "Failu prasības", + "com_ui_skill_upload_size_error": "Prasmju imports nedrīkst pārsniegt {{0}} MB", "com_ui_skill_upload_title": "Augšupielādes prasme", "com_ui_skill_version": "v{{0}}", "com_ui_skill_view_rendered": "Skatīt atveidoto", @@ -1641,6 +1649,7 @@ "com_ui_upload_agent_avatar": "Aģenta avatars veiksmīgi atjaunināts", "com_ui_upload_agent_avatar_label": "Augšupielādēt aģenta avatāra attēlu", "com_ui_upload_avatar_label": "Augšupielādēt avatāra attēlu", + "com_ui_upload_code_environment": "Augšupielādēt Code vidē", "com_ui_upload_delay": "Augšupielāde \"{{0}}\" aizņem vairāk laika nekā paredzēts. Lūdzu, uzgaidiet, kamēr faila indeksēšana ir pabeigta izguvei.", "com_ui_upload_error": "Augšupielādējot failu, radās kļūda.", "com_ui_upload_file_context": "Augšupielādēt failu kā kontekstu", From cbdfe4614bb19efc7a717902672c7039008e8124 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 21 May 2026 14:17:55 -0400 Subject: [PATCH 049/395] =?UTF-8?q?=F0=9F=8F=83=20fix:=20Improve=20OpenID?= =?UTF-8?q?=20Lookup=20Planning=20(#13229)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: improve OpenID lookup planning * fix: add issuer-bound source id index * fix: align OpenID source id index * fix: preserve admin refresh recency --- api/strategies/openIdJwtStrategy.spec.js | 24 +- packages/api/src/auth/openid.spec.ts | 236 ++++++++++++++---- packages/api/src/auth/openid.ts | 76 ++++-- packages/api/src/auth/refresh.spec.ts | 62 ++++- packages/api/src/auth/refresh.ts | 47 +++- .../src/methods/user.methods.spec.ts | 14 ++ packages/data-schemas/src/schema/user.ts | 1 + 7 files changed, 373 insertions(+), 87 deletions(-) diff --git a/api/strategies/openIdJwtStrategy.spec.js b/api/strategies/openIdJwtStrategy.spec.js index 59229a3d159..e45499734bc 100644 --- a/api/strategies/openIdJwtStrategy.spec.js +++ b/api/strategies/openIdJwtStrategy.spec.js @@ -339,7 +339,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 +348,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 +362,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/packages/api/src/auth/openid.spec.ts b/packages/api/src/auth/openid.spec.ts index deaba8c5c04..975e736b720 100644 --- a/packages/api/src/auth/openid.spec.ts +++ b/packages/api/src/auth/openid.spec.ts @@ -1,7 +1,10 @@ -import { Types } from 'mongoose'; -import { logger } from '@librechat/data-schemas'; +import mongoose, { Types } from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { logger, createMethods, createModels } from '@librechat/data-schemas'; import { ErrorTypes } from 'librechat-data-provider'; import type { IUser, UserMethods } from '@librechat/data-schemas'; +import type { CommandStartedEvent } from 'mongodb'; +import type { FilterQuery } from 'mongoose'; import { findOpenIDUser, getOpenIdEmail, getOpenIdIssuer, normalizeOpenIdIssuer } from './openid'; function newId() { @@ -100,7 +103,8 @@ describe('findOpenIDUser', () => { }); expect(mockFindUser).toHaveBeenCalledWith({ - $or: [{ openidId: 'openid_123', openidIssuer: issuer }], + openidId: 'openid_123', + openidIssuer: issuer, }); expect(result).toEqual({ user: mockUser, @@ -118,7 +122,7 @@ describe('findOpenIDUser', () => { username: 'testuser', } as IUser; - mockFindUser.mockResolvedValueOnce(mockUser); + mockFindUser.mockResolvedValueOnce(null).mockResolvedValueOnce(mockUser); const result = await findOpenIDUser({ openidId: 'openid_123', @@ -127,11 +131,13 @@ describe('findOpenIDUser', () => { idOnTheSource: 'source_123', }); - expect(mockFindUser).toHaveBeenCalledWith({ - $or: [ - { openidId: 'openid_123', openidIssuer: issuer }, - { idOnTheSource: 'source_123', openidIssuer: issuer }, - ], + expect(mockFindUser).toHaveBeenNthCalledWith(1, { + openidId: 'openid_123', + openidIssuer: issuer, + }); + expect(mockFindUser).toHaveBeenNthCalledWith(2, { + idOnTheSource: 'source_123', + openidIssuer: issuer, }); expect(result).toEqual({ user: mockUser, @@ -161,11 +167,10 @@ describe('findOpenIDUser', () => { email: 'user@example.com', }); + expect(mockFindUser).toHaveBeenCalledTimes(1); expect(mockFindUser).toHaveBeenCalledWith({ - $or: [ - { openidId: 'openid_123', openidIssuer: issuer }, - { idOnTheSource: 'source_123', openidIssuer: issuer }, - ], + openidId: 'openid_123', + openidIssuer: issuer, }); expect(result).toEqual({ user: mockUser, @@ -194,7 +199,8 @@ describe('findOpenIDUser', () => { }); expect(mockFindUser).toHaveBeenCalledWith({ - $or: [{ openidId: 'openid_123', openidIssuer: 'https://issuer.example.com' }], + openidId: 'openid_123', + openidIssuer: 'https://issuer.example.com', }); expect(result).toEqual({ user: mockUser, @@ -213,7 +219,7 @@ describe('findOpenIDUser', () => { username: 'testuser', } as IUser; - mockFindUser.mockResolvedValueOnce(mockUser); + mockFindUser.mockResolvedValueOnce(null).mockResolvedValueOnce(mockUser); const result = await findOpenIDUser({ openidId: 'openid_123', @@ -221,18 +227,13 @@ describe('findOpenIDUser', () => { findUser: mockFindUser, }); - expect(mockFindUser).toHaveBeenCalledWith({ - $or: [ - { openidId: 'openid_123', openidIssuer: 'https://issuer.example.com' }, - { - openidId: 'openid_123', - $or: [ - { openidIssuer: { $exists: false } }, - { openidIssuer: null }, - { openidIssuer: '' }, - ], - }, - ], + expect(mockFindUser).toHaveBeenNthCalledWith(1, { + openidId: 'openid_123', + openidIssuer: 'https://issuer.example.com', + }); + expect(mockFindUser).toHaveBeenNthCalledWith(2, { + openidId: 'openid_123', + openidIssuer: { $exists: false }, }); expect(result).toEqual({ user: { ...mockUser, openidIssuer: 'https://issuer.example.com' }, @@ -251,7 +252,7 @@ describe('findOpenIDUser', () => { username: 'testuser', } as IUser; - mockFindUser.mockResolvedValueOnce(mockUser); + mockFindUser.mockResolvedValueOnce(null).mockResolvedValueOnce(mockUser); const result = await findOpenIDUser({ openidId: 'openid_123', @@ -259,18 +260,13 @@ describe('findOpenIDUser', () => { findUser: mockFindUser, }); - expect(mockFindUser).toHaveBeenCalledWith({ - $or: [ - { openidId: 'openid_123', openidIssuer: 'https://issuer.example.com' }, - { - openidId: 'openid_123', - $or: [ - { openidIssuer: { $exists: false } }, - { openidIssuer: null }, - { openidIssuer: '' }, - ], - }, - ], + expect(mockFindUser).toHaveBeenNthCalledWith(1, { + openidId: 'openid_123', + openidIssuer: 'https://issuer.example.com', + }); + expect(mockFindUser).toHaveBeenNthCalledWith(2, { + openidId: 'openid_123', + openidIssuer: { $exists: false }, }); expect(result).toEqual({ user: { ...mockUser, openidIssuer: 'https://issuer.example.com' }, @@ -320,9 +316,12 @@ describe('findOpenIDUser', () => { }); expect(mockFindUser).toHaveBeenNthCalledWith(1, { - $or: [{ openidId: 'openid_123', openidIssuer: issuer }], + openidId: 'openid_123', + openidIssuer: issuer, + }); + expect(mockFindUser).toHaveBeenNthCalledWith(2, { + email: 'user@example.com', }); - expect(mockFindUser).toHaveBeenNthCalledWith(2, { email: 'user@example.com' }); expect(result).toEqual({ user: mockUser, error: null, @@ -361,7 +360,8 @@ describe('findOpenIDUser', () => { expect(mockFindUser).toHaveBeenCalledTimes(1); expect(mockFindUser).toHaveBeenCalledWith({ - $or: [{ openidId: 'openid_123', openidIssuer: issuer }], + openidId: 'openid_123', + openidIssuer: issuer, }); expect(result).toEqual({ user: null, @@ -674,7 +674,8 @@ describe('findOpenIDUser', () => { }); expect(mockFindUser).toHaveBeenCalledWith({ - $or: [{ openidId: 'openid_123', openidIssuer: issuer }], + openidId: 'openid_123', + openidIssuer: issuer, }); expect(result).toEqual({ user: null, @@ -763,6 +764,153 @@ describe('findOpenIDUser', () => { }); }); +type CapturedFindCommand = { + find?: unknown; + filter?: unknown; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function planContainsStage(value: unknown, stage: string): boolean { + if (!isRecord(value)) return false; + if (value.stage === stage) return true; + + return Object.values(value).some((entry) => { + if (Array.isArray(entry)) return entry.some((item) => planContainsStage(item, stage)); + return planContainsStage(entry, stage); + }); +} + +function getTotalDocsExamined(explain: unknown): number | undefined { + if (!isRecord(explain)) return undefined; + const executionStats = explain.executionStats; + if (!isRecord(executionStats)) return undefined; + const totalDocsExamined = executionStats.totalDocsExamined; + return typeof totalDocsExamined === 'number' ? totalDocsExamined : undefined; +} + +describe('findOpenIDUser Mongo compatibility', () => { + let mongoServer: MongoMemoryServer; + let User: mongoose.Model; + let methods: ReturnType; + + const issuer = 'https://issuer.example.com'; + const originalOpenIdIssuer = process.env.OPENID_ISSUER; + + async function seedUsers(count: number) { + await User.insertMany( + Array.from({ length: count }, (_, index) => ({ + email: `filler-${index}@example.com`, + provider: 'openid', + openidId: `filler-sub-${index}`, + openidIssuer: issuer, + idOnTheSource: `filler-oid-${index}`, + })), + ); + } + + async function captureFindFilters(run: () => Promise): Promise<{ + result: T; + filters: unknown[]; + }> { + const filters: unknown[] = []; + const client = mongoose.connection.getClient(); + const listener = (event: CommandStartedEvent) => { + const command = event.command as CapturedFindCommand; + if (event.commandName === 'find' && command.find === User.collection.name) { + filters.push(command.filter); + } + }; + + client.on('commandStarted', listener); + try { + const result = await run(); + return { result, filters }; + } finally { + client.off('commandStarted', listener); + } + } + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri(), { monitorCommands: true }); + createModels(mongoose); + User = mongoose.models.User as mongoose.Model; + methods = createMethods(mongoose); + }); + + afterAll(async () => { + if (originalOpenIdIssuer == null) { + delete process.env.OPENID_ISSUER; + } else { + process.env.OPENID_ISSUER = originalOpenIdIssuer; + } + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + beforeEach(async () => { + process.env.OPENID_ISSUER = issuer; + await mongoose.connection.dropDatabase(); + await User.syncIndexes(); + }); + + it('keeps exact issuer lookup indexable on a seeded user collection', async () => { + await seedUsers(1500); + await User.create({ + email: 'target@example.com', + provider: 'openid', + openidId: 'target-sub', + openidIssuer: issuer, + idOnTheSource: 'target-oid', + }); + + const { result, filters } = await captureFindFilters(() => + findOpenIDUser({ + openidId: 'target-sub', + idOnTheSource: 'target-oid', + openidIssuer: issuer, + findUser: methods.findUser, + }), + ); + + expect(result.user?.email).toBe('target@example.com'); + expect(filters).toEqual([{ openidId: 'target-sub', openidIssuer: issuer }]); + + const explain = await User.findOne(filters[0] as FilterQuery).explain('executionStats'); + expect(planContainsStage(explain, 'IXSCAN')).toBe(true); + expect(getTotalDocsExamined(explain)).toBeLessThanOrEqual(1); + }); + + it('resolves legacy issuer-less users without nested or disjunctive filters', async () => { + await User.create({ + email: 'legacy@example.com', + provider: 'openid', + openidId: 'legacy-sub', + idOnTheSource: 'legacy-oid', + }); + + const { result, filters } = await captureFindFilters(() => + findOpenIDUser({ + openidId: 'legacy-sub', + idOnTheSource: 'legacy-oid', + openidIssuer: issuer, + findUser: methods.findUser, + }), + ); + + expect(result.user?.email).toBe('legacy@example.com'); + expect(result.migration).toBe(true); + expect(filters).toEqual([ + { openidId: 'legacy-sub', openidIssuer: issuer }, + { idOnTheSource: 'legacy-oid', openidIssuer: issuer }, + { openidId: 'legacy-sub', openidIssuer: { $exists: false } }, + ]); + }); +}); + describe('getOpenIdEmail', () => { const originalEmailClaim = process.env.OPENID_EMAIL_CLAIM; diff --git a/packages/api/src/auth/openid.ts b/packages/api/src/auth/openid.ts index 89432e6dd79..8ee5cbd517f 100644 --- a/packages/api/src/auth/openid.ts +++ b/packages/api/src/auth/openid.ts @@ -20,6 +20,11 @@ type OpenIdLookupField = 'openidId' | 'idOnTheSource'; type OpenIdUserResolution = { user: IUser | null; error: string | null; migration: boolean }; const OPENID_DISCOVERY_PATH = '/.well-known/openid-configuration'; +const LEGACY_ISSUER_FILTERS: Array['openidIssuer']> = [ + { $exists: false }, + null, + '', +]; export function normalizeOpenIdIssuer(issuer: string | undefined): string | undefined { const normalized = issuer?.trim().replace(/\/+$/, ''); @@ -56,24 +61,68 @@ function isLegacyOpenIdIssuer(openidIssuer: string | undefined): boolean { return openidIssuer != null && loginIssuer != null && openidIssuer === loginIssuer; } +function hasOpenIdLookupValue(value: string | undefined): value is string { + return typeof value === 'string' && value.length > 0; +} + +function getIssuerExactCondition( + field: OpenIdLookupField, + value: string | undefined, + openidIssuer: string | undefined, +): FilterQuery | null { + if (!hasOpenIdLookupValue(value) || !openidIssuer) return null; + return { [field]: value, openidIssuer }; +} + +function getLegacyIssuerConditions( + field: OpenIdLookupField, + value: string | undefined, + openidIssuer: string | undefined, +): FilterQuery[] { + if (!hasOpenIdLookupValue(value) || !isLegacyOpenIdIssuer(openidIssuer)) return []; + return LEGACY_ISSUER_FILTERS.map((issuerFilter) => ({ + [field]: value, + openidIssuer: issuerFilter, + })); +} + export function getIssuerBoundConditions( field: OpenIdLookupField, value: string | undefined, openidIssuer: string | undefined, ): FilterQuery[] { - if (!value || typeof value !== 'string') return []; - if (!openidIssuer) return []; + const exactCondition = getIssuerExactCondition(field, value, openidIssuer); + if (!exactCondition) return []; + return [exactCondition, ...getLegacyIssuerConditions(field, value, openidIssuer)]; +} - const conditions: FilterQuery[] = [{ [field]: value, openidIssuer }]; +function getPrimaryLookupConditions( + openidId: string | undefined, + idOnTheSource: string | undefined, + openidIssuer: string | undefined, +): FilterQuery[] { + const exactConditions = [ + getIssuerExactCondition('openidId', openidId, openidIssuer), + getIssuerExactCondition('idOnTheSource', idOnTheSource, openidIssuer), + ].filter((condition): condition is FilterQuery => condition != null); + + return [ + ...exactConditions, + ...getLegacyIssuerConditions('openidId', openidId, openidIssuer), + ...getLegacyIssuerConditions('idOnTheSource', idOnTheSource, openidIssuer), + ]; +} - if (isLegacyOpenIdIssuer(openidIssuer)) { - conditions.push({ - [field]: value, - $or: [{ openidIssuer: { $exists: false } }, { openidIssuer: null }, { openidIssuer: '' }], - }); +async function findFirstOpenIdUser( + findUser: UserMethods['findUser'], + conditions: FilterQuery[], +): Promise { + for (const condition of conditions) { + const user = await findUser(condition); + if (user) return user; } - return conditions; + return null; } export function isUserIssuerAllowed(user: IUser, openidIssuer: string | undefined): boolean { @@ -160,14 +209,11 @@ export async function findOpenIDUser({ strategyName?: string; }): Promise { const normalizedIssuer = normalizeOpenIdIssuer(openidIssuer); - const primaryConditions = [ - ...getIssuerBoundConditions('openidId', openidId, normalizedIssuer), - ...getIssuerBoundConditions('idOnTheSource', idOnTheSource, normalizedIssuer), - ]; + const primaryConditions = getPrimaryLookupConditions(openidId, idOnTheSource, normalizedIssuer); - let user = null; + let user: IUser | null = null; if (primaryConditions.length > 0) { - user = await findUser({ $or: primaryConditions }); + user = await findFirstOpenIdUser(findUser, primaryConditions); } const primaryIssuerResolution = resolveIssuerBoundUser( diff --git a/packages/api/src/auth/refresh.spec.ts b/packages/api/src/auth/refresh.spec.ts index aa7ff8ebd90..683b40e4f82 100644 --- a/packages/api/src/auth/refresh.spec.ts +++ b/packages/api/src/auth/refresh.spec.ts @@ -19,6 +19,7 @@ const SUB = 'idp-sub-12345'; const ORIGINAL_OPENID_SCOPE = process.env.OPENID_SCOPE; const ORIGINAL_OPENID_REFRESH_AUDIENCE = process.env.OPENID_REFRESH_AUDIENCE; +const ORIGINAL_OPENID_ISSUER = process.env.OPENID_ISSUER; function makeUser(overrides: Partial = {}): IUser { const _id = overrides._id ?? new Types.ObjectId(); @@ -75,6 +76,12 @@ describe('buildOpenIDRefreshParams', () => { } else { process.env.OPENID_REFRESH_AUDIENCE = ORIGINAL_OPENID_REFRESH_AUDIENCE; } + + if (ORIGINAL_OPENID_ISSUER === undefined) { + delete process.env.OPENID_ISSUER; + } else { + process.env.OPENID_ISSUER = ORIGINAL_OPENID_ISSUER; + } }); it('returns scope-only params when OPENID_SCOPE is set', () => { @@ -296,10 +303,9 @@ describe('applyAdminRefresh', () => { }); const [filter] = (deps.findUsers as jest.Mock).mock.calls[0]; - expect(filter).toMatchObject({ - $or: expect.arrayContaining([ - { openidId: SUB, openidIssuer: 'https://issuer.example.com' }, - ]), + expect(filter).toEqual({ + openidId: SUB, + openidIssuer: 'https://issuer.example.com', }); }); @@ -316,6 +322,54 @@ describe('applyAdminRefresh', () => { ); }); + it('selects the most recently updated user across legacy issuer fallback filters', async () => { + const previousOpenIDIssuer = process.env.OPENID_ISSUER; + process.env.OPENID_ISSUER = 'https://issuer.example.com'; + try { + const olderMissingIssuer = makeUser({ + email: 'older@example.com', + openidIssuer: undefined, + updatedAt: new Date('2026-01-01T00:00:00.000Z'), + }); + const newerEmptyIssuer = makeUser({ + email: 'newer@example.com', + openidIssuer: '', + updatedAt: new Date('2026-02-01T00:00:00.000Z'), + }); + const findUsers = jest + .fn() + .mockImplementation(async (filter: { openidIssuer?: unknown }) => { + if (filter.openidIssuer === '') return [newerEmptyIssuer]; + if ( + typeof filter.openidIssuer === 'object' && + filter.openidIssuer != null && + '$exists' in filter.openidIssuer + ) { + return [olderMissingIssuer]; + } + return []; + }); + const deps = makeDeps(undefined, { findUsers }); + const tokenset = makeTokenset({ + claims: () => ({ sub: SUB, iss: 'https://issuer.example.com' }), + }); + + const result = await applyAdminRefresh(tokenset, deps, { + expectedIssuer: 'https://issuer.example.com', + }); + + expect(result.user.email).toBe('newer@example.com'); + expect(deps.mintToken).toHaveBeenCalledWith(newerEmptyIssuer, tokenset); + expect(findUsers).toHaveBeenCalledTimes(4); + } finally { + if (previousOpenIDIssuer === undefined) { + delete process.env.OPENID_ISSUER; + } else { + process.env.OPENID_ISSUER = previousOpenIDIssuer; + } + } + }); + it('throws USER_ID_MISMATCH when direct user_id resolves but issuer differs', async () => { const id = new Types.ObjectId(); const user = makeUser({ _id: id, openidIssuer: 'https://other-issuer.example.com' }); diff --git a/packages/api/src/auth/refresh.ts b/packages/api/src/auth/refresh.ts index c1b2f101cfc..2ae5ca0f0ae 100644 --- a/packages/api/src/auth/refresh.ts +++ b/packages/api/src/auth/refresh.ts @@ -133,6 +133,38 @@ export function buildOpenIDRefreshParams(): OpenIDRefreshParams { return params; } +function applyTenantFilter( + filter: FilterQuery, + tenantId: string | undefined, +): FilterQuery { + return tenantId ? ({ ...filter, tenantId } as FilterQuery) : filter; +} + +async function findLatestAdminUser( + deps: AdminRefreshDeps, + filters: FilterQuery[], + tenantId: string | undefined, +): Promise { + let latestUser: IUser | undefined; + + for (const baseFilter of filters) { + const [user] = await deps.findUsers( + applyTenantFilter(baseFilter, tenantId), + SAFE_USER_PROJECTION, + { + sort: { updatedAt: -1 }, + limit: 1, + }, + ); + if (!user) continue; + if (!latestUser || (user.updatedAt?.getTime() ?? 0) > (latestUser.updatedAt?.getTime() ?? 0)) { + latestUser = user; + } + } + + return latestUser; +} + async function resolveAdminUser( deps: AdminRefreshDeps, openidId: string, @@ -173,17 +205,10 @@ async function resolveAdminUser( } const issuerBound = getIssuerBoundConditions('openidId', openidId, normalizedIssuer); - const baseFilter: FilterQuery = - issuerBound.length > 0 ? { $or: issuerBound } : ({ openidId } as FilterQuery); - const filter: FilterQuery = expectedTenantId - ? ({ ...baseFilter, tenantId: expectedTenantId } as FilterQuery) - : baseFilter; - - const [user] = await deps.findUsers(filter, SAFE_USER_PROJECTION, { - sort: { updatedAt: -1 }, - limit: 1, - }); - return user; + const filters = + issuerBound.length > 0 ? issuerBound : ([{ openidId }] as Array>); + + return findLatestAdminUser(deps, filters, expectedTenantId); } function readClaims(tokenset: RefreshTokenset): AdminRefreshClaims { diff --git a/packages/data-schemas/src/methods/user.methods.spec.ts b/packages/data-schemas/src/methods/user.methods.spec.ts index 9298447198b..8869b2006e2 100644 --- a/packages/data-schemas/src/methods/user.methods.spec.ts +++ b/packages/data-schemas/src/methods/user.methods.spec.ts @@ -38,6 +38,20 @@ beforeEach(async () => { }); describe('User schema indexes', () => { + test('should define an issuer-bound idOnTheSource lookup index', async () => { + await User.syncIndexes(); + + const indexes = await User.collection.indexes(); + + expect(indexes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: { idOnTheSource: 1, openidIssuer: 1, tenantId: 1 }, + }), + ]), + ); + }); + test('should allow the same OpenID subject from different issuers', async () => { await User.syncIndexes(); diff --git a/packages/data-schemas/src/schema/user.ts b/packages/data-schemas/src/schema/user.ts index 3e2756c3d40..f43319642d0 100644 --- a/packages/data-schemas/src/schema/user.ts +++ b/packages/data-schemas/src/schema/user.ts @@ -168,6 +168,7 @@ const userSchema = new Schema( userSchema.index({ email: 1, tenantId: 1 }, { unique: true }); userSchema.index({ role: 1, tenantId: 1 }); +userSchema.index({ idOnTheSource: 1, openidIssuer: 1, tenantId: 1 }); const oAuthIdFields = [ 'googleId', From 12a44120f88e7b9894d9c15dac9d2ee667c1de82 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 21 May 2026 14:18:34 -0400 Subject: [PATCH 050/395] =?UTF-8?q?=E2=9A=A1=20feat:=20Add=20Gemini=203.5?= =?UTF-8?q?=20Flash=20Support=20(#13231)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Gemini 3.5 Flash support * fix: refine Gemini 3.5 Flash overrides * fix: satisfy Gemini thinking config types * fix: drop empty Gemini thinking config --- api/utils/tokens.spec.js | 3 + packages/api/src/endpoints/google/llm.spec.ts | 144 ++++++++++++++++++ packages/api/src/endpoints/google/llm.ts | 89 ++++++++++- packages/api/src/utils/tokens.ts | 1 + packages/data-provider/src/config.ts | 2 + packages/data-provider/src/schemas.ts | 2 +- packages/data-schemas/src/methods/tx.spec.ts | 18 +++ packages/data-schemas/src/methods/tx.ts | 3 + 8 files changed, 260 insertions(+), 2 deletions(-) diff --git a/api/utils/tokens.spec.js b/api/utils/tokens.spec.js index 143d3bb2462..ceb44c36202 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'], ); diff --git a/packages/api/src/endpoints/google/llm.spec.ts b/packages/api/src/endpoints/google/llm.spec.ts index edfa74f0442..6443939a9b9 100644 --- a/packages/api/src/endpoints/google/llm.spec.ts +++ b/packages/api/src/endpoints/google/llm.spec.ts @@ -628,6 +628,150 @@ describe('getGoogleConfig', () => { }); }); + it('should default Gemini 3.5 Flash to medium thinkingLevel', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-3.5-flash', + }, + }); + + expect((result.llmConfig as Record).thinkingConfig).toMatchObject({ + includeThoughts: true, + thinkingLevel: 'MEDIUM', + }); + }); + + it('should preserve explicit Gemini 3.5 Flash thinkingLevel', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-3.5-flash', + thinkingLevel: ThinkingLevel.low, + }, + }); + + expect((result.llmConfig as Record).thinkingConfig).toMatchObject({ + includeThoughts: true, + thinkingLevel: 'LOW', + }); + }); + + it('should apply Gemini 3.5 Flash overrides to versioned aliases', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'google/gemini-3.5-flash-latest', + temperature: 0.7, + }, + }); + + expect(result.llmConfig).not.toHaveProperty('temperature'); + expect((result.llmConfig as Record).thinkingConfig).toMatchObject({ + includeThoughts: true, + thinkingLevel: 'MEDIUM', + }); + }); + + it('should remove legacy sampling params for Gemini 3.5 Flash', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const modelOptions = { + model: 'gemini-3.5-flash', + temperature: 0.7, + topP: 0.9, + topK: 40, + top_p: 0.9, + top_k: 40, + thinking_budget: 5000, + } as unknown as t.GoogleParameters; + + const result = getGoogleConfig(credentials, { + modelOptions, + defaultParams: { + temperature: 0.5, + topP: 0.8, + topK: 20, + }, + addParams: { + temperature: 0.2, + topP: 0.6, + topK: 10, + }, + }); + + expect(result.llmConfig).not.toHaveProperty('temperature'); + expect(result.llmConfig).not.toHaveProperty('topP'); + expect(result.llmConfig).not.toHaveProperty('topK'); + expect(result.llmConfig).not.toHaveProperty('top_p'); + expect(result.llmConfig).not.toHaveProperty('top_k'); + expect(result.llmConfig).not.toHaveProperty('thinking_budget'); + }); + + it('should respect dropParams for Gemini 3.5 Flash thinkingConfig', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-3.5-flash', + }, + dropParams: ['thinkingConfig'], + }); + + expect(result.llmConfig).not.toHaveProperty('thinkingConfig'); + }); + + it('should respect dropParams for Gemini 3.5 Flash includeThoughts', () => { + const credentials = { + [AuthKeys.GOOGLE_SERVICE_KEY]: { + project_id: 'test-project', + }, + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-3.5-flash', + }, + dropParams: ['includeThoughts'], + }); + + expect(result.llmConfig).not.toHaveProperty('includeThoughts'); + expect((result.llmConfig as Record).thinkingConfig).toMatchObject({ + thinkingLevel: 'MEDIUM', + }); + expect((result.llmConfig as Record).thinkingConfig).not.toHaveProperty( + 'includeThoughts', + ); + }); + + it('should remove empty Gemini 3.5 Flash thinkingConfig when all fields are dropped', () => { + const credentials = { + [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', + }; + + const result = getGoogleConfig(credentials, { + modelOptions: { + model: 'gemini-3.5-flash', + }, + dropParams: ['includeThoughts', 'thinkingLevel'], + }); + + expect(result.llmConfig).not.toHaveProperty('thinkingConfig'); + }); + it('should omit thinkingLevel when unset (empty string) for Gemini 3', () => { const credentials = { [AuthKeys.GOOGLE_API_KEY]: 'test-api-key', diff --git a/packages/api/src/endpoints/google/llm.ts b/packages/api/src/endpoints/google/llm.ts index 460dc742524..e9133e60b36 100644 --- a/packages/api/src/endpoints/google/llm.ts +++ b/packages/api/src/endpoints/google/llm.ts @@ -7,10 +7,22 @@ import { isEnabled } from '~/utils'; type GoogleThinkingLevel = 'THINKING_LEVEL_UNSPECIFIED' | 'MINIMAL' | 'LOW' | 'MEDIUM' | 'HIGH'; type GoogleThinkingConfig = { - includeThoughts: boolean; + includeThoughts?: boolean; thinkingLevel?: GoogleThinkingLevel; }; +const GEMINI_3_5_FLASH = 'gemini-3.5-flash'; +const GEMINI_3_5_FLASH_DEFAULT_THINKING_LEVEL: GoogleThinkingLevel = 'MEDIUM'; +const gemini35FlashLegacyParams = [ + 'temperature', + 'topP', + 'topK', + 'top_p', + 'top_k', + 'thinkingBudget', + 'thinking_budget', +] as const; + const googleThinkingLevels = new Set([ 'THINKING_LEVEL_UNSPECIFIED', 'MINIMAL', @@ -116,6 +128,12 @@ function normalizeGoogleThinkingLevel(value: unknown): GoogleThinkingLevel | und return normalized; } +function isGemini35Flash(model: string) { + const normalized = model.toLowerCase(); + const modelId = normalized.split('/').pop() ?? normalized; + return modelId === GEMINI_3_5_FLASH || modelId.startsWith(`${GEMINI_3_5_FLASH}-`); +} + function getVertexMultiRegionEndpoint(location: string): string | undefined { return vertexMultiRegionEndpoints.get(location); } @@ -128,6 +146,68 @@ function sanitizeModelOptions(modelOptions: Partial | undefi return sanitizedOptions; } +function applyGemini35FlashOverrides({ + config, + provider, + thinking, + dropParams, +}: { + config: GoogleClientOptions | VertexAIClientOptions; + provider: Providers; + thinking: boolean; + dropParams?: string[]; +}) { + const mutableConfig = config as Record; + const model = mutableConfig.model; + if (typeof model !== 'string' || !isGemini35Flash(model)) { + return; + } + + gemini35FlashLegacyParams.forEach((param) => { + delete mutableConfig[param]; + }); + + if (!thinking) { + return; + } + + const droppedParams = new Set(dropParams ?? []); + if (droppedParams.has('thinkingConfig')) { + return; + } + + const shouldDropIncludeThoughts = droppedParams.has('includeThoughts'); + const shouldDropThinkingLevel = droppedParams.has('thinkingLevel'); + const configWithThinking = config as { thinkingConfig?: GoogleThinkingConfig }; + const thinkingConfig: GoogleThinkingConfig = { ...(configWithThinking.thinkingConfig ?? {}) }; + + if (shouldDropIncludeThoughts) { + delete thinkingConfig.includeThoughts; + } + + if (shouldDropThinkingLevel) { + delete thinkingConfig.thinkingLevel; + } + + if (!shouldDropIncludeThoughts && thinkingConfig.includeThoughts == null) { + thinkingConfig.includeThoughts = true; + } + + if (!shouldDropThinkingLevel && !thinkingConfig.thinkingLevel) { + thinkingConfig.thinkingLevel = GEMINI_3_5_FLASH_DEFAULT_THINKING_LEVEL; + } + + if (Object.keys(thinkingConfig).length > 0) { + configWithThinking.thinkingConfig = thinkingConfig; + } else { + delete configWithThinking.thinkingConfig; + } + + if (provider === Providers.VERTEXAI && !shouldDropIncludeThoughts) { + (config as VertexAIClientOptions).includeThoughts = true; + } +} + function isAllowedVertexEndpoint(endpoint: string): boolean { if (!/^[a-z0-9][a-z0-9.-]*[a-z0-9]$/.test(endpoint)) { return false; @@ -453,6 +533,13 @@ export function getGoogleConfig( }); } + applyGemini35FlashOverrides({ + config: llmConfig, + provider, + thinking, + dropParams: Array.isArray(options.dropParams) ? options.dropParams : undefined, + }); + if (provider === Providers.VERTEXAI && shouldSyncVertexEndpoint && !hasCustomVertexEndpoint) { applyVertexMultiRegionEndpoint(llmConfig as VertexAIClientOptions & { endpoint?: string }); } diff --git a/packages/api/src/utils/tokens.ts b/packages/api/src/utils/tokens.ts index 75a579eb40b..019a0f1366f 100644 --- a/packages/api/src/utils/tokens.ts +++ b/packages/api/src/utils/tokens.ts @@ -117,6 +117,7 @@ const googleModels = { 'gemini-3-pro-image': 1000000, 'gemini-3.1': 1000000, 'gemini-3.1-flash-lite': 1000000, + 'gemini-3.5-flash': 1048576, }; const anthropicModels = { diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index f5a2be1fe9e..077c72757e5 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1595,6 +1595,8 @@ export const defaultModels = { [EModelEndpoint.assistants]: [...sharedOpenAIModels, 'chatgpt-4o-latest'], [EModelEndpoint.agents]: sharedOpenAIModels, // TODO: Add agent models (agentsModels) [EModelEndpoint.google]: [ + // Gemini 3.5 Models + 'gemini-3.5-flash', // Gemini 3.1 Models 'gemini-3.1-pro-preview', 'gemini-3.1-pro-preview-customtools', diff --git a/packages/data-provider/src/schemas.ts b/packages/data-provider/src/schemas.ts index 7a8d7e47b7b..7fa1826e590 100644 --- a/packages/data-provider/src/schemas.ts +++ b/packages/data-provider/src/schemas.ts @@ -372,7 +372,7 @@ export const googleSettings = { }, maxOutputTokens: { min: 1 as const, - max: 64000 as const, + max: 65536 as const, step: 1 as const, default: 8192 as const, }, diff --git a/packages/data-schemas/src/methods/tx.spec.ts b/packages/data-schemas/src/methods/tx.spec.ts index 51968b81e76..9ca5e2a7102 100644 --- a/packages/data-schemas/src/methods/tx.spec.ts +++ b/packages/data-schemas/src/methods/tx.spec.ts @@ -1497,6 +1497,7 @@ describe('Google Model Tests', () => { 'gemini-3.1-pro-preview', 'gemini-3.1-pro-preview-customtools', 'gemini-3.1-flash-lite-preview', + 'gemini-3.5-flash', 'gemini-2.5-pro', 'gemini-2.5-flash', 'gemini-2.5-flash-lite', @@ -1544,6 +1545,7 @@ describe('Google Model Tests', () => { 'gemini-3.1-pro-preview': 'gemini-3.1', 'gemini-3.1-pro-preview-customtools': 'gemini-3.1', 'gemini-3.1-flash-lite-preview': 'gemini-3.1-flash-lite', + 'gemini-3.5-flash': 'gemini-3.5-flash', 'gemini-2.5-pro': 'gemini-2.5-pro', 'gemini-2.5-flash': 'gemini-2.5-flash', 'gemini-2.5-flash-lite': 'gemini-2.5-flash-lite', @@ -1645,6 +1647,22 @@ describe('Google Model Tests', () => { cacheTokenValues['gemini-3.1-flash-lite'].read, ); }); + + it('should return correct rates for Gemini 3.5 Flash', () => { + const model = 'gemini-3.5-flash'; + expect(getMultiplier({ model, tokenType: 'prompt', endpoint: EModelEndpoint.google })).toBe( + tokenValues['gemini-3.5-flash'].prompt, + ); + expect(getMultiplier({ model, tokenType: 'completion', endpoint: EModelEndpoint.google })).toBe( + tokenValues['gemini-3.5-flash'].completion, + ); + expect(getCacheMultiplier({ model, cacheType: 'write' })).toBe( + cacheTokenValues['gemini-3.5-flash'].write, + ); + expect(getCacheMultiplier({ model, cacheType: 'read' })).toBe( + cacheTokenValues['gemini-3.5-flash'].read, + ); + }); }); describe('Gemini 3.1 Premium Token Pricing', () => { diff --git a/packages/data-schemas/src/methods/tx.ts b/packages/data-schemas/src/methods/tx.ts index 3a5cd8e11c8..db7d5392308 100644 --- a/packages/data-schemas/src/methods/tx.ts +++ b/packages/data-schemas/src/methods/tx.ts @@ -186,6 +186,7 @@ export const tokenValues: Record 'gemini-3-pro-image': { prompt: 2, completion: 120 }, 'gemini-3.1': { prompt: 2, completion: 12 }, 'gemini-3.1-flash-lite': { prompt: 0.25, completion: 1.5 }, + 'gemini-3.5-flash': { prompt: 1.5, completion: 9 }, 'gemini-pro-vision': { prompt: 0.5, completion: 1.5 }, grok: { prompt: 2.0, completion: 10.0 }, 'grok-beta': { prompt: 5.0, completion: 15.0 }, @@ -329,6 +330,8 @@ export const cacheTokenValues: Record = 'gemini-3.1': { write: 2, read: 0.2 }, // Gemini 3.1 Flash-Lite - cache write: $0.25/1M, cache read: $0.025/1M 'gemini-3.1-flash-lite': { write: 0.25, read: 0.025 }, + // Gemini 3.5 Flash - cache write: $1.50/1M, cache read: $0.15/1M + 'gemini-3.5-flash': { write: 1.5, read: 0.15 }, }; /** From d7482ebe06d29f3822ae301e40f27c3a37c83ffc Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 21 May 2026 15:11:14 -0400 Subject: [PATCH 051/395] =?UTF-8?q?=F0=9F=A7=A9=20fix:=20Support=20Documen?= =?UTF-8?q?tDB=20Prompt=20Group=20Lookup=20(#13232)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: support DocumentDB prompt group lookup * test: address prompt group review feedback * test: prove prompt group indexed lookup --- .../src/methods/prompt.getPromptGroup.spec.ts | 229 ++++++++++++++++++ packages/data-schemas/src/methods/prompt.ts | 41 ++-- 2 files changed, 244 insertions(+), 26 deletions(-) create mode 100644 packages/data-schemas/src/methods/prompt.getPromptGroup.spec.ts diff --git a/packages/data-schemas/src/methods/prompt.getPromptGroup.spec.ts b/packages/data-schemas/src/methods/prompt.getPromptGroup.spec.ts new file mode 100644 index 00000000000..3d3b6a74bd6 --- /dev/null +++ b/packages/data-schemas/src/methods/prompt.getPromptGroup.spec.ts @@ -0,0 +1,229 @@ +import mongoose from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import type { PipelineStage } from 'mongoose'; +import { SYSTEM_TENANT_ID, tenantStorage } from '~/config/tenantContext'; +import { createModels, logger } from '..'; +import { createMethods } from './index'; + +logger.silent = true; + +type PromptModel = mongoose.Model; +type PromptGroupModel = mongoose.Model; +type Methods = ReturnType; +type MongoExplainStage = Record; + +let mongoServer: MongoMemoryServer; +let Prompt: PromptModel; +let PromptGroup: PromptGroupModel; +let methods: Methods; + +const TENANT_A = 'tenant-a'; +const TENANT_B = 'tenant-b'; + +async function withTenant(tenantId: string | undefined, fn: () => Promise): Promise { + return tenantStorage.run({ tenantId }, async () => fn()); +} + +async function seedGroupAndPrompt(opts: { tenantId?: string; promptTenantId?: string } = {}) { + const author = new mongoose.Types.ObjectId(); + const prompt = await Prompt.create({ + groupId: new mongoose.Types.ObjectId(), + author, + prompt: 'Hello {{name}}', + type: 'text', + tenantId: opts.promptTenantId ?? opts.tenantId, + }); + const group = await PromptGroup.create({ + name: 'Repro Group', + productionId: prompt._id, + author, + authorName: 'Test Author', + tenantId: opts.tenantId, + }); + return { group, prompt }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function collectRecords(value: unknown, predicate: (record: Record) => boolean) { + const matches: Array> = []; + + function visit(candidate: unknown) { + if (Array.isArray(candidate)) { + for (const item of candidate) { + visit(item); + } + return; + } + + if (!isRecord(candidate)) return; + if (predicate(candidate)) { + matches.push(candidate); + } + + for (const child of Object.values(candidate)) { + visit(child); + } + } + + visit(value); + return matches; +} + +function hasIndexedIdPlan(explain: unknown): boolean { + return collectRecords(explain, (record) => { + const stage = record.stage; + if (stage === 'IDHACK' || stage === 'EXPRESS_IXSCAN') return true; + if (stage !== 'IXSCAN') return false; + + const keyPattern = record.keyPattern; + return isRecord(keyPattern) && keyPattern._id === 1; + }).length > 0; +} + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + createModels(mongoose); + Prompt = mongoose.models.Prompt; + PromptGroup = mongoose.models.PromptGroup; + methods = createMethods(mongoose, { + removeAllPermissions: async () => {}, + }); +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); +}); + +afterEach(async () => { + await Prompt.deleteMany({}); + await PromptGroup.deleteMany({}); +}); + +describe('getPromptGroup', () => { + it('returns the group with productionPrompt populated when no tenant context is set', async () => { + const { group } = await seedGroupAndPrompt(); + const result = await methods.getPromptGroup({ _id: group._id }); + + expect(result).not.toBeNull(); + expect(result?.productionPrompt).toBeTruthy(); + expect((result?.productionPrompt as { prompt: string }).prompt).toBe('Hello {{name}}'); + }); + + it('returns the group with productionPrompt populated when tenant matches', async () => { + const { group } = await seedGroupAndPrompt({ tenantId: TENANT_A }); + + const result = await withTenant(TENANT_A, () => methods.getPromptGroup({ _id: group._id })); + + expect(result).not.toBeNull(); + expect(result?.productionPrompt).toBeTruthy(); + expect((result?.productionPrompt as { prompt: string }).prompt).toBe('Hello {{name}}'); + }); + + it('clears productionPrompt when the joined prompt belongs to a different tenant', async () => { + const { group } = await seedGroupAndPrompt({ + tenantId: TENANT_A, + promptTenantId: TENANT_B, + }); + + const result = await withTenant(TENANT_A, () => methods.getPromptGroup({ _id: group._id })); + + expect(result).not.toBeNull(); + expect(result?.productionPrompt).toBeNull(); + }); + + it('returns the group with productionPrompt populated under SYSTEM_TENANT_ID context', async () => { + const { group } = await seedGroupAndPrompt({ + tenantId: TENANT_A, + promptTenantId: TENANT_B, + }); + + const result = await withTenant(SYSTEM_TENANT_ID, () => + methods.getPromptGroup({ _id: group._id }), + ); + + expect(result).not.toBeNull(); + expect(result?.productionPrompt).toBeTruthy(); + expect((result?.productionPrompt as { prompt: string }).prompt).toBe('Hello {{name}}'); + }); + + it('returns null when the group does not exist', async () => { + const missingId = new mongoose.Types.ObjectId(); + const result = await methods.getPromptGroup({ _id: missingId }); + expect(result).toBeNull(); + }); + + it('accepts string _id values (route-handler shape)', async () => { + const { group } = await seedGroupAndPrompt(); + const result = await methods.getPromptGroup({ + _id: (group._id as mongoose.Types.ObjectId).toString(), + }); + expect(result).not.toBeNull(); + expect((result?.productionPrompt as { prompt: string }).prompt).toBe('Hello {{name}}'); + }); + + describe('regression: DocumentDB-incompatible aggregation form', () => { + it('does not use $lookup with let/pipeline (multi-join form unsupported by DocumentDB)', async () => { + const { group } = await seedGroupAndPrompt({ tenantId: TENANT_A }); + const aggregateSpy = jest.spyOn(PromptGroup, 'aggregate'); + + try { + await withTenant(TENANT_A, () => methods.getPromptGroup({ _id: group._id })); + + expect(aggregateSpy).toHaveBeenCalledTimes(1); + const pipeline: PipelineStage[] = aggregateSpy.mock.calls[0][0]; + + for (const stage of pipeline) { + if (!('$lookup' in stage)) continue; + const lookup = stage.$lookup; + expect(lookup.let).toBeUndefined(); + expect(lookup.pipeline).toBeUndefined(); + expect(lookup.localField).toBeDefined(); + expect(lookup.foreignField).toBeDefined(); + } + } finally { + aggregateSpy.mockRestore(); + } + }); + + it('uses indexed _id execution paths for the group match and production prompt lookup', async () => { + const { group } = await seedGroupAndPrompt({ tenantId: TENANT_A }); + const aggregateSpy = jest.spyOn(PromptGroup, 'aggregate'); + + try { + await withTenant(TENANT_A, () => methods.getPromptGroup({ _id: group._id })); + + const pipeline: PipelineStage[] = aggregateSpy.mock.calls[0][0]; + const [matchStage] = pipeline; + expect(matchStage).toEqual({ $match: { _id: group._id } }); + + const lookupStage = pipeline.find((stage) => '$lookup' in stage); + expect(lookupStage).toMatchObject({ + $lookup: { + from: 'prompts', + localField: 'productionId', + foreignField: '_id', + as: 'productionPrompt', + }, + }); + + const explain = await PromptGroup.aggregate(pipeline).explain('executionStats'); + const stages = isRecord(explain) && Array.isArray(explain.stages) ? explain.stages : []; + const lookupExplain = stages.find( + (stage): stage is MongoExplainStage => + isRecord(stage) && isRecord(stage.$lookup) && stage.$lookup.from === 'prompts', + ); + + expect(hasIndexedIdPlan(explain)).toBe(true); + expect(lookupExplain?.indexesUsed).toContain('_id_'); + expect(lookupExplain?.totalDocsExamined).toBeLessThanOrEqual(1); + } finally { + aggregateSpy.mockRestore(); + } + }); + }); +}); diff --git a/packages/data-schemas/src/methods/prompt.ts b/packages/data-schemas/src/methods/prompt.ts index 86d830fecdc..3a0bd352625 100644 --- a/packages/data-schemas/src/methods/prompt.ts +++ b/packages/data-schemas/src/methods/prompt.ts @@ -512,37 +512,26 @@ export function createPromptMethods(mongoose: typeof import('mongoose'), deps: P const tenantId = getTenantId(); const useTenantFilter = tenantId && tenantId !== SYSTEM_TENANT_ID; - const lookupStage = useTenantFilter - ? { - $lookup: { - from: 'prompts', - let: { prodId: '$productionId' }, - pipeline: [ - { - $match: { - $expr: { $eq: ['$_id', '$$prodId'] }, - tenantId, - }, - }, - ], - as: 'productionPrompt', - }, - } - : { - $lookup: { - from: 'prompts', - localField: 'productionId', - foreignField: '_id', - as: 'productionPrompt', - }, - }; - const result = await PromptGroup.aggregate([ { $match: matchFilter }, - lookupStage, + { + $lookup: { + from: 'prompts', + localField: 'productionId', + foreignField: '_id', + as: 'productionPrompt', + }, + }, { $unwind: { path: '$productionPrompt', preserveNullAndEmptyArrays: true } }, ]); const group = result[0] || null; + if ( + group?.productionPrompt && + useTenantFilter && + group.productionPrompt.tenantId !== tenantId + ) { + group.productionPrompt = null; + } if (group?.author) { group.author = group.author.toString(); } From 05a3d1ed810ddcd4ed7d0d71e5fcc1fc14cda04c Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 21 May 2026 15:28:54 -0400 Subject: [PATCH 052/395] =?UTF-8?q?=F0=9F=9B=A3=EF=B8=8F=20feat:=20Add=20M?= =?UTF-8?q?CP=20Remote=20Proxy=20Support=20(#13076)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add MCP remote proxy support * fix: Harden MCP Proxy Review Findings * fix: Honor MCP Proxy Env Precedence * fix: Harden MCP proxy routing * fix: Align MCP proxy bypass semantics * test: Pin MCP proxy admin scope --- .env.example | 2 + librechat.example.yaml | 1 + .../mcp/__tests__/MCPConnectionSSRF.test.ts | 865 ++++++++++++++++++ packages/api/src/mcp/__tests__/mcp.spec.ts | 26 + packages/api/src/mcp/connection.ts | 567 ++++++++++-- packages/api/src/utils/env.spec.ts | 43 + packages/api/src/utils/env.ts | 12 + packages/data-provider/specs/mcp.spec.ts | 59 +- packages/data-provider/src/mcp.ts | 25 + 9 files changed, 1534 insertions(+), 66 deletions(-) diff --git a/.env.example b/.env.example index bf992b67cce..35a2a840431 100644 --- a/.env.example +++ b/.env.example @@ -141,6 +141,8 @@ NODE_MAX_OLD_SPACE_SIZE=6144 # ENDPOINTS=openAI,assistants,azureOpenAI,google,anthropic +# Optional outbound proxy for server-side requests, including remote MCP HTTP/SSE transports. +# Remote MCP transports also honor HTTP_PROXY, HTTPS_PROXY, and NO_PROXY when PROXY is unset. PROXY= #===================================# diff --git a/librechat.example.yaml b/librechat.example.yaml index d3e0d006ccf..58dab0300a8 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -313,6 +313,7 @@ actions: # everything: # # type: sse # type can optionally be omitted # url: http://localhost:3001/sse +# # proxy: "${MCP_PROXY_URL}" # optional outbound proxy (http/https/socks/socks5) # timeout: 60000 # 1 minute timeout for this server, this is the default timeout for MCP servers. # puppeteer: # type: stdio diff --git a/packages/api/src/mcp/__tests__/MCPConnectionSSRF.test.ts b/packages/api/src/mcp/__tests__/MCPConnectionSSRF.test.ts index 60de0db5e4a..aaec527e46b 100644 --- a/packages/api/src/mcp/__tests__/MCPConnectionSSRF.test.ts +++ b/packages/api/src/mcp/__tests__/MCPConnectionSSRF.test.ts @@ -13,6 +13,7 @@ import * as net from 'net'; import * as http from 'http'; +import { lookup } from 'node:dns/promises'; import { randomUUID } from 'crypto'; import { Request as UndiciRequest } from 'undici'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; @@ -38,6 +39,10 @@ jest.mock('@librechat/data-schemas', () => ({ }, })); +jest.mock('node:dns/promises', () => ({ + lookup: jest.fn(), +})); + jest.mock('~/auth', () => ({ createSSRFSafeUndiciConnect: jest.fn(() => ({ lookup: (_hostname: string, optionsOrCallback: unknown, maybeCallback?: LookupCallback) => { @@ -51,6 +56,7 @@ jest.mock('~/auth', () => ({ callback(null, '127.0.0.1', 4); }, })), + isSSRFTarget: jest.fn(() => false), resolveHostnameSSRF: jest.fn(async () => false), })); @@ -61,10 +67,18 @@ jest.mock('~/mcp/mcpConfig', () => ({ const mockedResolveHostnameSSRF = resolveHostnameSSRF as jest.MockedFunction< typeof resolveHostnameSSRF >; +const mockedLookup = lookup as unknown as jest.MockedFunction< + (hostname: string, options: { all: true }) => Promise> +>; const mockedCreateSSRFSafeUndiciConnect = createSSRFSafeUndiciConnect as jest.MockedFunction< typeof createSSRFSafeUndiciConnect >; +beforeEach(() => { + mockedLookup.mockReset(); + mockedLookup.mockResolvedValue([{ address: '203.0.113.10', family: 4 }]); +}); + function getLookupCallback( optionsOrCallback: unknown, maybeCallback?: LookupCallback, @@ -534,6 +548,60 @@ async function createHeaderCaptureServer(): Promise { }; } +async function createTunnelProxyCaptureServer(): Promise { + const headers: http.IncomingHttpHeaders[] = []; + const requests: CapturedRequest[] = []; + const server = http.createServer((_req, res) => { + res.writeHead(502); + res.end(); + }); + server.on('connect', (req, clientSocket, head) => { + headers.push({ ...req.headers }); + requests.push({ + method: 'CONNECT', + headers: { ...req.headers }, + body: req.url ?? '', + }); + + let buffer = Buffer.from(head); + let responded = false; + const respondIfRequestComplete = () => { + if (responded || !buffer.includes('\r\n\r\n')) { + return; + } + responded = true; + const requestLine = buffer.toString('utf8').split('\r\n')[0] ?? ''; + requests.push({ + method: requestLine.split(' ')[0] ?? '', + headers: {}, + body: requestLine, + }); + clientSocket.write( + 'HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{}', + ); + clientSocket.end(); + }; + + clientSocket.on('error', () => undefined); + clientSocket.write('HTTP/1.1 200 Connection Established\r\n\r\n'); + clientSocket.on('data', (chunk: Buffer) => { + buffer = Buffer.concat([buffer, chunk]); + respondIfRequestComplete(); + }); + respondIfRequestComplete(); + }); + + const destroySockets = trackSockets(server); + const port = await getFreePort(); + await new Promise((resolve) => server.listen(port, '127.0.0.1', resolve)); + return { + url: `http://127.0.0.1:${port}/`, + receivedHeaders: headers, + receivedRequests: requests, + close: destroySockets, + }; +} + async function createRawResponseServer(handler: http.RequestListener): Promise { const server = http.createServer(handler); const destroySockets = trackSockets(server); @@ -940,6 +1008,803 @@ describe('MCP SSRF protection – customFetch input shapes', () => { return factory.call(connection, () => null, undefined, undefined, undefined, undefined, true); } + function createBaseUrlFetch(connection: MCPConnection, baseUrl: string): CustomFetch { + const factory = ( + connection as unknown as { + createFetchFunction: ( + getHeaders: () => Record | null | undefined, + timeout?: number, + sseBodyTimeout?: number, + configuredSecretHeaderKeys?: ReadonlySet, + baseUrl?: string, + ) => CustomFetch; + } + ).createFetchFunction; + return factory.call(connection, () => null, undefined, 300000, undefined, baseUrl); + } + + function createBaseUrlDispatchers(connection: MCPConnection, baseUrl: string): string[] { + const privateSelf = connection as unknown as { + agents: Array<{ constructor: { name: string } }>; + }; + createBaseUrlFetch(connection, baseUrl); + return privateSelf.agents.map((agent) => agent.constructor.name); + } + + const proxyEnvKeys = [ + 'PROXY', + 'HTTP_PROXY', + 'HTTPS_PROXY', + 'NO_PROXY', + 'http_proxy', + 'https_proxy', + 'no_proxy', + ] as const; + type ProxyEnvKey = (typeof proxyEnvKeys)[number]; + + function snapshotProxyEnv(): Partial> { + const snapshot: Partial> = {}; + for (const key of proxyEnvKeys) { + if (process.env[key] != null) { + snapshot[key] = process.env[key]; + } + } + return snapshot; + } + + function restoreProxyEnv(snapshot: Partial>): void { + for (const key of proxyEnvKeys) { + if (snapshot[key] == null) { + delete process.env[key]; + } else { + process.env[key] = snapshot[key]; + } + } + } + + function clearProxyEnv(): void { + for (const key of proxyEnvKeys) { + delete process.env[key]; + } + } + + it('should allocate proxy dispatchers for streamable-http when proxy is configured', () => { + conn = new MCPConnection({ + serverName: 'customfetch-proxy-dispatchers', + serverConfig: { + type: 'streamable-http', + url: 'https://mcp.example.com/mcp', + proxy: 'http://proxy.example.com:8080', + }, + useSSRFProtection: false, + }); + + const privateSelf = conn as unknown as { + agents: Array<{ constructor: { name: string } }>; + createFetchFunction: ( + getHeaders: () => Record | null | undefined, + timeout?: number, + sseBodyTimeout?: number, + configuredSecretHeaderKeys?: ReadonlySet, + baseUrl?: string, + ) => CustomFetch; + }; + privateSelf.createFetchFunction.call( + conn, + () => null, + undefined, + 300000, + undefined, + 'https://mcp.example.com/mcp', + ); + + expect(privateSelf.agents.map((agent) => agent.constructor.name)).toEqual([ + 'ProxyAgent', + 'ProxyAgent', + ]); + }); + + it('should use the PROXY env var for streamable-http when server proxy is not configured', () => { + const originalProxy = process.env.PROXY; + process.env.PROXY = 'http://env-proxy.example.com:8080'; + try { + conn = new MCPConnection({ + serverName: 'customfetch-env-proxy-dispatchers', + serverConfig: { + type: 'streamable-http', + url: 'https://mcp.example.com/mcp', + }, + useSSRFProtection: false, + }); + + const privateSelf = conn as unknown as { + agents: Array<{ constructor: { name: string } }>; + createFetchFunction: ( + getHeaders: () => Record | null | undefined, + timeout?: number, + sseBodyTimeout?: number, + configuredSecretHeaderKeys?: ReadonlySet, + baseUrl?: string, + ) => CustomFetch; + }; + privateSelf.createFetchFunction.call( + conn, + () => null, + undefined, + 300000, + undefined, + 'https://mcp.example.com/mcp', + ); + + expect(privateSelf.agents.map((agent) => agent.constructor.name)).toEqual([ + 'ProxyAgent', + 'ProxyAgent', + ]); + } finally { + if (originalProxy == null) { + delete process.env.PROXY; + } else { + process.env.PROXY = originalProxy; + } + } + }); + + it('should use standard HTTP proxy env vars for streamable-http when PROXY is absent', () => { + const originalProxy = process.env.PROXY; + const originalHttpProxy = process.env.HTTP_PROXY; + const originalHttpsProxy = process.env.HTTPS_PROXY; + const originalNoProxy = process.env.NO_PROXY; + const originalLowerHttpProxy = process.env.http_proxy; + const originalLowerHttpsProxy = process.env.https_proxy; + const originalLowerNoProxy = process.env.no_proxy; + + delete process.env.PROXY; + delete process.env.http_proxy; + delete process.env.https_proxy; + delete process.env.no_proxy; + process.env.HTTP_PROXY = 'http://http-proxy.example.com:8080'; + process.env.HTTPS_PROXY = 'http://https-proxy.example.com:8080'; + process.env.NO_PROXY = 'localhost,127.0.0.1'; + + try { + conn = new MCPConnection({ + serverName: 'customfetch-standard-env-proxy-dispatchers', + serverConfig: { + type: 'streamable-http', + url: 'https://mcp.example.com/mcp', + }, + useSSRFProtection: false, + }); + + const privateSelf = conn as unknown as { + agents: Array<{ constructor: { name: string } }>; + createFetchFunction: ( + getHeaders: () => Record | null | undefined, + timeout?: number, + sseBodyTimeout?: number, + configuredSecretHeaderKeys?: ReadonlySet, + baseUrl?: string, + ) => CustomFetch; + }; + privateSelf.createFetchFunction.call( + conn, + () => null, + undefined, + 300000, + undefined, + 'https://mcp.example.com/mcp', + ); + + expect(privateSelf.agents.map((agent) => agent.constructor.name)).toEqual([ + 'ProxyAgent', + 'ProxyAgent', + ]); + } finally { + if (originalProxy == null) { + delete process.env.PROXY; + } else { + process.env.PROXY = originalProxy; + } + if (originalHttpProxy == null) { + delete process.env.HTTP_PROXY; + } else { + process.env.HTTP_PROXY = originalHttpProxy; + } + if (originalHttpsProxy == null) { + delete process.env.HTTPS_PROXY; + } else { + process.env.HTTPS_PROXY = originalHttpsProxy; + } + if (originalNoProxy == null) { + delete process.env.NO_PROXY; + } else { + process.env.NO_PROXY = originalNoProxy; + } + if (originalLowerHttpProxy == null) { + delete process.env.http_proxy; + } else { + process.env.http_proxy = originalLowerHttpProxy; + } + if (originalLowerHttpsProxy == null) { + delete process.env.https_proxy; + } else { + process.env.https_proxy = originalLowerHttpsProxy; + } + if (originalLowerNoProxy == null) { + delete process.env.no_proxy; + } else { + process.env.no_proxy = originalLowerNoProxy; + } + } + }); + + it('should honor NO_PROXY when standard HTTP proxy env vars are configured', () => { + const originalProxy = process.env.PROXY; + const originalHttpsProxy = process.env.HTTPS_PROXY; + const originalNoProxy = process.env.NO_PROXY; + const originalLowerHttpsProxy = process.env.https_proxy; + const originalLowerNoProxy = process.env.no_proxy; + + delete process.env.PROXY; + delete process.env.https_proxy; + delete process.env.no_proxy; + process.env.HTTPS_PROXY = 'http://https-proxy.example.com:8080'; + process.env.NO_PROXY = 'mcp.example.com'; + + try { + conn = new MCPConnection({ + serverName: 'customfetch-standard-env-no-proxy', + serverConfig: { + type: 'streamable-http', + url: 'https://mcp.example.com/mcp', + }, + useSSRFProtection: false, + }); + + const privateSelf = conn as unknown as { + agents: Array<{ constructor: { name: string } }>; + createFetchFunction: ( + getHeaders: () => Record | null | undefined, + timeout?: number, + sseBodyTimeout?: number, + configuredSecretHeaderKeys?: ReadonlySet, + baseUrl?: string, + ) => CustomFetch; + }; + privateSelf.createFetchFunction.call( + conn, + () => null, + undefined, + 300000, + undefined, + 'https://mcp.example.com/mcp', + ); + + expect(privateSelf.agents.map((agent) => agent.constructor.name)).toEqual(['Agent', 'Agent']); + } finally { + if (originalProxy == null) { + delete process.env.PROXY; + } else { + process.env.PROXY = originalProxy; + } + if (originalHttpsProxy == null) { + delete process.env.HTTPS_PROXY; + } else { + process.env.HTTPS_PROXY = originalHttpsProxy; + } + if (originalNoProxy == null) { + delete process.env.NO_PROXY; + } else { + process.env.NO_PROXY = originalNoProxy; + } + if (originalLowerHttpsProxy == null) { + delete process.env.https_proxy; + } else { + process.env.https_proxy = originalLowerHttpsProxy; + } + if (originalLowerNoProxy == null) { + delete process.env.no_proxy; + } else { + process.env.no_proxy = originalLowerNoProxy; + } + } + }); + + it('should honor bare IPv6 NO_PROXY entries without parsing a port suffix', () => { + const originalProxy = process.env.PROXY; + const originalHttpProxy = process.env.HTTP_PROXY; + const originalNoProxy = process.env.NO_PROXY; + const originalLowerHttpProxy = process.env.http_proxy; + const originalLowerNoProxy = process.env.no_proxy; + + delete process.env.PROXY; + delete process.env.http_proxy; + delete process.env.no_proxy; + process.env.HTTP_PROXY = 'http://http-proxy.example.com:8080'; + process.env.NO_PROXY = '::1'; + + try { + conn = new MCPConnection({ + serverName: 'customfetch-standard-env-no-proxy-ipv6', + serverConfig: { + type: 'streamable-http', + url: 'http://[::1]:3000/mcp', + }, + useSSRFProtection: false, + }); + + const privateSelf = conn as unknown as { + agents: Array<{ constructor: { name: string } }>; + createFetchFunction: ( + getHeaders: () => Record | null | undefined, + timeout?: number, + sseBodyTimeout?: number, + configuredSecretHeaderKeys?: ReadonlySet, + baseUrl?: string, + ) => CustomFetch; + }; + privateSelf.createFetchFunction.call( + conn, + () => null, + undefined, + 300000, + undefined, + 'http://[::1]:3000/mcp', + ); + + expect(privateSelf.agents.map((agent) => agent.constructor.name)).toEqual(['Agent', 'Agent']); + } finally { + if (originalProxy == null) { + delete process.env.PROXY; + } else { + process.env.PROXY = originalProxy; + } + if (originalHttpProxy == null) { + delete process.env.HTTP_PROXY; + } else { + process.env.HTTP_PROXY = originalHttpProxy; + } + if (originalNoProxy == null) { + delete process.env.NO_PROXY; + } else { + process.env.NO_PROXY = originalNoProxy; + } + if (originalLowerHttpProxy == null) { + delete process.env.http_proxy; + } else { + process.env.http_proxy = originalLowerHttpProxy; + } + if (originalLowerNoProxy == null) { + delete process.env.no_proxy; + } else { + process.env.no_proxy = originalLowerNoProxy; + } + } + }); + + it('should honor wildcard tokens in NO_PROXY lists', () => { + const originalProxy = process.env.PROXY; + const originalHttpProxy = process.env.HTTP_PROXY; + const originalNoProxy = process.env.NO_PROXY; + const originalLowerHttpProxy = process.env.http_proxy; + const originalLowerNoProxy = process.env.no_proxy; + + delete process.env.PROXY; + delete process.env.http_proxy; + delete process.env.no_proxy; + process.env.HTTP_PROXY = 'http://http-proxy.example.com:8080'; + process.env.NO_PROXY = 'localhost,*'; + + try { + conn = new MCPConnection({ + serverName: 'customfetch-standard-env-no-proxy-wildcard-list', + serverConfig: { + type: 'streamable-http', + url: 'http://mcp.example.com/mcp', + }, + useSSRFProtection: false, + }); + + const privateSelf = conn as unknown as { + agents: Array<{ constructor: { name: string } }>; + createFetchFunction: ( + getHeaders: () => Record | null | undefined, + timeout?: number, + sseBodyTimeout?: number, + configuredSecretHeaderKeys?: ReadonlySet, + baseUrl?: string, + ) => CustomFetch; + }; + privateSelf.createFetchFunction.call( + conn, + () => null, + undefined, + 300000, + undefined, + 'http://mcp.example.com/mcp', + ); + + expect(privateSelf.agents.map((agent) => agent.constructor.name)).toEqual(['Agent', 'Agent']); + } finally { + if (originalProxy == null) { + delete process.env.PROXY; + } else { + process.env.PROXY = originalProxy; + } + if (originalHttpProxy == null) { + delete process.env.HTTP_PROXY; + } else { + process.env.HTTP_PROXY = originalHttpProxy; + } + if (originalNoProxy == null) { + delete process.env.NO_PROXY; + } else { + process.env.NO_PROXY = originalNoProxy; + } + if (originalLowerHttpProxy == null) { + delete process.env.http_proxy; + } else { + process.env.http_proxy = originalLowerHttpProxy; + } + if (originalLowerNoProxy == null) { + delete process.env.no_proxy; + } else { + process.env.no_proxy = originalLowerNoProxy; + } + } + }); + + it('should honor CIDR and IP range patterns in NO_PROXY lists', async () => { + const originalEnv = snapshotProxyEnv(); + clearProxyEnv(); + process.env.HTTP_PROXY = 'http://http-proxy.example.com:8080'; + process.env.NO_PROXY = '10.0.0.0/8,192.168.1.10-192.168.1.20'; + + const expectDispatcherNamesForUrl = async ( + url: string, + expectedNames: string[], + ): Promise => { + await safeDisconnect(conn); + conn = new MCPConnection({ + serverName: `customfetch-no-proxy-${url}`, + serverConfig: { + type: 'streamable-http', + url, + }, + useSSRFProtection: false, + }); + expect(createBaseUrlDispatchers(conn, url)).toEqual(expectedNames); + }; + + try { + await expectDispatcherNamesForUrl('http://10.2.3.4/mcp', ['Agent', 'Agent']); + await expectDispatcherNamesForUrl('http://192.168.1.15/mcp', ['Agent', 'Agent']); + await expectDispatcherNamesForUrl('http://192.168.1.25/mcp', ['ProxyAgent', 'ProxyAgent']); + } finally { + restoreProxyEnv(originalEnv); + } + }); + + it('should match NO_PROXY host entries like undici env proxy agents', async () => { + const originalEnv = snapshotProxyEnv(); + clearProxyEnv(); + process.env.HTTPS_PROXY = 'http://https-proxy.example.com:8080'; + + const expectDispatcherNamesForUrl = async ( + noProxy: string, + url: string, + expectedNames: string[], + ): Promise => { + await safeDisconnect(conn); + process.env.NO_PROXY = noProxy; + conn = new MCPConnection({ + serverName: `customfetch-no-proxy-host-${noProxy}-${url}`, + serverConfig: { + type: 'streamable-http', + url, + }, + useSSRFProtection: false, + }); + expect(createBaseUrlDispatchers(conn, url)).toEqual(expectedNames); + }; + + try { + await expectDispatcherNamesForUrl('example.com', 'https://example.com/mcp', [ + 'Agent', + 'Agent', + ]); + await expectDispatcherNamesForUrl('example.com', 'https://api.example.com/mcp', [ + 'Agent', + 'Agent', + ]); + await expectDispatcherNamesForUrl('*.example.com', 'https://api.example.com/mcp', [ + 'Agent', + 'Agent', + ]); + await expectDispatcherNamesForUrl('*.example.com', 'https://example.com/mcp', [ + 'Agent', + 'Agent', + ]); + await expectDispatcherNamesForUrl('.example.com', 'https://example.com/mcp', [ + 'Agent', + 'Agent', + ]); + await expectDispatcherNamesForUrl('.example.com', 'https://api.example.com/mcp', [ + 'Agent', + 'Agent', + ]); + await expectDispatcherNamesForUrl('example.com', 'https://badexample.com/mcp', [ + 'ProxyAgent', + 'ProxyAgent', + ]); + } finally { + restoreProxyEnv(originalEnv); + } + }); + + it('should let empty lowercase proxy env vars disable uppercase fallbacks', () => { + const originalProxy = process.env.PROXY; + const originalHttpProxy = process.env.HTTP_PROXY; + const originalNoProxy = process.env.NO_PROXY; + const originalLowerHttpProxy = process.env.http_proxy; + const originalLowerNoProxy = process.env.no_proxy; + + delete process.env.PROXY; + delete process.env.NO_PROXY; + delete process.env.no_proxy; + process.env.HTTP_PROXY = 'http://http-proxy.example.com:8080'; + process.env.http_proxy = ''; + + try { + conn = new MCPConnection({ + serverName: 'customfetch-standard-empty-lowercase-proxy', + serverConfig: { + type: 'streamable-http', + url: 'http://mcp.example.com/mcp', + }, + useSSRFProtection: false, + }); + + const privateSelf = conn as unknown as { + agents: Array<{ constructor: { name: string } }>; + createFetchFunction: ( + getHeaders: () => Record | null | undefined, + timeout?: number, + sseBodyTimeout?: number, + configuredSecretHeaderKeys?: ReadonlySet, + baseUrl?: string, + ) => CustomFetch; + }; + privateSelf.createFetchFunction.call( + conn, + () => null, + undefined, + 300000, + undefined, + 'http://mcp.example.com/mcp', + ); + + expect(privateSelf.agents.map((agent) => agent.constructor.name)).toEqual(['Agent', 'Agent']); + } finally { + if (originalProxy == null) { + delete process.env.PROXY; + } else { + process.env.PROXY = originalProxy; + } + if (originalHttpProxy == null) { + delete process.env.HTTP_PROXY; + } else { + process.env.HTTP_PROXY = originalHttpProxy; + } + if (originalNoProxy == null) { + delete process.env.NO_PROXY; + } else { + process.env.NO_PROXY = originalNoProxy; + } + if (originalLowerHttpProxy == null) { + delete process.env.http_proxy; + } else { + process.env.http_proxy = originalLowerHttpProxy; + } + if (originalLowerNoProxy == null) { + delete process.env.no_proxy; + } else { + process.env.no_proxy = originalLowerNoProxy; + } + } + }); + + it('should let empty lowercase no_proxy disable uppercase fallbacks', () => { + const originalProxy = process.env.PROXY; + const originalHttpProxy = process.env.HTTP_PROXY; + const originalNoProxy = process.env.NO_PROXY; + const originalLowerHttpProxy = process.env.http_proxy; + const originalLowerNoProxy = process.env.no_proxy; + + delete process.env.PROXY; + delete process.env.http_proxy; + process.env.HTTP_PROXY = 'http://http-proxy.example.com:8080'; + process.env.NO_PROXY = 'mcp.example.com'; + process.env.no_proxy = ''; + + try { + conn = new MCPConnection({ + serverName: 'customfetch-standard-empty-lowercase-no-proxy', + serverConfig: { + type: 'streamable-http', + url: 'http://mcp.example.com/mcp', + }, + useSSRFProtection: false, + }); + + const privateSelf = conn as unknown as { + agents: Array<{ constructor: { name: string } }>; + createFetchFunction: ( + getHeaders: () => Record | null | undefined, + timeout?: number, + sseBodyTimeout?: number, + configuredSecretHeaderKeys?: ReadonlySet, + baseUrl?: string, + ) => CustomFetch; + }; + privateSelf.createFetchFunction.call( + conn, + () => null, + undefined, + 300000, + undefined, + 'http://mcp.example.com/mcp', + ); + + expect(privateSelf.agents.map((agent) => agent.constructor.name)).toEqual([ + 'ProxyAgent', + 'ProxyAgent', + ]); + } finally { + if (originalProxy == null) { + delete process.env.PROXY; + } else { + process.env.PROXY = originalProxy; + } + if (originalHttpProxy == null) { + delete process.env.HTTP_PROXY; + } else { + process.env.HTTP_PROXY = originalHttpProxy; + } + if (originalNoProxy == null) { + delete process.env.NO_PROXY; + } else { + process.env.NO_PROXY = originalNoProxy; + } + if (originalLowerHttpProxy == null) { + delete process.env.http_proxy; + } else { + process.env.http_proxy = originalLowerHttpProxy; + } + if (originalLowerNoProxy == null) { + delete process.env.no_proxy; + } else { + process.env.no_proxy = originalLowerNoProxy; + } + } + }); + + it('should recompute proxy dispatchers from the resolved request URL', async () => { + const originalEnv = snapshotProxyEnv(); + const capture = await createHeaderCaptureServer(); + clearProxyEnv(); + process.env.HTTP_PROXY = 'http://http-proxy.example.com:8080'; + process.env.NO_PROXY = '127.0.0.1'; + + try { + conn = new MCPConnection({ + serverName: 'customfetch-recompute-proxy-dispatcher', + serverConfig: { + type: 'streamable-http', + url: 'http://mcp.example.com/mcp', + }, + useSSRFProtection: false, + }); + + const customFetch = createBaseUrlFetch(conn, 'http://mcp.example.com/mcp'); + const response = await customFetch(capture.url); + + expect(response.status).toBe(200); + await response.body?.cancel(); + expect(capture.receivedRequests).toHaveLength(1); + expect( + (conn as unknown as { agents: Array<{ constructor: { name: string } }> }).agents.map( + (agent) => agent.constructor.name, + ), + ).toEqual(['ProxyAgent', 'ProxyAgent', 'Agent']); + } finally { + restoreProxyEnv(originalEnv); + await capture.close(); + } + }); + + it('should preflight proxied targets before dispatching network requests', async () => { + mockedResolveHostnameSSRF.mockResolvedValueOnce(true); + + conn = new MCPConnection({ + serverName: 'customfetch-proxy-ssrf', + serverConfig: { + type: 'streamable-http', + url: 'https://mcp.example.com/mcp', + proxy: 'http://proxy.example.com:8080', + }, + useSSRFProtection: true, + }); + + const customFetch = getCustomFetch(conn); + + await expect(customFetch('http://blocked.example.com/mcp')).rejects.toThrow( + /proxied MCP request target/, + ); + expect(mockedResolveHostnameSSRF).toHaveBeenCalledWith('blocked.example.com', null, '80'); + }); + + it('should fail closed when proxied target DNS cannot be resolved before dispatch', async () => { + mockedResolveHostnameSSRF.mockResolvedValueOnce(false); + mockedLookup.mockRejectedValueOnce( + Object.assign(new Error('getaddrinfo ENOTFOUND'), { + code: 'ENOTFOUND', + }), + ); + + conn = new MCPConnection({ + serverName: 'customfetch-proxy-ssrf-dns-fail-closed', + serverConfig: { + type: 'streamable-http', + url: 'https://mcp.example.com/mcp', + proxy: 'http://proxy.example.com:8080', + }, + useSSRFProtection: true, + }); + + const customFetch = getCustomFetch(conn); + + await expect(customFetch('http://proxy-only.internal/mcp')).rejects.toThrow( + /could not be resolved before proxying/, + ); + expect(mockedLookup).toHaveBeenCalledWith('proxy-only.internal', { all: true }); + }); + + it('should skip proxied DNS preflight for explicitly allowed target hosts', async () => { + const proxy = await createTunnelProxyCaptureServer(); + mockedResolveHostnameSSRF.mockClear(); + mockedLookup.mockClear(); + mockedLookup.mockRejectedValueOnce( + Object.assign(new Error('getaddrinfo ENOTFOUND'), { + code: 'ENOTFOUND', + }), + ); + + try { + conn = new MCPConnection({ + serverName: 'customfetch-proxy-ssrf-allowed-dns', + serverConfig: { + type: 'streamable-http', + url: 'https://mcp.example.com/mcp', + proxy: proxy.url, + }, + useSSRFProtection: true, + allowedAddresses: ['proxy-only.internal:80'], + }); + + const customFetch = getCustomFetch(conn); + const response = await customFetch('http://proxy-only.internal/mcp'); + + expect(response.status).toBe(200); + await response.body?.cancel().catch(() => undefined); + expect(proxy.receivedRequests[0]?.method).toBe('CONNECT'); + expect(mockedResolveHostnameSSRF).not.toHaveBeenCalled(); + expect(mockedLookup).not.toHaveBeenCalled(); + } finally { + await proxy.close(); + } + }); + it.each<['string' | 'URL' | 'Request']>([['string'], ['URL'], ['Request']])( 'should accept a %s input without throwing on URL derivation', async (shape) => { diff --git a/packages/api/src/mcp/__tests__/mcp.spec.ts b/packages/api/src/mcp/__tests__/mcp.spec.ts index d5cc44569fe..de2839371da 100644 --- a/packages/api/src/mcp/__tests__/mcp.spec.ts +++ b/packages/api/src/mcp/__tests__/mcp.spec.ts @@ -150,6 +150,18 @@ describe('Environment Variable Extraction (MCP)', () => { expect(result.headers).toEqual(options.headers); }); + it('should validate proxy URLs for remote HTTP transports', () => { + const options = { + type: 'streamable-http', + url: 'https://example.com/api', + proxy: 'http://proxy.example.com:8080', + }; + + const result = StreamableHTTPOptionsSchema.parse(options); + + expect(result.proxy).toBe('http://proxy.example.com:8080'); + }); + it('should accept "http" as an alias for "streamable-http"', () => { const options = { type: 'http', @@ -324,6 +336,20 @@ describe('Environment Variable Extraction (MCP)', () => { }); }); + it('should process proxy in streamable-http options', () => { + process.env.MCP_PROXY_URL = 'http://proxy.example.com:8080'; + const options: MCPOptions = { + type: 'streamable-http', + url: 'https://example.com', + proxy: '${MCP_PROXY_URL}', + }; + + const result = processMCPEnv({ options }); + + expect('proxy' in result && result.proxy).toBe('http://proxy.example.com:8080'); + delete process.env.MCP_PROXY_URL; + }); + it('should maintain streamable-http type in processed options', () => { const options: MCPOptions = { type: 'streamable-http', diff --git a/packages/api/src/mcp/connection.ts b/packages/api/src/mcp/connection.ts index 0bb4c033d4a..8edc01f2f49 100644 --- a/packages/api/src/mcp/connection.ts +++ b/packages/api/src/mcp/connection.ts @@ -1,6 +1,8 @@ +import { lookup } from 'node:dns/promises'; +import { isIP } from 'node:net'; import { EventEmitter } from 'events'; import { logger } from '@librechat/data-schemas'; -import { fetch as undiciFetch, Agent } from 'undici'; +import { fetch as undiciFetch, Agent, ProxyAgent } from 'undici'; import { StdioClientTransport, getDefaultEnvironment, @@ -15,16 +17,38 @@ import type { RequestInit as UndiciRequestInit, RequestInfo as UndiciRequestInfo, Response as UndiciResponse, + Dispatcher, } from 'undici'; import type { MCPOAuthTokens } from './oauth/types'; import type * as t from './types'; -import { createSSRFSafeUndiciConnect, resolveHostnameSSRF } from '~/auth'; +import { createSSRFSafeUndiciConnect, isSSRFTarget, resolveHostnameSSRF } from '~/auth'; +import { isAddressAllowed } from '~/auth/domain'; import { runOutsideTracing } from '~/utils/tracing'; import { sanitizeUrlForLogging } from './utils'; import { withTimeout } from '~/utils/promise'; import { mcpConfig } from './mcpConfig'; type FetchLike = (url: string | URL, init?: RequestInit) => Promise; +type ManagedDispatcher = Agent | ProxyAgent; +type ParsedIP = { version: 4 | 6; bits: 32 | 128; value: bigint }; + +const BIGINT_ZERO = BigInt(0); +const BIGINT_ONE = BigInt(1); +const BIGINT_EIGHT = BigInt(8); +const BIGINT_SIXTEEN = BigInt(16); +const UINT16_MASK = BigInt(0xffff); + +type MCPProxyConfig = + | { + type: 'explicit'; + proxyUrl: string; + } + | { + type: 'env'; + httpProxy?: string; + httpsProxy?: string; + noProxy?: string; + }; function isStdioOptions(options: t.MCPOptions): options is t.StdioOptions { return 'command' in options; @@ -273,10 +297,7 @@ async function guardMCPStreamableHTTPResponse( const sseEventDataLines: string[] = []; const unresolvedRequestIds = new Set(context.requestIds ?? []); - const buildAndLogBlockedError = ( - reason: string, - details: Record, - ): Error => { + const buildAndLogBlockedError = (reason: string, details: Record): Error => { const message = buildBlockedMCPResponseMessage(reason, { maxResponseBytes, maxLineBytes, @@ -509,7 +530,7 @@ function normalizeInitHeaders(init: UndiciRequestInit | undefined): Record | null | undefined, ): UndiciRequestInit { const hasInitHeaders = init?.headers != null; @@ -541,6 +562,343 @@ function getUrlPort(url: URL | string): string { return ''; } +function getTrimmedEnv(...keys: string[]): string | undefined { + for (const key of keys) { + const rawValue = process.env[key]; + if (rawValue != null) { + return rawValue.trim() || undefined; + } + } + return undefined; +} + +function getMCPProxyConfig(options: t.MCPOptions): MCPProxyConfig | undefined { + const configuredProxy = + 'proxy' in options && typeof options.proxy === 'string' ? options.proxy.trim() : ''; + if (configuredProxy) { + return { type: 'explicit', proxyUrl: configuredProxy }; + } + + const libreChatProxy = process.env.PROXY?.trim() ?? ''; + if (libreChatProxy) { + return { type: 'explicit', proxyUrl: libreChatProxy }; + } + + const httpProxy = getTrimmedEnv('http_proxy', 'HTTP_PROXY'); + const httpsProxy = getTrimmedEnv('https_proxy', 'HTTPS_PROXY'); + if (!httpProxy && !httpsProxy) { + return undefined; + } + + return { + type: 'env', + httpProxy, + httpsProxy, + noProxy: getTrimmedEnv('no_proxy', 'NO_PROXY'), + }; +} + +function parseIPv4ToBigInt(ip: string): bigint | null { + const octets = ip.split('.'); + if (octets.length !== 4) { + return null; + } + + let value = BIGINT_ZERO; + for (const octet of octets) { + if (!/^\d{1,3}$/.test(octet)) { + return null; + } + const parsed = Number.parseInt(octet, 10); + if (!Number.isInteger(parsed) || parsed < 0 || parsed > 255) { + return null; + } + value = (value << BIGINT_EIGHT) + BigInt(parsed); + } + return value; +} + +function parseIPv6ToBigInt(ip: string): bigint | null { + let normalized = ip.toLowerCase().replace(/^\[|\]$/g, ''); + const zoneIndex = normalized.indexOf('%'); + if (zoneIndex !== -1) { + normalized = normalized.slice(0, zoneIndex); + } + + if (normalized.includes('.')) { + const lastColon = normalized.lastIndexOf(':'); + if (lastColon === -1) { + return null; + } + const ipv4Value = parseIPv4ToBigInt(normalized.slice(lastColon + 1)); + if (ipv4Value == null) { + return null; + } + const hi = Number((ipv4Value >> BIGINT_SIXTEEN) & UINT16_MASK).toString(16); + const lo = Number(ipv4Value & UINT16_MASK).toString(16); + normalized = `${normalized.slice(0, lastColon)}:${hi}:${lo}`; + } + + const halves = normalized.split('::'); + if (halves.length > 2) { + return null; + } + + const left = halves[0] ? halves[0].split(':') : []; + const right = halves.length === 2 && halves[1] ? halves[1].split(':') : []; + const missing = halves.length === 2 ? 8 - left.length - right.length : 0; + if (missing < 0 || (halves.length === 1 && left.length !== 8)) { + return null; + } + + const parts = [...left, ...Array(missing).fill('0'), ...right]; + if (parts.length !== 8 || parts.some((part) => !/^[0-9a-f]{1,4}$/.test(part))) { + return null; + } + + return parts.reduce( + (value, part) => (value << BIGINT_SIXTEEN) + BigInt(Number.parseInt(part, 16)), + BIGINT_ZERO, + ); +} + +function parseIPLiteral(value: string): ParsedIP | null { + const normalized = value + .toLowerCase() + .trim() + .replace(/^\[|\]$/g, ''); + const version = isIP(normalized); + if (version === 4) { + const parsed = parseIPv4ToBigInt(normalized); + return parsed == null ? null : { version: 4, bits: 32, value: parsed }; + } + if (version === 6) { + const parsed = parseIPv6ToBigInt(normalized); + return parsed == null ? null : { version: 6, bits: 128, value: parsed }; + } + return null; +} + +function ipMatchesCIDR(hostname: string, cidr: string): boolean { + const [rangeAddress, prefixLength, extra] = cidr.split('/'); + if (!rangeAddress || prefixLength == null || extra != null || !/^\d+$/.test(prefixLength)) { + return false; + } + + const hostIP = parseIPLiteral(hostname); + const rangeIP = parseIPLiteral(rangeAddress); + if (!hostIP || !rangeIP || hostIP.version !== rangeIP.version) { + return false; + } + + const prefix = Number.parseInt(prefixLength, 10); + if (!Number.isInteger(prefix) || prefix < 0 || prefix > rangeIP.bits) { + return false; + } + + const bits = BigInt(rangeIP.bits); + const mask = + prefix === 0 + ? BIGINT_ZERO + : (((BIGINT_ONE << bits) - BIGINT_ONE) << BigInt(rangeIP.bits - prefix)) & + ((BIGINT_ONE << bits) - BIGINT_ONE); + return (hostIP.value & mask) === (rangeIP.value & mask); +} + +function ipMatchesRange(hostname: string, range: string): boolean { + const [startAddress, endAddress, extra] = range.split('-'); + if (!startAddress || !endAddress || extra != null) { + return false; + } + + const hostIP = parseIPLiteral(hostname); + const startIP = parseIPLiteral(startAddress); + const endIP = parseIPLiteral(endAddress); + if ( + !hostIP || + !startIP || + !endIP || + hostIP.version !== startIP.version || + hostIP.version !== endIP.version + ) { + return false; + } + + const min = startIP.value <= endIP.value ? startIP.value : endIP.value; + const max = startIP.value <= endIP.value ? endIP.value : startIP.value; + return hostIP.value >= min && hostIP.value <= max; +} + +function matchesNoProxyIPPattern(hostname: string, entryHostname: string): boolean { + if (entryHostname.includes('/')) { + return ipMatchesCIDR(hostname, entryHostname); + } + if (entryHostname.includes('-')) { + return ipMatchesRange(hostname, entryHostname); + } + return false; +} + +function getProxyEntryPort(entry: string): { + hostname: string; + port: number; +} { + const trimmed = entry.trim(); + const bracketed = trimmed.match(/^\[([^\]]+)\](?::(\d+))?$/); + if (bracketed) { + return { + hostname: bracketed[1].toLowerCase(), + port: bracketed[2] ? Number.parseInt(bracketed[2], 10) : 0, + }; + } + + const separatorCount = (trimmed.match(/:/g) ?? []).length; + const parsed = separatorCount === 1 ? trimmed.match(/^(.+):(\d+)$/) : null; + const hostname = (parsed ? parsed[1] : trimmed).replace(/^\[|\]$/g, '').toLowerCase(); + return { + hostname: hostname.replace(/^\*?\./, ''), + port: parsed ? Number.parseInt(parsed[2], 10) : 0, + }; +} + +function shouldBypassEnvProxy(url: URL, noProxy?: string): boolean { + if (!noProxy) { + return false; + } + + const trimmed = noProxy.trim(); + if (!trimmed) { + return false; + } + if (trimmed === '*') { + return true; + } + + const hostname = url.hostname.replace(/^\[|\]$/g, '').toLowerCase(); + const port = Number.parseInt(getUrlPort(url), 10) || 0; + + for (const entry of trimmed.split(/[,\s]/)) { + if (!entry) { + continue; + } + if (entry === '*') { + return true; + } + + const proxyEntry = getProxyEntryPort(entry); + if (proxyEntry.port && proxyEntry.port !== port) { + continue; + } + if (matchesNoProxyIPPattern(hostname, proxyEntry.hostname)) { + return true; + } + if (hostname === proxyEntry.hostname || hostname.endsWith(`.${proxyEntry.hostname}`)) { + return true; + } + } + + return false; +} + +function getProxyUrlForRequest( + proxyConfig: MCPProxyConfig | undefined, + urlString: string, +): string | undefined { + if (!proxyConfig || !urlString) { + return undefined; + } + if (proxyConfig.type === 'explicit') { + return proxyConfig.proxyUrl; + } + + const url = new URL(urlString); + if (shouldBypassEnvProxy(url, proxyConfig.noProxy)) { + return undefined; + } + if (url.protocol === 'https:') { + return proxyConfig.httpsProxy ?? proxyConfig.httpProxy; + } + if (url.protocol === 'http:') { + return proxyConfig.httpProxy; + } + return undefined; +} + +function createMCPDispatcher(options: { + bodyTimeout: number; + headersTimeout: number; + proxyUrl?: string; + keepAliveTimeout?: number; + keepAliveMaxTimeout?: number; + connect?: ReturnType; +}): ManagedDispatcher { + const { bodyTimeout, headersTimeout, proxyUrl, keepAliveTimeout, keepAliveMaxTimeout, connect } = + options; + + const baseOptions = { + bodyTimeout, + headersTimeout, + ...(keepAliveTimeout != null ? { keepAliveTimeout } : {}), + ...(keepAliveMaxTimeout != null ? { keepAliveMaxTimeout } : {}), + }; + + if (proxyUrl) { + return new ProxyAgent({ + uri: proxyUrl, + ...baseOptions, + }); + } + + return new Agent({ + ...baseOptions, + ...(connect != null ? { connect } : {}), + }); +} + +async function assertProxiedRequestTargetResolvable(hostname: string): Promise { + if (parseIPLiteral(hostname)) { + return; + } + + try { + await lookup(hostname, { all: true }); + } catch { + throw new Error( + `SSRF protection: proxied MCP request target "${hostname}" could not be resolved before proxying`, + ); + } +} + +async function assertProxiedRequestTargetAllowed( + urlString: string, + proxyConfig: MCPProxyConfig | undefined, + useSSRFProtection: boolean, + allowedAddresses?: string[] | null, +): Promise { + if (!proxyConfig || !useSSRFProtection) { + return; + } + + const targetUrl = new URL(urlString); + const port = getUrlPort(targetUrl); + if (isAddressAllowed(targetUrl.hostname, allowedAddresses, port)) { + return; + } + + const isBlockedTarget = + isSSRFTarget(targetUrl.hostname, allowedAddresses, port) || + (await resolveHostnameSSRF(targetUrl.hostname, allowedAddresses, port)); + + if (!isBlockedTarget) { + await assertProxiedRequestTargetResolvable(targetUrl.hostname); + return; + } + + throw new Error( + `SSRF protection: proxied MCP request target "${targetUrl.hostname}" resolved to a private/reserved address`, + ); +} + /** * Drops credential-bearing headers when a 307/308 redirect crosses an origin * boundary. Removes the always-forbidden set plus any caller-supplied secret @@ -758,7 +1116,7 @@ export class MCPConnection extends EventEmitter { private isReconnecting = false; private isInitializing = false; private reconnectAttempts = 0; - private agents: Agent[] = []; + private agents: Dispatcher[] = []; private readonly userId?: string; private lastPingTime: number; private lastConnectionCheckAt: number = 0; @@ -768,6 +1126,7 @@ export class MCPConnection extends EventEmitter { private oauthRecovery = false; private readonly useSSRFProtection: boolean; private readonly allowedAddresses?: string[] | null; + private readonly proxyConfig?: MCPProxyConfig; iconPath?: string; timeout?: number; sseReadTimeout?: number; @@ -883,6 +1242,7 @@ export class MCPConnection extends EventEmitter { this.userId = params.userId; this.useSSRFProtection = params.useSSRFProtection === true; this.allowedAddresses = params.allowedAddresses ?? null; + this.proxyConfig = getMCPProxyConfig(params.serverConfig); this.iconPath = params.serverConfig.iconPath; this.timeout = params.serverConfig.timeout; this.sseReadTimeout = params.serverConfig.sseReadTimeout; @@ -926,58 +1286,81 @@ export class MCPConnection extends EventEmitter { baseUrl?: string, guardStreamableHTTPResponses = false, ): (input: UndiciRequestInfo, init?: UndiciRequestInit) => Promise { - const basePort = baseUrl ? getUrlPort(baseUrl) : ''; - const ssrfConnect = this.useSSRFProtection - ? createSSRFSafeUndiciConnect(this.allowedAddresses, basePort) - : undefined; - const connectOpts = ssrfConnect != null ? { connect: ssrfConnect } : {}; + const proxyConfig = this.proxyConfig; + const useSSRFProtection = this.useSSRFProtection; + const allowedAddresses = this.allowedAddresses; /** Capture only the fields needed by the fetch closure; see factory note above. */ const agents = this.agents; const logPrefix = this.getLogPrefix(); const effectiveTimeout = timeout || DEFAULT_TIMEOUT; - const postAgent = new Agent({ - bodyTimeout: effectiveTimeout, - headersTimeout: effectiveTimeout, - ...connectOpts, - }); - this.agents.push(postAgent); + const requestDispatchers = new Map(); + const ssrfConnects = new Map>(); + + const getSSRFConnect = ( + targetPort: string, + dispatcherAllowedAddresses: string[] | null | undefined, + forceSafeDirectConnect: boolean, + ): ReturnType => { + const key = `${forceSafeDirectConnect ? 'redirect' : 'configured'}:${targetPort}`; + const existingConnect = ssrfConnects.get(key); + if (existingConnect) { + return existingConnect; + } - let getAgent: Agent | undefined; - if (sseBodyTimeout != null) { - getAgent = new Agent({ - bodyTimeout: sseBodyTimeout, - headersTimeout: effectiveTimeout, - ...connectOpts, - }); - this.agents.push(getAgent); - } + const connect = forceSafeDirectConnect + ? createSSRFSafeUndiciConnect() + : createSSRFSafeUndiciConnect(dispatcherAllowedAddresses, targetPort); + ssrfConnects.set(key, connect); + return connect; + }; - let safeRedirectPostAgent: Agent | undefined; - let safeRedirectGetAgent: Agent | undefined; /** - * Allowlist mode keeps the original MCP URL admin-approved, but redirect - * targets are server-controlled. These agents add connect-time DNS checks - * for those cross-origin hops so DNS rebinding cannot beat the standalone - * resolveHostnameSSRF pre-check. + * Proxy selection depends on the resolved request URL, not just the + * configured MCP base URL. SSE message endpoints can be absolute URLs, so + * cache dispatchers by the target URL's proxy decision and connect policy. */ - const createSafeRedirectAgent = (bodyTimeout: number): Agent => { - const redirectSSRFConnect = createSSRFSafeUndiciConnect(); - const agent = new Agent({ + const getRequestDispatcher = ( + isGetRequest: boolean, + targetUrlString: string, + dispatcherAllowedAddresses: string[] | null | undefined, + forceSafeDirectConnect = false, + ): ManagedDispatcher => { + const bodyTimeout = + isGetRequest && sseBodyTimeout != null ? sseBodyTimeout : effectiveTimeout; + const proxyUrl = getProxyUrlForRequest(proxyConfig, targetUrlString); + const targetPort = getUrlPort(targetUrlString); + const needsSSRFConnect = !proxyUrl && (useSSRFProtection || forceSafeDirectConnect); + const key = [ + bodyTimeout, + proxyUrl ?? 'direct', + needsSSRFConnect ? targetPort : 'open', + forceSafeDirectConnect ? 'redirect' : 'configured', + ].join(':'); + const existingAgent = requestDispatchers.get(key); + if (existingAgent) { + return existingAgent; + } + + const connect = needsSSRFConnect + ? getSSRFConnect(targetPort, dispatcherAllowedAddresses, forceSafeDirectConnect) + : undefined; + const agent = createMCPDispatcher({ bodyTimeout, headersTimeout: effectiveTimeout, - connect: redirectSSRFConnect, + proxyUrl, + ...(connect != null ? { connect } : {}), }); + requestDispatchers.set(key, agent); agents.push(agent); return agent; }; - const getSafeRedirectDispatcher = (isGetRequest: boolean): Agent => { - if (!isGetRequest || sseBodyTimeout == null) { - safeRedirectPostAgent ??= createSafeRedirectAgent(effectiveTimeout); - return safeRedirectPostAgent; + + if (baseUrl) { + getRequestDispatcher(false, baseUrl, allowedAddresses); + if (sseBodyTimeout != null) { + getRequestDispatcher(true, baseUrl, allowedAddresses); } - safeRedirectGetAgent ??= createSafeRedirectAgent(sseBodyTimeout); - return safeRedirectGetAgent; - }; + } return async function customFetch( input: UndiciRequestInfo, @@ -995,7 +1378,6 @@ export class MCPConnection extends EventEmitter { const { urlString, resolvedInit } = await resolveFetchInput(input, init); const isGet = (resolvedInit?.method ?? 'GET').toUpperCase() === 'GET'; - const dispatcher = isGet && getAgent ? getAgent : postAgent; const requestHeaders = getHeaders(); /** * Headers that originated from user/server configuration — runtime @@ -1008,10 +1390,22 @@ export class MCPConnection extends EventEmitter { ...(configuredSecretHeaderKeys ?? []), ]); - let currentInit = buildFetchInit(resolvedInit, dispatcher, requestHeaders); let currentUrlString = urlString; + let currentAllowedAddresses = allowedAddresses; + let forceRedirectSSRFConnect = false; + let currentInit = buildFetchInit( + resolvedInit, + getRequestDispatcher(isGet, currentUrlString, currentAllowedAddresses), + requestHeaders, + ); const originalOrigin = new URL(currentUrlString).origin; for (let redirects = 0; ; redirects++) { + await assertProxiedRequestTargetAllowed( + currentUrlString, + proxyConfig, + useSSRFProtection, + currentAllowedAddresses, + ); const response = await undiciFetch(currentUrlString, currentInit); const isMethodPreservingRedirect = response.status === 307 || response.status === 308; const responseContext = { @@ -1051,7 +1445,7 @@ export class MCPConnection extends EventEmitter { * design — letting redirect targets inherit the exemption would open * an SSRF amplification primitive. */ - if (await resolveHostnameSSRF(targetUrl.hostname)) { + if (isSSRFTarget(targetUrl.hostname) || (await resolveHostnameSSRF(targetUrl.hostname))) { logger.warn( `[MCP] Blocked redirect to private/reserved address: ${sanitizeUrlForLogging(targetUrl)}`, ); @@ -1071,6 +1465,8 @@ export class MCPConnection extends EventEmitter { } if (isCrossOriginRedirect) { + currentAllowedAddresses = null; + forceRedirectSSRFConnect = true; /** * Once a server-controlled cross-origin hop is seen, keep the safe * dispatcher for the rest of this redirect chain. Restoring the @@ -1081,7 +1477,22 @@ export class MCPConnection extends EventEmitter { */ currentInit = { ...currentInit, - dispatcher: getSafeRedirectDispatcher(isGet), + dispatcher: getRequestDispatcher( + isGet, + targetUrl.href, + currentAllowedAddresses, + forceRedirectSSRFConnect, + ), + }; + } else { + currentInit = { + ...currentInit, + dispatcher: getRequestDispatcher( + isGet, + targetUrl.href, + currentAllowedAddresses, + forceRedirectSSRFConnect, + ), }; } @@ -1177,17 +1588,33 @@ export class MCPConnection extends EventEmitter { * The connect timeout is extended because proxies may delay initial response. */ const sseTimeout = this.timeout || SSE_CONNECT_TIMEOUT; - const ssrfConnect = this.useSSRFProtection - ? createSSRFSafeUndiciConnect(this.allowedAddresses, getUrlPort(url)) - : undefined; - const sseAgent = new Agent({ - bodyTimeout: sseTimeout, - headersTimeout: sseTimeout, - keepAliveTimeout: sseTimeout, - keepAliveMaxTimeout: sseTimeout * 2, - ...(ssrfConnect != null ? { connect: ssrfConnect } : {}), - }); - this.agents.push(sseAgent); + const sseAgents = new Map(); + const getSSEDispatcher = (targetUrlString: string): ManagedDispatcher => { + const proxyUrl = getProxyUrlForRequest(this.proxyConfig, targetUrlString); + const targetPort = getUrlPort(targetUrlString); + const key = `${proxyUrl ?? 'direct'}:${this.useSSRFProtection && !proxyUrl ? targetPort : 'open'}`; + const existingAgent = sseAgents.get(key); + if (existingAgent) { + return existingAgent; + } + + const connect = + this.useSSRFProtection && !proxyUrl + ? createSSRFSafeUndiciConnect(this.allowedAddresses, targetPort) + : undefined; + const agent = createMCPDispatcher({ + bodyTimeout: sseTimeout, + headersTimeout: sseTimeout, + keepAliveTimeout: sseTimeout, + keepAliveMaxTimeout: sseTimeout * 2, + proxyUrl, + ...(connect != null ? { connect } : {}), + }); + sseAgents.set(key, agent); + this.agents.push(agent); + return agent; + }; + getSSEDispatcher(options.url); const sseConfiguredSecretHeaderKeys: ReadonlySet = new Set( Object.keys(headers).map((key) => key.toLowerCase()), ); @@ -1198,15 +1625,25 @@ export class MCPConnection extends EventEmitter { signal: abortController.signal, }, eventSourceInit: { - fetch: (url, init) => { + fetch: async (url, init) => { + const { urlString, resolvedInit } = await resolveFetchInput( + url as UndiciRequestInfo, + init as UndiciRequestInit, + ); + await assertProxiedRequestTargetAllowed( + urlString, + this.proxyConfig, + this.useSSRFProtection, + this.allowedAddresses, + ); /** Merge headers: SSE defaults < init headers < user headers (user wins) */ const fetchHeaders = new Headers( - Object.assign({}, SSE_REQUEST_HEADERS, init?.headers, headers), + Object.assign({}, SSE_REQUEST_HEADERS, resolvedInit?.headers, headers), ); - return undiciFetch(url, { - ...init, + return undiciFetch(urlString, { + ...resolvedInit, redirect: 'manual', - dispatcher: sseAgent, + dispatcher: getSSEDispatcher(urlString), headers: fetchHeaders, }); }, diff --git a/packages/api/src/utils/env.spec.ts b/packages/api/src/utils/env.spec.ts index e1244fa6052..52013d943d6 100644 --- a/packages/api/src/utils/env.spec.ts +++ b/packages/api/src/utils/env.spec.ts @@ -990,6 +990,7 @@ describe('processMCPEnv', () => { process.env.OAUTH_CLIENT_ID = 'oauth-client-id-value'; process.env.OAUTH_CLIENT_SECRET = 'oauth-client-secret-value'; process.env.MCP_SERVER_URL = 'https://mcp.example.com'; + process.env.MCP_PROXY_URL = 'http://proxy.example.com:8080'; }); afterEach(() => { @@ -998,6 +999,7 @@ describe('processMCPEnv', () => { delete process.env.OAUTH_CLIENT_ID; delete process.env.OAUTH_CLIENT_SECRET; delete process.env.MCP_SERVER_URL; + delete process.env.MCP_PROXY_URL; }); it('should return null/undefined as-is', () => { @@ -1045,6 +1047,47 @@ describe('processMCPEnv', () => { }); }); + it('should process outbound proxy for remote MCP options', () => { + const options: MCPOptions = { + type: 'sse', + url: '${MCP_SERVER_URL}/sse', + proxy: '${MCP_PROXY_URL}', + }; + + const result = processMCPEnv({ options }); + + expect(result).toEqual({ + type: 'sse', + url: 'https://mcp.example.com/sse', + proxy: 'http://proxy.example.com:8080', + }); + }); + + it('should not process user-controlled placeholders in outbound proxy', () => { + const user = createTestUser({ id: 'user-proxy-target' }); + const body = { conversationId: 'conv-1', parentMessageId: 'parent-1', messageId: 'msg-1' }; + const options: MCPOptions = { + type: 'sse', + url: '${MCP_SERVER_URL}/sse', + proxy: + 'http://proxy.example.com/{{CUSTOM_PROXY_PATH}}/{{LIBRECHAT_USER_ID}}/{{LIBRECHAT_BODY_MESSAGEID}}', + }; + + const result = processMCPEnv({ + options, + user, + body, + customUserVars: { CUSTOM_PROXY_PATH: 'tenant-proxy' }, + }); + + expect(result).toEqual({ + type: 'sse', + url: 'https://mcp.example.com/sse', + proxy: + 'http://proxy.example.com/{{CUSTOM_PROXY_PATH}}/{{LIBRECHAT_USER_ID}}/{{LIBRECHAT_BODY_MESSAGEID}}', + }); + }); + it('should process OAuth configuration with environment variables', () => { const options: MCPOptions = { type: 'streamable-http', diff --git a/packages/api/src/utils/env.ts b/packages/api/src/utils/env.ts index b5220f1ae87..61c308682be 100644 --- a/packages/api/src/utils/env.ts +++ b/packages/api/src/utils/env.ts @@ -271,6 +271,13 @@ function processSingleValue({ return value; } +function processAdminValue(originalValue: string, dbSourced: boolean): string { + if (typeof originalValue !== 'string') { + return String(originalValue); + } + return dbSourced ? originalValue : extractEnvVariable(originalValue); +} + /** * Recursively processes an object to replace environment variables in string values * @param params - Processing parameters @@ -383,6 +390,11 @@ export function processMCPEnv(params: { }); } + // Process outbound proxy if it exists (for SSE and StreamableHTTP types) + if ('proxy' in newObj && newObj.proxy) { + newObj.proxy = processAdminValue(newObj.proxy, dbSourced); + } + // Process OAuth configuration if it exists (for all transport types) if ('oauth' in newObj && newObj.oauth) { const processedOAuth: Record = {}; diff --git a/packages/data-provider/specs/mcp.spec.ts b/packages/data-provider/specs/mcp.spec.ts index 573769c4fad..6892c2ab164 100644 --- a/packages/data-provider/specs/mcp.spec.ts +++ b/packages/data-provider/specs/mcp.spec.ts @@ -1,4 +1,8 @@ -import { SSEOptionsSchema, MCPServerUserInputSchema } from '../src/mcp'; +import { + SSEOptionsSchema, + StreamableHTTPOptionsSchema, + MCPServerUserInputSchema, +} from '../src/mcp'; describe('MCPServerUserInputSchema', () => { describe('env variable exfiltration prevention', () => { @@ -52,6 +56,59 @@ describe('MCPServerUserInputSchema', () => { }); }); + describe('proxy field restrictions', () => { + it('should accept admin-configured proxies for SSE', () => { + const result = SSEOptionsSchema.safeParse({ + type: 'sse', + url: 'https://mcp-server.com/sse', + proxy: 'http://proxy.example.com:8080', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.proxy).toBe('http://proxy.example.com:8080'); + } + }); + + it('should accept admin-configured proxies for streamable-http', () => { + const result = StreamableHTTPOptionsSchema.safeParse({ + type: 'streamable-http', + url: 'https://mcp-server.com/http', + proxy: 'http://proxy.example.com:8080', + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.proxy).toBe('http://proxy.example.com:8080'); + } + }); + + it('should reject unsupported proxy protocols', () => { + const result = StreamableHTTPOptionsSchema.safeParse({ + type: 'streamable-http', + url: 'https://mcp-server.com/http', + proxy: 'ftp://proxy.example.com', + }); + expect(result.success).toBe(false); + }); + + it('should reject SSE proxy configuration from user input', () => { + const result = MCPServerUserInputSchema.safeParse({ + type: 'sse', + url: 'https://mcp-server.com/sse', + proxy: 'http://proxy.example.com:8080', + }); + expect(result.success).toBe(false); + }); + + it('should reject streamable-http proxy configuration from user input', () => { + const result = MCPServerUserInputSchema.safeParse({ + type: 'streamable-http', + url: 'https://mcp-server.com/http', + proxy: 'http://proxy.example.com:8080', + }); + expect(result.success).toBe(false); + }); + }); + describe('protocol allowlisting', () => { it('should reject file:// URLs for SSE', () => { const result = MCPServerUserInputSchema.safeParse({ diff --git a/packages/data-provider/src/mcp.ts b/packages/data-provider/src/mcp.ts index b22a599b9b6..4236d49096c 100644 --- a/packages/data-provider/src/mcp.ts +++ b/packages/data-provider/src/mcp.ts @@ -103,6 +103,25 @@ const BaseOptionsSchema = z.object({ .optional(), }); +const ProxyUrlSchema = z + .string() + .transform((val: string) => extractEnvVariable(val)) + .pipe(z.string().url()) + .refine( + (val: string) => { + const protocol = new URL(val).protocol; + return ( + protocol === 'http:' || + protocol === 'https:' || + protocol === 'socks:' || + protocol === 'socks5:' + ); + }, + { + message: 'Proxy URL must use http://, https://, socks://, or socks5://', + }, + ); + export const StdioOptionsSchema = BaseOptionsSchema.extend({ type: z.literal('stdio').default('stdio'), /** @@ -163,6 +182,8 @@ export const WebSocketOptionsSchema = BaseOptionsSchema.extend({ export const SSEOptionsSchema = BaseOptionsSchema.extend({ type: z.literal('sse').default('sse'), headers: z.record(z.string(), z.string()).optional(), + /** Optional outbound proxy URL for this remote MCP transport */ + proxy: ProxyUrlSchema.optional(), url: z .string() .transform((val: string) => extractEnvVariable(val)) @@ -181,6 +202,8 @@ export const SSEOptionsSchema = BaseOptionsSchema.extend({ export const StreamableHTTPOptionsSchema = BaseOptionsSchema.extend({ type: z.union([z.literal('streamable-http'), z.literal('http')]), headers: z.record(z.string(), z.string()).optional(), + /** Optional outbound proxy URL for this remote MCP transport */ + proxy: ProxyUrlSchema.optional(), url: z .string() .transform((val: string) => extractEnvVariable(val)) @@ -261,9 +284,11 @@ export const MCPServerUserInputSchema = z.union([ url: userUrlSchema(isWsProtocol, 'WebSocket URL must use ws:// or wss://'), }), omitServerManagedFields(SSEOptionsSchema).extend({ + proxy: z.never().optional(), url: userUrlSchema(isHttpProtocol, 'SSE URL must use http:// or https://'), }), omitServerManagedFields(StreamableHTTPOptionsSchema).extend({ + proxy: z.never().optional(), url: userUrlSchema(isHttpProtocol, 'Streamable HTTP URL must use http:// or https://'), }), ]); From cfe0f9f7f60a8efd1fc35e89e67e985e2ee4930a Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 21 May 2026 15:39:57 -0400 Subject: [PATCH 053/395] =?UTF-8?q?=F0=9F=93=A1=20fix:=20Respect=20Custom?= =?UTF-8?q?=20Endpoint=20Stream=20Usage=20Opt-In=20(#13237)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../__tests__/run-summarization.test.ts | 33 +++++++++++++++++++ packages/api/src/agents/run.ts | 8 ++++- 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/packages/api/src/agents/__tests__/run-summarization.test.ts b/packages/api/src/agents/__tests__/run-summarization.test.ts index 7d22511c218..ba31a0cdac7 100644 --- a/packages/api/src/agents/__tests__/run-summarization.test.ts +++ b/packages/api/src/agents/__tests__/run-summarization.test.ts @@ -205,6 +205,39 @@ beforeEach(() => { jest.clearAllMocks(); }); +// --------------------------------------------------------------------------- +// Suite: custom endpoint stream usage defaults +// --------------------------------------------------------------------------- +describe('custom endpoint stream usage defaults', () => { + it('disables streamUsage by default for OpenAI-compatible custom endpoints', async () => { + const agents = await callAndCapture({ + agents: [makeAgent({ endpoint: 'LiteLLM' })], + }); + const clientOptions = agents[0].clientOptions as Record; + + expect(clientOptions.streamUsage).toBe(false); + expect(clientOptions.usage).toBe(true); + }); + + it('respects explicit streamUsage from endpoint-resolved model parameters', async () => { + const agents = await callAndCapture({ + agents: [ + makeAgent({ + endpoint: 'LiteLLM', + model_parameters: { + model: 'gpt-4o', + streamUsage: true, + }, + }), + ], + }); + const clientOptions = agents[0].clientOptions as Record; + + expect(clientOptions.streamUsage).toBe(true); + expect(clientOptions.usage).toBe(true); + }); +}); + // --------------------------------------------------------------------------- // Suite 1: reserveRatio // --------------------------------------------------------------------------- diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index b0cc3105b61..e01f123e6aa 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -804,6 +804,10 @@ export async function createRun({ ); const modelParameters = normalizeAgentModelParameters(agent.model_parameters); + const hasExplicitStreamUsage = Object.prototype.hasOwnProperty.call( + modelParameters ?? {}, + 'streamUsage', + ); const llmConfig = Object.assign( { provider, @@ -847,7 +851,9 @@ export async function createRun({ customProviders.has(agent.provider) || (agent.provider === Providers.OPENAI && agent.endpoint !== agent.provider) ) { - llmConfig.streamUsage = false; + if (!hasExplicitStreamUsage) { + llmConfig.streamUsage = false; + } llmConfig.usage = true; } From a865d404310fcc4f7962b8e3dc6bf8114d9948a2 Mon Sep 17 00:00:00 2001 From: Dan Lew Date: Thu, 21 May 2026 15:23:30 -0500 Subject: [PATCH 054/395] =?UTF-8?q?=F0=9F=8D=9E=20fix:=20don't=20show=20'd?= =?UTF-8?q?eleting=20file'=20toast=20on=20attached=20files=20(#13239)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There are two ways to add a file to a conversation: 1. Uploading a new file. 2. Using an existing file (from the side panel). If you decide to remove the file, the behavior differs depending on how it was added. If you just uploaded a new file, it gets deleted from the conversation & the system. But if it's an existing file, then it only gets removed from the conversation (but not deleted). However, in both cases, it would show a toast saying that the file was deleted, which is incorrect for the "existing file" case. Now we check whether the file is `attached` (to the system) before showing the deletion toast, and skip showing it if we're not actually deleting the file. --- client/src/components/Chat/Input/Files/FileRow.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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', From b9d4a595b93bb485c6e08f92c2b8d7d79db6046b Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 21 May 2026 21:13:06 -0400 Subject: [PATCH 055/395] =?UTF-8?q?=F0=9F=97=82=EF=B8=8F=20refactor:=20Cla?= =?UTF-8?q?rify=20Code=20Sandbox=20File=20Guidance=20(#13236)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- packages/api/src/agents/cleanup.spec.ts | 16 ++++++++++++++++ packages/api/src/agents/cleanup.ts | 18 ++++++++++-------- packages/api/src/agents/handlers.ts | 10 +++++----- packages/api/src/agents/tools.spec.ts | 4 +++- packages/api/src/agents/tools.ts | 12 ++---------- 5 files changed, 36 insertions(+), 24 deletions(-) diff --git a/packages/api/src/agents/cleanup.spec.ts b/packages/api/src/agents/cleanup.spec.ts index 077007de3bb..563b5a67d3a 100644 --- a/packages/api/src/agents/cleanup.spec.ts +++ b/packages/api/src/agents/cleanup.spec.ts @@ -77,6 +77,22 @@ describe('cleanCodeToolOutput', () => { expect(output).toMatch(/- \/mnt\/data\/test_folder\/\.dirkeep/); }); + it('preserves the compact session-file summary', () => { + const summary = + 'Session files: 4 persisted file(s) are available in /mnt/data, including 1 image(s). ' + + 'Use known /mnt/data paths directly in later code-tool calls. ' + + 'The app displays files/images automatically; do not invent download links or wrap generated images in Markdown.'; + const input = [ + 'stdout:', + 'Report generated', + '', + 'Generated files:', + summary, + ].join('\n'); + const output = cleanCodeToolOutput(input); + expect(output).toBe(input); + }); + it('passes through output that contains no boilerplate (no false positives)', () => { const input = 'stdout:\nHello, world!\n'; expect(cleanCodeToolOutput(input)).toBe('stdout:\nHello, world!'); diff --git a/packages/api/src/agents/cleanup.ts b/packages/api/src/agents/cleanup.ts index 9025d2ea7c9..ffb051901c2 100644 --- a/packages/api/src/agents/cleanup.ts +++ b/packages/api/src/agents/cleanup.ts @@ -3,8 +3,8 @@ * tool output before LibreChat re-injects it into the assistant's * conversation history. * - * The bash executor in `@librechat/agents` appends two kinds of noise - * to every successful run: + * Older bash executor output from `@librechat/agents` appended two + * kinds of noise to successful runs: * * 1. **Trailing "Note:" paragraphs** — long behavioral hints repeating * rules the agent already has via its system prompt @@ -21,12 +21,14 @@ * per-file annotations are redundant *and* phrased inconsistently * ("downloaded" vs. "displayed" vs. "known to the user"). * - * Stripping happens in LibreChat (this file), not upstream, so the - * cleaning is reversible — pin to a specific upstream version and the - * patterns adjust here without releasing a new agents build. The - * patterns are anchored conservatively: only the documented forms are - * matched, so a future upstream string change leaves user-authored - * `Note:` lines (or legitimate `|`-delimited filenames) untouched. + * Newer compact summaries under `Generated files:` intentionally do + * not list `- /...` paths and pass through unchanged. Stripping happens + * in LibreChat (this file), not upstream, so the cleaning is reversible + * — pin to a specific upstream version and the patterns adjust here + * without releasing a new agents build. The patterns are anchored + * conservatively: only the documented forms are matched, so a future + * upstream string change leaves user-authored `Note:` lines (or + * legitimate `|`-delimited filenames) untouched. */ /** diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index 4690cf73e43..51fed861be4 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -1179,11 +1179,11 @@ export function createToolExecuteHandler(options: ToolExecuteOptions): EventHand * call. Almost always means the seeding chain * (primeCodeFiles → initialSessions → * CodeSessionContext) dropped the file upstream. - * `session_id` is still emitted; agents falls - * through to the `/files/` legacy fetch - * which is post-cutover broken (returns 400). - * Pair with `[primeCodeFiles]` traces below to - * locate the layer that lost the ref. */ + * `session_id` is still emitted for continuity, but + * concrete file refs must arrive through + * `_injected_files`; agents no longer falls back to + * `/files/`. Pair with `[primeCodeFiles]` + * traces below to locate the layer that lost the ref. */ logger.warn( `[code-env:inject] tool=${tc.name} _injected_files=0 — sandbox will see no input files`, { diff --git a/packages/api/src/agents/tools.spec.ts b/packages/api/src/agents/tools.spec.ts index 2ad9c30db9b..0643259847e 100644 --- a/packages/api/src/agents/tools.spec.ts +++ b/packages/api/src/agents/tools.spec.ts @@ -211,8 +211,10 @@ describe('registerCodeExecutionTools', () => { const readFile = result.toolDefinitions.find((d) => d.name === 'read_file'); expect(readFile?.description).toContain('code-execution sandbox'); expect(readFile?.description).toContain('/mnt/data/'); + expect(readFile?.description).toContain('Do not run ls/find'); + expect(readFile?.description).toContain('/tmp is per-call scratch'); expect(readFile?.description).toContain('truncated around 256KB'); - expect(readFile?.description).toContain('may return an error'); + expect(readFile?.description).toContain('true filesystem discovery'); expect(readFile?.description).not.toContain('{skillName}'); expect(readFile?.description).not.toContain('SKILL.md'); expect(JSON.stringify(readFile?.parameters)).not.toContain('{skillName}'); diff --git a/packages/api/src/agents/tools.ts b/packages/api/src/agents/tools.ts index 268b7df01af..59c7ade5d80 100644 --- a/packages/api/src/agents/tools.ts +++ b/packages/api/src/agents/tools.ts @@ -100,17 +100,9 @@ const READ_FILE_DEF: LCTool = Object.freeze({ responseFormat: ReadFileToolDefinition.responseFormat, }) as LCTool; -const CODE_READ_FILE_DESCRIPTION = `Read the contents of a file from the code-execution sandbox or from prior code-execution output. Returns text content with line numbers for easy reference. +const CODE_READ_FILE_DESCRIPTION = `Read a known text file from the code-execution sandbox. Returns line-numbered text; large files may be truncated around 256KB. -BEHAVIOR: -- Text files: returned with numbered lines. -- Large text files are truncated around 256KB with a note to use bash_tool for the full content. -- Binary files and formats that are not safe to serialize as text may return an error. Use bash_tool to inspect or process them. - -CONSTRAINTS: -- Only files produced by code execution or attached to the code-execution sandbox are accessible. -- Use paths returned by tool output or paths under /mnt/data/. -- Do not guess file paths. Use bash_tool to inspect available sandbox files when needed.`; +Use for text, CSV, JSON, Markdown, logs, and small source files at paths returned by tool output, just written, or under /mnt/data/. Do not run ls/find just to rediscover known paths. Use bash_tool for binary files, large files, transforms, metadata, or true filesystem discovery. /tmp is per-call scratch and unavailable later.`; const CODE_READ_FILE_PARAMETERS: LCTool['parameters'] = Object.freeze({ type: 'object', From 40a3df3901c6ab2eeb39de2291c35179c5301030 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Thu, 21 May 2026 21:46:27 -0400 Subject: [PATCH 056/395] =?UTF-8?q?=F0=9F=93=A6=20chore:=20bump=20`@librec?= =?UTF-8?q?hat/agents`=20to=20v3.1.90=20and=20npm=20audit=20fix=20(#13242)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * chore: bump `@librechat/agents` to v3.1.90 * chore: npm audit fix * chore: bump turbo --- api/package.json | 2 +- package-lock.json | 324 +++++++++++++++++++------------------- package.json | 2 +- packages/api/package.json | 2 +- 4 files changed, 168 insertions(+), 162 deletions(-) diff --git a/api/package.json b/api/package.json index 1b0e3651c7a..66563c07f28 100644 --- a/api/package.json +++ b/api/package.json @@ -46,7 +46,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.0.1", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.1.89", + "@librechat/agents": "^3.1.90", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/package-lock.json b/package-lock.json index f6b57a3865c..d2d74def486 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,7 +40,7 @@ "lint-staged": "^15.4.3", "prettier": "^3.5.0", "prettier-plugin-tailwindcss": "^0.6.11", - "turbo": "^2.8.12", + "turbo": "^2.9.14", "typescript-eslint": "^8.24.0" } }, @@ -61,7 +61,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.0.1", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.1.89", + "@librechat/agents": "^3.1.90", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", @@ -11915,6 +11915,32 @@ "node": ">=20" } }, + "node_modules/@langchain/langgraph": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.3.2.tgz", + "integrity": "sha512-SL7Ktsr681R7da+1b2MVOWEbaCoFJOXEJPTGOjg4JIG4C7quWbTYC8DzxhcCxte6D/8cGp0rYDBnbKLXEpNqlA==", + "license": "MIT", + "dependencies": { + "@langchain/langgraph-checkpoint": "^1.0.2", + "@langchain/langgraph-sdk": "~1.9.4", + "@langchain/protocol": "^0.0.15", + "@standard-schema/spec": "1.1.0", + "uuid": "^10.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@langchain/core": "^1.1.44", + "zod": "^3.25.32 || ^4.2.0", + "zod-to-json-schema": "^3.x" + }, + "peerDependenciesMeta": { + "zod-to-json-schema": { + "optional": true + } + } + }, "node_modules/@langchain/langgraph-checkpoint": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/@langchain/langgraph-checkpoint/-/langgraph-checkpoint-1.0.2.tgz", @@ -11945,27 +11971,25 @@ } }, "node_modules/@langchain/langgraph-sdk": { - "version": "1.8.10", - "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.8.10.tgz", - "integrity": "sha512-wrB3rkRw5KAmsqezwvKP3midT4qJrV6Hj9XJMYo+cbvXC4HYpSAmyY/VriSyeTFRbLG/OP/pY2Yz+9Z54nSaXQ==", + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/@langchain/langgraph-sdk/-/langgraph-sdk-1.9.4.tgz", + "integrity": "sha512-hhASJGKa2MDJDtDkuIFdWGysMTog/HkYe0r6B6Gn1XqsURWnF7FIFl9diITAPOv1tB8YpyjnbpsBj/NkT5d+jQ==", "license": "MIT", "dependencies": { + "@langchain/protocol": "^0.0.15", "@types/json-schema": "^7.0.15", "p-queue": "^9.0.1", "p-retry": "^7.1.1", "uuid": "^13.0.0" }, "peerDependencies": { - "@langchain/core": "^1.1.16", + "@langchain/core": "^1.1.44", "react": "^18 || ^19", "react-dom": "^18 || ^19", "svelte": "^4.0.0 || ^5.0.0", "vue": "^3.0.0" }, "peerDependenciesMeta": { - "@langchain/core": { - "optional": true - }, "react": { "optional": true }, @@ -11981,9 +12005,9 @@ } }, "node_modules/@langchain/langgraph-sdk/node_modules/p-queue": { - "version": "9.2.0", - "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.2.0.tgz", - "integrity": "sha512-dWgLE8AH0HjQ9fe74pUkKkvzzYT18Inp4zra3lKHnnwqGvcfcUBrvF2EAVX+envufDNBOzpPq/IBUONDbI7+3g==", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/p-queue/-/p-queue-9.3.0.tgz", + "integrity": "sha512-7NED7xhQ74Ngp4JP/2e0VZHp7vSWfJfqeiR92jPgxsz6m0Se4P03YoTKa9dDXyZ3r6P616gUXttrB6nnHYKang==", "license": "MIT", "dependencies": { "eventemitter3": "^5.0.4", @@ -12009,9 +12033,9 @@ } }, "node_modules/@langchain/langgraph-sdk/node_modules/uuid": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.1.tgz", - "integrity": "sha512-9ezox2roIft6ExBVTVqibSd5dc5/47Sw/uY6b4SjQUT2TzQ0tltNquWA46y4xPQmdZYqvnio22SgWd41M86+jw==", + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.2.tgz", + "integrity": "sha512-vzi9uRZ926x4XV73S/4qQaTwPXM2JBj6/6lI/byHH1jOpCzb0zDbfytgA9LcN/hzb2l7WQSQnxITOVx5un/wGw==", "funding": [ "https://github.com/sponsors/broofa", "https://github.com/sponsors/ctavan" @@ -12021,6 +12045,20 @@ "uuid": "dist-node/bin/uuid" } }, + "node_modules/@langchain/langgraph/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/@langchain/mistralai": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@langchain/mistralai/-/mistralai-1.0.8.tgz", @@ -12083,6 +12121,12 @@ "url": "https://github.com/sponsors/colinhacks" } }, + "node_modules/@langchain/protocol": { + "version": "0.0.15", + "resolved": "https://registry.npmjs.org/@langchain/protocol/-/protocol-0.0.15.tgz", + "integrity": "sha512-MllvbpMjqHevUm+v94M422mH7XKN+wGCvJRBVROTWBotEDOATYB4Ktk2UheYP859y9o2LlhtPek5t1T9eyfAbQ==", + "license": "MIT" + }, "node_modules/@langchain/textsplitters": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@langchain/textsplitters/-/textsplitters-1.0.1.tgz", @@ -12221,9 +12265,9 @@ } }, "node_modules/@librechat/agents": { - "version": "3.1.89", - "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.89.tgz", - "integrity": "sha512-CQRaLdCjyE4Pw6FowfSEBuq5K1B7YZ4cK3JpD/EW/LHl2SCU/Un9S1woiVpw05eIKaMwv28RfcW7yC1JS27PpQ==", + "version": "3.1.90", + "resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.90.tgz", + "integrity": "sha512-ZZTJmgc4It8+8suTt+Jrl04uA4BXUu5BzVdLCaft1RRroNkZhsoq5XrYxEsWuk6Q4FnrT4ubYP4JVuVyC/V+pw==", "license": "MIT", "dependencies": { "@anthropic-ai/sdk": "^0.92.0", @@ -12271,45 +12315,6 @@ } } }, - "node_modules/@librechat/agents/node_modules/@langchain/langgraph": { - "version": "1.2.9", - "resolved": "https://registry.npmjs.org/@langchain/langgraph/-/langgraph-1.2.9.tgz", - "integrity": "sha512-3c7BtGycHC2v9p6w/Hv8L7kEl1YnZYOQTDJtmAp3knk6JOedO7d2bYP3y0SRyhv5orUEGf/KGvx8ZsB/ideP7g==", - "license": "MIT", - "dependencies": { - "@langchain/langgraph-checkpoint": "^1.0.1", - "@langchain/langgraph-sdk": "~1.8.9", - "@standard-schema/spec": "1.1.0", - "uuid": "^10.0.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@langchain/core": "^1.1.40", - "zod": "^3.25.32 || ^4.2.0", - "zod-to-json-schema": "^3.x" - }, - "peerDependenciesMeta": { - "zod-to-json-schema": { - "optional": true - } - } - }, - "node_modules/@librechat/agents/node_modules/@langchain/langgraph/node_modules/uuid": { - "version": "10.0.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", - "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", - "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", - "funding": [ - "https://github.com/sponsors/broofa", - "https://github.com/sponsors/ctavan" - ], - "license": "MIT", - "bin": { - "uuid": "dist/bin/uuid" - } - }, "node_modules/@librechat/agents/node_modules/diff": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", @@ -19669,6 +19674,90 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "devOptional": true }, + "node_modules/@turbo/darwin-64": { + "version": "2.9.14", + "resolved": "https://registry.npmjs.org/@turbo/darwin-64/-/darwin-64-2.9.14.tgz", + "integrity": "sha512-t7QiPflaEyBE4oayeZtSmu4mEfjgIrcNlNNl1z1dmIVPqEdtA7+CfTf8d7KXsOGPh6aNgWjKxyvQg9uGfDQF+A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@turbo/darwin-arm64": { + "version": "2.9.14", + "resolved": "https://registry.npmjs.org/@turbo/darwin-arm64/-/darwin-arm64-2.9.14.tgz", + "integrity": "sha512-d23147mC9BsCPA9mJ0h/ubcpbRgcJBXbcG3+Vq7YLhjz3IXuvQsJ1UXH8f4MD76ZjJ4m/E4aRdJV+MW88CDfbw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@turbo/linux-64": { + "version": "2.9.14", + "resolved": "https://registry.npmjs.org/@turbo/linux-64/-/linux-64-2.9.14.tgz", + "integrity": "sha512-P3ZKB5tuUDdDQWuAsACGUR1qv9W7BNWxdxqVJ0kZNuNNPRaVYTPPikLcp79+GiEcW3npsR+KyP38lnQiBc5aSA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@turbo/linux-arm64": { + "version": "2.9.14", + "resolved": "https://registry.npmjs.org/@turbo/linux-arm64/-/linux-arm64-2.9.14.tgz", + "integrity": "sha512-ZRTlzcUMrrPv9ZuDzRF9n60Ym13bKeG9jDB8WjxyLhWNzV+AJQN+zdpIk3NJYf2zQsGUm1mNar2P0elRzLw25g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@turbo/windows-64": { + "version": "2.9.14", + "resolved": "https://registry.npmjs.org/@turbo/windows-64/-/windows-64-2.9.14.tgz", + "integrity": "sha512-exanwN6sIduZwykYeiTQj8kCmOhazP5WOz3bvXMcYtjhL6Z3iRWLewKrXCBq0bqwSP3iBMb/AerRCnHI4lx46A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@turbo/windows-arm64": { + "version": "2.9.14", + "resolved": "https://registry.npmjs.org/@turbo/windows-arm64/-/windows-arm64-2.9.14.tgz", + "integrity": "sha512-fVdCsnmYoKICsycbWuuGp6Jvi51/3G/UluFWuAUCvR8PIW5IJkAk5BM9UF8PSm0Q2IphWHFZjYEgjHsh3B9y/g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -28791,9 +28880,9 @@ } }, "node_modules/is-network-error": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.1.tgz", - "integrity": "sha512-6QCxa49rQbmUWLfk0nuGqzql9U8uaV2H6279bRErPBHe/109hCzsLUBUHfbEtvLIHBd6hyXbgedBSHevm43Edw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/is-network-error/-/is-network-error-1.3.2.tgz", + "integrity": "sha512-PhBY86zaxNZUuWP6h13Vu5oFe0XY6/UlKzQnYFELzGVHygP3MxmvTfYSG7GN3aIab/iWudSMgjSnG9Dq+nHrgA==", "license": "MIT", "engines": { "node": ">=16" @@ -30478,11 +30567,12 @@ } }, "node_modules/js-cookie": { - "version": "3.0.5", - "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", - "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.7.tgz", + "integrity": "sha512-z/wZZgDrkNV1eA0ULjM/F9/50Ya8fbzgKneSpoPsXSGd0KnpdtHfOZWK+GcwLk+EZbS4F9RBhU+K2RgzuDaItw==", + "license": "MIT", "engines": { - "node": ">=14" + "node": ">=20" } }, "node_modules/js-tiktoken": { @@ -41198,106 +41288,22 @@ "license": "MIT" }, "node_modules/turbo": { - "version": "2.8.12", - "resolved": "https://registry.npmjs.org/turbo/-/turbo-2.8.12.tgz", - "integrity": "sha512-auUAMLmi0eJhxDhQrxzvuhfEbICnVt0CTiYQYY8WyRJ5nwCDZxD0JG8bCSxT4nusI2CwJzmZAay5BfF6LmK7Hw==", + "version": "2.9.14", + "resolved": "https://registry.npmjs.org/turbo/-/turbo-2.9.14.tgz", + "integrity": "sha512-BQqXRr4UoWI3UPFrtznCLykYHxwxWh53iCB57x092jPMjIlW1wnm3N895g5irpiXmnxUhREBB0n6+y8BHhs4nw==", "dev": true, "license": "MIT", "bin": { "turbo": "bin/turbo" }, "optionalDependencies": { - "turbo-darwin-64": "2.8.12", - "turbo-darwin-arm64": "2.8.12", - "turbo-linux-64": "2.8.12", - "turbo-linux-arm64": "2.8.12", - "turbo-windows-64": "2.8.12", - "turbo-windows-arm64": "2.8.12" - } - }, - "node_modules/turbo-darwin-64": { - "version": "2.8.12", - "resolved": "https://registry.npmjs.org/turbo-darwin-64/-/turbo-darwin-64-2.8.12.tgz", - "integrity": "sha512-EiHJmW2MeQQx+21x8hjMHw/uPhXt9PIxvDrxzOtyVwrXzL0tQmsxtO4qHf2l7uA+K6PUJ4+TjY1MHZDuCvWXrw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/turbo-darwin-arm64": { - "version": "2.8.12", - "resolved": "https://registry.npmjs.org/turbo-darwin-arm64/-/turbo-darwin-arm64-2.8.12.tgz", - "integrity": "sha512-cbqqGN0vd7ly2TeuaM8k9AK9u1CABO4kBA5KPSqovTiLL3sORccn/mZzJSbvQf0EsYRfU34MgW5FotfwW3kx8Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/turbo-linux-64": { - "version": "2.8.12", - "resolved": "https://registry.npmjs.org/turbo-linux-64/-/turbo-linux-64-2.8.12.tgz", - "integrity": "sha512-jXKw9j4r4q6s0goSXuKI3aKbQK2qiNeP25lGGEnq018TM6SWRW1CCpPMxyG91aCKrub7wDm/K45sGNT4ZFBcFQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/turbo-linux-arm64": { - "version": "2.8.12", - "resolved": "https://registry.npmjs.org/turbo-linux-arm64/-/turbo-linux-arm64-2.8.12.tgz", - "integrity": "sha512-BRJCMdyXjyBoL0GYpvj9d2WNfMHwc3tKmJG5ATn2Efvil9LsiOsd/93/NxDqW0jACtHFNVOPnd/CBwXRPiRbwA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/turbo-windows-64": { - "version": "2.8.12", - "resolved": "https://registry.npmjs.org/turbo-windows-64/-/turbo-windows-64-2.8.12.tgz", - "integrity": "sha512-vyFOlpFFzQFkikvSVhVkESEfzIopgs2J7J1rYvtSwSHQ4zmHxkC95Q8Kjkus8gg+8X2mZyP1GS5jirmaypGiPw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/turbo-windows-arm64": { - "version": "2.8.12", - "resolved": "https://registry.npmjs.org/turbo-windows-arm64/-/turbo-windows-arm64-2.8.12.tgz", - "integrity": "sha512-9nRnlw5DF0LkJClkIws1evaIF36dmmMEO84J5Uj4oQ8C0QTHwlH7DNe5Kq2Jdmu8GXESCNDNuUYG8Cx6W/vm3g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] + "@turbo/darwin-64": "2.9.14", + "@turbo/darwin-arm64": "2.9.14", + "@turbo/linux-64": "2.9.14", + "@turbo/linux-arm64": "2.9.14", + "@turbo/windows-64": "2.9.14", + "@turbo/windows-arm64": "2.9.14" + } }, "node_modules/type": { "version": "2.7.3", @@ -43524,7 +43530,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.0.1", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.1.89", + "@librechat/agents": "^3.1.90", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.29.0", "@opentelemetry/api": "^1.9.0", diff --git a/package.json b/package.json index 8d80fe1ca77..efde1344254 100644 --- a/package.json +++ b/package.json @@ -136,7 +136,7 @@ "lint-staged": "^15.4.3", "prettier": "^3.5.0", "prettier-plugin-tailwindcss": "^0.6.11", - "turbo": "^2.8.12", + "turbo": "^2.9.14", "typescript-eslint": "^8.24.0" }, "overrides": { diff --git a/packages/api/package.json b/packages/api/package.json index 78629b76aef..9d86fc0dbbe 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -104,7 +104,7 @@ "@azure/storage-blob": "^12.30.0", "@google/genai": "^2.0.1", "@keyv/redis": "^4.3.3", - "@librechat/agents": "^3.1.89", + "@librechat/agents": "^3.1.90", "@librechat/data-schemas": "*", "@modelcontextprotocol/sdk": "^1.29.0", "@opentelemetry/api": "^1.9.0", From 34a693121ce4ac61ac1a4d64368165cb5ec58ab9 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 22 May 2026 07:24:35 -0400 Subject: [PATCH 057/395] =?UTF-8?q?=F0=9F=A7=B5=20fix:=20Preserve=20Stream?= =?UTF-8?q?ing=20Messages=20During=20Stale=20Refetch=20(#13247)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Messages/__tests__/queries.test.ts | 82 +++++++++++++++++++ client/src/data-provider/Messages/queries.ts | 64 +++++++++++---- 2 files changed, 131 insertions(+), 15 deletions(-) create mode 100644 client/src/data-provider/Messages/__tests__/queries.test.ts diff --git a/client/src/data-provider/Messages/__tests__/queries.test.ts b/client/src/data-provider/Messages/__tests__/queries.test.ts new file mode 100644 index 00000000000..c8fd0e99a18 --- /dev/null +++ b/client/src/data-provider/Messages/__tests__/queries.test.ts @@ -0,0 +1,82 @@ +import type { TMessage } from 'librechat-data-provider'; +import { getStableMessages } from '../queries'; + +const message = (overrides: Partial): TMessage => + ({ + messageId: 'message-id', + conversationId: 'convo-id', + parentMessageId: '00000000-0000-0000-0000-000000000000', + text: '', + sender: 'User', + isCreatedByUser: true, + createdAt: '2026-05-21T12:00:00.000Z', + updatedAt: '2026-05-21T12:00:00.000Z', + ...overrides, + }) as TMessage; + +describe('getStableMessages', () => { + it('keeps cache when an empty result races with unhydrated stream messages', () => { + const currentMessages = [ + message({ messageId: 'persisted-1' }), + message({ messageId: 'user-2', createdAt: undefined, updatedAt: undefined }), + message({ + messageId: 'user-2_', + isCreatedByUser: false, + createdAt: undefined, + updatedAt: undefined, + }), + ]; + + const result = getStableMessages({ + pathname: '/c/convo-id', + result: [], + currentMessages, + }); + + expect(result).toBe(currentMessages); + }); + + it('keeps cache when a one-message result returns for a larger cache', () => { + const currentMessages = [ + message({ messageId: 'persisted-1' }), + message({ messageId: 'persisted-2' }), + ]; + + const result = getStableMessages({ + pathname: '/c/convo-id', + result: [currentMessages[0]], + currentMessages, + }); + + expect(result).toBe(currentMessages); + }); + + it('accepts fewer persisted messages when the cache is fully hydrated', () => { + const currentMessages = [ + message({ messageId: 'persisted-1' }), + message({ messageId: 'persisted-2' }), + message({ messageId: 'persisted-3' }), + ]; + const serverMessages = [currentMessages[0], currentMessages[1]]; + + const result = getStableMessages({ + pathname: '/c/convo-id', + result: serverMessages, + currentMessages, + }); + + expect(result).toBe(serverMessages); + }); + + it('does not preserve cache on the new conversation route', () => { + const currentMessages = [message({ messageId: 'user-1', createdAt: undefined })]; + + const result = getStableMessages({ + pathname: '/c/new', + result: [], + currentMessages, + }); + + expect(result).toEqual([]); + }); +}); diff --git a/client/src/data-provider/Messages/queries.ts b/client/src/data-provider/Messages/queries.ts index bf0df06b4b3..0799b4adaca 100644 --- a/client/src/data-provider/Messages/queries.ts +++ b/client/src/data-provider/Messages/queries.ts @@ -5,6 +5,39 @@ import { QueryKeys, dataService } from 'librechat-data-provider'; import type * as t from 'librechat-data-provider'; import { logger } from '~/utils'; +type StableMessagesParams = { + pathname: string; + result: t.TMessage[]; + currentMessages?: t.TMessage[]; +}; + +function hasUnhydratedMessage(messages: t.TMessage[]) { + return messages.some((message) => { + const messageId = message.messageId ?? ''; + return message.createdAt == null || message.updatedAt == null || messageId.endsWith('_'); + }); +} + +export function getStableMessages({ + pathname, + result, + currentMessages, +}: StableMessagesParams): t.TMessage[] { + if (pathname.includes('/c/new') || !currentMessages?.length) { + return result; + } + + if (result.length >= currentMessages.length) { + return result; + } + + if (result.length === 1 || hasUnhydratedMessage(currentMessages)) { + return currentMessages; + } + + return result; +} + export const useGetMessagesByConvoId = ( id: string, config?: UseQueryOptions, @@ -15,22 +48,23 @@ export const useGetMessagesByConvoId = ( [QueryKeys.messages, id], async () => { const result = await dataService.getMessagesByConvoId(id); - if (!location.pathname.includes('/c/new') && result?.length === 1) { - const currentMessages = queryClient.getQueryData([QueryKeys.messages, id]); - if (currentMessages?.length === 1) { - return result; - } - if (currentMessages && currentMessages?.length > 1) { - logger.warn( - 'messages', - `Messages query for convo ${id} returned fewer than cache; path: "${location.pathname}"`, - result, - currentMessages, - ); - return currentMessages; - } + const currentMessages = queryClient.getQueryData([QueryKeys.messages, id]); + const stableMessages = getStableMessages({ + pathname: location.pathname, + result, + currentMessages, + }); + + if (stableMessages === currentMessages) { + logger.warn( + 'messages', + `Messages query for convo ${id} returned fewer than cache; path: "${location.pathname}"`, + result, + currentMessages, + ); } - return result; + + return stableMessages; }, { refetchOnWindowFocus: false, From c1e071b7a0fc54149be088f2a21cfdedee4217ce Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 22 May 2026 20:39:16 -0400 Subject: [PATCH 058/395] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20fix:=20Harden?= =?UTF-8?q?=20MCP=20OAuth=20Request=20Handling=20(#13264)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Harden MCP OAuth request handling * fix: Bound MCP OAuth dispatcher cache * fix: Harden OAuth DNS lookup handling --- packages/api/src/auth/agent.spec.ts | 83 ++++++++++++++++- packages/api/src/auth/agent.ts | 54 ++++++++--- .../MCPConnectionAgentLifecycle.test.ts | 2 + .../mcp/__tests__/MCPConnectionSSRF.test.ts | 1 + .../MCPOAuthClientRegistrationReuse.test.ts | 2 +- .../MCPOAuthConnectionEvents.test.ts | 2 + .../src/mcp/__tests__/MCPOAuthFlow.test.ts | 1 + .../__tests__/MCPOAuthRaceCondition.test.ts | 2 + .../__tests__/dbSourced.integration.test.ts | 2 + .../api/src/mcp/__tests__/handler.test.ts | 55 ++++++----- .../mcp/oauth/detectOAuth.fallback.test.ts | 2 + .../api/src/mcp/oauth/detectOAuth.test.ts | 7 ++ packages/api/src/mcp/oauth/detectOAuth.ts | 62 +++++++++---- packages/api/src/mcp/oauth/handler.ts | 75 ++++++++++----- .../mcp/oauth/hardenedFetch.behavior.test.ts | 91 ++++++++++++++++++ .../api/src/mcp/oauth/hardenedFetch.test.ts | 82 +++++++++++++++++ packages/api/src/mcp/oauth/hardenedFetch.ts | 92 +++++++++++++++++++ packages/api/src/mcp/oauth/types.ts | 4 + packages/api/src/mcp/oauth/url.ts | 6 ++ .../src/mcp/registry/MCPServerInspector.ts | 6 +- .../MCPReinitRecovery.integration.test.ts | 2 + 21 files changed, 555 insertions(+), 78 deletions(-) create mode 100644 packages/api/src/mcp/oauth/hardenedFetch.behavior.test.ts create mode 100644 packages/api/src/mcp/oauth/hardenedFetch.test.ts create mode 100644 packages/api/src/mcp/oauth/hardenedFetch.ts create mode 100644 packages/api/src/mcp/oauth/url.ts diff --git a/packages/api/src/auth/agent.spec.ts b/packages/api/src/auth/agent.spec.ts index 0764f7aa2f6..f74542c6c82 100644 --- a/packages/api/src/auth/agent.spec.ts +++ b/packages/api/src/auth/agent.spec.ts @@ -11,7 +11,11 @@ import http from 'node:http'; import type { LookupFunction } from 'node:net'; import { createSSRFSafeAgents, createSSRFSafeUndiciConnect } from './agent'; -type LookupCallback = (err: NodeJS.ErrnoException | null, address: string, family: number) => void; +type LookupCallback = ( + err: NodeJS.ErrnoException | null, + address: string | dns.LookupAddress[], + family?: number, +) => void; const mockedDnsLookup = dns.lookup as jest.MockedFunction; const httpAgentPrototype = http.Agent.prototype as unknown as { @@ -28,6 +32,16 @@ function mockDnsResult(address: string, family: number): void { }) as never); } +function mockDnsAllResult(addresses: dns.LookupAddress[]): void { + mockedDnsLookup.mockImplementation((( + _hostname: string, + _options: unknown, + callback: LookupCallback, + ) => { + callback(null, addresses); + }) as never); +} + function mockDnsError(err: NodeJS.ErrnoException): void { mockedDnsLookup.mockImplementation((( _hostname: string, @@ -123,6 +137,73 @@ describe('createSSRFSafeUndiciConnect', () => { expect(result.address).toBe('93.184.216.34'); }); + it('lookup should block private IPs when DNS returns all addresses', async () => { + mockDnsAllResult([{ address: '127.0.0.1', family: 4 }]); + const connect = createSSRFSafeUndiciConnect(); + + const result = await new Promise<{ err: NodeJS.ErrnoException | null }>((resolve) => { + connect.lookup('localhost', { all: true }, (err) => { + resolve({ err }); + }); + }); + + expect(result.err).toBeTruthy(); + expect(result.err!.code).toBe('ESSRF'); + }); + + it('lookup should allow public IPs when DNS returns all addresses', async () => { + const addresses = [{ address: '93.184.216.34', family: 4 }]; + mockDnsAllResult(addresses); + const connect = createSSRFSafeUndiciConnect(); + + const result = await new Promise<{ + err: NodeJS.ErrnoException | null; + address: string | dns.LookupAddress[]; + }>((resolve) => { + connect.lookup('example.com', { all: true }, (err, address) => { + resolve({ err, address }); + }); + }); + + expect(result.err).toBeNull(); + expect(result.address).toEqual(addresses); + }); + + it('lookup should block mixed public and private all-address results', async () => { + mockDnsAllResult([ + { address: '93.184.216.34', family: 4 }, + { address: '10.0.0.1', family: 4 }, + ]); + const connect = createSSRFSafeUndiciConnect(); + + const result = await new Promise<{ err: NodeJS.ErrnoException | null }>((resolve) => { + connect.lookup('rebinding.example.com', { all: true }, (err) => { + resolve({ err }); + }); + }); + + expect(result.err).toBeTruthy(); + expect(result.err!.code).toBe('ESSRF'); + }); + + it('lookup should honor allowedAddresses when DNS returns all addresses', async () => { + const addresses = [{ address: '10.0.0.5', family: 4 }]; + mockDnsAllResult(addresses); + const connect = createSSRFSafeUndiciConnect(['10.0.0.5:11434'], '11434'); + + const result = await new Promise<{ + err: NodeJS.ErrnoException | null; + address: string | dns.LookupAddress[]; + }>((resolve) => { + connect.lookup('private.example.com', { all: true }, (err, address) => { + resolve({ err, address }); + }); + }); + + expect(result.err).toBeNull(); + expect(result.address).toEqual(addresses); + }); + it('lookup should forward DNS errors', async () => { const dnsError = Object.assign(new Error('ENOTFOUND'), { code: 'ENOTFOUND', diff --git a/packages/api/src/auth/agent.ts b/packages/api/src/auth/agent.ts index 8337a7d071c..7222d8de645 100644 --- a/packages/api/src/auth/agent.ts +++ b/packages/api/src/auth/agent.ts @@ -9,6 +9,37 @@ import { } from './allowedAddresses'; import { isPrivateIP } from './ip'; +type LookupResult = string | dns.LookupAddress[]; + +function createSSRFLookupError(hostname: string, address: string): NodeJS.ErrnoException { + return Object.assign( + new Error(`SSRF protection: ${hostname} resolved to blocked address ${address}`), + { code: 'ESSRF' }, + ) as NodeJS.ErrnoException; +} + +function getBlockedLookupAddress( + lookupResult: LookupResult, + hostnameAllowed: boolean, + exemptSet: Set | null, + normalizedPort: string, +): string | null { + if (hostnameAllowed) { + return null; + } + + const addresses = Array.isArray(lookupResult) + ? lookupResult.map(({ address }) => address) + : [lookupResult]; + + return ( + addresses.find( + (address) => + isPrivateIP(address) && !isAddressInAllowedSet(address, exemptSet, normalizedPort), + ) ?? null + ); +} + /** * Builds a DNS lookup wrapper that blocks resolution to private/reserved IP * addresses. When `allowedAddresses` is provided, hostname/IP + port pairs @@ -34,20 +65,19 @@ function buildSSRFSafeLookup( callback(err, '', 0); return; } - if ( - !hostnameAllowed && - typeof address === 'string' && - isPrivateIP(address) && - !isAddressInAllowedSet(address, exemptSet, normalizedPort) - ) { - const ssrfError = Object.assign( - new Error(`SSRF protection: ${hostname} resolved to blocked address ${address}`), - { code: 'ESSRF' }, - ) as NodeJS.ErrnoException; - callback(ssrfError, address, family as number); + + const blockedAddress = getBlockedLookupAddress( + address, + hostnameAllowed, + exemptSet, + normalizedPort, + ); + if (blockedAddress) { + callback(createSSRFLookupError(hostname, blockedAddress), blockedAddress, family); return; } - callback(null, address as string, family as number); + + callback(null, address, family); }); }; } diff --git a/packages/api/src/mcp/__tests__/MCPConnectionAgentLifecycle.test.ts b/packages/api/src/mcp/__tests__/MCPConnectionAgentLifecycle.test.ts index 99cfa2363a7..95274f699fc 100644 --- a/packages/api/src/mcp/__tests__/MCPConnectionAgentLifecycle.test.ts +++ b/packages/api/src/mcp/__tests__/MCPConnectionAgentLifecycle.test.ts @@ -39,6 +39,8 @@ jest.mock('@librechat/data-schemas', () => ({ jest.mock('~/auth', () => ({ createSSRFSafeUndiciConnect: jest.fn(() => undefined), + isOAuthUrlAllowed: jest.fn(() => false), + isSSRFTarget: jest.fn(() => false), resolveHostnameSSRF: jest.fn(async () => false), })); diff --git a/packages/api/src/mcp/__tests__/MCPConnectionSSRF.test.ts b/packages/api/src/mcp/__tests__/MCPConnectionSSRF.test.ts index aaec527e46b..93375cbd688 100644 --- a/packages/api/src/mcp/__tests__/MCPConnectionSSRF.test.ts +++ b/packages/api/src/mcp/__tests__/MCPConnectionSSRF.test.ts @@ -56,6 +56,7 @@ jest.mock('~/auth', () => ({ callback(null, '127.0.0.1', 4); }, })), + isOAuthUrlAllowed: jest.fn(() => false), isSSRFTarget: jest.fn(() => false), resolveHostnameSSRF: jest.fn(async () => false), })); diff --git a/packages/api/src/mcp/__tests__/MCPOAuthClientRegistrationReuse.test.ts b/packages/api/src/mcp/__tests__/MCPOAuthClientRegistrationReuse.test.ts index 75cf4147b22..68723ea099f 100644 --- a/packages/api/src/mcp/__tests__/MCPOAuthClientRegistrationReuse.test.ts +++ b/packages/api/src/mcp/__tests__/MCPOAuthClientRegistrationReuse.test.ts @@ -45,7 +45,7 @@ jest.mock('@librechat/data-schemas', () => ({ jest.mock('~/auth', () => ({ createSSRFSafeUndiciConnect: jest.fn(() => undefined), resolveHostnameSSRF: jest.fn(async () => false), - isSSRFTarget: jest.fn(async () => false), + isSSRFTarget: jest.fn(() => false), isOAuthUrlAllowed: jest.fn(() => true), })); diff --git a/packages/api/src/mcp/__tests__/MCPOAuthConnectionEvents.test.ts b/packages/api/src/mcp/__tests__/MCPOAuthConnectionEvents.test.ts index 79470337a75..08b28dd817e 100644 --- a/packages/api/src/mcp/__tests__/MCPOAuthConnectionEvents.test.ts +++ b/packages/api/src/mcp/__tests__/MCPOAuthConnectionEvents.test.ts @@ -25,6 +25,8 @@ jest.mock('@librechat/data-schemas', () => ({ jest.mock('~/auth', () => ({ createSSRFSafeUndiciConnect: jest.fn(() => undefined), + isOAuthUrlAllowed: jest.fn(() => false), + isSSRFTarget: jest.fn(() => false), resolveHostnameSSRF: jest.fn(async () => false), })); diff --git a/packages/api/src/mcp/__tests__/MCPOAuthFlow.test.ts b/packages/api/src/mcp/__tests__/MCPOAuthFlow.test.ts index cbd29d35710..545540ee579 100644 --- a/packages/api/src/mcp/__tests__/MCPOAuthFlow.test.ts +++ b/packages/api/src/mcp/__tests__/MCPOAuthFlow.test.ts @@ -30,6 +30,7 @@ jest.mock('@librechat/data-schemas', () => ({ /** Bypass SSRF validation — these tests use real local HTTP servers. */ jest.mock('~/auth', () => ({ ...jest.requireActual('~/auth'), + createSSRFSafeUndiciConnect: jest.fn(() => undefined), isSSRFTarget: jest.fn(() => false), resolveHostnameSSRF: jest.fn(async () => false), })); diff --git a/packages/api/src/mcp/__tests__/MCPOAuthRaceCondition.test.ts b/packages/api/src/mcp/__tests__/MCPOAuthRaceCondition.test.ts index bf907422f59..ae3d055ba3b 100644 --- a/packages/api/src/mcp/__tests__/MCPOAuthRaceCondition.test.ts +++ b/packages/api/src/mcp/__tests__/MCPOAuthRaceCondition.test.ts @@ -31,6 +31,8 @@ jest.mock('@librechat/data-schemas', () => ({ jest.mock('~/auth', () => ({ createSSRFSafeUndiciConnect: jest.fn(() => undefined), + isOAuthUrlAllowed: jest.fn(() => false), + isSSRFTarget: jest.fn(() => false), resolveHostnameSSRF: jest.fn(async () => false), })); diff --git a/packages/api/src/mcp/__tests__/dbSourced.integration.test.ts b/packages/api/src/mcp/__tests__/dbSourced.integration.test.ts index 5866fa1a083..79241f1d6b7 100644 --- a/packages/api/src/mcp/__tests__/dbSourced.integration.test.ts +++ b/packages/api/src/mcp/__tests__/dbSourced.integration.test.ts @@ -37,6 +37,8 @@ jest.mock('@librechat/data-schemas', () => ({ jest.mock('~/auth', () => ({ createSSRFSafeUndiciConnect: jest.fn(() => undefined), + isOAuthUrlAllowed: jest.fn(() => false), + isSSRFTarget: jest.fn(() => false), resolveHostnameSSRF: jest.fn(async () => false), })); diff --git a/packages/api/src/mcp/__tests__/handler.test.ts b/packages/api/src/mcp/__tests__/handler.test.ts index 6e0c1961bf5..f69ccd52a5c 100644 --- a/packages/api/src/mcp/__tests__/handler.test.ts +++ b/packages/api/src/mcp/__tests__/handler.test.ts @@ -836,14 +836,17 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { await MCPOAuthHandler.revokeOAuthToken(mockServerName, mockToken, 'access', metadata); - expect(mockFetch).toHaveBeenCalledWith(new URL('https://auth.example.com/oauth/revoke'), { - method: 'POST', - body: 'token=test-token-12345&token_type_hint=access_token', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Authorization: `Basic ${Buffer.from('test-client-id:test-client-secret').toString('base64')}`, - }, - }); + expect(mockFetch).toHaveBeenCalledWith( + new URL('https://auth.example.com/oauth/revoke'), + expect.objectContaining({ + method: 'POST', + body: 'token=test-token-12345&token_type_hint=access_token', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${Buffer.from('test-client-id:test-client-secret').toString('base64')}`, + }, + }), + ); }); it('should successfully revoke a refresh token with client_secret_basic auth', async () => { @@ -862,14 +865,17 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { await MCPOAuthHandler.revokeOAuthToken(mockServerName, mockToken, 'refresh', metadata); - expect(mockFetch).toHaveBeenCalledWith(new URL('https://auth.example.com/oauth/revoke'), { - method: 'POST', - body: 'token=test-token-12345&token_type_hint=refresh_token', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - Authorization: `Basic ${Buffer.from('test-client-id:test-client-secret').toString('base64')}`, - }, - }); + expect(mockFetch).toHaveBeenCalledWith( + new URL('https://auth.example.com/oauth/revoke'), + expect.objectContaining({ + method: 'POST', + body: 'token=test-token-12345&token_type_hint=refresh_token', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${Buffer.from('test-client-id:test-client-secret').toString('base64')}`, + }, + }), + ); }); it('should successfully revoke an access token with client_secret_post auth', async () => { @@ -888,13 +894,16 @@ describe('MCPOAuthHandler - Configurable OAuth Metadata', () => { await MCPOAuthHandler.revokeOAuthToken(mockServerName, mockToken, 'access', metadata); - expect(mockFetch).toHaveBeenCalledWith(new URL('https://auth.example.com/oauth/revoke'), { - method: 'POST', - body: 'token=test-token-12345&token_type_hint=access_token&client_secret=test-client-secret&client_id=test-client-id', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - }, - }); + expect(mockFetch).toHaveBeenCalledWith( + new URL('https://auth.example.com/oauth/revoke'), + expect.objectContaining({ + method: 'POST', + body: 'token=test-token-12345&token_type_hint=access_token&client_secret=test-client-secret&client_id=test-client-id', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }), + ); }); it('should fallback to /revoke endpoint when revocationEndpoint is not provided', async () => { diff --git a/packages/api/src/mcp/oauth/detectOAuth.fallback.test.ts b/packages/api/src/mcp/oauth/detectOAuth.fallback.test.ts index 324fca8df60..4a9e20699da 100644 --- a/packages/api/src/mcp/oauth/detectOAuth.fallback.test.ts +++ b/packages/api/src/mcp/oauth/detectOAuth.fallback.test.ts @@ -6,6 +6,8 @@ jest.mock('@modelcontextprotocol/sdk/client/auth.js', () => ({ })); jest.mock('~/auth', () => ({ + createSSRFSafeUndiciConnect: jest.fn(() => ({ lookup: jest.fn() })), + isOAuthUrlAllowed: jest.fn(() => false), isSSRFTarget: jest.fn(() => false), resolveHostnameSSRF: jest.fn(async () => false), })); diff --git a/packages/api/src/mcp/oauth/detectOAuth.test.ts b/packages/api/src/mcp/oauth/detectOAuth.test.ts index cfcbf9a6921..c65d9416b07 100644 --- a/packages/api/src/mcp/oauth/detectOAuth.test.ts +++ b/packages/api/src/mcp/oauth/detectOAuth.test.ts @@ -8,6 +8,8 @@ jest.mock('@modelcontextprotocol/sdk/client/auth.js', () => ({ })); jest.mock('~/auth', () => ({ + createSSRFSafeUndiciConnect: jest.fn(() => ({ lookup: jest.fn() })), + isOAuthUrlAllowed: jest.fn(() => false), isSSRFTarget: jest.fn(() => false), resolveHostnameSSRF: jest.fn(async () => false), })); @@ -233,6 +235,7 @@ describe('detectOAuthRequirement', () => { expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith( 'https://mcp.example.com', expect.objectContaining({ resourceMetadataUrl: new URL(metadataUrl) }), + expect.any(Function), ); }); @@ -284,6 +287,7 @@ describe('detectOAuthRequirement', () => { expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith( 'https://mcp.example.com/mcp', expect.objectContaining({ resourceMetadataUrl: new URL(metadataUrl) }), + expect.any(Function), ); }); }); @@ -315,6 +319,7 @@ describe('detectOAuthRequirement', () => { expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith( 'https://mcp.example.com', expect.objectContaining({ resourceMetadataUrl: undefined }), + expect.any(Function), ); expect(result.requiresOAuth).toBe(true); expect(result.method).toBe('protected-resource-metadata'); @@ -343,6 +348,7 @@ describe('detectOAuthRequirement', () => { expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith( 'https://mcp.example.com', expect.objectContaining({ resourceMetadataUrl: undefined }), + expect.any(Function), ); expect(result.requiresOAuth).toBe(true); expect(result.method).toBe('401-challenge-metadata'); @@ -371,6 +377,7 @@ describe('detectOAuthRequirement', () => { expect(mockDiscoverOAuthProtectedResourceMetadata).toHaveBeenCalledWith( 'https://mcp.example.com', expect.objectContaining({ resourceMetadataUrl: undefined }), + expect.any(Function), ); }); }); diff --git a/packages/api/src/mcp/oauth/detectOAuth.ts b/packages/api/src/mcp/oauth/detectOAuth.ts index 33391b181dd..52bfe828624 100644 --- a/packages/api/src/mcp/oauth/detectOAuth.ts +++ b/packages/api/src/mcp/oauth/detectOAuth.ts @@ -6,8 +6,11 @@ // Manual testing ensures the OAuth detection still works against real MCP servers. import { discoverOAuthProtectedResourceMetadata } from '@modelcontextprotocol/sdk/client/auth.js'; -import { isSSRFTarget, resolveHostnameSSRF } from '~/auth'; +import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport'; +import { isSSRFTarget, resolveHostnameSSRF, isOAuthUrlAllowed } from '~/auth'; import { probeResourceMetadataHint } from './resourceHint'; +import { createHardenedOAuthFetch } from './hardenedFetch'; +import { getOAuthUrlPort } from './url'; import { mcpConfig } from '../mcpConfig'; export interface OAuthDetectionResult { @@ -32,8 +35,13 @@ export interface OAuthDetectionResult { * * @param serverUrl - The MCP server URL to check for OAuth requirements */ -export async function detectOAuthRequirement(serverUrl: string): Promise { - const hint = await probeResourceMetadataHint(serverUrl); +export async function detectOAuthRequirement( + serverUrl: string, + allowedDomains?: string[] | null, + allowedAddresses?: string[] | null, +): Promise { + const fetchFn = createHardenedOAuthFetch({ allowedDomains, allowedAddresses }); + const hint = await probeResourceMetadataHint(serverUrl, fetchFn); /** * The `resource_metadata` URL is attacker-controlled (it's echoed from the MCP @@ -42,10 +50,10 @@ export async function detectOAuthRequirement(serverUrl: string): Promise { try { - const resourceMetadata = await discoverOAuthProtectedResourceMetadata(serverUrl, { - resourceMetadataUrl, - }); + const resourceMetadata = await discoverOAuthProtectedResourceMetadata( + serverUrl, + { + resourceMetadataUrl, + }, + fetchFn, + ); if (!resourceMetadata?.authorization_servers?.length) return null; @@ -118,15 +131,25 @@ async function checkProtectedResourceMetadata( /** * SSRF-guards an attacker-controlled `resource_metadata` hint before the SDK follows it. - * `detectOAuthRequirement` runs without admin-scoped `allowedDomains`, so the rejection - * policy here is stricter than the handler's: any private/loopback/metadata-service - * target is dropped, regardless of origin relative to the MCP server. On rejection the - * caller continues with path-aware discovery (safe, since it targets the server itself). + * Honors the same allowedDomains/allowedAddresses policy used by the OAuth handler: + * trusted admin allowlist matches bypass the private-address block; otherwise hints + * are rejected when they target restricted hostnames or resolve to private addresses. + * On rejection the caller continues with path-aware discovery. */ -async function validateHintUrl(hintUrl: URL): Promise { +async function validateHintUrl( + hintUrl: URL, + allowedDomains?: string[] | null, + allowedAddresses?: string[] | null, +): Promise { try { - if (isSSRFTarget(hintUrl.hostname)) return undefined; - if (await resolveHostnameSSRF(hintUrl.hostname)) return undefined; + if (isOAuthUrlAllowed(hintUrl.href, allowedDomains, allowedAddresses)) return hintUrl; + + const port = getOAuthUrlPort(hintUrl); + const allowedDomainsActive = Array.isArray(allowedDomains) && allowedDomains.length > 0; + const effectiveAddresses = allowedDomainsActive ? null : allowedAddresses; + + if (isSSRFTarget(hintUrl.hostname, effectiveAddresses, port)) return undefined; + if (await resolveHostnameSSRF(hintUrl.hostname, effectiveAddresses, port)) return undefined; return hintUrl; } catch { // If validation itself fails (e.g. DNS lookup threw), be conservative and drop the hint. @@ -135,9 +158,12 @@ async function validateHintUrl(hintUrl: URL): Promise { } // Fallback: only called when probing threw. Caller already gates on `OAUTH_ON_AUTH_ERROR`. -async function checkAuthErrorFallback(serverUrl: string): Promise { +async function checkAuthErrorFallback( + serverUrl: string, + fetchFn: FetchLike, +): Promise { try { - const response = await fetch(serverUrl, { + const response = await fetchFn(serverUrl, { method: 'HEAD', signal: AbortSignal.timeout(mcpConfig.OAUTH_DETECTION_TIMEOUT), }); diff --git a/packages/api/src/mcp/oauth/handler.ts b/packages/api/src/mcp/oauth/handler.ts index 519a9a20bf9..0e7a645b033 100644 --- a/packages/api/src/mcp/oauth/handler.ts +++ b/packages/api/src/mcp/oauth/handler.ts @@ -1,6 +1,6 @@ import { randomBytes } from 'crypto'; import { logger } from '@librechat/data-schemas'; -import { FetchLike } from '@modelcontextprotocol/sdk/shared/transport'; +import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport'; import { OAuthMetadataSchema } from '@modelcontextprotocol/sdk/shared/auth.js'; import { checkResourceAllowed, @@ -32,18 +32,13 @@ import { import { isSSRFTarget, resolveHostnameSSRF, isOAuthUrlAllowed } from '~/auth'; import { probeResourceMetadataHint } from './resourceHint'; import { MCPTokenStorage } from './tokens'; +import { createHardenedOAuthFetch } from './hardenedFetch'; +import { getOAuthUrlPort } from './url'; import { sanitizeUrlForLogging } from '~/mcp/utils'; /** Type for the OAuth metadata from the SDK */ type SDKOAuthMetadata = Parameters[1]['metadata']; -function getOAuthUrlPort(url: URL): string { - if (url.port) return url.port; - if (url.protocol === 'http:') return '80'; - if (url.protocol === 'https:') return '443'; - return ''; -} - export class MCPOAuthHandler { private static readonly FLOW_TYPE = 'mcp_oauth'; private static readonly FLOW_TTL = 10 * 60 * 1000; // 10 minutes @@ -54,7 +49,11 @@ export class MCPOAuthHandler { private static createOAuthFetch( headers: Record, clientInfo?: OAuthClientInformation, + allowedDomains?: string[] | null, + allowedAddresses?: string[] | null, ): FetchLike { + const hardenedFetch = createHardenedOAuthFetch({ allowedDomains, allowedAddresses }); + return async (url: string | URL, init?: RequestInit): Promise => { const newHeaders = new Headers(init?.headers ?? {}); for (const [key, value] of Object.entries(headers)) { @@ -118,13 +117,13 @@ export class MCPOAuthHandler { } } - return fetch(url, { + return hardenedFetch(url, { ...init, body: params.toString(), headers: newHeaders, }); } - return fetch(url, { + return hardenedFetch(url, { ...init, headers: newHeaders, }); @@ -151,7 +150,12 @@ export class MCPOAuthHandler { let authServerUrl = new URL(serverUrl); let resourceMetadata: OAuthProtectedResourceMetadata | undefined; - const fetchFn = this.createOAuthFetch(oauthHeaders); + const fetchFn = this.createOAuthFetch( + oauthHeaders, + undefined, + allowedDomains, + allowedAddresses, + ); /** * RFC 9728 §5.1: when the server's 401 `WWW-Authenticate` header advertises a @@ -356,6 +360,8 @@ export class MCPOAuthHandler { resourceMetadata?: OAuthProtectedResourceMetadata, redirectUri?: string, tokenExchangeMethod?: TokenExchangeMethodEnum, + allowedDomains?: string[] | null, + allowedAddresses?: string[] | null, ): Promise { logger.debug( `[MCPOAuth] Starting client registration for ${sanitizeUrlForLogging(serverUrl)}, server metadata:`, @@ -417,7 +423,7 @@ export class MCPOAuthHandler { const clientInfo = await registerClient(serverUrl, { metadata: metadata as unknown as SDKOAuthMetadata, clientMetadata, - fetchFn: this.createOAuthFetch(oauthHeaders), + fetchFn: this.createOAuthFetch(oauthHeaders, undefined, allowedDomains, allowedAddresses), }); const forcedAuthMethod = getForcedTokenEndpointAuthMethod(tokenExchangeMethod); @@ -556,6 +562,8 @@ export class MCPOAuthHandler { codeVerifier, clientInfo, metadata, + ...(allowedDomains !== undefined && { allowedDomains }), + ...(allowedAddresses !== undefined && { allowedAddresses }), ...(Object.keys(oauthHeaders).length > 0 && { oauthHeaders }), }; @@ -637,6 +645,8 @@ export class MCPOAuthHandler { resourceMetadata, redirectUri, config?.token_exchange_method, + allowedDomains, + allowedAddresses, ); logger.debug(`[MCPOAuth] Client registered with ID: ${clientInfo.client_id}`); } @@ -711,6 +721,8 @@ export class MCPOAuthHandler { clientInfo, metadata, resourceMetadata, + ...(allowedDomains !== undefined && { allowedDomains }), + ...(allowedAddresses !== undefined && { allowedAddresses }), ...(Object.keys(oauthHeaders).length > 0 && { oauthHeaders }), ...(reusedStoredClient && { reusedStoredClient }), }; @@ -739,9 +751,9 @@ export class MCPOAuthHandler { /** * Completes the OAuth flow by exchanging the authorization code for tokens. * - * `allowedDomains` is intentionally absent: all URLs used here (serverUrl, - * token_endpoint) originate from {@link MCPOAuthFlowMetadata} that was - * SSRF-validated during {@link initiateOAuthFlow}. No new URL resolution occurs. + * The token exchange reuses the SSRF policy captured during + * {@link initiateOAuthFlow} and enforces it again at connect time. This closes + * DNS rebinding gaps between the preflight validation and the callback request. */ static async completeOAuthFlow( flowId: string, @@ -789,7 +801,12 @@ export class MCPOAuthHandler { codeVerifier: metadata.codeVerifier, authorizationCode, resource, - fetchFn: this.createOAuthFetch(oauthHeaders, metadata.clientInfo), + fetchFn: this.createOAuthFetch( + oauthHeaders, + metadata.clientInfo, + metadata.allowedDomains, + metadata.allowedAddresses, + ), }); logger.debug('[MCPOAuth] Token exchange successful', { @@ -1070,7 +1087,12 @@ export class MCPOAuthHandler { } else { /** Auto-discover OAuth configuration for refresh */ const serverUrl = new URL(metadata.serverUrl); - const fetchFn = this.createOAuthFetch(oauthHeaders); + const fetchFn = this.createOAuthFetch( + oauthHeaders, + undefined, + allowedDomains, + allowedAddresses, + ); const oauthMetadata = await this.discoverWithOriginFallback(serverUrl, fetchFn); if (!oauthMetadata) { @@ -1149,7 +1171,8 @@ export class MCPOAuthHandler { has_auth_header: !!headers['Authorization'], }); - const response = await fetch(tokenUrl, { + const oauthFetch = createHardenedOAuthFetch({ allowedDomains, allowedAddresses }); + const response = await oauthFetch(tokenUrl, { method: 'POST', headers, body, @@ -1232,7 +1255,8 @@ export class MCPOAuthHandler { body.append('client_id', config.client_id); } - const response = await fetch(tokenUrl, { + const oauthFetch = createHardenedOAuthFetch({ allowedDomains, allowedAddresses }); + const response = await oauthFetch(tokenUrl, { method: 'POST', headers, body, @@ -1256,7 +1280,12 @@ export class MCPOAuthHandler { /** Auto-discover OAuth configuration for refresh */ const serverUrl = new URL(metadata.serverUrl); - const fetchFn = this.createOAuthFetch(oauthHeaders); + const fetchFn = this.createOAuthFetch( + oauthHeaders, + undefined, + allowedDomains, + allowedAddresses, + ); const oauthMetadata = await this.discoverWithOriginFallback(serverUrl, fetchFn); let tokenUrl: URL; @@ -1286,7 +1315,8 @@ export class MCPOAuthHandler { ...oauthHeaders, }; - const response = await fetch(tokenUrl, { + const oauthFetch = createHardenedOAuthFetch({ allowedDomains, allowedAddresses }); + const response = await oauthFetch(tokenUrl, { method: 'POST', headers, body, @@ -1361,7 +1391,8 @@ export class MCPOAuthHandler { logger.info( `[MCPOAuth] Revoking tokens for ${serverName} via ${sanitizeUrlForLogging(revokeUrl.toString())}`, ); - const response = await fetch(revokeUrl, { + const oauthFetch = createHardenedOAuthFetch({ allowedDomains, allowedAddresses }); + const response = await oauthFetch(revokeUrl, { method: 'POST', body: body.toString(), headers, diff --git a/packages/api/src/mcp/oauth/hardenedFetch.behavior.test.ts b/packages/api/src/mcp/oauth/hardenedFetch.behavior.test.ts new file mode 100644 index 00000000000..206616fdd00 --- /dev/null +++ b/packages/api/src/mcp/oauth/hardenedFetch.behavior.test.ts @@ -0,0 +1,91 @@ +import http from 'node:http'; +import type { AddressInfo, Socket } from 'node:net'; +import { createHardenedOAuthFetch, resetHardenedOAuthFetchDispatchers } from './hardenedFetch'; + +type TestServer = { + port: number; + requestCount: () => number; + close: () => Promise; +}; + +async function createLocalServer(): Promise { + let requestCount = 0; + const sockets = new Set(); + const server = http.createServer((_req, res) => { + requestCount += 1; + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ ok: true })); + }); + + server.on('connection', (socket) => { + sockets.add(socket); + socket.once('close', () => sockets.delete(socket)); + }); + + await new Promise((resolve) => server.listen(0, 'localhost', resolve)); + const address = server.address() as AddressInfo; + + return { + port: address.port, + requestCount: () => requestCount, + close: () => + new Promise((resolve) => { + for (const socket of sockets) { + socket.destroy(); + } + sockets.clear(); + server.close(() => resolve()); + }), + }; +} + +describe('createHardenedOAuthFetch request policy', () => { + let server: TestServer; + + beforeEach(async () => { + server = await createLocalServer(); + }); + + afterEach(async () => { + resetHardenedOAuthFetchDispatchers(); + await server.close(); + }); + + it('blocks local OAuth requests unless the endpoint is explicitly trusted', async () => { + const oauthFetch = createHardenedOAuthFetch(); + + await expect( + oauthFetch(`http://localhost:${server.port}/token`, { + signal: AbortSignal.timeout(1000), + }), + ).rejects.toThrow(); + + expect(server.requestCount()).toBe(0); + }); + + it('allows explicitly trusted local OAuth endpoints', async () => { + const oauthFetch = createHardenedOAuthFetch({ allowedDomains: ['localhost'] }); + + const response = await oauthFetch(`http://localhost:${server.port}/token`, { + signal: AbortSignal.timeout(1000), + }); + + await expect(response.json()).resolves.toEqual({ ok: true }); + expect(server.requestCount()).toBe(1); + }); + + it('does not use address exemptions when domain policy is active but unmatched', async () => { + const oauthFetch = createHardenedOAuthFetch({ + allowedDomains: ['trusted.example.com'], + allowedAddresses: [`localhost:${server.port}`], + }); + + await expect( + oauthFetch(`http://localhost:${server.port}/token`, { + signal: AbortSignal.timeout(1000), + }), + ).rejects.toThrow(); + + expect(server.requestCount()).toBe(0); + }); +}); diff --git a/packages/api/src/mcp/oauth/hardenedFetch.test.ts b/packages/api/src/mcp/oauth/hardenedFetch.test.ts new file mode 100644 index 00000000000..5e9f5bb082b --- /dev/null +++ b/packages/api/src/mcp/oauth/hardenedFetch.test.ts @@ -0,0 +1,82 @@ +import { createSSRFSafeUndiciConnect, isOAuthUrlAllowed } from '~/auth'; +import { createHardenedOAuthFetch, resetHardenedOAuthFetchDispatchers } from './hardenedFetch'; + +jest.mock('~/auth', () => ({ + createSSRFSafeUndiciConnect: jest.fn(() => ({ lookup: jest.fn() })), + isOAuthUrlAllowed: jest.fn(() => false), +})); + +const mockCreateSSRFSafeUndiciConnect = createSSRFSafeUndiciConnect as jest.MockedFunction< + typeof createSSRFSafeUndiciConnect +>; +const mockIsOAuthUrlAllowed = isOAuthUrlAllowed as jest.MockedFunction; + +describe('createHardenedOAuthFetch', () => { + const originalFetch = global.fetch; + const mockFetch = jest.fn() as unknown as jest.MockedFunction; + + beforeEach(() => { + jest.clearAllMocks(); + global.fetch = mockFetch; + mockFetch.mockResolvedValue({ ok: true } as Response); + mockIsOAuthUrlAllowed.mockReturnValue(false); + }); + + afterEach(() => { + resetHardenedOAuthFetchDispatchers(); + }); + + afterAll(() => { + global.fetch = originalFetch; + }); + + it('attaches an SSRF-safe dispatcher at connect time', async () => { + await createHardenedOAuthFetch()('https://auth.example.com:9443/token', { + method: 'POST', + }); + + expect(mockCreateSSRFSafeUndiciConnect).toHaveBeenCalledWith(undefined, '9443'); + expect(mockFetch).toHaveBeenCalledWith( + 'https://auth.example.com:9443/token', + expect.objectContaining({ + method: 'POST', + dispatcher: expect.any(Object), + }), + ); + }); + + it('does not apply allowedAddresses when allowedDomains is active but unmatched', async () => { + await createHardenedOAuthFetch({ + allowedDomains: ['https://trusted.example.com'], + allowedAddresses: ['10.0.0.5:9444'], + })('https://untrusted.example.com:9444/token'); + + expect(mockCreateSSRFSafeUndiciConnect).toHaveBeenCalledWith(null, '9444'); + expect(mockFetch.mock.calls[0][1]).toEqual( + expect.objectContaining({ dispatcher: expect.any(Object) }), + ); + }); + + it('preserves admin-trusted allowedDomains bypass behavior', async () => { + mockIsOAuthUrlAllowed.mockReturnValueOnce(true); + + await createHardenedOAuthFetch({ + allowedDomains: ['https://auth.example.com'], + })('https://auth.example.com/token', { method: 'GET' }); + + expect(mockCreateSSRFSafeUndiciConnect).not.toHaveBeenCalled(); + expect(mockFetch.mock.calls[0][1]).not.toHaveProperty('dispatcher'); + }); + + it('normalizes allowedAddresses before caching dispatchers', async () => { + await createHardenedOAuthFetch({ + allowedAddresses: ['10.0.0.5:9443', '192.168.1.5:9443'], + })('https://auth.example.com:9443/token'); + + await createHardenedOAuthFetch({ + allowedAddresses: ['192.168.1.5:9443', '10.0.0.5:9443'], + })('https://auth.example.com:9443/token'); + + expect(mockCreateSSRFSafeUndiciConnect).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/api/src/mcp/oauth/hardenedFetch.ts b/packages/api/src/mcp/oauth/hardenedFetch.ts new file mode 100644 index 00000000000..18988af0c7d --- /dev/null +++ b/packages/api/src/mcp/oauth/hardenedFetch.ts @@ -0,0 +1,92 @@ +import { Agent } from 'undici'; +import type { Dispatcher } from 'undici'; +import type { FetchLike } from '@modelcontextprotocol/sdk/shared/transport'; +import { createSSRFSafeUndiciConnect, isOAuthUrlAllowed } from '~/auth'; +import { getOAuthUrlPort } from './url'; + +type FetchInitWithDispatcher = RequestInit & { + dispatcher?: Dispatcher; +}; + +const MAX_OAUTH_DISPATCHERS = 64; +const oauthDispatchers = new Map(); + +function shouldBypassSSRFDispatcher(url: string | URL, allowedDomains?: string[] | null): boolean { + if (!Array.isArray(allowedDomains) || allowedDomains.length === 0) { + return false; + } + + return isOAuthUrlAllowed(url.toString(), allowedDomains, null); +} + +function getDispatcherCacheKey(port: string, allowedAddresses?: string[] | null): string { + const normalizedAddresses = Array.isArray(allowedAddresses) + ? [...new Set(allowedAddresses)].sort().join('\n') + : ''; + return `${port}\0${normalizedAddresses}`; +} + +function evictOldestDispatcher(): void { + const oldestKey = oauthDispatchers.keys().next().value as string | undefined; + if (!oldestKey) { + return; + } + + const dispatcher = oauthDispatchers.get(oldestKey); + oauthDispatchers.delete(oldestKey); + dispatcher?.destroy(); +} + +function getOAuthDispatcher( + url: string | URL, + allowedDomains?: string[] | null, + allowedAddresses?: string[] | null, +): Agent | undefined { + if (shouldBypassSSRFDispatcher(url, allowedDomains)) { + return undefined; + } + + const parsedUrl = url instanceof URL ? url : new URL(url); + const port = getOAuthUrlPort(parsedUrl); + const effectiveAddresses = + Array.isArray(allowedDomains) && allowedDomains.length > 0 ? null : allowedAddresses; + const cacheKey = getDispatcherCacheKey(port, effectiveAddresses); + const cached = oauthDispatchers.get(cacheKey); + if (cached) { + oauthDispatchers.delete(cacheKey); + oauthDispatchers.set(cacheKey, cached); + return cached; + } + + if (oauthDispatchers.size >= MAX_OAUTH_DISPATCHERS) { + evictOldestDispatcher(); + } + + const dispatcher = new Agent({ + connect: createSSRFSafeUndiciConnect(effectiveAddresses, port), + }); + oauthDispatchers.set(cacheKey, dispatcher); + return dispatcher; +} + +export function createHardenedOAuthFetch({ + allowedDomains, + allowedAddresses, +}: { + allowedDomains?: string[] | null; + allowedAddresses?: string[] | null; +} = {}): FetchLike { + return async (url: string | URL, init?: RequestInit): Promise => { + const dispatcher = getOAuthDispatcher(url, allowedDomains, allowedAddresses); + const fetchInit: FetchInitWithDispatcher = + dispatcher != null ? { ...init, dispatcher } : { ...init }; + return fetch(url, fetchInit); + }; +} + +export function resetHardenedOAuthFetchDispatchers(): void { + for (const dispatcher of oauthDispatchers.values()) { + dispatcher.destroy(); + } + oauthDispatchers.clear(); +} diff --git a/packages/api/src/mcp/oauth/types.ts b/packages/api/src/mcp/oauth/types.ts index 20db2bc2a79..7e9d2d24b22 100644 --- a/packages/api/src/mcp/oauth/types.ts +++ b/packages/api/src/mcp/oauth/types.ts @@ -91,6 +91,10 @@ export interface MCPOAuthFlowMetadata extends FlowMetadata { authorizationUrl?: string; /** Custom headers for OAuth token exchange, persisted at flow initiation for the callback. */ oauthHeaders?: Record; + /** Domain allowlist captured at flow initiation for callback-time SSRF enforcement. */ + allowedDomains?: string[] | null; + /** Address exemptions captured at flow initiation for callback-time SSRF enforcement. */ + allowedAddresses?: string[] | null; /** True when the flow reused a stored client registration from a prior successful OAuth flow */ reusedStoredClient?: boolean; /** Tenant context captured at flow initiation for callback replay (SameSite cookies unavailable on cross-origin redirects) */ diff --git a/packages/api/src/mcp/oauth/url.ts b/packages/api/src/mcp/oauth/url.ts new file mode 100644 index 00000000000..6556507ed51 --- /dev/null +++ b/packages/api/src/mcp/oauth/url.ts @@ -0,0 +1,6 @@ +export function getOAuthUrlPort(url: URL): string { + if (url.port) return url.port; + if (url.protocol === 'http:') return '80'; + if (url.protocol === 'https:') return '443'; + return ''; +} diff --git a/packages/api/src/mcp/registry/MCPServerInspector.ts b/packages/api/src/mcp/registry/MCPServerInspector.ts index e4877e352b3..fde1c7d521e 100644 --- a/packages/api/src/mcp/registry/MCPServerInspector.ts +++ b/packages/api/src/mcp/registry/MCPServerInspector.ts @@ -106,7 +106,11 @@ export class MCPServerInspector { return; } - const result = await detectOAuthRequirement(this.config.url); + const result = await detectOAuthRequirement( + this.config.url, + this.allowedDomains, + this.allowedAddresses, + ); this.config.requiresOAuth = result.requiresOAuth; this.config.oauthMetadata = result.metadata; } diff --git a/packages/api/src/mcp/registry/__tests__/MCPReinitRecovery.integration.test.ts b/packages/api/src/mcp/registry/__tests__/MCPReinitRecovery.integration.test.ts index 9545486fde8..00a485cd859 100644 --- a/packages/api/src/mcp/registry/__tests__/MCPReinitRecovery.integration.test.ts +++ b/packages/api/src/mcp/registry/__tests__/MCPReinitRecovery.integration.test.ts @@ -46,6 +46,8 @@ jest.mock('@librechat/data-schemas', () => ({ jest.mock('~/auth', () => ({ createSSRFSafeUndiciConnect: jest.fn(() => undefined), + isOAuthUrlAllowed: jest.fn(() => false), + isSSRFTarget: jest.fn(() => false), resolveHostnameSSRF: jest.fn(async () => false), })); From d462bf4113f2e8ca1798cc4cdd91d0800b3d07f1 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 22 May 2026 20:43:34 -0400 Subject: [PATCH 059/395] =?UTF-8?q?=F0=9F=AA=AA=20fix:=20Consolidate=20MCP?= =?UTF-8?q?=20OAuth=20Policy=20(#13254)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🦉 feat: Implement proactive OAuth flow for connections without stored tokens * 🤝 fix: Enhance proactive OAuth flow handling in MCPConnectionFactory * fix: Add timeout handling for proactive OAuth flow in MCPConnectionFactory Co-authored-by: Copilot * fix: Refine proactive MCP OAuth flow * test: Cover proactive OAuth missing handler * fix: Require explicit MCP OAuth signal * fix: Consolidate MCP OAuth policy --------- Co-authored-by: Gil Assunção Co-authored-by: Copilot --- packages/api/src/mcp/MCPConnectionFactory.ts | 89 +++- packages/api/src/mcp/MCPManager.ts | 4 +- packages/api/src/mcp/UserConnectionManager.ts | 60 ++- .../__tests__/MCPConnectionFactory.test.ts | 450 ++++++++++++++++++ .../api/src/mcp/__tests__/MCPManager.test.ts | 114 +++++ packages/api/src/mcp/types/index.ts | 13 + packages/api/src/mcp/utils.ts | 10 + 7 files changed, 710 insertions(+), 30 deletions(-) diff --git a/packages/api/src/mcp/MCPConnectionFactory.ts b/packages/api/src/mcp/MCPConnectionFactory.ts index aa2dd5d6d30..68e3abd776c 100644 --- a/packages/api/src/mcp/MCPConnectionFactory.ts +++ b/packages/api/src/mcp/MCPConnectionFactory.ts @@ -7,7 +7,7 @@ import type { FlowStateManager } from '~/flow/manager'; import type * as t from './types'; import { MCPTokenStorage, MCPOAuthHandler, ReauthenticationRequiredError } from '~/mcp/oauth'; import { PENDING_STALE_MS, normalizeExpiresAt } from '~/flow/manager'; -import { sanitizeUrlForLogging, isClientRejectionMessage } from './utils'; +import { sanitizeUrlForLogging, isClientRejectionMessage, isOAuthServer } from './utils'; import { withTimeout } from '~/utils/promise'; import { MCPConnection } from './connection'; import { processMCPEnv } from '~/utils'; @@ -46,7 +46,7 @@ export class MCPConnectionFactory { /** Creates a new MCP connection with optional OAuth support */ static async create( basic: t.BasicConnectionOptions, - oauth?: t.OAuthConnectionOptions, + oauth?: t.OAuthConnectionOptions | t.UserConnectionContext, ): Promise { const factory = new this(basic, oauth); return factory.createConnection(); @@ -237,9 +237,23 @@ export class MCPConnectionFactory { let cleanupOAuthHandlers: (() => void) | null = null; if (this.useOAuth) { cleanupOAuthHandlers = this.handleOAuthEvents(connection); + } else { + const nonOAuthHandler = () => { + logger.info( + `${this.logPrefix} Server does not use OAuth; treating 401/403 as auth failure`, + ); + connection.emit('oauthFailed', new Error('Server does not use OAuth')); + }; + connection.on('oauthRequired', nonOAuthHandler); + cleanupOAuthHandlers = () => { + connection.removeListener('oauthRequired', nonOAuthHandler); + }; } try { + if (this.shouldInitiateOAuthBeforeConnect(oauthTokens)) { + await this.initiateOAuthBeforeConnect(connection); + } await this.attemptToConnect(connection); if (cleanupOAuthHandlers) { cleanupOAuthHandlers(); @@ -253,6 +267,77 @@ export class MCPConnectionFactory { } } + private shouldInitiateOAuthBeforeConnect(oauthTokens: MCPOAuthTokens | null): boolean { + if (!this.useOAuth || oauthTokens) { + return false; + } + return isOAuthServer(this.serverConfig); + } + + private getServerUrl(): string | undefined { + return 'url' in this.serverConfig ? this.serverConfig.url : undefined; + } + + private async initiateOAuthBeforeConnect(connection: MCPConnection): Promise { + const serverUrl = this.getServerUrl(); + if (!serverUrl) { + throw new Error(`${this.logPrefix} OAuth required but server URL is missing from config`); + } + + const oauthTimeout = this.connectionTimeout ?? 60000 * 2; + logger.info( + `${this.logPrefix} No stored tokens, proactively triggering OAuth flow before connecting (timeout: ${oauthTimeout}ms)`, + ); + + await new Promise((resolve, reject) => { + let timeoutId: ReturnType | null = null; + let oauthHandledListener: (() => void) | null = null; + let oauthFailedListener: ((error: Error) => void) | null = null; + + const cleanup = () => { + if (timeoutId) { + clearTimeout(timeoutId); + } + if (oauthHandledListener) { + connection.off('oauthHandled', oauthHandledListener); + } + if (oauthFailedListener) { + connection.off('oauthFailed', oauthFailedListener); + } + }; + + oauthHandledListener = () => { + cleanup(); + resolve(); + }; + + oauthFailedListener = (error: Error) => { + cleanup(); + reject(error); + }; + + timeoutId = setTimeout(() => { + cleanup(); + reject(new Error(`Proactive OAuth flow timeout after ${oauthTimeout}ms`)); + }, oauthTimeout); + + connection.once('oauthHandled', oauthHandledListener); + connection.once('oauthFailed', oauthFailedListener); + + const emitted = connection.emit('oauthRequired', { + serverName: this.serverName, + error: new Error('OAuth tokens missing before connection'), + serverUrl, + userId: this.userId, + }); + + if (!emitted) { + cleanup(); + reject(new Error('OAuth required but no handler is registered')); + } + }); + } + /** Retrieves existing OAuth tokens from storage or returns null */ protected async getOAuthTokens(): Promise { if (!this.tokenMethods?.findToken) return null; diff --git a/packages/api/src/mcp/MCPManager.ts b/packages/api/src/mcp/MCPManager.ts index c0a7acfd06e..7b0127b4755 100644 --- a/packages/api/src/mcp/MCPManager.ts +++ b/packages/api/src/mcp/MCPManager.ts @@ -18,7 +18,7 @@ import { preProcessGraphTokens } from '~/utils/graph'; import { formatToolContent } from './parsers'; import { MCPConnection } from './connection'; import { processMCPEnv } from '~/utils/env'; -import { isUserSourced } from './utils'; +import { isUserSourced, isOAuthServer } from './utils'; /** * Centralized manager for MCP server connections and tool execution. @@ -102,7 +102,7 @@ export class MCPManager extends UserConnectionManager { return { tools: null, oauthRequired: false, oauthUrl: null }; } - const useOAuth = Boolean(serverConfig.requiresOAuth || serverConfig.oauthMetadata); + const useOAuth = isOAuthServer(serverConfig); const registry = MCPServersRegistry.getInstance(); const useSSRFProtection = registry.shouldEnableSSRFProtection(); diff --git a/packages/api/src/mcp/UserConnectionManager.ts b/packages/api/src/mcp/UserConnectionManager.ts index f352497f786..2a420861c3a 100644 --- a/packages/api/src/mcp/UserConnectionManager.ts +++ b/packages/api/src/mcp/UserConnectionManager.ts @@ -4,7 +4,7 @@ import type * as t from './types'; import { MCPServersRegistry } from '~/mcp/registry/MCPServersRegistry'; import { ConnectionsRepository } from '~/mcp/ConnectionsRepository'; import { MCPConnectionFactory } from '~/mcp/MCPConnectionFactory'; -import { isUserSourced } from './utils'; +import { isUserSourced, isOAuthServer } from './utils'; import { MCPConnection } from './connection'; import { mcpConfig } from './mcpConfig'; @@ -35,14 +35,7 @@ export abstract class UserConnectionManager { } /** Gets or creates a connection for a specific user, coalescing concurrent attempts */ - public async getUserConnection( - opts: { - serverName: string; - forceNew?: boolean; - /** Pre-resolved config for config-source servers not in YAML/DB */ - serverConfig?: t.ParsedServerConfig; - } & Omit, - ): Promise { + public async getUserConnection(opts: t.UserMCPConnectionOptions): Promise { const { serverName, forceNew, user } = opts; const userId = user?.id; if (!userId) { @@ -89,11 +82,7 @@ export abstract class UserConnectionManager { returnOnOAuth = false, connectionTimeout, serverConfig: providedConfig, - }: { - serverName: string; - forceNew?: boolean; - serverConfig?: t.ParsedServerConfig; - } & Omit, + }: t.UserMCPConnectionOptions, userId: string, ): Promise { if (await this.appConnections!.has(serverName)) { @@ -161,16 +150,26 @@ export abstract class UserConnectionManager { try { const registry = MCPServersRegistry.getInstance(); - connection = await MCPConnectionFactory.create( - { - serverConfig: config, - serverName: serverName, - dbSourced: isUserSourced(config), - useSSRFProtection: registry.shouldEnableSSRFProtection(), - allowedDomains: registry.getAllowedDomains(), - allowedAddresses: registry.getAllowedAddresses(), - }, - { + const basic: t.BasicConnectionOptions = { + serverConfig: config, + serverName: serverName, + dbSourced: isUserSourced(config), + useSSRFProtection: registry.shouldEnableSSRFProtection(), + allowedDomains: registry.getAllowedDomains(), + allowedAddresses: registry.getAllowedAddresses(), + }; + + const useOAuth = isOAuthServer(config); + let connectionOptions: t.OAuthConnectionOptions | t.UserConnectionContext; + if (useOAuth) { + if (!flowManager) { + throw new McpError( + ErrorCode.InvalidRequest, + `[MCP][User: ${userId}] OAuth server "${serverName}" requires a flowManager`, + ); + } + + connectionOptions = { useOAuth: true, user: user, customUserVars: customUserVars, @@ -182,8 +181,17 @@ export abstract class UserConnectionManager { returnOnOAuth: returnOnOAuth, requestBody: requestBody, connectionTimeout: connectionTimeout, - }, - ); + }; + } else { + connectionOptions = { + user, + customUserVars, + requestBody, + connectionTimeout, + }; + } + + connection = await MCPConnectionFactory.create(basic, connectionOptions); if (!(await connection?.isConnected())) { throw new Error('Failed to establish connection after initialization attempt.'); diff --git a/packages/api/src/mcp/__tests__/MCPConnectionFactory.test.ts b/packages/api/src/mcp/__tests__/MCPConnectionFactory.test.ts index 0467a38c276..5a0787868d3 100644 --- a/packages/api/src/mcp/__tests__/MCPConnectionFactory.test.ts +++ b/packages/api/src/mcp/__tests__/MCPConnectionFactory.test.ts @@ -95,6 +95,34 @@ describe('MCPConnectionFactory', () => { expect(mockConnectionInstance.connect).toHaveBeenCalled(); }); + it('should register fallback oauthRequired handler for non-OAuth connections', async () => { + const basicOptions = { + serverName: 'test-server', + serverConfig: mockServerConfig, + }; + + mockConnectionInstance.isConnected.mockResolvedValue(true); + + await MCPConnectionFactory.create(basicOptions); + + expect(mockConnectionInstance.on).toHaveBeenCalledWith('oauthRequired', expect.any(Function)); + + const onCall = (mockConnectionInstance.on as jest.Mock).mock.calls.find( + ([event]: [string]) => event === 'oauthRequired', + ); + const handler = onCall![1] as () => void; + handler(); + + expect(mockConnectionInstance.emit).toHaveBeenCalledWith( + 'oauthFailed', + expect.objectContaining({ message: 'Server does not use OAuth' }), + ); + expect(mockConnectionInstance.removeListener).toHaveBeenCalledWith( + 'oauthRequired', + expect.any(Function), + ); + }); + it('should create a connection with OAuth', async () => { const basicOptions = { serverName: 'test-server', @@ -1002,4 +1030,426 @@ describe('MCPConnectionFactory', () => { expect(mockLogger.debug).toHaveBeenCalled(); }); }); + + describe('proactive OAuth flow', () => { + const makeOAuthServerConfig = (): t.MCPOptions => + ({ + type: 'streamable-http' as const, + url: 'https://bigquery.googleapis.com/mcp', + initTimeout: 5000, + requiresOAuth: true, + }) as unknown as t.MCPOptions; + + const makeOAuthOptions = () => ({ + useOAuth: true as const, + user: mockUser, + flowManager: mockFlowManager, + tokenMethods: { + findToken: jest.fn(), + createToken: jest.fn(), + updateToken: jest.fn(), + deleteTokens: jest.fn(), + }, + }); + + function wireEventHandlers(instance: jest.Mocked) { + type Listener = (...args: unknown[]) => void; + + const handlers: Record = {}; + const onceWrappers = new Map(); + const key = (event: string | symbol): string => + typeof event === 'symbol' ? event.toString() : event; + const addHandler = (event: string | symbol, handler: Listener) => { + (handlers[key(event)] ??= []).push(handler); + }; + const removeHandler = (event: string | symbol, handler: Listener) => { + const list = handlers[key(event)]; + if (!list) { + return; + } + const wrapped = onceWrappers.get(handler); + const handlerToRemove = wrapped ?? handler; + const index = list.indexOf(handlerToRemove); + if (index !== -1) { + list.splice(index, 1); + } + if (wrapped) { + onceWrappers.delete(handler); + } + }; + + instance.on.mockImplementation((event: string | symbol, handler: Listener) => { + addHandler(event, handler); + return instance; + }); + + instance.once.mockImplementation((event: string | symbol, handler: Listener) => { + const wrapped: Listener = (...args) => { + removeHandler(event, handler); + handler(...args); + }; + onceWrappers.set(handler, wrapped); + addHandler(event, wrapped); + return instance; + }); + + instance.off.mockImplementation((event: string | symbol, handler: Listener) => { + removeHandler(event, handler); + return instance; + }); + + instance.removeListener.mockImplementation((event: string | symbol, handler: Listener) => { + removeHandler(event, handler); + return instance; + }); + + instance.emit.mockImplementation((event: string | symbol, ...args: unknown[]) => { + const list = handlers[key(event)]; + if (!list || list.length === 0) { + return false; + } + for (const fn of [...list]) { + fn(...args); + } + return true; + }); + + return handlers; + } + + it('should trigger proactive OAuth when requiresOAuth and no tokens', async () => { + const serverConfig = makeOAuthServerConfig(); + const oauthOptions = makeOAuthOptions(); + + mockProcessMCPEnv.mockReturnValue(serverConfig); + mockFlowManager.createFlowWithHandler.mockRejectedValue(new Error('no tokens')); + + const mockTokens: MCPOAuthTokens = { + access_token: 'bq-token', + token_type: 'Bearer', + obtained_at: Date.now(), + }; + + const mockFlowData = { + authorizationUrl: 'https://accounts.google.com/o/oauth2/auth?state=xyz', + flowId: 'flow-bq', + flowMetadata: { + serverName: 'bigquery', + userId: 'user123', + serverUrl: 'https://bigquery.googleapis.com/mcp', + state: 'state-xyz', + clientInfo: { client_id: 'bq-client' }, + }, + }; + + mockMCPOAuthHandler.generateFlowId.mockReturnValue('flow-bq'); + mockMCPOAuthHandler.initiateOAuthFlow.mockResolvedValue(mockFlowData); + mockFlowManager.getFlowState.mockResolvedValue(null); + mockFlowManager.createFlow.mockResolvedValue(mockTokens); + + wireEventHandlers(mockConnectionInstance); + mockConnectionInstance.isConnected.mockResolvedValue(true); + + const connection = await MCPConnectionFactory.create( + { serverName: 'bigquery', serverConfig }, + oauthOptions, + ); + + expect(connection).toBe(mockConnectionInstance); + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringContaining('proactively triggering OAuth flow'), + ); + expect(mockConnectionInstance.setOAuthTokens).toHaveBeenCalledWith(mockTokens); + expect(mockConnectionInstance.connect).toHaveBeenCalled(); + }); + + it('should trigger proactive OAuth when oauth is configured without requiresOAuth', async () => { + const serverConfig = { + type: 'streamable-http' as const, + url: 'https://drivemcp.googleapis.com/mcp/v1', + initTimeout: 5000, + oauth: { + authorization_url: 'https://accounts.google.com/o/oauth2/v2/auth', + token_url: 'https://oauth2.googleapis.com/token', + }, + } as t.ParsedServerConfig; + const oauthOptions = makeOAuthOptions(); + + mockProcessMCPEnv.mockReturnValue(serverConfig); + mockFlowManager.createFlowWithHandler.mockResolvedValue(null); + + const mockTokens: MCPOAuthTokens = { + access_token: 'drive-token', + token_type: 'Bearer', + obtained_at: Date.now(), + }; + + mockMCPOAuthHandler.generateFlowId.mockReturnValue('flow-drive'); + mockMCPOAuthHandler.initiateOAuthFlow.mockResolvedValue({ + authorizationUrl: 'https://accounts.google.com/o/oauth2/auth?state=drive', + flowId: 'flow-drive', + flowMetadata: { + serverName: 'drive', + userId: 'user123', + serverUrl: 'https://drivemcp.googleapis.com/mcp/v1', + state: 'state-drive', + }, + }); + mockFlowManager.getFlowState.mockResolvedValue(null); + mockFlowManager.createFlow.mockResolvedValue(mockTokens); + + wireEventHandlers(mockConnectionInstance); + mockConnectionInstance.isConnected.mockResolvedValue(true); + + const connection = await MCPConnectionFactory.create( + { serverName: 'drive', serverConfig }, + oauthOptions, + ); + + expect(connection).toBe(mockConnectionInstance); + expect(mockMCPOAuthHandler.initiateOAuthFlow).toHaveBeenCalled(); + expect(mockConnectionInstance.setOAuthTokens).toHaveBeenCalledWith(mockTokens); + expect(mockConnectionInstance.connect).toHaveBeenCalled(); + }); + + it('should not trigger proactive OAuth when only OAuth metadata is present', async () => { + const serverConfig = { + type: 'streamable-http' as const, + url: 'https://metadata-only.example.com/mcp', + initTimeout: 5000, + oauthMetadata: { + authorization_servers: ['https://auth.example.com/'], + }, + } as t.ParsedServerConfig; + const oauthOptions = makeOAuthOptions(); + + mockProcessMCPEnv.mockReturnValue(serverConfig); + mockFlowManager.createFlowWithHandler.mockResolvedValue(null); + mockConnectionInstance.isConnected.mockResolvedValue(true); + + const connection = await MCPConnectionFactory.create( + { serverName: 'metadata-only', serverConfig }, + oauthOptions, + ); + + expect(connection).toBe(mockConnectionInstance); + expect(mockMCPOAuthHandler.initiateOAuthFlow).not.toHaveBeenCalled(); + expect(mockConnectionInstance.connect).toHaveBeenCalled(); + }); + + it('should NOT trigger proactive OAuth when useOAuth is true but requiresOAuth is absent', async () => { + const serverConfig = { + command: 'node', + args: ['server.js'], + initTimeout: 5000, + } as t.MCPOptions; + + const oauthOptions = makeOAuthOptions(); + + mockProcessMCPEnv.mockReturnValue(serverConfig); + mockFlowManager.createFlowWithHandler.mockRejectedValue(new Error('no tokens')); + mockConnectionInstance.isConnected.mockResolvedValue(true); + + const connection = await MCPConnectionFactory.create( + { serverName: 'test-server', serverConfig }, + oauthOptions, + ); + + expect(connection).toBe(mockConnectionInstance); + expect(mockLogger.info).not.toHaveBeenCalledWith( + expect.stringContaining('proactively triggering OAuth flow'), + ); + }); + + it('should not trigger proactive OAuth when requiresOAuth is explicitly false', async () => { + const serverConfig = { + type: 'streamable-http' as const, + url: 'https://api.example.com/mcp', + initTimeout: 5000, + requiresOAuth: false, + oauth: { + authorization_url: 'https://auth.example.com/oauth/authorize', + token_url: 'https://auth.example.com/oauth/token', + }, + } as t.MCPOptions; + const oauthOptions = makeOAuthOptions(); + + mockProcessMCPEnv.mockReturnValue(serverConfig); + mockFlowManager.createFlowWithHandler.mockResolvedValue(null); + mockConnectionInstance.isConnected.mockResolvedValue(true); + + const connection = await MCPConnectionFactory.create( + { serverName: 'test-server', serverConfig }, + oauthOptions, + ); + + expect(connection).toBe(mockConnectionInstance); + expect(mockMCPOAuthHandler.initiateOAuthFlow).not.toHaveBeenCalled(); + expect(mockLogger.info).not.toHaveBeenCalledWith( + expect.stringContaining('proactively triggering OAuth flow'), + ); + }); + + it('should skip proactive OAuth when tokens already exist', async () => { + const serverConfig = makeOAuthServerConfig(); + const oauthOptions = makeOAuthOptions(); + + mockProcessMCPEnv.mockReturnValue(serverConfig); + const existingTokens: MCPOAuthTokens = { + access_token: 'existing-token', + token_type: 'Bearer', + obtained_at: Date.now(), + }; + mockFlowManager.createFlowWithHandler.mockResolvedValue(existingTokens); + mockConnectionInstance.isConnected.mockResolvedValue(true); + + const connection = await MCPConnectionFactory.create( + { serverName: 'bigquery', serverConfig }, + oauthOptions, + ); + + expect(connection).toBe(mockConnectionInstance); + expect(mockLogger.info).not.toHaveBeenCalledWith( + expect.stringContaining('proactively triggering OAuth flow'), + ); + }); + + it('should reject when proactive OAuth flow fails', async () => { + const serverConfig = makeOAuthServerConfig(); + const oauthOptions = { + ...makeOAuthOptions(), + returnOnOAuth: true, + oauthStart: jest.fn(), + }; + + mockProcessMCPEnv.mockReturnValue(serverConfig); + mockFlowManager.createFlowWithHandler.mockRejectedValue(new Error('no tokens')); + + const mockFlowData = { + authorizationUrl: 'https://accounts.google.com/o/oauth2/auth', + flowId: 'flow-bq', + flowMetadata: { + serverName: 'bigquery', + userId: 'user123', + serverUrl: 'https://bigquery.googleapis.com/mcp', + state: 'state-xyz', + }, + }; + + mockMCPOAuthHandler.generateFlowId.mockReturnValue('flow-bq'); + mockMCPOAuthHandler.initiateOAuthFlow.mockResolvedValue(mockFlowData); + mockFlowManager.getFlowState.mockResolvedValue(null); + mockFlowManager.createFlow.mockReturnValue(new Promise(() => {})); + + wireEventHandlers(mockConnectionInstance); + mockConnectionInstance.isConnected.mockResolvedValue(false); + + await expect( + MCPConnectionFactory.create({ serverName: 'bigquery', serverConfig }, oauthOptions), + ).rejects.toThrow('OAuth flow initiated - return early'); + }); + + it('should throw when requiresOAuth is true but url is missing', async () => { + const serverConfig = { + type: 'streamable-http' as const, + initTimeout: 5000, + requiresOAuth: true, + } as unknown as t.MCPOptions; + + const oauthOptions = makeOAuthOptions(); + + mockProcessMCPEnv.mockReturnValue(serverConfig); + mockFlowManager.createFlowWithHandler.mockRejectedValue(new Error('no tokens')); + + wireEventHandlers(mockConnectionInstance); + mockConnectionInstance.isConnected.mockResolvedValue(false); + + await expect( + MCPConnectionFactory.create({ serverName: 'no-url', serverConfig }, oauthOptions), + ).rejects.toThrow('server URL is missing'); + }); + + it('should reject when proactive OAuth has no registered handler', async () => { + const serverConfig = makeOAuthServerConfig(); + const oauthOptions = makeOAuthOptions(); + + mockProcessMCPEnv.mockReturnValue(serverConfig); + mockFlowManager.createFlowWithHandler.mockResolvedValue(null); + mockConnectionInstance.on.mockReturnValue(mockConnectionInstance); + mockConnectionInstance.once.mockReturnValue(mockConnectionInstance); + mockConnectionInstance.off.mockReturnValue(mockConnectionInstance); + mockConnectionInstance.emit.mockReturnValue(false); + + await expect( + MCPConnectionFactory.create({ serverName: 'bigquery', serverConfig }, oauthOptions), + ).rejects.toThrow('OAuth required but no handler is registered'); + + expect(mockConnectionInstance.connect).not.toHaveBeenCalled(); + }); + + it('should clean up cross-listeners when oauthHandled fires', async () => { + const serverConfig = makeOAuthServerConfig(); + const oauthOptions = makeOAuthOptions(); + + mockProcessMCPEnv.mockReturnValue(serverConfig); + mockFlowManager.createFlowWithHandler.mockRejectedValue(new Error('no tokens')); + + const mockTokens: MCPOAuthTokens = { + access_token: 'cleanup-token', + token_type: 'Bearer', + obtained_at: Date.now(), + }; + + mockMCPOAuthHandler.generateFlowId.mockReturnValue('flow-cleanup'); + mockMCPOAuthHandler.initiateOAuthFlow.mockResolvedValue({ + authorizationUrl: 'https://auth.example.com', + flowId: 'flow-cleanup', + flowMetadata: { + serverName: 'bigquery', + userId: 'user123', + serverUrl: 'https://bigquery.googleapis.com/mcp', + state: 'state-cleanup', + clientInfo: { client_id: 'client-cleanup' }, + }, + }); + mockFlowManager.getFlowState.mockResolvedValue(null); + mockFlowManager.createFlow.mockResolvedValue(mockTokens); + + const handlers = wireEventHandlers(mockConnectionInstance); + mockConnectionInstance.isConnected.mockResolvedValue(true); + + await MCPConnectionFactory.create({ serverName: 'bigquery', serverConfig }, oauthOptions); + + // After oauthHandled resolved, the oauthFailed listener should have been removed + const failedListeners = handlers['oauthFailed'] ?? []; + expect(failedListeners.length).toBe(0); + }); + + it('should not trigger proactive OAuth during tool discovery', async () => { + const serverConfig = makeOAuthServerConfig(); + const oauthOptions = { + ...makeOAuthOptions(), + oauthStart: jest.fn(), + }; + const mockTools = [ + { name: 'tool1', description: 'First tool', inputSchema: { type: 'object' } }, + ]; + + mockProcessMCPEnv.mockReturnValue(serverConfig); + mockFlowManager.createFlowWithHandler.mockResolvedValue(null); + mockConnectionInstance.connect.mockResolvedValue(undefined); + mockConnectionInstance.isConnected.mockResolvedValue(true); + mockConnectionInstance.fetchTools = jest.fn().mockResolvedValue(mockTools); + + const result = await MCPConnectionFactory.discoverTools( + { serverName: 'bigquery', serverConfig }, + oauthOptions, + ); + + expect(result.tools).toEqual(mockTools); + expect(result.oauthRequired).toBe(false); + expect(oauthOptions.oauthStart).not.toHaveBeenCalled(); + expect(mockMCPOAuthHandler.initiateOAuthFlow).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/api/src/mcp/__tests__/MCPManager.test.ts b/packages/api/src/mcp/__tests__/MCPManager.test.ts index 45a51e01aca..cd30a75318f 100644 --- a/packages/api/src/mcp/__tests__/MCPManager.test.ts +++ b/packages/api/src/mcp/__tests__/MCPManager.test.ts @@ -905,6 +905,28 @@ describe('MCPManager', () => { ); }); + it('should treat configured oauth as OAuth when requiresOAuth is unset', async () => { + mockAppConnections({ + get: jest.fn().mockResolvedValue(null), + }); + + (mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue({ + type: 'sse', + url: 'https://api.example.com', + oauth: { + authorization_url: 'https://auth.example.com/oauth/authorize', + token_url: 'https://auth.example.com/oauth/token', + }, + }); + + const manager = await MCPManager.createInstance(newMCPServersConfig()); + const result = await manager.discoverServerTools({ serverName }); + + expect(result.tools).toBeNull(); + expect(result.oauthRequired).toBe(true); + expect(MCPConnectionFactory.discoverTools).not.toHaveBeenCalled(); + }); + it('should return OAuth info when server requires OAuth but no user provided', async () => { mockAppConnections({ get: jest.fn().mockResolvedValue(null), @@ -967,4 +989,96 @@ describe('MCPManager', () => { ); }); }); + + describe('getUserConnection - useOAuth derivation', () => { + const mockUser = { id: userId, email: 'test@example.com' } as unknown as IUser; + const mockFlowManager = { + createFlow: jest.fn(), + getFlowState: jest.fn(), + deleteFlow: jest.fn(), + }; + const mockConnection = { + isConnected: jest.fn().mockResolvedValue(true), + isStale: jest.fn().mockReturnValue(false), + disconnect: jest.fn(), + } as unknown as MCPConnection; + + it('should pass useOAuth for servers with configured oauth and no requiresOAuth value', async () => { + mockAppConnections({ + has: jest.fn().mockResolvedValue(false), + }); + + (mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue({ + type: 'sse', + url: 'https://oauth-mcp.example.com', + oauth: { + authorization_url: 'https://auth.example.com/oauth/authorize', + token_url: 'https://auth.example.com/oauth/token', + }, + }); + + (MCPConnectionFactory.create as jest.Mock).mockResolvedValue(mockConnection); + + const manager = await MCPManager.createInstance(newMCPServersConfig()); + await manager.getUserConnection({ + serverName, + user: mockUser, + flowManager: mockFlowManager as unknown as t.UserMCPConnectionOptions['flowManager'], + }); + + expect(MCPConnectionFactory.create).toHaveBeenCalledWith( + expect.objectContaining({ serverName }), + expect.objectContaining({ useOAuth: true }), + ); + }); + + it('should not pass useOAuth for servers with requiresOAuth: false', async () => { + mockAppConnections({ + has: jest.fn().mockResolvedValue(false), + }); + + (mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue({ + type: 'streamable-http', + url: 'http://private-mcp.svc:5446/mcp', + requiresOAuth: false, + oauth: { + authorization_url: 'https://auth.example.com/oauth/authorize', + token_url: 'https://auth.example.com/oauth/token', + }, + }); + + (MCPConnectionFactory.create as jest.Mock).mockResolvedValue(mockConnection); + + const manager = await MCPManager.createInstance(newMCPServersConfig()); + await manager.getUserConnection({ + serverName, + user: mockUser, + }); + + expect(MCPConnectionFactory.create).toHaveBeenCalledWith( + expect.objectContaining({ serverName }), + expect.not.objectContaining({ useOAuth: true }), + ); + }); + + it('should throw when OAuth server lacks flowManager', async () => { + mockAppConnections({ + has: jest.fn().mockResolvedValue(false), + }); + + (mockRegistryInstance.getServerConfig as jest.Mock).mockResolvedValue({ + type: 'sse', + url: 'https://oauth-mcp.example.com', + requiresOAuth: true, + }); + + const manager = await MCPManager.createInstance(newMCPServersConfig()); + await expect( + manager.getUserConnection({ + serverName, + user: mockUser, + }), + ).rejects.toThrow('requires a flowManager'); + }); + }); }); diff --git a/packages/api/src/mcp/types/index.ts b/packages/api/src/mcp/types/index.ts index 6bc593c1eb9..ed85c13fa97 100644 --- a/packages/api/src/mcp/types/index.ts +++ b/packages/api/src/mcp/types/index.ts @@ -204,6 +204,19 @@ export interface OAuthConnectionOptions extends UserConnectionContext { returnOnOAuth?: boolean; } +/** Options accepted by UserConnectionManager.getUserConnection. OAuth fields are optional. */ +export interface UserMCPConnectionOptions extends UserConnectionContext { + serverName: string; + forceNew?: boolean; + serverConfig?: ParsedServerConfig; + flowManager?: FlowStateManager; + tokenMethods?: TokenMethods; + signal?: AbortSignal; + oauthStart?: (authURL: string) => Promise; + oauthEnd?: () => Promise; + returnOnOAuth?: boolean; +} + export interface ToolDiscoveryOptions { serverName: string; user?: IUser; diff --git a/packages/api/src/mcp/utils.ts b/packages/api/src/mcp/utils.ts index 9befc3c5499..872f43002cd 100644 --- a/packages/api/src/mcp/utils.ts +++ b/packages/api/src/mcp/utils.ts @@ -3,6 +3,16 @@ import type { ParsedServerConfig } from '~/mcp/types'; export const mcpToolPattern = new RegExp(`^.+${Constants.mcp_delimiter}.+$`); +/** Whether a server should use MCP OAuth handling. */ +export function isOAuthServer( + config: Pick, +): boolean { + if (config.requiresOAuth === false) { + return false; + } + return config.requiresOAuth === true || config.oauth != null; +} + /** Checks that `customUserVars` is present AND non-empty (guards against truthy `{}`) */ export function hasCustomUserVars(config: Pick): boolean { return !!config.customUserVars && Object.keys(config.customUserVars).length > 0; From bd64251eb98bee9a0ec8514166dd4b5e9e1d2724 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 22 May 2026 20:46:14 -0400 Subject: [PATCH 060/395] =?UTF-8?q?=F0=9F=AA=AA=20fix:=20Prevent=20MCP=20S?= =?UTF-8?q?erver=20Name=20Collisions=20(#13256)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: prevent MCP server name collisions * chore: address MCP registry review nits * fix: reserve MCP config names from request context * chore: format MCP registry changes * chore: address MCP collision review findings --- api/server/controllers/mcp.js | 8 +- api/server/routes/__tests__/mcp.spec.js | 49 ++++++++ api/server/services/MCP.js | 31 +++-- api/server/services/__tests__/MCP.spec.js | 31 ++++- .../src/mcp/registry/MCPServersRegistry.ts | 117 ++++++++++++++---- .../ServerConfigsRepositoryInterface.ts | 2 +- .../__tests__/MCPServersRegistry.test.ts | 90 +++++++++++++- .../__tests__/ServerConfigsDB.test.ts | 7 ++ .../__tests__/ensureConfigServers.test.ts | 2 +- .../src/mcp/registry/db/ServerConfigsDB.ts | 6 +- .../src/methods/mcpServer.spec.ts | 31 +++++ .../data-schemas/src/methods/mcpServer.ts | 29 +++-- 12 files changed, 354 insertions(+), 49 deletions(-) diff --git a/api/server/controllers/mcp.js b/api/server/controllers/mcp.js index 8d20cbc82ca..a50680f3d16 100644 --- a/api/server/controllers/mcp.js +++ b/api/server/controllers/mcp.js @@ -14,7 +14,11 @@ const { isMCPInspectionFailedError, } = require('@librechat/api'); const { Constants, MCPServerUserInputSchema } = require('librechat-data-provider'); -const { resolveConfigServers, resolveAllMcpConfigs } = require('~/server/services/MCP'); +const { + resolveConfigServers, + resolveMcpConfigNames, + resolveAllMcpConfigs, +} = require('~/server/services/MCP'); const { cacheMCPServerTools, getMCPServerTools } = require('~/server/services/Config'); const { getMCPManager, getMCPServersRegistry } = require('~/config'); @@ -213,11 +217,13 @@ const createMCPServerController = async (req, res) => { errors: validation.error.errors, }); } + 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, diff --git a/api/server/routes/__tests__/mcp.spec.js b/api/server/routes/__tests__/mcp.spec.js index 30a86a53b2f..3a3e6ac7964 100644 --- a/api/server/routes/__tests__/mcp.spec.js +++ b/api/server/routes/__tests__/mcp.spec.js @@ -108,9 +108,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(), })); @@ -171,6 +173,8 @@ describe('MCP Routes', () => { beforeEach(() => { jest.clearAllMocks(); + mockResolveAllMcpConfigs.mockResolvedValue({}); + mockResolveMcpConfigNames.mockResolvedValue([]); }); describe('GET /:serverName/oauth/initiate', () => { @@ -2155,6 +2159,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 +2319,22 @@ describe('MCP Routes', () => { expect(response.status).toBe(500); expect(response.body).toEqual({ message: 'Database connection failed' }); }); + + 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', () => { diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js index c691c4330a4..85c29ddfdca 100644 --- a/api/server/services/MCP.js +++ b/api/server/services/MCP.js @@ -54,6 +54,15 @@ function evictStale(map, ttl) { const unavailableMsg = "This tool's MCP server is temporarily unavailable. Please try again shortly."; +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 +72,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 +83,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 +104,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 || {}); @@ -874,6 +890,7 @@ module.exports = { createMCPTools, getMCPSetupData, resolveConfigServers, + resolveMcpConfigNames, resolveAllMcpConfigs, checkOAuthFlowStatus, getServerConnectionStatus, diff --git a/api/server/services/__tests__/MCP.spec.js b/api/server/services/__tests__/MCP.spec.js index 2de88cfcd05..cfc71b77529 100644 --- a/api/server/services/__tests__/MCP.spec.js +++ b/api/server/services/__tests__/MCP.spec.js @@ -48,7 +48,7 @@ jest.mock('~/server/services/Tools/mcp', () => ({ })); const { getAppConfig } = require('~/server/services/Config'); -const { resolveConfigServers, resolveAllMcpConfigs } = require('../MCP'); +const { resolveConfigServers, resolveMcpConfigNames, resolveAllMcpConfigs } = require('../MCP'); describe('resolveConfigServers', () => { beforeEach(() => jest.clearAllMocks()); @@ -93,6 +93,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()); diff --git a/packages/api/src/mcp/registry/MCPServersRegistry.ts b/packages/api/src/mcp/registry/MCPServersRegistry.ts index 534cb3fd8b2..81c59878ffa 100644 --- a/packages/api/src/mcp/registry/MCPServersRegistry.ts +++ b/packages/api/src/mcp/registry/MCPServersRegistry.ts @@ -35,12 +35,12 @@ const CONFIG_SERVER_INIT_TIMEOUT_MS = (() => { * - Config Cache (configCacheRepo): Admin-defined configs from Config overrides, lazily initialized * - DB Repository (dbConfigsRepo): User-provided configs created at runtime (MongoDB + ACL) * - * Query priority: YAML cache → Config cache → DB. + * Query priority: Config cache → YAML cache → DB. */ export class MCPServersRegistry { private static instance: MCPServersRegistry; - private readonly dbConfigsRepo: IServerConfigsRepositoryInterface; + private readonly dbConfigsRepo: ServerConfigsDB; private readonly cacheConfigsRepo: IServerConfigsRepositoryInterface; private readonly configCacheRepo: IServerConfigsRepositoryInterface; private readonly allowedDomains?: string[] | null; @@ -164,8 +164,7 @@ export class MCPServersRegistry { /** * Returns all server configs visible to the given user. - * YAML and Config tiers are mutually exclusive by design (`ensureConfigServers` filters - * YAML names), so the spread order only matters for User DB (highest priority) overriding both. + * Operator-managed servers (YAML + Config) override User DB servers on name collisions. */ public async getAllServerConfigs( userId?: string, @@ -176,11 +175,13 @@ export class MCPServersRegistry { return this.getBaseServerConfigs(userId, role); } const base = await this.getBaseServerConfigs(userId, role); - return { ...configServers, ...base }; + this.warnOnOperatorManagedNameCollisions(configServers, base, 'Config'); + return { ...base, ...configServers }; } /** * Returns YAML + user-DB server configs, cached via `readThroughCacheAll`. + * YAML wins on name collisions so a user-created server cannot hide global config. * Always called by `getAllServerConfigs` so the DB query is amortized across * requests within the TTL window regardless of whether `configServers` is present. */ @@ -214,10 +215,14 @@ export class MCPServersRegistry { userId?: string, role?: string, ): Promise> { - const result = { - ...(await this.cacheConfigsRepo.getAll()), - ...(await this.dbConfigsRepo.getAll(userId, role)), - }; + const [dbConfigs, yamlConfigs] = await Promise.all([ + this.dbConfigsRepo.getAll(userId, role), + this.cacheConfigsRepo.getAll(), + ]); + + this.warnOnOperatorManagedNameCollisions(yamlConfigs, dbConfigs, 'YAML'); + + const result = { ...dbConfigs, ...yamlConfigs }; await this.readThroughCacheAll.set(cacheKey, result); return result; @@ -230,15 +235,14 @@ export class MCPServersRegistry { public async addServerStub( serverName: string, config: t.MCPOptions, - storageLocation: 'CACHE' | 'DB', + storageLocation: 'CACHE', userId?: string, ): Promise { const configRepo = this.getConfigRepository(storageLocation); - const source: t.MCPServerSource = storageLocation === 'CACHE' ? 'yaml' : 'user'; - const stubConfig: t.ParsedServerConfig = { ...config, inspectionFailed: true, source }; + const stubConfig: t.ParsedServerConfig = { ...config, inspectionFailed: true, source: 'yaml' }; const result = await configRepo.add(serverName, stubConfig, userId); - await this.readThroughCache.delete(this.getReadThroughCacheKey(serverName, userId)); - await this.readThroughCache.delete(this.getReadThroughCacheKey(serverName)); + await this.invalidateServerReadCaches(result.serverName, userId); + this.resetYamlServerNamesMemo(); return result; } @@ -247,6 +251,7 @@ export class MCPServersRegistry { config: t.MCPOptions, storageLocation: 'CACHE' | 'DB', userId?: string, + reservedServerNames?: Iterable, ): Promise { const configRepo = this.getConfigRepository(storageLocation); let parsedConfig: t.ParsedServerConfig; @@ -269,7 +274,20 @@ export class MCPServersRegistry { ...parsedConfig, source: (storageLocation === 'CACHE' ? 'yaml' : 'user') as t.MCPServerSource, }; - return await configRepo.add(serverName, tagged, userId); + const result = + storageLocation === 'DB' + ? await this.dbConfigsRepo.add( + serverName, + tagged, + userId, + await this.getOperatorManagedServerNames(reservedServerNames), + ) + : await configRepo.add(serverName, tagged, userId); + await this.invalidateServerReadCaches(result.serverName, userId); + if (storageLocation === 'CACHE') { + this.resetYamlServerNamesMemo(); + } + return result; } /** @@ -312,10 +330,7 @@ export class MCPServersRegistry { const updatedConfig = { ...parsedConfig, updatedAt: Date.now() }; await configRepo.update(serverName, updatedConfig, userId); - await this.readThroughCache.delete(this.getReadThroughCacheKey(serverName, userId)); - await this.readThroughCache.delete(this.getReadThroughCacheKey(serverName)); - // Full clear required: getAllServerConfigs is keyed by userId with no reverse index to enumerate cached keys - await this.readThroughCacheAll.clear(); + await this.invalidateServerReadCaches(serverName, userId); return { serverName, config: updatedConfig }; } @@ -359,6 +374,7 @@ export class MCPServersRegistry { throw new MCPInspectionFailedError(serverName, error as Error); } await configRepo.update(serverName, parsedConfig, userId); + await this.invalidateServerReadCaches(serverName, userId); return parsedConfig; } @@ -515,12 +531,7 @@ export class MCPServersRegistry { public async invalidateConfigCache(): Promise { const allCached = await this.configCacheRepo.getAll(); const evictedNames = [ - ...new Set( - Object.keys(allCached).map((key) => { - const lastColon = key.lastIndexOf(':'); - return lastColon > 0 ? key.slice(0, lastColon) : key; - }), - ), + ...new Set(Object.keys(allCached).map((key) => this.parseServerNameFromConfigCacheKey(key))), ]; await Promise.all([ @@ -553,8 +564,7 @@ export class MCPServersRegistry { await this.configCacheRepo.reset(); await this.readThroughCache.clear(); await this.readThroughCacheAll.clear(); - this.yamlServerNames = null; - this.yamlServerNamesPromise = null; + this.resetYamlServerNamesMemo(); } public async removeServer( @@ -564,6 +574,10 @@ export class MCPServersRegistry { ): Promise { const configRepo = this.getConfigRepository(storageLocation); await configRepo.remove(serverName, userId); + await this.invalidateServerReadCaches(serverName, userId); + if (storageLocation === 'CACHE') { + this.resetYamlServerNamesMemo(); + } } private getConfigRepository(storageLocation: 'CACHE' | 'DB'): IServerConfigsRepositoryInterface { @@ -583,6 +597,55 @@ export class MCPServersRegistry { return userId ? `${serverName}::${userId}` : serverName; } + private async invalidateServerReadCaches(serverName: string, userId?: string): Promise { + const deletes = [ + this.readThroughCache.delete(this.getReadThroughCacheKey(serverName)), + this.readThroughCacheAll.clear(), + ]; + + if (userId) { + deletes.push(this.readThroughCache.delete(this.getReadThroughCacheKey(serverName, userId))); + } + + await Promise.all(deletes); + } + + private async getOperatorManagedServerNames( + reservedServerNames: Iterable = [], + ): Promise { + const yamlNames = await this.getYamlServerNames(); + + return [...new Set([...yamlNames, ...reservedServerNames])]; + } + + private parseServerNameFromConfigCacheKey(cacheKey: string): string { + const lastColon = cacheKey.lastIndexOf(':'); + return lastColon > 0 ? cacheKey.slice(0, lastColon) : cacheKey; + } + + private warnOnOperatorManagedNameCollisions( + operatorConfigs: Record, + candidateConfigs: Record, + operatorSource: 'Config' | 'YAML', + ): void { + const shadowedNames = Object.keys(operatorConfigs).filter( + (serverName) => candidateConfigs[serverName]?.source === 'user', + ); + if (!shadowedNames.length) { + return; + } + + logger.warn( + `[MCPServersRegistry] ${operatorSource} MCP server(s) shadow DB-backed server(s) with colliding name(s): ` + + `${shadowedNames.join(', ')}. DB records remain stored but are hidden while operator-managed servers use these names.`, + ); + } + + private resetYamlServerNamesMemo(): void { + this.yamlServerNames = null; + this.yamlServerNamesPromise = null; + } + /** * Returns memoized YAML server names. Populated lazily on first call after boot/reset. * YAML servers don't change after boot, so this avoids repeated `getAll()` calls. diff --git a/packages/api/src/mcp/registry/ServerConfigsRepositoryInterface.ts b/packages/api/src/mcp/registry/ServerConfigsRepositoryInterface.ts index e55f66faa70..71eac64908e 100644 --- a/packages/api/src/mcp/registry/ServerConfigsRepositoryInterface.ts +++ b/packages/api/src/mcp/registry/ServerConfigsRepositoryInterface.ts @@ -1,7 +1,7 @@ import { ParsedServerConfig, AddServerResult } from '~/mcp/types'; /** - * Interface for future DB implementation + * Contract for MCP server configuration storage, whether cache-backed or DB-backed. */ export interface IServerConfigsRepositoryInterface { add(serverName: string, config: ParsedServerConfig, userId?: string): Promise; diff --git a/packages/api/src/mcp/registry/__tests__/MCPServersRegistry.test.ts b/packages/api/src/mcp/registry/__tests__/MCPServersRegistry.test.ts index a20c09705ff..a1298dcd6eb 100644 --- a/packages/api/src/mcp/registry/__tests__/MCPServersRegistry.test.ts +++ b/packages/api/src/mcp/registry/__tests__/MCPServersRegistry.test.ts @@ -1,4 +1,5 @@ import type * as t from '~/mcp/types'; +import { logger } from '@librechat/data-schemas'; import { MCPServersRegistry } from '~/mcp/registry/MCPServersRegistry'; import { MCPServerInspector } from '~/mcp/registry/MCPServerInspector'; @@ -10,7 +11,10 @@ jest.mock('~/mcp/registry/db/ServerConfigsDB', () => ({ ServerConfigsDB: jest.fn().mockImplementation(() => ({ get: jest.fn().mockResolvedValue(undefined), getAll: jest.fn().mockResolvedValue({}), - add: jest.fn().mockResolvedValue(undefined), + add: jest.fn().mockImplementation(async (serverName: string, config: t.ParsedServerConfig) => ({ + serverName, + config, + })), update: jest.fn().mockResolvedValue(undefined), remove: jest.fn().mockResolvedValue(undefined), reset: jest.fn().mockResolvedValue(undefined), @@ -99,6 +103,90 @@ describe('MCPServersRegistry', () => { expect(configs).toHaveProperty('app_server'); expect(configs).toHaveProperty('user_server'); }); + + it('should keep YAML servers authoritative when a DB server has the same name', async () => { + const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(); + const yamlConfig = { ...testParsedConfig, source: 'yaml' as const, title: 'YAML Slack' }; + const dbConfig = { ...testParsedConfig, source: 'user' as const, title: 'User Slack' }; + await registry['cacheConfigsRepo'].add('slack', yamlConfig); + jest.spyOn(registry['dbConfigsRepo'], 'getAll').mockResolvedValue({ + slack: dbConfig, + user_server: dbConfig, + }); + + try { + const configs = await registry.getAllServerConfigs('user-1'); + + expect(configs.slack).toMatchObject({ source: 'yaml', title: 'YAML Slack' }); + expect(configs.user_server).toMatchObject({ source: 'user', title: 'User Slack' }); + } finally { + warnSpy.mockRestore(); + } + }); + + it('should warn when operator-managed servers shadow DB servers', async () => { + const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(); + const yamlConfig = { ...testParsedConfig, source: 'yaml' as const, title: 'YAML Slack' }; + const dbConfig = { ...testParsedConfig, source: 'user' as const, title: 'User Slack' }; + await registry['cacheConfigsRepo'].add('slack', yamlConfig); + jest.spyOn(registry['dbConfigsRepo'], 'getAll').mockResolvedValue({ slack: dbConfig }); + + try { + await registry.getAllServerConfigs('user-1'); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('slack')); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('shadow DB-backed server')); + } finally { + warnSpy.mockRestore(); + } + }); + + it('should warn when config servers shadow DB servers', async () => { + const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(); + const configServer = { + ...testParsedConfig, + source: 'config' as const, + title: 'Config Slack', + }; + const dbConfig = { ...testParsedConfig, source: 'user' as const, title: 'User Slack' }; + jest.spyOn(registry['dbConfigsRepo'], 'getAll').mockResolvedValue({ slack: dbConfig }); + + try { + await registry.getAllServerConfigs('user-1', { slack: configServer }); + + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('Config MCP server')); + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('slack')); + } finally { + warnSpy.mockRestore(); + } + }); + }); + + describe('addServer', () => { + it('should reserve YAML and current config server names when creating DB servers', async () => { + await registry.addServer('slack', { ...testParsedConfig, title: 'Slack' }, 'CACHE'); + await registry['configCacheRepo'].upsert('other_tenant:hash', { + ...testParsedConfig, + source: 'config', + title: 'Other Tenant Server', + }); + const dbAddSpy = jest.spyOn(registry['dbConfigsRepo'], 'add').mockResolvedValue({ + serverName: 'slack-2', + config: { ...testParsedConfig, source: 'user', title: 'Slack' }, + }); + + await registry.addServer( + 'temp_server_name', + { ...testParsedConfig, title: 'Slack' }, + 'DB', + 'user-1', + ['config_slack'], + ); + + const reservedServerNames = Array.from(dbAddSpy.mock.calls[0]?.[3] ?? []); + expect(reservedServerNames).toEqual(expect.arrayContaining(['slack', 'config_slack'])); + expect(reservedServerNames).not.toContain('other_tenant'); + }); }); describe('reset', () => { diff --git a/packages/api/src/mcp/registry/__tests__/ServerConfigsDB.test.ts b/packages/api/src/mcp/registry/__tests__/ServerConfigsDB.test.ts index e8c089df340..8d031c33a71 100644 --- a/packages/api/src/mcp/registry/__tests__/ServerConfigsDB.test.ts +++ b/packages/api/src/mcp/registry/__tests__/ServerConfigsDB.test.ts @@ -124,6 +124,13 @@ describe('ServerConfigsDB', () => { expect(result.config.dbId).toBeDefined(); }); + it('should reserve operator-managed server names when creating a DB server', async () => { + const config = createSSEConfig('My Test Server', 'A test server'); + const result = await serverConfigsDB.add('temp-name', config, userId, ['my-test-server']); + + expect(result.serverName).toBe('my-test-server-2'); + }); + it('should grant owner ACL to the user', async () => { const config = createSSEConfig('ACL Test Server'); const result = await serverConfigsDB.add('temp-name', config, userId); diff --git a/packages/api/src/mcp/registry/__tests__/ensureConfigServers.test.ts b/packages/api/src/mcp/registry/__tests__/ensureConfigServers.test.ts index 3d1433f1947..9b601dcee22 100644 --- a/packages/api/src/mcp/registry/__tests__/ensureConfigServers.test.ts +++ b/packages/api/src/mcp/registry/__tests__/ensureConfigServers.test.ts @@ -221,7 +221,7 @@ describe('MCPServersRegistry — ensureConfigServers', () => { }); describe('merge order', () => { - it('should merge YAML → config → user with correct precedence in getAllServerConfigs', async () => { + it('should keep operator-managed servers authoritative in getAllServerConfigs', async () => { await registry.addServer('yaml_srv', yamlConfig, 'CACHE'); const configServers = await registry.ensureConfigServers({ config_srv: sseConfig }); diff --git a/packages/api/src/mcp/registry/db/ServerConfigsDB.ts b/packages/api/src/mcp/registry/db/ServerConfigsDB.ts index 30e01857210..5eaf92a2c2f 100644 --- a/packages/api/src/mcp/registry/db/ServerConfigsDB.ts +++ b/packages/api/src/mcp/registry/db/ServerConfigsDB.ts @@ -105,7 +105,7 @@ export class ServerConfigsDB implements IServerConfigsRepositoryInterface { /** * Creates a new MCP server and grants owner permissions to the user. - * @param serverName - Temporary server name (not persisted) will be replaced by the nano id generated by the db method + * @param serverName - Placeholder name kept for repository compatibility; final serverName comes from config.title * @param config - Server configuration to store * @param userId - ID of the user creating the server (required) * @returns The created server result with serverName and config (including dbId) @@ -115,9 +115,10 @@ export class ServerConfigsDB implements IServerConfigsRepositoryInterface { serverName: string, config: ParsedServerConfig, userId?: string, + reservedServerNames?: Iterable, ): Promise { logger.debug( - `[ServerConfigsDB.add] Starting Creating server with temp servername: ${serverName} for the user with the ID ${userId}`, + `[ServerConfigsDB.add] Creating DB-backed server from config title. Placeholder: ${serverName}; userId: ${userId}`, ); if (!userId) { throw new Error( @@ -139,6 +140,7 @@ export class ServerConfigsDB implements IServerConfigsRepositoryInterface { const createdServer = await this._dbMethods.createMCPServer({ config: encryptedConfig, author: userId, + reservedServerNames, }); await this._aclService.grantPermission({ principalType: PrincipalType.USER, diff --git a/packages/data-schemas/src/methods/mcpServer.spec.ts b/packages/data-schemas/src/methods/mcpServer.spec.ts index 66f09824382..9d5f915bfde 100644 --- a/packages/data-schemas/src/methods/mcpServer.spec.ts +++ b/packages/data-schemas/src/methods/mcpServer.spec.ts @@ -141,6 +141,37 @@ describe('MCPServer Model Tests', () => { expect(server.serverName).toBe('test-server-2'); }); + test('should append suffix when base name is reserved', async () => { + const server = await methods.createMCPServer({ + config: createSSEConfig('Test Server'), + author: authorId, + reservedServerNames: ['test-server'], + }); + + expect(server.serverName).toBe('test-server-2'); + }); + + test('should skip both DB and reserved names when finding next suffix', async () => { + await MCPServer.create({ + serverName: 'test-server', + config: createSSEConfig('Test Server'), + author: authorId, + }); + await MCPServer.create({ + serverName: 'test-server-2', + config: createSSEConfig('Test Server'), + author: authorId, + }); + + const server = await methods.createMCPServer({ + config: createSSEConfig('Test Server'), + author: authorId, + reservedServerNames: ['test-server-3'], + }); + + expect(server.serverName).toBe('test-server-4'); + }); + test('should find next available number in sequence', async () => { // Create servers with sequential names await MCPServer.create({ diff --git a/packages/data-schemas/src/methods/mcpServer.ts b/packages/data-schemas/src/methods/mcpServer.ts index 4851e2c5664..d48fd2a4e4e 100644 --- a/packages/data-schemas/src/methods/mcpServer.ts +++ b/packages/data-schemas/src/methods/mcpServer.ts @@ -46,27 +46,38 @@ function generateServerNameFromTitle(title: string): string { export function createMCPServerMethods(mongoose: typeof import('mongoose')) { /** - * Finds the next available server name by checking for duplicates. - * If baseName exists, returns baseName-2, baseName-3, etc. + * Finds the next available server name by checking DB and reserved-name collisions. + * If baseName is taken or reserved, returns baseName-2, baseName-3, etc. */ - async function findNextAvailableServerName(baseName: string): Promise { + async function findNextAvailableServerName( + baseName: string, + reservedServerNames: Set = new Set(), + ): Promise { const MCPServer = mongoose.models.MCPServer as Model; // Find all servers with matching base name pattern (baseName or baseName-N) const escapedBaseName = escapeRegex(baseName); + const matchingNamePattern = new RegExp(`^${escapedBaseName}(-\\d+)?$`); const existing = await MCPServer.find({ - serverName: { $regex: `^${escapedBaseName}(-\\d+)?$` }, + serverName: { $regex: matchingNamePattern }, }) .select('serverName') .lean>(); - if (existing.length === 0) { + const existingNames = new Set([ + ...existing.map((server) => server.serverName), + ...Array.from(reservedServerNames).filter((serverName) => + matchingNamePattern.test(serverName), + ), + ]); + + if (existingNames.size === 0) { return baseName; } // Extract numbers from existing names - const numbers = existing.map((s) => { - const match = s.serverName.match(/-(\d+)$/); + const numbers = Array.from(existingNames).map((serverName) => { + const match = serverName.match(/-(\d+)$/); return match ? parseInt(match[1], 10) : 1; }); @@ -86,9 +97,11 @@ export function createMCPServerMethods(mongoose: typeof import('mongoose')) { async function createMCPServer(data: { config: MCPOptions; author: string | Types.ObjectId; + reservedServerNames?: Iterable; }): Promise { const MCPServer = mongoose.models.MCPServer as Model; let lastError: unknown; + const reservedServerNames = new Set(data.reservedServerNames ?? []); for (let attempt = 0; attempt < MAX_CREATE_RETRIES; attempt++) { try { @@ -97,7 +110,7 @@ export function createMCPServerMethods(mongoose: typeof import('mongoose')) { let serverName: string; if (data.config.title) { const baseSlug = generateServerNameFromTitle(data.config.title); - serverName = await findNextAvailableServerName(baseSlug); + serverName = await findNextAvailableServerName(baseSlug, reservedServerNames); } else { serverName = `mcp-${nanoid(16)}`; } From 2ce3921f7886ec7e048ac9403e3bdd98a0a588cc Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 22 May 2026 20:47:41 -0400 Subject: [PATCH 061/395] =?UTF-8?q?=E2=9A=96=EF=B8=8F=20feat:=20Add=20Oper?= =?UTF-8?q?ational=20Prometheus=20Metrics=20(#13265)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * add operational prometheus metrics * fix metrics review findings * fix metrics edge cases * gate mongoose metrics instrumentation * gate metrics setup when unconfigured --- api/db/connect.js | 4 +- api/server/index.metrics.spec.js | 4 +- packages/api/src/app/metrics.spec.ts | 338 +++++++++++- packages/api/src/app/metrics.ts | 485 +++++++++++++++++- packages/api/src/auth/openid.spec.ts | 8 + packages/api/src/auth/openid.ts | 119 +++-- .../api/src/stream/GenerationJobManager.ts | 62 ++- 7 files changed, 964 insertions(+), 56 deletions(-) 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/server/index.metrics.spec.js b/api/server/index.metrics.spec.js index 3f29347292d..c907aca3b61 100644 --- a/api/server/index.metrics.spec.js +++ b/api/server/index.metrics.spec.js @@ -72,16 +72,18 @@ describe('Server metrics route', () => { 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(() => { - delete process.env.METRICS_SECRET; + process.env.METRICS_SECRET = 'test-secret'; }); afterAll(async () => { + delete process.env.METRICS_SECRET; await mongoServer.stop(); await mongoose.disconnect(); }); diff --git a/packages/api/src/app/metrics.spec.ts b/packages/api/src/app/metrics.spec.ts index 6bb66ed2789..80984dcb8b9 100644 --- a/packages/api/src/app/metrics.spec.ts +++ b/packages/api/src/app/metrics.spec.ts @@ -1,5 +1,19 @@ /// -import { normalizePath } from './metrics'; +import { EventEmitter } from 'events'; +import express from 'express'; +import type { Request, Response } from 'express'; +import { + createMetrics, + instrumentMongooseQueryMetrics, + normalizePath, + recordGenerationJob, + recordGenerationStreamResumePendingEvents, + recordGenerationStreamSubscription, + recordOpenIDUserLookup, + setGenerationJobsInFlight, +} from './metrics'; + +const request = require('supertest') as (app: express.Express) => any; describe('normalizePath', () => { it.each([ @@ -9,8 +23,17 @@ describe('normalizePath', () => { ['/api/messages/artifact/507f1f77bcf86cd799439012', '/api/messages/artifact/#id'], ['/api/convos/507f1f77bcf86cd799439011', '/api/convos/#id'], ['/api/files/507f1f77bcf86cd799439011', '/api/files/#id'], + ['/api/files/507f1f77bcf86cd799439011/preview', '/api/files/#id/preview'], + ['/api/files/download/user-123/file-456', '/api/files/download/#id/#id'], + ['/api/files/download-url/user-123/file-456', '/api/files/download-url/#id/#id'], + ['/api/files/code/download/session-123/file-456', '/api/files/code/download/#id/#id'], ['/api/agents/507f1f77bcf86cd799439011', '/api/agents/#id'], + ['/api/agents/chat/stream/stream-123', '/api/agents/chat/stream/#id'], + ['/api/agents/chat/status/convo-123', '/api/agents/chat/status/#id'], + ['/api/agents/v1/chat/completions', '/api/agents/v1/chat/completions'], + ['/api/agents/v1/responses', '/api/agents/v1/responses'], ['/api/assistants/507f1f77bcf86cd799439011', '/api/assistants/#id'], + ['/api/skills/507f1f77bcf86cd799439011/files/reference.md', '/api/skills/#id/files'], ['/api/share/some-token-value', '/api/share/#token'], ['/share/shareId-with_nanoidChars', '/share/#id'], ['/share/shareId-with_nanoidChars/edit', '/share/#id/edit'], @@ -45,3 +68,316 @@ describe('normalizePath', () => { expect(normalizePath(input)).toBe(normalized); }); }); + +describe('createMetrics', () => { + afterEach(() => { + delete process.env.METRICS_SECRET; + }); + + it('uses a no-op middleware and unauthorized router when metrics are not configured', async () => { + const app = express(); + const { metricsMiddleware, metricsRouter } = createMetrics(); + const res = new EventEmitter(); + const next = jest.fn(); + + metricsMiddleware( + { headers: {}, method: 'GET', path: '/api/slow-response' } as Request, + res as unknown as Response, + next, + ); + app.use('/metrics', metricsRouter); + + await request(app).get('/metrics').expect(401); + expect(next).toHaveBeenCalledTimes(1); + expect(res.listenerCount('finish')).toBe(0); + expect(res.listenerCount('close')).toBe(0); + }); + + it('tracks request counts, in-flight gauges, and request body sizes', async () => { + const app = express(); + process.env.METRICS_SECRET = 'test-secret'; + const { metricsMiddleware, metricsRouter } = createMetrics(); + app.use(metricsMiddleware); + app.use(express.text({ type: '*/*' })); + app.post('/api/files/:id', (_req, res) => { + res.status(201).send('ok'); + }); + app.use('/metrics', metricsRouter); + + await request(app) + .post('/api/files/507f1f77bcf86cd799439011') + .set('Content-Type', 'text/plain') + .send('x'.repeat(42)) + .expect(201); + + const response = await request(app) + .get('/metrics') + .set('Authorization', 'Bearer test-secret') + .expect(200); + + expect(response.text).toMatch( + /http_requests_total\{method="POST",path="\/api\/files\/#id",status="201"\} 1/, + ); + expect(response.text).toMatch( + /http_requests_in_flight\{method="POST",path="\/api\/files\/#id"\} 0/, + ); + expect(response.text).toMatch( + /http_request_body_bytes_count\{method="POST",path="\/api\/files\/#id"\} 1/, + ); + expect(response.text).toMatch( + /http_request_body_bytes_sum\{method="POST",path="\/api\/files\/#id"\} 42/, + ); + }); + + it('tracks SSE stream counts, active gauges, and stream duration', async () => { + const app = express(); + process.env.METRICS_SECRET = 'test-secret'; + const { metricsMiddleware, metricsRouter } = createMetrics(); + app.use(metricsMiddleware); + app.get('/api/agents/chat/stream/:streamId', (_req, res) => { + res.setHeader('Content-Type', 'text/event-stream'); + res.write('event: message\ndata: {}\n\n'); + res.end(); + }); + app.use('/metrics', metricsRouter); + + await request(app).get('/api/agents/chat/stream/stream-123').expect(200); + + const response = await request(app) + .get('/metrics') + .set('Authorization', 'Bearer test-secret') + .expect(200); + + expect(response.text).toMatch( + /sse_streams_total\{method="GET",path="\/api\/agents\/chat\/stream\/#id",status="200"\} 1/, + ); + expect(response.text).toMatch( + /sse_streams_in_flight\{method="GET",path="\/api\/agents\/chat\/stream\/#id"\} 0/, + ); + expect(response.text).toMatch( + /sse_stream_duration_seconds_count\{method="GET",path="\/api\/agents\/chat\/stream\/#id",status="200"\} 1/, + ); + }); + + it('tracks upload counts, active gauges, duration, and upload bytes', async () => { + const app = express(); + process.env.METRICS_SECRET = 'test-secret'; + const { metricsMiddleware, metricsRouter } = createMetrics(); + app.use(metricsMiddleware); + app.post('/api/files', (_req, res) => { + res.status(201).send('ok'); + }); + app.use('/metrics', metricsRouter); + + await request(app) + .post('/api/files') + .attach('file', Buffer.from('uploaded body'), 'test.txt') + .expect(201); + + const response = await request(app) + .get('/metrics') + .set('Authorization', 'Bearer test-secret') + .expect(200); + + expect(response.text).toMatch( + /upload_requests_total\{method="POST",path="\/api\/files",status="201"\} 1/, + ); + expect(response.text).toMatch( + /upload_requests_in_flight\{method="POST",path="\/api\/files"\} 0/, + ); + expect(response.text).toMatch( + /upload_request_duration_seconds_count\{method="POST",path="\/api\/files",status="201"\} 1/, + ); + expect(response.text).toMatch(/upload_bytes_total\{method="POST",path="\/api\/files"\} \d+/); + }); + + it('does not track non-upload methods on upload paths as uploads', async () => { + const app = express(); + process.env.METRICS_SECRET = 'test-secret'; + const { metricsMiddleware, metricsRouter } = createMetrics(); + app.use(metricsMiddleware); + app.delete('/api/files', (_req, res) => { + res.status(204).end(); + }); + app.use('/metrics', metricsRouter); + + await request(app).delete('/api/files').expect(204); + + const response = await request(app) + .get('/metrics') + .set('Authorization', 'Bearer test-secret') + .expect(200); + + expect(response.text).not.toMatch(/upload_requests_total\{method="DELETE"/); + expect(response.text).not.toMatch(/upload_bytes_total\{method="DELETE"/); + }); + + it('labels requests closed before finish as client-aborted', async () => { + const app = express(); + process.env.METRICS_SECRET = 'test-secret'; + const { metricsMiddleware, metricsRouter } = createMetrics(); + const headers = new Map(); + const res = Object.assign(new EventEmitter(), { + destroyed: false, + getHeader: (name: string) => headers.get(name.toLowerCase()), + setHeader(name: string, value: unknown) { + headers.set(name.toLowerCase(), value); + return this; + }, + statusCode: 200, + writableEnded: false, + writeHead() { + return this; + }, + }); + const next = jest.fn(); + + metricsMiddleware( + { headers: {}, method: 'GET', path: '/api/slow-response' } as Request, + res as unknown as Response, + next, + ); + res.emit('close'); + app.use('/metrics', metricsRouter); + + const response = await request(app) + .get('/metrics') + .set('Authorization', 'Bearer test-secret') + .expect(200); + + expect(next).toHaveBeenCalledTimes(1); + expect(response.text).toMatch( + /http_requests_total\{method="GET",path="\/api\/#path",status="499"\} 1/, + ); + expect(response.text).not.toMatch( + /http_requests_total\{method="GET",path="\/api\/#path",status="200"\}/, + ); + }); + + it('tracks OpenID user lookup outcomes and latency', async () => { + const app = express(); + process.env.METRICS_SECRET = 'test-secret'; + const { metricsRouter } = createMetrics(); + app.use('/metrics', metricsRouter); + + recordOpenIDUserLookup('found', 0.2); + + const response = await request(app) + .get('/metrics') + .set('Authorization', 'Bearer test-secret') + .expect(200); + + expect(response.text).toMatch(/openid_user_lookup_total\{result="found"\} 1/); + expect(response.text).toMatch(/openid_user_lookup_duration_seconds_count\{result="found"\} 1/); + expect(response.text).toMatch(/openid_user_lookup_duration_seconds_sum\{result="found"\} 0.2/); + }); + + it('tracks mongoose query counts and latency by model and operation', async () => { + class FakeQuery { + model = { modelName: 'User' }; + op = 'findOne'; + + exec() { + return Promise.resolve({ _id: 'user-1' }); + } + } + + const app = express(); + process.env.METRICS_SECRET = 'test-secret'; + const { metricsRouter } = createMetrics(); + app.use('/metrics', metricsRouter); + + instrumentMongooseQueryMetrics({ Query: FakeQuery } as never); + await new FakeQuery().exec(); + + const response = await request(app) + .get('/metrics') + .set('Authorization', 'Bearer test-secret') + .expect(200); + + expect(response.text).toMatch( + /mongoose_queries_total\{model="User",operation="findOne",status="success"\} 1/, + ); + expect(response.text).toMatch( + /mongoose_query_duration_seconds_count\{model="User",operation="findOne",status="success"\} 1/, + ); + }); + + it('does not instrument mongoose queries when metrics are not configured', async () => { + class FakeQuery { + model = { modelName: 'User' }; + op = 'findOne'; + + exec() { + return Promise.resolve({ _id: 'user-1' }); + } + } + + const originalExec = FakeQuery.prototype.exec; + + instrumentMongooseQueryMetrics({ Query: FakeQuery } as never); + + expect(FakeQuery.prototype.exec).toBe(originalExec); + }); + + it('tracks mongoose query errors thrown before a promise is returned', async () => { + class ThrowingQuery { + model = { modelName: 'User' }; + op = 'findOne'; + + exec() { + throw new Error('sync database error'); + } + } + + const app = express(); + process.env.METRICS_SECRET = 'test-secret'; + const { metricsRouter } = createMetrics(); + app.use('/metrics', metricsRouter); + + instrumentMongooseQueryMetrics({ Query: ThrowingQuery } as never); + expect(() => new ThrowingQuery().exec()).toThrow('sync database error'); + + const response = await request(app) + .get('/metrics') + .set('Authorization', 'Bearer test-secret') + .expect(200); + + expect(response.text).toMatch( + /mongoose_queries_total\{model="User",operation="findOne",status="error"\} 1/, + ); + expect(response.text).toMatch( + /mongoose_query_duration_seconds_count\{model="User",operation="findOne",status="error"\} 1/, + ); + }); + + it('tracks generation job and stream resume metrics', async () => { + const app = express(); + process.env.METRICS_SECRET = 'test-secret'; + const { metricsRouter } = createMetrics(); + app.use('/metrics', metricsRouter); + + recordGenerationJob('memory', 'created'); + setGenerationJobsInFlight('memory', 2); + recordGenerationStreamSubscription('redis', 'resume', 'not_found'); + recordGenerationStreamSubscription('redis', 'resume_state', 'missing'); + recordGenerationStreamResumePendingEvents('memory', 3); + + const response = await request(app) + .get('/metrics') + .set('Authorization', 'Bearer test-secret') + .expect(200); + + expect(response.text).toMatch(/generation_jobs_total\{store="memory",result="created"\} 1/); + expect(response.text).toMatch(/generation_jobs_in_flight\{store="memory"\} 2/); + expect(response.text).toMatch( + /generation_stream_subscriptions_total\{store="redis",type="resume",result="not_found"\} 1/, + ); + expect(response.text).toMatch( + /generation_stream_subscriptions_total\{store="redis",type="resume_state",result="missing"\} 1/, + ); + expect(response.text).toMatch( + /generation_stream_resume_pending_events_total\{store="memory"\} 3/, + ); + }); +}); diff --git a/packages/api/src/app/metrics.ts b/packages/api/src/app/metrics.ts index 8089a9c3a14..4dd9fddac25 100644 --- a/packages/api/src/app/metrics.ts +++ b/packages/api/src/app/metrics.ts @@ -1,10 +1,18 @@ import { timingSafeEqual } from 'crypto'; import { Router } from 'express'; -import { Registry, collectDefaultMetrics, Counter, Histogram } from 'prom-client'; +import { Registry, collectDefaultMetrics, Counter, Gauge, Histogram } from 'prom-client'; import { logger } from '@librechat/data-schemas'; import type { Request, Response, NextFunction, RequestHandler } from 'express'; +import type { Mongoose } from 'mongoose'; const PATH_NORMALIZATIONS: [RegExp, string][] = [ + [/^\/api\/agents\/chat\/stream\/[^/]+(?=\/|$)/, '/api/agents/chat/stream/#id'], + [/^\/api\/agents\/chat\/status\/[^/]+(?=\/|$)/, '/api/agents/chat/status/#id'], + [/^\/api\/files\/code\/download\/[^/]+\/[^/]+(?=\/|$)/, '/api/files/code/download/#id/#id'], + [/^\/api\/files\/download-url\/[^/]+\/[^/]+(?=\/|$)/, '/api/files/download-url/#id/#id'], + [/^\/api\/files\/download\/[^/]+\/[^/]+(?=\/|$)/, '/api/files/download/#id/#id'], + [/^\/api\/files\/[^/]+\/preview(?=\/|$)/, '/api/files/#id/preview'], + [/^\/api\/skills\/[^/]+\/files(?:\/.*)?(?=\/|$)/, '/api/skills/#id/files'], [/^\/api\/messages\/artifact\/[^/]+(?=\/|$)/, '/api/messages/artifact/#id'], [/^\/api\/messages\/[^/]+\/[^/]+(?=\/|$)/, '/api/messages/#id/#id'], [/^\/api\/convos\/[^/]+\/messages\/[^/]+(?=\/|$)/, '/api/convos/#id/messages/#id'], @@ -22,9 +30,39 @@ const PATH_NORMALIZATIONS: [RegExp, string][] = [ ], ]; -const STATIC_PATHS = new Set(['/', '/health', '/metrics', '/api/auth/login', '/api/config']); +const STATIC_PATHS = new Set([ + '/', + '/health', + '/metrics', + '/api/auth/login', + '/api/config', + '/api/agents/chat/abort', + '/api/agents/chat/active', + '/api/agents/v1/chat/completions', + '/api/agents/v1/responses', + '/api/files', + '/api/files/config', + '/api/files/images', + '/api/files/images/avatar', + '/api/files/speech/stt', +]); + +const UPLOAD_PATHS = new Set([ + '/api/files', + '/api/files/images', + '/api/files/images/avatar', + '/api/files/speech/stt', + '/api/skills/#id/files', +]); + +const UPLOAD_METHODS = new Set(['POST', 'PUT', 'PATCH']); const LOW_CARDINALITY_PATHS: RegExp[] = [ + /^\/api\/agents\/chat\/stream\/#id$/, + /^\/api\/agents\/chat\/status\/#id$/, + /^\/api\/files\/#id\/preview$/, + /^\/api\/files\/(code\/download|download-url|download)\/#id\/#id$/, + /^\/api\/skills\/#id\/files$/, /^\/api\/messages\/#id$/, /^\/api\/messages\/#id\/#id$/, /^\/api\/messages\/artifact\/#id$/, @@ -89,7 +127,215 @@ export interface PrometheusMetrics { metricsRouter: Router; } +export type OpenIDUserLookupResult = 'found' | 'not_found' | 'migration' | 'auth_failed' | 'error'; +export type GenerationJobStore = 'memory' | 'redis'; +export type GenerationJobResult = 'created' | 'completed' | 'error' | 'aborted' | 'abort_failed'; +export type GenerationStreamSubscriptionType = 'initial' | 'resume' | 'resume_state'; +export type GenerationStreamSubscriptionResult = + | 'success' + | 'not_found' + | 'error' + | 'found' + | 'missing'; + +type OpenIDUserLookupMetrics = { + recordLookup: (result: OpenIDUserLookupResult, durationSeconds: number) => void; +}; + +let openIDUserLookupMetrics: OpenIDUserLookupMetrics = { + recordLookup: () => undefined, +}; + +export function recordOpenIDUserLookup( + result: OpenIDUserLookupResult, + durationSeconds: number, +): void { + openIDUserLookupMetrics.recordLookup(result, durationSeconds); +} + +type MongooseQueryMetrics = { + recordQuery: (model: string, operation: string, status: string, durationSeconds: number) => void; +}; + +let mongooseQueryMetrics: MongooseQueryMetrics = { + recordQuery: () => undefined, +}; + +type GenerationJobMetrics = { + recordJob: (store: GenerationJobStore, result: GenerationJobResult) => void; + setJobsInFlight: (store: GenerationJobStore, count: number) => void; + recordSubscription: ( + store: GenerationJobStore, + type: GenerationStreamSubscriptionType, + result: GenerationStreamSubscriptionResult, + ) => void; + recordResumePendingEvents: (store: GenerationJobStore, count: number) => void; +}; + +let generationJobMetrics: GenerationJobMetrics = { + recordJob: () => undefined, + setJobsInFlight: () => undefined, + recordSubscription: () => undefined, + recordResumePendingEvents: () => undefined, +}; + +const resetMetricRecorders = (): void => { + openIDUserLookupMetrics = { + recordLookup: () => undefined, + }; + mongooseQueryMetrics = { + recordQuery: () => undefined, + }; + generationJobMetrics = { + recordJob: () => undefined, + setJobsInFlight: () => undefined, + recordSubscription: () => undefined, + recordResumePendingEvents: () => undefined, + }; +}; + +export function recordGenerationJob(store: GenerationJobStore, result: GenerationJobResult): void { + generationJobMetrics.recordJob(store, result); +} + +export function setGenerationJobsInFlight(store: GenerationJobStore, count: number): void { + generationJobMetrics.setJobsInFlight(store, count); +} + +export function recordGenerationStreamSubscription( + store: GenerationJobStore, + type: GenerationStreamSubscriptionType, + result: GenerationStreamSubscriptionResult, +): void { + generationJobMetrics.recordSubscription(store, type, result); +} + +export function recordGenerationStreamResumePendingEvents( + store: GenerationJobStore, + count: number, +): void { + generationJobMetrics.recordResumePendingEvents(store, count); +} + +const getElapsedSeconds = (startedAt: bigint): number => + Number(process.hrtime.bigint() - startedAt) / 1_000_000_000; + +export const isMetricsConfigured = (): boolean => Boolean(process.env.METRICS_SECRET); + +const createUnauthorizedMetricsRouter = (): Router => { + const metricsRouter = Router(); + metricsRouter.get('/', (_req, res) => { + res.status(401).end(); + }); + return metricsRouter; +}; + +const normalizeMongooseLabel = (value: unknown): string => { + if (typeof value !== 'string' || !value) return 'unknown'; + return value.replace(/[^a-zA-Z0-9_:-]/g, '_').slice(0, 64) || 'unknown'; +}; + +const getHeader = (headers: Record, name: string): unknown => { + const lowerName = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === lowerName) return value; + } + return undefined; +}; + +const headerIncludes = (value: unknown, expected: string): boolean => { + if (Array.isArray(value)) { + return value.some((entry) => headerIncludes(entry, expected)); + } + return typeof value === 'string' && value.toLowerCase().includes(expected); +}; + +const isEventStreamContentType = (value: unknown): boolean => + headerIncludes(value, 'text/event-stream'); + +const isMultipartContentType = (value: unknown): boolean => + headerIncludes(value, 'multipart/form-data'); + +const getRequestContentLength = (req: Request): number | null => { + const contentLength = req.headers['content-length']; + const rawContentLength = Array.isArray(contentLength) ? contentLength[0] : contentLength; + const bodyBytes = rawContentLength == null ? NaN : Number(rawContentLength); + return Number.isFinite(bodyBytes) && bodyBytes >= 0 ? bodyBytes : null; +}; + +const isUploadRequest = (req: Request, normalizedPath: string): boolean => { + if (!UPLOAD_METHODS.has(req.method)) return false; + if (isMultipartContentType(req.headers['content-type'])) return true; + return UPLOAD_PATHS.has(normalizedPath); +}; + +export function recordMongooseQuery( + model: string, + operation: string, + status: string, + durationSeconds: number, +): void { + mongooseQueryMetrics.recordQuery( + normalizeMongooseLabel(model), + normalizeMongooseLabel(operation), + normalizeMongooseLabel(status), + durationSeconds, + ); +} + +export function instrumentMongooseQueryMetrics(mongoose: Mongoose): void { + if (!isMetricsConfigured()) return; + + const instrumented = Symbol.for('librechat.mongooseQueryMetrics.instrumented'); + const queryPrototype = mongoose.Query?.prototype as + | (typeof mongoose.Query.prototype & { [instrumented]?: boolean }) + | undefined; + + if (!queryPrototype || queryPrototype[instrumented]) return; + + const originalExec = queryPrototype.exec; + queryPrototype.exec = function instrumentedExec( + this: typeof queryPrototype & { + model?: { modelName?: string }; + op?: string; + }, + ...args: Parameters + ) { + const startedAt = process.hrtime.bigint(); + const model = normalizeMongooseLabel(this.model?.modelName); + const operation = normalizeMongooseLabel(this.op); + + let result: ReturnType; + try { + result = originalExec.apply(this, args); + } catch (error) { + recordMongooseQuery(model, operation, 'error', getElapsedSeconds(startedAt)); + throw error; + } + + return Promise.resolve(result).then( + (result) => { + recordMongooseQuery(model, operation, 'success', getElapsedSeconds(startedAt)); + return result; + }, + (error) => { + recordMongooseQuery(model, operation, 'error', getElapsedSeconds(startedAt)); + throw error; + }, + ); + } as typeof originalExec; + queryPrototype[instrumented] = true; +} + export function createMetrics(): PrometheusMetrics { + if (!isMetricsConfigured()) { + resetMetricRecorders(); + return { + metricsMiddleware: (_req: Request, _res: Response, next: NextFunction) => next(), + metricsRouter: createUnauthorizedMetricsRouter(), + }; + } + const registry = new Registry(); collectDefaultMetrics({ register: registry }); @@ -108,13 +354,238 @@ export function createMetrics(): PrometheusMetrics { registers: [registry], }); + const httpRequestsInFlight = new Gauge({ + name: 'http_requests_in_flight', + help: 'HTTP requests currently being handled', + labelNames: ['method', 'path'] as const, + registers: [registry], + }); + + const httpRequestBodyBytes = new Histogram({ + name: 'http_request_body_bytes', + help: 'HTTP request body size in bytes from the Content-Length header', + labelNames: ['method', 'path'] as const, + buckets: [1_000, 10_000, 100_000, 1_000_000, 5_000_000, 10_000_000, 25_000_000, 50_000_000], + registers: [registry], + }); + + const sseStreams = new Counter({ + name: 'sse_streams_total', + help: 'Total SSE streams opened', + labelNames: ['method', 'path', 'status'] as const, + registers: [registry], + }); + + const sseStreamsInFlight = new Gauge({ + name: 'sse_streams_in_flight', + help: 'SSE streams currently open', + labelNames: ['method', 'path'] as const, + registers: [registry], + }); + + const sseStreamDuration = new Histogram({ + name: 'sse_stream_duration_seconds', + help: 'SSE stream open duration in seconds', + labelNames: ['method', 'path', 'status'] as const, + buckets: [1, 5, 10, 30, 60, 120, 300, 600, 1_200, 1_800], + registers: [registry], + }); + + const uploadRequests = new Counter({ + name: 'upload_requests_total', + help: 'Total upload requests', + labelNames: ['method', 'path', 'status'] as const, + registers: [registry], + }); + + const uploadRequestsInFlight = new Gauge({ + name: 'upload_requests_in_flight', + help: 'Upload requests currently being handled', + labelNames: ['method', 'path'] as const, + registers: [registry], + }); + + const uploadRequestDuration = new Histogram({ + name: 'upload_request_duration_seconds', + help: 'Upload request duration in seconds', + labelNames: ['method', 'path', 'status'] as const, + buckets: [0.1, 0.3, 0.5, 1, 2, 5, 10, 30, 60, 120, 300], + registers: [registry], + }); + + const uploadBytes = new Counter({ + name: 'upload_bytes_total', + help: 'Upload request bytes from the Content-Length header', + labelNames: ['method', 'path'] as const, + registers: [registry], + }); + + const openIDUserLookupTotal = new Counter({ + name: 'openid_user_lookup_total', + help: 'OpenID user lookup attempts', + labelNames: ['result'] as const, + registers: [registry], + }); + + const openIDUserLookupDuration = new Histogram({ + name: 'openid_user_lookup_duration_seconds', + help: 'OpenID user lookup latency in seconds', + labelNames: ['result'] as const, + buckets: [0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60], + registers: [registry], + }); + + openIDUserLookupMetrics = { + recordLookup: (result, durationSeconds) => { + openIDUserLookupTotal.inc({ result }); + openIDUserLookupDuration.observe({ result }, durationSeconds); + }, + }; + + const mongooseQueries = new Counter({ + name: 'mongoose_queries_total', + help: 'Mongoose queries by model, operation, and status', + labelNames: ['model', 'operation', 'status'] as const, + registers: [registry], + }); + + const mongooseQueryDuration = new Histogram({ + name: 'mongoose_query_duration_seconds', + help: 'Mongoose query duration in seconds by model, operation, and status', + labelNames: ['model', 'operation', 'status'] as const, + buckets: [0.001, 0.003, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60], + registers: [registry], + }); + + mongooseQueryMetrics = { + recordQuery: (model, operation, status, durationSeconds) => { + const labels = { model, operation, status }; + mongooseQueries.inc(labels); + mongooseQueryDuration.observe(labels, durationSeconds); + }, + }; + + const generationJobs = new Counter({ + name: 'generation_jobs_total', + help: 'Generation jobs by backing store and result', + labelNames: ['store', 'result'] as const, + registers: [registry], + }); + + const generationJobsInFlight = new Gauge({ + name: 'generation_jobs_in_flight', + help: 'Generation jobs currently running in this process', + labelNames: ['store'] as const, + registers: [registry], + }); + + const generationStreamSubscriptions = new Counter({ + name: 'generation_stream_subscriptions_total', + help: 'Generation stream subscription attempts by backing store, type, and result', + labelNames: ['store', 'type', 'result'] as const, + registers: [registry], + }); + + const generationStreamResumePendingEvents = new Counter({ + name: 'generation_stream_resume_pending_events_total', + help: 'Pending events delivered while resuming generation streams', + labelNames: ['store'] as const, + registers: [registry], + }); + + generationJobMetrics = { + recordJob: (store, result) => generationJobs.inc({ store, result }), + setJobsInFlight: (store, count) => generationJobsInFlight.set({ store }, count), + recordSubscription: (store, type, result) => + generationStreamSubscriptions.inc({ store, type, result }), + recordResumePendingEvents: (store, count) => + generationStreamResumePendingEvents.inc({ store }, count), + }; + const metricsMiddleware = (req: Request, res: Response, next: NextFunction): void => { const end = httpDuration.startTimer(); - res.on('finish', () => { - const labels = { method: req.method, path: normalizePath(req.path), status: res.statusCode }; - httpRequests.inc(labels); - end(labels); - }); + const labels = { method: req.method, path: normalizePath(req.path) }; + const uploadTracked = isUploadRequest(req, labels.path); + const uploadStartedAt = uploadTracked ? process.hrtime.bigint() : null; + let sseTracked = false; + let sseStartedAt: bigint | null = null; + let completed = false; + + const markSSEStream = () => { + if (completed || res.writableEnded || res.destroyed) return; + if (sseTracked) return; + sseTracked = true; + sseStartedAt = process.hrtime.bigint(); + sseStreamsInFlight.inc(labels); + }; + + const originalSetHeader = res.setHeader; + res.setHeader = function setHeader(this: Response, ...args: Parameters) { + const [name, value] = args; + const result = originalSetHeader.apply(this, args); + if (String(name).toLowerCase() === 'content-type' && isEventStreamContentType(value)) { + markSSEStream(); + } + return result; + } as Response['setHeader']; + + const originalWriteHead = res.writeHead; + res.writeHead = function writeHead(this: Response, ...args: [number, unknown?, unknown?]) { + const [, reasonPhrase, headers] = args; + const responseHeaders = + typeof reasonPhrase === 'object' && reasonPhrase != null ? reasonPhrase : headers; + if ( + isEventStreamContentType( + getHeader((responseHeaders ?? {}) as Record, 'content-type'), + ) || + isEventStreamContentType(res.getHeader('content-type')) + ) { + markSSEStream(); + } + return originalWriteHead.apply(this, args as Parameters); + } as Response['writeHead']; + + httpRequestsInFlight.inc(labels); + if (uploadTracked) { + uploadRequestsInFlight.inc(labels); + } + + const complete = (completedBy: 'finish' | 'close') => { + if (completed) return; + completed = true; + + const requestLabels = { ...labels, status: completedBy === 'close' ? 499 : res.statusCode }; + httpRequests.inc(requestLabels); + end(requestLabels); + httpRequestsInFlight.dec(labels); + + const bodyBytes = getRequestContentLength(req); + if (bodyBytes != null) { + httpRequestBodyBytes.observe(labels, bodyBytes); + } + + if (sseTracked) { + sseStreams.inc(requestLabels); + sseStreamsInFlight.dec(labels); + if (sseStartedAt) { + sseStreamDuration.observe(requestLabels, getElapsedSeconds(sseStartedAt)); + } + } + + if (uploadTracked) { + uploadRequests.inc(requestLabels); + uploadRequestsInFlight.dec(labels); + if (uploadStartedAt) { + uploadRequestDuration.observe(requestLabels, getElapsedSeconds(uploadStartedAt)); + } + if (bodyBytes != null) { + uploadBytes.inc(labels, bodyBytes); + } + } + }; + + res.once('finish', () => complete('finish')); + res.once('close', () => complete('close')); next(); }; diff --git a/packages/api/src/auth/openid.spec.ts b/packages/api/src/auth/openid.spec.ts index 975e736b720..543fa94fe38 100644 --- a/packages/api/src/auth/openid.spec.ts +++ b/packages/api/src/auth/openid.spec.ts @@ -5,6 +5,7 @@ import { ErrorTypes } from 'librechat-data-provider'; import type { IUser, UserMethods } from '@librechat/data-schemas'; import type { CommandStartedEvent } from 'mongodb'; import type { FilterQuery } from 'mongoose'; +import { recordOpenIDUserLookup } from '~/app/metrics'; import { findOpenIDUser, getOpenIdEmail, getOpenIdIssuer, normalizeOpenIdIssuer } from './openid'; function newId() { @@ -19,6 +20,11 @@ jest.mock('@librechat/data-schemas', () => ({ }, })); +jest.mock('~/app/metrics', () => ({ + isMetricsConfigured: jest.fn(() => true), + recordOpenIDUserLookup: jest.fn(), +})); + describe('normalizeOpenIdIssuer', () => { it('normalizes blank, trailing-slash, and discovery-document issuers', () => { expect(normalizeOpenIdIssuer('')).toBeUndefined(); @@ -111,6 +117,7 @@ describe('findOpenIDUser', () => { error: null, migration: false, }); + expect(recordOpenIDUserLookup).toHaveBeenCalledWith('found', expect.any(Number)); }); it('should find user by idOnTheSource', async () => { @@ -734,6 +741,7 @@ describe('findOpenIDUser', () => { findUser: mockFindUser, }), ).rejects.toThrow('Database error'); + expect(recordOpenIDUserLookup).toHaveBeenCalledWith('error', expect.any(Number)); }); it('should reject email fallback when openidId is empty and user has a stored openidId', async () => { diff --git a/packages/api/src/auth/openid.ts b/packages/api/src/auth/openid.ts index 8ee5cbd517f..4d346a7591f 100644 --- a/packages/api/src/auth/openid.ts +++ b/packages/api/src/auth/openid.ts @@ -2,6 +2,8 @@ import { logger } from '@librechat/data-schemas'; import { ErrorTypes } from 'librechat-data-provider'; import type { IUser, UserMethods } from '@librechat/data-schemas'; import type { FilterQuery } from 'mongoose'; +import { isMetricsConfigured, recordOpenIDUserLookup } from '~/app/metrics'; +import type { OpenIDUserLookupResult } from '~/app/metrics'; export type OpenIdEmailClaims = { email?: unknown; @@ -65,6 +67,17 @@ function hasOpenIdLookupValue(value: string | undefined): value is string { return typeof value === 'string' && value.length > 0; } +function getElapsedSeconds(startedAt: bigint): number { + return Number(process.hrtime.bigint() - startedAt) / 1_000_000_000; +} + +function getOpenIDUserLookupResult(resolution: OpenIdUserResolution): OpenIDUserLookupResult { + if (resolution.error) return 'auth_failed'; + if (resolution.migration) return 'migration'; + if (resolution.user) return 'found'; + return 'not_found'; +} + function getIssuerExactCondition( field: OpenIdLookupField, value: string | undefined, @@ -208,61 +221,79 @@ export async function findOpenIDUser({ idOnTheSource?: string; strategyName?: string; }): Promise { - const normalizedIssuer = normalizeOpenIdIssuer(openidIssuer); - const primaryConditions = getPrimaryLookupConditions(openidId, idOnTheSource, normalizedIssuer); - - let user: IUser | null = null; - if (primaryConditions.length > 0) { - user = await findFirstOpenIdUser(findUser, primaryConditions); - } - - const primaryIssuerResolution = resolveIssuerBoundUser( - user, - normalizedIssuer, - strategyName, - 'OpenID lookup', - ); - if (primaryIssuerResolution) return primaryIssuerResolution; - - if (!user && email) { - user = await findUser({ email }); - logger.warn( - `[${strategyName}] user ${user ? 'found' : 'not found'} with email: ${email} for openidId: ${openidId}`, - ); - - // If user found by email, check if they're allowed to use OpenID provider - if (user && user.provider && user.provider !== 'openid') { - logger.warn( - `[${strategyName}] Attempted OpenID login by user ${user.email}, was registered with "${user.provider}" provider`, + const lookupStartedAt = isMetricsConfigured() ? process.hrtime.bigint() : null; + const finish = (resolution: OpenIdUserResolution): OpenIdUserResolution => { + if (lookupStartedAt != null) { + recordOpenIDUserLookup( + getOpenIDUserLookupResult(resolution), + getElapsedSeconds(lookupStartedAt), ); - return { user: null, error: ErrorTypes.AUTH_FAILED, migration: false }; } + return resolution; + }; - if (user?.openidId && user.openidId !== openidId) { - logger.warn( - `[${strategyName}] Rejected email fallback for ${user.email}: stored openidId does not match token sub`, - ); - return { user: null, error: ErrorTypes.AUTH_FAILED, migration: false }; + try { + const normalizedIssuer = normalizeOpenIdIssuer(openidIssuer); + const primaryConditions = getPrimaryLookupConditions(openidId, idOnTheSource, normalizedIssuer); + + let user: IUser | null = null; + if (primaryConditions.length > 0) { + user = await findFirstOpenIdUser(findUser, primaryConditions); } - const emailIssuerResolution = resolveIssuerBoundUser( + const primaryIssuerResolution = resolveIssuerBoundUser( user, normalizedIssuer, strategyName, - 'email fallback', + 'OpenID lookup', ); - if (emailIssuerResolution) return emailIssuerResolution; + if (primaryIssuerResolution) return finish(primaryIssuerResolution); - if (user && !user.openidId) { - logger.info( - `[${strategyName}] Preparing user ${user.email} for migration to OpenID with sub: ${openidId}`, + if (!user && email) { + user = await findUser({ email }); + logger.warn( + `[${strategyName}] user ${user ? 'found' : 'not found'} with email: ${email} for openidId: ${openidId}`, ); - user.provider = 'openid'; - user.openidId = openidId; - if (normalizedIssuer) user.openidIssuer = normalizedIssuer; - return { user, error: null, migration: true }; + + // If user found by email, check if they're allowed to use OpenID provider + if (user && user.provider && user.provider !== 'openid') { + logger.warn( + `[${strategyName}] Attempted OpenID login by user ${user.email}, was registered with "${user.provider}" provider`, + ); + return finish({ user: null, error: ErrorTypes.AUTH_FAILED, migration: false }); + } + + if (user?.openidId && user.openidId !== openidId) { + logger.warn( + `[${strategyName}] Rejected email fallback for ${user.email}: stored openidId does not match token sub`, + ); + return finish({ user: null, error: ErrorTypes.AUTH_FAILED, migration: false }); + } + + const emailIssuerResolution = resolveIssuerBoundUser( + user, + normalizedIssuer, + strategyName, + 'email fallback', + ); + if (emailIssuerResolution) return finish(emailIssuerResolution); + + if (user && !user.openidId) { + logger.info( + `[${strategyName}] Preparing user ${user.email} for migration to OpenID with sub: ${openidId}`, + ); + user.provider = 'openid'; + user.openidId = openidId; + if (normalizedIssuer) user.openidIssuer = normalizedIssuer; + return finish({ user, error: null, migration: true }); + } } - } - return { user, error: null, migration: false }; + return finish({ user, error: null, migration: false }); + } catch (error) { + if (lookupStartedAt != null) { + recordOpenIDUserLookup('error', getElapsedSeconds(lookupStartedAt)); + } + throw error; + } } diff --git a/packages/api/src/stream/GenerationJobManager.ts b/packages/api/src/stream/GenerationJobManager.ts index de6232e85b0..366445bb268 100644 --- a/packages/api/src/stream/GenerationJobManager.ts +++ b/packages/api/src/stream/GenerationJobManager.ts @@ -10,6 +10,13 @@ import type { IJobStore, } from './interfaces/IJobStore'; import type * as t from '~/types'; +import { + recordGenerationJob, + recordGenerationStreamResumePendingEvents, + recordGenerationStreamSubscription, + setGenerationJobsInFlight, +} from '~/app/metrics'; +import type { GenerationJobStore } from '~/app/metrics'; import { InMemoryEventTransport } from './implementations/InMemoryEventTransport'; import { InMemoryJobStore } from './implementations/InMemoryJobStore'; @@ -87,6 +94,9 @@ class GenerationJobManagerClass { /** Runtime state - always in-memory, not serializable */ private runtimeState = new Map(); + /** Jobs actively generating in this process. */ + private runningJobs = new Set(); + private cleanupInterval: NodeJS.Timeout | null = null; /** Whether we're using Redis stores */ @@ -144,6 +154,7 @@ class GenerationJobManagerClass { isRedis?: boolean; cleanupOnComplete?: boolean; }): void { + const previousStore = this.storeLabel; if (this.cleanupInterval) { logger.warn( '[GenerationJobManager] Reconfiguring after initialization - destroying existing services', @@ -151,10 +162,14 @@ class GenerationJobManagerClass { this.destroy(); } + this.runningJobs.clear(); + setGenerationJobsInFlight(previousStore, 0); + this.jobStore = services.jobStore; this.eventTransport = services.eventTransport; this._isRedis = services.isRedis ?? false; this._cleanupOnComplete = services.cleanupOnComplete ?? true; + this.syncRunningJobMetrics(); logger.info( `[GenerationJobManager] Configured with ${this._isRedis ? 'Redis' : 'in-memory'} stores`, @@ -168,6 +183,14 @@ class GenerationJobManagerClass { return this._isRedis; } + private get storeLabel(): GenerationJobStore { + return this._isRedis ? 'redis' : 'memory'; + } + + private syncRunningJobMetrics(store: GenerationJobStore = this.storeLabel): void { + setGenerationJobsInFlight(store, this.runningJobs.size); + } + /** * Get the job store instance (for advanced use cases). */ @@ -226,6 +249,9 @@ class GenerationJobManagerClass { hasSubscriber: false, }; this.runtimeState.set(streamId, runtime); + this.runningJobs.add(streamId); + this.syncRunningJobMetrics(); + recordGenerationJob(this.storeLabel, 'created'); // Resolve immediately - early event buffer handles late subscribers resolveReady!(); @@ -554,6 +580,9 @@ class GenerationJobManagerClass { completedAt: Date.now(), error, }); + this.runningJobs.delete(streamId); + this.syncRunningJobMetrics(); + recordGenerationJob(this.storeLabel, 'error'); // Keep runtime state so subscribe() can access errorEvent logger.debug( `[GenerationJobManager] Job completed with error (keeping for late subscribers): ${streamId}`, @@ -575,6 +604,9 @@ class GenerationJobManagerClass { }); } + this.runningJobs.delete(streamId); + this.syncRunningJobMetrics(); + recordGenerationJob(this.storeLabel, 'completed'); logger.debug(`[GenerationJobManager] Job completed: ${streamId}`); } @@ -592,6 +624,7 @@ class GenerationJobManagerClass { if (!jobData) { logger.warn(`[GenerationJobManager] Cannot abort - job not found: ${streamId}`); + recordGenerationJob(this.storeLabel, 'abort_failed'); return { text: '', content: [], @@ -682,6 +715,9 @@ class GenerationJobManagerClass { }); } + this.runningJobs.delete(streamId); + this.syncRunningJobMetrics(); + recordGenerationJob(this.storeLabel, 'aborted'); logger.debug(`[GenerationJobManager] Job aborted: ${streamId}`); return { @@ -723,9 +759,11 @@ class GenerationJobManagerClass { onError?: t.ErrorHandler, options?: t.SubscribeOptions, ): Promise<{ unsubscribe: t.UnsubscribeFn } | null> { + const subscriptionType = options?.skipBufferReplay ? 'resume' : 'initial'; // Use lazy initialization to support cross-replica subscriptions const runtime = await this.getOrCreateRuntimeState(streamId); if (!runtime) { + recordGenerationStreamSubscription(this.storeLabel, subscriptionType, 'not_found'); return null; } @@ -761,8 +799,14 @@ class GenerationJobManagerClass { onError, }); - if (subscription.ready) { - await subscription.ready; + try { + if (subscription.ready) { + await subscription.ready; + } + recordGenerationStreamSubscription(this.storeLabel, subscriptionType, 'success'); + } catch (err) { + recordGenerationStreamSubscription(this.storeLabel, subscriptionType, 'error'); + throw err; } const isFirst = this.eventTransport.isFirstSubscriber(streamId); @@ -864,6 +908,11 @@ class GenerationJobManagerClass { : 0; const resumeState = await this.getResumeState(streamId); + recordGenerationStreamSubscription( + this.storeLabel, + 'resume_state', + resumeState ? 'found' : 'missing', + ); let pendingEvents: t.ServerSentEvent[] = []; if (!this._isRedis) { @@ -872,6 +921,7 @@ class GenerationJobManagerClass { pendingEvents = runtime.earlyEventBuffer.slice(bufferLengthAtSnapshot); runtime.earlyEventBuffer = []; if (pendingEvents.length > 0) { + recordGenerationStreamResumePendingEvents(this.storeLabel, pendingEvents.length); logger.debug( `[GenerationJobManager] Captured ${pendingEvents.length} gap events for ${streamId}`, ); @@ -1179,11 +1229,13 @@ class GenerationJobManagerClass { */ private async cleanup(): Promise { const count = await this.jobStore.cleanup(); + let runningJobsChanged = false; // Cleanup runtime state for deleted jobs for (const streamId of this.runtimeState.keys()) { if (!(await this.jobStore.hasJob(streamId))) { this.runtimeState.delete(streamId); + runningJobsChanged = this.runningJobs.delete(streamId) || runningJobsChanged; this.runStepBuffers?.delete(streamId); this.jobStore.clearContentState(streamId); this.eventTransport.cleanup(streamId); @@ -1207,6 +1259,10 @@ class GenerationJobManagerClass { } } + if (runningJobsChanged) { + this.syncRunningJobMetrics(); + } + if (count > 0) { logger.debug(`[GenerationJobManager] Cleaned up ${count} expired jobs`); } @@ -1295,6 +1351,8 @@ class GenerationJobManagerClass { await this.jobStore.destroy(); this.eventTransport.destroy(); this.runtimeState.clear(); + this.runningJobs.clear(); + this.syncRunningJobMetrics(); this.runStepBuffers?.clear(); logger.debug('[GenerationJobManager] Destroyed'); From 03b477a84c73b3bc9af9370cf2c12b6f4160c8b5 Mon Sep 17 00:00:00 2001 From: Ravi Kumar L Date: Sat, 23 May 2026 03:19:45 +0200 Subject: [PATCH 062/395] =?UTF-8?q?=F0=9F=96=B2=EF=B8=8F=20feat:=20Trace?= =?UTF-8?q?=20SSE=20Stream=20Lifecycle=20with=20OTel=20(#13266)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/server/routes/agents/index.js | 31 ++-- packages/api/src/telemetry/index.ts | 2 + packages/api/src/telemetry/stream.spec.ts | 208 ++++++++++++++++++++++ packages/api/src/telemetry/stream.ts | 145 +++++++++++++++ 4 files changed, 373 insertions(+), 13 deletions(-) create mode 100644 packages/api/src/telemetry/stream.spec.ts create mode 100644 packages/api/src/telemetry/stream.ts diff --git a/api/server/routes/agents/index.js b/api/server/routes/agents/index.js index bbb39f5d2c9..f7076ce153b 100644 --- a/api/server/routes/agents/index.js +++ b/api/server/routes/agents/index.js @@ -1,5 +1,6 @@ const express = require('express'); const { isEnabled, GenerationJobManager } = require('@librechat/api'); +const { createSseStreamTelemetry } = require('@librechat/api/telemetry'); const { logger } = require('@librechat/data-schemas'); const { uaParser, @@ -76,35 +77,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 +126,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 +147,7 @@ router.get('/chat/stream/:streamId', async (req, res) => { } if (!result) { + streamTelemetry.recordSubscribeFailed(); onError('Failed to subscribe to stream'); return; } diff --git a/packages/api/src/telemetry/index.ts b/packages/api/src/telemetry/index.ts index 2eb188bf84a..104d8c9b5e5 100644 --- a/packages/api/src/telemetry/index.ts +++ b/packages/api/src/telemetry/index.ts @@ -1,5 +1,7 @@ export { getTelemetryConfig } from './config'; export { initializeTelemetry, shutdownTelemetry } from './sdk'; export { telemetryErrorMiddleware, telemetryMiddleware } from './middleware'; +export { createSseStreamTelemetry } from './stream'; +export type { SseStreamTelemetry } from './stream'; export type { TelemetryConfig, TelemetryStatus } from './config'; export type { TelemetryController } from './sdk'; diff --git a/packages/api/src/telemetry/stream.spec.ts b/packages/api/src/telemetry/stream.spec.ts new file mode 100644 index 00000000000..460ef400784 --- /dev/null +++ b/packages/api/src/telemetry/stream.spec.ts @@ -0,0 +1,208 @@ +import { EventEmitter } from 'node:events'; +import { context, SpanKind, SpanStatusCode, trace } from '@opentelemetry/api'; +import type { Response } from 'express'; +import type { Span, Tracer } from '@opentelemetry/api'; +import type { ServerRequest } from '~/types'; +import { createSseStreamTelemetry } from './stream'; + +interface MockResponse extends EventEmitter { + writableEnded: boolean; +} + +function createSpan(): jest.Mocked { + const span = {} as jest.Mocked; + span.addEvent = jest.fn, Parameters>(() => span); + span.addLink = jest.fn, Parameters>(() => span); + span.addLinks = jest.fn, Parameters>(() => span); + span.end = jest.fn>(); + span.isRecording = jest.fn>(() => true); + span.recordException = jest.fn>(); + span.setAttribute = jest.fn, Parameters>(() => span); + span.setAttributes = jest.fn, Parameters>(() => span); + span.setStatus = jest.fn, Parameters>(() => span); + span.spanContext = jest.fn, Parameters>( + () => ({ + spanId: '0000000000000000', + traceFlags: 0, + traceId: '00000000000000000000000000000000', + }), + ); + span.updateName = jest.fn, Parameters>(() => span); + return span; +} + +function createRequest(): ServerRequest { + return { + method: 'GET', + } as ServerRequest; +} + +function createResponse(): MockResponse { + const res = new EventEmitter() as MockResponse; + res.writableEnded = false; + return res; +} + +function mockTracer(span: jest.Mocked): jest.Mock { + const startSpan = jest.fn(() => span); + jest.spyOn(trace, 'getTracer').mockReturnValue({ startSpan } as unknown as Tracer); + return startSpan; +} + +afterEach(() => { + jest.restoreAllMocks(); +}); + +describe('createSseStreamTelemetry', () => { + it('records normal stream completion attributes', () => { + const span = createSpan(); + const startSpan = mockTracer(span); + const res = createResponse(); + const telemetry = createSseStreamTelemetry({ + isResume: false, + req: createRequest(), + res: res as Response, + streamId: 'stream-1', + }); + const payload = 'event: message\ndata: {"final":true}\n\n'; + + telemetry.recordHeadersFlushed(); + telemetry.recordFinalEventEmitted(); + telemetry.recordWrite(payload, { final: true }); + res.writableEnded = true; + res.emit('finish'); + + expect(startSpan).toHaveBeenCalledWith('librechat.sse.stream', { + kind: SpanKind.INTERNAL, + attributes: expect.objectContaining({ + 'http.request.method': 'GET', + 'http.route': '/api/agents/chat/stream/:streamId', + 'librechat.stream.id': 'stream-1', + 'librechat.stream.resume': false, + 'librechat.stream.route': '/api/agents/chat/stream/:streamId', + }), + }, context.active()); + expect(span.addEvent).toHaveBeenCalledWith('headers_flushed'); + expect(span.addEvent).toHaveBeenCalledWith('first_chunk'); + expect(span.addEvent).toHaveBeenCalledWith('final_event_emitted'); + expect(span.addEvent).toHaveBeenCalledWith('final_event_written'); + expect(span.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + 'http.response.body.size': Buffer.byteLength(payload), + 'librechat.stream.bytes.sent': Buffer.byteLength(payload), + 'librechat.stream.chunks.count': 1, + 'librechat.stream.completed': true, + 'librechat.stream.end_reason': 'done', + 'librechat.stream.error_event_emitted': false, + 'librechat.stream.final_event_emitted': true, + 'librechat.stream.final_event_written': true, + 'librechat.stream.time_to_first_chunk_ms': expect.any(Number), + }), + ); + expect(span.setStatus).not.toHaveBeenCalled(); + expect(span.end).toHaveBeenCalledTimes(1); + }); + + it('records client aborts on close before writableEnded', () => { + const span = createSpan(); + mockTracer(span); + const res = createResponse(); + const telemetry = createSseStreamTelemetry({ + isResume: true, + req: createRequest(), + res: res as Response, + streamId: 'stream-2', + }); + + telemetry.recordWrite('event: message\ndata: {}\n\n'); + res.emit('close'); + + expect(span.addEvent).toHaveBeenCalledWith('client_aborted'); + expect(span.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + 'librechat.stream.completed': false, + 'librechat.stream.end_reason': 'client_aborted', + }), + ); + expect(span.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.ERROR }); + expect(span.setAttribute).toHaveBeenCalledWith('error.type', 'client_aborted'); + expect(span.end).toHaveBeenCalledTimes(1); + }); + + it('preserves subscribe_failed as the terminal reason when an error event is sent', () => { + const span = createSpan(); + mockTracer(span); + const res = createResponse(); + const telemetry = createSseStreamTelemetry({ + isResume: false, + req: createRequest(), + res: res as Response, + streamId: 'stream-3', + }); + + telemetry.recordSubscribeFailed(); + telemetry.recordErrorEventEmitted(); + telemetry.recordWrite('event: error\ndata: {"error":"Failed to subscribe to stream"}\n\n'); + res.writableEnded = true; + res.emit('finish'); + + expect(span.addEvent).toHaveBeenCalledWith('subscribe_failed'); + expect(span.addEvent).toHaveBeenCalledWith('error_event_emitted'); + expect(span.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + 'librechat.stream.completed': false, + 'librechat.stream.end_reason': 'subscribe_failed', + 'librechat.stream.error_event_emitted': true, + }), + ); + expect(span.setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.ERROR }); + expect(span.setAttribute).toHaveBeenCalledWith('error.type', 'subscribe_failed'); + expect(span.end).toHaveBeenCalledTimes(1); + }); + + it('ends only once when finish and close both fire', () => { + const span = createSpan(); + mockTracer(span); + const res = createResponse(); + const telemetry = createSseStreamTelemetry({ + isResume: false, + req: createRequest(), + res: res as Response, + streamId: 'stream-4', + }); + + telemetry.recordFinalEventEmitted(); + telemetry.recordWrite('event: message\ndata: {"final":true}\n\n', { final: true }); + res.writableEnded = true; + res.emit('finish'); + res.emit('close'); + + expect(span.end).toHaveBeenCalledTimes(1); + expect(span.setAttributes).toHaveBeenCalledTimes(1); + }); + + it('does not count writes after the stream span has ended', () => { + const span = createSpan(); + mockTracer(span); + const res = createResponse(); + const telemetry = createSseStreamTelemetry({ + isResume: false, + req: createRequest(), + res: res as Response, + streamId: 'stream-5', + }); + const firstPayload = 'event: message\ndata: {"index":1}\n\n'; + + telemetry.recordWrite(firstPayload); + res.emit('close'); + telemetry.recordWrite('event: message\ndata: {"index":2}\n\n'); + + expect(span.setAttributes).toHaveBeenCalledWith( + expect.objectContaining({ + 'librechat.stream.bytes.sent': Buffer.byteLength(firstPayload), + 'librechat.stream.chunks.count': 1, + }), + ); + expect(span.end).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/api/src/telemetry/stream.ts b/packages/api/src/telemetry/stream.ts new file mode 100644 index 00000000000..d696f557c32 --- /dev/null +++ b/packages/api/src/telemetry/stream.ts @@ -0,0 +1,145 @@ +import { performance } from 'node:perf_hooks'; +import { context, SpanKind, SpanStatusCode, trace } from '@opentelemetry/api'; +import type { Attributes, Span } from '@opentelemetry/api'; +import type { Response } from 'express'; +import type { ServerRequest } from '~/types'; + +const STREAM_SPAN_NAME = 'librechat.sse.stream'; +const STREAM_ROUTE = '/api/agents/chat/stream/:streamId'; + +type StreamEndReason = 'done' | 'client_aborted' | 'server_error' | 'subscribe_failed'; + +export interface SseStreamTelemetry { + recordHeadersFlushed: () => void; + recordWrite: (payload: string, options?: { final?: boolean }) => void; + recordFinalEventEmitted: () => void; + recordErrorEventEmitted: () => void; + recordSubscribeFailed: () => void; +} + +interface SseStreamTelemetryOptions { + isResume: boolean; + req: ServerRequest; + res: Response; + streamId: string; +} + +class SseStreamSpanTelemetry implements SseStreamTelemetry { + private readonly span: Span; + private readonly startTimeMs = performance.now(); + private bytesSent = 0; + private chunksCount = 0; + private ended = false; + private errorEventEmitted = false; + private finalEventEmitted = false; + private finalEventWritten = false; + private firstChunkMs: number | undefined; + private plannedEndReason: StreamEndReason | undefined; + + constructor({ isResume, req, res, streamId }: SseStreamTelemetryOptions) { + this.span = trace.getTracer('librechat.telemetry').startSpan(STREAM_SPAN_NAME, { + kind: SpanKind.INTERNAL, + attributes: { + 'http.request.method': req.method, + 'http.route': STREAM_ROUTE, + 'librechat.stream.id': streamId, + 'librechat.stream.resume': isResume, + 'librechat.stream.route': STREAM_ROUTE, + }, + }, context.active()); + + res.once('finish', () => { + this.end(this.plannedEndReason ?? (this.errorEventEmitted ? 'server_error' : 'done')); + }); + + res.once('close', () => { + if (res.writableEnded) { + this.end(this.plannedEndReason ?? (this.errorEventEmitted ? 'server_error' : 'done')); + return; + } + + this.span.addEvent('client_aborted'); + this.end('client_aborted'); + }); + } + + recordHeadersFlushed(): void { + this.span.addEvent('headers_flushed'); + this.span.setAttribute('librechat.stream.headers_flushed', true); + } + + recordWrite(payload: string, options?: { final?: boolean }): void { + if (this.ended) { + return; + } + + this.chunksCount += 1; + this.bytesSent += Buffer.byteLength(payload); + + if (this.firstChunkMs === undefined) { + this.firstChunkMs = performance.now() - this.startTimeMs; + this.span.addEvent('first_chunk'); + this.span.setAttribute('librechat.stream.time_to_first_chunk_ms', this.firstChunkMs); + } + + if (options?.final) { + this.finalEventWritten = true; + this.span.addEvent('final_event_written'); + } + } + + recordFinalEventEmitted(): void { + this.finalEventEmitted = true; + this.plannedEndReason = 'done'; + this.span.addEvent('final_event_emitted'); + } + + recordErrorEventEmitted(): void { + this.errorEventEmitted = true; + this.plannedEndReason ??= 'server_error'; + this.span.addEvent('error_event_emitted'); + } + + recordSubscribeFailed(): void { + this.plannedEndReason = 'subscribe_failed'; + this.span.addEvent('subscribe_failed'); + } + + private end(reason: StreamEndReason): void { + if (this.ended) { + return; + } + + this.ended = true; + const attributes: Attributes = { + 'http.response.body.size': this.bytesSent, + 'librechat.stream.bytes.sent': this.bytesSent, + 'librechat.stream.chunks.count': this.chunksCount, + 'librechat.stream.completed': reason === 'done', + 'librechat.stream.duration_ms': performance.now() - this.startTimeMs, + 'librechat.stream.end_reason': reason, + 'librechat.stream.error_event_emitted': this.errorEventEmitted, + 'librechat.stream.final_event_emitted': this.finalEventEmitted, + 'librechat.stream.final_event_written': this.finalEventWritten, + }; + + if (this.firstChunkMs !== undefined) { + attributes['librechat.stream.time_to_first_chunk_ms'] = this.firstChunkMs; + } + + this.span.setAttributes(attributes); + + if (reason !== 'done') { + this.span.setStatus({ code: SpanStatusCode.ERROR }); + this.span.setAttribute('error.type', reason); + } + + this.span.end(); + } +} + +export function createSseStreamTelemetry( + options: SseStreamTelemetryOptions, +): SseStreamTelemetry { + return new SseStreamSpanTelemetry(options); +} From 01af63cb52f4dccdcccf0ea6ea4f7632bc387723 Mon Sep 17 00:00:00 2001 From: Dev Chohan <87763613+devanchohan@users.noreply.github.com> Date: Sat, 23 May 2026 08:20:59 +0700 Subject: [PATCH 063/395] =?UTF-8?q?=E2=8C=9B=20fix:=20Use=20JWT=20exp=20cl?= =?UTF-8?q?aim=20for=20MCP=20when=20OAuth=20token=20omits=20expires=5Fin?= =?UTF-8?q?=20(#13248)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP OAuth access tokens are stored with a 365-day default expiry when the provider's token response omits `expires_in` (only RECOMMENDED per RFC 6749 §5.1). Providers that issue short-lived JWT access tokens but omit `expires_in` (e.g. Salesforce) therefore get tokens treated as valid for a year and never refreshed, so every call 401s once the real token lapses until the user manually reconnects. When the access token is a JWT (RFC 9068), read its `exp` claim and use it as the authoritative expiry, falling back to the 365-day default only for opaque tokens. Explicit `expires_at`/`expires_in` still take precedence. Adds unit tests for storeTokens expiry resolution. Co-authored-by: Claude Opus 4.7 --- packages/api/src/mcp/oauth/tokens.test.ts | 90 +++++++++++++++++++++++ packages/api/src/mcp/oauth/tokens.ts | 48 +++++++++++- 2 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 packages/api/src/mcp/oauth/tokens.test.ts diff --git a/packages/api/src/mcp/oauth/tokens.test.ts b/packages/api/src/mcp/oauth/tokens.test.ts new file mode 100644 index 00000000000..f22c7f69c83 --- /dev/null +++ b/packages/api/src/mcp/oauth/tokens.test.ts @@ -0,0 +1,90 @@ +import jwt from 'jsonwebtoken'; +import type { OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js'; +import type { TokenMethods } from '@librechat/data-schemas'; +import { MCPTokenStorage } from './tokens'; + +jest.mock('@librechat/data-schemas', () => ({ + logger: { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), + }, + encryptV2: jest.fn(async (value: string) => `encrypted:${value}`), + decryptV2: jest.fn(async (value: string) => value.replace(/^encrypted:/, '')), +})); + +// Avoid pulling in librechat-data-provider via ~/mcp/utils; storeTokens does not use it. +jest.mock('~/mcp/utils', () => ({ + isInvalidClientMessage: jest.fn(() => false), +})); + +const DEFAULT_TTL_SECONDS = 365 * 24 * 60 * 60; + +/** Signs a JWT carrying the given `exp` (epoch seconds). Signature is irrelevant — storeTokens only decodes. */ +function makeJwt(expEpochSeconds: number): string { + return jwt.sign({ exp: expEpochSeconds, sub: 'test-user' }, 'test-secret'); +} + +/** Runs storeTokens with only createToken wired up and returns the stored access-token record. */ +async function storeAndCapture(tokens: OAuthTokens) { + const createToken = jest.fn().mockResolvedValue({}); + await MCPTokenStorage.storeTokens({ + userId: 'user-1', + serverName: 'salesforce', + tokens, + createToken: createToken as unknown as TokenMethods['createToken'], + }); + const accessTokenCall = createToken.mock.calls.find((call) => call[0]?.type === 'mcp_oauth'); + expect(accessTokenCall).toBeDefined(); + return accessTokenCall![0] as { expiresIn: number }; +} + +describe('MCPTokenStorage.storeTokens expiry handling', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('uses the JWT `exp` claim when the provider omits expires_in/expires_at', async () => { + const expSeconds = Math.floor(Date.now() / 1000) + 1800; // 30 minutes + const stored = await storeAndCapture({ + access_token: makeJwt(expSeconds), + token_type: 'Bearer', + }); + + // ~1800s, not the 365-day default. + expect(stored.expiresIn).toBeGreaterThanOrEqual(1798); + expect(stored.expiresIn).toBeLessThanOrEqual(1800); + expect(stored.expiresIn).toBeLessThan(DEFAULT_TTL_SECONDS); + }); + + it('falls back to the default TTL for opaque (non-JWT) tokens with no expiry', async () => { + const stored = await storeAndCapture({ + access_token: '00Dxx0000001gPF!AQ4AQP_opaque_salesforce_session_token', + token_type: 'Bearer', + }); + + expect(stored.expiresIn).toBe(DEFAULT_TTL_SECONDS); + }); + + it('still prefers an explicit expires_in over the JWT exp', async () => { + const expSeconds = Math.floor(Date.now() / 1000) + 1800; + const stored = await storeAndCapture({ + access_token: makeJwt(expSeconds), + token_type: 'Bearer', + expires_in: 900, + }); + + expect(stored.expiresIn).toBe(900); + }); + + it('ignores a JWT exp that is already in the past and uses the default TTL', async () => { + const expSeconds = Math.floor(Date.now() / 1000) - 100; // already expired + const stored = await storeAndCapture({ + access_token: makeJwt(expSeconds), + token_type: 'Bearer', + }); + + expect(stored.expiresIn).toBe(DEFAULT_TTL_SECONDS); + }); +}); diff --git a/packages/api/src/mcp/oauth/tokens.ts b/packages/api/src/mcp/oauth/tokens.ts index 5476cc302b5..ae15268c8fa 100644 --- a/packages/api/src/mcp/oauth/tokens.ts +++ b/packages/api/src/mcp/oauth/tokens.ts @@ -1,3 +1,4 @@ +import jwt from 'jsonwebtoken'; import { logger, encryptV2, decryptV2 } from '@librechat/data-schemas'; import type { OAuthTokens, OAuthClientInformation } from '@modelcontextprotocol/sdk/shared/auth.js'; import type { TokenMethods, IToken } from '@librechat/data-schemas'; @@ -54,6 +55,33 @@ interface GetTokensParams { deleteTokens?: TokenMethods['deleteTokens']; } +/** + * Reads the `exp` claim (RFC 7519 §4.1.4 / RFC 9068) from a JWT-format access + * token, returned as epoch milliseconds. Returns null for opaque (non-JWT) + * tokens or when no usable `exp` is present. The signature is intentionally + * not verified — the protected resource server validates the token; here we + * only read its self-declared expiry to avoid a lossy default. + */ +function getJwtAccessTokenExpiry(accessToken?: string): number | null { + if (!accessToken) { + return null; + } + try { + const decoded = jwt.decode(accessToken); + if ( + decoded != null && + typeof decoded !== 'string' && + typeof decoded.exp === 'number' && + Number.isFinite(decoded.exp) + ) { + return decoded.exp * 1000; + } + } catch { + /* Not a JWT or malformed — fall through to other expiry sources. */ + } + return null; +} + export class MCPTokenStorage { static getLogPrefix(userId: string, serverName: string): string { return isSystemUserId(userId) @@ -105,9 +133,23 @@ export class MCPTokenStorage { expiresInSeconds = tokens.expires_in; accessTokenExpiry = new Date(Date.now() + tokens.expires_in * 1000); } else { - logger.debug(`${logPrefix} No expiry provided, using default`); - expiresInSeconds = defaultTTL; - accessTokenExpiry = new Date(Date.now() + defaultTTL * 1000); + /** + * RFC 6749 §5.1 makes `expires_in` only RECOMMENDED, so some providers + * (e.g. Salesforce) omit it. When the access token is a JWT (RFC 9068), + * its `exp` claim is the authoritative lifetime — prefer it over the + * 365-day default so the token is refreshed on time rather than being + * treated as valid for a year and never refreshed. + */ + const jwtExpiryMs = getJwtAccessTokenExpiry(tokens.access_token); + if (jwtExpiryMs != null && jwtExpiryMs > Date.now()) { + logger.debug(`${logPrefix} Using JWT exp claim: ${new Date(jwtExpiryMs).toISOString()}`); + accessTokenExpiry = new Date(jwtExpiryMs); + expiresInSeconds = Math.floor((jwtExpiryMs - Date.now()) / 1000); + } else { + logger.debug(`${logPrefix} No expiry provided, using default`); + expiresInSeconds = defaultTTL; + accessTokenExpiry = new Date(Date.now() + defaultTTL * 1000); + } } logger.debug(`${logPrefix} Calculated expiry date: ${accessTokenExpiry.toISOString()}`); From 5d393ad79a036f74b3c7d412634e65ac33c47aab Mon Sep 17 00:00:00 2001 From: eleite93 <93412593+eleite93@users.noreply.github.com> Date: Sat, 23 May 2026 13:57:59 +0100 Subject: [PATCH 064/395] =?UTF-8?q?=F0=9F=AA=AA=20fix:=20Support=20OpenID?= =?UTF-8?q?=20PKCE=20Without=20Client=20Secret=20(#12364)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: allow OpenID PKCE authentication without client secret * Linting * Strategy fix * fix(openid): trim secret gates and add PKCE client metadata tests * chore(openid): normalize spec line endings * ⚡ perf: Short-Circuit Config Override Resolution for Empty Principals (#12549) Skip the getApplicableConfigs DB query when buildPrincipals returns an empty array, since there are no principals to match against. * ⚡ perf: Separate Error Handling for Principal Resolution vs Config Overrides (#12550) Distinguish between buildPrincipals and getApplicableConfigs failures so the uncached fallback to baseConfig is intentional and logged separately from config override errors. * Revert "⚡ perf: Separate Error Handling for Principal Resolution vs Config Overrides (#12550)" This reverts commit 1729378a652f97e33ceded17c1b6735888670702. * Revert "⚡ perf: Short-Circuit Config Override Resolution for Empty Principals (#12549)" This reverts commit a100aa57382518e9525d0f98e7540ea5e8d09545. --------- Co-authored-by: CMF\e-leite Co-authored-by: Danny Avila --- .env.example | 3 +- api/server/routes/config.js | 2 +- api/server/socialLogins.js | 2 +- api/strategies/openidStrategy.js | 25 +++-- api/strategies/openidStrategy.spec.js | 149 +++++++++++++++++++++++--- 5 files changed, 154 insertions(+), 27 deletions(-) diff --git a/.env.example b/.env.example index 35a2a840431..81294989821 100644 --- a/.env.example +++ b/.env.example @@ -570,7 +570,8 @@ 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= diff --git a/api/server/routes/config.js b/api/server/routes/config.js index 2b9fd6e7ad9..33d1a8a3250 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -33,7 +33,7 @@ function isBirthday() { function buildSharedPayload() { 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; diff --git a/api/server/socialLogins.js b/api/server/socialLogins.js index dfb03b4d37d..78f0e82a322 100644 --- a/api/server/socialLogins.js +++ b/api/server/socialLogins.js @@ -83,7 +83,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 diff --git a/api/strategies/openidStrategy.js b/api/strategies/openidStrategy.js index 595c2b535a4..7321eb59d2e 100644 --- a/api/strategies/openidStrategy.js +++ b/api/strategies/openidStrategy.js @@ -767,18 +767,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 +800,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 +812,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(), ); diff --git a/api/strategies/openidStrategy.spec.js b/api/strategies/openidStrategy.spec.js index 15e507e9b56..56cc97a3afc 100644 --- a/api/strategies/openidStrategy.spec.js +++ b/api/strategies/openidStrategy.spec.js @@ -3,10 +3,12 @@ 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 { getOpenIdIssuer, resolveAppConfigForUser, isEnabled } = require('@librechat/api'); const { getAppConfig } = require('~/server/services/Config'); const { setupOpenId } = require('./openidStrategy'); +const mockCloudfrontFileSource = FileSources.cloudfront ?? 'cloudfront'; + // --- Mocks --- jest.mock('node-fetch'); jest.mock('jsonwebtoken/decode'); @@ -22,18 +24,68 @@ jest.mock('~/server/services/Files/strategies', () => ({ 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'), + 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(), @@ -145,6 +197,7 @@ describe('setupOpenId', () => { beforeEach(async () => { // Clear previous mock calls and reset implementations jest.clearAllMocks(); + isEnabled.mockImplementation(jest.requireActual('@librechat/api').isEnabled); // Reset environment variables needed by the strategy process.env.OPENID_ISSUER = 'https://fake-issuer.com'; @@ -164,6 +217,7 @@ describe('setupOpenId', () => { delete process.env.OPENID_EMAIL_CLAIM; delete process.env.PROXY; delete process.env.OPENID_USE_PKCE; + delete process.env.OPENID_GENERATE_NONCE; // Default jwtDecode mock returns a token that includes the required role. jwtDecode.mockReturnValue({ @@ -195,6 +249,71 @@ 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('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(); @@ -1122,7 +1241,7 @@ describe('setupOpenId', () => { 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 +1249,7 @@ describe('setupOpenId', () => { const { saveBuffer } = strategyResult.value; const [saveParams] = saveBuffer.mock.calls[0]; - expect(getStrategyFunctions).toHaveBeenLastCalledWith(FileSources.cloudfront); + expect(getStrategyFunctions).toHaveBeenLastCalledWith(mockCloudfrontFileSource); expect(saveParams).toEqual( expect.objectContaining({ basePath: 'avatars', From 058cd5219f21f7fe3dc5bdbac56352a311fe9c7d Mon Sep 17 00:00:00 2001 From: jingyeong <40864934+sand116@users.noreply.github.com> Date: Sat, 23 May 2026 21:58:15 +0900 Subject: [PATCH 065/395] =?UTF-8?q?=F0=9F=93=9C=20feat:=20Add=20Explicit?= =?UTF-8?q?=20new=20Skill=20Route=20from=20Agent=20Builder=20(#13119)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: add explicit skills/new route * style: fix test formatting --------- Co-authored-by: parkjingyeong --- .../components/Skills/layouts/SkillsView.tsx | 27 ++++++- .../layouts/__tests__/SkillsView.spec.tsx | 72 +++++++++++++++++++ .../routes/__tests__/skillsRoutes.spec.tsx | 70 ++++++++++++++++++ client/src/routes/index.tsx | 4 ++ 4 files changed, 170 insertions(+), 3 deletions(-) create mode 100644 client/src/components/Skills/layouts/__tests__/SkillsView.spec.tsx create mode 100644 client/src/routes/__tests__/skillsRoutes.spec.tsx diff --git a/client/src/components/Skills/layouts/SkillsView.tsx b/client/src/components/Skills/layouts/SkillsView.tsx index 961a2751072..b19ba588fb0 100644 --- a/client/src/components/Skills/layouts/SkillsView.tsx +++ b/client/src/components/Skills/layouts/SkillsView.tsx @@ -6,15 +6,15 @@ import { useHasAccess, useAuthContext, useLocalize } from '~/hooks'; import SkillFileViewer from '~/components/Skills/display/SkillFileViewer'; import SkillDetail from '~/components/Skills/display/SkillDetail'; import SkillState from '~/components/Skills/display/SkillState'; -import { SkillForm } from '~/components/Skills/forms'; +import { CreateSkillForm, SkillForm } from '~/components/Skills/forms'; /** - * Skill detail / edit route content. + * Skill detail / edit / create route content. * * Reader-first: the default `/skills/:skillId` shows the read-only * `SkillDetail` view (rendered markdown, metadata, source toggle). * Edit is reached via `/skills/:skillId/edit` or the Edit button. - * Create is a dialog triggered from the sidebar, not a route. + * Create is reached via `/skills/new`. */ export default function SkillsView() { const { skillId } = useParams(); @@ -26,7 +26,12 @@ export default function SkillsView() { permissionType: PermissionTypes.SKILLS, permission: Permissions.USE, }); + const hasCreateAccess = useHasAccess({ + permissionType: PermissionTypes.SKILLS, + permission: Permissions.CREATE, + }); + const isCreate = location.pathname.endsWith('/new'); const isEdit = location.pathname.endsWith('/edit'); const rolesLoaded = user?.role != null && roles?.[user.role] != null; @@ -42,6 +47,14 @@ export default function SkillsView() { return ; } + if (isCreate && !hasCreateAccess) { + return ; + } + + if (isCreate) { + return ; + } + // No skill selected — empty state if (!skillId) { return ( @@ -57,6 +70,14 @@ export default function SkillsView() { return isEdit ? : ; } +function CreateView() { + return ( +
+ +
+ ); +} + /** Read-only detail view — the default when clicking a skill. */ function DetailView({ skillId }: { skillId: string }) { const localize = useLocalize(); diff --git a/client/src/components/Skills/layouts/__tests__/SkillsView.spec.tsx b/client/src/components/Skills/layouts/__tests__/SkillsView.spec.tsx new file mode 100644 index 00000000000..8243d9544f4 --- /dev/null +++ b/client/src/components/Skills/layouts/__tests__/SkillsView.spec.tsx @@ -0,0 +1,72 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import { createMemoryRouter, RouterProvider } from 'react-router-dom'; +import SkillsView from '../SkillsView'; + +const mockUseHasAccess = jest.fn(() => true); + +jest.mock( + 'librechat-data-provider', + () => ({ + PermissionTypes: { SKILLS: 'skills' }, + Permissions: { USE: 'use', CREATE: 'create' }, + }), + { virtual: true }, +); + +jest.mock( + '@librechat/client', + () => ({ + Spinner: () =>
, + }), + { virtual: true }, +); + +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => key, + useHasAccess: (...args: unknown[]) => mockUseHasAccess(...args), + useAuthContext: () => ({ + user: { role: 'admin' }, + roles: { admin: {} }, + }), +})); + +jest.mock('~/data-provider', () => ({ + useGetSkillByIdQuery: jest.fn(() => ({ + isLoading: false, + isError: false, + data: null, + })), +})); + +jest.mock('~/components/Skills/forms', () => ({ + CreateSkillForm: () =>
, + SkillForm: () =>
, +})); + +jest.mock('~/components/Skills/display/SkillFileViewer', () => () => ( +
+)); +jest.mock('~/components/Skills/display/SkillDetail', () => () => ( +
+)); +jest.mock('~/components/Skills/display/SkillState', () => ({ title }: { title: string }) => ( +
{title}
+)); + +describe('SkillsView', () => { + beforeEach(() => { + mockUseHasAccess.mockReset(); + mockUseHasAccess.mockReturnValue(true); + }); + + it('renders the create skill form for /skills/new', () => { + const router = createMemoryRouter([{ path: '/skills/new', element: }], { + initialEntries: ['/skills/new'], + }); + + render(); + + expect(screen.getByTestId('create-skill-form')).toBeInTheDocument(); + }); +}); diff --git a/client/src/routes/__tests__/skillsRoutes.spec.tsx b/client/src/routes/__tests__/skillsRoutes.spec.tsx new file mode 100644 index 00000000000..7263a5ceb3e --- /dev/null +++ b/client/src/routes/__tests__/skillsRoutes.spec.tsx @@ -0,0 +1,70 @@ +import React from 'react'; + +jest.mock('~/components/Auth', () => ({ + Login: () => null, + VerifyEmail: () => null, + Registration: () => null, + ResetPassword: () => null, + ApiErrorWatcher: () => null, + TwoFactorScreen: () => null, + RequestPasswordReset: () => null, +})); + +jest.mock('~/components/Agents/MarketplaceContext', () => ({ + MarketplaceProvider: ({ children }: { children: React.ReactNode }) => children, +})); + +jest.mock('~/components/Agents/Marketplace', () => () => null); +jest.mock('~/components/OAuth', () => ({ + OAuthSuccess: () => null, + OAuthError: () => null, +})); +jest.mock('~/hooks/AuthContext', () => ({ + AuthContextProvider: ({ children }: { children: React.ReactNode }) => children, +})); + +jest.mock('../RouteErrorBoundary', () => () => null); +jest.mock('../Layouts/Startup', () => () => null); +jest.mock('../Layouts/Login', () => () => null); +jest.mock('../Dashboard', () => ({ + __esModule: true, + default: { path: 'dashboard', element: null }, +})); +jest.mock('../ShareRoute', () => ({ + __esModule: true, + default: () => null, +})); +jest.mock('../ChatRoute', () => ({ + __esModule: true, + default: () => null, +})); +jest.mock('../Search', () => ({ + __esModule: true, + default: () => null, +})); +jest.mock('../Root', () => ({ + __esModule: true, + default: () => null, +})); + +import { router } from '../index'; + +type RouteNode = { + path?: string; + children?: RouteNode[]; +}; + +function flattenPaths(routes: RouteNode[]): string[] { + return routes.flatMap((route) => [ + ...(route.path ? [route.path] : []), + ...(route.children ? flattenPaths(route.children) : []), + ]); +} + +describe('skills routes', () => { + it('registers the explicit /skills/new route', () => { + const paths = flattenPaths((router as unknown as { routes: RouteNode[] }).routes); + + expect(paths).toContain('skills/new'); + }); +}); diff --git a/client/src/routes/index.tsx b/client/src/routes/index.tsx index b73aa47b1f1..3a120fba3dd 100644 --- a/client/src/routes/index.tsx +++ b/client/src/routes/index.tsx @@ -137,6 +137,10 @@ export const router = createBrowserRouter( path: 'skills', lazy: loadSkillsView, }, + { + path: 'skills/new', + lazy: loadSkillsView, + }, { path: 'skills/:skillId', lazy: loadSkillsView, From fb851cae63910b639b3acf49f6923a0d1c72e058 Mon Sep 17 00:00:00 2001 From: apuzikov Date: Sat, 23 May 2026 15:01:44 +0200 Subject: [PATCH 066/395] =?UTF-8?q?=F0=9F=AA=AA=20fix:=20Allow=20Optional?= =?UTF-8?q?=20client=5Fsecret=20for=20MCP=20OAuth=20(#12460)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: Allow empty client_secret for MCP OAuth configuration * fix: Enhance OAuth client registration logic to support predefined client_id and handle empty client_secret --- .../MCPServerDialog/sections/AuthSection.tsx | 26 ++----------------- packages/api/src/mcp/oauth/handler.ts | 21 ++++++++++++++- 2 files changed, 22 insertions(+), 25 deletions(-) diff --git a/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/sections/AuthSection.tsx b/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/sections/AuthSection.tsx index 6d18ccf15b0..29b3924c3ed 100644 --- a/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/sections/AuthSection.tsx +++ b/client/src/components/SidePanel/MCPBuilder/MCPServerDialog/sections/AuthSection.tsx @@ -183,35 +183,13 @@ export default function AuthSection({ isEditMode, serverName }: AuthSectionProps
- {errors.auth?.oauth_client_secret && ( - - )}
diff --git a/packages/api/src/mcp/oauth/handler.ts b/packages/api/src/mcp/oauth/handler.ts index 0e7a645b033..6f1deea8a3d 100644 --- a/packages/api/src/mcp/oauth/handler.ts +++ b/packages/api/src/mcp/oauth/handler.ts @@ -597,7 +597,25 @@ export class MCPOAuthHandler { let clientInfo: OAuthClientInformation | undefined; let reusedStoredClient = false; - if (findToken) { + if (config?.client_id) { + logger.debug(`[MCPOAuth] Using predefined client_id for ${serverName}`); + let tokenEndpointAuthMethod: string; + if (!config.client_secret) { + tokenEndpointAuthMethod = 'none'; + } else { + tokenEndpointAuthMethod = + getForcedTokenEndpointAuthMethod(config.token_exchange_method) ?? 'client_secret_basic'; + } + + clientInfo = { + client_id: config.client_id, + client_secret: config.client_secret, + redirect_uris: [redirectUri], + scope: config.scope, + token_endpoint_auth_method: tokenEndpointAuthMethod, + }; + logger.debug(`[MCPOAuth] Using predefined client with ID: ${clientInfo.client_id}`); + } else if (findToken) { try { const existing = await MCPTokenStorage.getClientInfoAndMetadata({ userId, @@ -638,6 +656,7 @@ export class MCPOAuthHandler { } if (!clientInfo) { + logger.debug(`[MCPOAuth] Registering OAuth client with redirect URI: ${redirectUri}`); clientInfo = await this.registerOAuthClient( authServerUrl.toString(), metadata, From abda15f4eb33d8511a7b29e23eff6d2689e47e85 Mon Sep 17 00:00:00 2001 From: janluedemann-esome Date: Sat, 23 May 2026 15:09:13 +0200 Subject: [PATCH 067/395] =?UTF-8?q?=F0=9F=9B=82=20fix:=20Detect=20OAuth=20?= =?UTF-8?q?Errors=20From=20HTTP=20400=20Responses=20(#11961)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(mcp): detect non-standard OAuth errors from servers returning HTTP 400 * add tests for oauth error check * fix(mcp): align factory OAuth error detection --- packages/api/src/mcp/MCPConnectionFactory.ts | 8 +++ .../src/mcp/__tests__/MCPConnection.test.ts | 22 +++++++ .../__tests__/MCPConnectionFactory.test.ts | 66 +++++++++++++++++++ packages/api/src/mcp/connection.ts | 4 ++ 4 files changed, 100 insertions(+) diff --git a/packages/api/src/mcp/MCPConnectionFactory.ts b/packages/api/src/mcp/MCPConnectionFactory.ts index 68e3abd776c..760325cbba4 100644 --- a/packages/api/src/mcp/MCPConnectionFactory.ts +++ b/packages/api/src/mcp/MCPConnectionFactory.ts @@ -626,10 +626,18 @@ export class MCPConnectionFactory { if (message.includes('invalid_token')) { return true; } + // Check for invalid_grant (OAuth servers return this for expired/revoked grants) + if (message.includes('invalid_grant')) { + return true; + } // Check for authentication required if (message.includes('authentication required') || message.includes('unauthorized')) { return true; } + // Check for missing authorization values (e.g., Amazon Ads MCP returns HTTP 400 with this) + if (message.includes('no authorization')) { + return true; + } } return false; diff --git a/packages/api/src/mcp/__tests__/MCPConnection.test.ts b/packages/api/src/mcp/__tests__/MCPConnection.test.ts index 5cb5606d570..6ba9d921712 100644 --- a/packages/api/src/mcp/__tests__/MCPConnection.test.ts +++ b/packages/api/src/mcp/__tests__/MCPConnection.test.ts @@ -82,6 +82,10 @@ describe('MCPConnection Error Detection', () => { if (message.includes('authentication required') || message.includes('unauthorized')) { return true; } + // Check for missing authorization values (e.g., Amazon Ads MCP returns HTTP 400 with this) + if (message.includes('no authorization')) { + return true; + } } return false; @@ -171,6 +175,24 @@ describe('MCPConnection Error Detection', () => { }; expect(isOAuthError(error)).toBe(true); }); + + it('should detect OAuth error for "no authorization" in message (HTTP 400)', () => { + const error = { + message: + 'Either no authorization values are specified or it could not be derived from the request', + }; + expect(isOAuthError(error)).toBe(true); + }); + + it('should detect OAuth error for "No authorization" with different casing', () => { + const error = { message: 'No Authorization header provided' }; + expect(isOAuthError(error)).toBe(true); + }); + + it('should not detect OAuth error for unrelated 400 errors', () => { + const error = { code: 400, message: 'Bad request: missing required field' }; + expect(isOAuthError(error)).toBe(false); + }); }); describe('error type differentiation', () => { diff --git a/packages/api/src/mcp/__tests__/MCPConnectionFactory.test.ts b/packages/api/src/mcp/__tests__/MCPConnectionFactory.test.ts index 5a0787868d3..8018ec0a6f4 100644 --- a/packages/api/src/mcp/__tests__/MCPConnectionFactory.test.ts +++ b/packages/api/src/mcp/__tests__/MCPConnectionFactory.test.ts @@ -844,6 +844,72 @@ describe('MCPConnectionFactory', () => { expect.stringContaining('OAuth required, stopping connection attempts'), ); }); + + it('should identify "no authorization" errors as OAuth errors (HTTP 400)', async () => { + const basicOptions = { + serverName: 'test-server', + serverConfig: mockServerConfig, + }; + + const oauthOptions = { + useOAuth: true as const, + user: mockUser, + flowManager: mockFlowManager, + tokenMethods: { + findToken: jest.fn(), + createToken: jest.fn(), + updateToken: jest.fn(), + deleteTokens: jest.fn(), + }, + }; + + const noAuthError = new Error( + 'Either no authorization values are specified or it could not be derived from the request', + ); + + mockConnectionInstance.connect.mockRejectedValue(noAuthError); + mockConnectionInstance.isConnected.mockResolvedValue(false); + + await expect(MCPConnectionFactory.create(basicOptions, oauthOptions)).rejects.toThrow( + 'no authorization', + ); + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringContaining('OAuth required, stopping connection attempts'), + ); + }); + + it('should identify invalid_grant errors as OAuth errors', async () => { + const basicOptions = { + serverName: 'test-server', + serverConfig: mockServerConfig, + }; + + const oauthOptions = { + useOAuth: true as const, + user: mockUser, + flowManager: mockFlowManager, + tokenMethods: { + findToken: jest.fn(), + createToken: jest.fn(), + updateToken: jest.fn(), + deleteTokens: jest.fn(), + }, + }; + + const invalidGrantError = new Error( + 'Streamable HTTP error: Error POSTing to endpoint: {"error":"invalid_grant"}', + ); + + mockConnectionInstance.connect.mockRejectedValue(invalidGrantError); + mockConnectionInstance.isConnected.mockResolvedValue(false); + + await expect(MCPConnectionFactory.create(basicOptions, oauthOptions)).rejects.toThrow( + 'invalid_grant', + ); + expect(mockLogger.info).toHaveBeenCalledWith( + expect.stringContaining('OAuth required, stopping connection attempts'), + ); + }); }); describe('discoverTools static method', () => { diff --git a/packages/api/src/mcp/connection.ts b/packages/api/src/mcp/connection.ts index 8edc01f2f49..3049c33ad25 100644 --- a/packages/api/src/mcp/connection.ts +++ b/packages/api/src/mcp/connection.ts @@ -2326,6 +2326,10 @@ export class MCPConnection extends EventEmitter { if (message.includes('authentication required') || message.includes('unauthorized')) { return true; } + // Check for missing authorization values (e.g., Amazon Ads MCP returns HTTP 400 with this) + if (message.includes('no authorization')) { + return true; + } } return false; From 1746153c173a70738fb14d8f15edf09574dec35d Mon Sep 17 00:00:00 2001 From: Max <135263966+verifizieren@users.noreply.github.com> Date: Sat, 23 May 2026 15:24:29 +0200 Subject: [PATCH 068/395] =?UTF-8?q?=F0=9F=AA=AC=20fix:=20Skip=20MCP=20Tool?= =?UTF-8?q?s=20When=20Required=20Custom=20User=20Vars=20Are=20Unset=20(#13?= =?UTF-8?q?152)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: skip MCP tools when required customUserVars are unset (#10969) * fix: whitespace-only values Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix: guard MCP registry lookup and unknown server config in customUserVars gate * fix: fail closed on MCP registry lookup errors --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Danny Avila --- api/server/services/ToolService.js | 35 +++++- api/server/services/Tools/mcp.js | 22 ++++ api/server/services/Tools/mcp.spec.js | 105 ++++++++++++++++++ .../services/__tests__/ToolService.spec.js | 98 +++++++++++++++- packages/api/src/mcp/__tests__/utils.test.ts | 54 +++++++++ packages/api/src/mcp/utils.ts | 23 ++++ 6 files changed, 335 insertions(+), 2 deletions(-) create mode 100644 api/server/services/Tools/mcp.spec.js diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 1c1e2cf4b95..83e47e445bb 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -20,6 +20,7 @@ const { buildImageToolContext, buildOAuthToolCallName, buildToolClassification, + getMissingCustomUserVars, buildWebSearchDynamicContext, getCodeApiAuthHeaders, } = require('@librechat/api'); @@ -67,7 +68,7 @@ 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'); @@ -638,6 +639,38 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to }; const getOrFetchMCPServerTools = async (userId, serverName) => { + 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; + } + const cached = await getMCPServerTools(userId, serverName); if (cached) { return cached; diff --git a/api/server/services/Tools/mcp.js b/api/server/services/Tools/mcp.js index f1ebcf97961..4b654fa11ab 100644 --- a/api/server/services/Tools/mcp.js +++ b/api/server/services/Tools/mcp.js @@ -1,4 +1,5 @@ const { logger } = require('@librechat/data-schemas'); +const { getMissingCustomUserVars } = require('@librechat/api'); const { CacheKeys, Constants } = require('librechat-data-provider'); const { getMCPManager, getMCPServersRegistry, getFlowStateManager } = require('~/config'); const { findToken, createToken, updateToken, deleteTokens } = require('~/models'); @@ -86,6 +87,27 @@ async function reinitMCPServer({ } 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 }; diff --git a/api/server/services/Tools/mcp.spec.js b/api/server/services/Tools/mcp.spec.js new file mode 100644 index 00000000000..bce51aa29a9 --- /dev/null +++ b/api/server/services/Tools/mcp.spec.js @@ -0,0 +1,105 @@ +const { Constants } = require('librechat-data-provider'); + +const mockGetConnection = jest.fn(); + +jest.mock('~/config', () => ({ + getMCPManager: jest.fn(() => ({ getConnection: mockGetConnection })), + 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: jest.fn(), +})); +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(); + }); + + 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('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__/ToolService.spec.js b/api/server/services/__tests__/ToolService.spec.js index be066d67927..ab95b389ca3 100644 --- a/api/server/services/__tests__/ToolService.spec.js +++ b/api/server/services/__tests__/ToolService.spec.js @@ -35,6 +35,8 @@ const mockDomainParser = jest.fn(); const mockLegacyDomainEncode = jest.fn(); const mockDecryptMetadata = jest.fn(); const mockCreateActionTool = jest.fn(); +const mockGetServerConfig = jest.fn(); +const mockResolveConfigServers = jest.fn(); jest.mock('~/server/services/Tools/credentials', () => ({ loadAuthValues: jest.fn().mockResolvedValue({}), })); @@ -69,9 +71,12 @@ jest.mock('~/models', () => ({ })); jest.mock('~/config', () => ({ getFlowStateManager: jest.fn(() => ({})), + getMCPServersRegistry: jest.fn(() => ({ + getServerConfig: (...args) => mockGetServerConfig(...args), + })), })); jest.mock('~/server/services/MCP', () => ({ - resolveConfigServers: jest.fn().mockResolvedValue({}), + resolveConfigServers: (...args) => mockResolveConfigServers(...args), })); jest.mock('~/cache', () => ({ getLogStores: jest.fn(() => ({})), @@ -113,6 +118,11 @@ describe('ToolService - Action Capability Gating', () => { }); mockLoadToolsUtil.mockResolvedValue({ loadedTools: [], toolContextMap: {} }); mockLoadActionSets.mockResolvedValue([]); + mockGetMCPServerTools.mockResolvedValue(null); + mockGetCachedTools.mockResolvedValue(null); + mockGetUserMCPAuthMap.mockResolvedValue({}); + mockGetServerConfig.mockResolvedValue(undefined); + mockResolveConfigServers.mockResolvedValue({}); }); describe('resolveAgentCapabilities', () => { @@ -259,6 +269,92 @@ describe('ToolService - Action Capability Gating', () => { expect(result.actionsEnabled).toBe(false); }); + + 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 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); + }); }); describe('loadAgentTools (definitionsOnly=false) — action tool filtering', () => { diff --git a/packages/api/src/mcp/__tests__/utils.test.ts b/packages/api/src/mcp/__tests__/utils.test.ts index 19f9c1eaf7c..ed626cdb458 100644 --- a/packages/api/src/mcp/__tests__/utils.test.ts +++ b/packages/api/src/mcp/__tests__/utils.test.ts @@ -5,6 +5,8 @@ import { redactServerSecrets, isInvalidClientMessage, isClientRejectionMessage, + getMissingCustomUserVars, + hasCustomUserVars, isUserSourced, } from '~/mcp/utils'; import type { ParsedServerConfig } from '~/mcp/types'; @@ -340,3 +342,55 @@ describe('isUserSourced', () => { expect(isUserSourced({})).toBe(false); }); }); + +describe('getMissingCustomUserVars', () => { + const configWithVars = (keys: string[]): Pick => ({ + customUserVars: Object.fromEntries( + keys.map((key) => [key, { title: key, description: `${key} description` }]), + ), + }); + + it('returns an empty array when the server declares no customUserVars', () => { + expect(getMissingCustomUserVars({}, {})).toEqual([]); + expect(getMissingCustomUserVars({ customUserVars: undefined }, undefined)).toEqual([]); + }); + + it('returns an empty array when customUserVars is an empty object', () => { + const config: Pick = { customUserVars: {} }; + expect(hasCustomUserVars(config)).toBe(false); + expect(getMissingCustomUserVars(config, undefined)).toEqual([]); + }); + + it('reports every declared variable when no values are provided', () => { + const config = configWithVars(['THINGY_TOKEN', 'THINGY_REGION']); + expect(getMissingCustomUserVars(config, undefined)).toEqual(['THINGY_TOKEN', 'THINGY_REGION']); + expect(getMissingCustomUserVars(config, null)).toEqual(['THINGY_TOKEN', 'THINGY_REGION']); + expect(getMissingCustomUserVars(config, {})).toEqual(['THINGY_TOKEN', 'THINGY_REGION']); + }); + + it('reports only the variables the user has not set', () => { + const config = configWithVars(['THINGY_TOKEN', 'THINGY_REGION']); + expect(getMissingCustomUserVars(config, { THINGY_TOKEN: 'abc123' })).toEqual(['THINGY_REGION']); + }); + + it('treats empty-string and whitespace-only values as missing', () => { + const config = configWithVars(['THINGY_TOKEN']); + expect(getMissingCustomUserVars(config, { THINGY_TOKEN: '' })).toEqual(['THINGY_TOKEN']); + expect(getMissingCustomUserVars(config, { THINGY_TOKEN: ' ' })).toEqual(['THINGY_TOKEN']); + expect(getMissingCustomUserVars(config, { THINGY_TOKEN: '\t\n ' })).toEqual(['THINGY_TOKEN']); + }); + + it('returns an empty array when every declared variable has a value', () => { + const config = configWithVars(['THINGY_TOKEN', 'THINGY_REGION']); + expect( + getMissingCustomUserVars(config, { THINGY_TOKEN: 'abc123', THINGY_REGION: 'eu-west-1' }), + ).toEqual([]); + }); + + it('ignores provided values for variables the server does not declare', () => { + const config = configWithVars(['THINGY_TOKEN']); + expect( + getMissingCustomUserVars(config, { THINGY_TOKEN: 'abc123', UNRELATED: 'value' }), + ).toEqual([]); + }); +}); diff --git a/packages/api/src/mcp/utils.ts b/packages/api/src/mcp/utils.ts index 872f43002cd..a92755e1a12 100644 --- a/packages/api/src/mcp/utils.ts +++ b/packages/api/src/mcp/utils.ts @@ -18,6 +18,29 @@ export function hasCustomUserVars(config: Pick 0; } +/** + * Returns the names of `customUserVars` declared on the server config for which + * the user has not supplied a non-blank value (unset, empty, or whitespace-only + * values count as missing, since they still fail auth). An empty array means + * every declared variable is satisfied (or the server declares none). + * + * Used to gate tool exposure: a server that requires user-provided credentials + * should not surface its tools to the model until those values are set, + * otherwise every tool call fails authentication. See issue #10969. + */ +export function getMissingCustomUserVars( + config: Pick, + providedVars?: Record | null, +): string[] { + if (!hasCustomUserVars(config)) { + return []; + } + return Object.keys(config.customUserVars ?? {}).filter((key) => { + const value = providedVars?.[key]; + return value == null || (typeof value === 'string' && value.trim() === ''); + }); +} + /** * Determines whether a server config is user-sourced (sandboxed placeholder resolution). * When `source` is set, it is authoritative. When absent (pre-upgrade cached configs), From 6d6ea08da4e0df2bdab803a6171c39e2bdac4034 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 23 May 2026 09:41:13 -0400 Subject: [PATCH 069/395] =?UTF-8?q?=F0=9F=86=94=20feat:=20Built-in=20Build?= =?UTF-8?q?=20Metadata=20for=20Support=20Triage=20(#12756)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🏗️ refactor: Derive App Version from Root package.json + Add buildInfo Schema The hardcoded `Constants.VERSION` in `data-provider` is now replaced at rollup build time via `@rollup/plugin-replace`, sourcing from the root `package.json` so version bumps are a single-file change. Adds the shape needed by the rest of the series: - `interface.buildInfo` boolean flag (default `true`) — lets self-hosters opt out of exposing commit/branch/date. - `buildInfo` on `TStartupConfig` — commit/commitShort/branch/buildDate. - `SettingsTabValues.ABOUT` — new settings tab enum value. Ref: https://github.com/danny-avila/LibreChat/issues/12406 * 🛠️ feat: Add Build Metadata Resolver and Expose via /api/config Adds `resolveBuildInfo()` in `@librechat/api` that surfaces commit SHA, branch, and build date from (in order) `BUILD_*` env vars, then local git metadata. Result is cached per-process. `/api/config` includes a `buildInfo` field on both authenticated and anonymous responses when `interface.buildInfo !== false` and at least one resolver field is populated. Omitted entirely otherwise. Designed so pre-built Docker images carry metadata via build-arg while source installs pick it up from `.git` — no manual version tracking. Ref: https://github.com/danny-avila/LibreChat/issues/12406 * ℹ️ feat: Add Settings → About Panel with Diagnostics Copy New Settings tab that renders the running build's version, commit (short SHA), branch, and build date in a monospaced block alongside a "Copy diagnostics" button that emits a preformatted text blob for pasting into support issues. Tab is hidden when `interface.buildInfo` is set to `false`. Reads from `startupConfig.buildInfo` provided by `/api/config`. Ref: https://github.com/danny-avila/LibreChat/issues/12406 * 🐳 ci: Inject BUILD_COMMIT/BRANCH/DATE into Docker Images Adds optional `BUILD_COMMIT`, `BUILD_BRANCH`, `BUILD_DATE` ARGs to both `Dockerfile` and `Dockerfile.multi`, wired as `ENV` vars in the runtime stage so the backend's `resolveBuildInfo` picks them up. All image-publishing workflows (`tag`, `main`, `dev`, `dev-branch`, `dev-staging`) now compute `${github.sha}`, `${github.ref_name}`, and a UTC timestamp, then pass them to `docker/build-push-action` as `build-args`. Defaults are empty — non-CI builds (local `docker build`) still work, and the backend falls back to local `.git` metadata if ARGs aren't set. Ref: https://github.com/danny-avila/LibreChat/issues/12406 * 📝 docs: Direct Bug Reporters to Settings → About for Version Info The previous instructions (`docker images | grep librechat`, `git rev-parse HEAD`) only worked for a subset of deployments and rarely produced a commit SHA for users pulling pre-built images. Point users to the new in-app Settings → About panel's "Copy diagnostics" button, which captures version, commit, branch, build date, and user agent in a single preformatted block. Fallback instructions preserved for older installs. Ref: https://github.com/danny-avila/LibreChat/issues/12406 * 🐳 fix: Move BUILD_* ENV to End of Docker Stages to Preserve Layer Cache Per-commit BUILD_COMMIT/BUILD_DATE changes were being promoted to ENV before `npm ci` / `npm run frontend` (single-stage) and before `npm ci --omit=dev` (multi-stage api-build), which invalidated the cache for every subsequent layer on every CI run. Move the ARG/ENV block below the heavy install and build steps in both Dockerfiles. Metadata is still available in the runtime image but no longer busts layer reuse. Addresses codex review on #12756. * 🔧 fix: Propagate interface.buildInfo=false to Unauthenticated /api/config The unauthenticated branch of `/api/config` was emitting an `interface` object only when `privacyPolicy` or `termsOfService` was set, which meant an admin's explicit `interface.buildInfo: false` opt-out was never visible to anonymous/guest clients. `Settings.tsx` gates the About tab on `startupConfig?.interface?.buildInfo !== false`, so a missing field fell through as "enabled" for those clients. Include `interface.buildInfo: false` in the unauth payload whenever it's explicitly disabled. Keep the implicit default (true) absent to preserve the minimal-unauth-payload convention. Addresses codex review on #12756. * 🔀 ci: Trigger Dev Image Workflows on Root package.json + Dockerfile Changes The baked `Constants.VERSION` now reads from the root `package.json` via rollup-plugin-replace, but the `dev-images.yml` and `dev-branch-images.yml` path filters only matched `api/**`, `client/**`, `packages/**`. A release commit that only bumps root `package.json` would not trigger a rebuild, leaving `latest` dev images with stale Footer/About version metadata. Include `package.json`, `package-lock.json`, and both Dockerfiles in the path filters so dependency changes (lockfile rebuilds) and image build tweaks also rebuild dev images. Addresses codex review on #12756. * 🧽 fix: Harden About Panel Lifecycle, A11y, and Loading Gate Review follow-ups on #12756: - #1 timer leak: stash the copy-state `setTimeout` in a ref and clear it from a `useEffect` cleanup so unmounting the Settings dialog mid-toast doesn't fire `setCopied(false)` on an unmounted component. - #3 flash of About tab: gate `aboutEnabled` on `startupConfig != null` so the tab stays hidden until `/api/config` returns. For admins who disabled `interface.buildInfo`, the tab no longer briefly appears and vanishes on page load. - #6 aria-live placement: move the live region off the interactive button onto a dedicated `` so screen readers announce the copied state, not the full button content on every re-render. - #2 missing coverage: add `About.spec.tsx` exercising populated/empty buildInfo rendering, invalid-date handling, diagnostics clipboard payload, copy-state toggling, unmount cleanup, and the live region. * ⚡ perf: Eagerly Resolve Build Info at Module Load Review follow-up #4 on #12756: `resolveBuildInfo()` calls `execFileSync` with a 2s timeout on source installs without `BUILD_*` env vars. Paying this cost on the first HTTP request blocks the event loop mid-flight. Call `resolveBuildInfo()` once at config route module load so the resolver's cache is warm before any request arrives. Docker images with the BUILD_* env vars set sidestep the git path entirely, so this only affects the edge case of source installs. * 📝 docs: Document rollup Version Placeholder Contract Review follow-ups #5 / #8 on #12756. The `__LIBRECHAT_VERSION__` placeholder relies on a substring replacement rule that only works because the token appears inside a string literal, and the substitution only runs during `npm run build:data-provider`. - Expand the `Constants.VERSION` JSDoc to spell out that consumers read the placeholder through the built dist bundle; source-level test imports would see the raw placeholder. - Add a NOTE above the rollup `replace` config warning future contributors not to repurpose the token as a bare identifier without switching to a quoted replacement value. Non-functional; prevents future contributors from stepping on a subtle constraint. * 🪪 fix: Only Toast "Copied" When Clipboard Copy Actually Succeeds Codex R5 on #12756. `copy-to-clipboard` returns a boolean indicating whether the underlying `execCommand('copy')` / fallback prompt actually wrote to the clipboard. The previous handler flipped to the "Copied" state unconditionally, which in hardened browsers or when the permission prompt is dismissed would mislead users into filing bug reports without the diagnostics blob attached. Gate the state/timer/live-region on the boolean return; silently no-op on failure rather than showing a false positive. Adds a test asserting the button label stays at "Copy diagnostics" when the clipboard call fails. * 🐳 fix: Derive main image metadata from checkout * 🪪 fix: Keep About enabled until disabled * ✅ test: Avoid literal Settings mock text * 🧱 refactor: Rename Build Info Module --- .github/ISSUE_TEMPLATE/BUG-REPORT.yml | 21 +-- .github/workflows/dev-branch-images.yml | 14 ++ .github/workflows/dev-images.yml | 14 ++ .github/workflows/dev-staging-images.yml | 10 ++ .github/workflows/main-image-workflow.yml | 10 ++ .github/workflows/tag-images.yml | 10 ++ Dockerfile | 12 ++ Dockerfile.multi | 14 ++ api/server/routes/__tests__/config.spec.js | 121 +++++++++++++ api/server/routes/config.js | 40 ++++- client/src/components/Nav/Settings.spec.tsx | 85 +++++++++ client/src/components/Nav/Settings.tsx | 27 ++- .../Nav/SettingsTabs/About/About.spec.tsx | 170 ++++++++++++++++++ .../Nav/SettingsTabs/About/About.tsx | 138 ++++++++++++++ .../src/components/Nav/SettingsTabs/index.ts | 1 + client/src/locales/en/translation.json | 10 ++ packages/api/src/app/build.spec.ts | 64 +++++++ packages/api/src/app/build.ts | 71 ++++++++ packages/api/src/app/index.ts | 2 + packages/data-provider/rollup.config.js | 12 +- packages/data-provider/src/config.ts | 24 ++- 21 files changed, 852 insertions(+), 18 deletions(-) create mode 100644 client/src/components/Nav/Settings.spec.tsx create mode 100644 client/src/components/Nav/SettingsTabs/About/About.spec.tsx create mode 100644 client/src/components/Nav/SettingsTabs/About/About.tsx create mode 100644 packages/api/src/app/build.spec.ts create mode 100644 packages/api/src/app/build.ts 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/workflows/dev-branch-images.yml b/.github/workflows/dev-branch-images.yml index 464f6ce55a6..f0e2ba54b57 100644 --- a/.github/workflows/dev-branch-images.yml +++ b/.github/workflows/dev-branch-images.yml @@ -9,6 +9,10 @@ on: - 'api/**' - 'client/**' - 'packages/**' + - 'package.json' + - 'package-lock.json' + - 'Dockerfile' + - 'Dockerfile.multi' permissions: contents: read @@ -65,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 @@ -79,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 a9fbef89293..efdd2027546 100644 --- a/.github/workflows/dev-images.yml +++ b/.github/workflows/dev-images.yml @@ -9,6 +9,10 @@ on: - 'api/**' - 'client/**' - 'packages/**' + - 'package.json' + - 'package-lock.json' + - 'Dockerfile' + - 'Dockerfile.multi' permissions: contents: read @@ -61,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 @@ -75,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 7bb06e5298d..6deb86205ca 100644 --- a/.github/workflows/dev-staging-images.yml +++ b/.github/workflows/dev-staging-images.yml @@ -53,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 @@ -67,3 +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/main-image-workflow.yml b/.github/workflows/main-image-workflow.yml index 348012de223..e5f76fe26ef 100644 --- a/.github/workflows/main-image-workflow.yml +++ b/.github/workflows/main-image-workflow.yml @@ -38,6 +38,12 @@ jobs: 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 uses: docker/setup-qemu-action@v3 @@ -80,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/tag-images.yml b/.github/workflows/tag-images.yml index 4477a89c130..a7d7ee7e9a1 100644 --- a/.github/workflows/tag-images.yml +++ b/.github/workflows/tag-images.yml @@ -74,6 +74,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 + - name: Resolve image tags id: image-tags env: @@ -104,3 +110,7 @@ jobs: 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/Dockerfile b/Dockerfile index 809f167413c..39efed4ca22 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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..30534a01828 100644 --- a/Dockerfile.multi +++ b/Dockerfile.multi @@ -4,6 +4,11 @@ # 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 ARG NPM_CI_TIMEOUT_SECONDS=1500 @@ -108,6 +113,15 @@ COPY --from=data-provider-build /app/packages/data-provider/dist ./packages/data 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/api/server/routes/__tests__/config.spec.js b/api/server/routes/__tests__/config.spec.js index 52a843116c6..d7fbd044461 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; @@ -446,4 +459,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/config.js b/api/server/routes/config.js index 33d1a8a3250..28bdd762f70 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -3,6 +3,7 @@ const { isEnabled, getBalanceConfig, getCloudFrontConfig, + resolveBuildInfo, sanitizeModelSpecs, } = require('@librechat/api'); const { defaultSocialLogins } = require('librechat-data-provider'); @@ -25,6 +26,13 @@ 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; @@ -105,6 +113,22 @@ function buildSharedPayload() { return payload; } +function buildBuildInfoPayload(interfaceConfig) { + if (interfaceConfig?.buildInfo === false) { + return undefined; + } + const info = resolveBuildInfo(); + if (!info.commit && !info.branch && !info.buildDate) { + return undefined; + } + return { + commit: info.commit, + commitShort: info.commitShort, + branch: info.branch, + buildDate: info.buildDate, + }; +} + function buildWebSearchConfig(appConfig) { const ws = appConfig?.webSearch; if (!ws) { @@ -159,7 +183,8 @@ router.get('/', async function (req, res) { }; 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; @@ -167,6 +192,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); @@ -205,6 +238,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/client/src/components/Nav/Settings.spec.tsx b/client/src/components/Nav/Settings.spec.tsx new file mode 100644 index 00000000000..114b99ef973 --- /dev/null +++ b/client/src/components/Nav/Settings.spec.tsx @@ -0,0 +1,85 @@ +import React from 'react'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import Settings from './Settings'; + +const mockUseGetStartupConfig = jest.fn(); + +jest.mock('~/data-provider', () => ({ + useGetStartupConfig: () => mockUseGetStartupConfig(), +})); + +jest.mock('~/hooks', () => ({ + useLocalize: () => (key: string) => key, +})); + +jest.mock('~/hooks/usePersonalizationAccess', () => ({ + __esModule: true, + default: () => ({ + hasMemoryOptOut: false, + hasAnyPersonalizationFeature: false, + }), +})); + +jest.mock('@librechat/client', () => ({ + GearIcon: () =>