From 57a6b466455da1800c8cc08370a5febbe3bc8165 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Tue, 4 Aug 2026 09:07:01 -0400 Subject: [PATCH] feat: add persisted content filter safeguards --- api/server/controllers/agents/client.test.js | 22 + api/server/routes/__tests__/convos.spec.js | 14 + api/server/routes/__tests__/share.spec.js | 56 + .../routes/assistants/contentFilter.spec.js | 28 + api/server/routes/convos.js | 9 + api/server/routes/share.js | 14 +- api/server/utils/import/fork.js | 34 +- api/server/utils/import/fork.spec.js | 51 + api/server/utils/import/importBatchBuilder.js | 110 +- .../utils/import/importBatchBuilder.spec.js | 85 + .../utils/import/importConversations.js | 9 +- .../utils/import/importConversations.spec.js | 38 + e2e/config/librechat.e2e.yaml | 4 + e2e/playwright.config.mock.ts | 19 + e2e/setup/fake-assistants-server.js | 517 ++++ e2e/specs/mock/content-filters.helpers.ts | 336 +++ .../mock/content-filters.persisted.spec.ts | 2094 +++++++++++++++++ .../mock/content-filters.submissions.spec.ts | 888 +++++++ librechat.example.yaml | 25 +- packages/api/src/agents/handlers.ts | 4 +- packages/api/src/agents/initialize.ts | 2 +- .../src/middleware/modelBoundContent.spec.ts | 170 ++ .../api/src/middleware/modelBoundContent.ts | 75 +- packages/api/src/protection/index.ts | 1 + packages/api/src/protection/legacy.spec.ts | 57 + packages/api/src/protection/legacy.ts | 20 +- .../api/src/protection/provenance.spec.ts | 71 + packages/api/src/protection/provenance.ts | 140 ++ .../api/src/shared-links/protection.spec.ts | 160 +- packages/api/src/shared-links/protection.ts | 67 +- packages/data-provider/src/filters.spec.ts | 28 + packages/data-provider/src/filters.ts | 14 +- packages/data-schemas/src/app/service.spec.ts | 17 + .../data-schemas/src/methods/message.spec.ts | 120 + packages/data-schemas/src/methods/message.ts | 17 +- 35 files changed, 5164 insertions(+), 152 deletions(-) create mode 100644 e2e/setup/fake-assistants-server.js create mode 100644 e2e/specs/mock/content-filters.helpers.ts create mode 100644 e2e/specs/mock/content-filters.persisted.spec.ts create mode 100644 e2e/specs/mock/content-filters.submissions.spec.ts create mode 100644 packages/api/src/protection/provenance.spec.ts create mode 100644 packages/api/src/protection/provenance.ts diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index aec7e89d32..2cb791332e 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -3275,6 +3275,28 @@ describe('AgentClient - titleConvo', () => { expect(result).toBeUndefined(); expect(mockProcessMemory).not.toHaveBeenCalled(); }); + + it('should contain automatic memory rejection and log only bounded metadata', async () => { + const { HumanMessage } = require('@librechat/agents/langchain/messages'); + const { logger } = require('@librechat/data-schemas'); + const sensitiveValue = 'PRIVATE-MEMORY-REJECTION-CONTENT'; + const contentFilterError = new Error(sensitiveValue); + contentFilterError.code = 'content_filter_block'; + mockProcessMemory.mockRejectedValueOnce(contentFilterError); + const errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => logger); + + try { + await expect(client.runMemory([new HumanMessage('Safe message')])).resolves.toBeUndefined(); + + expect(mockProcessMemory).toHaveBeenCalledTimes(1); + expect(errorSpy).toHaveBeenCalledWith('Memory Agent failed to process memory', { + type: 'Error', + }); + expect(JSON.stringify(errorSpy.mock.calls)).not.toContain(sensitiveValue); + } finally { + errorSpy.mockRestore(); + } + }); }); describe('getMessagesForConversation - mapMethod and mapCondition', () => { diff --git a/api/server/routes/__tests__/convos.spec.js b/api/server/routes/__tests__/convos.spec.js index dc598f4b5f..41d2c9b35b 100644 --- a/api/server/routes/__tests__/convos.spec.js +++ b/api/server/routes/__tests__/convos.spec.js @@ -46,6 +46,11 @@ describe('Convos Routes', () => { app.use((req, res, next) => { req.user = { id: 'test-user-123', role: 'USER' }; req.config = { + messageFilter: { + pii: { + starterPatterns: ['sk_prefix'], + }, + }, filters: { messages: { pii: { @@ -87,6 +92,9 @@ describe('Convos Routes', () => { }, }, }, + legacyPii: { + starterPatterns: ['sk_prefix'], + }, }); }); @@ -140,6 +148,9 @@ describe('Convos Routes', () => { }, }, }, + legacyPii: { + starterPatterns: ['sk_prefix'], + }, }), ); }); @@ -193,6 +204,9 @@ describe('Convos Routes', () => { }, }, }, + legacyPii: { + starterPatterns: ['sk_prefix'], + }, }), ); }); diff --git a/api/server/routes/__tests__/share.spec.js b/api/server/routes/__tests__/share.spec.js index 5052401d14..6100a48795 100644 --- a/api/server/routes/__tests__/share.spec.js +++ b/api/server/routes/__tests__/share.spec.js @@ -171,6 +171,7 @@ const buildApp = ({ retentionMode = RetentionMode.TEMPORARY, user = { id: 'user-123' }, filters, + messageFilter, } = {}) => { const app = express(); app.use(express.json()); @@ -179,6 +180,7 @@ const buildApp = ({ req.config = { interfaceConfig: { retentionMode }, ...(filters == null ? {} : { filters }), + ...(messageFilter == null ? {} : { messageFilter }), }; next(); }); @@ -311,6 +313,60 @@ describe('share routes', () => { }); }); + it('threads a legacy-only detector into strict shared-content preflight', async () => { + const strictFilters = { + messages: { unattributedAssistantContent: 'inspect' }, + }; + const legacyPii = { + starterPatterns: [], + customPatterns: [{ id: 'private', label: 'private value', regex: 'PRIVATE-[A-Z]+' }], + }; + const share = { + shareId: 'share-123', + title: 'Protected Conversation', + messages: [{ isCreatedByUser: false, role: 'assistant', text: 'safe model output' }], + }; + mockSharedMessagesResult(share); + + const response = await request( + buildApp({ filters: strictFilters, messageFilter: { pii: legacyPii } }), + ).get('/api/share/share-123'); + + expect(response.status).toBe(200); + expect(mockAssertConversationContentAllowed).toHaveBeenCalledWith( + strictFilters, + { + conversations: [{ title: share.title }], + messages: share.messages, + }, + { legacyPii }, + ); + }); + + it('keeps shared-message preflight active with only legacy message filtering', async () => { + const legacyPii = { starterPatterns: ['sk_prefix'] }; + const share = { + shareId: 'share-123', + title: 'Protected Conversation', + messages: [{ isCreatedByUser: true, text: 'safe user input' }], + }; + mockSharedMessagesResult(share); + + const response = await request(buildApp({ messageFilter: { pii: legacyPii } })).get( + '/api/share/share-123', + ); + + expect(response.status).toBe(200); + expect(mockAssertConversationContentAllowed).toHaveBeenCalledWith( + undefined, + { + conversations: [{ title: share.title }], + messages: share.messages, + }, + { legacyPii }, + ); + }); + it('returns a raw-free 400 when existing shared metadata fails current policy', async () => { const error = Object.assign(new Error('PRIVATE-SENTINEL'), { code: 'content_filter_block', diff --git a/api/server/routes/assistants/contentFilter.spec.js b/api/server/routes/assistants/contentFilter.spec.js index 0ab275a77c..43d2b62d49 100644 --- a/api/server/routes/assistants/contentFilter.spec.js +++ b/api/server/routes/assistants/contentFilter.spec.js @@ -131,6 +131,34 @@ describe('assistant route content filters', () => { expect(mockCreateAssistantV1).not.toHaveBeenCalled(); }); + it.each([ + ['V1', './v1', mockPatchAssistantV1], + ['V2', './v2', mockPatchAssistantV2], + ])( + 'allows a safe partial assistant patch on %s while instruction filtering is active', + async (_version, route, controller) => { + const module = require(route); + const app = createApp(module.v1 ?? module, { + filters: { + agentInstructions: { + pii: { + fields: ['instructions'], + starterPatterns: [], + customPatterns: [customPattern], + }, + }, + }, + }); + + const response = await request(app) + .patch('/assistant-id') + .send({ description: 'Safe remediation metadata edit.' }); + + expect(response.status).toBe(200); + expect(controller).toHaveBeenCalledTimes(1); + }, + ); + it('blocks function parameter schemas on V2 patch before the controller', async () => { const app = createApp(require('./v2'), { filters: { diff --git a/api/server/routes/convos.js b/api/server/routes/convos.js index e946c2169c..9a74ad0757 100644 --- a/api/server/routes/convos.js +++ b/api/server/routes/convos.js @@ -349,6 +349,9 @@ router.post( userRole: req.user.role, interfaceConfig: req.config?.interfaceConfig, filters: req.config?.filters, + ...(req.config?.messageFilter?.pii == null + ? {} + : { legacyPii: req.config.messageFilter.pii }), }); res.status(201).json({ message: 'Conversation(s) imported successfully' }); } catch (error) { @@ -382,6 +385,9 @@ router.post('/fork', forkIpLimiter, forkUserLimiter, configMiddleware, async (re splitAtTarget, option, filters: req.config?.filters, + ...(req.config?.messageFilter?.pii == null + ? {} + : { legacyPii: req.config.messageFilter.pii }), }); res.json(result); @@ -409,6 +415,9 @@ router.post( conversationId, title, filters: req.config?.filters, + ...(req.config?.messageFilter?.pii == null + ? {} + : { legacyPii: req.config.messageFilter.pii }), }); res.status(201).json(result); } catch (error) { diff --git a/api/server/routes/share.js b/api/server/routes/share.js index 1b738fe209..99f0b5f533 100644 --- a/api/server/routes/share.js +++ b/api/server/routes/share.js @@ -111,7 +111,8 @@ const omitUnsharedMessageFiles = (messages) => })); const createShareContentPreflight = (filters, options = {}) => { - if (filters == null) { + const legacyPii = options.legacyPii; + if (filters == null && legacyPii == null) { return undefined; } return async ({ title, messages, shareId }) => { @@ -126,11 +127,16 @@ const createShareContentPreflight = (filters, options = {}) => { : messages, }; if (options.user == null) { - await assertConversationContentAllowed(filters, snapshot); + if (legacyPii == null) { + await assertConversationContentAllowed(filters, snapshot); + } else { + await assertConversationContentAllowed(filters, snapshot, { legacyPii }); + } } else { await assertConversationContentAllowed(filters, snapshot, { user: options.user, getFiles, + ...(legacyPii == null ? {} : { legacyPii }), }); } if (!inspectSharedFileMetadata) { @@ -320,6 +326,7 @@ if (allowSharedLinks) { try { const contentPreflight = createShareContentPreflight(req.config?.filters, { sharedFileMetadata: true, + legacyPii: req.config?.messageFilter?.pii, }); const share = await getSharedMessages(req.params.shareId, req.shareResourceId, { // Viewer-independent: the per-link choice (stored on the share) decides @@ -364,6 +371,7 @@ if (allowSharedLinks) { snapshotFiles: !isFileSnapshotKillSwitchActive(), sharedContentPreflight: createShareContentPreflight(req.config?.filters, { sharedFileMetadata: true, + legacyPii: req.config?.messageFilter?.pii, }), }); if (!result) { @@ -559,6 +567,7 @@ router.post( user: req.user, sharedFileMetadata: true, sharedFileMetadataFiles: false, + legacyPii: req.config?.messageFilter?.pii, }); const created = await createSharedLink( @@ -611,6 +620,7 @@ router.patch('/:shareId', requireJwtAuth, configMiddleware, async (req, res) => user: req.user, sharedFileMetadata: true, sharedFileMetadataFiles: false, + legacyPii: req.config?.messageFilter?.pii, }); const updatedShare = await updateSharedLink( req.user.id, diff --git a/api/server/utils/import/fork.js b/api/server/utils/import/fork.js index eaae4f93d2..a1740ed050 100644 --- a/api/server/utils/import/fork.js +++ b/api/server/utils/import/fork.js @@ -82,7 +82,8 @@ function cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder) { * @param {boolean} [params.splitAtTarget=false] - Optional flag for splitting the messages at the target message level. * @param {string} [params.latestMessageId] - latestMessageId - Required if splitAtTarget is true. * @param {object} [params.filters] - Source-aware content filters applied before cloned records are persisted. - * @param {(userId: string, interfaceConfig?: object, filters?: object) => ImportBatchBuilder} [params.builderFactory] - Optional factory function for creating an ImportBatchBuilder instance. + * @param {object} [params.legacyPii] - Legacy messageFilter.pii applied before cloned records are persisted. + * @param {(userId: string, interfaceConfig?: object, filters?: object, legacyPii?: object) => ImportBatchBuilder} [params.builderFactory] - Optional factory function for creating an ImportBatchBuilder instance. * @returns {Promise} The response after forking the conversation. */ async function forkConversation({ @@ -95,6 +96,7 @@ async function forkConversation({ splitAtTarget = false, latestMessageId, filters, + legacyPii, builderFactory = createImportBatchBuilder, }) { try { @@ -112,7 +114,10 @@ async function forkConversation({ targetMessageId = latestMessageId; } - const importBatchBuilder = builderFactory(requestUserId, undefined, filters); + const importBatchBuilder = + legacyPii == null + ? builderFactory(requestUserId, undefined, filters) + : builderFactory(requestUserId, undefined, filters, legacyPii); importBatchBuilder.startConversation(originalConvo.endpoint ?? EModelEndpoint.openAI); let messagesToClone = []; @@ -394,7 +399,7 @@ function stripSharedFileIds(message) { * @param {number} [params.targetMessageIndex] - Index, within the shared payload, of the message at the tip of the branch the viewer has active. When set, only the direct path to that message is cloned so the fork continues the branch that was actually shown rather than the newest sibling. An index is used (not id or `createdAt`) because shared ids are re-anonymized per request while `getSharedMessages` returns a deterministic, stable order, so the same index resolves to the same message on the server. * @param {boolean} [params.snapshotFiles] - When `false`, file/attachment metadata is omitted from the cloned messages, mirroring the GET share route so the global shared-file kill switch is honored. * @param {(snapshot: object) => Promise} [params.sharedContentPreflight] - Reapplies current policy to the exact public projection before a legacy shared-file snapshot is persisted. - * @param {(userId: string, interfaceConfig?: object, filters?: object) => ImportBatchBuilder} [params.builderFactory] - Optional factory function for creating an ImportBatchBuilder instance. + * @param {(userId: string, interfaceConfig?: object, filters?: object, legacyPii?: object) => ImportBatchBuilder} [params.builderFactory] - Optional factory function for creating an ImportBatchBuilder instance. * @param {(options: object) => Promise} [params.loadAppConfig] - Resolves the app config; injectable for tests. Called inside the requesting user's tenant context so retention policy is read from the viewer's tenant, not the share owner's. * @returns {Promise} The new conversation and messages, or null when the share is missing or empty. */ @@ -477,11 +482,15 @@ async function forkSharedConversation({ // can actually use; hard-coding OpenAI breaks the first follow-up message on // deployments that don't expose it. const { endpoint, model } = await resolveImportDefaultEndpoint({ requestUserId, userRole }); - const importBatchBuilder = builderFactory( - requestUserId, - appConfig?.interfaceConfig, - appConfig?.filters, - ); + const importBatchBuilder = + appConfig?.messageFilter?.pii == null + ? builderFactory(requestUserId, appConfig?.interfaceConfig, appConfig?.filters) + : builderFactory( + requestUserId, + appConfig?.interfaceConfig, + appConfig?.filters, + appConfig.messageFilter.pii, + ); importBatchBuilder.startConversation(endpoint); cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder); @@ -514,7 +523,8 @@ async function forkSharedConversation({ * @param {string} params.conversationId - The ID of the conversation to duplicate. * @param {string} [params.title] - Optional title override for the duplicate. * @param {object} [params.filters] - Source-aware content filters applied before cloned records are persisted. - * @param {(userId: string, interfaceConfig?: object, filters?: object) => ImportBatchBuilder} [params.builderFactory] - Optional factory function for creating an ImportBatchBuilder instance. + * @param {object} [params.legacyPii] - Legacy messageFilter.pii applied before cloned records are persisted. + * @param {(userId: string, interfaceConfig?: object, filters?: object, legacyPii?: object) => ImportBatchBuilder} [params.builderFactory] - Optional factory function for creating an ImportBatchBuilder instance. * @returns {Promise<{ conversation: TConversation, messages: TMessage[] }>} The duplicated conversation and messages. */ async function duplicateConversation({ @@ -522,6 +532,7 @@ async function duplicateConversation({ conversationId, title, filters, + legacyPii, builderFactory = createImportBatchBuilder, }) { const originalConvo = await getConvo(userId, conversationId); @@ -539,7 +550,10 @@ async function duplicateConversation({ originalMessages[originalMessages.length - 1].messageId, ); - const importBatchBuilder = builderFactory(userId, undefined, filters); + const importBatchBuilder = + legacyPii == null + ? builderFactory(userId, undefined, filters) + : builderFactory(userId, undefined, filters, legacyPii); importBatchBuilder.startConversation(originalConvo.endpoint ?? EModelEndpoint.openAI); cloneMessagesWithTimestamps(messagesToClone, importBatchBuilder); diff --git a/api/server/utils/import/fork.spec.js b/api/server/utils/import/fork.spec.js index 68882c7f5a..625d422ba4 100644 --- a/api/server/utils/import/fork.spec.js +++ b/api/server/utils/import/fork.spec.js @@ -298,6 +298,38 @@ describe('forkConversation', () => { expect(bulkSaveMessages).not.toHaveBeenCalled(); expect(bulkIncrementTagCounts).not.toHaveBeenCalled(); }); + + test('blocks unattributed assistant prose with strict legacy-only policy before forking', async () => { + getMessages.mockResolvedValue([ + { + messageId: 'private-assistant-message', + parentMessageId: Constants.NO_PARENT, + isCreatedByUser: false, + text: 'Legacy unattributed PRIVATE-SENTINEL', + createdAt: '2021-01-01', + }, + ]); + + await expect( + forkConversation({ + originalConvoId: 'abc123', + targetMessageId: 'private-assistant-message', + requestUserId: 'user1', + option: ForkOptions.DIRECT_PATH, + filters: { messages: { unattributedAssistantContent: 'inspect' } }, + legacyPii: { + starterPatterns: [], + customPatterns: [{ id: 'private', label: 'private value', regex: 'PRIVATE-[A-Z]+' }], + }, + }), + ).rejects.toMatchObject({ + code: 'content_filter_block', + body: expect.objectContaining({ source: 'message', field: 'text' }), + }); + + expect(bulkSaveConvos).not.toHaveBeenCalled(); + expect(bulkSaveMessages).not.toHaveBeenCalled(); + }); }); describe('duplicateConversation', () => { @@ -682,6 +714,25 @@ describe('forkSharedConversation', () => { expect(builderFactory).toHaveBeenCalledWith('user1', interfaceConfig, undefined); }); + test('passes strict attribution and legacy message policy to the shared-fork builder', async () => { + const filters = { messages: { unattributedAssistantContent: 'inspect' } }; + const legacyPii = { starterPatterns: [] }; + const loadAppConfig = jest.fn().mockResolvedValue({ + filters, + messageFilter: { pii: legacyPii }, + }); + const builderFactory = jest.fn((...args) => createImportBatchBuilder(...args)); + + await forkSharedConversation({ + shareId: 'share123', + requestUserId: 'user1', + loadAppConfig, + builderFactory, + }); + + expect(builderFactory).toHaveBeenCalledWith('user1', undefined, filters, legacyPii); + }); + test('should resolve the app config under the requesting user tenant', async () => { const { tenantStorage, getTenantId } = require('@librechat/data-schemas'); let tenantDuringConfigLoad; diff --git a/api/server/utils/import/importBatchBuilder.js b/api/server/utils/import/importBatchBuilder.js index 84c0045ff2..23222e6001 100644 --- a/api/server/utils/import/importBatchBuilder.js +++ b/api/server/utils/import/importBatchBuilder.js @@ -3,9 +3,11 @@ const { ContentFilterError, UninspectableFileError, assertModelBoundContent, + createConfiguredContentInspector, extractConversationImportContent, getBlockedOpaqueFileField, getContentTraversalFragments, + getUserSubmittedPathState, inspectContent, isContentTraversalProtected, isContentTraversalLimitError, @@ -31,10 +33,11 @@ const { FALLBACK_MODEL_BY_ENDPOINT } = require('./defaults'); * @param {string} requestUserId - The ID of the user making the request. * @param {object} [interfaceConfig] - Runtime interface config for import retention. * @param {object} [filters] - Source-aware content filters for submitted imports. + * @param {object} [legacyPii] - Legacy messageFilter.pii configuration. * @returns {ImportBatchBuilder} - The newly created ImportBatchBuilder instance. */ -function createImportBatchBuilder(requestUserId, interfaceConfig, filters) { - return new ImportBatchBuilder(requestUserId, interfaceConfig, filters); +function createImportBatchBuilder(requestUserId, interfaceConfig, filters, legacyPii) { + return new ImportBatchBuilder(requestUserId, interfaceConfig, filters, legacyPii); } /** @@ -47,6 +50,7 @@ function createImportBatchBuilder(requestUserId, interfaceConfig, filters) { * @param {{ id?: string, tenantId?: string }} [resolutionContext.user] - Snapshot owner. * @param {Function} [resolutionContext.getFiles] - Canonical file lookup. * @param {object[]} [resolutionContext.trustedLiveFiles] - Server-hydrated canonical rows. + * @param {object} [resolutionContext.legacyPii] - Legacy messageFilter.pii configuration. * @returns {Promise} * @throws {ContentFilterError|UninspectableFileError|import('@librechat/api').ContentTraversalLimitError} */ @@ -55,37 +59,40 @@ async function assertConversationContentAllowed( { conversations, messages }, resolutionContext = {}, ) { - if (filters == null) { + const { legacyPii } = resolutionContext; + if (filters == null && legacyPii == null) { return; } - let conversationFragments; - let conversationTraversalError; - try { - conversationFragments = extractConversationImportContent({ - conversations, - messages: [], - }); - conversationFragments = [...conversationFragments]; - } catch (error) { - if (!isContentTraversalLimitError(error)) { - throw error; + if (filters != null) { + let conversationFragments; + let conversationTraversalError; + try { + conversationFragments = extractConversationImportContent({ + conversations, + messages: [], + }); + conversationFragments = [...conversationFragments]; + } catch (error) { + if (!isContentTraversalLimitError(error)) { + throw error; + } + conversationFragments = getContentTraversalFragments(error); + conversationTraversalError = error; + } + const conversationFinding = inspectContent(conversationFragments, { filters }); + if (conversationFinding != null) { + throw new ContentFilterError(conversationFinding); + } + if ( + conversationTraversalError != null && + isContentTraversalProtected({ + error: conversationTraversalError, + filters, + }) + ) { + throw conversationTraversalError; } - conversationFragments = getContentTraversalFragments(error); - conversationTraversalError = error; - } - const conversationFinding = inspectContent(conversationFragments, { filters }); - if (conversationFinding != null) { - throw new ContentFilterError(conversationFinding); - } - if ( - conversationTraversalError != null && - isContentTraversalProtected({ - error: conversationTraversalError, - filters, - }) - ) { - throw conversationTraversalError; } /** @@ -96,7 +103,7 @@ async function assertConversationContentAllowed( */ let storedMessages = messages; let resolvedFiles = []; - if (filters.files?.pii != null) { + if (filters?.files?.pii != null) { const fileInspection = await resolveCanonicalFileReferences({ filters, input: messages, @@ -119,6 +126,7 @@ async function assertConversationContentAllowed( try { assertModelBoundContent({ filters, + legacyPii, storedMessages: [message], }); } catch (error) { @@ -126,22 +134,20 @@ async function assertConversationContentAllowed( throw error; } - const explicitPaths = Array.isArray(message.userSubmittedPaths) - ? message.userSubmittedPaths.filter( - (path) => typeof path === 'string' && path.startsWith('/'), - ) - : []; - if (Array.isArray(message.content)) { - for (let index = 0; index < message.content.length; index++) { - if (message.content[index]?.type === 'steer') { - explicitPaths.push(`/content/${index}`); - } - } - } + const submittedPathState = getUserSubmittedPathState(message); + const explicitPaths = submittedPathState.paths; + const isStrictUnattributedAssistant = + filters?.messages?.unattributedAssistantContent === 'inspect' && + typeof message.isUserSubmitted !== 'boolean' && + explicitPaths.length === 0 && + (message.isCreatedByUser === false || + message.role === 'assistant' || + message.role === 'ai'); const isWholeMessageSubmitted = message.isCreatedByUser === true || message.isUserSubmitted === true || - new Set(explicitPaths).size > 256; + submittedPathState.overflowed || + isStrictUnattributedAssistant; const relevantFragments = getContentTraversalFragments(error).filter( (fragment) => isWholeMessageSubmitted || @@ -150,7 +156,9 @@ async function assertConversationContentAllowed( (path) => fragment.path === path || fragment.path.startsWith(`${path}/`), ), ); - const messageFinding = inspectContent(relevantFragments, { filters }); + const messageFinding = createConfiguredContentInspector({ filters, legacyPii })?.inspect( + relevantFragments, + ); if (messageFinding != null) { throw new ContentFilterError(messageFinding); } @@ -162,13 +170,14 @@ async function assertConversationContentAllowed( } } - const traversalFilters = - isWholeMessageSubmitted || explicitPaths.length > 0 - ? filters - : { ...filters, messages: undefined }; + let traversalFilters = filters; + if (!isWholeMessageSubmitted && explicitPaths.length === 0 && filters != null) { + traversalFilters = { ...filters, messages: undefined }; + } if ( isNestedMessageTraversalProtected({ filters: traversalFilters, + legacyPii: isWholeMessageSubmitted || explicitPaths.length > 0 ? legacyPii : undefined, roles: isWholeMessageSubmitted || explicitPaths.length > 0 ? ['user'] : [message.role, 'tool'], }) @@ -188,11 +197,13 @@ class ImportBatchBuilder { * @param {string} requestUserId - The ID of the user making the import request. * @param {object} [interfaceConfig] - Runtime interface config for import retention. * @param {object} [filters] - Source-aware content filters for submitted imports. + * @param {object} [legacyPii] - Legacy messageFilter.pii configuration. */ - constructor(requestUserId, interfaceConfig, filters) { + constructor(requestUserId, interfaceConfig, filters, legacyPii) { this.requestUserId = requestUserId; this.interfaceConfig = interfaceConfig; this.filters = filters; + this.legacyPii = legacyPii; this.conversations = []; this.messages = []; this.retentionFields = undefined; @@ -312,6 +323,7 @@ class ImportBatchBuilder { { user: { id: this.requestUserId }, getFiles, + ...(this.legacyPii == null ? {} : { legacyPii: this.legacyPii }), }, ); diff --git a/api/server/utils/import/importBatchBuilder.spec.js b/api/server/utils/import/importBatchBuilder.spec.js index 71dfa41a56..926060f459 100644 --- a/api/server/utils/import/importBatchBuilder.spec.js +++ b/api/server/utils/import/importBatchBuilder.spec.js @@ -98,6 +98,91 @@ describe('ImportBatchBuilder content filtering', () => { expect(bulkSaveMessages).toHaveBeenCalledTimes(1); }); + it('applies strict legacy attribution with a legacy-only message detector', async () => { + const builder = new ImportBatchBuilder( + 'user-123', + undefined, + { messages: { unattributedAssistantContent: 'inspect' } }, + { + starterPatterns: [], + customPatterns: [pattern], + }, + ); + builder.startConversation(EModelEndpoint.openAI); + builder.saveMessage({ + sender: 'Assistant', + isCreatedByUser: false, + text: 'Legacy unattributed IMPORT-SECRET', + }); + builder.finishConversation('safe title', new Date('2026-01-01T00:00:00.000Z')); + + await expect(builder.saveBatch()).rejects.toMatchObject({ + body: { + error: 'content_filter_block', + source: 'message', + field: 'text', + }, + }); + expect(bulkSaveMessages).not.toHaveBeenCalled(); + }); + + it('keeps strict attribution in the traversal fallback for ineffective provenance paths', async () => { + const builder = new ImportBatchBuilder('user-123', undefined, { + ...filtersFor('messages', ['text']), + messages: { + ...filtersFor('messages', ['text']).messages, + unattributedAssistantContent: 'inspect', + }, + }); + builder.startConversation(EModelEndpoint.openAI); + builder.saveMessage({ + sender: 'Assistant', + role: 'assistant', + isCreatedByUser: false, + text: 'Legacy unattributed IMPORT-SECRET', + userSubmittedPaths: ['/messageId'], + }); + builder.finishConversation('safe title', new Date('2026-01-01T00:00:00.000Z')); + mockAssertModelBoundContent.mockImplementationOnce(() => { + throw new actualApi.ContentTraversalLimitError([ + { + id: 'stored-message.text', + path: '/text', + text: 'Legacy unattributed IMPORT-SECRET', + source: 'message', + field: 'text', + format: 'plain', + treatment: 'inspect_only', + provenance: 'user', + }, + ]); + }); + + await expect(builder.saveBatch()).rejects.toMatchObject({ + body: { + error: 'content_filter_block', + source: 'message', + field: 'text', + }, + }); + expect(bulkSaveMessages).not.toHaveBeenCalled(); + }); + + it('keeps legacy-only filtering active for explicitly submitted imported rows', async () => { + const builder = new ImportBatchBuilder('user-123', undefined, undefined, { + starterPatterns: [], + customPatterns: [pattern], + }); + builder.startConversation(EModelEndpoint.openAI); + builder.addUserMessage('Imported IMPORT-SECRET'); + builder.finishConversation('safe title', new Date('2026-01-01T00:00:00.000Z')); + + await expect(builder.saveBatch()).rejects.toMatchObject({ + body: expect.objectContaining({ source: 'message', field: 'text' }), + }); + expect(bulkSaveMessages).not.toHaveBeenCalled(); + }); + it('blocks provenance-marked assistant content while ignoring adjacent model prose', async () => { const builder = new ImportBatchBuilder( 'user-123', diff --git a/api/server/utils/import/importConversations.js b/api/server/utils/import/importConversations.js index 31cd73a9f5..bbabcdf728 100644 --- a/api/server/utils/import/importConversations.js +++ b/api/server/utils/import/importConversations.js @@ -8,10 +8,10 @@ const maxFileSize = resolveImportMaxFileSize(); /** * Job definition for importing a conversation. - * @param {{ filepath: string, requestUserId: string, userRole?: string, interfaceConfig?: object, filters?: object }} job + * @param {{ filepath: string, requestUserId: string, userRole?: string, interfaceConfig?: object, filters?: object, legacyPii?: object }} job */ const importConversations = async (job) => { - const { filepath, requestUserId, userRole, interfaceConfig, filters } = job; + const { filepath, requestUserId, userRole, interfaceConfig, filters, legacyPii } = job; try { logger.debug(`user: ${requestUserId} | Importing conversation(s) from file...`); @@ -28,7 +28,10 @@ const importConversations = async (job) => { await importer( jsonData, requestUserId, - (userId) => createImportBatchBuilder(userId, interfaceConfig, filters), + (userId) => + legacyPii == null + ? createImportBatchBuilder(userId, interfaceConfig, filters) + : createImportBatchBuilder(userId, interfaceConfig, filters, legacyPii), userRole, ); logger.debug(`user: ${requestUserId} | Finished importing conversations`); diff --git a/api/server/utils/import/importConversations.spec.js b/api/server/utils/import/importConversations.spec.js index 1e3875484e..711939a37d 100644 --- a/api/server/utils/import/importConversations.spec.js +++ b/api/server/utils/import/importConversations.spec.js @@ -81,4 +81,42 @@ describe('importConversations content filtering', () => { expect(bulkSaveMessages).not.toHaveBeenCalled(); expect(bulkIncrementTagCounts).not.toHaveBeenCalled(); }); + + it('threads strict attribution with a legacy-only detector into imported rows', async () => { + getImporter.mockReturnValue(async (_jsonData, requestUserId, builderFactory) => { + const builder = builderFactory(requestUserId); + builder.startConversation(EModelEndpoint.openAI); + builder.saveMessage({ + sender: 'Assistant', + isCreatedByUser: false, + text: 'Legacy unattributed IMPORT-SECRET', + }); + builder.finishConversation('safe title'); + await builder.saveBatch(); + }); + + await expect( + importConversations({ + filepath, + requestUserId: 'user-123', + filters: { messages: { unattributedAssistantContent: 'inspect' } }, + legacyPii: { + starterPatterns: [], + customPatterns: [ + { + id: 'import-secret', + label: 'restricted import value', + regex: 'IMPORT-SECRET', + }, + ], + }, + }), + ).rejects.toMatchObject({ + code: 'content_filter_block', + body: expect.objectContaining({ source: 'message', field: 'text' }), + }); + + await expect(fs.stat(filepath)).rejects.toMatchObject({ code: 'ENOENT' }); + expect(bulkSaveMessages).not.toHaveBeenCalled(); + }); }); diff --git a/e2e/config/librechat.e2e.yaml b/e2e/config/librechat.e2e.yaml index b8ebab2851..a285f696db 100644 --- a/e2e/config/librechat.e2e.yaml +++ b/e2e/config/librechat.e2e.yaml @@ -24,6 +24,10 @@ mcpSettings: allowedDomains: - https://allowed.example.com +actions: + allowedDomains: + - example.com + mcpServers: e2e-memory: type: stdio diff --git a/e2e/playwright.config.mock.ts b/e2e/playwright.config.mock.ts index 9d40af1eed..35bd74b9c1 100644 --- a/e2e/playwright.config.mock.ts +++ b/e2e/playwright.config.mock.ts @@ -13,6 +13,8 @@ const labelServerPath = path.resolve(rootPath, 'e2e/setup/fake-label-server.js') * `writeRuntimeMockConfig` substitutes any override into the generated copy. */ const LABEL_PORT = process.env.E2E_LABEL_PORT || '8889'; const fakeModelHookPath = path.resolve(rootPath, 'e2e/setup/fake-model.js'); +const assistantsServerPath = path.resolve(rootPath, 'e2e/setup/fake-assistants-server.js'); +const ASSISTANTS_PORT = process.env.E2E_ASSISTANTS_PORT || '8890'; const configTemplatePath = path.resolve(rootPath, 'e2e/config/librechat.e2e.yaml'); const configPath = path.resolve(rootPath, 'e2e/.generated/librechat.e2e.yaml'); const reportPath = path.resolve(rootPath, 'e2e/playwright-report'); @@ -29,7 +31,10 @@ const vanillaOverrides = { OPENID_AUTO_REDIRECT: 'false', ALLOW_SOCIAL_LOGIN: 'false', ALLOW_SOCIAL_REGISTRATION: 'false', + ALLOW_SHARED_LINKS_PUBLIC: 'true', STREAM_KEEP_COMPLETED_JOBS: 'true', + FORK_IP_MAX: '100', + FORK_USER_MAX: '100', /** A local `.env` may enable balance enforcement, which `neutralizeCredentialEnv` * does not blank (not credential-shaped); the fresh e2e user has no balance * record, so every streaming spec would be refused with a token_balance @@ -43,6 +48,10 @@ const baseEnv = { DEPLOYMENT_SKILLS_DIR: deploymentSkillsPath, /** Loaded in-process by `@librechat/api`'s `createRun` to swap in a fake model. */ LIBRECHAT_TEST_RUN_HOOK: fakeModelHookPath, + /** The Assistants runtime uses the OpenAI SDK directly, outside the agents run hook. */ + ASSISTANTS_API_KEY: 'e2e-mock-assistants-key', + ASSISTANTS_BASE_URL: `http://127.0.0.1:${ASSISTANTS_PORT}/v1`, + ASSISTANTS_MODELS: 'gpt-4o-mini', ...vanillaOverrides, }; @@ -176,5 +185,15 @@ export default defineConfig({ timeout: 60_000, reuseExistingServer: false, }, + { + // Stateful provider-boundary fake for Assistant CRUD and streamed runs. + command: `node ${assistantsServerPath}`, + cwd: rootPath, + env: { ...process.env, E2E_ASSISTANTS_PORT: ASSISTANTS_PORT }, + url: `http://127.0.0.1:${ASSISTANTS_PORT}/`, + stdout: 'pipe', + timeout: 60_000, + reuseExistingServer: false, + }, ], }); diff --git a/e2e/setup/fake-assistants-server.js b/e2e/setup/fake-assistants-server.js new file mode 100644 index 0000000000..41a2b4014a --- /dev/null +++ b/e2e/setup/fake-assistants-server.js @@ -0,0 +1,517 @@ +/** + * Stateful OpenAI Assistants API fixture for credential-free mock e2e tests. + * + * This deliberately implements only the provider operations LibreChat uses for + * Assistant CRUD and a text-only streamed run. It is a provider-boundary fake: + * LibreChat's real routes, OpenAI SDK client, persistence, content preflights, + * and SSE handling all remain in the request path. + */ +const http = require('http'); +const { randomUUID } = require('crypto'); + +const PORT = Number(process.env.E2E_ASSISTANTS_PORT) || 8890; +const DEFAULT_REPLY = process.env.E2E_ASSISTANTS_REPLY || 'E2E mock assistant reply: pong'; +const MAX_BODY_BYTES = 1024 * 1024; + +const assistants = new Map(); +const threads = new Map(); +const runs = new Map(); +const requests = []; + +function now() { + return Math.floor(Date.now() / 1000); +} + +function createId(prefix) { + return `${prefix}_${randomUUID().replaceAll('-', '')}`; +} + +function readBody(req) { + return new Promise((resolve, reject) => { + let raw = ''; + req.setEncoding('utf8'); + req.on('data', (chunk) => { + raw += chunk; + if (Buffer.byteLength(raw) > MAX_BODY_BYTES) { + reject(new Error('Request body exceeds fixture limit')); + req.destroy(); + } + }); + req.on('end', () => { + if (!raw) { + resolve({}); + return; + } + try { + resolve(JSON.parse(raw)); + } catch { + reject(new Error('Request body must be valid JSON')); + } + }); + req.on('error', reject); + }); +} + +function sendJson(res, status, payload) { + const body = JSON.stringify(payload); + res.writeHead(status, { + 'Content-Type': 'application/json', + 'Content-Length': Buffer.byteLength(body), + }); + res.end(body); +} + +function sendError(res, status, message) { + sendJson(res, status, { + error: { + message, + type: status === 404 ? 'invalid_request_error' : 'e2e_fixture_error', + param: null, + code: null, + }, + }); +} + +function asTextContent(content) { + if (typeof content === 'string') { + return [{ type: 'text', text: { value: content, annotations: [] } }]; + } + if (!Array.isArray(content)) { + return []; + } + return content.map((part) => { + if (part?.type !== 'text') { + return part; + } + if (typeof part.text === 'string') { + return { ...part, text: { value: part.text, annotations: [] } }; + } + return { + ...part, + text: { + value: part.text?.value ?? '', + annotations: part.text?.annotations ?? [], + }, + }; + }); +} + +function createMessage({ + threadId, + role, + content, + assistantId = null, + runId = null, + metadata = {}, +}) { + return { + id: createId('msg'), + object: 'thread.message', + created_at: now(), + assistant_id: assistantId, + thread_id: threadId, + run_id: runId, + role, + content: asTextContent(content), + attachments: [], + metadata, + status: 'completed', + incomplete_details: null, + completed_at: now(), + incomplete_at: null, + }; +} + +function createAssistant(body) { + const createdAt = now(); + return { + ...body, + id: createId('asst'), + object: 'assistant', + created_at: createdAt, + name: body.name ?? null, + description: body.description ?? null, + instructions: body.instructions ?? null, + model: body.model, + tools: body.tools ?? [], + tool_resources: body.tool_resources ?? {}, + metadata: body.metadata ?? {}, + response_format: body.response_format ?? 'auto', + temperature: body.temperature ?? 1, + top_p: body.top_p ?? 1, + }; +} + +function listResponse(data) { + return { + object: 'list', + data, + first_id: data[0]?.id ?? null, + last_id: data[data.length - 1]?.id ?? null, + has_more: false, + }; +} + +function assistantReply(thread) { + const latestUserMessage = [...thread.messages] + .reverse() + .find((message) => message.role === 'user'); + const text = latestUserMessage?.content + ?.filter((part) => part?.type === 'text') + .map((part) => part.text?.value ?? '') + .join('\n'); + const marker = text?.match(/E2E_REPLY:([A-Za-z0-9._-]+)/)?.[1]; + return marker ? `E2E assistant reply ${marker}` : DEFAULT_REPLY; +} + +function runObject({ id, threadId, assistant, status, usage = null }) { + const timestamp = now(); + return { + id, + object: 'thread.run', + created_at: timestamp, + assistant_id: assistant.id, + thread_id: threadId, + status, + started_at: timestamp, + expires_at: timestamp + 600, + cancelled_at: null, + failed_at: null, + completed_at: status === 'completed' ? timestamp : null, + required_action: null, + last_error: null, + model: assistant.model, + instructions: assistant.instructions ?? '', + tools: assistant.tools ?? [], + tool_resources: assistant.tool_resources ?? {}, + metadata: {}, + incomplete_details: null, + usage, + temperature: assistant.temperature ?? 1, + top_p: assistant.top_p ?? 1, + max_prompt_tokens: null, + max_completion_tokens: null, + truncation_strategy: { type: 'auto', last_messages: null }, + response_format: assistant.response_format ?? 'auto', + tool_choice: 'auto', + parallel_tool_calls: true, + }; +} + +function runStep({ id, runId, threadId, assistantId, messageId, status }) { + const timestamp = now(); + return { + id, + object: 'thread.run.step', + created_at: timestamp, + assistant_id: assistantId, + thread_id: threadId, + run_id: runId, + type: 'message_creation', + status, + step_details: { + type: 'message_creation', + message_creation: { message_id: messageId }, + }, + last_error: null, + expired_at: null, + cancelled_at: null, + failed_at: null, + completed_at: status === 'completed' ? timestamp : null, + metadata: null, + usage: + status === 'completed' ? { prompt_tokens: 8, completion_tokens: 6, total_tokens: 14 } : null, + }; +} + +function sendAssistantStream(res, { assistant, thread }) { + const runId = createId('run'); + const stepId = createId('step'); + const reply = assistantReply(thread); + const message = createMessage({ + threadId: thread.id, + role: 'assistant', + content: reply, + assistantId: assistant.id, + runId, + }); + const createdRun = runObject({ + id: runId, + threadId: thread.id, + assistant, + status: 'queued', + }); + const completedRun = runObject({ + id: runId, + threadId: thread.id, + assistant, + status: 'completed', + usage: { prompt_tokens: 8, completion_tokens: 6, total_tokens: 14 }, + }); + const createdStep = runStep({ + id: stepId, + runId, + threadId: thread.id, + assistantId: assistant.id, + messageId: message.id, + status: 'in_progress', + }); + const completedStep = runStep({ + id: stepId, + runId, + threadId: thread.id, + assistantId: assistant.id, + messageId: message.id, + status: 'completed', + }); + const createdMessage = { ...message, content: [], status: 'in_progress', completed_at: null }; + const messageDelta = { + id: message.id, + object: 'thread.message.delta', + delta: { + content: [ + { + index: 0, + type: 'text', + text: { value: reply, annotations: [] }, + }, + ], + }, + }; + + runs.set(runId, completedRun); + thread.messages.push(message); + + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + const sendEvent = (event, data) => { + res.write(`event: ${event}\n`); + res.write(`data: ${JSON.stringify(data)}\n\n`); + }; + sendEvent('thread.run.created', createdRun); + sendEvent('thread.run.step.created', createdStep); + sendEvent('thread.message.created', createdMessage); + sendEvent('thread.message.delta', messageDelta); + sendEvent('thread.message.completed', message); + sendEvent('thread.run.step.completed', completedStep); + sendEvent('thread.run.completed', completedRun); + res.write('data: [DONE]\n\n'); + res.end(); +} + +function recordRequest(req, url, body) { + requests.push({ + method: req.method, + path: url.pathname, + query: Object.fromEntries(url.searchParams), + body, + }); +} + +function pathMatch(pathname, pattern) { + const match = pathname.match(pattern); + return match?.slice(1).map(decodeURIComponent) ?? null; +} + +const server = http.createServer(async (req, res) => { + const url = new URL(req.url, `http://127.0.0.1:${PORT}`); + + if (req.method === 'GET' && url.pathname === '/') { + sendJson(res, 200, { ok: true, service: 'fake-assistants-server' }); + return; + } + + if (req.method === 'GET' && url.pathname === '/__e2e/requests') { + sendJson(res, 200, { count: requests.length, requests }); + return; + } + + if (req.method === 'POST' && url.pathname === '/__e2e/reset') { + assistants.clear(); + threads.clear(); + runs.clear(); + requests.length = 0; + sendJson(res, 200, { ok: true }); + return; + } + + try { + const body = req.method === 'GET' || req.method === 'DELETE' ? {} : await readBody(req); + recordRequest(req, url, body); + + if (req.method === 'GET' && url.pathname === '/v1/models') { + sendJson(res, 200, { object: 'list', data: [{ id: 'gpt-4o-mini', object: 'model' }] }); + return; + } + + if (url.pathname === '/v1/assistants') { + if (req.method === 'POST') { + if (typeof body.model !== 'string' || body.model.length === 0) { + sendError(res, 400, 'model is required'); + return; + } + const assistant = createAssistant(body); + assistants.set(assistant.id, assistant); + sendJson(res, 200, assistant); + return; + } + if (req.method === 'GET') { + const order = url.searchParams.get('order') ?? 'desc'; + const data = [...assistants.values()].sort((a, b) => + order === 'asc' ? a.created_at - b.created_at : b.created_at - a.created_at, + ); + sendJson(res, 200, listResponse(data)); + return; + } + } + + const assistantPath = pathMatch(url.pathname, /^\/v1\/assistants\/([^/]+)$/); + if (assistantPath) { + const [assistantId] = assistantPath; + const assistant = assistants.get(assistantId); + if (!assistant) { + sendError(res, 404, `No assistant found with id '${assistantId}'`); + return; + } + if (req.method === 'GET') { + sendJson(res, 200, assistant); + return; + } + if (req.method === 'POST') { + const updated = { + ...assistant, + ...body, + id: assistant.id, + object: assistant.object, + created_at: assistant.created_at, + }; + assistants.set(assistantId, updated); + sendJson(res, 200, updated); + return; + } + if (req.method === 'DELETE') { + assistants.delete(assistantId); + sendJson(res, 200, { id: assistantId, object: 'assistant.deleted', deleted: true }); + return; + } + } + + if (req.method === 'POST' && url.pathname === '/v1/threads') { + const threadId = createId('thread'); + const thread = { + id: threadId, + object: 'thread', + created_at: now(), + metadata: body.metadata ?? {}, + tool_resources: body.tool_resources ?? {}, + messages: (body.messages ?? []).map((message) => + createMessage({ + threadId, + role: message.role, + content: message.content, + metadata: message.metadata ?? {}, + }), + ), + }; + threads.set(threadId, thread); + const { messages: _messages, ...response } = thread; + sendJson(res, 200, response); + return; + } + + const messagesPath = pathMatch(url.pathname, /^\/v1\/threads\/([^/]+)\/messages$/); + if (messagesPath) { + const [threadId] = messagesPath; + const thread = threads.get(threadId); + if (!thread) { + sendError(res, 404, `No thread found with id '${threadId}'`); + return; + } + if (req.method === 'POST') { + const message = createMessage({ + threadId, + role: body.role, + content: body.content, + metadata: body.metadata ?? {}, + }); + thread.messages.push(message); + sendJson(res, 200, message); + return; + } + if (req.method === 'GET') { + const order = url.searchParams.get('order') ?? 'desc'; + const data = [...thread.messages].sort((a, b) => + order === 'asc' ? a.created_at - b.created_at : b.created_at - a.created_at, + ); + sendJson(res, 200, listResponse(data)); + return; + } + } + + const messagePath = pathMatch(url.pathname, /^\/v1\/threads\/([^/]+)\/messages\/([^/]+)$/); + if (messagePath) { + const [threadId, messageId] = messagePath; + const thread = threads.get(threadId); + const message = thread?.messages.find((candidate) => candidate.id === messageId); + if (!message) { + sendError(res, 404, `No message found with id '${messageId}'`); + return; + } + if (req.method === 'GET') { + sendJson(res, 200, message); + return; + } + if (req.method === 'POST') { + Object.assign(message, body); + sendJson(res, 200, message); + return; + } + } + + const createRunPath = pathMatch(url.pathname, /^\/v1\/threads\/([^/]+)\/runs$/); + if (createRunPath && req.method === 'POST') { + const [threadId] = createRunPath; + const thread = threads.get(threadId); + const assistant = assistants.get(body.assistant_id); + if (!thread) { + sendError(res, 404, `No thread found with id '${threadId}'`); + return; + } + if (!assistant) { + sendError(res, 404, `No assistant found with id '${body.assistant_id}'`); + return; + } + if (body.stream !== true) { + sendError(res, 400, 'Only streamed runs are supported by the e2e fixture'); + return; + } + sendAssistantStream(res, { assistant, thread }); + return; + } + + const runPath = pathMatch(url.pathname, /^\/v1\/threads\/([^/]+)\/runs\/([^/]+)$/); + if (runPath && req.method === 'GET') { + const [threadId, runId] = runPath; + const run = runs.get(runId); + if (!run || run.thread_id !== threadId) { + sendError(res, 404, `No run found with id '${runId}'`); + return; + } + sendJson(res, 200, run); + return; + } + + sendError(res, 404, `Unhandled ${req.method} ${url.pathname}`); + } catch (error) { + if (!res.headersSent) { + sendError(res, 400, error.message); + } + } +}); + +server.listen(PORT, '127.0.0.1', () => { + console.log(`[e2e] fake assistants server listening on http://127.0.0.1:${PORT}`); +}); diff --git a/e2e/specs/mock/content-filters.helpers.ts b/e2e/specs/mock/content-filters.helpers.ts new file mode 100644 index 0000000000..a53d9df0b2 --- /dev/null +++ b/e2e/specs/mock/content-filters.helpers.ts @@ -0,0 +1,336 @@ +import fs from 'fs'; +import path from 'path'; +import { randomUUID } from 'crypto'; +import yaml from 'js-yaml'; +import { expect } from '@playwright/test'; +import { configSchema } from 'librechat-data-provider'; +import type { APIRequestContext } from '@playwright/test'; +import type { FiltersConfig } from 'librechat-data-provider'; +import { getPrimaryE2EUser } from '../../setup/users.mock'; + +const PROJECT_ROOT = path.resolve(__dirname, '../../..'); +const GENERATED_CONFIG_ROOT = path.join(PROJECT_ROOT, 'e2e/.generated'); +const RELOAD_SENTINEL = `e2e-content-filter-reload-${process.pid}-${randomUUID()}`; +const RELOAD_SENTINEL_PATH = `/api/admin/config/user/${encodeURIComponent(RELOAD_SENTINEL)}`; +const RELOAD_PRIORITY = 10; + +type RequestFetchOptions = NonNullable[1]>; + +type RuntimeConfig = { + filters?: FiltersConfig; + [key: string]: unknown; +}; + +type BaselineState = { + configPath: string; + contents: Buffer; + mode: number; +}; + +export type RequestResult = { + ok: boolean; + status: number; + text: string; + body: unknown; +}; + +export type RequestResultOptions = { + path: string; + token?: string; + method?: string; + data?: RequestFetchOptions['data']; + multipart?: RequestFetchOptions['multipart']; +}; + +export type ContentFilterBlockExpectation = { + source: string; + field: string; + marker: string; +}; + +let baselineState: BaselineState | undefined; + +function isRecord(value: unknown): value is Record { + return value != null && typeof value === 'object' && !Array.isArray(value); +} + +function isWithin(parent: string, candidate: string): boolean { + const relative = path.relative(parent, candidate); + return relative === '' || (!relative.startsWith(`..${path.sep}`) && relative !== '..'); +} + +function getRuntimeConfigPath(): string { + const configuredPath = process.env.CONFIG_PATH?.trim(); + if (!configuredPath) { + throw new Error('CONFIG_PATH must be set for content-filter e2e tests'); + } + + const configPath = path.resolve(configuredPath); + if (!isWithin(GENERATED_CONFIG_ROOT, configPath) || configPath === GENERATED_CONFIG_ROOT) { + throw new Error( + `Refusing to modify CONFIG_PATH outside ${GENERATED_CONFIG_ROOT}: ${configPath}`, + ); + } + + const generatedRootStat = fs.lstatSync(GENERATED_CONFIG_ROOT); + if (!generatedRootStat.isDirectory() || generatedRootStat.isSymbolicLink()) { + throw new Error(`Expected a non-symlink generated config directory: ${GENERATED_CONFIG_ROOT}`); + } + + const configStat = fs.lstatSync(configPath); + if (!configStat.isFile() || configStat.isSymbolicLink()) { + throw new Error(`Expected a non-symlink generated config file: ${configPath}`); + } + + const realGeneratedRoot = fs.realpathSync(GENERATED_CONFIG_ROOT); + const realConfigDirectory = fs.realpathSync(path.dirname(configPath)); + if (!isWithin(realGeneratedRoot, realConfigDirectory)) { + throw new Error(`Refusing to modify CONFIG_PATH through an external directory: ${configPath}`); + } + + return configPath; +} + +function parseRuntimeConfig(contents: Buffer): RuntimeConfig { + const parsed = yaml.load(contents.toString('utf8')); + if (!isRecord(parsed)) { + throw new Error('Generated LibreChat config must contain a YAML object'); + } + return parsed as RuntimeConfig; +} + +function captureBaseline(): BaselineState { + const configPath = getRuntimeConfigPath(); + if (baselineState) { + if (baselineState.configPath !== configPath) { + throw new Error('CONFIG_PATH changed while a content-filter baseline was active'); + } + return baselineState; + } + + const contents = fs.readFileSync(configPath); + const config = parseRuntimeConfig(contents); + if (Object.prototype.hasOwnProperty.call(config, 'filters')) { + throw new Error('Content-filter e2e baseline must not define filters'); + } + + baselineState = { + configPath, + contents, + mode: fs.statSync(configPath).mode & 0o777, + }; + return baselineState; +} + +function validateRuntimeConfig(config: RuntimeConfig): void { + const result = configSchema.strict().safeParse(config); + if (result.success) { + return; + } + + const issues = result.error.issues + .map((issue) => `${issue.path.join('.') || ''}: ${issue.message}`) + .join('; '); + throw new Error(`Invalid generated LibreChat config: ${issues}`); +} + +function atomicWrite(state: BaselineState, contents: string | Buffer): void { + const currentPath = getRuntimeConfigPath(); + if (currentPath !== state.configPath) { + throw new Error('CONFIG_PATH changed before the generated config write'); + } + + const temporaryPath = path.join( + path.dirname(state.configPath), + `.${path.basename(state.configPath)}.${process.pid}.${randomUUID()}.tmp`, + ); + + try { + fs.writeFileSync(temporaryPath, contents, { mode: state.mode }); + fs.renameSync(temporaryPath, state.configPath); + } finally { + if (fs.existsSync(temporaryPath)) { + fs.unlinkSync(temporaryPath); + } + } +} + +function parseResponseBody(text: string): unknown { + if (!text) { + return null; + } + try { + return JSON.parse(text); + } catch { + return text; + } +} + +function getConfigFromResult(result: RequestResult): RuntimeConfig { + expect(result.ok, `Expected base-config request to succeed: ${result.text}`).toBe(true); + if (!isRecord(result.body) || !isRecord(result.body.config)) { + throw new Error(`Expected base-config response to contain a config object: ${result.text}`); + } + return result.body.config as RuntimeConfig; +} + +async function triggerConfigReload(request: APIRequestContext, token: string): Promise { + const result = await requestResult(request, { + path: RELOAD_SENTINEL_PATH, + token, + method: 'PUT', + data: { overrides: {}, priority: RELOAD_PRIORITY }, + }); + + expect(result.ok, `Expected config reload trigger to succeed: ${result.text}`).toBe(true); + expect(result.body, result.text).toEqual( + expect.objectContaining({ + config: expect.objectContaining({ principalId: RELOAD_SENTINEL }), + }), + ); +} + +async function getLoadedConfig( + request: APIRequestContext, + token: string, + baseOnly: boolean, +): Promise { + const result = await requestResult(request, { + path: `/api/admin/config/base${baseOnly ? '?baseOnly=true' : ''}`, + token, + }); + return getConfigFromResult(result); +} + +async function getLoadedFilterState( + request: APIRequestContext, + token: string, +): Promise<{ base: FiltersConfig | undefined; effective: FiltersConfig | undefined }> { + const [baseConfig, effectiveConfig] = await Promise.all([ + getLoadedConfig(request, token, true), + getLoadedConfig(request, token, false), + ]); + return { base: baseConfig.filters, effective: effectiveConfig.filters }; +} + +async function deleteReloadSentinel(request: APIRequestContext, token: string): Promise { + const result = await requestResult(request, { + path: RELOAD_SENTINEL_PATH, + token, + method: 'DELETE', + }); + expect([200, 404], `Expected reload sentinel cleanup to succeed: ${result.text}`).toContain( + result.status, + ); +} + +export async function loginAdmin(request: APIRequestContext): Promise { + const { email, password } = getPrimaryE2EUser(); + const response = await request.post('/api/auth/login', { + data: { email, password }, + failOnStatusCode: false, + }); + const ok = response.ok(); + const status = response.status(); + const text = await response.text(); + await response.dispose(); + const body = parseResponseBody(text); + + if (!ok) { + throw new Error(`Admin login failed with status ${status}`); + } + if (!isRecord(body) || typeof body.token !== 'string' || body.token.length === 0) { + throw new Error('Admin login response did not include an access token'); + } + return body.token; +} + +export async function requestResult( + request: APIRequestContext, + options: RequestResultOptions, +): Promise { + if (options.data !== undefined && options.multipart !== undefined) { + throw new Error('requestResult accepts either data or multipart, not both'); + } + + const fetchOptions: RequestFetchOptions = { + method: options.method ?? 'GET', + failOnStatusCode: false, + }; + if (options.token?.trim()) { + fetchOptions.headers = { Authorization: `Bearer ${options.token}` }; + } + if (options.data !== undefined) { + fetchOptions.data = options.data; + } + if (options.multipart !== undefined) { + fetchOptions.multipart = options.multipart; + } + + const response = await request.fetch(options.path, fetchOptions); + const result: RequestResult = { + ok: response.ok(), + status: response.status(), + text: await response.text(), + body: null, + }; + result.body = parseResponseBody(result.text); + await response.dispose(); + return result; +} + +export async function setRuntimeFilters( + request: APIRequestContext, + token: string, + filters: FiltersConfig, +): Promise { + const baseline = captureBaseline(); + const config = { ...parseRuntimeConfig(baseline.contents), filters }; + validateRuntimeConfig(config); + atomicWrite(baseline, yaml.dump(config, { noRefs: true, lineWidth: 120 })); + + await triggerConfigReload(request, token); + await expect + .poll(async () => getLoadedFilterState(request, token), { + timeout: 30000, + intervals: [100, 250, 500, 1000], + }) + .toEqual({ base: filters, effective: filters }); +} + +export async function restoreRuntimeFilters( + request: APIRequestContext, + token: string, +): Promise { + const baseline = captureBaseline(); + atomicWrite(baseline, baseline.contents); + + try { + await triggerConfigReload(request, token); + await expect + .poll(async () => getLoadedFilterState(request, token), { + timeout: 30000, + intervals: [100, 250, 500, 1000], + }) + .toEqual({ base: undefined, effective: undefined }); + } finally { + await deleteReloadSentinel(request, token); + } + + baselineState = undefined; +} + +export function expectContentFilterBlock( + result: RequestResult, + expectation: ContentFilterBlockExpectation, +): void { + expect(result.status).toBe(400); + expect(result.body).toEqual( + expect.objectContaining({ + error: 'content_filter_block', + source: expectation.source, + field: expectation.field, + }), + ); + expect(result.text).not.toContain(expectation.marker); +} diff --git a/e2e/specs/mock/content-filters.persisted.spec.ts b/e2e/specs/mock/content-filters.persisted.spec.ts new file mode 100644 index 0000000000..a54a09fe5d --- /dev/null +++ b/e2e/specs/mock/content-filters.persisted.spec.ts @@ -0,0 +1,2094 @@ +import { randomUUID } from 'crypto'; +import { expect, test } from '@playwright/test'; +import type { FiltersConfig } from 'librechat-data-provider'; +import { withMongo } from './db'; +import { MOCK_ENDPOINTS, replyPrompt, replyText, selectMockEndpoint, sendMessage } from './helpers'; +import { + expectContentFilterBlock, + loginAdmin, + requestResult, + restoreRuntimeFilters, + setRuntimeFilters, +} from './content-filters.helpers'; + +const NO_PARENT = '00000000-0000-0000-0000-000000000000'; +const OPAQUE_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAHUlEQVQ4jWNwaDjwnxLMMGrA/9EwODAaBg3DIgwACY9/HwbtciYAAAAASUVORK5CYII=', + 'base64', +); + +type JsonObject = Record; +type RequestResult = Awaited>; + +type StoredFixtures = { + conversationIds: string[]; + agentIds: string[]; + messageConversationId?: string; + messageId?: string; + messageShareId?: string; + titleConversationId?: string; + feedbackConversationId?: string; + toolConversationId?: string; + promptGroupId?: string; + metadataPromptGroupId?: string; + promptId?: string; + presetId?: string; + instructionAgentId?: string; + starterAgentId?: string; + modelParameterAgentId?: string; + skillAgentId?: string; + memoryAgentId?: string; + fileAgentId?: string; + opaqueFileAgentId?: string; + actionAgentId?: string; + skillId?: string; + skillName?: string; + skillVersion?: number; + memoryKey?: string; + file?: { file_id: string; filepath: string }; + opaqueFile?: { file_id: string; filepath: string }; + actionId?: string; +}; + +const asObject = (value: unknown): JsonObject => + value != null && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {}; + +const expectSuccess = (result: RequestResult, status?: number): void => { + expect(result.ok, result.text).toBe(true); + if (status != null) { + expect(result.status, result.text).toBe(status); + } +}; + +const expectStoredMarker = (result: RequestResult, marker: string): void => { + expectSuccess(result); + expect(result.text).toContain(marker); +}; + +const requireString = (value: unknown, label: string): string => { + expect(typeof value, `Expected ${label} to be a string`).toBe('string'); + expect(value, `Expected ${label} not to be empty`).not.toBe(''); + return value as string; +}; + +const requireNumber = (value: unknown, label: string): number => { + expect(typeof value, `Expected ${label} to be a number`).toBe('number'); + return value as number; +}; + +const createAgentPayload = (suffix: string, overrides: JsonObject = {}): JsonObject => ({ + name: `E2E persisted-filter agent ${suffix}`, + description: 'Safe agent used for post-policy persisted-content coverage.', + instructions: 'Use only safe deterministic instructions.', + provider: MOCK_ENDPOINTS[0].label, + model: MOCK_ENDPOINTS[0].model, + model_parameters: {}, + tools: [], + conversation_starters: ['Ask a safe question'], + ...overrides, +}); + +async function createStoredMessage( + request: Parameters[0], + token: string, + conversationId: string, + body: JsonObject, +): Promise { + const result = await requestResult(request, { + path: `/api/messages/${encodeURIComponent(conversationId)}`, + token, + method: 'POST', + data: { + messageId: randomUUID(), + parentMessageId: NO_PARENT, + sender: 'User', + endpoint: MOCK_ENDPOINTS[0].label, + endpointType: 'custom', + model: MOCK_ENDPOINTS[0].model, + isCreatedByUser: true, + ...body, + }, + }); + expectSuccess(result, 201); + return asObject(result.body); +} + +async function createAgent( + request: Parameters[0], + token: string, + fixtures: StoredFixtures, + suffix: string, + overrides: JsonObject = {}, +): Promise { + const result = await requestResult(request, { + path: '/api/agents', + token, + method: 'POST', + data: createAgentPayload(suffix, overrides), + }); + expectSuccess(result, 201); + const agent = asObject(result.body); + fixtures.agentIds.push(requireString(agent.id, `${suffix} agent id`)); + return agent; +} + +async function duplicateConversation( + request: Parameters[0], + token: string, + conversationId: string, +): Promise { + return requestResult(request, { + path: '/api/convos/duplicate', + token, + method: 'POST', + data: { conversationId, title: 'Safe copied conversation' }, + }); +} + +async function duplicateAgent( + request: Parameters[0], + token: string, + agentId: string, +): Promise { + return requestResult(request, { + path: `/api/agents/${encodeURIComponent(agentId)}/duplicate`, + token, + method: 'POST', + }); +} + +async function expectAsyncFilterStreamError( + request: Parameters[0], + token: string, + started: RequestResult, + expectedLabel: string, + marker: string, +): Promise { + expectSuccess(started, 200); + const startBody = asObject(started.body); + expect(startBody.status).toBe('started'); + const conversationId = requireString(startBody.conversationId, 'blocked stream conversation id'); + const streamId = requireString(startBody.streamId, 'blocked stream id'); + + let terminalStatus: RequestResult | undefined; + await expect + .poll( + async () => { + const status = await requestResult(request, { + path: `/api/agents/chat/status/${encodeURIComponent(conversationId)}`, + token, + }); + if (status.status === 503) { + return { active: true, status: 'pending' }; + } + expectSuccess(status, 200); + terminalStatus = status; + const statusBody = asObject(status.body); + return { active: statusBody.active, status: statusBody.status }; + }, + { timeout: 30000, intervals: [100, 250, 500, 1000] }, + ) + .toEqual({ active: false, status: 'error' }); + expect(terminalStatus?.text).not.toContain(marker); + + const errorStream = await requestResult(request, { + path: `/api/agents/chat/stream/${encodeURIComponent(streamId)}?resume=true`, + token, + }); + expectSuccess(errorStream, 200); + expect(errorStream.text).toContain('event: error'); + expect(errorStream.text).toContain(expectedLabel); + expect(errorStream.text).not.toContain(marker); + return conversationId; +} + +async function expectAsyncStreamCompleted( + request: Parameters[0], + token: string, + started: RequestResult, +): Promise { + expectSuccess(started, 200); + const startBody = asObject(started.body); + expect(startBody.status).toBe('started'); + const conversationId = requireString( + startBody.conversationId, + 'completed stream conversation id', + ); + const streamId = requireString(startBody.streamId, 'completed stream id'); + + await expect + .poll( + async () => { + const status = await requestResult(request, { + path: `/api/agents/chat/status/${encodeURIComponent(conversationId)}`, + token, + }); + if (status.status === 503) { + return { active: true, status: 'pending' }; + } + expectSuccess(status, 200); + const statusBody = asObject(status.body); + return { active: statusBody.active, status: statusBody.status }; + }, + { timeout: 30000, intervals: [100, 250, 500, 1000] }, + ) + .toEqual({ active: false, status: 'complete' }); + + const stream = await requestResult(request, { + path: `/api/agents/chat/stream/${encodeURIComponent(streamId)}?resume=true`, + token, + }); + expectSuccess(stream, 200); + expect(stream.text).not.toContain('event: error'); + return conversationId; +} + +async function cleanupFixtures( + request: Parameters[0], + token: string, + fixtures: StoredFixtures, +): Promise { + if (fixtures.actionId && fixtures.actionAgentId) { + await requestResult(request, { + path: `/api/agents/actions/${encodeURIComponent(fixtures.actionAgentId)}/${encodeURIComponent( + fixtures.actionId, + )}`, + token, + method: 'DELETE', + }); + } + if (fixtures.messageShareId) { + await requestResult(request, { + path: `/api/share/${encodeURIComponent(fixtures.messageShareId)}`, + token, + method: 'DELETE', + }); + } + await Promise.all( + fixtures.agentIds.map((agentId) => + requestResult(request, { + path: `/api/agents/${encodeURIComponent(agentId)}`, + token, + method: 'DELETE', + }), + ), + ); + const files = [fixtures.file, fixtures.opaqueFile].filter( + (file): file is NonNullable => file != null, + ); + if (files.length > 0) { + await requestResult(request, { + path: '/api/files', + token, + method: 'DELETE', + data: { files }, + }); + } + if (fixtures.skillId) { + await requestResult(request, { + path: `/api/skills/${encodeURIComponent(fixtures.skillId)}`, + token, + method: 'DELETE', + }); + } + if (fixtures.memoryKey && fixtures.memoryAgentId) { + await requestResult(request, { + path: `/api/memories/${encodeURIComponent(fixtures.memoryKey)}?agentId=${encodeURIComponent( + fixtures.memoryAgentId!, + )}`, + token, + method: 'DELETE', + }); + } + await Promise.all( + [fixtures.promptGroupId, fixtures.metadataPromptGroupId] + .filter((groupId): groupId is string => groupId != null) + .map((groupId) => + requestResult(request, { + path: `/api/prompts/groups/${encodeURIComponent(groupId)}`, + token, + method: 'DELETE', + }), + ), + ); + if (fixtures.presetId) { + await requestResult(request, { + path: '/api/presets/delete', + token, + method: 'POST', + data: { presetId: fixtures.presetId }, + }); + } + await Promise.all( + fixtures.conversationIds.map((conversationId) => + requestResult(request, { + path: '/api/convos', + token, + method: 'DELETE', + data: { arg: { conversationId } }, + }), + ), + ); +} + +test.describe('persisted source-aware content filters', () => { + test.describe.configure({ mode: 'serial', timeout: 300000 }); + + test('rechecks records created before all twelve filters are activated', async ({ + page, + request, + }) => { + const suffix = `${Date.now()}-${Math.floor(Math.random() * 10000)}`; + const markers = { + messages: `E2E-PERSISTED-MESSAGE-${suffix}`, + prompts: `E2E-PERSISTED-PROMPT-${suffix}`, + promptGroupName: `E2E-PERSISTED-PROMPT-GROUP-${suffix}`, + agentInstructions: `E2E-PERSISTED-AGENT-INSTRUCTION-${suffix}`, + conversationStarters: `E2E-PERSISTED-CONVERSATION-STARTER-${suffix}`, + conversationTitles: `E2E-PERSISTED-CONVERSATION-TITLE-${suffix}`, + feedback: `E2E-PERSISTED-FEEDBACK-${suffix}`, + skills: `E2E-PERSISTED-SKILL-${suffix}`, + memories: `E2E-PERSISTED-MEMORY-${suffix}`, + files: `E2E-PERSISTED-FILE-${suffix}`, + toolArguments: `E2E-PERSISTED-TOOL-ARGUMENT-${suffix}`, + modelParameters: `E2E-PERSISTED-MODEL-PARAMETER-${suffix}`, + actionMetadata: `E2E-PERSISTED-ACTION-METADATA-${suffix}`, + } as const; + const memoryKeySuffix = Array.from(randomUUID().replace(/-/g, ''), (character) => + String.fromCharCode(97 + Number.parseInt(character, 16)), + ).join(''); + const pii = (id: string, field: string, marker: string) => ({ + fields: [field], + starterPatterns: [], + customPatterns: [ + { + id: `e2e-persisted-${id}-${suffix}`, + label: `E2E persisted ${id.replace(/-/g, ' ')} value`, + regex: `^${marker}$`, + }, + ], + }); + const filters = { + messages: { pii: pii('messages', 'text', markers.messages) }, + prompts: { + pii: { + fields: ['name', 'text', 'preset_text'], + starterPatterns: [], + customPatterns: [ + { + id: `e2e-persisted-prompts-${suffix}`, + label: 'E2E persisted protected prompt', + regex: `^${markers.prompts}$`, + }, + { + id: `e2e-persisted-prompt-group-${suffix}`, + label: 'E2E persisted protected prompt group', + regex: `^${markers.promptGroupName}$`, + }, + ], + }, + }, + agentInstructions: { + pii: pii('agent-instructions', 'instructions', markers.agentInstructions), + }, + conversationStarters: { + pii: pii('conversation-starters', 'text', markers.conversationStarters), + }, + conversationTitles: { + pii: pii('conversation-titles', 'title', markers.conversationTitles), + }, + feedback: { pii: pii('feedback', 'text', markers.feedback) }, + skills: { pii: pii('skills', 'instructions', markers.skills) }, + memories: { pii: pii('memories', 'value', markers.memories) }, + files: { + pii: { + ...pii('files', 'extracted_text', markers.files), + uninspectable: 'block', + }, + }, + toolArguments: { pii: pii('tool-arguments', 'arguments', markers.toolArguments) }, + modelParameters: { pii: pii('model-parameters', 'stop', markers.modelParameters) }, + actionMetadata: { + pii: pii('action-metadata', 'privacy_policy_url', markers.actionMetadata), + }, + } as FiltersConfig; + const fixtures: StoredFixtures = { conversationIds: [], agentIds: [] }; + const token = await loginAdmin(request); + let filtersAttempted = false; + let filtersActive = false; + + try { + await restoreRuntimeFilters(request, token); + + await test.step('create every fixture before policy activation', async () => { + const seedLabel = `persisted-filter-seed-${suffix}`; + await page.goto('/c/new', { timeout: 10000 }); + await selectMockEndpoint(page, MOCK_ENDPOINTS[0]); + const seedResponse = await sendMessage(page, replyPrompt(seedLabel)); + expect(seedResponse.ok()).toBe(true); + await expect( + page.getByTestId('messages-view').getByText(replyText(seedLabel), { exact: true }), + ).toBeVisible({ timeout: 30000 }); + await expect(page).toHaveURL(/\/c\/(?!new)[0-9a-fA-F-]{36}$/); + const seedMatch = new URL(page.url()).pathname.match(/^\/c\/([0-9a-fA-F-]{36})$/); + fixtures.messageConversationId = requireString( + seedMatch?.[1], + 'persisted message conversation id', + ); + fixtures.conversationIds.push(fixtures.messageConversationId); + + const cloneSafeConversation = async (label: string): Promise => { + const cloned = await duplicateConversation( + request, + token, + fixtures.messageConversationId!, + ); + expectSuccess(cloned, 201); + const conversationId = requireString( + asObject(asObject(cloned.body).conversation).conversationId, + label, + ); + fixtures.conversationIds.push(conversationId); + return conversationId; + }; + fixtures.titleConversationId = await cloneSafeConversation( + 'persisted title conversation id', + ); + fixtures.feedbackConversationId = await cloneSafeConversation( + 'persisted feedback conversation id', + ); + fixtures.toolConversationId = await cloneSafeConversation('persisted tool conversation id'); + + const storedMessage = await createStoredMessage( + request, + token, + fixtures.messageConversationId, + { + text: markers.messages, + }, + ); + fixtures.messageId = requireString(storedMessage.messageId, 'persisted marker message id'); + const share = await requestResult(request, { + path: `/api/share/${encodeURIComponent(fixtures.messageConversationId)}`, + token, + method: 'POST', + data: {}, + }); + expectSuccess(share, 200); + fixtures.messageShareId = requireString( + asObject(share.body).shareId, + 'persisted message share id', + ); + + const title = await requestResult(request, { + path: '/api/convos/update', + token, + method: 'POST', + data: { + arg: { + conversationId: fixtures.titleConversationId, + title: markers.conversationTitles, + }, + }, + }); + expectSuccess(title, 201); + + const feedbackMessage = await createStoredMessage( + request, + token, + fixtures.feedbackConversationId, + { text: 'Safe feedback target message.' }, + ); + const feedbackMessageId = requireString(feedbackMessage.messageId, 'feedback message id'); + const feedback = await requestResult(request, { + path: `/api/messages/${encodeURIComponent( + fixtures.feedbackConversationId, + )}/${encodeURIComponent(feedbackMessageId)}/feedback`, + token, + method: 'PUT', + data: { + feedback: { rating: 'thumbsDown', tag: 'other', text: markers.feedback }, + }, + }); + expectSuccess(feedback, 200); + + await createStoredMessage(request, token, fixtures.toolConversationId, { + content: [ + { + type: 'tool_call', + tool_call: { + id: `call_${suffix}`, + name: 'safe_lookup', + args: markers.toolArguments, + }, + }, + ], + }); + + const prompt = await requestResult(request, { + path: '/api/prompts', + token, + method: 'POST', + data: { + prompt: { prompt: markers.prompts, type: 'text' }, + group: { name: `E2E persisted prompt ${suffix}` }, + }, + }); + expectSuccess(prompt, 200); + const promptBody = asObject(prompt.body); + const promptRecord = asObject(promptBody.prompt); + const promptGroup = asObject(promptBody.group); + fixtures.promptId = requireString(promptRecord._id, 'persisted prompt id'); + fixtures.promptGroupId = requireString( + promptGroup._id ?? promptRecord.groupId, + 'persisted prompt group id', + ); + + const metadataPrompt = await requestResult(request, { + path: '/api/prompts', + token, + method: 'POST', + data: { + prompt: { prompt: 'Safe prompt for protected group metadata.', type: 'text' }, + group: { name: markers.promptGroupName }, + }, + }); + expectSuccess(metadataPrompt, 200); + const metadataPromptBody = asObject(metadataPrompt.body); + const metadataPromptRecord = asObject(metadataPromptBody.prompt); + fixtures.metadataPromptGroupId = requireString( + asObject(metadataPromptBody.group)._id ?? metadataPromptRecord.groupId, + 'persisted protected-metadata prompt group id', + ); + + const preset = await requestResult(request, { + path: '/api/presets', + token, + method: 'POST', + data: { + title: `E2E persisted preset ${suffix}`, + promptPrefix: markers.prompts, + endpoint: MOCK_ENDPOINTS[0].label, + model: MOCK_ENDPOINTS[0].model, + }, + }); + expectSuccess(preset, 201); + fixtures.presetId = requireString(asObject(preset.body).presetId, 'persisted preset id'); + + fixtures.skillName = `e2e-persisted-skill-${suffix}`; + const skill = await requestResult(request, { + path: '/api/skills', + token, + method: 'POST', + data: { + name: fixtures.skillName, + description: 'Skill created before runtime policy activation.', + body: markers.skills, + }, + }); + expectSuccess(skill, 201); + fixtures.skillId = requireString(asObject(skill.body)._id, 'persisted skill id'); + fixtures.skillVersion = requireNumber( + asObject(skill.body).version, + 'persisted skill version', + ); + + fixtures.memoryKey = `e_to_e_persisted_memory_${memoryKeySuffix}`; + const memoryAgent = await createAgent(request, token, fixtures, `${suffix}-memory`, { + memory_scope: 'agent', + }); + fixtures.memoryAgentId = requireString(memoryAgent.id, 'memory agent id'); + const memory = await requestResult(request, { + path: '/api/memories', + token, + method: 'POST', + data: { + key: fixtures.memoryKey, + value: markers.memories, + agentId: fixtures.memoryAgentId, + }, + }); + expectSuccess(memory, 201); + + const fileAgent = await createAgent(request, token, fixtures, `${suffix}-file`); + fixtures.fileAgentId = requireString(fileAgent.id, 'file agent id'); + + const file = await requestResult(request, { + path: '/api/files', + token, + method: 'POST', + multipart: { + endpoint: MOCK_ENDPOINTS[0].label, + endpointType: 'custom', + agent_id: fixtures.fileAgentId, + tool_resource: 'context', + file_id: randomUUID(), + file: { + name: `e2e-persisted-${suffix}.txt`, + mimeType: 'text/plain', + buffer: Buffer.from(markers.files), + }, + }, + }); + expectSuccess(file, 200); + const fileBody = asObject(file.body); + fixtures.file = { + file_id: requireString(fileBody.file_id, 'persisted file id'), + filepath: requireString(fileBody.filepath, 'persisted file path'), + }; + + const opaqueFile = await requestResult(request, { + path: '/api/files', + token, + method: 'POST', + multipart: { + endpoint: MOCK_ENDPOINTS[0].label, + endpointType: 'custom', + message_file: 'true', + file_id: randomUUID(), + file: { + name: `e2e-persisted-opaque-${suffix}.png`, + mimeType: 'image/png', + buffer: OPAQUE_PNG, + }, + }, + }); + expectSuccess(opaqueFile, 200); + const opaqueFileBody = asObject(opaqueFile.body); + fixtures.opaqueFile = { + file_id: requireString(opaqueFileBody.file_id, 'persisted opaque file id'), + filepath: requireString(opaqueFileBody.filepath, 'persisted opaque file path'), + }; + + const instructionAgent = await createAgent( + request, + token, + fixtures, + `${suffix}-instruction`, + { instructions: markers.agentInstructions }, + ); + fixtures.instructionAgentId = requireString(instructionAgent.id, 'instruction agent id'); + + const starterAgent = await createAgent(request, token, fixtures, `${suffix}-starter`, { + conversation_starters: [markers.conversationStarters], + }); + fixtures.starterAgentId = requireString(starterAgent.id, 'starter agent id'); + + const modelParameterAgent = await createAgent( + request, + token, + fixtures, + `${suffix}-model-parameter`, + { model_parameters: { stop: [markers.modelParameters] } }, + ); + fixtures.modelParameterAgentId = requireString( + modelParameterAgent.id, + 'model-parameter agent id', + ); + + const skillAgent = await createAgent(request, token, fixtures, `${suffix}-skill`, { + skills_enabled: true, + skills: [fixtures.skillId], + }); + fixtures.skillAgentId = requireString(skillAgent.id, 'skill agent id'); + + const opaqueFileAgent = await createAgent( + request, + token, + fixtures, + `${suffix}-opaque-file`, + { tool_resources: { context: { file_ids: [fixtures.opaqueFile.file_id] } } }, + ); + fixtures.opaqueFileAgentId = requireString(opaqueFileAgent.id, 'opaque file agent id'); + + const actionAgent = await createAgent(request, token, fixtures, `${suffix}-action`); + fixtures.actionAgentId = requireString(actionAgent.id, 'action agent id'); + const action = await requestResult(request, { + path: `/api/agents/actions/${encodeURIComponent(fixtures.actionAgentId)}`, + token, + method: 'POST', + data: { + functions: [ + { + type: 'function', + function: { + name: `persisted_lookup_${suffix.replace(/-/g, '_')}`, + description: 'Return a safe deterministic lookup result.', + parameters: { type: 'object', properties: {} }, + }, + }, + ], + metadata: { + domain: 'https://example.com', + privacy_policy_url: markers.actionMetadata, + }, + }, + }); + expectSuccess(action, 200); + const actionItems = Array.isArray(action.body) ? action.body : []; + fixtures.actionId = requireString( + asObject(actionItems[1]).action_id, + 'persisted action id', + ); + }); + + filtersAttempted = true; + filtersActive = true; + await setRuntimeFilters(request, token, filters); + + await test.step('messages remain manageable but old shares are blocked on read', async () => { + const visible = await requestResult(request, { + path: `/api/messages/${encodeURIComponent(fixtures.messageConversationId!)}`, + token, + }); + expectStoredMarker(visible, markers.messages); + + const expectMessageBlock = (result: RequestResult): void => { + expectContentFilterBlock(result, { + source: 'message', + field: 'text', + marker: markers.messages, + }); + }; + + const blockedShareRead = await requestResult(request, { + path: `/api/share/${encodeURIComponent(fixtures.messageShareId!)}`, + }); + expectMessageBlock(blockedShareRead); + + const blockedDuplicate = await duplicateConversation( + request, + token, + fixtures.messageConversationId!, + ); + expectMessageBlock(blockedDuplicate); + + const blockedFork = await requestResult(request, { + path: '/api/convos/fork', + token, + method: 'POST', + data: { + conversationId: fixtures.messageConversationId, + messageId: fixtures.messageId, + option: 'directPath', + }, + }); + expectMessageBlock(blockedFork); + + const blockedSharedFork = await requestResult(request, { + path: `/api/share/${encodeURIComponent(fixtures.messageShareId!)}/fork`, + token, + method: 'POST', + data: {}, + }); + expectMessageBlock(blockedSharedFork); + }); + + await test.step('prompts are redacted or omitted and cannot be promoted', async () => { + const versions = await requestResult(request, { + path: `/api/prompts?groupId=${encodeURIComponent(fixtures.promptGroupId!)}`, + token, + }); + expectSuccess(versions, 200); + const versionItems = Array.isArray(versions.body) ? versions.body : []; + const blockedVersion = versionItems + .map(asObject) + .find((prompt) => prompt._id === fixtures.promptId); + expect(blockedVersion).toEqual( + expect.objectContaining({ + _id: fixtures.promptId, + groupId: fixtures.promptGroupId, + prompt: '', + contentFilterBlocked: true, + }), + ); + expect(blockedVersion).not.toHaveProperty('name'); + expect(versions.text).not.toContain(markers.prompts); + + const directGroup = await requestResult(request, { + path: `/api/prompts/groups/${encodeURIComponent(fixtures.promptGroupId!)}`, + token, + }); + expectSuccess(directGroup, 200); + const directGroupBody = asObject(directGroup.body); + const directProductionPrompt = asObject(directGroupBody.productionPrompt); + expect(directGroupBody._id).toBe(fixtures.promptGroupId); + expect(directProductionPrompt).toEqual( + expect.objectContaining({ + _id: fixtures.promptId, + groupId: fixtures.promptGroupId, + prompt: '', + contentFilterBlocked: true, + }), + ); + expect(directGroup.text).not.toContain(markers.prompts); + + const blockedMetadataGroup = await requestResult(request, { + path: `/api/prompts/groups/${encodeURIComponent(fixtures.metadataPromptGroupId!)}`, + token, + }); + expectContentFilterBlock(blockedMetadataGroup, { + source: 'prompt', + field: 'name', + marker: markers.promptGroupName, + }); + + const paginatedGroups = await requestResult(request, { + path: `/api/prompts/groups?name=${encodeURIComponent( + `E2E persisted prompt ${suffix}`, + )}&limit=10`, + token, + }); + expectSuccess(paginatedGroups, 200); + const paginatedGroup = ( + Array.isArray(asObject(paginatedGroups.body).promptGroups) + ? (asObject(paginatedGroups.body).promptGroups as unknown[]) + : [] + ) + .map(asObject) + .find((group) => group._id === fixtures.promptGroupId); + expect(paginatedGroup?._id).toBe(fixtures.promptGroupId); + expect(asObject(paginatedGroup?.productionPrompt)).toEqual( + expect.objectContaining({ + _id: fixtures.promptId, + prompt: '', + contentFilterBlocked: true, + }), + ); + expect(paginatedGroups.text).not.toContain(markers.prompts); + + const metadataPaginatedGroups = await requestResult(request, { + path: `/api/prompts/groups?name=${encodeURIComponent(markers.promptGroupName)}&limit=10`, + token, + }); + expectSuccess(metadataPaginatedGroups, 200); + const metadataGroupItems = Array.isArray( + asObject(metadataPaginatedGroups.body).promptGroups, + ) + ? (asObject(metadataPaginatedGroups.body).promptGroups as unknown[]).map(asObject) + : []; + expect( + metadataGroupItems.some((group) => group._id === fixtures.metadataPromptGroupId), + ).toBe(false); + expect(metadataPaginatedGroups.text).not.toContain(markers.promptGroupName); + + const reusable = await requestResult(request, { path: '/api/prompts/all', token }); + expectSuccess(reusable, 200); + const reusableGroups = Array.isArray(reusable.body) ? reusable.body.map(asObject) : []; + expect(reusableGroups.some((group) => group._id === fixtures.promptGroupId)).toBe(false); + expect(reusableGroups.some((group) => group._id === fixtures.metadataPromptGroupId)).toBe( + false, + ); + expect(reusable.text).not.toContain(markers.prompts); + expect(reusable.text).not.toContain(markers.promptGroupName); + + const blocked = await requestResult(request, { + path: `/api/prompts/${encodeURIComponent(fixtures.promptId!)}/tags/production`, + token, + method: 'PATCH', + }); + expectContentFilterBlock(blocked, { + source: 'prompt', + field: 'text', + marker: markers.prompts, + }); + + const presets = await requestResult(request, { path: '/api/presets', token }); + expectSuccess(presets, 200); + const presetItems = Array.isArray(presets.body) ? presets.body : []; + const blockedPreset = presetItems + .map(asObject) + .find((preset) => preset.presetId === fixtures.presetId); + expect(blockedPreset).toEqual( + expect.objectContaining({ + presetId: fixtures.presetId, + title: '', + endpoint: MOCK_ENDPOINTS[0].label, + model: MOCK_ENDPOINTS[0].model, + contentFilterBlocked: true, + }), + ); + expect(blockedPreset).not.toHaveProperty('promptPrefix'); + expect(presets.text).not.toContain(markers.prompts); + }); + + await test.step('agent instructions stay visible, safe partial edits work, and reuse fails', async () => { + const visible = await requestResult(request, { + path: `/api/agents/${encodeURIComponent(fixtures.instructionAgentId!)}/expanded`, + token, + }); + expectStoredMarker(visible, markers.agentInstructions); + const safeEdit = await requestResult(request, { + path: `/api/agents/${encodeURIComponent(fixtures.instructionAgentId!)}`, + token, + method: 'PATCH', + data: { description: 'Safe remediation metadata edit.' }, + }); + expectSuccess(safeEdit, 200); + const blocked = await duplicateAgent(request, token, fixtures.instructionAgentId!); + expectContentFilterBlock(blocked, { + source: 'agent_instruction', + field: 'instructions', + marker: markers.agentInstructions, + }); + }); + + await test.step('conversation starters stay visible but prevent agent reuse', async () => { + const visible = await requestResult(request, { + path: `/api/agents/${encodeURIComponent(fixtures.starterAgentId!)}/expanded`, + token, + }); + expectStoredMarker(visible, markers.conversationStarters); + const blocked = await duplicateAgent(request, token, fixtures.starterAgentId!); + expectContentFilterBlock(blocked, { + source: 'conversation_starter', + field: 'text', + marker: markers.conversationStarters, + }); + }); + + await test.step('conversation titles stay visible, allow a safe override, and block stored-title reuse', async () => { + const visible = await requestResult(request, { + path: `/api/convos/${encodeURIComponent(fixtures.titleConversationId!)}`, + token, + }); + expectStoredMarker(visible, markers.conversationTitles); + + const safeOverride = await duplicateConversation( + request, + token, + fixtures.titleConversationId!, + ); + expectSuccess(safeOverride, 201); + fixtures.conversationIds.push( + requireString( + asObject(asObject(safeOverride.body).conversation).conversationId, + 'safe-title override copied conversation id', + ), + ); + + const blocked = await requestResult(request, { + path: '/api/convos/duplicate', + token, + method: 'POST', + data: { conversationId: fixtures.titleConversationId }, + }); + expectContentFilterBlock(blocked, { + source: 'conversation_title', + field: 'title', + marker: markers.conversationTitles, + }); + }); + + await test.step('feedback stays visible but prevents conversation reuse', async () => { + const visible = await requestResult(request, { + path: `/api/messages/${encodeURIComponent(fixtures.feedbackConversationId!)}`, + token, + }); + expectStoredMarker(visible, markers.feedback); + const blocked = await duplicateConversation( + request, + token, + fixtures.feedbackConversationId!, + ); + expectContentFilterBlock(blocked, { + source: 'feedback', + field: 'text', + marker: markers.feedback, + }); + }); + + await test.step('skills stay visible, allow safe partial edits, and reject protected edits', async () => { + const visible = await requestResult(request, { + path: `/api/skills/${encodeURIComponent(fixtures.skillId!)}`, + token, + }); + expectStoredMarker(visible, markers.skills); + const safeEdit = await requestResult(request, { + path: `/api/skills/${encodeURIComponent(fixtures.skillId!)}`, + token, + method: 'PATCH', + data: { + expectedVersion: fixtures.skillVersion, + description: 'Safe skill metadata remediation edit.', + }, + }); + expectSuccess(safeEdit, 200); + fixtures.skillVersion = requireNumber( + asObject(safeEdit.body).version, + 'updated persisted skill version', + ); + const stillVisible = await requestResult(request, { + path: `/api/skills/${encodeURIComponent(fixtures.skillId!)}`, + token, + }); + expectStoredMarker(stillVisible, markers.skills); + const blocked = await requestResult(request, { + path: `/api/skills/${encodeURIComponent(fixtures.skillId!)}`, + token, + method: 'PATCH', + data: { expectedVersion: fixtures.skillVersion, body: markers.skills }, + }); + expectContentFilterBlock(blocked, { + source: 'skill', + field: 'instructions', + marker: markers.skills, + }); + const copiedAgent = await duplicateAgent(request, token, fixtures.skillAgentId!); + expectSuccess(copiedAgent, 201); + fixtures.agentIds.push( + requireString(asObject(asObject(copiedAgent.body).agent).id, 'copied skill agent id'), + ); + + const skillMessageId = randomUUID(); + const startedSkill = await requestResult(request, { + path: '/api/agents/chat/agents', + token, + method: 'POST', + data: { + text: `Safe persisted-skill runtime request ${suffix}`, + sender: 'User', + clientTimestamp: new Date().toISOString(), + isCreatedByUser: true, + parentMessageId: NO_PARENT, + conversationId: 'new', + messageId: skillMessageId, + responseMessageId: `${skillMessageId}_response`, + endpoint: 'agents', + endpointType: 'agents', + agent_id: fixtures.skillAgentId, + manualSkills: [fixtures.skillName], + isTemporary: false, + isRegenerate: false, + error: false, + }, + }); + fixtures.conversationIds.push( + await expectAsyncFilterStreamError( + request, + token, + startedSkill, + 'E2E persisted skills value', + markers.skills, + ), + ); + }); + + await test.step('message continuation rechecks stored history before model use', async () => { + const continueMessageId = randomUUID(); + const continueResponseMessageId = `${continueMessageId}_response`; + const startedContinue = await requestResult(request, { + path: `/api/agents/chat/${encodeURIComponent(MOCK_ENDPOINTS[0].label)}`, + token, + method: 'POST', + data: { + text: `Safe persisted-message continuation ${suffix}`, + sender: 'User', + clientTimestamp: new Date().toISOString(), + isCreatedByUser: true, + parentMessageId: fixtures.messageId, + conversationId: fixtures.messageConversationId, + messageId: continueMessageId, + responseMessageId: continueResponseMessageId, + endpoint: MOCK_ENDPOINTS[0].label, + endpointType: 'custom', + model: MOCK_ENDPOINTS[0].model, + isTemporary: false, + isRegenerate: false, + error: false, + }, + }); + const continuedConversationId = await expectAsyncFilterStreamError( + request, + token, + startedContinue, + 'E2E persisted messages value', + markers.messages, + ); + expect(continuedConversationId).toBe(fixtures.messageConversationId); + + await withMongo(async (db) => { + const attemptedRows = await db + .collection('messages') + .find({ + conversationId: fixtures.messageConversationId, + messageId: { $in: [continueMessageId, continueResponseMessageId] }, + }) + .toArray(); + expect(attemptedRows).toHaveLength(0); + + const original = await db.collection('messages').findOne({ + conversationId: fixtures.messageConversationId, + messageId: fixtures.messageId, + }); + expect(original).toEqual( + expect.objectContaining({ + messageId: fixtures.messageId, + text: markers.messages, + isCreatedByUser: true, + }), + ); + }); + }); + + await test.step('files stay previewable but prevent reuse by an agent', async () => { + const visible = await requestResult(request, { + path: `/api/files/${encodeURIComponent(fixtures.file!.file_id)}/preview`, + token, + }); + expectSuccess(visible, 200); + expect(asObject(visible.body)).toEqual( + expect.objectContaining({ + file_id: fixtures.file!.file_id, + status: 'ready', + text: markers.files, + }), + ); + expectStoredMarker(visible, markers.files); + const blocked = await duplicateAgent(request, token, fixtures.fileAgentId!); + expectContentFilterBlock(blocked, { + source: 'file', + field: 'extracted_text', + marker: markers.files, + }); + + const opaquePreview = await requestResult(request, { + path: `/api/files/${encodeURIComponent(fixtures.opaqueFile!.file_id)}/preview`, + token, + }); + expectSuccess(opaquePreview, 200); + expect(opaquePreview.body).toEqual({ + file_id: fixtures.opaqueFile!.file_id, + status: 'ready', + }); + + const opaqueBlocked = await duplicateAgent(request, token, fixtures.opaqueFileAgentId!); + expect(opaqueBlocked.status).toBe(400); + expect(opaqueBlocked.body).toEqual({ + error: 'content_filter_uninspectable', + message: 'Submitted file content could not be inspected before processing.', + source: 'file', + field: 'extracted_text', + }); + expect(opaqueBlocked.text).not.toContain(fixtures.opaqueFile!.filepath); + }); + + await test.step('stored tool arguments stay visible but prevent conversation reuse', async () => { + const visible = await requestResult(request, { + path: `/api/messages/${encodeURIComponent(fixtures.toolConversationId!)}`, + token, + }); + expectStoredMarker(visible, markers.toolArguments); + const blocked = await duplicateConversation(request, token, fixtures.toolConversationId!); + expectContentFilterBlock(blocked, { + source: 'tool_argument', + field: 'arguments', + marker: markers.toolArguments, + }); + }); + + await test.step('model parameters stay visible but prevent agent reuse', async () => { + const visible = await requestResult(request, { + path: `/api/agents/${encodeURIComponent(fixtures.modelParameterAgentId!)}/expanded`, + token, + }); + expectStoredMarker(visible, markers.modelParameters); + const blocked = await duplicateAgent(request, token, fixtures.modelParameterAgentId!); + expectContentFilterBlock(blocked, { + source: 'model_parameter', + field: 'stop', + marker: markers.modelParameters, + }); + }); + + await test.step('action metadata stays manageable but prevents agent reuse', async () => { + const visible = await requestResult(request, { path: '/api/agents/actions', token }); + expectStoredMarker(visible, markers.actionMetadata); + const blocked = await duplicateAgent(request, token, fixtures.actionAgentId!); + expectContentFilterBlock(blocked, { + source: 'action_metadata', + field: 'privacy_policy_url', + marker: markers.actionMetadata, + }); + }); + + await test.step('memories stay visible, reject resubmission, and fail closed at runtime', async () => { + const visible = await requestResult(request, { path: '/api/memories', token }); + expectStoredMarker(visible, markers.memories); + const blocked = await requestResult(request, { + path: `/api/memories/${encodeURIComponent( + fixtures.memoryKey!, + )}?agentId=${encodeURIComponent(fixtures.memoryAgentId!)}`, + token, + method: 'PATCH', + data: { value: markers.memories }, + }); + expectContentFilterBlock(blocked, { + source: 'memory', + field: 'value', + marker: markers.memories, + }); + + const memoryMessageId = randomUUID(); + const startedMemory = await requestResult(request, { + path: '/api/agents/chat/agents', + token, + method: 'POST', + data: { + text: `Safe persisted-memory runtime request ${suffix}`, + sender: 'User', + clientTimestamp: new Date().toISOString(), + isCreatedByUser: true, + parentMessageId: NO_PARENT, + conversationId: 'new', + messageId: memoryMessageId, + responseMessageId: `${memoryMessageId}_response`, + endpoint: 'agents', + endpointType: 'agents', + agent_id: fixtures.memoryAgentId, + isTemporary: false, + isRegenerate: false, + error: false, + }, + }); + fixtures.conversationIds.push( + await expectAsyncFilterStreamError( + request, + token, + startedMemory, + 'E2E persisted memories value', + markers.memories, + ), + ); + }); + + await test.step('deactivation restores stored values without destructive mutation', async () => { + await restoreRuntimeFilters(request, token); + filtersActive = false; + + const trackConversation = ( + result: RequestResult, + label: string, + expectedStatus: number, + ): void => { + expectSuccess(result, expectedStatus); + fixtures.conversationIds.push( + requireString(asObject(asObject(result.body).conversation).conversationId, label), + ); + }; + const trackAgent = (result: RequestResult, label: string): void => { + expectSuccess(result, 201); + fixtures.agentIds.push(requireString(asObject(asObject(result.body).agent).id, label)); + }; + const startRecoveredAgent = async ( + agentId: string, + label: string, + extra: JsonObject = {}, + ): Promise => { + const messageId = randomUUID(); + const started = await requestResult(request, { + path: '/api/agents/chat/agents', + token, + method: 'POST', + data: { + text: `Safe post-deactivation ${label} request ${suffix}`, + sender: 'User', + clientTimestamp: new Date().toISOString(), + isCreatedByUser: true, + parentMessageId: NO_PARENT, + conversationId: 'new', + messageId, + responseMessageId: `${messageId}_response`, + endpoint: 'agents', + endpointType: 'agents', + agent_id: agentId, + isTemporary: false, + isRegenerate: false, + error: false, + ...extra, + }, + }); + fixtures.conversationIds.push(await expectAsyncStreamCompleted(request, token, started)); + }; + + const restoredVersions = await requestResult(request, { + path: `/api/prompts?groupId=${encodeURIComponent(fixtures.promptGroupId!)}`, + token, + }); + expectStoredMarker(restoredVersions, markers.prompts); + const restoredPrompt = (Array.isArray(restoredVersions.body) ? restoredVersions.body : []) + .map(asObject) + .find((prompt) => prompt._id === fixtures.promptId); + expect(restoredPrompt).toEqual( + expect.objectContaining({ + _id: fixtures.promptId, + groupId: fixtures.promptGroupId, + prompt: markers.prompts, + }), + ); + expect(restoredPrompt).not.toHaveProperty('contentFilterBlocked'); + + const restoredPromptGroup = await requestResult(request, { + path: `/api/prompts/groups/${encodeURIComponent(fixtures.promptGroupId!)}`, + token, + }); + expectStoredMarker(restoredPromptGroup, markers.prompts); + expect(asObject(asObject(restoredPromptGroup.body).productionPrompt)).toEqual( + expect.objectContaining({ + _id: fixtures.promptId, + groupId: fixtures.promptGroupId, + prompt: markers.prompts, + }), + ); + + const restoredReusablePrompts = await requestResult(request, { + path: '/api/prompts/all', + token, + }); + expectStoredMarker(restoredReusablePrompts, markers.prompts); + expectStoredMarker(restoredReusablePrompts, markers.promptGroupName); + const restoredReusableGroups = Array.isArray(restoredReusablePrompts.body) + ? restoredReusablePrompts.body.map(asObject) + : []; + expect(restoredReusableGroups).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + _id: fixtures.promptGroupId, + productionPrompt: expect.objectContaining({ + _id: fixtures.promptId, + prompt: markers.prompts, + }), + }), + expect.objectContaining({ + _id: fixtures.metadataPromptGroupId, + name: markers.promptGroupName, + }), + ]), + ); + + const restoredPromotion = await requestResult(request, { + path: `/api/prompts/${encodeURIComponent(fixtures.promptId!)}/tags/production`, + token, + method: 'PATCH', + }); + expectSuccess(restoredPromotion, 200); + + const restoredMetadataGroup = await requestResult(request, { + path: `/api/prompts/groups/${encodeURIComponent(fixtures.metadataPromptGroupId!)}`, + token, + }); + expectStoredMarker(restoredMetadataGroup, markers.promptGroupName); + expect(asObject(restoredMetadataGroup.body)._id).toBe(fixtures.metadataPromptGroupId); + + const restoredPresets = await requestResult(request, { path: '/api/presets', token }); + expectStoredMarker(restoredPresets, markers.prompts); + const restoredPreset = (Array.isArray(restoredPresets.body) ? restoredPresets.body : []) + .map(asObject) + .find((preset) => preset.presetId === fixtures.presetId); + expect(restoredPreset).toEqual( + expect.objectContaining({ + presetId: fixtures.presetId, + title: `E2E persisted preset ${suffix}`, + promptPrefix: markers.prompts, + }), + ); + expect(restoredPreset).not.toHaveProperty('contentFilterBlocked'); + + const restoredSkill = await requestResult(request, { + path: `/api/skills/${encodeURIComponent(fixtures.skillId!)}`, + token, + }); + expectStoredMarker(restoredSkill, markers.skills); + expect(asObject(restoredSkill.body)).toEqual( + expect.objectContaining({ + _id: fixtures.skillId, + body: markers.skills, + version: fixtures.skillVersion, + }), + ); + await startRecoveredAgent(fixtures.skillAgentId!, 'persisted skill', { + manualSkills: [fixtures.skillName], + }); + + const restoredMemories = await requestResult(request, { path: '/api/memories', token }); + expectStoredMarker(restoredMemories, markers.memories); + const restoredMemoryItems = Array.isArray(asObject(restoredMemories.body).memories) + ? (asObject(restoredMemories.body).memories as unknown[]).map(asObject) + : []; + expect(restoredMemoryItems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + key: fixtures.memoryKey, + value: markers.memories, + agentId: fixtures.memoryAgentId, + }), + ]), + ); + await startRecoveredAgent(fixtures.memoryAgentId!, 'persisted memory'); + + const restoredAgent = await requestResult(request, { + path: `/api/agents/${encodeURIComponent(fixtures.instructionAgentId!)}/expanded`, + token, + }); + expectStoredMarker(restoredAgent, markers.agentInstructions); + expect(asObject(restoredAgent.body)).toEqual( + expect.objectContaining({ + id: fixtures.instructionAgentId, + instructions: markers.agentInstructions, + }), + ); + trackAgent( + await duplicateAgent(request, token, fixtures.instructionAgentId!), + 'post-deactivation copied instruction agent id', + ); + + const restoredStarterAgent = await requestResult(request, { + path: `/api/agents/${encodeURIComponent(fixtures.starterAgentId!)}/expanded`, + token, + }); + expectStoredMarker(restoredStarterAgent, markers.conversationStarters); + trackAgent( + await duplicateAgent(request, token, fixtures.starterAgentId!), + 'post-deactivation copied starter agent id', + ); + + const restoredTitleConversation = await requestResult(request, { + path: `/api/convos/${encodeURIComponent(fixtures.titleConversationId!)}`, + token, + }); + expectStoredMarker(restoredTitleConversation, markers.conversationTitles); + const restoredTitleCopy = await requestResult(request, { + path: '/api/convos/duplicate', + token, + method: 'POST', + data: { conversationId: fixtures.titleConversationId }, + }); + trackConversation( + restoredTitleCopy, + 'post-deactivation copied stored-title conversation id', + 201, + ); + + const restoredFeedback = await requestResult(request, { + path: `/api/messages/${encodeURIComponent(fixtures.feedbackConversationId!)}`, + token, + }); + expectStoredMarker(restoredFeedback, markers.feedback); + trackConversation( + await duplicateConversation(request, token, fixtures.feedbackConversationId!), + 'post-deactivation copied feedback conversation id', + 201, + ); + + const restoredFile = await requestResult(request, { + path: `/api/files/${encodeURIComponent(fixtures.file!.file_id)}/preview`, + token, + }); + expectSuccess(restoredFile, 200); + expect(asObject(restoredFile.body)).toEqual( + expect.objectContaining({ + file_id: fixtures.file!.file_id, + status: 'ready', + text: markers.files, + }), + ); + expectStoredMarker(restoredFile, markers.files); + const restoredFileAgentCopy = await duplicateAgent(request, token, fixtures.fileAgentId!); + trackAgent(restoredFileAgentCopy, 'post-deactivation copied file agent id'); + + const restoredOpaqueFile = await requestResult(request, { + path: `/api/files/${encodeURIComponent(fixtures.opaqueFile!.file_id)}/preview`, + token, + }); + expectSuccess(restoredOpaqueFile, 200); + expect(restoredOpaqueFile.body).toEqual({ + file_id: fixtures.opaqueFile!.file_id, + status: 'ready', + }); + trackAgent( + await duplicateAgent(request, token, fixtures.opaqueFileAgentId!), + 'post-deactivation copied opaque-file agent id', + ); + + const restoredToolArguments = await requestResult(request, { + path: `/api/messages/${encodeURIComponent(fixtures.toolConversationId!)}`, + token, + }); + expectStoredMarker(restoredToolArguments, markers.toolArguments); + trackConversation( + await duplicateConversation(request, token, fixtures.toolConversationId!), + 'post-deactivation copied tool-argument conversation id', + 201, + ); + + const restoredModelParameters = await requestResult(request, { + path: `/api/agents/${encodeURIComponent(fixtures.modelParameterAgentId!)}/expanded`, + token, + }); + expectStoredMarker(restoredModelParameters, markers.modelParameters); + trackAgent( + await duplicateAgent(request, token, fixtures.modelParameterAgentId!), + 'post-deactivation copied model-parameter agent id', + ); + + const restoredActions = await requestResult(request, { + path: '/api/agents/actions', + token, + }); + expectStoredMarker(restoredActions, markers.actionMetadata); + const restoredActionItems = Array.isArray(restoredActions.body) + ? restoredActions.body.map(asObject) + : []; + expect(restoredActionItems).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + action_id: fixtures.actionId, + agent_id: fixtures.actionAgentId, + metadata: expect.objectContaining({ + privacy_policy_url: markers.actionMetadata, + }), + }), + ]), + ); + trackAgent( + await duplicateAgent(request, token, fixtures.actionAgentId!), + 'post-deactivation copied action agent id', + ); + + const restoredMessages = await requestResult(request, { + path: `/api/messages/${encodeURIComponent(fixtures.messageConversationId!)}`, + token, + }); + expectStoredMarker(restoredMessages, markers.messages); + + const restoredShare = await requestResult(request, { + path: `/api/share/${encodeURIComponent(fixtures.messageShareId!)}`, + }); + expectStoredMarker(restoredShare, markers.messages); + + const reusableCopy = await duplicateConversation( + request, + token, + fixtures.messageConversationId!, + ); + trackConversation(reusableCopy, 'post-deactivation copied message conversation id', 201); + + const restoredFork = await requestResult(request, { + path: '/api/convos/fork', + token, + method: 'POST', + data: { + conversationId: fixtures.messageConversationId, + messageId: fixtures.messageId, + option: 'directPath', + }, + }); + trackConversation(restoredFork, 'post-deactivation message fork id', 200); + + const restoredSharedFork = await requestResult(request, { + path: `/api/share/${encodeURIComponent(fixtures.messageShareId!)}/fork`, + token, + method: 'POST', + data: {}, + }); + trackConversation(restoredSharedFork, 'post-deactivation shared-message fork id', 201); + }); + } finally { + try { + if (filtersAttempted || filtersActive) { + await restoreRuntimeFilters(request, token); + filtersActive = false; + } + } finally { + await cleanupFixtures(request, token, fixtures); + } + } + }); + + test('rechecks a provider-backed Assistant created before policy activation', async ({ + request, + }) => { + test.setTimeout(240000); + + const token = await loginAdmin(request); + const suffix = `${Date.now()}-${Math.floor(Math.random() * 10000)}`; + const marker = `E2E-PERSISTED-ASSISTANT-INSTRUCTION-${suffix}`; + const blockedChatText = `Safe existing Assistant invocation ${suffix}`; + const assistantProviderURL = `http://127.0.0.1:${process.env.E2E_ASSISTANTS_PORT || '8890'}`; + const conversationIds = new Set(); + let assistantId: string | undefined; + let filtersAttempted = false; + let filtersActive = false; + + const filters = { + agentInstructions: { + pii: { + fields: ['instructions'], + starterPatterns: [], + customPatterns: [ + { + id: `e2e-persisted-assistant-${suffix}`, + label: 'E2E persisted Assistant instruction', + regex: `^${marker}$`, + }, + ], + }, + }, + } as FiltersConfig; + const assistantChat = async (text: string): Promise => { + const messageId = randomUUID(); + return requestResult(request, { + path: '/api/assistants/v2/chat', + token, + method: 'POST', + data: { + text, + sender: 'User', + clientTimestamp: new Date().toISOString(), + isCreatedByUser: true, + parentMessageId: NO_PARENT, + conversationId: null, + messageId, + responseMessageId: `${messageId}_response`, + endpoint: 'assistants', + endpointType: 'assistants', + model: 'gpt-4o-mini', + assistant_id: assistantId, + files: [], + isTemporary: false, + isRegenerate: false, + error: false, + }, + }); + }; + const expectAssistantChatCompleted = ( + result: RequestResult, + expectedReply: string, + label: string, + ): void => { + expectSuccess(result, 200); + expect(result.text).toContain(expectedReply); + expect(result.text).not.toContain('event: error'); + const conversationId = result.text.match(/"conversationId":"([^"]+)"/)?.[1]; + conversationIds.add(requireString(conversationId, label)); + }; + + try { + await restoreRuntimeFilters(request, token); + const resetProvider = await requestResult(request, { + path: `${assistantProviderURL}/__e2e/reset`, + method: 'POST', + }); + expectSuccess(resetProvider, 200); + + const created = await requestResult(request, { + path: '/api/assistants/v2', + token, + method: 'POST', + data: { + endpoint: 'assistants', + model: 'gpt-4o-mini', + name: `E2E persisted Assistant ${suffix}`, + description: 'Created before persisted-content policy activation.', + instructions: marker, + tools: [], + conversation_starters: ['Ask a safe question'], + }, + }); + expectSuccess(created, 201); + assistantId = requireString(asObject(created.body).id, 'persisted Assistant id'); + + const preActivationRead = await requestResult(request, { + path: `/api/assistants/v2/${encodeURIComponent(assistantId)}?endpoint=assistants`, + token, + }); + expectStoredMarker(preActivationRead, marker); + expect(asObject(preActivationRead.body)).toEqual( + expect.objectContaining({ + id: assistantId, + instructions: marker, + model: 'gpt-4o-mini', + }), + ); + + filtersAttempted = true; + await setRuntimeFilters(request, token, filters); + filtersActive = true; + + const safePartialEdit = await requestResult(request, { + path: `/api/assistants/v2/${encodeURIComponent(assistantId)}`, + token, + method: 'PATCH', + data: { + endpoint: 'assistants', + description: 'Safe remediation metadata edit while policy is active.', + }, + }); + expectSuccess(safePartialEdit, 200); + expect(asObject(safePartialEdit.body)).toEqual( + expect.objectContaining({ + id: assistantId, + description: 'Safe remediation metadata edit while policy is active.', + instructions: marker, + }), + ); + + const activePolicyRead = await requestResult(request, { + path: `/api/assistants/v2/${encodeURIComponent(assistantId)}?endpoint=assistants`, + token, + }); + expectStoredMarker(activePolicyRead, marker); + + const blockedInvocation = await assistantChat(blockedChatText); + expectContentFilterBlock(blockedInvocation, { + source: 'agent_instruction', + field: 'instructions', + marker, + }); + + await withMongo(async (db) => { + expect(await db.collection('messages').countDocuments({ text: blockedChatText })).toBe(0); + }); + const blockedProviderRequests = await requestResult(request, { + path: `${assistantProviderURL}/__e2e/requests`, + }); + expectSuccess(blockedProviderRequests, 200); + const blockedProviderRequestItems = Array.isArray( + asObject(blockedProviderRequests.body).requests, + ) + ? (asObject(blockedProviderRequests.body).requests as unknown[]).map(asObject) + : []; + expect( + blockedProviderRequestItems.some((item) => + String(item.path).match(/^\/v1\/threads(?:\/|$)/), + ), + ).toBe(false); + + await restoreRuntimeFilters(request, token); + filtersActive = false; + + const rollbackRead = await requestResult(request, { + path: `/api/assistants/v2/${encodeURIComponent(assistantId)}?endpoint=assistants`, + token, + }); + expectStoredMarker(rollbackRead, marker); + expectAssistantChatCompleted( + await assistantChat('E2E_REPLY:rollback'), + 'E2E assistant reply rollback', + 'post-deactivation Assistant conversation id', + ); + + await setRuntimeFilters(request, token, filters); + filtersActive = true; + const remediated = await requestResult(request, { + path: `/api/assistants/v2/${encodeURIComponent(assistantId)}`, + token, + method: 'PATCH', + data: { + endpoint: 'assistants', + instructions: 'Safe recovered Assistant instructions.', + }, + }); + expectSuccess(remediated, 200); + expect(asObject(remediated.body)).toEqual( + expect.objectContaining({ + id: assistantId, + instructions: 'Safe recovered Assistant instructions.', + }), + ); + expectAssistantChatCompleted( + await assistantChat('E2E_REPLY:remediation'), + 'E2E assistant reply remediation', + 'post-remediation Assistant conversation id', + ); + } finally { + try { + if (filtersAttempted || filtersActive) { + await restoreRuntimeFilters(request, token); + filtersActive = false; + } + } finally { + if (assistantId) { + const deleted = await requestResult(request, { + path: `/api/assistants/v2/${encodeURIComponent( + assistantId, + )}?endpoint=assistants&model=gpt-4o-mini`, + token, + method: 'DELETE', + data: { endpoint: 'assistants' }, + }); + expectSuccess(deleted, 200); + expect(deleted.body).toEqual(expect.objectContaining({ id: assistantId, deleted: true })); + } + for (const conversationId of conversationIds) { + await requestResult(request, { + path: '/api/convos', + token, + method: 'DELETE', + data: { arg: { conversationId } }, + }); + } + } + } + }); + + test('applies configurable attribution to pre-upgrade assistant messages', async ({ + request, + }) => { + test.setTimeout(240000); + + const token = await loginAdmin(request); + const suffix = `${Date.now()}-${Math.floor(Math.random() * 10000)}`; + const marker = `E2E-PERSISTED-UNATTRIBUTED-ASSISTANT-${suffix}`; + const legacyMessageId = randomUUID(); + const explicitModelMessageId = randomUUID(); + const seedMessageId = randomUUID(); + const seedResponseMessageId = randomUUID(); + const createdConversationIds = new Set(); + let sourceConversationId: string | undefined; + let sourceUser: unknown; + let filtersAttempted = false; + let filtersActive = false; + + const customPattern = { + id: `e2e-unattributed-assistant-${suffix}`, + label: 'E2E unattributed assistant content', + regex: `^${marker}$`, + }; + const filtersFor = ( + unattributedAssistantContent?: 'model_output' | 'inspect', + ): FiltersConfig => ({ + messages: { + pii: { + fields: ['text'], + starterPatterns: [], + customPatterns: [customPattern], + }, + ...(unattributedAssistantContent ? { unattributedAssistantContent } : {}), + }, + }); + const applyFilters = async (filters: FiltersConfig): Promise => { + filtersAttempted = true; + await setRuntimeFilters(request, token, filters); + filtersActive = true; + }; + const trackFork = (result: RequestResult, label: string): void => { + expectSuccess(result, 200); + createdConversationIds.add( + requireString(asObject(asObject(result.body).conversation).conversationId, label), + ); + }; + const forkBranch = async (messageId: string): Promise => + requestResult(request, { + path: '/api/convos/fork', + token, + method: 'POST', + data: { + conversationId: sourceConversationId, + messageId, + option: 'directPath', + }, + }); + + try { + await restoreRuntimeFilters(request, token); + + const startedSeed = await requestResult(request, { + path: `/api/agents/chat/${encodeURIComponent(MOCK_ENDPOINTS[0].label)}`, + token, + method: 'POST', + data: { + text: replyPrompt(`legacy-attribution-seed-${suffix}`), + sender: 'User', + clientTimestamp: new Date().toISOString(), + isCreatedByUser: true, + parentMessageId: NO_PARENT, + conversationId: 'new', + messageId: seedMessageId, + responseMessageId: seedResponseMessageId, + endpoint: MOCK_ENDPOINTS[0].label, + endpointType: 'custom', + model: MOCK_ENDPOINTS[0].model, + isTemporary: false, + isRegenerate: false, + error: false, + }, + }); + sourceConversationId = await expectAsyncStreamCompleted(request, token, startedSeed); + createdConversationIds.add(sourceConversationId); + + await expect + .poll( + async () => { + const messages = await requestResult(request, { + path: `/api/messages/${encodeURIComponent(sourceConversationId!)}`, + token, + }); + expectSuccess(messages, 200); + return (Array.isArray(messages.body) ? messages.body : []) + .map(asObject) + .some((message) => message.messageId === seedResponseMessageId); + }, + { timeout: 30000, intervals: [100, 250, 500, 1000] }, + ) + .toBe(true); + + await withMongo(async (db) => { + const seed = await db.collection('messages').findOne({ + conversationId: sourceConversationId, + messageId: seedResponseMessageId, + }); + if (!seed) { + throw new Error('Expected completed seed response in MongoDB'); + } + expect(seed).toEqual( + expect.objectContaining({ + messageId: seedResponseMessageId, + isCreatedByUser: false, + isUserSubmitted: false, + }), + ); + sourceUser = seed.user; + const now = Date.now(); + const shared = { + conversationId: sourceConversationId, + user: seed.user, + ...(typeof seed.tenantId === 'string' ? { tenantId: seed.tenantId } : {}), + endpoint: seed.endpoint, + model: seed.model, + parentMessageId: seedResponseMessageId, + sender: 'Assistant', + text: marker, + isCreatedByUser: false, + isTemporary: false, + unfinished: false, + error: false, + }; + await db.collection('messages').insertMany([ + { + ...shared, + messageId: legacyMessageId, + createdAt: new Date(now + 1), + updatedAt: new Date(now + 1), + }, + { + ...shared, + messageId: explicitModelMessageId, + isUserSubmitted: false, + createdAt: new Date(now + 2), + updatedAt: new Date(now + 2), + }, + ]); + }); + + await applyFilters(filtersFor()); + trackFork(await forkBranch(legacyMessageId), 'default-attribution legacy assistant fork id'); + + await applyFilters(filtersFor('model_output')); + trackFork( + await forkBranch(legacyMessageId), + 'explicit-model-output legacy assistant fork id', + ); + + await applyFilters(filtersFor('inspect')); + trackFork( + await forkBranch(explicitModelMessageId), + 'strict-attribution explicit model-output fork id', + ); + const blockedLegacy = await forkBranch(legacyMessageId); + expectContentFilterBlock(blockedLegacy, { + source: 'message', + field: 'text', + marker, + }); + + const strictContinuationMessageId = randomUUID(); + const startedStrictContinuation = await requestResult(request, { + path: `/api/agents/chat/${encodeURIComponent(MOCK_ENDPOINTS[0].label)}`, + token, + method: 'POST', + data: { + text: `Safe strict legacy continuation ${suffix}`, + sender: 'User', + clientTimestamp: new Date().toISOString(), + isCreatedByUser: true, + parentMessageId: legacyMessageId, + conversationId: sourceConversationId, + messageId: strictContinuationMessageId, + responseMessageId: `${strictContinuationMessageId}_response`, + endpoint: MOCK_ENDPOINTS[0].label, + endpointType: 'custom', + model: MOCK_ENDPOINTS[0].model, + isTemporary: false, + isRegenerate: false, + error: false, + }, + }); + const strictContinuationConversationId = await expectAsyncFilterStreamError( + request, + token, + startedStrictContinuation, + 'E2E unattributed assistant content', + marker, + ); + expect(strictContinuationConversationId).toBe(sourceConversationId); + + await restoreRuntimeFilters(request, token); + filtersActive = false; + trackFork(await forkBranch(legacyMessageId), 'post-deactivation legacy assistant fork id'); + + await withMongo(async (db) => { + const rows = await db + .collection('messages') + .find({ + conversationId: sourceConversationId, + messageId: { $in: [legacyMessageId, explicitModelMessageId] }, + }) + .toArray(); + expect(rows).toHaveLength(2); + const legacy = rows.find((row) => row.messageId === legacyMessageId); + const explicitModel = rows.find((row) => row.messageId === explicitModelMessageId); + expect(legacy).toEqual(expect.objectContaining({ text: marker, isCreatedByUser: false })); + expect(legacy).not.toHaveProperty('isUserSubmitted'); + expect(legacy).not.toHaveProperty('userSubmittedPaths'); + expect(explicitModel).toEqual( + expect.objectContaining({ + text: marker, + isCreatedByUser: false, + isUserSubmitted: false, + }), + ); + expect(explicitModel).not.toHaveProperty('userSubmittedPaths'); + }); + } finally { + try { + if (filtersAttempted || filtersActive) { + await restoreRuntimeFilters(request, token); + filtersActive = false; + } + } finally { + for (const conversationId of createdConversationIds) { + await requestResult(request, { + path: '/api/convos', + token, + method: 'DELETE', + data: { arg: { conversationId } }, + }); + } + if (sourceConversationId && sourceUser != null) { + await withMongo(async (db) => { + const scope = { conversationId: sourceConversationId, user: sourceUser }; + await db.collection('messages').deleteMany(scope); + await db.collection('conversations').deleteMany(scope); + }); + } else { + await withMongo(async (db) => { + await db.collection('messages').deleteMany({ + messageId: { $in: [legacyMessageId, explicitModelMessageId] }, + }); + }); + } + } + } + }); +}); diff --git a/e2e/specs/mock/content-filters.submissions.spec.ts b/e2e/specs/mock/content-filters.submissions.spec.ts new file mode 100644 index 0000000000..2c95e87486 --- /dev/null +++ b/e2e/specs/mock/content-filters.submissions.spec.ts @@ -0,0 +1,888 @@ +import { randomUUID } from 'crypto'; +import { expect, test } from '@playwright/test'; +import type { APIRequestContext } from '@playwright/test'; +import type { FiltersConfig } from 'librechat-data-provider'; +import { withMongo } from './db'; +import { MOCK_ENDPOINTS } from './helpers'; +import { + expectContentFilterBlock, + loginAdmin, + requestResult, + restoreRuntimeFilters, + setRuntimeFilters, +} from './content-filters.helpers'; + +const NO_PARENT = '00000000-0000-0000-0000-000000000000'; + +type JsonObject = Record; +type RequestResult = Awaited>; + +const asObject = (value: unknown): JsonObject => + value != null && typeof value === 'object' && !Array.isArray(value) ? (value as JsonObject) : {}; + +const expectSuccess = (result: RequestResult, status?: number) => { + expect(result.ok, result.text).toBe(true); + if (status != null) { + expect(result.status, result.text).toBe(status); + } +}; + +async function expectNoStoredDocument( + collection: string, + filter: JsonObject, + label: string, +): Promise { + await withMongo(async (db) => { + expect(await db.collection(collection).findOne(filter), label).toBeNull(); + }); +} + +async function expectAsyncStreamCompleted( + request: APIRequestContext, + token: string, + started: RequestResult, +): Promise { + expectSuccess(started, 200); + const startBody = asObject(started.body); + expect(startBody.status).toBe('started'); + expect(typeof startBody.conversationId).toBe('string'); + expect(typeof startBody.streamId).toBe('string'); + const conversationId = startBody.conversationId as string; + const streamId = startBody.streamId as string; + + await expect + .poll( + async () => { + const status = await requestResult(request, { + path: `/api/agents/chat/status/${encodeURIComponent(conversationId)}`, + token, + }); + if (status.status === 503) { + return { active: true, status: 'pending' }; + } + expectSuccess(status, 200); + const statusBody = asObject(status.body); + return { active: statusBody.active, status: statusBody.status }; + }, + { timeout: 30000, intervals: [100, 250, 500, 1000] }, + ) + .toEqual({ active: false, status: 'complete' }); + + const stream = await requestResult(request, { + path: `/api/agents/chat/stream/${encodeURIComponent(streamId)}?resume=true`, + token, + }); + expectSuccess(stream, 200); + expect(stream.text).not.toContain('event: error'); + return conversationId; +} + +const createAgentPayload = (suffix: string, overrides: JsonObject = {}) => ({ + name: `E2E content-filter agent ${suffix}`, + description: 'Safe agent used by the content-filter submission matrix.', + instructions: 'Keep this reusable test agent safe and deterministic.', + provider: MOCK_ENDPOINTS[0].label, + model: MOCK_ENDPOINTS[0].model, + model_parameters: {}, + tools: [], + conversation_starters: ['Ask a safe question'], + ...overrides, +}); + +test.describe.serial('source-aware content filters', () => { + test('rejects fresh protected submissions for each configured source', async ({ request }) => { + test.setTimeout(180000); + + const suffix = `${Date.now()}-${Math.floor(Math.random() * 10000)}`; + const markers = { + messages: `E2E-CF-MESSAGE-${suffix}`, + prompts: `E2E-CF-PROMPT-${suffix}`, + agentInstructions: `E2E-CF-AGENT-INSTRUCTION-${suffix}`, + conversationStarters: `E2E-CF-CONVERSATION-STARTER-${suffix}`, + conversationTitles: `E2E-CF-CONVERSATION-TITLE-${suffix}`, + feedback: `E2E-CF-FEEDBACK-${suffix}`, + skills: `E2E-CF-SKILL-${suffix}`, + memories: `E2E-CF-MEMORY-${suffix}`, + files: `E2E-CF-FILE-${suffix}`, + toolArguments: `E2E-CF-TOOL-ARGUMENT-${suffix}`, + modelParameters: `E2E-CF-MODEL-PARAMETER-${suffix}`, + actionMetadata: `E2E-CF-ACTION-METADATA-${suffix}`, + } as const; + const memoryKeySuffix = Array.from(randomUUID().replace(/-/g, ''), (character) => + String.fromCharCode(97 + Number.parseInt(character, 16)), + ).join(''); + + const pii = (id: string, field: string, marker: string) => ({ + fields: [field], + starterPatterns: [], + customPatterns: [ + { + id: `e2e-${id}-${suffix}`, + label: 'E2E protected value', + regex: `^${marker}$`, + }, + ], + }); + + const filters = { + messages: { pii: pii('messages', 'text', markers.messages) }, + prompts: { pii: pii('prompts', 'text', markers.prompts) }, + agentInstructions: { + pii: pii('agent-instructions', 'instructions', markers.agentInstructions), + }, + conversationStarters: { + pii: pii('conversation-starters', 'text', markers.conversationStarters), + }, + conversationTitles: { + pii: pii('conversation-titles', 'title', markers.conversationTitles), + }, + feedback: { pii: pii('feedback', 'text', markers.feedback) }, + skills: { pii: pii('skills', 'instructions', markers.skills) }, + memories: { pii: pii('memories', 'value', markers.memories) }, + files: { pii: pii('files', 'content', markers.files) }, + toolArguments: { + pii: pii('tool-arguments', 'arguments', markers.toolArguments), + }, + modelParameters: { + pii: pii('model-parameters', 'stop', markers.modelParameters), + }, + actionMetadata: { + pii: pii('action-metadata', 'privacy_policy_url', markers.actionMetadata), + }, + } as FiltersConfig; + + const token = await loginAdmin(request); + let filtersAttempted = false; + let filtersActive = false; + let conversationId: string | undefined; + let safeUserMessageId: string | undefined; + let promptGroupId: string | undefined; + let agentId: string | undefined; + let skillId: string | undefined; + let memoryKey: string | undefined; + let uploadedFile: { file_id: string; filepath: string } | undefined; + let actionId: string | undefined; + + try { + filtersAttempted = true; + await setRuntimeFilters(request, token, filters); + filtersActive = true; + + await test.step('messages', async () => { + const blockedMessageId = randomUUID(); + const blocked = await requestResult(request, { + path: `/api/agents/chat/${encodeURIComponent(MOCK_ENDPOINTS[0].label)}`, + token, + method: 'POST', + data: { + text: markers.messages, + sender: 'User', + clientTimestamp: new Date().toISOString(), + isCreatedByUser: true, + parentMessageId: NO_PARENT, + conversationId: 'new', + messageId: blockedMessageId, + responseMessageId: `${blockedMessageId}_response`, + endpoint: MOCK_ENDPOINTS[0].label, + endpointType: 'custom', + model: MOCK_ENDPOINTS[0].model, + isTemporary: false, + isRegenerate: false, + error: false, + }, + }); + expectContentFilterBlock(blocked, { + source: 'message', + field: 'text', + marker: markers.messages, + }); + await expectNoStoredDocument( + 'messages', + { messageId: blockedMessageId }, + 'Blocked chat message must not be persisted', + ); + + const chatMessageId = randomUUID(); + const chat = await requestResult(request, { + path: `/api/agents/chat/${encodeURIComponent(MOCK_ENDPOINTS[0].label)}`, + token, + method: 'POST', + data: { + text: `Safe content-filter conversation control ${suffix}`, + sender: 'User', + clientTimestamp: new Date().toISOString(), + isCreatedByUser: true, + parentMessageId: NO_PARENT, + conversationId: 'new', + messageId: chatMessageId, + responseMessageId: `${chatMessageId}_response`, + endpoint: MOCK_ENDPOINTS[0].label, + endpointType: 'custom', + model: MOCK_ENDPOINTS[0].model, + isTemporary: false, + isRegenerate: false, + error: false, + }, + }); + conversationId = await expectAsyncStreamCompleted(request, token, chat); + + safeUserMessageId = randomUUID(); + const safe = await requestResult(request, { + path: `/api/messages/${encodeURIComponent(conversationId!)}`, + token, + method: 'POST', + data: { + text: `Safe content-filter control ${suffix}`, + name: markers.messages, + sender: 'User', + clientTimestamp: new Date().toISOString(), + isCreatedByUser: true, + parentMessageId: NO_PARENT, + conversationId, + messageId: safeUserMessageId, + endpoint: MOCK_ENDPOINTS[0].label, + model: MOCK_ENDPOINTS[0].model, + isTemporary: false, + error: false, + }, + }); + expectSuccess(safe, 201); + }); + + await test.step('prompts', async () => { + const blockedGroupName = `E2E blocked prompt ${suffix}`; + const blocked = await requestResult(request, { + path: '/api/prompts', + token, + method: 'POST', + data: { + prompt: { prompt: markers.prompts, type: 'text' }, + group: { name: blockedGroupName }, + }, + }); + expectContentFilterBlock(blocked, { + source: 'prompt', + field: 'text', + marker: markers.prompts, + }); + await expectNoStoredDocument( + 'prompts', + { prompt: markers.prompts }, + 'Blocked prompt must not be persisted', + ); + await expectNoStoredDocument( + 'promptgroups', + { name: blockedGroupName }, + 'Blocked prompt group must not be persisted', + ); + + const safe = await requestResult(request, { + path: '/api/prompts', + token, + method: 'POST', + data: { + prompt: { prompt: 'A safe reusable prompt.', type: 'text' }, + group: { name: markers.prompts }, + }, + }); + expectSuccess(safe, 200); + const safeBody = asObject(safe.body); + const group = asObject(safeBody.group); + const prompt = asObject(safeBody.prompt); + promptGroupId = (group._id ?? prompt.groupId) as string | undefined; + expect(promptGroupId).toBeTruthy(); + }); + + await test.step('agent instructions', async () => { + const blockedAgentName = `E2E content-filter agent ${suffix}-blocked-instructions`; + const blocked = await requestResult(request, { + path: '/api/agents', + token, + method: 'POST', + data: createAgentPayload(`${suffix}-blocked-instructions`, { + instructions: markers.agentInstructions, + }), + }); + expectContentFilterBlock(blocked, { + source: 'agent_instruction', + field: 'instructions', + marker: markers.agentInstructions, + }); + await expectNoStoredDocument( + 'agents', + { name: blockedAgentName }, + 'Blocked agent must not be persisted', + ); + + const blockedAssistantName = `E2E blocked assistant ${suffix}`; + const blockedAssistant = await requestResult(request, { + path: '/api/assistants/v1', + token, + method: 'POST', + data: { name: blockedAssistantName, instructions: markers.agentInstructions }, + }); + expectContentFilterBlock(blockedAssistant, { + source: 'agent_instruction', + field: 'instructions', + marker: markers.agentInstructions, + }); + await expectNoStoredDocument( + 'assistants', + { name: blockedAssistantName }, + 'Blocked assistant must not be persisted', + ); + + const safe = await requestResult(request, { + path: '/api/agents', + token, + method: 'POST', + data: createAgentPayload(`${suffix}-safe`, { + description: markers.agentInstructions, + }), + }); + expectSuccess(safe, 201); + agentId = asObject(safe.body).id as string | undefined; + expect(agentId).toBeTruthy(); + }); + + await test.step('conversation starters', async () => { + const blockedAgentName = `E2E content-filter agent ${suffix}-blocked-starter`; + const blocked = await requestResult(request, { + path: '/api/agents', + token, + method: 'POST', + data: createAgentPayload(`${suffix}-blocked-starter`, { + conversation_starters: [markers.conversationStarters], + }), + }); + expectContentFilterBlock(blocked, { + source: 'conversation_starter', + field: 'text', + marker: markers.conversationStarters, + }); + await expectNoStoredDocument( + 'agents', + { name: blockedAgentName }, + 'Agent with a blocked conversation starter must not be persisted', + ); + + const safe = await requestResult(request, { + path: `/api/agents/${encodeURIComponent(agentId!)}`, + token, + method: 'PATCH', + data: { conversation_starters: ['A safe conversation starter'] }, + }); + expectSuccess(safe, 200); + }); + + await test.step('conversation titles', async () => { + const blocked = await requestResult(request, { + path: '/api/convos/update', + token, + method: 'POST', + data: { arg: { conversationId, title: markers.conversationTitles } }, + }); + expectContentFilterBlock(blocked, { + source: 'conversation_title', + field: 'title', + marker: markers.conversationTitles, + }); + await expectNoStoredDocument( + 'conversations', + { conversationId, title: markers.conversationTitles }, + 'Blocked conversation title must not be persisted', + ); + + const safe = await requestResult(request, { + path: '/api/convos/update', + token, + method: 'POST', + data: { arg: { conversationId, title: `E2E safe title ${suffix}` } }, + }); + expectSuccess(safe, 201); + }); + + await test.step('feedback', async () => { + const path = `/api/messages/${encodeURIComponent(conversationId!)}/${encodeURIComponent( + safeUserMessageId!, + )}/feedback`; + const blocked = await requestResult(request, { + path, + token, + method: 'PUT', + data: { + feedback: { rating: 'thumbsDown', tag: 'other', text: markers.feedback }, + }, + }); + expectContentFilterBlock(blocked, { + source: 'feedback', + field: 'text', + marker: markers.feedback, + }); + await expectNoStoredDocument( + 'messages', + { messageId: safeUserMessageId, 'feedback.text': markers.feedback }, + 'Blocked feedback must not be persisted', + ); + + const safe = await requestResult(request, { + path, + token, + method: 'PUT', + data: { + feedback: { rating: 'thumbsDown', tag: 'other', text: 'Safe feedback.' }, + }, + }); + expectSuccess(safe, 200); + }); + + await test.step('skills', async () => { + const blockedSkillName = `e2e-blocked-skill-${suffix}`; + const blocked = await requestResult(request, { + path: '/api/skills', + token, + method: 'POST', + data: { + name: blockedSkillName, + description: 'Blocked skill submission control.', + body: markers.skills, + }, + }); + expectContentFilterBlock(blocked, { + source: 'skill', + field: 'instructions', + marker: markers.skills, + }); + await expectNoStoredDocument( + 'skills', + { name: blockedSkillName }, + 'Blocked skill must not be persisted', + ); + + const safe = await requestResult(request, { + path: '/api/skills', + token, + method: 'POST', + data: { + name: `e2e-safe-skill-${suffix}`, + description: markers.skills, + body: 'Use only safe deterministic content.', + }, + }); + expectSuccess(safe, 201); + skillId = asObject(safe.body)._id as string | undefined; + expect(skillId).toBeTruthy(); + }); + + await test.step('memories', async () => { + const blockedMemoryKey = `e_to_e_blocked_memory_${memoryKeySuffix}`; + const blocked = await requestResult(request, { + path: '/api/memories', + token, + method: 'POST', + data: { + key: blockedMemoryKey, + value: markers.memories, + }, + }); + expectContentFilterBlock(blocked, { + source: 'memory', + field: 'value', + marker: markers.memories, + }); + await expectNoStoredDocument( + 'memoryentries', + { key: blockedMemoryKey }, + 'Blocked memory must not be persisted', + ); + + memoryKey = `e_to_e_safe_memory_${memoryKeySuffix}`; + const safe = await requestResult(request, { + path: '/api/memories', + token, + method: 'POST', + data: { key: memoryKey, value: 'Safe memory value.' }, + }); + expectSuccess(safe, 201); + }); + + await test.step('files', async () => { + const blockedFileId = randomUUID(); + const blocked = await requestResult(request, { + path: '/api/files', + token, + method: 'POST', + multipart: { + endpoint: MOCK_ENDPOINTS[0].label, + endpointType: 'custom', + message_file: 'true', + file_id: blockedFileId, + file: { + name: `e2e-blocked-${suffix}.txt`, + mimeType: 'text/plain', + buffer: Buffer.from(markers.files), + }, + }, + }); + expectContentFilterBlock(blocked, { + source: 'file', + field: 'content', + marker: markers.files, + }); + await expectNoStoredDocument( + 'files', + { file_id: blockedFileId }, + 'Blocked file must not be persisted', + ); + + const safe = await requestResult(request, { + path: '/api/files', + token, + method: 'POST', + multipart: { + endpoint: MOCK_ENDPOINTS[0].label, + endpointType: 'custom', + message_file: 'true', + file_id: randomUUID(), + file: { + name: markers.files, + mimeType: 'text/plain', + buffer: Buffer.from('Safe file content.'), + }, + }, + }); + expectSuccess(safe, 200); + const safeBody = asObject(safe.body); + if (typeof safeBody.file_id === 'string' && typeof safeBody.filepath === 'string') { + uploadedFile = { file_id: safeBody.file_id, filepath: safeBody.filepath }; + } + expect(uploadedFile).toBeTruthy(); + }); + + await test.step('tool arguments', async () => { + const messagePath = `/api/messages/${encodeURIComponent(conversationId!)}`; + const blockedToolMessageId = randomUUID(); + const blocked = await requestResult(request, { + path: messagePath, + token, + method: 'POST', + data: { + messageId: blockedToolMessageId, + parentMessageId: safeUserMessageId, + sender: 'User', + endpoint: MOCK_ENDPOINTS[0].label, + model: MOCK_ENDPOINTS[0].model, + isCreatedByUser: true, + content: [ + { + type: 'tool_call', + tool_call: { + id: `call_blocked_${suffix}`, + name: 'safe_lookup', + args: markers.toolArguments, + }, + }, + ], + }, + }); + expectContentFilterBlock(blocked, { + source: 'tool_argument', + field: 'arguments', + marker: markers.toolArguments, + }); + await expectNoStoredDocument( + 'messages', + { messageId: blockedToolMessageId }, + 'Message with blocked tool arguments must not be persisted', + ); + + const safe = await requestResult(request, { + path: messagePath, + token, + method: 'POST', + data: { + messageId: randomUUID(), + parentMessageId: safeUserMessageId, + sender: 'User', + endpoint: MOCK_ENDPOINTS[0].label, + model: MOCK_ENDPOINTS[0].model, + isCreatedByUser: true, + content: [ + { + type: 'tool_call', + tool_call: { + id: `call_safe_${suffix}`, + name: 'safe_lookup', + args: '{"query":"safe"}', + }, + }, + ], + }, + }); + expectSuccess(safe, 201); + }); + + await test.step('model parameters', async () => { + const blockedAgentName = `E2E content-filter agent ${suffix}-blocked-model-parameters`; + const blocked = await requestResult(request, { + path: '/api/agents', + token, + method: 'POST', + data: createAgentPayload(`${suffix}-blocked-model-parameters`, { + model_parameters: { stop: [markers.modelParameters] }, + }), + }); + expectContentFilterBlock(blocked, { + source: 'model_parameter', + field: 'stop', + marker: markers.modelParameters, + }); + await expectNoStoredDocument( + 'agents', + { name: blockedAgentName }, + 'Agent with blocked model parameters must not be persisted', + ); + + const safe = await requestResult(request, { + path: `/api/agents/${encodeURIComponent(agentId!)}`, + token, + method: 'PATCH', + data: { model_parameters: { stop: ['SAFE-STOP-SEQUENCE'] } }, + }); + expectSuccess(safe, 200); + }); + + await test.step('action metadata', async () => { + const actionPayload = (privacyPolicyUrl: string) => ({ + functions: [ + { + type: 'function', + function: { + name: `safe_lookup_${suffix.replace(/-/g, '_')}`, + description: 'Return a safe deterministic lookup result.', + parameters: { type: 'object', properties: {} }, + }, + }, + ], + metadata: { + domain: 'https://example.com', + privacy_policy_url: privacyPolicyUrl, + }, + }); + + const blocked = await requestResult(request, { + path: `/api/agents/actions/${encodeURIComponent(agentId!)}`, + token, + method: 'POST', + data: actionPayload(markers.actionMetadata), + }); + expectContentFilterBlock(blocked, { + source: 'action_metadata', + field: 'privacy_policy_url', + marker: markers.actionMetadata, + }); + await expectNoStoredDocument( + 'actions', + { agent_id: agentId, 'metadata.privacy_policy_url': markers.actionMetadata }, + 'Action with blocked metadata must not be persisted', + ); + + const safe = await requestResult(request, { + path: `/api/agents/actions/${encodeURIComponent(agentId!)}`, + token, + method: 'POST', + data: actionPayload('https://example.com/privacy'), + }); + expectSuccess(safe, 200); + const responseItems = Array.isArray(safe.body) ? safe.body : []; + actionId = asObject(responseItems[1]).action_id as string | undefined; + expect(actionId).toBeTruthy(); + }); + } finally { + try { + if (filtersAttempted || filtersActive) { + await restoreRuntimeFilters(request, token); + filtersActive = false; + } + } finally { + if (actionId && agentId) { + await requestResult(request, { + path: `/api/agents/actions/${encodeURIComponent(agentId)}/${encodeURIComponent(actionId)}`, + token, + method: 'DELETE', + }); + } + if (uploadedFile) { + await requestResult(request, { + path: '/api/files', + token, + method: 'DELETE', + data: { files: [uploadedFile] }, + }); + } + if (memoryKey) { + await requestResult(request, { + path: `/api/memories/${encodeURIComponent(memoryKey)}`, + token, + method: 'DELETE', + }); + } + if (skillId) { + await requestResult(request, { + path: `/api/skills/${encodeURIComponent(skillId)}`, + token, + method: 'DELETE', + }); + } + if (agentId) { + await requestResult(request, { + path: `/api/agents/${encodeURIComponent(agentId)}`, + token, + method: 'DELETE', + }); + } + if (promptGroupId) { + await requestResult(request, { + path: `/api/prompts/groups/${encodeURIComponent(promptGroupId)}`, + token, + method: 'DELETE', + }); + } + if (conversationId) { + await requestResult(request, { + path: '/api/convos', + token, + method: 'DELETE', + data: { arg: { conversationId } }, + }); + } + } + } + }); + + test('honors omitted and explicit message filter selector defaults', async ({ request }) => { + test.setTimeout(120000); + + const token = await loginAdmin(request); + const marker = `E2E-CF-CONFIG-${Date.now()}-${Math.floor(Math.random() * 10000)}`; + const bearerValue = 'Authorization: Bearer e2e-config-contract-token'; + const nonmatchingCustomPatterns = [ + { + id: `e2e-config-nonmatching-${Date.now()}`, + label: 'E2E nonmatching config detector', + regex: '^E2E-CF-NEVER-MATCH$', + }, + ]; + let filtersAttempted = false; + let filtersActive = false; + let conversationId: string | undefined; + + const applyFilters = async (filters: FiltersConfig): Promise => { + filtersAttempted = true; + await setRuntimeFilters(request, token, filters); + filtersActive = true; + }; + + const submitMessage = async (text: string, name?: string) => { + const messageId = randomUUID(); + const result = await requestResult(request, { + path: `/api/agents/chat/${encodeURIComponent(MOCK_ENDPOINTS[0].label)}`, + token, + method: 'POST', + data: { + text, + ...(name ? { name } : {}), + sender: 'User', + clientTimestamp: new Date().toISOString(), + isCreatedByUser: true, + parentMessageId: NO_PARENT, + conversationId: 'new', + messageId, + responseMessageId: `${messageId}_response`, + endpoint: MOCK_ENDPOINTS[0].label, + endpointType: 'custom', + model: MOCK_ENDPOINTS[0].model, + isTemporary: false, + isRegenerate: false, + error: false, + }, + }); + return { messageId, result }; + }; + + try { + await applyFilters({ + messages: { + pii: { + starterPatterns: [], + customPatterns: [ + { + id: `e2e-config-fields-${Date.now()}`, + label: 'E2E config field selector', + regex: `^${marker}$`, + }, + ], + }, + }, + }); + const omittedFields = await submitMessage('Safe field-selector control.', marker); + expectContentFilterBlock(omittedFields.result, { + source: 'message', + field: 'name', + marker, + }); + await expectNoStoredDocument( + 'messages', + { messageId: omittedFields.messageId }, + 'Message blocked by the default field selection must not be persisted', + ); + + await applyFilters({ + messages: { pii: { fields: ['text'], customPatterns: nonmatchingCustomPatterns } }, + }); + const omittedStarters = await submitMessage(bearerValue); + expectContentFilterBlock(omittedStarters.result, { + source: 'message', + field: 'text', + marker: bearerValue, + }); + await expectNoStoredDocument( + 'messages', + { messageId: omittedStarters.messageId }, + 'Message blocked by default starter patterns must not be persisted', + ); + + await applyFilters({ + messages: { + pii: { + fields: ['text'], + starterPatterns: [], + customPatterns: nonmatchingCustomPatterns, + }, + }, + }); + const explicitEmptyStarters = await submitMessage(bearerValue); + conversationId = await expectAsyncStreamCompleted( + request, + token, + explicitEmptyStarters.result, + ); + } finally { + try { + if (filtersAttempted || filtersActive) { + await restoreRuntimeFilters(request, token); + filtersActive = false; + } + } finally { + if (conversationId) { + await requestResult(request, { + path: '/api/convos', + token, + method: 'DELETE', + data: { arg: { conversationId } }, + }); + } + } + } + }); +}); diff --git a/librechat.example.yaml b/librechat.example.yaml index 8e59b97ddf..cfd7b40a16 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -887,7 +887,12 @@ endpoints: # Apply opt-in content filters to source-classified submitted and reusable content. # `filters` is base-config-only: database overrides and tombstones cannot add, # change, or remove this policy for individual users, groups, or roles. -# Omit `filters`, a source, or its `pii` block to leave that scope disabled. +# In multi-replica deployments, use a coordinated deploy or restart and verify +# every replica loaded the same base config before considering policy active; +# local cache invalidation is not a rollout barrier. +# Omit `filters` or a source to leave that scope disabled. Omitting a source's +# `pii` block disables its source-aware detectors; for messages, `inspect` can +# still change how an enabled legacy `messageFilter.pii` attributes old rows. # Omit `fields` to filter every supported field for an enabled source. # `starterPatterns` and `customPatterns` can be configured independently # under any source so each input surface can use a different policy. Starter @@ -895,8 +900,26 @@ endpoints: # linear-time syntax; unsupported constructs are rejected at config load. # Omit `starterPatterns` to enable the full starter catalog; set it to `[]` # to disable starters while retaining any configured custom patterns. +# Enabling or changing this policy does not rewrite or delete stored records. +# Current policy rechecks protected fields when they are resubmitted and when +# records are copied, shared, or become model-bound. Safe partial metadata edits +# can succeed so records remain repairable, but persisted protected fields remain +# unusable on those paths until repaired. Protected prompt or preset fields may +# be blanked with `contentFilterBlocked: true` in management views. Prompt-group +# metadata blocked by policy returns an explicit error on direct GET, while +# collection and reuse responses omit that group. +# Automatic memory maintenance may log and skip a rejected background update +# while allowing the main chat response to continue. +# Legacy assistant rows without provenance default to `model_output`, preserving +# legacy behavior. Inventory or migrate those records before relying on retroactive +# enforcement. Opting into `inspect` treats otherwise unattributed assistant content, +# including selected attachment projections, as submitted; explicit model provenance +# remains exempt. +# Roll out strict file inspection deliberately: `uninspectable: block` can make +# older opaque files unavailable for reuse until inspectable text is present. # filters: # messages: +# unattributedAssistantContent: model_output # `model_output` (default) or `inspect` # pii: # fields: [name, text, summary, quote, answer, decision_response, decision_reason, content_part, attachment_reference, assembled_context] # starterPatterns: [sk_prefix, bearer_header, api_key_header] diff --git a/packages/api/src/agents/handlers.ts b/packages/api/src/agents/handlers.ts index c3d1169f19..d977690284 100644 --- a/packages/api/src/agents/handlers.ts +++ b/packages/api/src/agents/handlers.ts @@ -13,8 +13,8 @@ import type { } from '@librechat/agents'; import type { StructuredToolInterface } from '@librechat/agents/langchain/tools'; import type { CodeEnvRef } from 'librechat-data-provider'; -import type { TextContentFragment } from '~/protection'; import type { SkillFileRecord, PrimeSkillFilesResult } from './skillFiles'; +import type { TextContentFragment } from '~/protection'; import type { ServerRequest } from '~/types'; import { backgroundTaskRegistry, @@ -54,13 +54,13 @@ import { contentFilterModelBoundBlockResponse, isContentFilterError, } from '~/middleware/contentFilter'; -import { getSafeErrorMetadata, logAxiosError, runOutsideTracing, truncateMiddle } from '~/utils'; import { hasIntentArg, stripIntentArg, stripIntentLabelsFromToolDefinitions, INTENT_ARG, } from './intent'; +import { getSafeErrorMetadata, logAxiosError, runOutsideTracing, truncateMiddle } from '~/utils'; import { buildSkillPrimeMessage, SKILL_FILE_PREFIX } from './skills'; import { parseFrontmatter } from '../skills/import'; import { cleanCodeToolOutput } from './cleanup'; diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index 6dbdf07b2d..b0d9fb85ec 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -74,8 +74,8 @@ import { import { extractAgentContent, extractSkillContent } from '../protection/adapters/submissions'; import { assertModelBoundContent } from '../middleware/modelBoundContent'; import { registerMemoryTools, memoryToolUsageGuard } from './memory'; -import { ContentFilterError } from '../middleware/contentFilter'; import { applyIntentLabels, sanitizeIntentLabels } from './intent'; +import { ContentFilterError } from '../middleware/contentFilter'; import { applyBackgroundToolCalls } from './background'; import { inspectContent } from '../protection/runtime'; import { filterFilesByEndpointConfig } from '~/files'; diff --git a/packages/api/src/middleware/modelBoundContent.spec.ts b/packages/api/src/middleware/modelBoundContent.spec.ts index 02a9816c0d..1c9c9b0d8c 100644 --- a/packages/api/src/middleware/modelBoundContent.spec.ts +++ b/packages/api/src/middleware/modelBoundContent.spec.ts @@ -167,6 +167,28 @@ describe('assertModelBoundContent', () => { ).not.toThrow(); }); + it('applies legacy-only rules across adjacent persisted submitted content parts', () => { + expect(() => + assertModelBoundContent({ + legacyPii: { + starterPatterns: [], + customPatterns: [{ id: 'private', label: 'private value', regex: 'PRIVATE-VALUE' }], + }, + storedMessages: [ + { + isCreatedByUser: false, + content: [ + { type: 'text', text: 'Model output' }, + { type: 'text', text: 'PRIVATE-' }, + { type: 'text', text: 'VALUE' }, + ], + userSubmittedPaths: ['/content/1/text', '/content/2/text'], + }, + ], + }), + ).toThrow('Submitted content contains a private value'); + }); + it('treats persisted steer parts as user-submitted without classifying neighboring model prose', () => { const mixedFilters: FiltersConfig = { messages: { @@ -347,6 +369,154 @@ describe('assertModelBoundContent', () => { ).not.toThrow(); }); + it('keeps explicit model_output attribution compatible with omission', () => { + expect(() => + assertModelBoundContent({ + filters: { + ...filters, + messages: { + ...filters.messages, + unattributedAssistantContent: 'model_output', + }, + }, + storedMessages: [ + { + isCreatedByUser: false, + role: 'assistant', + text: 'Legacy model output PRIVATE-VALUE', + }, + ], + }), + ).not.toThrow(); + }); + + it('inspects unattributed assistant rows when strict legacy attribution is enabled', () => { + expect(() => + assertModelBoundContent({ + filters: { + ...filters, + messages: { + ...filters.messages, + unattributedAssistantContent: 'inspect', + }, + }, + storedMessages: [ + { + isCreatedByUser: false, + role: 'assistant', + text: 'Legacy unattributed PRIVATE-VALUE', + }, + ], + }), + ).toThrow('Submitted content contains a private value'); + }); + + it('recognizes an assistant role as unattributed even without an author flag', () => { + expect(() => + assertModelBoundContent({ + filters: { + ...filters, + messages: { + ...filters.messages, + unattributedAssistantContent: 'inspect', + }, + }, + storedMessages: [ + { + role: 'assistant', + text: 'Legacy unattributed PRIVATE-VALUE', + }, + ], + }), + ).toThrow('Submitted content contains a private value'); + }); + + it('honors explicit model attribution and path-scoped user attribution in strict mode', () => { + const strictFilters: FiltersConfig = { + messages: { + pii: { + fields: ['text', 'content_part'], + starterPatterns: [], + customPatterns: [{ id: 'private', label: 'private value', regex: 'PRIVATE-[A-Z]+' }], + }, + unattributedAssistantContent: 'inspect', + }, + }; + + expect(() => + assertModelBoundContent({ + filters: strictFilters, + storedMessages: [ + { + isCreatedByUser: false, + isUserSubmitted: false, + role: 'assistant', + text: 'Explicit model output PRIVATE-VALUE', + }, + ], + }), + ).not.toThrow(); + + expect(() => + assertModelBoundContent({ + filters: strictFilters, + storedMessages: [ + { + isCreatedByUser: false, + isUserSubmitted: false, + role: 'assistant', + text: 'Model output PRIVATE-MODEL', + content: [{ type: 'text', text: 'Safe user edit' }], + userSubmittedPaths: ['/content/0/text'], + }, + ], + }), + ).not.toThrow(); + + expect(() => + assertModelBoundContent({ + filters: strictFilters, + storedMessages: [ + { + isCreatedByUser: false, + role: 'assistant', + text: 'Model output PRIVATE-MODEL', + content: [ + { type: 'text', text: 'Model content' }, + { type: 'steer', steer: 'Safe user steer' }, + ], + }, + ], + }), + ).not.toThrow(); + }); + + it.each(['not-a-json-pointer', '/missing', '/messageId', '/__proto__/polluted'])( + 'treats ineffective provenance path %s as unattributed in strict mode', + (userSubmittedPath) => { + expect(() => + assertModelBoundContent({ + filters: { + ...filters, + messages: { + ...filters.messages, + unattributedAssistantContent: 'inspect', + }, + }, + storedMessages: [ + { + isCreatedByUser: false, + role: 'assistant', + messageId: 'legacy-message', + text: 'Legacy unattributed PRIVATE-VALUE', + userSubmittedPaths: [userSubmittedPath], + }, + ], + }), + ).toThrow('Submitted content contains a private value'); + }, + ); + it('re-inspects structured historical tool output without treating assistant prose as a message', () => { expect(() => assertModelBoundContent({ diff --git a/packages/api/src/middleware/modelBoundContent.ts b/packages/api/src/middleware/modelBoundContent.ts index 343979e249..fc05f840c5 100644 --- a/packages/api/src/middleware/modelBoundContent.ts +++ b/packages/api/src/middleware/modelBoundContent.ts @@ -35,6 +35,10 @@ import { isContentTraversalLimitError, isNestedMessageTraversalProtected, } from '../protection/adapters/nested'; +import { + getSafeUserSubmittedPathSegments, + getUserSubmittedPathState, +} from '../protection/provenance'; import { createConfiguredContentInspector } from '../protection/runtime'; import { extractMessageContent } from '../protection/adapters/messages'; import { ContentFilterError } from './contentFilter'; @@ -131,48 +135,6 @@ function getHydratedAgentFiles( return files; } -const MAX_USER_SUBMITTED_PATHS = 256; -const MAX_USER_SUBMITTED_PATH_LENGTH = 2048; -const blockedPointerSegments = new Set(['__proto__', 'constructor', 'prototype']); - -interface NormalizedUserSubmittedPaths { - readonly paths: JsonPointer[]; - readonly overflowed: boolean; -} - -function normalizeUserSubmittedPaths( - paths: readonly string[] | undefined, -): NormalizedUserSubmittedPaths { - const normalized: JsonPointer[] = []; - const seen = new Set(); - for (const path of paths ?? []) { - if ( - typeof path !== 'string' || - !path.startsWith('/') || - path.length > MAX_USER_SUBMITTED_PATH_LENGTH || - seen.has(path) - ) { - continue; - } - seen.add(path); - if (normalized.length >= MAX_USER_SUBMITTED_PATHS) { - return { paths: normalized, overflowed: true }; - } - normalized.push(path as JsonPointer); - } - return { paths: normalized, overflowed: false }; -} - -function getSemanticUserSubmittedPaths(message: StoredModelBoundMessage): JsonPointer[] { - const paths: JsonPointer[] = []; - for (let index = 0; index < (message.content?.length ?? 0); index++) { - if (message.content?.[index]?.type === 'steer') { - paths.push(`/content/${index}` as JsonPointer); - } - } - return paths; -} - function isFragmentWithinPath(fragment: TextContentFragment, path: JsonPointer): boolean { return fragment.path === path || fragment.path.startsWith(`${path}/`); } @@ -226,10 +188,6 @@ function getUserSubmittedAssembledContext( }; } -function decodeJsonPointerSegment(segment: string): string { - return segment.replace(/~1/g, '/').replace(/~0/g, '~'); -} - /** * Builds a sparse object containing only marked fields while retaining their * original keys and ancestry. File fail-close checks need that shape to @@ -243,8 +201,8 @@ function projectUserSubmittedPaths( let projected = false; for (const path of paths) { - const segments = path.slice(1).split('/').map(decodeJsonPointerSegment); - if (segments.length === 0 || segments.some((segment) => blockedPointerSegments.has(segment))) { + const segments = getSafeUserSubmittedPathSegments(path); + if (segments == null) { continue; } @@ -378,14 +336,18 @@ export function assertModelBoundContent(input: ModelBoundContentInput): void { } const storedUserMessages: StoredModelBoundMessage[] = []; for (const message of input.storedMessages ?? []) { - const normalizedPaths = normalizeUserSubmittedPaths([ - ...(message.userSubmittedPaths ?? []), - ...getSemanticUserSubmittedPaths(message), - ]); + const submittedPathState = getUserSubmittedPathState(message); + const effectiveUserSubmittedPaths = submittedPathState.paths; + const isUnattributedAssistant = + input.filters?.messages?.unattributedAssistantContent === 'inspect' && + typeof message.isUserSubmitted !== 'boolean' && + effectiveUserSubmittedPaths.length === 0 && + (message.isCreatedByUser === false || normalizeRole(message) === 'assistant'); const isEntireMessageUserSubmitted = message?.isCreatedByUser === true || message?.isUserSubmitted === true || - normalizedPaths.overflowed; + submittedPathState.overflowed || + isUnattributedAssistant; let messageFragments: readonly TextContentFragment[]; let traversalError: ContentTraversalLimitError | null = null; try { @@ -398,7 +360,7 @@ export function assertModelBoundContent(input: ModelBoundContentInput): void { messageFragments = getContentTraversalFragments(error); } if (!isEntireMessageUserSubmitted) { - const userSubmittedPaths = normalizedPaths.paths; + const userSubmittedPaths = effectiveUserSubmittedPaths; const projectedMessage = projectUserSubmittedPaths(message, userSubmittedPaths); if (projectedMessage != null) { assertInspectableFileInput( @@ -406,8 +368,9 @@ export function assertModelBoundContent(input: ModelBoundContentInput): void { omitResolvedCanonicalFileLocators(projectedMessage, resolvedFilesById), ); } - /** Legacy unmarked assistant rows are treated as model-generated to avoid - * retroactively blocking model output. Structured tool calls/results + /** Legacy unmarked assistant rows are treated as model-generated by + * default. Strict attribution can inspect an otherwise unattributed + * assistant row as submitted content. Structured tool calls/results * remain externally sourced model-bound content. Explicit paths and * semantic steer parts identify user-authored fragments in mixed rows. */ const submittedFragments = messageFragments.filter((fragment) => diff --git a/packages/api/src/protection/index.ts b/packages/api/src/protection/index.ts index 96a24c3be6..48ffa63076 100644 --- a/packages/api/src/protection/index.ts +++ b/packages/api/src/protection/index.ts @@ -1,6 +1,7 @@ export * from './types'; export * from './runtime'; export * from './legacy'; +export * from './provenance'; export * from './files'; export * from './adapters/chat'; export * from './adapters/nested'; diff --git a/packages/api/src/protection/legacy.spec.ts b/packages/api/src/protection/legacy.spec.ts index 2376a3fc87..8c0a2072fe 100644 --- a/packages/api/src/protection/legacy.spec.ts +++ b/packages/api/src/protection/legacy.spec.ts @@ -49,6 +49,63 @@ describe('legacy content protection', () => { }); }); + it('applies legacy message rules to provenance-selected stored message prose', () => { + const config: MessageFilterPiiConfig = { + starterPatterns: [], + customPatterns: [{ id: 'private', label: 'private value', regex: 'PRIVATE-VALUE' }], + }; + + expect( + inspectLegacyPii([fragment('stored-message.text', 'PRIVATE-VALUE')], config), + ).toMatchObject({ + ruleId: 'private', + source: 'message', + field: 'text', + }); + expect( + inspectLegacyPii( + [ + { + ...fragment('stored-message.name.sender', 'PRIVATE-VALUE'), + field: 'name', + }, + ], + config, + ), + ).toBeNull(); + }); + + it.each(['stored-message.assembled', 'stored-message.user-submitted-assembled'])( + 'applies legacy rules to split submitted prose through %s', + (id) => { + const config: MessageFilterPiiConfig = { + starterPatterns: [], + customPatterns: [{ id: 'private', label: 'private value', regex: 'PRIVATE-VALUE' }], + }; + const assembled: TextContentFragment = { + ...fragment(id, 'PRIVATE-VALUE'), + source: 'assembled_context', + field: 'assembled_context', + treatment: 'inspect_only', + }; + + expect( + inspectLegacyPii( + [ + fragment('stored-message.part.0', 'PRIVATE-'), + fragment('stored-message.part.1', 'VALUE'), + ], + config, + ), + ).toBeNull(); + expect(inspectLegacyPii([assembled], config)).toMatchObject({ + ruleId: 'private', + source: 'assembled_context', + field: 'assembled_context', + }); + }, + ); + it('preserves candidate-first ordering when different rules match different fields', () => { const config: MessageFilterPiiConfig = { starterPatterns: [], diff --git a/packages/api/src/protection/legacy.ts b/packages/api/src/protection/legacy.ts index bf7e139e43..794cf22895 100644 --- a/packages/api/src/protection/legacy.ts +++ b/packages/api/src/protection/legacy.ts @@ -11,12 +11,25 @@ export interface LegacyPiiInspector { inspect(fragments: Iterable): ProtectionFinding | null; } +const LEGACY_STORED_MESSAGE_FIELDS = new Set([ + 'text', + 'quote', + 'answer', + 'decision_response', + 'decision_reason', + 'content_part', +]); + const LEGACY_INSPECTOR_CACHE = new WeakMap(); const INACTIVE_LEGACY_CONFIGS = new WeakSet(); export function isLegacyPiiFragment(fragment: TextContentFragment): boolean { - if (fragment.source === 'assembled_context' && fragment.id === 'chat.assembled.quote-text') { - return true; + if (fragment.source === 'assembled_context') { + return ( + fragment.id === 'chat.assembled.quote-text' || + fragment.id === 'stored-message.assembled' || + fragment.id === 'stored-message.user-submitted-assembled' + ); } if (fragment.source === 'tool_argument') { return /^chat\.decision\.\d+\.arguments$/.test(fragment.id); @@ -24,6 +37,9 @@ export function isLegacyPiiFragment(fragment: TextContentFragment): boolean { if (fragment.source !== 'message') { return false; } + if (fragment.id.startsWith('stored-message.')) { + return LEGACY_STORED_MESSAGE_FIELDS.has(fragment.field); + } return ( fragment.id === 'chat.text' || fragment.id === 'chat.answer' || diff --git a/packages/api/src/protection/provenance.spec.ts b/packages/api/src/protection/provenance.spec.ts new file mode 100644 index 0000000000..b90d5ee168 --- /dev/null +++ b/packages/api/src/protection/provenance.spec.ts @@ -0,0 +1,71 @@ +import { getUserSubmittedPathState } from './provenance'; + +describe('getUserSubmittedPathState', () => { + it('keeps only pointers that resolve through safe own properties and expands steer parts', () => { + const inherited = { inherited: 'not submitted' }; + const message = Object.assign(Object.create(inherited), { + text: 'submitted text', + messageId: 'not submitted content', + attachments: [{ file_id: 'submitted-file' }], + content: [ + { type: 'text', text: 'model text' }, + { type: 'steer', steer: 'submitted steer' }, + Object.create({ type: 'steer' }), + ], + userSubmittedPaths: [ + '/text', + '/attachments/0', + '/text', + '/missing', + '/messageId', + '/inherited', + '/__proto__/polluted', + '/content/~2invalid', + 'not-a-pointer', + ], + }); + + expect(getUserSubmittedPathState(message)).toEqual({ + paths: ['/text', '/attachments/0', '/content/1'], + overflowed: false, + }); + }); + + it('supports the additional protected metadata roots in shared-message projections', () => { + const message = { + iconURL: 'submitted icon', + userSubmittedPaths: ['/iconURL'], + }; + + expect(getUserSubmittedPathState(message)).toEqual({ paths: [], overflowed: false }); + expect(getUserSubmittedPathState(message, { scope: 'shared_message' })).toEqual({ + paths: ['/iconURL'], + overflowed: false, + }); + }); + + it('fails closed when unique bounded pointer candidates exceed 256', () => { + const content = Array.from({ length: 257 }, (_, index) => ({ text: `part-${index}` })); + + const result = getUserSubmittedPathState({ + content, + userSubmittedPaths: content.map((_, index) => `/content/${index}/text`), + }); + + expect(result.overflowed).toBe(true); + expect(result.paths).toHaveLength(256); + expect(result.paths[0]).toBe('/content/0/text'); + expect(result.paths[255]).toBe('/content/255/text'); + }); + + it('ignores overlong pointers without weakening effective bounded paths', () => { + const overlong = `/${'x'.repeat(2048)}`; + + expect( + getUserSubmittedPathState({ + text: 'submitted text', + userSubmittedPaths: [overlong, '/text'], + }), + ).toEqual({ paths: ['/text'], overflowed: false }); + }); +}); diff --git a/packages/api/src/protection/provenance.ts b/packages/api/src/protection/provenance.ts new file mode 100644 index 0000000000..8fd6e764be --- /dev/null +++ b/packages/api/src/protection/provenance.ts @@ -0,0 +1,140 @@ +import type { JsonPointer } from './types'; + +export const MAX_USER_SUBMITTED_PATHS = 256; +export const MAX_USER_SUBMITTED_PATH_LENGTH = 2048; + +const BLOCKED_POINTER_SEGMENTS = new Set(['__proto__', 'constructor', 'prototype']); +const STORED_MESSAGE_SUBMITTED_ROOTS = new Set([ + 'attachments', + 'content', + 'files', + 'name', + 'original', + 'quotes', + 'sender', + 'summary', + 'text', + 'tool_calls', + 'updated', +]); +const SHARED_MESSAGE_SUBMITTED_ROOTS = new Set([ + ...STORED_MESSAGE_SUBMITTED_ROOTS, + 'alwaysAppliedSkills', + 'finish_reason', + 'iconURL', + 'manualSkills', +]); + +export interface UserSubmittedPathState { + readonly paths: JsonPointer[]; + readonly overflowed: boolean; +} + +export interface UserSubmittedPathOptions { + readonly scope?: 'stored_message' | 'shared_message'; +} + +type UserSubmittedPathCarrier = object & { + readonly userSubmittedPaths?: readonly unknown[]; + readonly content?: readonly unknown[]; +}; + +function decodeJsonPointerSegment(segment: string): string { + return segment.replace(/~1/g, '/').replace(/~0/g, '~'); +} + +export function getSafeUserSubmittedPathSegments(path: JsonPointer): readonly string[] | undefined { + const encodedSegments = path.slice(1).split('/'); + if (encodedSegments.some((segment) => /~(?:[^01]|$)/.test(segment))) { + return undefined; + } + const segments = encodedSegments.map(decodeJsonPointerSegment); + if (segments.length === 0 || segments.some((segment) => BLOCKED_POINTER_SEGMENTS.has(segment))) { + return undefined; + } + return segments; +} + +function isEffectiveUserSubmittedPath( + message: UserSubmittedPathCarrier, + path: JsonPointer, + scope: NonNullable, +): boolean { + const segments = getSafeUserSubmittedPathSegments(path); + if (segments == null) { + return false; + } + const submittedRoots = + scope === 'shared_message' ? SHARED_MESSAGE_SUBMITTED_ROOTS : STORED_MESSAGE_SUBMITTED_ROOTS; + if (!submittedRoots.has(segments[0])) { + return false; + } + let source: unknown = message; + for (const segment of segments) { + if ( + source == null || + typeof source !== 'object' || + !Object.prototype.hasOwnProperty.call(source, segment) + ) { + return false; + } + source = (source as Record)[segment]; + } + return source !== undefined; +} + +function getSemanticUserSubmittedPaths(message: UserSubmittedPathCarrier): JsonPointer[] { + if (!Array.isArray(message.content)) { + return []; + } + const paths: JsonPointer[] = []; + for (let index = 0; index < message.content.length; index++) { + const part = message.content[index]; + if ( + part != null && + typeof part === 'object' && + Object.prototype.hasOwnProperty.call(part, 'type') && + (part as Record).type === 'steer' + ) { + paths.push(`/content/${index}` as JsonPointer); + } + } + return paths; +} + +/** + * Resolves durable caller-authorship pointers against the exact stored row. + * Ineffective or unsafe pointers never suppress strict whole-row attribution; + * excessive unique bounded candidates still fail closed via `overflowed`. + */ +export function getUserSubmittedPathState( + message: UserSubmittedPathCarrier, + options: UserSubmittedPathOptions = {}, +): UserSubmittedPathState { + const candidates = [ + ...(Array.isArray(message.userSubmittedPaths) ? message.userSubmittedPaths : []), + ...getSemanticUserSubmittedPaths(message), + ]; + const paths: JsonPointer[] = []; + const seen = new Set(); + + for (const path of candidates) { + if ( + typeof path !== 'string' || + !path.startsWith('/') || + path.length > MAX_USER_SUBMITTED_PATH_LENGTH || + seen.has(path) + ) { + continue; + } + seen.add(path); + if (seen.size > MAX_USER_SUBMITTED_PATHS) { + return { paths, overflowed: true }; + } + const pointer = path as JsonPointer; + if (isEffectiveUserSubmittedPath(message, pointer, options.scope ?? 'stored_message')) { + paths.push(pointer); + } + } + return { paths, overflowed: false }; +} diff --git a/packages/api/src/shared-links/protection.spec.ts b/packages/api/src/shared-links/protection.spec.ts index 6dc33f4ccd..21ab9ce517 100644 --- a/packages/api/src/shared-links/protection.spec.ts +++ b/packages/api/src/shared-links/protection.spec.ts @@ -179,8 +179,29 @@ describe('shared file metadata protection', () => { }); }); - it('treats legacy model metadata conservatively but honors explicit server provenance', () => { - const legacyError = capturePolicyError(() => + it('does not let ineffective provenance paths suppress conservative shared metadata checks', () => { + const error = capturePolicyError(() => + assertSharedFileMetadataAllowed({ + filters: attachmentFilters, + messages: [ + { + isCreatedByUser: false, + userSubmittedPaths: ['/missing'], + iconURL: 'https://example.test/PRIVATE-SENTINEL', + }, + ], + shareId: 'share-123', + }), + ); + + expect(error.body).toMatchObject({ + source: 'message', + field: 'attachment_reference', + }); + }); + + it('applies the configured attribution policy to legacy assistant metadata', () => { + const compatibilityError = capturePolicyError(() => assertSharedFileMetadataAllowed({ filters: attachmentFilters, messages: [ @@ -192,14 +213,41 @@ describe('shared file metadata protection', () => { shareId: 'share-123', }), ); - expect(legacyError.body).toMatchObject({ + expect(compatibilityError.body).toMatchObject({ + source: 'message', + field: 'attachment_reference', + }); + + const strictError = capturePolicyError(() => + assertSharedFileMetadataAllowed({ + filters: { + messages: { + ...attachmentFilters.messages, + unattributedAssistantContent: 'inspect', + }, + }, + messages: [ + { + isCreatedByUser: false, + iconURL: 'https://example.test/PRIVATE-SENTINEL', + }, + ], + shareId: 'share-123', + }), + ); + expect(strictError.body).toMatchObject({ source: 'message', field: 'attachment_reference', }); expect(() => assertSharedFileMetadataAllowed({ - filters: attachmentFilters, + filters: { + messages: { + ...attachmentFilters.messages, + unattributedAssistantContent: 'inspect', + }, + }, messages: [ { isCreatedByUser: false, @@ -213,7 +261,12 @@ describe('shared file metadata protection', () => { const error = capturePolicyError(() => assertSharedFileMetadataAllowed({ - filters: attachmentFilters, + filters: { + messages: { + ...attachmentFilters.messages, + unattributedAssistantContent: 'inspect', + }, + }, messages: [ { isCreatedByUser: false, @@ -230,6 +283,103 @@ describe('shared file metadata protection', () => { }); }); + it('applies legacy attribution to shared assistant attachment projections', () => { + const legacyAttachment = { + reference: { label: 'PRIVATE-SENTINEL' }, + }; + const strictFilters: FiltersConfig = { + messages: { + ...attachmentFilters.messages, + unattributedAssistantContent: 'inspect', + }, + }; + + expect(() => + assertSharedFileMetadataAllowed({ + filters: attachmentFilters, + messages: [{ isCreatedByUser: false, attachments: [legacyAttachment] }], + shareId: 'share-123', + }), + ).not.toThrow(); + + expect(() => + assertSharedFileMetadataAllowed({ + filters: strictFilters, + messages: [{ isCreatedByUser: false, attachments: [legacyAttachment] }], + shareId: 'share-123', + }), + ).toThrow(ContentFilterError); + + expect(() => + assertSharedFileMetadataAllowed({ + filters: strictFilters, + messages: [ + { + isCreatedByUser: false, + isUserSubmitted: false, + attachments: [legacyAttachment], + }, + ], + shareId: 'share-123', + }), + ).not.toThrow(); + + expect(() => + assertSharedFileMetadataAllowed({ + filters: strictFilters, + messages: [ + { + isCreatedByUser: false, + isUserSubmitted: false, + userSubmittedPaths: ['/attachments/0'], + attachments: [legacyAttachment], + }, + ], + shareId: 'share-123', + }), + ).toThrow(ContentFilterError); + }); + + it('preserves conservative attachment attribution for role-only legacy rows', () => { + expect(() => + assertSharedFileMetadataAllowed({ + filters: attachmentFilters, + messages: [ + { + role: 'assistant', + attachments: [{ reference: { label: 'PRIVATE-SENTINEL' } }], + }, + ], + shareId: 'share-123', + }), + ).toThrow(ContentFilterError); + }); + + it.each(['/missing', '/role', '/__proto__/polluted'])( + 'does not let ineffective shared provenance path %s suppress strict attribution', + (userSubmittedPath) => { + expect(() => + assertSharedFileMetadataAllowed({ + filters: { + messages: { + ...attachmentFilters.messages, + unattributedAssistantContent: 'inspect', + }, + }, + messages: [ + { + isCreatedByUser: false, + role: 'assistant', + userSubmittedPaths: [userSubmittedPath], + attachments: [{ reference: { label: 'PRIVATE-SENTINEL' } }], + }, + ], + shareId: 'share-123', + }), + ).toThrow(ContentFilterError); + }, + ); + it('keeps shared response metadata protection default-off', () => { expect(() => assertSharedFileMetadataAllowed({ diff --git a/packages/api/src/shared-links/protection.ts b/packages/api/src/shared-links/protection.ts index bd6636f33f..e2b7f3d860 100644 --- a/packages/api/src/shared-links/protection.ts +++ b/packages/api/src/shared-links/protection.ts @@ -25,6 +25,7 @@ import { extractStoredMessageContent, } from '../protection/adapters/submissions'; import { assertModelBoundContent } from '../middleware/modelBoundContent'; +import { getUserSubmittedPathState } from '../protection/provenance'; import { ContentFilterError } from '../middleware/contentFilter'; import { inspectContent } from '../protection/runtime'; @@ -42,6 +43,7 @@ export interface SerializedSharedMessage { readonly isCreatedByUser?: boolean; readonly isUserSubmitted?: boolean; readonly userSubmittedPaths?: readonly string[]; + readonly role?: string; readonly iconURL?: string; readonly finish_reason?: string; readonly manualSkills?: readonly (string | null | undefined)[]; @@ -164,16 +166,36 @@ function isSubmittedPath(path: string, submittedPaths: readonly string[]): boole ); } -function isEntireMessageSubmitted(message: SerializedSharedMessage): boolean { +function isSharedAssistantMessage(message: SerializedSharedMessage): boolean { + return message.isCreatedByUser === false || message.role === 'assistant' || message.role === 'ai'; +} + +function isEntireMessageSubmitted( + message: SerializedSharedMessage, + filters: FiltersConfig | undefined, + submittedPaths: ReturnType, +): boolean { + if (message.isCreatedByUser === true || message.isUserSubmitted === true) { + return true; + } + if (submittedPaths.overflowed) { + return true; + } + if (typeof message.isUserSubmitted === 'boolean' || submittedPaths.paths.length > 0) { + return false; + } + if (message.isCreatedByUser == null) { + return true; + } return ( - message.isCreatedByUser === true || - message.isUserSubmitted === true || - (message.isCreatedByUser == null && message.isUserSubmitted == null) + isSharedAssistantMessage(message) && + filters?.messages?.unattributedAssistantContent === 'inspect' ); } function collectSerializedFiles( messages: readonly SerializedSharedMessage[], + filters: FiltersConfig | undefined, ): CollectedSerializedFile[] { const files: CollectedSerializedFile[] = []; const append = ( @@ -201,8 +223,9 @@ function collectSerializedFiles( for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) { const message = messages[messageIndex]; - const entireMessageSubmitted = isEntireMessageSubmitted(message); - const submittedPaths = message.userSubmittedPaths ?? []; + const submittedPathState = getUserSubmittedPathState(message, { scope: 'shared_message' }); + const submittedPaths = submittedPathState.paths; + const entireMessageSubmitted = isEntireMessageSubmitted(message, filters, submittedPathState); append( message.files, 'file', @@ -236,15 +259,22 @@ function collectSerializedFiles( function extractSharedMessageMetadataFragments( messages: readonly SerializedSharedMessage[], + filters: FiltersConfig | undefined, ): TextContentFragment[] { const fragments: TextContentFragment[] = []; for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) { const message = messages[messageIndex]; - const submittedPaths = message.userSubmittedPaths ?? []; + const submittedPathState = getUserSubmittedPathState(message, { scope: 'shared_message' }); + const submittedPaths = submittedPathState.paths; + /** Shared response-only metadata predates message provenance markers and + * can be authored by users or reusable configuration even on assistant + * rows, so retain the existing conservative default for unmarked fields. */ const legacyMetadataIsUnattributed = - message.isUserSubmitted == null && message.userSubmittedPaths == null; + message.isUserSubmitted == null && + submittedPathState.paths.length === 0 && + !submittedPathState.overflowed; const isSubmitted = (path: string) => - isEntireMessageSubmitted(message) || + isEntireMessageSubmitted(message, filters, submittedPathState) || legacyMetadataIsUnattributed || isSubmittedPath(path, submittedPaths); const appendMessageValue = ( @@ -400,7 +430,7 @@ function getNestedTarget(file: CollectedSerializedFile): NestedSerializedPayload if (file.userSubmitted) { return 'attachment'; } - return isToolAttachment(file.file) ? 'tool' : 'attachment'; + return isToolAttachment(file.file) ? 'tool' : 'file'; } function getNestedClassification( @@ -439,13 +469,14 @@ function getDistinctClassifications( function getNestedClassifications( target: NestedSerializedPayloadTarget, rootKey: string, + includeMessageClassification: boolean, ): NestedPayloadClassification[] { const classifications: NestedPayloadClassification[] = [getNestedClassification(target, rootKey)]; const fileField = FILE_FIELD_BY_STANDARD_KEY.get(rootKey); if (fileField != null) { classifications.push({ source: 'file', field: fileField, provenance: 'user' }); } - if (MESSAGE_ATTACHMENT_STANDARD_KEYS.has(rootKey)) { + if (includeMessageClassification && MESSAGE_ATTACHMENT_STANDARD_KEYS.has(rootKey)) { classifications.push({ source: 'message', field: 'attachment_reference', @@ -858,7 +889,7 @@ function extractNestedSerializedPayloadFragments( let visitedNodes = 0; for (const [key, value] of entries) { - const classifications = getNestedClassifications(target, key); + const classifications = getNestedClassifications(target, key, collectedFile.userSubmitted); const activeClassifications = classifications.filter((classification) => isClassificationActive(filters, classification), ); @@ -1061,7 +1092,7 @@ function extractLocatorAliasFragments( field: 'uri', provenance: 'user', } as const; - if (isClassificationActive(filters, messageClassification)) { + if (collectedFile.userSubmitted && isClassificationActive(filters, messageClassification)) { appendClassifiedFragment(state, messageClassification, value, path, 'uri'); } if (isClassificationActive(filters, fileClassification)) { @@ -1082,7 +1113,7 @@ function extractLocatorAliasFragments( if (decodedUri == null || decodedUri === value) { continue; } - if (isClassificationActive(filters, messageClassification)) { + if (collectedFile.userSubmitted && isClassificationActive(filters, messageClassification)) { appendClassifiedFragment(state, messageClassification, decodedUri, path, 'uri'); } if (isClassificationActive(filters, fileClassification)) { @@ -1206,16 +1237,18 @@ export function assertSharedFileMetadataAllowed({ if (filters == null) { return; } - const messageMetadataFragments = extractSharedMessageMetadataFragments(messages); + const messageMetadataFragments = extractSharedMessageMetadataFragments(messages, filters); const collectedFiles = - includeFiles && hasSerializedFilePolicy(filters) ? collectSerializedFiles(messages) : []; + includeFiles && hasSerializedFilePolicy(filters) + ? collectSerializedFiles(messages, filters) + : []; if (collectedFiles.length === 0 && messageMetadataFragments.length === 0) { return; } const files = collectedFiles.map(({ file }) => file); const attachmentFragments = extractStoredMessageContent({ - files, + files: collectedFiles.filter(({ userSubmitted }) => userSubmitted).map(({ file }) => file), }).filter( (fragment) => fragment.source === 'message' && fragment.field === 'attachment_reference', ); diff --git a/packages/data-provider/src/filters.spec.ts b/packages/data-provider/src/filters.spec.ts index 8075df947e..2803011c92 100644 --- a/packages/data-provider/src/filters.spec.ts +++ b/packages/data-provider/src/filters.spec.ts @@ -55,6 +55,34 @@ describe('filtersConfigSchema', () => { ).toBe(false); }); + it('accepts an explicit attribution policy for legacy assistant content', () => { + expect( + filtersConfigSchema.parse({ + messages: { unattributedAssistantContent: 'inspect' }, + }), + ).toEqual({ messages: { unattributedAssistantContent: 'inspect' } }); + expect( + filtersConfigSchema.parse({ + messages: { unattributedAssistantContent: 'model_output' }, + }), + ).toEqual({ messages: { unattributedAssistantContent: 'model_output' } }); + expect( + filtersConfigSchema.safeParse({ + messages: { unattributedAssistantContent: 'block' }, + }).success, + ).toBe(false); + expect( + hasActiveFiltersConfig({ + messages: { unattributedAssistantContent: 'inspect' }, + }), + ).toBe(true); + expect( + hasActiveFiltersConfig({ + messages: { unattributedAssistantContent: 'model_output' }, + }), + ).toBe(false); + }); + it('keeps default patterns, custom patterns, and explicit file fail-close active', () => { expect(hasActivePiiPatterns({})).toBe(true); expect( diff --git a/packages/data-provider/src/filters.ts b/packages/data-provider/src/filters.ts index a5fc7e59a5..c0515eac1d 100644 --- a/packages/data-provider/src/filters.ts +++ b/packages/data-provider/src/filters.ts @@ -117,6 +117,8 @@ export const toolArgumentFilterFieldSchema = z.enum(TOOL_ARGUMENT_FILTER_FIELDS) export const modelParameterFilterFieldSchema = z.enum(MODEL_PARAMETER_FILTER_FIELDS); export const filterPiiStarterPatternSchema = z.enum(FILTER_PII_STARTER_PATTERNS); export const actionMetadataFilterFieldSchema = z.enum(ACTION_METADATA_FILTER_FIELDS); +export const unattributedAssistantContentSchema = z.enum(['model_output', 'inspect']); +export type UnattributedAssistantContent = z.infer; export type MessageFilterField = z.infer; export type PromptFilterField = z.infer; @@ -165,6 +167,9 @@ export function hasActiveFiltersConfig(filters: FiltersConfig | null | undefined if (filters == null) { return false; } + if (filters.messages?.unattributedAssistantContent === 'inspect') { + return true; + } const sourcePatterns = [ filters.messages?.pii, filters.prompts?.pii, @@ -233,6 +238,13 @@ function createSourceFilterSchema(fieldSchema: Field .strict(); } +const messageSourceFilterSchema = z + .object({ + pii: createPiiFilterSchema(messageFilterFieldSchema).optional(), + unattributedAssistantContent: unattributedAssistantContentSchema.optional(), + }) + .strict(); + const fileSourceFilterSchema = z .object({ pii: createPiiFilterSchema(fileFilterFieldSchema) @@ -245,7 +257,7 @@ const fileSourceFilterSchema = z export const filtersConfigSchema = z .object({ - messages: createSourceFilterSchema(messageFilterFieldSchema).optional(), + messages: messageSourceFilterSchema.optional(), prompts: createSourceFilterSchema(promptFilterFieldSchema).optional(), agentInstructions: createSourceFilterSchema(agentInstructionFilterFieldSchema).optional(), conversationStarters: createSourceFilterSchema(conversationStarterFilterFieldSchema).optional(), diff --git a/packages/data-schemas/src/app/service.spec.ts b/packages/data-schemas/src/app/service.spec.ts index 8102f1a623..820335f4b5 100644 --- a/packages/data-schemas/src/app/service.spec.ts +++ b/packages/data-schemas/src/app/service.spec.ts @@ -103,6 +103,23 @@ describe('loadFiltersConfig', () => { ).toBeUndefined(); }); + it('retains strict legacy attribution without source-aware PII patterns', () => { + expect( + loadFiltersConfig({ + filters: { + messages: { unattributedAssistantContent: 'inspect' }, + }, + }), + ).toEqual({ messages: { unattributedAssistantContent: 'inspect' } }); + expect( + loadFiltersConfig({ + filters: { + messages: { unattributedAssistantContent: 'model_output' }, + }, + }), + ).toBeUndefined(); + }); + it('returns a validated source-aware filter config', () => { const result = loadFiltersConfig({ filters: { diff --git a/packages/data-schemas/src/methods/message.spec.ts b/packages/data-schemas/src/methods/message.spec.ts index 1fe2c897ee..6bbe56f19f 100644 --- a/packages/data-schemas/src/methods/message.spec.ts +++ b/packages/data-schemas/src/methods/message.spec.ts @@ -123,6 +123,32 @@ describe('Message Operations', () => { expect(savedMessage?.userSubmittedPaths).toEqual(['/content/0/text']); }); + it('stamps native model output without overriding explicit provenance', async () => { + const result = await saveMessage(mockCtx, { + ...mockMessageData, + isCreatedByUser: false, + }); + + expect(result?.isUserSubmitted).toBe(false); + + const explicitlySubmitted = await saveMessage(mockCtx, { + ...mockMessageData, + messageId: 'msg-explicit-submitted', + isCreatedByUser: false, + isUserSubmitted: true, + }); + expect(explicitlySubmitted?.isUserSubmitted).toBe(true); + + const pathScoped = await saveMessage(mockCtx, { + ...mockMessageData, + messageId: 'msg-path-scoped', + isCreatedByUser: false, + userSubmittedPaths: ['/content/0/text'], + }); + expect(pathScoped?.isUserSubmitted).toBe(false); + expect(pathScoped?.userSubmittedPaths).toEqual(['/content/0/text']); + }); + it('bounds and validates stored user-submitted provenance paths', async () => { const submittedPaths = [ 'not-a-pointer', @@ -184,6 +210,100 @@ describe('Message Operations', () => { }); }); + describe('bulkSaveMessages', () => { + it('preserves unknown provenance when cloning an unmarked assistant row', async () => { + const conversationId = uuidv4(); + await bulkSaveMessages([ + { + user: 'user123', + messageId: 'bulk-unmarked-assistant', + conversationId, + isCreatedByUser: false, + }, + { + user: 'user123', + messageId: 'bulk-user-submitted', + conversationId, + isCreatedByUser: false, + isUserSubmitted: true, + }, + ]); + + const rows = await Message.find({ conversationId }).lean(); + expect( + rows.find(({ messageId }) => messageId === 'bulk-unmarked-assistant'), + ).not.toHaveProperty('isUserSubmitted'); + expect( + rows.find(({ messageId }) => messageId === 'bulk-user-submitted')?.isUserSubmitted, + ).toBe(true); + }); + }); + + describe('recordMessage', () => { + it('stamps native model output without overriding explicit provenance', async () => { + const conversationId = uuidv4(); + const modelOutput = await recordMessage({ + user: 'user123', + messageId: 'record-model-output', + conversationId, + isCreatedByUser: false, + }); + expect(modelOutput?.isUserSubmitted).toBe(false); + + const explicitlySubmitted = await recordMessage({ + user: 'user123', + messageId: 'record-user-submitted', + conversationId, + isCreatedByUser: false, + isUserSubmitted: true, + }); + expect(explicitlySubmitted?.isUserSubmitted).toBe(true); + }); + }); + + it('preserves unknown provenance when message savers update legacy assistant rows', async () => { + const conversationId = uuidv4(); + const messageIds = ['legacy-save', 'legacy-bulk', 'legacy-record']; + await Message.collection.insertMany( + messageIds.map((messageId) => ({ + user: 'user123', + messageId, + conversationId, + isCreatedByUser: false, + text: 'Legacy assistant output', + })), + ); + + await saveMessage(mockCtx, { + messageId: 'legacy-save', + conversationId, + isCreatedByUser: false, + text: 'Updated assistant output', + }); + await bulkSaveMessages([ + { + user: 'user123', + messageId: 'legacy-bulk', + conversationId, + isCreatedByUser: false, + text: 'Updated assistant output', + }, + ]); + await recordMessage({ + user: 'user123', + messageId: 'legacy-record', + conversationId, + isCreatedByUser: false, + text: 'Updated assistant output', + }); + + const rows = await Message.find({ messageId: { $in: messageIds } }).lean(); + expect(rows).toHaveLength(3); + for (const row of rows) { + expect(row).not.toHaveProperty('isUserSubmitted'); + } + }); + describe('updateMessageText', () => { it('should update message text for the authenticated user', async () => { // First save a message diff --git a/packages/data-schemas/src/methods/message.ts b/packages/data-schemas/src/methods/message.ts index 3b90a62209..31111d5b1a 100644 --- a/packages/data-schemas/src/methods/message.ts +++ b/packages/data-schemas/src/methods/message.ts @@ -188,11 +188,18 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa ...getSteerUserSubmittedPaths(params.content), ]); delete update.userSubmittedPaths; + const stampModelOutputOnInsert = + params.isCreatedByUser === false && params.isUserSubmitted === undefined; const messageUpdate = - userSubmittedPaths.length > 0 + userSubmittedPaths.length > 0 || stampModelOutputOnInsert ? { $set: update, - $addToSet: { userSubmittedPaths: { $each: userSubmittedPaths } }, + ...(userSubmittedPaths.length > 0 && { + $addToSet: { userSubmittedPaths: { $each: userSubmittedPaths } }, + }), + ...(stampModelOutputOnInsert && { + $setOnInsert: { isUserSubmitted: false }, + }), } : update; const message = await Message.findOneAndUpdate( @@ -300,8 +307,12 @@ export function createMessageMethods(mongoose: typeof import('mongoose')): Messa parentMessageId, ...rest, }; + const update = + rest.isCreatedByUser === false && rest.isUserSubmitted === undefined + ? { $set: message, $setOnInsert: { isUserSubmitted: false } } + : message; - return await Message.findOneAndUpdate({ user, messageId }, message, { + return await Message.findOneAndUpdate({ user, messageId }, update, { upsert: true, new: true, });