diff --git a/api/server/services/Files/Code/__tests__/process-traversal.spec.js b/api/server/services/Files/Code/__tests__/process-traversal.spec.js index 2db366d06b..0b8548445d 100644 --- a/api/server/services/Files/Code/__tests__/process-traversal.spec.js +++ b/api/server/services/Files/Code/__tests__/process-traversal.spec.js @@ -10,11 +10,23 @@ jest.mock('@librechat/agents', () => ({ const mockSanitizeFilename = jest.fn(); -jest.mock('@librechat/api', () => ({ - logAxiosError: jest.fn(), - getBasePath: jest.fn(() => ''), - sanitizeFilename: mockSanitizeFilename, -})); +const mockAxios = jest.fn().mockResolvedValue({ + data: Buffer.from('file-content'), +}); +mockAxios.post = jest.fn(); + +jest.mock('@librechat/api', () => { + const http = require('http'); + const https = require('https'); + return { + logAxiosError: jest.fn(), + getBasePath: jest.fn(() => ''), + sanitizeFilename: mockSanitizeFilename, + createAxiosInstance: jest.fn(() => mockAxios), + codeServerHttpAgent: new http.Agent({ keepAlive: false }), + codeServerHttpsAgent: new https.Agent({ keepAlive: false }), + }; +}); jest.mock('librechat-data-provider', () => ({ ...jest.requireActual('librechat-data-provider'), @@ -53,12 +65,6 @@ jest.mock('~/server/utils', () => ({ determineFileType: jest.fn().mockResolvedValue({ mime: 'text/csv' }), })); -jest.mock('axios', () => - jest.fn().mockResolvedValue({ - data: Buffer.from('file-content'), - }), -); - const { createFile } = require('~/models'); const { processCodeOutput } = require('../process'); diff --git a/api/server/services/Files/Code/crud.js b/api/server/services/Files/Code/crud.js index 4781219fcf..945aec787b 100644 --- a/api/server/services/Files/Code/crud.js +++ b/api/server/services/Files/Code/crud.js @@ -1,6 +1,11 @@ const FormData = require('form-data'); const { getCodeBaseURL } = require('@librechat/agents'); -const { createAxiosInstance, logAxiosError } = require('@librechat/api'); +const { + logAxiosError, + createAxiosInstance, + codeServerHttpAgent, + codeServerHttpsAgent, +} = require('@librechat/api'); const axios = createAxiosInstance(); @@ -25,6 +30,8 @@ async function getCodeOutputDownloadStream(fileIdentifier, apiKey) { 'User-Agent': 'LibreChat/1.0', 'X-API-Key': apiKey, }, + httpAgent: codeServerHttpAgent, + httpsAgent: codeServerHttpsAgent, timeout: 15000, }; @@ -69,6 +76,9 @@ async function uploadCodeEnvFile({ req, stream, filename, apiKey, entity_id = '' 'User-Id': req.user.id, 'X-API-Key': apiKey, }, + httpAgent: codeServerHttpAgent, + httpsAgent: codeServerHttpsAgent, + timeout: 120000, maxContentLength: MAX_FILE_SIZE, maxBodyLength: MAX_FILE_SIZE, }; diff --git a/api/server/services/Files/Code/crud.spec.js b/api/server/services/Files/Code/crud.spec.js new file mode 100644 index 0000000000..261f0f052b --- /dev/null +++ b/api/server/services/Files/Code/crud.spec.js @@ -0,0 +1,149 @@ +const http = require('http'); +const https = require('https'); +const { Readable } = require('stream'); + +const mockAxios = jest.fn(); +mockAxios.post = jest.fn(); + +jest.mock('@librechat/agents', () => ({ + getCodeBaseURL: jest.fn(() => 'https://code-api.example.com'), +})); + +jest.mock('@librechat/api', () => { + const http = require('http'); + const https = require('https'); + return { + logAxiosError: jest.fn(({ message }) => message), + createAxiosInstance: jest.fn(() => mockAxios), + codeServerHttpAgent: new http.Agent({ keepAlive: false }), + codeServerHttpsAgent: new https.Agent({ keepAlive: false }), + }; +}); + +const { codeServerHttpAgent, codeServerHttpsAgent } = require('@librechat/api'); +const { getCodeOutputDownloadStream, uploadCodeEnvFile } = require('./crud'); + +describe('Code CRUD', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('getCodeOutputDownloadStream', () => { + it('should pass dedicated keepAlive:false agents to axios', async () => { + const mockResponse = { data: Readable.from(['chunk']) }; + mockAxios.mockResolvedValue(mockResponse); + + await getCodeOutputDownloadStream('session-1/file-1', 'test-key'); + + const callConfig = mockAxios.mock.calls[0][0]; + expect(callConfig.httpAgent).toBe(codeServerHttpAgent); + expect(callConfig.httpsAgent).toBe(codeServerHttpsAgent); + expect(callConfig.httpAgent).toBeInstanceOf(http.Agent); + expect(callConfig.httpsAgent).toBeInstanceOf(https.Agent); + expect(callConfig.httpAgent.keepAlive).toBe(false); + expect(callConfig.httpsAgent.keepAlive).toBe(false); + }); + + it('should request stream response from the correct URL', async () => { + mockAxios.mockResolvedValue({ data: Readable.from(['chunk']) }); + + await getCodeOutputDownloadStream('session-1/file-1', 'test-key'); + + const callConfig = mockAxios.mock.calls[0][0]; + expect(callConfig.url).toBe('https://code-api.example.com/download/session-1/file-1'); + expect(callConfig.responseType).toBe('stream'); + expect(callConfig.timeout).toBe(15000); + expect(callConfig.headers['X-API-Key']).toBe('test-key'); + }); + + it('should throw on network error', async () => { + mockAxios.mockRejectedValue(new Error('ECONNREFUSED')); + + await expect(getCodeOutputDownloadStream('s/f', 'key')).rejects.toThrow(); + }); + }); + + describe('uploadCodeEnvFile', () => { + const baseUploadParams = { + req: { user: { id: 'user-123' } }, + stream: Readable.from(['file-content']), + filename: 'data.csv', + apiKey: 'test-key', + }; + + it('should pass dedicated keepAlive:false agents to axios', async () => { + mockAxios.post.mockResolvedValue({ + data: { + message: 'success', + session_id: 'sess-1', + files: [{ fileId: 'fid-1', filename: 'data.csv' }], + }, + }); + + await uploadCodeEnvFile(baseUploadParams); + + const callConfig = mockAxios.post.mock.calls[0][2]; + expect(callConfig.httpAgent).toBe(codeServerHttpAgent); + expect(callConfig.httpsAgent).toBe(codeServerHttpsAgent); + expect(callConfig.httpAgent).toBeInstanceOf(http.Agent); + expect(callConfig.httpsAgent).toBeInstanceOf(https.Agent); + expect(callConfig.httpAgent.keepAlive).toBe(false); + expect(callConfig.httpsAgent.keepAlive).toBe(false); + }); + + it('should set a timeout on upload requests', async () => { + mockAxios.post.mockResolvedValue({ + data: { + message: 'success', + session_id: 'sess-1', + files: [{ fileId: 'fid-1', filename: 'data.csv' }], + }, + }); + + await uploadCodeEnvFile(baseUploadParams); + + const callConfig = mockAxios.post.mock.calls[0][2]; + expect(callConfig.timeout).toBe(120000); + }); + + it('should return fileIdentifier on success', async () => { + mockAxios.post.mockResolvedValue({ + data: { + message: 'success', + session_id: 'sess-1', + files: [{ fileId: 'fid-1', filename: 'data.csv' }], + }, + }); + + const result = await uploadCodeEnvFile(baseUploadParams); + expect(result).toBe('sess-1/fid-1'); + }); + + it('should append entity_id query param when provided', async () => { + mockAxios.post.mockResolvedValue({ + data: { + message: 'success', + session_id: 'sess-1', + files: [{ fileId: 'fid-1', filename: 'data.csv' }], + }, + }); + + const result = await uploadCodeEnvFile({ ...baseUploadParams, entity_id: 'agent-42' }); + expect(result).toBe('sess-1/fid-1?entity_id=agent-42'); + }); + + it('should throw when server returns non-success message', async () => { + mockAxios.post.mockResolvedValue({ + data: { message: 'quota_exceeded', session_id: 's', files: [] }, + }); + + await expect(uploadCodeEnvFile(baseUploadParams)).rejects.toThrow('quota_exceeded'); + }); + + it('should throw on network error', async () => { + mockAxios.post.mockRejectedValue(new Error('ECONNREFUSED')); + + await expect(uploadCodeEnvFile(baseUploadParams)).rejects.toThrow(); + }); + }); +}); diff --git a/api/server/services/Files/Code/process.js b/api/server/services/Files/Code/process.js index e878b00255..7cdebeb202 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -1,9 +1,15 @@ const path = require('path'); const { v4 } = require('uuid'); -const axios = require('axios'); const { logger } = require('@librechat/data-schemas'); const { getCodeBaseURL } = require('@librechat/agents'); -const { logAxiosError, getBasePath, sanitizeFilename } = require('@librechat/api'); +const { + getBasePath, + logAxiosError, + sanitizeFilename, + createAxiosInstance, + codeServerHttpAgent, + codeServerHttpsAgent, +} = require('@librechat/api'); const { Tools, megabyte, @@ -23,6 +29,8 @@ const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { convertImage } = require('~/server/services/Files/images/convert'); const { determineFileType } = require('~/server/utils'); +const axios = createAxiosInstance(); + /** * Creates a fallback download URL response when file cannot be processed locally. * Used when: file exceeds size limit, storage strategy unavailable, or download error occurs. @@ -102,6 +110,8 @@ const processCodeOutput = async ({ 'User-Agent': 'LibreChat/1.0', 'X-API-Key': apiKey, }, + httpAgent: codeServerHttpAgent, + httpsAgent: codeServerHttpsAgent, timeout: 15000, }); @@ -300,6 +310,8 @@ async function getSessionInfo(fileIdentifier, apiKey) { 'User-Agent': 'LibreChat/1.0', 'X-API-Key': apiKey, }, + httpAgent: codeServerHttpAgent, + httpsAgent: codeServerHttpsAgent, timeout: 5000, }); @@ -448,5 +460,6 @@ const primeFiles = async (options, apiKey) => { module.exports = { primeFiles, + getSessionInfo, processCodeOutput, }; diff --git a/api/server/services/Files/Code/process.spec.js b/api/server/services/Files/Code/process.spec.js index b89a6c6307..a805ee2bcc 100644 --- a/api/server/services/Files/Code/process.spec.js +++ b/api/server/services/Files/Code/process.spec.js @@ -36,11 +36,24 @@ jest.mock('uuid', () => ({ v4: jest.fn(() => 'mock-uuid-1234'), })); -// Mock axios -jest.mock('axios'); -const axios = require('axios'); +// Mock axios — process.js now uses createAxiosInstance() from @librechat/api +const mockAxios = jest.fn(); +mockAxios.post = jest.fn(); +mockAxios.isAxiosError = jest.fn(() => false); + +jest.mock('@librechat/api', () => { + const http = require('http'); + const https = require('https'); + return { + logAxiosError: jest.fn(), + getBasePath: jest.fn(() => ''), + sanitizeFilename: jest.fn((name) => name), + createAxiosInstance: jest.fn(() => mockAxios), + codeServerHttpAgent: new http.Agent({ keepAlive: false }), + codeServerHttpsAgent: new https.Agent({ keepAlive: false }), + }; +}); -// Mock logger jest.mock('@librechat/data-schemas', () => ({ logger: { warn: jest.fn(), @@ -49,18 +62,10 @@ jest.mock('@librechat/data-schemas', () => ({ }, })); -// Mock getCodeBaseURL jest.mock('@librechat/agents', () => ({ getCodeBaseURL: jest.fn(() => 'https://code-api.example.com'), })); -// Mock logAxiosError and getBasePath -jest.mock('@librechat/api', () => ({ - logAxiosError: jest.fn(), - getBasePath: jest.fn(() => ''), - sanitizeFilename: jest.fn((name) => name), -})); - // Mock models const mockClaimCodeFile = jest.fn(); jest.mock('~/models', () => ({ @@ -90,14 +95,16 @@ jest.mock('~/server/utils', () => ({ determineFileType: jest.fn(), })); +const http = require('http'); +const https = require('https'); const { createFile, getFiles } = require('~/models'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { convertImage } = require('~/server/services/Files/images/convert'); const { determineFileType } = require('~/server/utils'); const { logger } = require('@librechat/data-schemas'); +const { codeServerHttpAgent, codeServerHttpsAgent } = require('@librechat/api'); -// Import after mocks -const { processCodeOutput } = require('./process'); +const { processCodeOutput, getSessionInfo } = require('./process'); describe('Code Process', () => { const mockReq = { @@ -145,7 +152,7 @@ describe('Code Process', () => { }); const smallBuffer = Buffer.alloc(100); - axios.mockResolvedValue({ data: smallBuffer }); + mockAxios.mockResolvedValue({ data: smallBuffer }); const result = await processCodeOutput(baseParams); @@ -168,7 +175,7 @@ describe('Code Process', () => { }); const smallBuffer = Buffer.alloc(100); - axios.mockResolvedValue({ data: smallBuffer }); + mockAxios.mockResolvedValue({ data: smallBuffer }); const result = await processCodeOutput(baseParams); @@ -182,7 +189,7 @@ describe('Code Process', () => { it('should process image files using convertImage', async () => { const imageParams = { ...baseParams, name: 'chart.png' }; const imageBuffer = Buffer.alloc(500); - axios.mockResolvedValue({ data: imageBuffer }); + mockAxios.mockResolvedValue({ data: imageBuffer }); const convertedFile = { filepath: '/uploads/converted-image.webp', @@ -212,7 +219,7 @@ describe('Code Process', () => { }); const imageBuffer = Buffer.alloc(500); - axios.mockResolvedValue({ data: imageBuffer }); + mockAxios.mockResolvedValue({ data: imageBuffer }); convertImage.mockResolvedValue({ filepath: '/images/user-123/existing-img-id.webp' }); const result = await processCodeOutput(imageParams); @@ -235,7 +242,7 @@ describe('Code Process', () => { describe('non-image file processing', () => { it('should process non-image files using saveBuffer', async () => { const smallBuffer = Buffer.alloc(100); - axios.mockResolvedValue({ data: smallBuffer }); + mockAxios.mockResolvedValue({ data: smallBuffer }); const mockSaveBuffer = jest.fn().mockResolvedValue('/uploads/saved-file.txt'); getStrategyFunctions.mockReturnValue({ saveBuffer: mockSaveBuffer }); @@ -256,7 +263,7 @@ describe('Code Process', () => { it('should detect MIME type from buffer', async () => { const smallBuffer = Buffer.alloc(100); - axios.mockResolvedValue({ data: smallBuffer }); + mockAxios.mockResolvedValue({ data: smallBuffer }); determineFileType.mockResolvedValue({ mime: 'application/pdf' }); const result = await processCodeOutput({ ...baseParams, name: 'document.pdf' }); @@ -267,7 +274,7 @@ describe('Code Process', () => { it('should fallback to application/octet-stream for unknown types', async () => { const smallBuffer = Buffer.alloc(100); - axios.mockResolvedValue({ data: smallBuffer }); + mockAxios.mockResolvedValue({ data: smallBuffer }); determineFileType.mockResolvedValue(null); const result = await processCodeOutput({ ...baseParams, name: 'unknown.xyz' }); @@ -282,7 +289,7 @@ describe('Code Process', () => { fileSizeLimitConfig.value = 1000; // 1KB limit const largeBuffer = Buffer.alloc(5000); // 5KB - exceeds 1KB limit - axios.mockResolvedValue({ data: largeBuffer }); + mockAxios.mockResolvedValue({ data: largeBuffer }); const result = await processCodeOutput(baseParams); @@ -300,7 +307,7 @@ describe('Code Process', () => { describe('fallback behavior', () => { it('should fallback to download URL when saveBuffer is not available', async () => { const smallBuffer = Buffer.alloc(100); - axios.mockResolvedValue({ data: smallBuffer }); + mockAxios.mockResolvedValue({ data: smallBuffer }); getStrategyFunctions.mockReturnValue({ saveBuffer: null }); const result = await processCodeOutput(baseParams); @@ -313,7 +320,7 @@ describe('Code Process', () => { }); it('should fallback to download URL on axios error', async () => { - axios.mockRejectedValue(new Error('Network error')); + mockAxios.mockRejectedValue(new Error('Network error')); const result = await processCodeOutput(baseParams); @@ -327,7 +334,7 @@ describe('Code Process', () => { describe('usage counter increment', () => { it('should set usage to 1 for new files', async () => { const smallBuffer = Buffer.alloc(100); - axios.mockResolvedValue({ data: smallBuffer }); + mockAxios.mockResolvedValue({ data: smallBuffer }); const result = await processCodeOutput(baseParams); @@ -341,7 +348,7 @@ describe('Code Process', () => { createdAt: '2024-01-01', }); const smallBuffer = Buffer.alloc(100); - axios.mockResolvedValue({ data: smallBuffer }); + mockAxios.mockResolvedValue({ data: smallBuffer }); const result = await processCodeOutput(baseParams); @@ -354,7 +361,7 @@ describe('Code Process', () => { createdAt: '2024-01-01', }); const smallBuffer = Buffer.alloc(100); - axios.mockResolvedValue({ data: smallBuffer }); + mockAxios.mockResolvedValue({ data: smallBuffer }); const result = await processCodeOutput(baseParams); @@ -365,7 +372,7 @@ describe('Code Process', () => { describe('metadata and file properties', () => { it('should include fileIdentifier in metadata', async () => { const smallBuffer = Buffer.alloc(100); - axios.mockResolvedValue({ data: smallBuffer }); + mockAxios.mockResolvedValue({ data: smallBuffer }); const result = await processCodeOutput(baseParams); @@ -376,7 +383,7 @@ describe('Code Process', () => { it('should set correct context for code-generated files', async () => { const smallBuffer = Buffer.alloc(100); - axios.mockResolvedValue({ data: smallBuffer }); + mockAxios.mockResolvedValue({ data: smallBuffer }); const result = await processCodeOutput(baseParams); @@ -385,7 +392,7 @@ describe('Code Process', () => { it('should include toolCallId and messageId in result', async () => { const smallBuffer = Buffer.alloc(100); - axios.mockResolvedValue({ data: smallBuffer }); + mockAxios.mockResolvedValue({ data: smallBuffer }); const result = await processCodeOutput(baseParams); @@ -395,7 +402,7 @@ describe('Code Process', () => { it('should call createFile with upsert enabled', async () => { const smallBuffer = Buffer.alloc(100); - axios.mockResolvedValue({ data: smallBuffer }); + mockAxios.mockResolvedValue({ data: smallBuffer }); await processCodeOutput(baseParams); @@ -408,5 +415,36 @@ describe('Code Process', () => { ); }); }); + + describe('socket pool isolation', () => { + it('should pass dedicated keepAlive:false agents to axios for processCodeOutput', async () => { + const smallBuffer = Buffer.alloc(100); + mockAxios.mockResolvedValue({ data: smallBuffer }); + + await processCodeOutput(baseParams); + + const callConfig = mockAxios.mock.calls[0][0]; + expect(callConfig.httpAgent).toBe(codeServerHttpAgent); + expect(callConfig.httpsAgent).toBe(codeServerHttpsAgent); + expect(callConfig.httpAgent).toBeInstanceOf(http.Agent); + expect(callConfig.httpsAgent).toBeInstanceOf(https.Agent); + expect(callConfig.httpAgent.keepAlive).toBe(false); + expect(callConfig.httpsAgent.keepAlive).toBe(false); + }); + + it('should pass dedicated keepAlive:false agents to axios for getSessionInfo', async () => { + mockAxios.mockResolvedValue({ + data: [{ name: 'sess/fid', lastModified: new Date().toISOString() }], + }); + + await getSessionInfo('sess/fid', 'api-key'); + + const callConfig = mockAxios.mock.calls[0][0]; + expect(callConfig.httpAgent).toBe(codeServerHttpAgent); + expect(callConfig.httpsAgent).toBe(codeServerHttpsAgent); + expect(callConfig.httpAgent.keepAlive).toBe(false); + expect(callConfig.httpsAgent.keepAlive).toBe(false); + }); + }); }); }); diff --git a/packages/api/src/utils/code.ts b/packages/api/src/utils/code.ts new file mode 100644 index 0000000000..9ac814a52b --- /dev/null +++ b/packages/api/src/utils/code.ts @@ -0,0 +1,11 @@ +import http from 'http'; +import https from 'https'; + +/** + * Dedicated agents for code-server requests, preventing socket pool contamination. + * follow-redirects (used by axios) leaks `socket.destroy` as a timeout listener; + * on Node 19+ (keepAlive: true by default), tainted sockets re-enter the global pool + * and kill unrelated requests (e.g., node-fetch in CodeExecutor) after the idle timeout. + */ +export const codeServerHttpAgent = new http.Agent({ keepAlive: false }); +export const codeServerHttpsAgent = new https.Agent({ keepAlive: false }); diff --git a/packages/api/src/utils/index.ts b/packages/api/src/utils/index.ts index 5b9315d8c7..a1412e21f2 100644 --- a/packages/api/src/utils/index.ts +++ b/packages/api/src/utils/index.ts @@ -1,5 +1,6 @@ export * from './axios'; export * from './azure'; +export * from './code'; export * from './common'; export * from './content'; export * from './email';