From 8f13845da5eab4ab209074ba272ae07588b9e670 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Fri, 11 Sep 2026 16:47:48 -0400 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=93=BF=20fix:=20Inspect=20Stored=20Fi?= =?UTF-8?q?le=20Locators=20Per=20Message=20(#15841)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: inspect stored file locators per message * fix: declare traversal diagnostics for isolated declaration builds * refactor: inject locator traversal reporting from application callers * fix: propagate traversal diagnostics across protected entry points * fix: complete middleware reporters and hydration message counts --- api/app/clients/BaseClient.js | 2 + api/server/controllers/agents/client.js | 13 ++ api/server/controllers/agents/openai.js | 2 + api/server/controllers/agents/responses.js | 4 + api/server/controllers/agents/resume.js | 2 + api/server/controllers/agents/v1.js | 2 + api/server/controllers/assistants/chatV1.js | 4 + api/server/controllers/assistants/chatV2.js | 4 + api/server/routes/agents/chat.js | 2 + api/server/routes/agents/index.js | 4 + api/server/routes/agents/openai.js | 6 +- api/server/routes/agents/tools.js | 7 +- api/server/routes/assistants/chatV1.js | 3 +- api/server/routes/assistants/chatV2.js | 3 +- api/server/routes/assistants/v1.js | 7 +- api/server/routes/assistants/v2.js | 7 +- api/server/routes/convos.js | 3 + api/server/routes/messages.js | 6 +- api/server/routes/presets.js | 2 + api/server/routes/share.js | 6 + api/server/services/ToolService.js | 9 +- api/server/utils/import/importBatchBuilder.js | 2 + .../utils/import/importBatchBuilder.spec.js | 30 ++- packages/api/src/agents/hitl/inspection.ts | 4 + .../api/src/agents/hitl/protection.spec.ts | 34 +++ packages/api/src/agents/hitl/protection.ts | 8 +- packages/api/src/agents/openai/service.ts | 3 + packages/api/src/app/metrics.spec.ts | 32 +++ packages/api/src/app/metrics.ts | 37 ++++ .../api/src/assistants/protection.spec.ts | 24 +++ packages/api/src/assistants/protection.ts | 11 + packages/api/src/imports.spec.ts | 29 +++ packages/api/src/imports.ts | 6 + packages/api/src/middleware/contentFilter.ts | 5 + .../api/src/middleware/messageFilterPii.ts | 4 + .../src/middleware/modelBoundContent.spec.ts | 51 +++++ .../api/src/middleware/modelBoundContent.ts | 28 ++- .../api/src/protection/adapters/nested.ts | 29 ++- packages/api/src/protection/diagnostics.ts | 8 + packages/api/src/protection/files.spec.ts | 96 +++++++++ packages/api/src/protection/files.ts | 201 ++++++++++-------- .../src/protection/messageMutations.spec.ts | 30 +++ .../api/src/protection/messageMutations.ts | 9 +- packages/api/src/shared-links/protection.ts | 3 + 44 files changed, 670 insertions(+), 112 deletions(-) create mode 100644 packages/api/src/protection/diagnostics.ts diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 05d1b7e714f..950efff0026 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -16,6 +16,7 @@ const { getLangfuseTraceMessageFields, isContentFilterError, assertModelBoundProviderContent, + reportLocatorTraversalFailure, collectModelBoundHistoricalFileIdState, projectModelBoundSourceFiles, isModelBoundAttachmentFile, @@ -302,6 +303,7 @@ class BaseClient { : [{ role: 'user', content: payload, isCreatedByUser: true, isUserSubmitted: true }]; const fileProjection = this.getModelBoundFileProjection(); assertModelBoundProviderContent({ + onTraversalFailure: reportLocatorTraversalFailure, filters: this.options.req?.config?.filters, legacyPii: this.options.req?.config?.messageFilter?.pii, providerMessages: messages, diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 10f30569d13..58fc1824d77 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -145,6 +145,7 @@ const { decrementPendingRequest, maybePrewarmCodeSandbox, assertModelBoundContent, + reportLocatorTraversalFailure, filterFilesByEndpointRuntimeConfig, createModelBoundChatModelCallback: createModelBoundContentCallback, createInitialModelBoundAdmissionCallback, @@ -516,6 +517,7 @@ class AgentClient extends BaseClient { admitSteerAttachments(files, steerId) { const modelBoundFiles = files.filter(isModelBoundAttachmentFile); assertModelBoundContent({ + onTraversalFailure: reportLocatorTraversalFailure, filters: this.options.req?.config?.filters, files: modelBoundFiles, }); @@ -2047,6 +2049,7 @@ class AgentClient extends BaseClient { return; } assertModelBoundContent({ + onTraversalFailure: reportLocatorTraversalFailure, legacyPii, storedMessages: this.modelBoundStoredMessages, }); @@ -2063,6 +2066,7 @@ class AgentClient extends BaseClient { const persistence = BaseClient.prototype.getModelBoundUserMessagePersistence.call(this); return createModelBoundContentCallback( { + onTraversalFailure: reportLocatorTraversalFailure, filters: this.options.req?.config?.filters, legacyPii: this.options.req?.config?.messageFilter?.pii, storedMessages: this.modelBoundStoredMessages, @@ -2393,6 +2397,7 @@ class AgentClient extends BaseClient { ]); void earlySharedContextPromise.catch(() => {}); assertModelBoundContent({ + onTraversalFailure: reportLocatorTraversalFailure, filters: this.options.req.config?.filters, legacyPii: this.options.req.config?.messageFilter?.pii, agents: allAgents.map(({ agent }) => agent), @@ -2473,6 +2478,7 @@ class AgentClient extends BaseClient { this.modelBoundCurrentFiles = [...modelBoundRequestAttachments]; assertModelBoundContent({ + onTraversalFailure: reportLocatorTraversalFailure, filters: this.options.req.config?.filters, files: modelBoundRequestAttachments, }); @@ -2720,6 +2726,7 @@ class AgentClient extends BaseClient { * user payload so strict file policy cannot be skipped by a late media * adapter. */ assertModelBoundContent({ + onTraversalFailure: reportLocatorTraversalFailure, filters: this.options.req.config?.filters, legacyPii: this.options.req.config?.messageFilter?.pii, submittedMessages: [{ role: 'user', content: latestFormatted.content }], @@ -3013,6 +3020,7 @@ class AgentClient extends BaseClient { }); if (assertLateBoundContent) { assertModelBoundContent({ + onTraversalFailure: reportLocatorTraversalFailure, filters: this.options.req.config?.filters, legacyPii: this.options.req.config?.messageFilter?.pii, agents: [agent], @@ -3041,6 +3049,7 @@ class AgentClient extends BaseClient { this.modelBoundMemoryContexts = [...modelBoundMemoryContexts]; this.modelBoundFileContexts = [...modelBoundFileContexts]; assertModelBoundContent({ + onTraversalFailure: reportLocatorTraversalFailure, filters: this.options.req.config?.filters, legacyPii: this.options.req.config?.messageFilter?.pii, agents: allAgents.map(({ agent }) => agent), @@ -4676,6 +4685,7 @@ class AgentClient extends BaseClient { } assertModelBoundContent({ + onTraversalFailure: reportLocatorTraversalFailure, filters: appConfig?.filters, legacyPii: appConfig?.messageFilter?.pii, agents: reachableAgents, @@ -5346,6 +5356,7 @@ class AgentClient extends BaseClient { }, { getAgentCheckpointer, + onTraversalFailure: reportLocatorTraversalFailure, getMessages: db.getMessages, getFiles: db.getFiles, }, @@ -5433,6 +5444,7 @@ class AgentClient extends BaseClient { agent === this.options.agent ? this.options.req.body.ephemeralAgent : undefined, }); assertModelBoundContent({ + onTraversalFailure: reportLocatorTraversalFailure, filters: this.options.req.config?.filters, legacyPii: this.options.req.config?.messageFilter?.pii, agents: [agent], @@ -5531,6 +5543,7 @@ class AgentClient extends BaseClient { sharedRunContext: scopedContext ?? '', }); assertModelBoundContent({ + onTraversalFailure: reportLocatorTraversalFailure, filters: this.options.req.config?.filters, legacyPii: this.options.req.config?.messageFilter?.pii, agents: [agent], diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index 78f930753f5..1ff29ca8744 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -50,6 +50,7 @@ const { isContentTraversalProtected, isContentTraversalLimitError, assertModelBoundContent, + reportLocatorTraversalFailure, hasModelBoundContentProtection, isContentFilterError, getSafeErrorMetadata, @@ -750,6 +751,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => { const manualSkillPrimes = primaryConfig.manualSkillPrimes; const alwaysApplySkillPrimes = primaryConfig.alwaysApplySkillPrimes; assertModelBoundContent({ + onTraversalFailure: reportLocatorTraversalFailure, filters: appConfig?.filters, legacyPii: appConfig?.messageFilter?.pii, submittedMessages: request.messages, diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index aa851fbb510..bccf4d3ddec 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -50,6 +50,7 @@ const { isContentTraversalLimitError, prependContentTraversalFragments, assertModelBoundContent, + reportLocatorTraversalFailure, hasModelBoundContentProtection, isContentFilterError, getSafeErrorMetadata, @@ -710,6 +711,7 @@ const executeResponse = async (envelope, { req, res }) => { : []; if (request.previous_response_id) { assertModelBoundContent({ + onTraversalFailure: reportLocatorTraversalFailure, filters: appConfig?.filters, legacyPii: appConfig?.messageFilter?.pii, storedMessages: previousMessages, @@ -1000,6 +1002,7 @@ const executeResponse = async (envelope, { req, res }) => { const modelBoundAgents = [...modelBoundAgentsById.values()]; const mergedMCPAuthMap = discoveredMCPAuthMap ?? primaryConfig.userMCPAuthMap; assertModelBoundContent({ + onTraversalFailure: reportLocatorTraversalFailure, filters: appConfig?.filters, legacyPii: appConfig?.messageFilter?.pii, agents: modelBoundAgents, @@ -1094,6 +1097,7 @@ const executeResponse = async (envelope, { req, res }) => { } assertModelBoundContent({ + onTraversalFailure: reportLocatorTraversalFailure, filters: appConfig?.filters, legacyPii: appConfig?.messageFilter?.pii, submittedMessages: inputMessages, diff --git a/api/server/controllers/agents/resume.js b/api/server/controllers/agents/resume.js index 3b55b714abb..793e89568b8 100644 --- a/api/server/controllers/agents/resume.js +++ b/api/server/controllers/agents/resume.js @@ -31,6 +31,7 @@ const { getAgentCheckpointer, isContentFilterError, preflightResumeContent, + reportLocatorTraversalFailure, getResumeProvenance, getUserFacingResumeError, decrementPendingRequest, @@ -197,6 +198,7 @@ async function deleteFailedResumeCheckpoint(args, context) { const GENERIC_RESUME_ERROR = 'Resume failed'; const resumeContentProtectionDependencies = { + onTraversalFailure: reportLocatorTraversalFailure, getAgentCheckpointer, checkAccess, getMessages, diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index 2d55ff44cda..f728a5bd4d2 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -37,6 +37,7 @@ const { isContentTraversalProtected, isContentTraversalLimitError, resolveCanonicalFileReferences, + reportLocatorTraversalFailure, } = require('@librechat/api'); const { Time, @@ -176,6 +177,7 @@ const blockFilteredAgentContent = async (req, res, agentData) => { if (filePolicyActive) { try { const fileInspection = await resolveCanonicalFileReferences({ + onTraversalFailure: reportLocatorTraversalFailure, filters, input: agentData, user: req.user, diff --git a/api/server/controllers/assistants/chatV1.js b/api/server/controllers/assistants/chatV1.js index cac475dee6b..5b33daa7950 100644 --- a/api/server/controllers/assistants/chatV1.js +++ b/api/server/controllers/assistants/chatV1.js @@ -12,6 +12,7 @@ const { isContentFilterError, hasActiveFilePolicy, preflightAssistantRunContent, + reportLocatorTraversalFailure, preflightAssistantUserMessageContent, } = require('@librechat/api'); const { @@ -335,6 +336,7 @@ const chatV1 = async (req, res) => { let persistedAssistant; try { persistedAssistant = await preflightAssistantRunContent({ + onTraversalFailure: reportLocatorTraversalFailure, config: req.config, openai, user: req.user, @@ -546,6 +548,7 @@ const chatV1 = async (req, res) => { await getRequestFileIds(); try { await preflightAssistantUserMessageContent({ + onTraversalFailure: reportLocatorTraversalFailure, config: req.config, user: req.user, message: userMessage, @@ -651,6 +654,7 @@ const chatV1 = async (req, res) => { try { await preflightAssistantRunContent({ + onTraversalFailure: reportLocatorTraversalFailure, config: req.config, openai, user: req.user, diff --git a/api/server/controllers/assistants/chatV2.js b/api/server/controllers/assistants/chatV2.js index ac451fa1732..b79053ba2ad 100644 --- a/api/server/controllers/assistants/chatV2.js +++ b/api/server/controllers/assistants/chatV2.js @@ -12,6 +12,7 @@ const { isContentFilterError, hasActiveFilePolicy, preflightAssistantRunContent, + reportLocatorTraversalFailure, preflightAssistantUserMessageContent, } = require('@librechat/api'); const { @@ -206,6 +207,7 @@ const chatV2 = async (req, res) => { await validateAuthor({ req, openai }); try { await preflightAssistantRunContent({ + onTraversalFailure: reportLocatorTraversalFailure, config: req.config, openai, user: req.user, @@ -377,6 +379,7 @@ const chatV2 = async (req, res) => { await getRequestFileIds(); try { await preflightAssistantUserMessageContent({ + onTraversalFailure: reportLocatorTraversalFailure, config: req.config, user: req.user, message: userMessage, @@ -489,6 +492,7 @@ const chatV2 = async (req, res) => { try { await preflightAssistantRunContent({ + onTraversalFailure: reportLocatorTraversalFailure, config: req.config, openai, user: req.user, diff --git a/api/server/routes/agents/chat.js b/api/server/routes/agents/chat.js index b9a16e560da..16ecf54fd04 100644 --- a/api/server/routes/agents/chat.js +++ b/api/server/routes/agents/chat.js @@ -2,6 +2,7 @@ const express = require('express'); const { logger } = require('@librechat/data-schemas'); const { createMessageFilterPii, + reportLocatorTraversalFailure, generateCheckAccess, skipAgentCheck, applyResumeContext, @@ -72,6 +73,7 @@ const restoreResumeContext = async (req, res, next) => { router.use(restoreResumeContext); router.use( createMessageFilterPii({ + onTraversalFailure: reportLocatorTraversalFailure, getConfig: (req) => req.config?.messageFilter?.pii, getFilters: (req) => req.config?.filters, getFiles, diff --git a/api/server/routes/agents/index.js b/api/server/routes/agents/index.js index 54405414737..afb425e7d80 100644 --- a/api/server/routes/agents/index.js +++ b/api/server/routes/agents/index.js @@ -1,5 +1,6 @@ const express = require('express'); const { + reportLocatorTraversalFailure, isEnabled, GenerationJobManager, TERMINAL_PUBLICATION_RECONNECT_ERROR, @@ -1054,6 +1055,7 @@ router.post( configMiddleware, ...steerLimiters, createMessageFilterPii({ + onTraversalFailure: reportLocatorTraversalFailure, getConfig: (req) => req.config?.messageFilter?.pii, getFilters: (req) => req.config?.filters, getFiles, @@ -1074,6 +1076,7 @@ router.post( configMiddleware, ...steerLimiters, createMessageFilterPii({ + onTraversalFailure: reportLocatorTraversalFailure, getConfig: (req) => req.config?.messageFilter?.pii, getFilters: (req) => req.config?.filters, getFiles, @@ -1113,6 +1116,7 @@ router.post( configMiddleware, ...steerLimiters, createMessageFilterPii({ + onTraversalFailure: reportLocatorTraversalFailure, getConfig: (req) => req.config?.messageFilter?.pii, getFilters: (req) => req.config?.filters, getFiles, diff --git a/api/server/routes/agents/openai.js b/api/server/routes/agents/openai.js index cf2cbb6e9ae..bb5bb83478b 100644 --- a/api/server/routes/agents/openai.js +++ b/api/server/routes/agents/openai.js @@ -20,6 +20,7 @@ */ const express = require('express'); const { + reportLocatorTraversalFailure, createAgentEventBindingHandlers, createAgentTriggerIngressHandlers, createMessageFilterPii, @@ -82,7 +83,10 @@ router.post( router.post( '/events', agentEventUserLimiter, - createMessageFilterPii({ getConfig: (req) => req.config?.messageFilter?.pii }), + createMessageFilterPii({ + onTraversalFailure: reportLocatorTraversalFailure, + getConfig: (req) => req.config?.messageFilter?.pii, + }), eventBindingHandlers.resolve, checkAgentTriggerPermission, eventHandlers.enqueueEvent, diff --git a/api/server/routes/agents/tools.js b/api/server/routes/agents/tools.js index f1d2c7c2703..843e3a56f26 100644 --- a/api/server/routes/agents/tools.js +++ b/api/server/routes/agents/tools.js @@ -1,11 +1,16 @@ const express = require('express'); -const { createContentFilter, extractToolArgumentContent } = require('@librechat/api'); +const { + reportLocatorTraversalFailure, + createContentFilter, + extractToolArgumentContent, +} = require('@librechat/api'); const { callTool, verifyToolAuth, getToolCalls } = require('~/server/controllers/tools'); const { getAvailableTools } = require('~/server/controllers/PluginController'); const { toolCallLimiter } = require('~/server/middleware'); const router = express.Router(); const filterToolArguments = createContentFilter({ + onTraversalFailure: reportLocatorTraversalFailure, getFilters: (req) => req.config?.filters, extract: (req) => { const { diff --git a/api/server/routes/assistants/chatV1.js b/api/server/routes/assistants/chatV1.js index 245b61aee8a..46820cde569 100644 --- a/api/server/routes/assistants/chatV1.js +++ b/api/server/routes/assistants/chatV1.js @@ -1,5 +1,5 @@ const express = require('express'); -const { createMessageFilterPii } = require('@librechat/api'); +const { createMessageFilterPii, reportLocatorTraversalFailure } = require('@librechat/api'); const router = express.Router(); const { handleAbort, validateModel, buildEndpointOption } = require('~/server/middleware'); @@ -12,6 +12,7 @@ const { getFiles } = require('~/models'); router.post('/abort', handleAbort()); const filterMessageContent = createMessageFilterPii({ + onTraversalFailure: reportLocatorTraversalFailure, getConfig: (req) => req.config?.messageFilter?.pii, getFilters: (req) => req.config?.filters, getFiles, diff --git a/api/server/routes/assistants/chatV2.js b/api/server/routes/assistants/chatV2.js index 2f28dac0c82..6e105539a99 100644 --- a/api/server/routes/assistants/chatV2.js +++ b/api/server/routes/assistants/chatV2.js @@ -1,5 +1,5 @@ const express = require('express'); -const { createMessageFilterPii } = require('@librechat/api'); +const { createMessageFilterPii, reportLocatorTraversalFailure } = require('@librechat/api'); const router = express.Router(); const { handleAbort, validateModel, buildEndpointOption } = require('~/server/middleware'); @@ -12,6 +12,7 @@ const { getFiles } = require('~/models'); router.post('/abort', handleAbort()); const filterMessageContent = createMessageFilterPii({ + onTraversalFailure: reportLocatorTraversalFailure, getConfig: (req) => req.config?.messageFilter?.pii, getFilters: (req) => req.config?.filters, getFiles, diff --git a/api/server/routes/assistants/v1.js b/api/server/routes/assistants/v1.js index 63d79a44f2f..d82a2f24314 100644 --- a/api/server/routes/assistants/v1.js +++ b/api/server/routes/assistants/v1.js @@ -1,5 +1,9 @@ const express = require('express'); -const { createContentFilter, extractAssistantContent } = require('@librechat/api'); +const { + reportLocatorTraversalFailure, + createContentFilter, + extractAssistantContent, +} = require('@librechat/api'); const controllers = require('~/server/controllers/assistants/v1'); const { getFiles } = require('~/models'); const documents = require('./documents'); @@ -9,6 +13,7 @@ const tools = require('./tools'); const router = express.Router(); const avatar = express.Router(); const filterAssistantContent = createContentFilter({ + onTraversalFailure: reportLocatorTraversalFailure, getFilters: (req) => req.config?.filters, extract: (req) => extractAssistantContent(req.body), getOpaqueFileInput: (req) => req.body, diff --git a/api/server/routes/assistants/v2.js b/api/server/routes/assistants/v2.js index 748b123e0a5..4af1dea42af 100644 --- a/api/server/routes/assistants/v2.js +++ b/api/server/routes/assistants/v2.js @@ -1,5 +1,9 @@ const express = require('express'); -const { createContentFilter, extractAssistantContent } = require('@librechat/api'); +const { + reportLocatorTraversalFailure, + createContentFilter, + extractAssistantContent, +} = require('@librechat/api'); const { configMiddleware } = require('~/server/middleware'); const v1 = require('~/server/controllers/assistants/v1'); const v2 = require('~/server/controllers/assistants/v2'); @@ -11,6 +15,7 @@ const tools = require('./tools'); const router = express.Router(); router.use(configMiddleware); const filterAssistantContent = createContentFilter({ + onTraversalFailure: reportLocatorTraversalFailure, getFilters: (req) => req.config?.filters, extract: (req) => extractAssistantContent(req.body), getOpaqueFileInput: (req) => req.body, diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index bdc00fb5f86..04c9ec5d992 100644 --- a/api/server/routes/convos.js +++ b/api/server/routes/convos.js @@ -2,6 +2,7 @@ const multer = require('multer'); const express = require('express'); const { sleep } = require('@librechat/agents'); const { + reportLocatorTraversalFailure, isEnabled, normalizeLimit, openCheckpointDeletion, @@ -64,10 +65,12 @@ const parentSubagentIndexHandler = createParentSubagentIndexHandler({ listSubagentTasksForThreads: db.listSubagentTasksForThreads, }); const filterConversationTitle = createContentFilter({ + onTraversalFailure: reportLocatorTraversalFailure, getFilters: (req) => req.config?.filters, extract: (req) => extractConversationTitleContent(req.body), }); const filterSubagentControlMessage = createContentFilter({ + onTraversalFailure: reportLocatorTraversalFailure, getFilters: (req) => req.config?.filters, getLegacyPii: (req) => req.config?.messageFilter?.pii, extract: (req) => diff --git a/api/server/routes/messages.js b/api/server/routes/messages.js index 24704ba7695..756f8ff8cb1 100644 --- a/api/server/routes/messages.js +++ b/api/server/routes/messages.js @@ -22,6 +22,7 @@ const { assertStoredMessageMutationAllowed, assertChatMutationAllowed, assertStoredMessageBranchAllowed, + reportLocatorTraversalFailure, mergeUserSubmittedPaths, mergeUserSubmittedMessageFieldPaths, isContentFilterError, @@ -40,6 +41,8 @@ const db = require('~/models'); const router = express.Router(); const filterStoredMessageContent = createContentFilter({ + messageCount: 1, + onTraversalFailure: reportLocatorTraversalFailure, getFilters: (req) => req.config?.filters, getMessageRoles: (req) => [req.body?.role], getOpaqueFileInput: (req) => req.body, @@ -47,6 +50,7 @@ const filterStoredMessageContent = createContentFilter({ extract: (req) => extractStoredMessageContent(req.body), }); const filterFeedbackContent = createContentFilter({ + onTraversalFailure: reportLocatorTraversalFailure, getFilters: (req) => req.config?.filters, extract: (req) => extractFeedbackContent(req.body), }); @@ -351,7 +355,7 @@ router.post('/branch', configMiddleware, async (req, res) => { message: newMessage, user: req.user, }, - { getFiles: db.getFiles }, + { getFiles: db.getFiles, onTraversalFailure: reportLocatorTraversalFailure }, ); const savedMessage = await db.saveMessage( diff --git a/api/server/routes/presets.js b/api/server/routes/presets.js index 7e3ee1b92e2..35b5e697ab6 100644 --- a/api/server/routes/presets.js +++ b/api/server/routes/presets.js @@ -2,6 +2,7 @@ const crypto = require('crypto'); const express = require('express'); const { logger } = require('@librechat/data-schemas'); const { + reportLocatorTraversalFailure, createContentFilter, extractPresetContent, projectStoredPresets, @@ -11,6 +12,7 @@ const { requireJwtAuth, configMiddleware } = require('~/server/middleware'); const router = express.Router(); const filterPresetContent = createContentFilter({ + onTraversalFailure: reportLocatorTraversalFailure, getFilters: (req) => req.config?.filters, extract: (req) => extractPresetContent(req.body), }); diff --git a/api/server/routes/share.js b/api/server/routes/share.js index 2aad568c247..3cd5761cb19 100644 --- a/api/server/routes/share.js +++ b/api/server/routes/share.js @@ -3,6 +3,7 @@ const express = require('express'); const { assertModelBoundContent, createShareContentPreflight, + reportLocatorTraversalFailure, isEnabled, isContentFilterError, isConversationImportError, @@ -140,6 +141,7 @@ const PREVIEW_LAZY_SWEEP_CUTOFF_MS = 2 * 60 * 1000; const enforceSharedFileContentPolicy = (req, res, next) => { try { assertModelBoundContent({ + onTraversalFailure: reportLocatorTraversalFailure, filters: req.config?.filters, files: [req.liveFile], }); @@ -367,6 +369,7 @@ if (allowSharedLinks) { async (req, res) => { try { const contentPreflight = createShareContentPreflight(req.config?.filters, { + onTraversalFailure: reportLocatorTraversalFailure, sharedFileMetadata: true, legacyPii: req.config?.messageFilter?.pii, }); @@ -429,6 +432,7 @@ if (allowSharedLinks) { // the GET share route so disabled file snapshots aren't copied into forks. snapshotFiles: !isFileSnapshotKillSwitchActive(), sharedContentPreflight: createShareContentPreflight(req.config?.filters, { + onTraversalFailure: reportLocatorTraversalFailure, sharedFileMetadata: true, legacyPii: req.config?.messageFilter?.pii, }), @@ -653,6 +657,7 @@ router.post( // did not uncheck "share files" (body flag absent defaults to enabled). const snapshotFiles = isFileSnapshotEnabled(req.config) && requestedSnapshotFiles !== false; const contentPreflight = createShareContentPreflight(req.config?.filters, { + onTraversalFailure: reportLocatorTraversalFailure, snapshotFiles, user: req.user, getFiles, @@ -723,6 +728,7 @@ router.patch( const snapshotFiles = isFileSnapshotEnabled(req.config) && requestedSnapshotFiles !== false; const contentPreflight = createShareContentPreflight(req.config?.filters, { + onTraversalFailure: reportLocatorTraversalFailure, snapshotFiles, user: req.user, getFiles, diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 88dae404c56..bc7693cf34a 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -32,6 +32,7 @@ const { inspectContentWithTraversal, ContentFilterError, assertModelBoundContent, + reportLocatorTraversalFailure, extractToolArgumentContent, contentFilterModelBoundBlockResponse, getSafeErrorMetadata, @@ -183,6 +184,7 @@ const assertToolResourcesAllowed = ({ req, toolResources, tools }) => { Array.isArray(resource?.files) ? resource.files : [], ); assertModelBoundContent({ + onTraversalFailure: reportLocatorTraversalFailure, filters, agents: [{ tool_resources: activeResources }], files, @@ -200,6 +202,7 @@ const withoutEncryptedActionSecrets = (action) => { const prepareStoredActionsForUse = async ({ actions, filters, decrypt }) => { if (filters != null) { assertModelBoundContent({ + onTraversalFailure: reportLocatorTraversalFailure, filters, actions: actions.map(withoutEncryptedActionSecrets), }); @@ -220,7 +223,11 @@ const prepareStoredActionsForUse = async ({ actions, filters, decrypt }) => { })), ); if (filters != null) { - assertModelBoundContent({ filters, actions: decryptedActions }); + assertModelBoundContent({ + onTraversalFailure: reportLocatorTraversalFailure, + filters, + actions: decryptedActions, + }); } return decryptedActions; }; diff --git a/api/server/utils/import/importBatchBuilder.js b/api/server/utils/import/importBatchBuilder.js index ac8da31cfa5..afbd5d3281e 100644 --- a/api/server/utils/import/importBatchBuilder.js +++ b/api/server/utils/import/importBatchBuilder.js @@ -3,6 +3,7 @@ const { assertConversationImportWriteSize, assertModelBoundContent, assertConversationImportContentAllowed, + reportLocatorTraversalFailure, executeConversationImportWrites, } = require('@librechat/api'); const { @@ -56,6 +57,7 @@ function createImportBatchBuilder(requestUserId, interfaceConfig, filters, legac async function assertConversationContentAllowed(filters, snapshot, resolutionContext = {}) { return assertConversationImportContentAllowed(filters, snapshot, { ...resolutionContext, + onTraversalFailure: reportLocatorTraversalFailure, assertModelBoundContent, }); } diff --git a/api/server/utils/import/importBatchBuilder.spec.js b/api/server/utils/import/importBatchBuilder.spec.js index f14ad5fd498..f7a28f54d03 100644 --- a/api/server/utils/import/importBatchBuilder.spec.js +++ b/api/server/utils/import/importBatchBuilder.spec.js @@ -437,8 +437,21 @@ describe('ImportBatchBuilder content filtering', () => { const messageCalls = mockAssertModelBoundContent.mock.calls.filter( ([input]) => input.storedMessages != null, ); - expect(fileCalls).toEqual([[{ filters, resolvedFiles: [canonicalFile] }]]); + expect(fileCalls).toEqual([ + [ + { + filters, + resolvedFiles: [canonicalFile], + onTraversalFailure: actualApi.reportLocatorTraversalFailure, + }, + ], + ]); expect(messageCalls).toHaveLength(2); + expect( + messageCalls.every( + ([input]) => input.onTraversalFailure === actualApi.reportLocatorTraversalFailure, + ), + ).toBe(true); expect(messageCalls.every(([input]) => input.storedMessages.length === 1)).toBe(true); expect(messageCalls.every(([input]) => input.resolvedFiles == null)).toBe(true); }); @@ -742,8 +755,21 @@ describe('ImportBatchBuilder content filtering', () => { const messageCalls = mockAssertModelBoundContent.mock.calls.filter( ([input]) => input.storedMessages != null, ); - expect(fileCalls).toEqual([[{ filters, resolvedFiles: [canonicalFile] }]]); + expect(fileCalls).toEqual([ + [ + { + filters, + resolvedFiles: [canonicalFile], + onTraversalFailure: actualApi.reportLocatorTraversalFailure, + }, + ], + ]); expect(messageCalls).toHaveLength(2); + expect( + messageCalls.every( + ([input]) => input.onTraversalFailure === actualApi.reportLocatorTraversalFailure, + ), + ).toBe(true); expect(messageCalls.every(([input]) => input.storedMessages.length === 1)).toBe(true); expect(messageCalls.every(([input]) => input.resolvedFiles == null)).toBe(true); expect(bulkSaveMessages).not.toHaveBeenCalled(); diff --git a/packages/api/src/agents/hitl/inspection.ts b/packages/api/src/agents/hitl/inspection.ts index 0b87e41c89f..0a0cadaaa0a 100644 --- a/packages/api/src/agents/hitl/inspection.ts +++ b/packages/api/src/agents/hitl/inspection.ts @@ -9,6 +9,7 @@ import { import type { Agent, FiltersConfig, UserSubmittedMessageFieldPath } from 'librechat-data-provider'; import type { AppConfig, IUser } from '@librechat/data-schemas'; import type { StoredMessageContentInput } from '~/protection/adapters/submissions'; +import type { LocatorTraversalReporter } from '../../protection/diagnostics'; import type { ExternalChatMessage } from '~/protection/adapters/messages'; import { hasActiveFilePolicy, @@ -69,6 +70,7 @@ type GetResumeMessages = (filter: { }) => Promise; export interface ResumeContentInspectionInput { + readonly onTraversalFailure?: LocatorTraversalReporter; appConfig?: AppConfig; conversationId: string; targetMessageId?: string | null; @@ -374,6 +376,8 @@ async function getResumeFileInspection( fileReferenceInputs: input.fileReferenceInputs ?? [], }; const fileInspection = await resolveCanonicalFileReferences({ + messageCount: storedMessages.length + input.submittedMessages.length, + onTraversalFailure: input.onTraversalFailure, filters, input: originalInput, user: input.user, diff --git a/packages/api/src/agents/hitl/protection.spec.ts b/packages/api/src/agents/hitl/protection.spec.ts index 7b814906104..6c7c1949154 100644 --- a/packages/api/src/agents/hitl/protection.spec.ts +++ b/packages/api/src/agents/hitl/protection.spec.ts @@ -41,6 +41,40 @@ function createInput(appConfig: unknown): AssertResumeRuntimeContentAllowedInput } describe('assertResumeRuntimeContentAllowed', () => { + it('reports locator failures from restored HITL content through its dependencies', async () => { + const onTraversalFailure = jest.fn(); + const dependencies = { ...createDependencies(), onTraversalFailure }; + dependencies.getFiles.mockResolvedValue([{ file_id: 'owned', filename: 'safe.txt' }]); + const input = createInput({ + filters: { files: { pii: { fields: ['name'], starterPatterns: ['sk_prefix'] } } }, + }); + await expect( + assertResumeRuntimeContentAllowed( + { + ...input, + storedMessages: [ + { + messageId: 'source-message', + isCreatedByUser: true, + role: 'user', + files: [{ file_id: 'owned' }], + content: Array.from({ length: 4200 }, () => ({ type: 'text', text: 'safe' })), + }, + ], + }, + dependencies, + ), + ).rejects.toMatchObject({ code: 'content_filter_uninspectable' }); + expect(onTraversalFailure).toHaveBeenCalledWith( + expect.objectContaining({ + operation: 'omit_resolved_file_locators', + reason: 'array_length', + messageCount: 1, + resolvedFileCount: 1, + }), + ); + }); + it.each([ { filters: { prompts: { pii: {} } } }, { diff --git a/packages/api/src/agents/hitl/protection.ts b/packages/api/src/agents/hitl/protection.ts index 7008e67375b..500e7b3126f 100644 --- a/packages/api/src/agents/hitl/protection.ts +++ b/packages/api/src/agents/hitl/protection.ts @@ -37,6 +37,7 @@ import type { ResumeContentInspectionInput, ResumeSnapshotAgent, } from './inspection'; +import type { LocatorTraversalReporter } from '../../protection/diagnostics'; import type { TextContentFragment } from '~/protection/types'; import type { CheckAccessParams } from '~/middleware/access'; import { @@ -167,6 +168,7 @@ const ENCRYPTED_ACTION_METADATA_FIELDS = [ ] as const; export interface ResumeContentProtectionDependencies { + readonly onTraversalFailure?: LocatorTraversalReporter; getAgentCheckpointer: ( config: TCheckpointerConfig | undefined, ) => Promise; @@ -219,7 +221,7 @@ export interface AssertResumeRuntimeContentAllowedInput export type ResumeRuntimeContentProtectionDependencies = Pick< ResumeContentProtectionDependencies, - 'getAgentCheckpointer' | 'getMessages' | 'getFiles' + 'getAgentCheckpointer' | 'getMessages' | 'getFiles' | 'onTraversalFailure' >; export interface ResumeRuntimeContentProjection { @@ -800,6 +802,7 @@ async function assertResumeAgentContentAllowed({ definitionAgents.push(memoryAgentDefinition.definition); } assertModelBoundContent({ + onTraversalFailure: dependencies.onTraversalFailure, filters: appConfig?.filters, legacyPii: appConfig?.messageFilter?.pii, agents: definitionAgents, @@ -855,6 +858,7 @@ async function assertResumeModelBoundContentAllowed( ): Promise { if (!hasResumeHistoryProtection(appConfig)) { assertModelBoundContent({ + onTraversalFailure: dependencies.onTraversalFailure, filters: appConfig?.filters, legacyPii: appConfig?.messageFilter?.pii, agents, @@ -883,6 +887,7 @@ async function assertResumeModelBoundContentAllowed( } const checkpointContent = getResumeCheckpointContent(checkpointMessages); const contentInspection = await getResumeContentInspection({ + onTraversalFailure: dependencies.onTraversalFailure, appConfig, conversationId, targetMessageId, @@ -897,6 +902,7 @@ async function assertResumeModelBoundContentAllowed( getFiles: dependencies.getFiles, }); assertModelBoundContent({ + onTraversalFailure: dependencies.onTraversalFailure, filters: appConfig?.filters, legacyPii: appConfig?.messageFilter?.pii, submittedMessages: contentInspection.submittedMessages, diff --git a/packages/api/src/agents/openai/service.ts b/packages/api/src/agents/openai/service.ts index 782494635c0..2214390bfc7 100644 --- a/packages/api/src/agents/openai/service.ts +++ b/packages/api/src/agents/openai/service.ts @@ -1,3 +1,4 @@ +import type { LocatorTraversalReporter } from '../../protection/diagnostics'; /** * OpenAI-compatible chat completions service for agents. * @@ -85,6 +86,7 @@ import { createSafeUser } from '~/utils'; * Dependencies for the chat completion service */ export interface ChatCompletionDependencies { + readonly onTraversalFailure?: LocatorTraversalReporter; /** Get agent by ID */ getAgent: (params: { id: string }) => Promise; /** Initialize agent for use */ @@ -776,6 +778,7 @@ export async function createAgentChatCompletion( ); } assertModelBoundContent({ + onTraversalFailure: deps.onTraversalFailure, filters, legacyPii, submittedMessages, diff --git a/packages/api/src/app/metrics.spec.ts b/packages/api/src/app/metrics.spec.ts index 3b4ab585d5b..25141f6c25d 100644 --- a/packages/api/src/app/metrics.spec.ts +++ b/packages/api/src/app/metrics.spec.ts @@ -1,3 +1,4 @@ +import { omitResolvedCanonicalFileLocators } from '../protection/files'; /// import express from 'express'; import request from 'supertest'; @@ -6,6 +7,7 @@ import { recordAgentEventActorReceiptMetric } from '@librechat/data-schemas'; import type { Request, Response } from 'express'; import { createMetrics, + reportLocatorTraversalFailure, instrumentMongooseQueryMetrics, normalizePath, recordAgentStartupMilestone, @@ -137,6 +139,36 @@ describe('createMetrics', () => { ); }); + it('records locator traversal reasons and count distributions without content labels', async () => { + process.env.METRICS_SECRET = 'test-secret'; + createMetrics(); + const app = express(); + app.use('/metrics', createMetrics().metricsRouter); + expect(() => + omitResolvedCanonicalFileLocators( + { file_id: 'PRIVATE-FILE', payload: new Array(4096) }, + new Map([['PRIVATE-FILE', { file_id: 'PRIVATE-FILE' }]]), + { messageCount: 58, onTraversalFailure: reportLocatorTraversalFailure }, + ), + ).toThrow(); + const response = await request(app).get('/metrics').set('Authorization', 'Bearer test-secret'); + expect(response.status).toBe(200); + expect(response.text).toContain( + 'content_filter_locator_traversal_failures_total{operation="omit_resolved_file_locators",reason="array_length"} 1', + ); + for (const [dimension, count] of [ + ['visitedNodes', 2], + ['depth', 1], + ['messageCount', 58], + ['resolvedFileCount', 1], + ]) { + expect(response.text).toContain( + `content_filter_locator_traversal_size_sum{operation="omit_resolved_file_locators",reason="array_length",dimension="${dimension}"} ${count}`, + ); + } + expect(response.text).not.toContain('PRIVATE-FILE'); + }); + it('exposes bounded event actor receipt settlement, replay, conflict, and migration metrics', async () => { const app = express(); process.env.METRICS_SECRET = 'test-secret'; diff --git a/packages/api/src/app/metrics.ts b/packages/api/src/app/metrics.ts index f44ae1ff59f..dc7bcc1da8b 100644 --- a/packages/api/src/app/metrics.ts +++ b/packages/api/src/app/metrics.ts @@ -5,6 +5,7 @@ import { logger, setAgentEventActorReceiptMetricObserver } from '@librechat/data import type { Request, Response, NextFunction, RequestHandler } from 'express'; import type { Mongoose } from 'mongoose'; import type { AgentStartupMilestone, AgentStartupResult } from '~/agents/phases'; +import type { LocatorTraversalFailure } from '../protection/diagnostics'; import { agentStartupMilestones, agentStartupResults } from '~/agents/phases'; const PATH_NORMALIZATIONS: [RegExp, string][] = [ @@ -281,7 +282,16 @@ let redisOperationMetrics: RedisOperationMetrics = { recordOperation: () => undefined, }; +let observeLocatorTraversal: (failure: LocatorTraversalFailure) => void = () => undefined; + +/** Application sink supplied explicitly to content inspection callers. */ +export function reportLocatorTraversalFailure(failure: LocatorTraversalFailure): void { + logger.warn(`[content-filter] Locator traversal incomplete ${JSON.stringify(failure)}`, failure); + observeLocatorTraversal(failure); +} + const resetMetricRecorders = (): void => { + observeLocatorTraversal = () => undefined; openIDUserLookupMetrics = { recordLookup: () => undefined, }; @@ -530,6 +540,33 @@ export function createMetrics(options: MetricsOptions = {}): PrometheusMetrics { const registry = new Registry(); collectDefaultMetrics({ register: registry }); + observeLocatorTraversal = () => undefined; + const locatorTraversalFailuresTotal = new Counter({ + name: 'content_filter_locator_traversal_failures_total', + help: 'Incomplete resolved file locator traversals', + labelNames: ['operation', 'reason'] as const, + registers: [registry], + }); + const locatorTraversalSize = new Histogram({ + name: 'content_filter_locator_traversal_size', + help: 'Structural counts at an incomplete resolved file locator traversal', + labelNames: ['operation', 'reason', 'dimension'] as const, + buckets: [0, 1, 8, 24, 64, 256, 1024, 4096, 16384], + registers: [registry], + }); + observeLocatorTraversal = (failure: LocatorTraversalFailure): void => { + const labels = { operation: failure.operation, reason: failure.reason }; + locatorTraversalFailuresTotal.inc(labels); + for (const dimension of [ + 'visitedNodes', + 'depth', + 'messageCount', + 'resolvedFileCount', + ] as const) { + locatorTraversalSize.observe({ ...labels, dimension }, failure[dimension]); + } + }; + const httpRequests = new Counter({ name: 'http_requests_total', help: 'Total HTTP requests', diff --git a/packages/api/src/assistants/protection.spec.ts b/packages/api/src/assistants/protection.spec.ts index a7812319747..8de93d764af 100644 --- a/packages/api/src/assistants/protection.spec.ts +++ b/packages/api/src/assistants/protection.spec.ts @@ -48,6 +48,30 @@ function createOpenAI({ } describe('Assistants model-bound content preflight', () => { + it('reports incomplete user-file hydration through the supplied reporter', async () => { + const onTraversalFailure = jest.fn(); + await preflightAssistantUserMessageContent({ + config: { filters: { files: { pii: { fields: ['name'], starterPatterns: ['sk_prefix'] } } } }, + user: { id: 'user-1' }, + message: { + file_ids: ['owned'], + metadata: { + trace: Array.from({ length: 4200 }, () => ({ text: 'safe' })), + }, + }, + getFiles: jest.fn().mockResolvedValue([{ file_id: 'owned', filename: 'safe.txt' }]), + onTraversalFailure, + }); + expect(onTraversalFailure).toHaveBeenCalledWith( + expect.objectContaining({ + operation: 'omit_resolved_file_locators', + reason: 'array_length', + messageCount: 1, + resolvedFileCount: 1, + }), + ); + }); + let getFiles: jest.Mock; beforeEach(() => { diff --git a/packages/api/src/assistants/protection.ts b/packages/api/src/assistants/protection.ts index d2fc8697ac8..ce997c4a272 100644 --- a/packages/api/src/assistants/protection.ts +++ b/packages/api/src/assistants/protection.ts @@ -10,6 +10,7 @@ import type { StoredMessageContentInput, } from '../protection/adapters/submissions'; import type { ExternalChatMessage } from '../protection/adapters/messages'; +import type { LocatorTraversalReporter } from '../protection/diagnostics'; import { hasActiveFilePolicy, resolveCanonicalFileReferences } from '../protection/files'; import { ContentTraversalLimitError } from '../protection/adapters/nested'; import { assertModelBoundContent } from '../middleware/modelBoundContent'; @@ -65,6 +66,7 @@ interface AssistantUserMessage extends ExternalChatMessage { } interface PreflightAssistantRunContentInput { + readonly onTraversalFailure?: LocatorTraversalReporter; readonly config?: AssistantProtectionConfig; readonly openai: AssistantOpenAIClient; readonly user?: CanonicalFileInspectionUser; @@ -74,6 +76,7 @@ interface PreflightAssistantRunContentInput { } interface PreflightAssistantUserMessageContentInput { + readonly onTraversalFailure?: LocatorTraversalReporter; readonly config?: AssistantProtectionConfig; readonly user?: CanonicalFileInspectionUser; readonly message: AssistantUserMessage; @@ -310,6 +313,7 @@ export async function preflightAssistantRunContent({ assistantId, threadId, getFiles, + onTraversalFailure, }: PreflightAssistantRunContentInput): Promise { const filters = config?.filters; const legacyPii = config?.messageFilter?.pii; @@ -339,6 +343,8 @@ export async function preflightAssistantRunContent({ let resolvedFiles: CanonicalFileInspectionFile[] = []; if (hasActiveFilePolicy(filters)) { const fileInspection = await resolveCanonicalFileReferences({ + messageCount: storedMessages.length, + onTraversalFailure, filters, input: content, user, @@ -349,6 +355,7 @@ export async function preflightAssistantRunContent({ } assertModelBoundContent({ + onTraversalFailure, filters, legacyPii, submittedMessages: hasActivePiiPatterns(legacyPii) @@ -372,6 +379,7 @@ export async function preflightAssistantUserMessageContent({ message, fileIds, getFiles, + onTraversalFailure, }: PreflightAssistantUserMessageContentInput): Promise { const filters = config?.filters; if (!hasActiveFilePolicy(filters)) { @@ -386,6 +394,8 @@ export async function preflightAssistantUserMessageContent({ file_ids: [...new Set([...(message.file_ids ?? []), ...fileIds])], }; const fileInspection = await resolveCanonicalFileReferences({ + messageCount: 1, + onTraversalFailure, filters, input: inspectionMessage, user, @@ -393,6 +403,7 @@ export async function preflightAssistantUserMessageContent({ }); assertModelBoundContent({ + onTraversalFailure, filters, legacyPii: config?.messageFilter?.pii, submittedMessages: [fileInspection.sanitizedInput], diff --git a/packages/api/src/imports.spec.ts b/packages/api/src/imports.spec.ts index c3912826310..72c376f0250 100644 --- a/packages/api/src/imports.spec.ts +++ b/packages/api/src/imports.spec.ts @@ -33,6 +33,35 @@ function deepValue(depth = 30): string | { nested: ReturnType } describe('conversation import protection', () => { + it('reports incomplete locator hydration through the import context', async () => { + const onTraversalFailure = jest.fn(); + await assertConversationImportContentAllowed( + { files: { pii: { fields: ['name'], starterPatterns: ['sk_prefix'] } } }, + { + conversations: [], + messages: [ + { + files: [{ file_id: 'owned' }], + content: Array.from({ length: 4200 }, () => ({ type: 'text', text: 'safe' })), + }, + ], + }, + { + user: { id: 'user-1' }, + onTraversalFailure, + getFiles: jest.fn().mockResolvedValue([{ file_id: 'owned', filename: 'safe.txt' }]), + }, + ); + expect(onTraversalFailure).toHaveBeenCalledWith( + expect.objectContaining({ + operation: 'omit_resolved_file_locators', + reason: 'array_length', + messageCount: 1, + resolvedFileCount: 1, + }), + ); + }); + it('returns without resolving or revalidating when protection is disabled', async () => { const getFiles = jest.fn< ReturnType, diff --git a/packages/api/src/imports.ts b/packages/api/src/imports.ts index 3547ff2abce..f8af5d082a5 100644 --- a/packages/api/src/imports.ts +++ b/packages/api/src/imports.ts @@ -14,6 +14,7 @@ import type { StoredMessageContentInput, } from './protection/adapters/submissions'; import type { ModelBoundContentInput } from './middleware/modelBoundContent'; +import type { LocatorTraversalReporter } from './protection/diagnostics'; import { getContentTraversalFragments, getContentTraversalScopes, @@ -50,6 +51,7 @@ export interface ConversationImportSnapshot { } export interface ConversationImportProtectionContext { + readonly onTraversalFailure?: LocatorTraversalReporter; readonly user?: CanonicalFileInspectionUser; readonly getFiles?: GetCanonicalFilesForInspection; readonly trustedLiveFiles?: readonly CanonicalFileInspectionFile[]; @@ -131,6 +133,8 @@ async function inspectConversationImportContent( let resolvedFiles: CanonicalFileInspectionFile[] = []; if (hasActiveFilePolicy(activeFilters)) { const fileInspection = await resolveCanonicalFileReferences({ + messageCount: snapshot.messages.length, + onTraversalFailure: context.onTraversalFailure, filters: activeFilters, input: snapshot.messages, user: context.user, @@ -145,6 +149,7 @@ async function inspectConversationImportContent( context.assertModelBoundContent ?? assertModelBoundContentAtBoundary; if (resolvedFiles.length > 0) { assertModelBoundContent({ + onTraversalFailure: context.onTraversalFailure, filters: activeFilters, resolvedFiles, }); @@ -153,6 +158,7 @@ async function inspectConversationImportContent( for (const message of storedMessages) { try { assertModelBoundContent({ + onTraversalFailure: context.onTraversalFailure, filters: activeFilters, legacyPii, storedMessages: [message], diff --git a/packages/api/src/middleware/contentFilter.ts b/packages/api/src/middleware/contentFilter.ts index 88a9ce81a12..8700e496d61 100644 --- a/packages/api/src/middleware/contentFilter.ts +++ b/packages/api/src/middleware/contentFilter.ts @@ -6,6 +6,7 @@ import type { } from 'express'; import type { FiltersConfig, MessageFilterPiiConfig } from 'librechat-data-provider'; import type { ProtectionFinding, TextContentFragment } from '../protection/types'; +import type { LocatorTraversalReporter } from '../protection/diagnostics'; import { contentFilterUninspectableResponse, getBlockedOpaqueFileField, @@ -98,6 +99,8 @@ export function isContentFilterError( } export interface CreateContentFilterOptions { + readonly messageCount?: number; + readonly onTraversalFailure?: LocatorTraversalReporter; getFilters: (req: ServerRequest) => FiltersConfig | undefined; getLegacyPii?: (req: ServerRequest) => MessageFilterPiiConfig | undefined; getMessageRoles?: (req: ServerRequest) => readonly (string | undefined)[]; @@ -125,6 +128,8 @@ export function createContentFilter(options: CreateContentFilterOptions): Reques if (opaqueFileInput != null && options.getFiles != null && hasActiveFilePolicy(filters)) { try { const fileInspection = await resolveCanonicalFileReferences({ + messageCount: options.messageCount, + onTraversalFailure: options.onTraversalFailure, filters, input: opaqueFileInput, user: ( diff --git a/packages/api/src/middleware/messageFilterPii.ts b/packages/api/src/middleware/messageFilterPii.ts index 17893ad3d04..a9cd123c052 100644 --- a/packages/api/src/middleware/messageFilterPii.ts +++ b/packages/api/src/middleware/messageFilterPii.ts @@ -15,6 +15,7 @@ import type { Response as ServerResponse, } from 'express'; import type { FiltersConfig, MessageFilterPiiConfig } from 'librechat-data-provider'; +import type { LocatorTraversalReporter } from '../protection/diagnostics'; import type { TextContentFragment } from '../protection/types'; import { contentFilterUninspectableResponse, @@ -142,6 +143,7 @@ export function findPiiMatchInMessages( } export interface CreateMessageFilterPiiOptions { + readonly onTraversalFailure?: LocatorTraversalReporter; getConfig: (req: ServerRequest) => MessageFilterPiiConfig | undefined; getFilters?: (req: ServerRequest) => FiltersConfig | undefined; getFiles?: GetCanonicalFilesForInspection; @@ -172,6 +174,8 @@ export function createMessageFilterPii(options: CreateMessageFilterPiiOptions): if (options.getFiles != null && hasActiveFilePolicy(filters)) { try { const fileInspection = await resolveCanonicalFileReferences({ + messageCount: 1, + onTraversalFailure: options.onTraversalFailure, filters, input: req.body, user: ( diff --git a/packages/api/src/middleware/modelBoundContent.spec.ts b/packages/api/src/middleware/modelBoundContent.spec.ts index aeb41fb9ce9..884326e080d 100644 --- a/packages/api/src/middleware/modelBoundContent.spec.ts +++ b/packages/api/src/middleware/modelBoundContent.spec.ts @@ -1133,6 +1133,57 @@ describe('assertModelBoundContent', () => { ).not.toThrow(); }); + it('inspects long stored history per message and still filters hydrated file content', () => { + const onTraversalFailure = jest.fn(); + const storedMessages = Array.from({ length: 58 }, (_, index) => ({ + isCreatedByUser: true, + role: 'user', + text: `Historical step ${index}`, + files: index === 0 ? [{ file_id: 'file-owned' }] : [], + content: Array.from({ length: 80 }, () => ({ type: 'text', text: 'safe preview material' })), + })); + const input = { + filters: { + files: { + pii: { + fields: ['extracted_text'], + starterPatterns: [], + customPatterns: [{ id: 'private', label: 'private value', regex: 'PRIVATE-FILE' }], + uninspectable: 'block', + }, + }, + } as FiltersConfig, + storedMessages, + onTraversalFailure, + resolvedFiles: [{ file_id: 'file-owned', text: 'safe canonical content' }], + }; + + expect(() => assertModelBoundContent(input)).not.toThrow(); + expect(() => + assertModelBoundContent({ + ...input, + resolvedFiles: [{ file_id: 'file-owned', text: 'PRIVATE-FILE' }], + }), + ).toThrow('Submitted content contains a private value'); + expect(() => + assertModelBoundContent({ + ...input, + storedMessages: [ + { ...storedMessages[0], content: storedMessages.flatMap((m) => m.content) }, + ], + }), + ).toThrow('Submitted content could not be completely inspected before processing.'); + expect(onTraversalFailure).toHaveBeenCalledTimes(1); + expect(onTraversalFailure).toHaveBeenCalledWith( + expect.objectContaining({ + operation: 'omit_resolved_file_locators', + reason: 'array_length', + messageCount: 1, + resolvedFileCount: 1, + }), + ); + }); + it('inspects owner-resolved file content before authorizing its stored locator', () => { expect(() => assertModelBoundContent({ diff --git a/packages/api/src/middleware/modelBoundContent.ts b/packages/api/src/middleware/modelBoundContent.ts index f981d2cf1a3..c17136d42bd 100644 --- a/packages/api/src/middleware/modelBoundContent.ts +++ b/packages/api/src/middleware/modelBoundContent.ts @@ -26,6 +26,7 @@ import type { } from '../protection/adapters/nested'; import type { JsonPointer, TextContentFragment } from '../protection/types'; import type { ExternalChatMessage } from '../protection/adapters/messages'; +import type { LocatorTraversalReporter } from '../protection/diagnostics'; import type { ConfiguredContentInspector } from '../protection/runtime'; import type { CanonicalFileInspectionFile } from '../protection/files'; import { @@ -230,6 +231,7 @@ class FatalModelBoundPolicyError extends StreamLimitExceededError { } export interface ModelBoundProviderContentInput { + readonly onTraversalFailure?: LocatorTraversalReporter; readonly filters?: FiltersConfig; readonly legacyPii?: MessageFilterPiiConfig; readonly providerMessages: readonly ModelBoundProviderMessage[]; @@ -689,6 +691,7 @@ export interface InitialModelBoundAdmission { } export interface ModelBoundContentInput { + readonly onTraversalFailure?: LocatorTraversalReporter; readonly filters?: FiltersConfig; readonly legacyPii?: MessageFilterPiiConfig; /** Fresh API input: every role is caller-submitted. */ @@ -3067,6 +3070,7 @@ function assertIndexedModelBoundProviderContent( const resolvedWorkBudgets = workBudgets ?? createProviderProjectionWorkBudgets(index); const projection = projectModelBoundProviderContent(input, index, resolvedWorkBudgets); assertModelBoundContent({ + onTraversalFailure: input.onTraversalFailure, filters: input.filters, legacyPii: input.legacyPii, storedMessages: projection.storedMessages, @@ -3162,6 +3166,7 @@ export function createModelBoundChatModelCallback( const resolvedFileSnapshot = snapshotBoundedProviderArray(input.resolvedFiles); const sourceFileIdSnapshot = snapshotBoundedSourceFileIds(input.fileIdsBySourceMessageId); const stableInput = { + onTraversalFailure: input.onTraversalFailure, filters: input.filters, legacyPii: input.legacyPii, storedMessages: storedMessageSnapshot.values, @@ -3530,7 +3535,6 @@ function inspectModelBoundContent( appendSubmittedTraversalError(error); } } - const storedUserMessages: StoredModelBoundMessage[] = []; let aggregateStoredTraversalErrorAdded = false; const appendStoredTraversalError = (error: ContentTraversalLimitError): void => { if ( @@ -3624,7 +3628,10 @@ function inspectModelBoundContent( if (projectedMessage != null) { assertInspectableFileInput( input.filters, - omitResolvedCanonicalFileLocators(projectedMessage, resolvedFilesById), + omitResolvedCanonicalFileLocators(projectedMessage, resolvedFilesById, { + messageCount: input.storedMessages?.length ?? 0, + onTraversalFailure: input.onTraversalFailure, + }), ); } /** Legacy unmarked assistant rows are treated as model-generated by @@ -3719,7 +3726,13 @@ function inspectModelBoundContent( } continue; } - storedUserMessages.push(message); + assertInspectableFileInput( + input.filters, + omitResolvedCanonicalFileLocators(message, resolvedFilesById, { + messageCount: input.storedMessages?.length ?? 0, + onTraversalFailure: input.onTraversalFailure, + }), + ); inspectFragments(messageFragments); inspectFragments(exactMessageFragments); if ( @@ -3734,10 +3747,6 @@ function inspectModelBoundContent( appendStoredTraversalError(traversalError); } } - assertInspectableFileInput( - input.filters, - omitResolvedCanonicalFileLocators(storedUserMessages, resolvedFilesById), - ); for (const agent of input.agents ?? []) { const agentFilesById = new Map(); for (const file of getHydratedAgentFiles(agent)) { @@ -3748,7 +3757,10 @@ function inspectModelBoundContent( } assertInspectableFileInput( input.filters, - omitResolvedCanonicalFileLocators(agent, agentFilesById), + omitResolvedCanonicalFileLocators(agent, agentFilesById, { + onTraversalFailure: input.onTraversalFailure, + messageCount: input.storedMessages?.length ?? 0, + }), ); appendExtractedContent(() => extractAgentContent(agent)); } diff --git a/packages/api/src/protection/adapters/nested.ts b/packages/api/src/protection/adapters/nested.ts index e2f66d24788..0590339fc11 100644 --- a/packages/api/src/protection/adapters/nested.ts +++ b/packages/api/src/protection/adapters/nested.ts @@ -71,10 +71,14 @@ export interface VisitNestedStringsOptions { export function getBoundedOwnEnumerableEntries( value: object, limit: number, -): { readonly entries: [string, unknown][]; readonly complete: boolean } { +): { + readonly entries: [string, unknown][]; + readonly complete: boolean; + readonly reason?: 'object_entries' | 'reflection_error'; +} { const entries: [string, unknown][] = []; if (limit !== Number.POSITIVE_INFINITY && (!Number.isSafeInteger(limit) || limit < 0)) { - return { entries, complete: false }; + return { entries, complete: false, reason: 'object_entries' }; } try { for (const key in value) { @@ -82,12 +86,12 @@ export function getBoundedOwnEnumerableEntries( continue; } if (entries.length >= limit) { - return { entries, complete: false }; + return { entries, complete: false, reason: 'object_entries' }; } entries.push([key, (value as { readonly [key: string]: unknown })[key]]); } } catch { - return { entries, complete: false }; + return { entries, complete: false, reason: 'reflection_error' }; } return { entries, complete: true }; } @@ -109,14 +113,30 @@ export type ContentTraversalScope = { const CONTENT_TRAVERSAL_FRAGMENTS = new WeakMap(); const CONTENT_TRAVERSAL_SCOPES = new WeakMap(); +export type ContentTraversalLimitReason = + | 'max_depth' + | 'max_nodes' + | 'array_length' + | 'object_entries' + | 'reflection_error'; + +export interface ContentTraversalDiagnostics { + readonly operation: 'omit_resolved_file_locators'; + readonly reason: ContentTraversalLimitReason; + readonly visitedNodes: number; + readonly depth: number; +} + export class ContentTraversalLimitError extends Error { public readonly code = 'content_filter_uninspectable'; public readonly statusCode = 400; public readonly body: UninspectableNestedContentResponse; + public readonly diagnostics?: ContentTraversalDiagnostics; constructor( fragments: readonly TextContentFragment[] = [], scopes: readonly ContentTraversalScope[] = [], + diagnostics?: ContentTraversalDiagnostics, ) { const primaryScope = scopes.find(({ fields }) => fields.length > 0); const body: UninspectableNestedContentResponse = { @@ -128,6 +148,7 @@ export class ContentTraversalLimitError extends Error { super(body.message); this.name = 'ContentTraversalLimitError'; this.body = body; + this.diagnostics = diagnostics; CONTENT_TRAVERSAL_FRAGMENTS.set(this, fragments); CONTENT_TRAVERSAL_SCOPES.set(this, scopes); Object.setPrototypeOf(this, ContentTraversalLimitError.prototype); diff --git a/packages/api/src/protection/diagnostics.ts b/packages/api/src/protection/diagnostics.ts new file mode 100644 index 00000000000..aa085150b5a --- /dev/null +++ b/packages/api/src/protection/diagnostics.ts @@ -0,0 +1,8 @@ +import type { ContentTraversalDiagnostics } from './adapters/nested'; + +export interface LocatorTraversalFailure extends ContentTraversalDiagnostics { + readonly messageCount: number; + readonly resolvedFileCount: number; +} + +export type LocatorTraversalReporter = (failure: LocatorTraversalFailure) => void; diff --git a/packages/api/src/protection/files.spec.ts b/packages/api/src/protection/files.spec.ts index c700e05e388..2c1cc6f9947 100644 --- a/packages/api/src/protection/files.spec.ts +++ b/packages/api/src/protection/files.spec.ts @@ -18,6 +18,7 @@ import { UPLOAD_EXTRACTED_TEXT_PLANS, UninspectableFileError, } from './files'; +import { ContentTraversalLimitError } from './adapters/nested'; describe('file content inspection policy', () => { it('defers extracted-text fail-close only to supported agent context extraction paths', () => { @@ -981,6 +982,7 @@ describe('file content inspection policy', () => { }); it('hydrates discovered file names without rejecting an unrelated oversized subtree', async () => { + const onTraversalFailure = jest.fn(); const canonicalFile = { file_id: 'owned-file', filename: 'safe-report.txt', @@ -1014,11 +1016,21 @@ describe('file content inspection policy', () => { input, user: { id: 'user-1' }, getFiles, + onTraversalFailure, + messageCount: input.messages.length, }), ).resolves.toMatchObject({ sanitizedInput: input, hydratedFiles: [canonicalFile], }); + expect(onTraversalFailure).toHaveBeenCalledWith( + expect.objectContaining({ + operation: 'omit_resolved_file_locators', + reason: 'array_length', + messageCount: 4200, + resolvedFileCount: 1, + }), + ); expect(getFiles).toHaveBeenCalledWith( { file_id: { $in: ['owned-file'] }, user: 'user-1' }, {}, @@ -1380,6 +1392,90 @@ describe('file content inspection policy', () => { }); }); + it.each([ + [ + 'max_depth', + () => { + let value: object = {}; + for (let i = 0; i < 26; i++) value = { child: value }; + return value; + }, + ], + ['max_nodes', () => [...Array.from({ length: 2047 }, () => ({ child: {} })), {}, {}]], + ['array_length', () => new Array(4096)], + [ + 'object_entries', + () => Object.fromEntries(Array.from({ length: 4096 }, (_, i) => [i, 'safe'])), + ], + [ + 'reflection_error', + () => + Object.defineProperty({}, 'payload', { + enumerable: true, + get() { + throw new Error('PRIVATE-CONTENT'); + }, + }), + ], + [ + 'reflection_error', + () => + Object.defineProperty([], '0', { + get() { + throw new Error('PRIVATE-CONTENT'); + }, + }), + ], + [ + 'reflection_error', + () => { + const { proxy, revoke } = Proxy.revocable({}, {}); + revoke(); + return proxy; + }, + ], + ] as const)('reports safe %s diagnostics for locator sanitization', (reason, makeInput) => { + const report = jest.fn(); + let failure: ContentTraversalLimitError | undefined; + try { + omitResolvedCanonicalFileLocators(makeInput(), new Map([['owned', { file_id: 'owned' }]]), { + onTraversalFailure: report, + messageCount: 58, + }); + } catch (error) { + expect(error).toBeInstanceOf(ContentTraversalLimitError); + failure = error as ContentTraversalLimitError; + } + expect(failure).toBeDefined(); + expect(failure?.diagnostics).toEqual({ + operation: 'omit_resolved_file_locators', + reason, + visitedNodes: expect.any(Number), + depth: expect.any(Number), + }); + expect(failure?.body).not.toHaveProperty('diagnostics'); + expect(report).toHaveBeenCalledTimes(1); + expect(report).toHaveBeenCalledWith({ + ...failure?.diagnostics, + messageCount: 58, + resolvedFileCount: 1, + }); + expect(JSON.stringify(report.mock.calls)).not.toContain('PRIVATE-CONTENT'); + }); + + it('preserves own __proto__ opaque payloads in a null-prototype inspection copy', () => { + const input = JSON.parse('{"file_id":"owned","__proto__":{"file_id":"unresolved"}}'); + const sanitized = omitResolvedCanonicalFileLocators( + input, + new Map([['owned', { file_id: 'owned' }]]), + ); + expect(Object.getPrototypeOf(sanitized)).toBeNull(); + expect(Object.prototype.hasOwnProperty.call(sanitized, '__proto__')).toBe(true); + expect( + getBlockedOpaqueFileField({ files: { pii: { uninspectable: 'block' } } }, sanitized), + ).not.toBeNull(); + }); + it('omits only locators that exactly match the resolved canonical row', () => { const canonicalFile = { file_id: 'owned-file', diff --git a/packages/api/src/protection/files.ts b/packages/api/src/protection/files.ts index 6a20f35914a..aefa675d83b 100644 --- a/packages/api/src/protection/files.ts +++ b/packages/api/src/protection/files.ts @@ -8,6 +8,8 @@ import { isPermissiveMimeConfig, } from 'librechat-data-provider'; import type { FileConfig, FileFilterField, FiltersConfig } from 'librechat-data-provider'; +import type { ContentTraversalLimitReason } from './adapters/nested'; +import type { LocatorTraversalReporter } from './diagnostics'; import { ContentTraversalLimitError, escapeJsonPointer, @@ -91,6 +93,8 @@ export type GetCanonicalFilesForInspection = ( ) => Promise; export interface CanonicalFileReferenceInspectionInput { + readonly messageCount?: number; + readonly onTraversalFailure?: LocatorTraversalReporter; readonly filters?: FiltersConfig; readonly input: T; readonly user?: CanonicalFileInspectionUser; @@ -1031,114 +1035,132 @@ function omitResolvedFileLocators( if (value == null || typeof value !== 'object') { return value; } - if (depth > MAX_OPAQUE_DEPTH) { - throw new ContentTraversalLimitError(); - } - const traversal = state ?? { seen: new WeakMap(), visited: 0 }; - if (traversal.visited >= MAX_OPAQUE_NODES) { - throw new ContentTraversalLimitError(); - } - const seenValue = traversal.seen.get(value); - if (seenValue !== undefined) { - return seenValue; - } - traversal.visited++; - - let valueIsArray: boolean; + const fail = (reason: ContentTraversalLimitReason): ContentTraversalLimitError => + new ContentTraversalLimitError([], [], { + operation: 'omit_resolved_file_locators', + reason, + visitedNodes: traversal.visited, + depth, + }); try { - valueIsArray = Array.isArray(value); - } catch { - throw new ContentTraversalLimitError(); - } - if (valueIsArray) { - const arrayValue = value as readonly unknown[]; - const arrayLength = captureOpaqueArrayLength(arrayValue); - const remainingNodes = MAX_OPAQUE_NODES - traversal.visited; - if (arrayLength > remainingNodes) { - throw new ContentTraversalLimitError(); + if (depth > MAX_OPAQUE_DEPTH) { + throw fail('max_depth'); } - const cloned: unknown[] = []; - traversal.seen.set(value, cloned); - for (let index = 0; index < arrayLength; index++) { - cloned.push( - omitResolvedFileLocators(arrayValue[index], resolvedFilesById, depth + 1, traversal), - ); + if (traversal.visited >= MAX_OPAQUE_NODES) { + throw fail('max_nodes'); } - return cloned; - } - - const remainingNodes = MAX_OPAQUE_NODES - traversal.visited; - const boundedEntries = getBoundedOwnEnumerableEntries(value, remainingNodes); - if (!boundedEntries.complete) { - throw new ContentTraversalLimitError(); - } - const entries = boundedEntries.entries; - - const cloned = Object.create(null) as MutableUnknownDictionary; - traversal.seen.set(value, cloned); - const fileId = entries.find(([key]) => key === 'file_id')?.[1]; - const resolvedFile = typeof fileId === 'string' ? resolvedFilesById.get(fileId) : undefined; - const matchesResolvedLocator = (locator: unknown): boolean => { - if (typeof locator !== 'string' || resolvedFile == null) { - return false; - } - return ( - resolvedFile.filepath === locator || - resolvedFile.uri === locator || - resolvedFile.url === locator || - resolvedFile.preview === locator - ); - }; - - for (const [key, child] of entries) { - if (resolvedFile != null && key === 'file_id') { - continue; + const seenValue = traversal.seen.get(value); + if (seenValue !== undefined) { + return seenValue; } - if ( - resolvedFile != null && - (key === 'uri' || key === 'url' || key === 'filepath' || key === 'preview') && - matchesResolvedLocator(child) - ) { - continue; + traversal.visited++; + + const valueIsArray = Array.isArray(value); + if (valueIsArray) { + const arrayValue = value as readonly unknown[]; + const arrayLength = captureOpaqueArrayLength(arrayValue); + const remainingNodes = MAX_OPAQUE_NODES - traversal.visited; + if (arrayLength > remainingNodes) { + throw fail('array_length'); + } + const cloned: unknown[] = []; + traversal.seen.set(value, cloned); + for (let index = 0; index < arrayLength; index++) { + cloned.push( + omitResolvedFileLocators(arrayValue[index], resolvedFilesById, depth + 1, traversal), + ); + } + return cloned; } - let childIsArray: boolean; - try { - childIsArray = Array.isArray(child); - } catch { - throw new ContentTraversalLimitError(); + + const remainingNodes = MAX_OPAQUE_NODES - traversal.visited; + const boundedEntries = getBoundedOwnEnumerableEntries(value, remainingNodes); + if (!boundedEntries.complete) { + throw fail(boundedEntries.reason ?? 'object_entries'); } - if (key === 'file_ids' && childIsArray) { - const childFileIds = child as readonly unknown[]; - const childFileIdCount = captureOpaqueArrayLength(childFileIds); - if (childFileIdCount > MAX_OPAQUE_NODES - traversal.visited) { - throw new ContentTraversalLimitError(); + const entries = boundedEntries.entries; + + const cloned = Object.create(null) as MutableUnknownDictionary; + traversal.seen.set(value, cloned); + const fileId = entries.find(([key]) => key === 'file_id')?.[1]; + const resolvedFile = typeof fileId === 'string' ? resolvedFilesById.get(fileId) : undefined; + const matchesResolvedLocator = (locator: unknown): boolean => { + if (typeof locator !== 'string' || resolvedFile == null) { + return false; } - const unresolvedFileIds: unknown[] = []; - for (let index = 0; index < childFileIdCount; index++) { - const childFileId = childFileIds[index]; - if (typeof childFileId !== 'string' || !resolvedFilesById.has(childFileId)) { - unresolvedFileIds.push(childFileId); - } + return ( + resolvedFile.filepath === locator || + resolvedFile.uri === locator || + resolvedFile.url === locator || + resolvedFile.preview === locator + ); + }; + + for (const [key, child] of entries) { + if (resolvedFile != null && key === 'file_id') { + continue; } - if (unresolvedFileIds.length > 0) { - cloned[key] = unresolvedFileIds; + if ( + resolvedFile != null && + (key === 'uri' || key === 'url' || key === 'filepath' || key === 'preview') && + matchesResolvedLocator(child) + ) { + continue; } - continue; + const childIsArray = Array.isArray(child); + if (key === 'file_ids' && childIsArray) { + const childFileIds = child as readonly unknown[]; + const childFileIdCount = captureOpaqueArrayLength(childFileIds); + if (childFileIdCount > MAX_OPAQUE_NODES - traversal.visited) { + throw fail('array_length'); + } + const unresolvedFileIds: unknown[] = []; + for (let index = 0; index < childFileIdCount; index++) { + const childFileId = childFileIds[index]; + if (typeof childFileId !== 'string' || !resolvedFilesById.has(childFileId)) { + unresolvedFileIds.push(childFileId); + } + } + if (unresolvedFileIds.length > 0) { + cloned[key] = unresolvedFileIds; + } + continue; + } + cloned[key] = omitResolvedFileLocators(child, resolvedFilesById, depth + 1, traversal); + } + return cloned; + } catch (error) { + if (error instanceof ContentTraversalLimitError && error.diagnostics != null) { + throw error; } - cloned[key] = omitResolvedFileLocators(child, resolvedFilesById, depth + 1, traversal); + throw fail(error instanceof ContentTraversalLimitError ? 'array_length' : 'reflection_error'); } - return cloned; } export function omitResolvedCanonicalFileLocators( input: T, resolvedFilesById: ReadonlyMap, + context: { + readonly messageCount?: number; + readonly onTraversalFailure?: LocatorTraversalReporter; + } = {}, ): T { if (resolvedFilesById.size === 0) { return input; } - return omitResolvedFileLocators(input, resolvedFilesById) as T; + try { + return omitResolvedFileLocators(input, resolvedFilesById) as T; + } catch (error) { + if (error instanceof ContentTraversalLimitError && error.diagnostics != null) { + context.onTraversalFailure?.({ + ...error.diagnostics, + messageCount: context.messageCount ?? 0, + resolvedFileCount: resolvedFilesById.size, + }); + } + throw error; + } } export function allowHydratedFileReferences( @@ -1254,7 +1276,10 @@ export async function resolveCanonicalFileReferences( let sanitizedInput = input.input; if (currentById.size > 0) { try { - sanitizedInput = omitResolvedCanonicalFileLocators(input.input, currentById); + sanitizedInput = omitResolvedCanonicalFileLocators(input.input, currentById, { + messageCount: input.messageCount, + onTraversalFailure: input.onTraversalFailure, + }); } catch (error) { if (!(error instanceof ContentTraversalLimitError)) { throw error; diff --git a/packages/api/src/protection/messageMutations.spec.ts b/packages/api/src/protection/messageMutations.spec.ts index 3e6c6f88438..bbe2530a5df 100644 --- a/packages/api/src/protection/messageMutations.spec.ts +++ b/packages/api/src/protection/messageMutations.spec.ts @@ -56,6 +56,36 @@ describe('typed message mutation policy', () => { ).toThrow(expect.objectContaining({ code: 'content_filter_uninspectable' })); }); + it('reports locator traversal failures through branch dependencies', async () => { + const onTraversalFailure = jest.fn(); + const message = { + isUserSubmitted: true, + files: [{ file_id: 'owned' }], + content: Array.from({ length: 4200 }, () => ({ type: 'text', text: 'safe' })), + }; + await expect( + assertStoredMessageBranchAllowed( + { + filters: { files: { pii: { fields: ['name'], starterPatterns: ['sk_prefix'] } } }, + user: { id: 'user-1' }, + message, + }, + { + getFiles: jest.fn().mockResolvedValue([{ file_id: 'owned', filename: 'safe.txt' }]), + onTraversalFailure, + }, + ), + ).rejects.toMatchObject({ code: 'content_filter_uninspectable' }); + expect(onTraversalFailure).toHaveBeenCalledWith( + expect.objectContaining({ + operation: 'omit_resolved_file_locators', + reason: 'array_length', + messageCount: 1, + resolvedFileCount: 1, + }), + ); + }); + it('hydrates canonical files before admitting a branch message', async () => { const getFiles = jest.fn().mockResolvedValue([ { diff --git a/packages/api/src/protection/messageMutations.ts b/packages/api/src/protection/messageMutations.ts index 50d24247eaf..0f3c5ab0c7e 100644 --- a/packages/api/src/protection/messageMutations.ts +++ b/packages/api/src/protection/messageMutations.ts @@ -3,6 +3,7 @@ import type { FiltersConfig, MessageFilterPiiConfig } from 'librechat-data-provi import type { CanonicalFileInspectionUser, GetCanonicalFilesForInspection } from './files'; import type { ModelBoundContentInput } from '../middleware/modelBoundContent'; import type { StoredMessageContentInput } from './adapters/submissions'; +import type { LocatorTraversalReporter } from './diagnostics'; import type { ChatSubmissionBody } from './adapters/chat'; import type { TextContentFragment } from './types'; import { hasActiveFilePolicy, resolveCanonicalFileReferences } from './files'; @@ -58,12 +59,17 @@ export interface StoredMessageBranchPolicyInput { */ export async function assertStoredMessageBranchAllowed( input: StoredMessageBranchPolicyInput, - dependencies: { readonly getFiles: GetCanonicalFilesForInspection }, + dependencies: { + readonly getFiles: GetCanonicalFilesForInspection; + readonly onTraversalFailure?: LocatorTraversalReporter; + }, ): Promise { let storedMessage: StoredMessageContentInput = input.message; let resolvedFiles: NonNullable = []; if (hasActiveFilePolicy(input.filters)) { const inspection = await resolveCanonicalFileReferences({ + messageCount: 1, + onTraversalFailure: dependencies.onTraversalFailure, filters: input.filters, input: input.message, user: input.user, @@ -73,6 +79,7 @@ export async function assertStoredMessageBranchAllowed( resolvedFiles = inspection.hydratedFiles; } assertModelBoundContent({ + onTraversalFailure: dependencies.onTraversalFailure, filters: input.filters, legacyPii: input.legacyPii, storedMessages: [storedMessage], diff --git a/packages/api/src/shared-links/protection.ts b/packages/api/src/shared-links/protection.ts index 68364bf71a5..5a788a28b1b 100644 --- a/packages/api/src/shared-links/protection.ts +++ b/packages/api/src/shared-links/protection.ts @@ -12,6 +12,7 @@ import type { } from '../protection/files'; import type { JsonPointer, TextContentFragment } from '../protection/types'; import type { FileContentInput } from '../protection/adapters/submissions'; +import type { LocatorTraversalReporter } from '../protection/diagnostics'; import type { ConversationImportMessage } from '../imports'; import { CONTENT_TRAVERSAL_MAX_DEPTH, @@ -101,6 +102,7 @@ export interface ShareContentPreflightInput { } export interface ShareContentPreflightOptions { + readonly onTraversalFailure?: LocatorTraversalReporter; readonly legacyPii?: MessageFilterPiiConfig | null; readonly snapshotFiles?: boolean; readonly user?: CanonicalFileInspectionUser; @@ -162,6 +164,7 @@ export function createShareContentPreflight( legacyPii, user: options.user, getFiles: options.getFiles, + onTraversalFailure: options.onTraversalFailure, }, ); if (!inspectSharedFileMetadata) { From 1bea7e4c9eaf208ade79a29b4474d2ae0223c833 Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sat, 12 Sep 2026 03:23:17 +0200 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=A7=BD=20style:=20Composer,=20Welcome?= =?UTF-8?q?=20Screen=20and=20Context=20Panel=20Polish=20(#15838)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🕵️ style: Mark Temporary Chat with the Incognito Icon The dashed speech bubble (`MessageCircleDashed`) read as a generic chat glyph rather than a retention cue, and its dashes break up at 16px. Every temporary chat surface now uses lucide's `hat-glasses`, the hat-and-glasses incognito mark: the header toggle, the read-only indicator chip once a conversation has started, the overflow menu row, and the landing empty state. `hat-glasses` landed in lucide 0.528.0, so `lucide` and `lucide-react` move from `^0.525.0` to `^0.528.0` in `client` and `packages/client` (dependencies and peers). That is the smallest bump that carries the icon; lucide redraws existing icons between minors and the whole UI draws from it, so jumping to the current 0.577 would bring unrelated visual churn. `useChatBadges` imported the old icon without using it; the import is dropped. * 🧹 style: Keep the Model Disclaimer on the Landing Page Only The footer disclaimer rendered under the composer in every conversation, where it repeats itself on each turn and costs a line of the thread. It now renders only on the landing page, where it is first seen. `Footer` is untouched, as are its other hosts: the auth screens and the public shared-link view still render it. The component is a zero-height `relative` wrapper around an `absolute bottom-0` bar, so dropping it from the conversation branch shifts no layout. * 📱 fix: Run the Mobile Composer to the Screen Edges Below `sm` the composer is full-bleed — no horizontal padding, squared off at the bottom, flush with the viewport floor — but it was inset on two sides there: - `.scrollbar-gutter-spacer` reserved the message column's scrollbar band (8px) on the composer's trailing edge. That lines the composer up with the thread on desktop, but on mobile it only cut a dead strip off its right side, so the padding now applies from `sm` up. Its two users, the composer band and the scroll-to-bottom control, move together, so the control still stacks over the send button. - The composer surface carried `pb-4` under its action row below `sm` (`sm:pb-0` above), leaving 16px of empty surface between the buttons and the bottom of the screen. Desktop metrics are unchanged: the band still reserves the gutter, the composer still clears the floor by `sm:mb-10`, and the surface still resolves to `pb-0`. * 🔅 style: Mute the Landing Page Disclaimer The footer sat in `text-primary`, the same weight as the greeting above it, so the least important line on the landing page read as one of the most prominent — hardest to ignore in the light theme, where it lands at 16.1:1 on white. It now takes `text-muted`, the quietest text token that still clears AA for 12px copy on `bg-presentation`: 5.11:1 on white and 7.93:1 on the dark canvas. The contrast modes collapse every text token to pure black or white, so `high-contrast-light` and `high-contrast-dark` keep the disclaimer at 21:1. The links move with it instead of keeping the brighter `text-secondary`: the underline carries the affordance, and a link that outshines its own sentence puts the emphasis back where this change takes it from. The text stays at the bottom of the page. The landing hierarchy is the composer and its title first, the sidebar second, and moving the disclaimer under the text field would place it above both. * 💠 style: Draw the Empty Prompts Panel Like Its Neighbours The prompts side panel hand-rolled its own empty card — the same circular icon, title and caption as the bookmarks, memories and schedules panels, but written out again and a shade off: `border-border-medium` where the shared card uses `border-border-light`. It now renders the `EmptyState` primitive the other panels use, which is where that appearance is owned. The strings, the icon and the list's own `my-2` are unchanged, so the panel reads the same apart from the border it was never meant to differ on. * 📐 fix: Close the Gap Under the Composer in a Conversation From `sm` up the composer band left 40px under itself, the height of the disclaimer that used to sit there. A started conversation no longer carries one, so the composer floated a line above the viewport floor with nothing in the gap. The clearance now follows what is underneath. `ChatView` owns that fact — it is the same `isLandingPage` it gates the footer on — and passes it to `ChatForm`: - landing, centred composer: `sm:mb-28`, unchanged - landing, composer at the bottom: `sm:mb-10`, the clearance the disclaimer needs, unchanged - started conversation: `sm:mb-4`, enough to show the surface's own shadow Below `sm` the composer still runs to the viewport floor in every state. `ChatForm` cannot answer this for itself: deriving it from `conversation?.messages?.length === 0` reads false while `messages` is undefined, which silently drops the un-centred landing page to the conversation clearance and overlaps the disclaimer. `SubagentThreadPanel` states that its own bottom padding matches the main composer's so the two surfaces end on one line when the panel is open beside a thread, so it moves with it. * 🎞️ fix: Slide the Composer Between Welcome Screen and Conversation With "Center Chat Input on Welcome Screen" off, the welcome composer sits at the bottom with the disclaimer's clearance under it and a conversation's sits 24px lower. React keeps the same form node across that navigation, so the band had everything it needed to travel and jumped instead: `margin-bottom`, the property that carries it, was not in the transition list — only `max-width` was, from the chat-width preference. `margin-bottom` now transitions with it, 300ms on Tailwind's default easing, in both directions and from the centred welcome composer as well. Reduced motion gets the new position outright (`motion-reduce:transition-none`): this is a slide across the page, not decoration. * ↔️ fix: Hold the Composer Still Across the Welcome Screen Opening a conversation from the welcome screen slid the composer 4px to the left. A conversation's composer band reserves the band the message column holds back for its scrollbar (`.scrollbar-gutter-spacer`, 8px here), which is what lines the composer up with the messages; the welcome screen reserved nothing, so its composer was centred on the full region and half the gutter off from where it was about to land. Now that `margin-bottom` animates, the sideways step ran alongside the downward one. The gutter is reserved once per state, wherever the centring happens: the conversation keeps it on the band around the composer, and the welcome screen takes it on the column that centres greeting and composer together, so both move as one and the composer lands where the message column will put it. Measured across the navigation — 1280 and 1024 wide, centred and bottom composer, sidebar open and collapsed: the surface keeps its exact left edge and width. Below `sm` the reservation does not apply and the composer stays full-bleed on both sides of the navigation. * 📏 fix: Measure the Scrollbar Gutter Before the Welcome Screen Reserves It The welcome screen holds back the band the message column reserves for its scrollbar, so the composer lands where the message column will put it. On a fresh load no message column has mounted, so `useScrollbarGutter` has published nothing and the spacer fell back to `--scrollbar-size`: a fixed 8px, correct only where the app's own `::-webkit-scrollbar` width applies. On an overlay-scrollbar platform the real band is zero, so the welcome screen started 4px off-centre and the composer stepped sideways as soon as the first conversation measured the band for real — the jump this change set out to remove, inverted. The gutter is now seeded from a detached probe built like the column itself (`overflow-y: auto` with `scrollbar-gutter: stable`), which answers the same question for the platform in front of the user: the scrollbar width, and zero where scrollbars overlay. A mounted column measures itself and is authoritative, so the seed only fills the gap before the first one exists and never overwrites a published measurement. Reported by Codex on PR #15838: https://github.com/danny-avila/LibreChat/pull/15838#discussion_r3991992182 * 🧪 test: Cover the Composer and Welcome Screen Behaviours Nine browser scenarios for what this change promises, in the mock harness against the in-process fake model, tagged so each one is addressable: - `welcome-screen-disclaimer-only`, `welcome-disclaimer-readable-while-muted`: the disclaimer stays on the welcome screen, leaves the conversation, and clears AA contrast against the canvas it is painted on — the dark project measures the dark palette, the light one the light palette. - `composer-holds-position-into-conversation`, `composer-holds-position-with-overlay-scrollbars`: the composer keeps its left edge and width across the navigation and lines up with the message column, both with the app's own scrollbars and on a platform whose scrollbars reserve nothing. - `composer-slides-down-into-conversation`, `composer-settles-instantly-under-reduced-motion`: the clearance change runs as a `margin-bottom` transition on the composer's own form, and runs no transition at all under `prefers-reduced-motion`. - `mobile-composer-reaches-screen-edges`: below `sm` the surface spans the viewport and its action row is the last thing in it. - `temporary-chat-marked-with-incognito-icon`: turning the mode on marks the toggle and the welcome screen with the outline incognito mark. - `empty-prompts-panel-matches-other-panels`: the empty prompts card and the empty bookmarks card are drawn identically. Each scenario skips the projects whose viewport it does not describe, rather than asserting a desktop layout under mobile emulation. * 🩹 fix: Seed the Gutter Before Paint and Keep Configured Footers Three findings from the review round on eec4b37, one root cause each. The gutter seed measured in a passive effect, so the welcome screen's first frame still reserved the `--scrollbar-size` token and recentred by 4px once the real band was known — the load-time half of the shift this reservation removes. It measures in a layout effect now, before the browser paints. Scoping the disclaimer to the welcome screen also unmounted the only authenticated-chat renderer of `customFooter`, and of a deployment's privacy policy and terms links. `Footer` takes `configuredOnly`: the generic disclaimer is dropped, operator content is kept, and with nothing configured it renders nothing rather than an empty bar. The conversation renders it again under that scope. The mobile composer spec gated itself on viewport width, and the repository's mock config has one Desktop Chrome project, so CI skipped it and reported nothing. It declares the viewport it describes with `test.use`. Two scenarios cover the first two: - `welcome-composer-paints-in-its-final-position`: samples the composer's x from the document's first frame on a platform whose scrollbars reserve nothing, and fails on any later correction. - `configured-footer-stays-in-a-conversation`: serves a configured `customFooter` through the startup-config route and finds it on both the welcome screen and inside a conversation. Findings: https://github.com/danny-avila/LibreChat/pull/15838#discussion_r3992539826 https://github.com/danny-avila/LibreChat/pull/15838#discussion_r3992539834 https://github.com/danny-avila/LibreChat/pull/15838#discussion_r3992539845 * 🧹 test: Drop the First-Paint Sampler, Keep the Layout Effect The sampler recorded the composer's x from the document's first frame to catch a late recentring on a platform whose scrollbars reserve nothing. It cannot fail: with the seed measured in a passive effect it still recorded a single position in all three projects, because React flushes that effect before the browser paints the commit it belongs to. A test that passes with and without the behaviour it describes pins nothing. The measurement stays in a layout effect: a value the layout reads belongs before paint by construction, whatever a given runtime's flush order happens to be. The reservation itself is covered by `composer-holds-position-with-overlay-scrollbars`, which does fail without it — 4px, the half-band Codex reported. * 🧷 fix: Clear the Configured Footer and Remeasure the Band on Contrast Two findings from the round on d942b7c, one invariant each. Keeping a configured footer in conversations left the composer reserving 16px above an absolutely positioned bar about 32px tall, so the bar painted over the composer's action row and would have taken its clicks. The clearance follows what actually renders beneath the composer now, not which page it is: `ChatView` reads `useConfiguredFooter` once and both decisions — whether to render the footer, and how much room to leave for it — come from that same answer. `ChatForm` takes `footerBelow` and keeps the welcome screen's rhythm for the welcome screen. The band is not a constant of the platform either: the contrast modes widen the app's own scrollbar to 0.75rem, and a reader can turn contrast on — or have the OS turn it on — while the welcome screen is the only thing mounted, with no message column to republish. The seed remeasures when `ThemeProvider` flips a class on the document element, which is the thing that changed the band. Two scenarios, both failing before the fixes: - `configured-footer-clears-the-composer`: with `customFooter` configured, the bar sits at or below the composer's bottom edge and the composer still takes a click at its own bottom row. - `composer-holds-position-across-a-contrast-switch`: contrast turned on while the welcome screen is up, then a conversation opened; the composer keeps its left edge and width. Findings: https://github.com/danny-avila/LibreChat/pull/15838#discussion_r3992984510 https://github.com/danny-avila/LibreChat/pull/15838#discussion_r3992984517 * 🪡 fix: Align the Subagent Panel With the Conversation's Footer The panel's bottom band states that it matches the main composer's clearance so the two surfaces end on one line beside each other. That clearance became footer-dependent in the previous commit, and the panel kept a fixed `pb-4`, so a deployment with a configured footer ended them 24px apart. The panel reads the same answer the composer reads — `useConfiguredFooter` — and clears the bar when there is one. Three consumers now share one source for the question "does a footer render beneath this surface": `ChatView`, `ChatForm` through `footerBelow`, and this panel. The panel's own alignment has no browser scenario: reaching this surface needs an agent with subagents, a dispatched task and an open control footer, which is a fixture of its own. The shared cause is covered by `configured-footer-clears-the-composer` on the main composer. Reported by Codex on PR #15838: https://github.com/danny-avila/LibreChat/pull/15838#discussion_r3993177163 * 🧵 fix: Hold the Footer Clearance Through a Cold Load and a Stale Band Three findings from the round on 55106cd. Whether a conversation carries a footer is an answer from the startup config, and on a cold `/c/` load it arrives after the composer is painted. `useConfiguredFooter` now reports `{ present, resolved }`, and the two readers use them differently on purpose: nothing renders until the answer arrives, because there is nothing to render, but the clearance is held from the first frame, because guessing "no footer" moves the composer twice. The measured scrollbar band outlives the chat screen on purpose, and the screens it outlives can change it — the auth layout carries a theme selector, and a contrast switch there widens the app's own scrollbar while no chat column is watching. The seed measures on every mount now instead of trusting a value it did not publish. `@librechat/client`'s peer range kept its previous line as well as the new one (`^0.525.0 || ^0.528.0`): only `/client` consumes `HatGlasses`, and the shared package still builds against either, so raising the published minimum would have rejected downstream installs for an icon it does not use. Its devDependency stays on 0.528, which is what it is tested against. Two scenarios, both failing before the fixes: - `configured-footer-clearance-survives-a-cold-load`: the startup config is served 700ms late with a custom footer, and the composer's bottom is sampled from the document's first frame; any correction fails it. - `welcome-screen-remeasures-a-stale-gutter`: a 40px reservation is left behind before the app boots, as another screen would, and the welcome screen has to replace it rather than inherit it. Findings: https://github.com/danny-avila/LibreChat/pull/15838#discussion_r3993328716 https://github.com/danny-avila/LibreChat/pull/15838#discussion_r3993328723 https://github.com/danny-avila/LibreChat/pull/15838#discussion_r3993328726 * 🧠 fix: Remember the Footer Answer Instead of Guessing It Reserving the footer's band while the startup config was in flight fixed the cold load of a deployment that configures a footer and broke the cold load of one that does not: the composer took `sm:mb-10`, then dropped 24px when the answer said there was nothing to clear. Both defaults are wrong for one of the two deployments, because the layout was being derived from an answer that does not exist yet. `useConfiguredFooter` remembers the answer instead. A deployment's footer configuration is a deployment-lifetime fact, so the last answer it gave is the right thing to lay out against while `/api/config` is in flight; it is recorded once the query resolves. Every load after the first is exact in either kind of deployment, and a first-ever visit falls back to LibreChat's default — no configured footer — and settles once if the deployment disagrees. `ChatView` and the subagent panel drop the `!resolved` term: `present` already carries the remembered answer, so the value is the same before and after the config answers. Scenarios, both sampling the composer's bottom from the document's first frame with the config served 700ms late: - `configured-footer-clearance-survives-a-cold-load` now primes the answer with a first visit and asserts the load after it, which is the load a returning user gets. - `default-clearance-survives-a-cold-load` covers the other branch: a deployment that configures nothing must not move either. Reported by Codex on PR #15838: https://github.com/danny-avila/LibreChat/pull/15838#discussion_r3993438258 * 🛟 fix: Treat a Failed Startup Config as No Answer React Query reports a request that exhausted its retries as fetched, with no data, so reading `isFetched` turned a failed `/api/config` into a confirmed "nothing configured": a deployment remembered as having a footer lost the clearance that footer needs, and the memory was overwritten with the guess, so the composer moved again on the next load that succeeded. `useConfiguredFooter` reads `isSuccess`. A failure now answers nothing: the remembered deployment state stands, and nothing is recorded until a response actually arrives. `config-failure-keeps-the-remembered-footer` covers it: a first visit records a configured footer, a second visit is served 500s until the retries run out, and the composer's clearance has to be the one it had. Reported by Codex on PR #15838: https://github.com/danny-avila/LibreChat/pull/15838#discussion_r3993554041 * 🧽 test: Drop the Config-Failure Scenario, Keep the Success Check The scenario recorded a configured footer, served `/api/config` 500s until the retries ran out, and then asserted the next successful load lays out once. It cannot fail: without a startup config the app renders no chat at all, so `useConfiguredFooter` is never mounted on the failing visit and nothing is recorded either way. The negative control — reading `isFetched` again — passes it in all three projects. The reported corruption therefore has no reachable path: the hook only observes a failed query while the chat is mounted, which requires a config that answered once, and React Query keeps that answer as cached data through a later failure. `isSuccess` stays. "A failure answers nothing" is what the code should say, and saying it costs nothing; the test that pretended to prove it does not stay. Reported by Codex on PR #15838: https://github.com/danny-avila/LibreChat/pull/15838#discussion_r3993554041 * 🧱 refactor: Own the Footer Memory in Jotai and Take the Landing Preference In Two repository-policy findings from the round on 5d535a6. The remembered footer answer was a raw `localStorage` read and write, which is the persistence path the client state rules replaced: it is feature-owned state that the chat surface both writes and reads, so it is a Jotai storage atom now (`configuredFooterAtom`, beside the feature that owns it). `getOnInit` is what makes it usable — the value has to be there on the first render, or it is the guess again — and only a successful config response writes to it. `ChatForm` also still subscribed to `centerFormOnLanding` directly, an app-level persisted preference the chat feature only consumes. `ChatView` already reads it for `Landing`, and now passes it in beside `isLandingPage` and `footerBelow`, so the composer's clearance is computed entirely from what the host handed it. Findings: https://github.com/danny-avila/LibreChat/pull/15838#discussion_r3993728399 https://github.com/danny-avila/LibreChat/pull/15838#discussion_r3993728405 * 🔍 fix: Give the Context Popover Its Own Foreground The popover is portaled to the body with a surface colour and no text colour, so anything inheriting \ there took the user-agent default black: the insights toggle rendered at 1.3:1 on the dark canvas and was invisible until hovered. The panel now names \ beside its surface, as the shared hover-card surface already does. * 🧮 fix: Show the Cached Share Beside Its Tokens The cached and cache-write rows were built without a max, and the row renders its share only when one is given, so both printed a bare token count while every row around them carried a percentage. Both now key the window, and the cached test pins the share. The insights toggle also names its resting colour here: the ghost variant defines only a hover colour, and every other glyph in the panel names a role. --- client/package.json | 4 +- client/src/components/Chat/ChatView.tsx | 36 ++- client/src/components/Chat/Footer.tsx | 91 +++++- client/src/components/Chat/Input/ChatForm.tsx | 64 +++- .../Chat/Input/TokenUsage/Breakdown.spec.tsx | 4 + .../Chat/Input/TokenUsage/Breakdown.tsx | 4 +- .../Chat/Input/TokenUsage/index.tsx | 2 +- .../__tests__/ChatForm.attachments.spec.tsx | 7 +- .../__tests__/ChatForm.pasteUpload.spec.tsx | 7 +- client/src/components/Chat/Landing.tsx | 4 +- .../src/components/Chat/Menus/HeaderMenu.tsx | 4 +- .../Subagents/SubagentThreadPanel.test.tsx | 3 + .../Chat/Subagents/SubagentThreadPanel.tsx | 17 +- client/src/components/Chat/TemporaryChat.tsx | 6 +- .../Chat/__tests__/ChatView.spec.tsx | 7 +- .../Chat/__tests__/ChatView.subagent.spec.tsx | 7 +- client/src/components/Chat/footerMemory.ts | 14 + client/src/components/Prompts/lists/List.tsx | 18 +- client/src/hooks/Messages/index.ts | 2 +- .../src/hooks/Messages/useScrollbarGutter.ts | 65 +++- client/src/hooks/useChatBadges.ts | 4 +- client/src/mobile.css | 10 +- .../scenarios/cold-load-clearance.spec.ts | 153 ++++++++++ .../mock/scenarios/composer-position.spec.ts | 282 ++++++++++++++++++ .../configured-footer-clearance.spec.ts | 68 +++++ .../mock/scenarios/configured-footer.spec.ts | 62 ++++ .../contrast-switch-position.spec.ts | 105 +++++++ .../scenarios/empty-prompts-panel.spec.ts | 60 ++++ .../scenarios/mobile-composer-edges.spec.ts | 57 ++++ e2e/specs/mock/scenarios/stale-gutter.spec.ts | 63 ++++ .../scenarios/temporary-chat-icon.spec.ts | 54 ++++ .../mock/scenarios/welcome-disclaimer.spec.ts | 105 +++++++ package-lock.json | 24 +- packages/client/package.json | 8 +- 34 files changed, 1342 insertions(+), 79 deletions(-) create mode 100644 client/src/components/Chat/footerMemory.ts create mode 100644 e2e/specs/mock/scenarios/cold-load-clearance.spec.ts create mode 100644 e2e/specs/mock/scenarios/composer-position.spec.ts create mode 100644 e2e/specs/mock/scenarios/configured-footer-clearance.spec.ts create mode 100644 e2e/specs/mock/scenarios/configured-footer.spec.ts create mode 100644 e2e/specs/mock/scenarios/contrast-switch-position.spec.ts create mode 100644 e2e/specs/mock/scenarios/empty-prompts-panel.spec.ts create mode 100644 e2e/specs/mock/scenarios/mobile-composer-edges.spec.ts create mode 100644 e2e/specs/mock/scenarios/stale-gutter.spec.ts create mode 100644 e2e/specs/mock/scenarios/temporary-chat-icon.spec.ts create mode 100644 e2e/specs/mock/scenarios/welcome-disclaimer.spec.ts diff --git a/client/package.json b/client/package.json index 669107f62bc..a8f070957c9 100644 --- a/client/package.json +++ b/client/package.json @@ -80,8 +80,8 @@ "js-cookie": "^3.0.5", "librechat-data-provider": "*", "lodash": "^4.17.23", - "lucide": "^0.525.0", - "lucide-react": "^0.525.0", + "lucide": "^0.528.0", + "lucide-react": "^0.528.0", "match-sorter": "^8.1.0", "mdast-util-directive": "^3.0.0", "mdast-util-from-markdown": "^2.0.1", diff --git a/client/src/components/Chat/ChatView.tsx b/client/src/components/Chat/ChatView.tsx index 96b0ad732a8..de800d6eb4d 100644 --- a/client/src/components/Chat/ChatView.tsx +++ b/client/src/components/Chat/ChatView.tsx @@ -8,6 +8,7 @@ import { Constants, buildTree } from 'librechat-data-provider'; import type { TChatProject } from 'librechat-data-provider'; import type { ChatFormValues } from '~/common'; import { + useScrollbarGutterSeed, useAddedResponse, useResumeOnLoad, useAdaptiveSSE, @@ -20,13 +21,13 @@ import ApprovalProvider from './Messages/Content/ApprovalContext'; import ConversationStarters from './Input/ConversationStarters'; import { pendingApprovalActionFamily } from './approval/state'; import { useGetMessagesByConvoId } from '~/data-provider'; +import Footer, { useConfiguredFooter } from './Footer'; import { AskAnswerHostProvider } from './ask/state'; import MessagesView from './Messages/MessagesView'; import Presentation from './Presentation'; import ChatForm from './Input/ChatForm'; import Landing from './Landing'; import Header from './Header'; -import Footer from './Footer'; import { cn } from '~/utils'; import store from '~/store'; @@ -51,6 +52,15 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje pendingApprovalActionFamily(conversationId ?? Constants.NEW_CONVO), ); + /** The welcome screen reserves the message column's scrollbar band before any + * column exists to measure it (see the column's class list below). */ + useScrollbarGutterSeed(); + + /** A conversation carries a footer only for configured content, and the + * composer's clearance has to account for the bar when it does — including + * while the config is still in flight, so a cold load does not jump. */ + const configuredFooter = useConfiguredFooter(); + const methods = useForm({ defaultValues: { text: '' }, }); @@ -101,6 +111,12 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje const isLandingPage = (!messagesTree || messagesTree.length === 0) && (conversationId === Constants.NEW_CONVO || !conversationId); + + /** A footer bar renders beneath the composer on the welcome screen always, and + * in a conversation when the deployment configured one. `present` already + * carries the remembered answer while the config is in flight, so this is the + * same value before and after it resolves. */ + const footerBelow = isLandingPage || configuredFooter.present; const isNavigating = (!messagesTree || messagesTree.length === 0) && conversationId != null; const isProjectLandingPage = isLandingPage && project != null; @@ -151,7 +167,15 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje className={cn( 'flex flex-col', isLandingPage - ? 'flex-1 items-center justify-end sm:justify-center' + ? /* The gutter is reserved once per state, wherever the + centring happens. A conversation centres the composer + inside the band below, against a message column that + holds the scrollbar band back; the landing page centres + this whole column instead, greeting and composer + together, so it holds the same band back here. Without + it the composer lands 4px right of where a conversation + puts it and slides sideways on the way in. */ + 'scrollbar-gutter-spacer flex-1 items-center justify-end sm:justify-center' : 'h-full overflow-y-auto', )} > @@ -181,9 +205,15 @@ function ChatView({ index = 0, project }: { index?: number; project?: TChatProje index={index} placeholder={chatFormPlaceholder} project={isProjectLandingPage ? project : undefined} + isLandingPage={isLandingPage} + footerBelow={footerBelow} + centerFormOnLanding={centerFormOnLanding} /> )} - {!isLandingPage &&