diff --git a/.env.example b/.env.example index eb7350a93e2..e7320aa3a42 100644 --- a/.env.example +++ b/.env.example @@ -73,6 +73,93 @@ NO_INDEX=true # Defaulted to 1. TRUST_PROXY=1 +#===============================# +# Security Headers # +#===============================# + +# Baseline HTTP security headers (HSTS, X-Frame-Options, X-Content-Type-Options, +# COOP, CORP, Referrer-Policy) are sent on every response. Content-Security-Policy +# is never set here. Set to false to send no security headers at all. +# SECURITY_HEADERS=true + +# Strict-Transport-Security. Only meaningful over HTTPS; browsers ignore it on +# plain HTTP. HSTS_INCLUDE_SUBDOMAINS applies the policy to every subdomain of +# this host for the full max-age, so enable it only if all of them serve HTTPS. +# HSTS_ENABLED=true +# HSTS_MAX_AGE=31536000 +# HSTS_INCLUDE_SUBDOMAINS=false +# HSTS_PRELOAD=false + +# X-Frame-Options. Set to DENY to block all framing, or to `off` if you embed +# LibreChat in an iframe on another origin. +# X_FRAME_OPTIONS=SAMEORIGIN + +# Referrer-Policy. Any standard token, or `off` to omit the header. +# REFERRER_POLICY=no-referrer + +# Cross-Origin-Opener-Policy. Use same-origin-allow-popups if a popup-based +# sign-in flow needs to reach back to the window that opened it. +# CROSS_ORIGIN_OPENER_POLICY=same-origin + +# Cross-Origin-Resource-Policy. Use cross-origin if other sites need to load +# resources served by LibreChat, such as uploaded images. +# CROSS_ORIGIN_RESOURCE_POLICY=same-origin + +#===============================# +# Content Security Policy # +#===============================# + +# Nonce-based CSP for the SPA HTML response. Off by default so existing +# deployments are unaffected. Turn it on in report-only mode first, review the +# violations your deployment actually produces, then set CSP_REPORT_ONLY=false. +# Only an explicit false/off/0/no enforces; anything unrecognized warns and stays +# report-only, so a typo cannot silently start blocking scripts. +# CSP_ENABLED=false +# CSP_REPORT_ONLY=true +# CSP_REPORT_URI= + +# The default policy accommodates what LibreChat actually loads at runtime: +# script-src 'wasm-unsafe-eval' HEIC image conversion compiles WebAssembly +# worker-src data: Monaco's loader bootstraps workers from data: +# Both are narrower than 'unsafe-eval'. Set these to false to drop them if your +# deployment uses neither HEIC uploads nor the artifact code editor. (The CSP_*_EXTRA +# and CSP_ADDITIONAL_DIRECTIVES variables only add sources; they cannot remove one.) +# CSP_ALLOW_WASM=true +# CSP_ALLOW_DATA_WORKERS=true + +# While CSP is enabled the SPA shell is always sent as `no-store` and the +# INDEX_CACHE_CONTROL / INDEX_PRAGMA / INDEX_EXPIRES overrides are ignored for it. +# A cached shell would pin a single nonce across page loads and users, which is +# precisely what a nonce policy exists to prevent. +# +# SECURITY_HEADERS=false disables CSP too; it is the global kill switch. + +# Add deployment-specific sources on top of LibreChat's defaults; they are +# appended, never replacing them. Comma- or space-separated. Quote values +# containing spaces. +# CSP_CONNECT_SRC_EXTRA="https://telemetry.example.com wss://stream.example.com" +# CSP_FRAME_SRC_EXTRA="https://tenant.sharepoint.com" +# CSP_IMG_SRC_EXTRA="https://cdn.example.com" +# CSP_STYLE_SRC_EXTRA= +# CSP_FONT_SRC_EXTRA= +# CSP_MEDIA_SRC_EXTRA= +# CSP_WORKER_SRC_EXTRA= +# CSP_FORM_ACTION_EXTRA= +# CSP_DEFAULT_SRC_EXTRA= + +# Script hosts get their own note: the default policy uses 'strict-dynamic', +# which makes browsers ignore every host source in script-src. Setting this +# drops 'strict-dynamic' so the hosts you list actually take effect. +# CSP_SCRIPT_SRC_EXTRA="https://trusted-scripts.example.com" + +# Who may frame LibreChat. Defaults to 'self'. Replace it if you embed LibreChat +# in a portal on another origin, and set X_FRAME_OPTIONS=off alongside it since +# older browsers honor that header instead. +# CSP_FRAME_ANCESTORS="'self' https://portal.example.com" + +# Raw directives appended to the policy, separated by semicolons. +# CSP_ADDITIONAL_DIRECTIVES="upgrade-insecure-requests" + # Trust X-Tenant-Id on unauthenticated routes. Disabled by default. # Enable only when a trusted reverse proxy strips any client-supplied value and sets its own. # TRUST_TENANT_HEADER=false diff --git a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js index 59e24d3c8df..02d35d6643c 100644 --- a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js +++ b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js @@ -63,6 +63,7 @@ const mockFilterPersistableAbortContent = jest.fn((content) => const mockGetConvo = jest.fn(); const mockGetMessages = jest.fn(); const mockSaveMessage = jest.fn(); +const mockSaveConvo = jest.fn(); const mockIsAgentTriggerPrincipalActive = jest.fn(); const mockIsSubagentOwnerAdmissible = jest.fn(); const mockAcquireEventChildGenerationLease = jest.fn(); @@ -224,6 +225,7 @@ jest.mock('~/cache', () => ({ jest.mock('~/models', () => ({ saveMessage: (...args) => mockSaveMessage(...args), + saveConvo: (...args) => mockSaveConvo(...args), getMessages: (...args) => mockGetMessages(...args), getConvo: (...args) => mockGetConvo(...args), isAgentTriggerPrincipalActive: (...args) => mockIsAgentTriggerPrincipalActive(...args), @@ -319,7 +321,12 @@ describe('ResumableAgentController resume metadata', () => { }), ); mockGenerationJobManager.finishTerminalJob.mockResolvedValue(undefined); - mockGenerationJobManager.completeJob.mockResolvedValue(true); + mockGenerationJobManager.completeJob.mockImplementation( + async (_streamId, _error, _createdAt, options) => { + await options?.beforeErrorPublication?.(); + return true; + }, + ); mockGenerationJobManager.beginProviderExecution.mockResolvedValue(true); mockGenerationJobManager.markProviderExecutionDrained.mockResolvedValue(true); mockGenerationJobManager.failPausePersistence.mockResolvedValue(true); @@ -334,6 +341,7 @@ describe('ResumableAgentController resume metadata', () => { mockGenerationJobManager.steering.park.mockResolvedValue(undefined); mockGenerationJobManager.steering.consumeRecovered.mockResolvedValue(true); mockSaveMessage.mockResolvedValue({}); + mockSaveConvo.mockResolvedValue({}); mockDeleteAgentCheckpoint.mockResolvedValue(undefined); }); @@ -1362,6 +1370,8 @@ describe('ResumableAgentController resume metadata', () => { await AgentController(req, res, jest.fn(), initializeClient, null); expect(allSubscribersLeftHandler).toEqual(expect.any(Function)); + mockSaveMessage.mockClear(); + mockSaveConvo.mockClear(); const oauthPart = { type: 'tool_call', @@ -2289,6 +2299,7 @@ describe('ResumableAgentController resume metadata', () => { error: 'Attached resources could not be restored', }), 1000, + expect.objectContaining({ beforeErrorPublication: expect.any(Function) }), ); }); @@ -2326,6 +2337,7 @@ describe('ResumableAgentController resume metadata', () => { error: 'Stateful code environment is not allowed by this deployment: conversation', }), 1000, + expect.objectContaining({ beforeErrorPublication: expect.any(Function) }), ); }); @@ -2516,6 +2528,7 @@ describe('ResumableAgentController resume metadata', () => { 'conversation-123', 'provider init failed', 1000, + expect.objectContaining({ beforeErrorPublication: expect.any(Function) }), ); }); @@ -2552,6 +2565,7 @@ describe('ResumableAgentController resume metadata', () => { 'conversation-123', 'Recovered steer cannot skip user message persistence', 1000, + expect.objectContaining({ beforeErrorPublication: expect.any(Function) }), ); }); @@ -3024,6 +3038,247 @@ describe('ResumableAgentController resume metadata', () => { expect(mockGenerationJobManager.claimTerminalJob).not.toHaveBeenCalled(); }); + describe('failed-turn persistence', () => { + const conversationId = 'conversation-123'; + + const createFailedRequest = (bodyOverrides = {}) => ({ + user: { id: 'user-123' }, + body: { + text: 'Hello with a removed model.', + messageId: 'user-message', + parentMessageId: 'prior-response', + conversationId, + endpointOption: { + endpoint: 'azureOpenAI', + modelOptions: { model: 'gpt-4o' }, + }, + ...bodyOverrides, + }, + config: {}, + }); + + async function flushBackgroundGeneration() { + for (let i = 0; i < 10; i++) { + await nextTick(); + } + } + + it('persists an initialization failure before terminal error publication', async () => { + const events = []; + mockSaveConvo.mockImplementation(async () => { + events.push('turn-persisted'); + return {}; + }); + mockGenerationJobManager.completeJob.mockImplementation( + async (_streamId, _error, _createdAt, options) => { + await options.beforeErrorPublication(); + events.push('error-published'); + return true; + }, + ); + const initializeClient = jest + .fn() + .mockRejectedValue(new Error('The model "gpt-4o" is not available.')); + + await AgentController( + createFailedRequest(), + createResumableResponse(), + jest.fn(), + initializeClient, + null, + ); + + expect(mockSaveMessage).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-123' }), + expect.objectContaining({ + messageId: 'user-message', + parentMessageId: 'prior-response', + conversationId, + text: 'Hello with a removed model.', + isCreatedByUser: true, + error: false, + }), + expect.any(Object), + ); + expect(mockSaveMessage).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-123' }), + expect.objectContaining({ + messageId: 'user-message_', + parentMessageId: 'user-message', + conversationId, + endpoint: 'azureOpenAI', + model: 'gpt-4o', + text: 'The model "gpt-4o" is not available.', + error: true, + isCreatedByUser: false, + }), + expect.any(Object), + ); + expect(events).toEqual(['turn-persisted', 'error-published']); + expect(mockSaveConvo).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-123' }), + { conversationId }, + expect.objectContaining({ noUpsert: true }), + ); + }); + + it('allows a follow-up to chain from the persisted failed response', async () => { + const initializeClient = jest.fn().mockRejectedValue(new Error('model unavailable')); + await AgentController( + createFailedRequest(), + createResumableResponse(), + jest.fn(), + initializeClient, + null, + ); + + expect(mockSaveMessage.mock.calls.map(([, message]) => message.messageId)).toContain( + 'user-message_', + ); + mockGetMessages.mockResolvedValue([{ _id: 'persisted-error-turn' }]); + const followUpRes = createResumableResponse(); + + await AgentController( + createFailedRequest({ + text: 'Retry with a valid model.', + messageId: 'follow-up-user', + parentMessageId: 'user-message_', + }), + followUpRes, + jest.fn(), + initializeClient, + null, + ); + + expect(followUpRes.status).not.toHaveBeenCalledWith(409); + expect(mockCheckAndIncrementPendingRequest).toHaveBeenCalledTimes(2); + }); + + it('persists failures raised before generation saves any message', async () => { + const client = { + options: {}, + sendMessage: jest.fn().mockRejectedValue(new Error('provider exploded')), + }; + + await AgentController( + createFailedRequest(), + createResumableResponse(), + jest.fn(), + jest.fn().mockResolvedValue({ client }), + null, + ); + await flushBackgroundGeneration(); + + expect(mockSaveMessage).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-123' }), + expect.objectContaining({ + messageId: 'user-message_', + text: 'provider exploded', + error: true, + }), + expect.any(Object), + ); + expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith( + conversationId, + 'provider exploded', + 1000, + expect.objectContaining({ beforeErrorPublication: expect.any(Function) }), + ); + }); + + it('uses the live user identity after generation starts', async () => { + const serverUserMessage = { + messageId: 'server-user', + parentMessageId: 'prior-response', + conversationId, + sender: 'User', + text: 'Hello with a removed model.', + isCreatedByUser: true, + }; + const client = { + options: {}, + sendMessage: jest.fn(async (_text, options) => { + options.onStart(serverUserMessage, 'server-response-uuid'); + throw new Error('failed after onStart'); + }), + }; + + await AgentController( + createFailedRequest(), + createResumableResponse(), + jest.fn(), + jest.fn().mockResolvedValue({ client }), + null, + ); + await flushBackgroundGeneration(); + + const savedIds = mockSaveMessage.mock.calls.map(([, message]) => message.messageId); + expect(savedIds).toEqual(expect.arrayContaining(['server-user', 'server-user_'])); + expect(savedIds).not.toContain('user-message_'); + }); + + it('does not overwrite an existing response row', async () => { + mockGetMessages.mockResolvedValue([{ _id: 'already-saved' }]); + + await AgentController( + createFailedRequest(), + createResumableResponse(), + jest.fn(), + jest.fn().mockRejectedValue(new Error('late failure')), + null, + ); + + expect(mockSaveMessage).not.toHaveBeenCalled(); + expect(mockSaveConvo).not.toHaveBeenCalled(); + }); + + it('creates the conversation row for a failed first turn', async () => { + const res = createResumableResponse(); + mockGenerationJobManager.claimGeneration.mockImplementation( + async (_userId, _clientRequestId, streamId, claimedConversationId) => + wonGenerationClaim({ streamId, conversationId: claimedConversationId }), + ); + const req = createFailedRequest({ + conversationId: undefined, + clientRequestId: 'failed-new-conversation', + parentMessageId: '00000000-0000-0000-0000-000000000000', + endpointOption: { + endpoint: 'azureOpenAI', + modelOptions: { model: 'gpt-4o' }, + chatProjectId: '507f1f77bcf86cd799439011', + }, + }); + + await AgentController( + req, + res, + jest.fn(), + jest.fn().mockRejectedValue(new Error('model unavailable')), + null, + ); + + const mintedConversationId = res.json.mock.calls[0][0].conversationId; + expect(mockSaveMessage).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ + messageId: 'user-message_', + conversationId: mintedConversationId, + }), + expect.any(Object), + ); + expect(mockSaveConvo).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user-123' }), + expect.objectContaining({ + conversationId: mintedConversationId, + endpoint: 'azureOpenAI', + model: 'gpt-4o', + chatProjectId: '507f1f77bcf86cd799439011', + }), + expect.any(Object), + ); + }); + }); + it('finalizes the failed job before releasing the idempotency claim', async () => { mockGenerationJobManager.claimGeneration.mockResolvedValue(wonGenerationClaim()); const initializeClient = jest.fn().mockRejectedValue(new Error('init boom after res.json')); @@ -3046,6 +3301,7 @@ describe('ResumableAgentController resume metadata', () => { 'conversation-123', expect.any(String), 1000, + expect.objectContaining({ beforeErrorPublication: expect.any(Function) }), ); expect(mockGenerationJobManager.releaseGeneration).toHaveBeenCalledWith( 'user-123', @@ -3112,6 +3368,7 @@ describe('ResumableAgentController resume metadata', () => { 'conversation-123', 'init boom after res.json', 1000, + expect.objectContaining({ beforeErrorPublication: expect.any(Function) }), ); expect(mockGenerationJobManager.releaseGeneration).toHaveBeenCalledWith( 'user-123', @@ -3227,6 +3484,7 @@ describe('ResumableAgentController resume metadata', () => { 'conversation-123', generationError.message, 1000, + expect.objectContaining({ beforeErrorPublication: expect.any(Function) }), ); expect(mockGenerationJobManager.completeJob.mock.invocationCallOrder[0]).toBeLessThan( mockDecrementPendingRequest.mock.invocationCallOrder[0], diff --git a/api/server/controllers/agents/errors.js b/api/server/controllers/agents/errors.js index b16ce75591c..308e10cfc41 100644 --- a/api/server/controllers/agents/errors.js +++ b/api/server/controllers/agents/errors.js @@ -1,5 +1,6 @@ // errorHandler.js const { logger } = require('@librechat/data-schemas'); +const { getTransactionsConfig } = require('@librechat/api'); const { CacheKeys, ViolationTypes } = require('librechat-data-provider'); const { sendResponse } = require('~/server/middleware/error'); const { recordUsage } = require('~/server/services/Threads'); @@ -118,6 +119,7 @@ const createErrorHandler = ({ req, res, getContext, originPath = '/assistants/ch model: run.model, user: req.user.id, conversationId, + transactions: getTransactionsConfig(req.config), }); } catch (error) { logger.error(`[${originPath}] Error fetching or processing run`, error); diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js index 75a1ee744c6..55ac564bb4e 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -40,6 +40,7 @@ const { logViolation } = require('~/cache'); const { recordScheduleOutcome, isScheduleLive } = require('~/server/services/Schedules'); const { saveMessage, + saveConvo, getMessages, getConvo, isAgentTriggerPrincipalActive, @@ -107,6 +108,18 @@ async function attachConversationCreatedAt(req, conversationId, conversationAnch } } +function getPreliminaryResponseMessageId({ messageId, responseMessageId }) { + if (typeof responseMessageId === 'string' && responseMessageId.length > 0) { + return responseMessageId; + } + + if (typeof messageId !== 'string' || messageId.length === 0) { + return null; + } + + return `${messageId.replace(/_+$/, '')}_`; +} + function getPreliminaryUserMessage( { messageId, parentMessageId, text, quotes, files, manualSkills, alwaysAppliedSkills }, conversationId, @@ -190,6 +203,165 @@ async function finishResumableRequest(req, userId) { } } +async function saveErrorTurn( + req, + { + conversationId, + endpointOption, + isNewConvo, + errorText, + liveUserMessage, + liveResponseMessageId, + sender, + }, +) { + try { + const { isContinued, isRegenerate, editedContent, responseMessageId, overrideParentMessageId } = + req.body ?? {}; + if ( + isContinued || + editedContent != null || + (responseMessageId && !isRegenerate) || + req.body?.recoverySteerId != null || + req.body?.clientRequestId?.startsWith?.('steer-recovery:') === true + ) { + return; + } + + let userMessage = null; + let errorMessageId = null; + let errorParentMessageId = null; + if (isRegenerate) { + errorMessageId = + typeof responseMessageId === 'string' && responseMessageId.length > 0 + ? responseMessageId + : null; + errorParentMessageId = liveUserMessage?.messageId ?? overrideParentMessageId ?? null; + } else { + userMessage = + liveUserMessage != null + ? { + ...liveUserMessage, + ...(liveUserMessage.files == null && + Array.isArray(req.body?.files) && + req.body.files.length > 0 && { files: req.body.files }), + ...(liveUserMessage.manualSkills == null && + Array.isArray(req.body?.manualSkills) && + req.body.manualSkills.length > 0 && { manualSkills: req.body.manualSkills }), + ...(liveUserMessage.alwaysAppliedSkills == null && + Array.isArray(req.body?.alwaysAppliedSkills) && + req.body.alwaysAppliedSkills.length > 0 && { + alwaysAppliedSkills: req.body.alwaysAppliedSkills, + }), + } + : getPreliminaryUserMessage(req.body, conversationId); + if (!userMessage) { + return; + } + errorMessageId = getPreliminaryResponseMessageId( + liveUserMessage != null ? { messageId: liveUserMessage.messageId } : req.body, + ); + errorParentMessageId = userMessage.messageId; + } + if (!errorMessageId || !errorParentMessageId) { + return; + } + + const userId = req.user.id; + const existing = await getMessages( + { user: userId, messageId: errorMessageId, conversationId }, + '_id', + ); + if (existing.length > 0) { + return; + } + if (liveResponseMessageId != null && liveResponseMessageId !== errorMessageId) { + const partial = await getMessages( + { user: userId, messageId: liveResponseMessageId, conversationId }, + '_id', + ); + if (partial.length > 0) { + return; + } + } + + const reqCtx = { + userId, + isTemporary: req?._agentEventBindingRetention?.isTemporary ?? req?.body?.isTemporary, + expiredAt: req?._agentEventBindingRetention?.expiredAt, + interfaceConfig: req?.config?.interfaceConfig, + }; + const context = 'api/server/controllers/agents/request.js - failed turn'; + const endpoint = endpointOption?.endpoint; + const model = getAgentResponseModel(req, endpointOption); + const iconURL = getEndpointIconURL(req, endpointOption); + + if (userMessage) { + const savedUserMessage = await saveMessage( + reqCtx, + { + ...userMessage, + user: userId, + sender: 'User', + isCreatedByUser: true, + error: false, + unfinished: false, + }, + { context }, + ); + if (!savedUserMessage) { + throw new Error('Failed user message could not be persisted'); + } + } + const savedErrorMessage = await saveMessage( + reqCtx, + { + messageId: errorMessageId, + conversationId, + parentMessageId: errorParentMessageId, + sender: sender ?? 'AI', + ...(endpoint != null && { endpoint }), + ...(model != null && { model }), + ...(iconURL != null && { iconURL }), + user: userId, + text: errorText, + error: true, + unfinished: false, + isCreatedByUser: false, + }, + { context }, + ); + if (!savedErrorMessage) { + throw new Error('Failed response message could not be persisted'); + } + + const agentId = endpointOption?.agent_id ?? req.body?.agent_id; + const chatProjectId = endpointOption?.chatProjectId ?? req.body?.chatProjectId; + const seedConvo = isNewConvo || req.resolvedConversation === null; + const convoFields = seedConvo + ? { + ...(endpoint != null && { endpoint }), + ...(endpointOption?.endpointType != null && { + endpointType: endpointOption.endpointType, + }), + ...(model != null && { model }), + ...(iconURL != null && { iconURL }), + ...(endpointOption?.spec != null && { spec: endpointOption.spec }), + ...(agentId != null && { agent_id: agentId }), + ...(typeof chatProjectId === 'string' && chatProjectId.length > 0 && { chatProjectId }), + } + : {}; + await saveConvo( + reqCtx, + { conversationId, ...convoFields }, + seedConvo ? { context } : { context, noUpsert: true }, + ); + } catch (err) { + logger.error('[AgentController] Failed to persist error turn', err); + throw err; + } +} + function classifyScheduledFailure(error, aborted = false) { if (aborted || error?.code === 'SCHEDULE_NO_LONGER_ACTIVE') { return { status: 'interrupted', error: error?.message }; @@ -1450,11 +1622,15 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit } let userMessage; + let liveResponseMessageId = preallocatedResponseMessageId; const getReqData = (data = {}) => { if (data.userMessage) { userMessage = data.userMessage; } + if (data.responseMessageId) { + liveResponseMessageId = data.responseMessageId; + } // conversationId is pre-generated, no need to update from callback }; @@ -1593,6 +1769,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit try { const onStart = (userMsg, respMsgId, _isNewConvo) => { userMessage = userMsg; + liveResponseMessageId = respMsgId; // Store userMessage and responseMessageId upfront for resume capability GenerationJobManager.updateMetadata( @@ -2179,8 +2356,18 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit // completeJob first wins running -> error and atomically parks // steers, then publishes. A competing abort/pause emits nothing. ownsScheduledFailure = - (await GenerationJobManager.completeJob(streamId, generationError, jobCreatedAt)) === - true; + (await GenerationJobManager.completeJob(streamId, generationError, jobCreatedAt, { + beforeErrorPublication: () => + saveErrorTurn(req, { + conversationId, + endpointOption, + isNewConvo, + errorText: generationError, + liveUserMessage: userMessage, + liveResponseMessageId, + sender: client?.sender, + }), + })) === true; } catch (completeErr) { logger.warn( '[ResumableAgentController] completeJob failed during generation-error cleanup', @@ -2262,6 +2449,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit } catch (error) { logger.error('[ResumableAgentController] Initialization error:', error); const initializationFailure = getInitializationFailure(error); + const streamStarted = res.headersSent; try { if (!res.headersSent) { if (error?.code === 'GENERATION_PREDECESSOR_MISMATCH') { @@ -2354,16 +2542,25 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit const initializationError = initializationFailure ? JSON.stringify(initializationFailure) : error.message || 'Failed to start generation'; + const completionPromise = streamStarted + ? GenerationJobManager.completeJob(streamId, initializationError, jobCreatedAt, { + beforeErrorPublication: () => + saveErrorTurn(req, { + conversationId, + endpointOption, + isNewConvo, + errorText: initializationError, + }), + }) + : GenerationJobManager.completeJob(streamId, initializationError, jobCreatedAt); initializationFinalized = - (await GenerationJobManager.completeJob(streamId, initializationError, jobCreatedAt).catch( - (completeErr) => { - logger.warn( - '[ResumableAgentController] completeJob failed during init-error cleanup', - completeErr, - ); - return false; - }, - )) === true; + (await completionPromise.catch((completeErr) => { + logger.warn( + '[ResumableAgentController] completeJob failed during init-error cleanup', + completeErr, + ); + return false; + })) === true; } if (initializationFinalized && !scheduleTerminalOutcomeRecorded) { await settleScheduledRun(classifyScheduledFailure(error)); diff --git a/api/server/controllers/assistants/chatV1.js b/api/server/controllers/assistants/chatV1.js index 2e6e6278756..cac475dee6b 100644 --- a/api/server/controllers/assistants/chatV1.js +++ b/api/server/controllers/assistants/chatV1.js @@ -7,6 +7,7 @@ const { checkBalance, getBalanceConfig, getModelMaxTokens, + getTransactionsConfig, ATTACHMENT_ONLY_TEXT, isContentFilterError, hasActiveFilePolicy, @@ -194,6 +195,7 @@ const chatV1 = async (req, res) => { model: run.model, user: req.user.id, conversationId, + transactions: getTransactionsConfig(req.config), }); } catch (error) { logger.error('[/assistants/chat/] Error fetching or processing run', error); @@ -734,6 +736,7 @@ const chatV1 = async (req, res) => { user: req.user.id, model: completedRun.model ?? model, conversationId, + transactions: getTransactionsConfig(req.config), }); } } else { @@ -742,6 +745,7 @@ const chatV1 = async (req, res) => { user: req.user.id, model: response.run.model ?? model, conversationId, + transactions: getTransactionsConfig(req.config), }); } } catch (error) { diff --git a/api/server/controllers/assistants/chatV2.js b/api/server/controllers/assistants/chatV2.js index cae3da468d3..ac451fa1732 100644 --- a/api/server/controllers/assistants/chatV2.js +++ b/api/server/controllers/assistants/chatV2.js @@ -6,6 +6,7 @@ const { countTokens, checkBalance, getBalanceConfig, + getTransactionsConfig, getModelMaxTokens, ATTACHMENT_ONLY_TEXT, isContentFilterError, @@ -574,6 +575,7 @@ const chatV2 = async (req, res) => { user: req.user.id, model: completedRun.model ?? model, conversationId, + transactions: getTransactionsConfig(req.config), }); } } else { @@ -582,6 +584,7 @@ const chatV2 = async (req, res) => { user: req.user.id, model: response.run.model ?? model, conversationId, + transactions: getTransactionsConfig(req.config), }); } } catch (error) { diff --git a/api/server/controllers/assistants/errors.js b/api/server/controllers/assistants/errors.js index f8dcf39f2bc..4aaa4c68d07 100644 --- a/api/server/controllers/assistants/errors.js +++ b/api/server/controllers/assistants/errors.js @@ -1,5 +1,6 @@ // errorHandler.js const { logger } = require('@librechat/data-schemas'); +const { getTransactionsConfig } = require('@librechat/api'); const { CacheKeys, ViolationTypes, ContentTypes } = require('librechat-data-provider'); const { recordUsage, checkMessageGaps } = require('~/server/services/Threads'); const { sendResponse } = require('~/server/middleware/error'); @@ -124,6 +125,7 @@ const createErrorHandler = ({ req, res, getContext, originPath = '/assistants/ch model: run.model, user: req.user.id, conversationId, + transactions: getTransactionsConfig(req.config), }); } catch (error) { logger.error(`[${originPath}] Error fetching or processing run`, error); diff --git a/api/server/csp.spec.js b/api/server/csp.spec.js new file mode 100644 index 00000000000..6e2e4eef46c --- /dev/null +++ b/api/server/csp.spec.js @@ -0,0 +1,232 @@ +const fs = require('fs'); +const path = require('path'); +const request = require('supertest'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const mongoose = require('mongoose'); + +/** + * Mirrors what a production `client/dist/index.html` actually contains: inline + * style, inline script, a module entry, and the module preloads Vite emits. + */ +const INDEX_HTML = + '