mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-21 23:26:34 +01:00
* 🔧 fix: Isolate HTTP agents for code-server axios requests Prevents socket hang up after 5s on Node 19+ when code executor has file attachments. follow-redirects (axios dep) leaks `socket.destroy` as a timeout listener on TCP sockets; with Node 19+ defaulting to keepAlive: true, tainted sockets re-enter the global pool and destroy active node-fetch requests in CodeExecutor after the idle timeout. Uses dedicated http/https agents with keepAlive: false for all axios calls targeting CODE_BASEURL in crud.js and process.js. Closes #12298 * ♻️ refactor: Extract code-server HTTP agents to shared module - Move duplicated agent construction from crud.js and process.js into a shared agents.js module to eliminate DRY violation - Switch process.js from raw `require('axios')` to `createAxiosInstance()` for proxy configuration parity with crud.js - Fix import ordering in process.js (agent constants no longer split imports) - Add 120s timeout to uploadCodeEnvFile (was the only code-server call without a timeout) * ✅ test: Add regression tests for code-server socket isolation - Add crud.spec.js covering getCodeOutputDownloadStream and uploadCodeEnvFile (agent options, timeout, URL, error handling) - Add socket pool isolation tests to process.spec.js asserting keepAlive:false agents are forwarded to axios - Update process.spec.js mocks for createAxiosInstance() migration * ♻️ refactor: Move code-server agents to packages/api Relocate agents.js from api/server/services/Files/Code/ to packages/api/src/utils/code.ts per workspace conventions. Consumers now import codeServerHttpAgent/codeServerHttpsAgent from @librechat/api.
130 lines
4.1 KiB
JavaScript
130 lines
4.1 KiB
JavaScript
jest.mock('uuid', () => ({ v4: jest.fn(() => 'mock-uuid') }));
|
|
|
|
jest.mock('@librechat/data-schemas', () => ({
|
|
logger: { warn: jest.fn(), debug: jest.fn(), error: jest.fn() },
|
|
}));
|
|
|
|
jest.mock('@librechat/agents', () => ({
|
|
getCodeBaseURL: jest.fn(() => 'http://localhost:8000'),
|
|
}));
|
|
|
|
const mockSanitizeFilename = jest.fn();
|
|
|
|
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'),
|
|
mergeFileConfig: jest.fn(() => ({ serverFileSizeLimit: 100 * 1024 * 1024 })),
|
|
getEndpointFileConfig: jest.fn(() => ({
|
|
fileSizeLimit: 100 * 1024 * 1024,
|
|
supportedMimeTypes: ['*/*'],
|
|
})),
|
|
fileConfig: { checkType: jest.fn(() => true) },
|
|
}));
|
|
|
|
jest.mock('~/models', () => ({
|
|
createFile: jest.fn().mockResolvedValue({}),
|
|
getFiles: jest.fn().mockResolvedValue([]),
|
|
updateFile: jest.fn(),
|
|
claimCodeFile: jest.fn().mockResolvedValue({ file_id: 'mock-uuid', usage: 0 }),
|
|
}));
|
|
|
|
const mockSaveBuffer = jest.fn().mockResolvedValue('/uploads/user123/mock-uuid__output.csv');
|
|
|
|
jest.mock('~/server/services/Files/strategies', () => ({
|
|
getStrategyFunctions: jest.fn(() => ({
|
|
saveBuffer: mockSaveBuffer,
|
|
})),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Files/permissions', () => ({
|
|
filterFilesByAgentAccess: jest.fn().mockResolvedValue([]),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Files/images/convert', () => ({
|
|
convertImage: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/utils', () => ({
|
|
determineFileType: jest.fn().mockResolvedValue({ mime: 'text/csv' }),
|
|
}));
|
|
|
|
const { createFile } = require('~/models');
|
|
const { processCodeOutput } = require('../process');
|
|
|
|
const baseParams = {
|
|
req: {
|
|
user: { id: 'user123' },
|
|
config: {
|
|
fileStrategy: 'local',
|
|
imageOutputType: 'webp',
|
|
fileConfig: {},
|
|
},
|
|
},
|
|
id: 'code-file-id',
|
|
apiKey: 'test-key',
|
|
toolCallId: 'tool-1',
|
|
conversationId: 'conv-1',
|
|
messageId: 'msg-1',
|
|
session_id: 'session-1',
|
|
};
|
|
|
|
describe('processCodeOutput path traversal protection', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
test('sanitizeFilename is called with the raw artifact name', async () => {
|
|
mockSanitizeFilename.mockReturnValueOnce('output.csv');
|
|
await processCodeOutput({ ...baseParams, name: 'output.csv' });
|
|
expect(mockSanitizeFilename).toHaveBeenCalledWith('output.csv');
|
|
});
|
|
|
|
test('sanitized name is used in saveBuffer fileName', async () => {
|
|
mockSanitizeFilename.mockReturnValueOnce('sanitized-name.txt');
|
|
await processCodeOutput({ ...baseParams, name: '../../../tmp/poc.txt' });
|
|
|
|
expect(mockSanitizeFilename).toHaveBeenCalledWith('../../../tmp/poc.txt');
|
|
const call = mockSaveBuffer.mock.calls[0][0];
|
|
expect(call.fileName).toBe('mock-uuid__sanitized-name.txt');
|
|
});
|
|
|
|
test('sanitized name is stored as filename in the file record', async () => {
|
|
mockSanitizeFilename.mockReturnValueOnce('safe-output.csv');
|
|
await processCodeOutput({ ...baseParams, name: 'unsafe/../../output.csv' });
|
|
|
|
const fileArg = createFile.mock.calls[0][0];
|
|
expect(fileArg.filename).toBe('safe-output.csv');
|
|
});
|
|
|
|
test('sanitized name is used for image file records', async () => {
|
|
const { convertImage } = require('~/server/services/Files/images/convert');
|
|
convertImage.mockResolvedValueOnce({
|
|
filepath: '/images/user123/mock-uuid.webp',
|
|
bytes: 100,
|
|
});
|
|
|
|
mockSanitizeFilename.mockReturnValueOnce('safe-chart.png');
|
|
await processCodeOutput({ ...baseParams, name: '../../../chart.png' });
|
|
|
|
expect(mockSanitizeFilename).toHaveBeenCalledWith('../../../chart.png');
|
|
const fileArg = createFile.mock.calls[0][0];
|
|
expect(fileArg.filename).toBe('safe-chart.png');
|
|
});
|
|
});
|