🧑‍💻 fix: Agents Config Defaults and Avatar Uploads Across File Strategies (#7814)

* fix: avatar processing across storage services, uniqueness by agent ID, prevent overwriting user avatar

* fix: sanitize file paths in deleteLocalFile function to prevent invalid path errors

* fix: correct spelling of 'agentsEndpointSchema' in agents.js and config.ts

* fix: default app.locals agents configuration setup and add agent endpoint schema default
This commit is contained in:
Danny Avila 2025-06-10 09:53:15 -04:00 committed by GitHub
parent 118ad943c9
commit a57224c1d5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 192 additions and 55 deletions

View file

@ -387,6 +387,7 @@ const uploadAgentAvatarHandler = async (req, res) => {
buffer: resizedBuffer, buffer: resizedBuffer,
userId: req.user.id, userId: req.user.id,
manual: 'false', manual: 'false',
agentId: agent_id,
}); });
const image = { const image = {

View file

@ -7,6 +7,7 @@ const {
getConfigDefaults, getConfigDefaults,
loadWebSearchConfig, loadWebSearchConfig,
} = require('librechat-data-provider'); } = require('librechat-data-provider');
const { agentsConfigSetup } = require('@librechat/api');
const { const {
checkHealth, checkHealth,
checkConfig, checkConfig,
@ -25,7 +26,6 @@ const { azureConfigSetup } = require('./start/azureOpenAI');
const { processModelSpecs } = require('./start/modelSpecs'); const { processModelSpecs } = require('./start/modelSpecs');
const { initializeS3 } = require('./Files/S3/initialize'); const { initializeS3 } = require('./Files/S3/initialize');
const { loadAndFormatTools } = require('./ToolService'); const { loadAndFormatTools } = require('./ToolService');
const { agentsConfigSetup } = require('./start/agents');
const { isEnabled } = require('~/server/utils'); const { isEnabled } = require('~/server/utils');
const { initializeRoles } = require('~/models'); const { initializeRoles } = require('~/models');
const { getMCPManager } = require('~/config'); const { getMCPManager } = require('~/config');
@ -103,8 +103,13 @@ const AppService = async (app) => {
balance, balance,
}; };
const agentsDefaults = agentsConfigSetup(config);
if (!Object.keys(config).length) { if (!Object.keys(config).length) {
app.locals = defaultLocals; app.locals = {
...defaultLocals,
[EModelEndpoint.agents]: agentsDefaults,
};
return; return;
} }
@ -139,9 +144,7 @@ const AppService = async (app) => {
); );
} }
if (endpoints?.[EModelEndpoint.agents]) { endpointLocals[EModelEndpoint.agents] = agentsConfigSetup(config, agentsDefaults);
endpointLocals[EModelEndpoint.agents] = agentsConfigSetup(config);
}
const endpointKeys = [ const endpointKeys = [
EModelEndpoint.openAI, EModelEndpoint.openAI,

View file

@ -2,8 +2,10 @@ const {
FileSources, FileSources,
EModelEndpoint, EModelEndpoint,
EImageOutputType, EImageOutputType,
AgentCapabilities,
defaultSocialLogins, defaultSocialLogins,
validateAzureGroups, validateAzureGroups,
defaultAgentCapabilities,
deprecatedAzureVariables, deprecatedAzureVariables,
conflictingAzureVariables, conflictingAzureVariables,
} = require('librechat-data-provider'); } = require('librechat-data-provider');
@ -151,6 +153,11 @@ describe('AppService', () => {
safeSearch: 1, safeSearch: 1,
serperApiKey: '${SERPER_API_KEY}', serperApiKey: '${SERPER_API_KEY}',
}, },
memory: undefined,
agents: {
disableBuilder: false,
capabilities: expect.arrayContaining([...defaultAgentCapabilities]),
},
}); });
}); });
@ -268,6 +275,71 @@ describe('AppService', () => {
); );
}); });
it('should correctly configure Agents endpoint based on custom config', async () => {
require('./Config/loadCustomConfig').mockImplementationOnce(() =>
Promise.resolve({
endpoints: {
[EModelEndpoint.agents]: {
disableBuilder: true,
recursionLimit: 10,
maxRecursionLimit: 20,
allowedProviders: ['openai', 'anthropic'],
capabilities: [AgentCapabilities.tools, AgentCapabilities.actions],
},
},
}),
);
await AppService(app);
expect(app.locals).toHaveProperty(EModelEndpoint.agents);
expect(app.locals[EModelEndpoint.agents]).toEqual(
expect.objectContaining({
disableBuilder: true,
recursionLimit: 10,
maxRecursionLimit: 20,
allowedProviders: expect.arrayContaining(['openai', 'anthropic']),
capabilities: expect.arrayContaining([AgentCapabilities.tools, AgentCapabilities.actions]),
}),
);
});
it('should configure Agents endpoint with defaults when no config is provided', async () => {
require('./Config/loadCustomConfig').mockImplementationOnce(() => Promise.resolve({}));
await AppService(app);
expect(app.locals).toHaveProperty(EModelEndpoint.agents);
expect(app.locals[EModelEndpoint.agents]).toEqual(
expect.objectContaining({
disableBuilder: false,
capabilities: expect.arrayContaining([...defaultAgentCapabilities]),
}),
);
});
it('should configure Agents endpoint with defaults when endpoints exist but agents is not defined', async () => {
require('./Config/loadCustomConfig').mockImplementationOnce(() =>
Promise.resolve({
endpoints: {
[EModelEndpoint.openAI]: {
titleConvo: true,
},
},
}),
);
await AppService(app);
expect(app.locals).toHaveProperty(EModelEndpoint.agents);
expect(app.locals[EModelEndpoint.agents]).toEqual(
expect.objectContaining({
disableBuilder: false,
capabilities: expect.arrayContaining([...defaultAgentCapabilities]),
}),
);
});
it('should correctly configure minimum Azure OpenAI Assistant values', async () => { it('should correctly configure minimum Azure OpenAI Assistant values', async () => {
const assistantGroups = [azureGroups[0], { ...azureGroups[1], assistants: true }]; const assistantGroups = [azureGroups[0], { ...azureGroups[1], assistants: true }];
require('./Config/loadCustomConfig').mockImplementationOnce(() => require('./Config/loadCustomConfig').mockImplementationOnce(() =>

View file

@ -91,15 +91,28 @@ async function prepareAzureImageURL(req, file) {
* @param {Buffer} params.buffer - The avatar image buffer. * @param {Buffer} params.buffer - The avatar image buffer.
* @param {string} params.userId - The user's id. * @param {string} params.userId - The user's id.
* @param {string} params.manual - Flag to indicate manual update. * @param {string} params.manual - Flag to indicate manual update.
* @param {string} [params.agentId] - Optional agent ID if this is an agent avatar.
* @param {string} [params.basePath='images'] - The base folder within the container. * @param {string} [params.basePath='images'] - The base folder within the container.
* @param {string} [params.containerName] - The Azure Blob container name. * @param {string} [params.containerName] - The Azure Blob container name.
* @returns {Promise<string>} The URL of the avatar. * @returns {Promise<string>} The URL of the avatar.
*/ */
async function processAzureAvatar({ buffer, userId, manual, basePath = 'images', containerName }) { async function processAzureAvatar({
buffer,
userId,
manual,
agentId,
basePath = 'images',
containerName,
}) {
try { try {
const metadata = await sharp(buffer).metadata(); const metadata = await sharp(buffer).metadata();
const extension = metadata.format === 'gif' ? 'gif' : 'png'; const extension = metadata.format === 'gif' ? 'gif' : 'png';
const fileName = `avatar.${extension}`; const timestamp = new Date().getTime();
/** Unique filename with timestamp and optional agent ID */
const fileName = agentId
? `agent-${agentId}-avatar-${timestamp}.${extension}`
: `avatar-${timestamp}.${extension}`;
const downloadURL = await saveBufferToAzure({ const downloadURL = await saveBufferToAzure({
userId, userId,
@ -110,9 +123,12 @@ async function processAzureAvatar({ buffer, userId, manual, basePath = 'images',
}); });
const isManual = manual === 'true'; const isManual = manual === 'true';
const url = `${downloadURL}?manual=${isManual}`; const url = `${downloadURL}?manual=${isManual}`;
if (isManual) {
// Only update user record if this is a user avatar (manual === 'true')
if (isManual && !agentId) {
await updateUser(userId, { avatar: url }); await updateUser(userId, { avatar: url });
} }
return url; return url;
} catch (error) { } catch (error) {
logger.error('[processAzureAvatar] Error uploading profile picture to Azure:', error); logger.error('[processAzureAvatar] Error uploading profile picture to Azure:', error);

View file

@ -82,14 +82,20 @@ async function prepareImageURL(req, file) {
* @param {Buffer} params.buffer - The Buffer containing the avatar image. * @param {Buffer} params.buffer - The Buffer containing the avatar image.
* @param {string} params.userId - The user ID. * @param {string} params.userId - The user ID.
* @param {string} params.manual - A string flag indicating whether the update is manual ('true' or 'false'). * @param {string} params.manual - A string flag indicating whether the update is manual ('true' or 'false').
* @param {string} [params.agentId] - Optional agent ID if this is an agent avatar.
* @returns {Promise<string>} - A promise that resolves with the URL of the uploaded avatar. * @returns {Promise<string>} - A promise that resolves with the URL of the uploaded avatar.
* @throws {Error} - Throws an error if Firebase is not initialized or if there is an error in uploading. * @throws {Error} - Throws an error if Firebase is not initialized or if there is an error in uploading.
*/ */
async function processFirebaseAvatar({ buffer, userId, manual }) { async function processFirebaseAvatar({ buffer, userId, manual, agentId }) {
try { try {
const metadata = await sharp(buffer).metadata(); const metadata = await sharp(buffer).metadata();
const extension = metadata.format === 'gif' ? 'gif' : 'png'; const extension = metadata.format === 'gif' ? 'gif' : 'png';
const fileName = `avatar.${extension}`; const timestamp = new Date().getTime();
/** Unique filename with timestamp and optional agent ID */
const fileName = agentId
? `agent-${agentId}-avatar-${timestamp}.${extension}`
: `avatar-${timestamp}.${extension}`;
const downloadURL = await saveBufferToFirebase({ const downloadURL = await saveBufferToFirebase({
userId, userId,
@ -98,10 +104,10 @@ async function processFirebaseAvatar({ buffer, userId, manual }) {
}); });
const isManual = manual === 'true'; const isManual = manual === 'true';
const url = `${downloadURL}?manual=${isManual}`; const url = `${downloadURL}?manual=${isManual}`;
if (isManual) { // Only update user record if this is a user avatar (manual === 'true')
if (isManual && !agentId) {
await updateUser(userId, { avatar: url }); await updateUser(userId, { avatar: url });
} }

View file

@ -201,6 +201,10 @@ const unlinkFile = async (filepath) => {
*/ */
const deleteLocalFile = async (req, file) => { const deleteLocalFile = async (req, file) => {
const { publicPath, uploads } = req.app.locals.paths; const { publicPath, uploads } = req.app.locals.paths;
/** Filepath stripped of query parameters (e.g., ?manual=true) */
const cleanFilepath = file.filepath.split('?')[0];
if (file.embedded && process.env.RAG_API_URL) { if (file.embedded && process.env.RAG_API_URL) {
const jwtToken = req.headers.authorization.split(' ')[1]; const jwtToken = req.headers.authorization.split(' ')[1];
axios.delete(`${process.env.RAG_API_URL}/documents`, { axios.delete(`${process.env.RAG_API_URL}/documents`, {
@ -213,32 +217,32 @@ const deleteLocalFile = async (req, file) => {
}); });
} }
if (file.filepath.startsWith(`/uploads/${req.user.id}`)) { if (cleanFilepath.startsWith(`/uploads/${req.user.id}`)) {
const userUploadDir = path.join(uploads, req.user.id); const userUploadDir = path.join(uploads, req.user.id);
const basePath = file.filepath.split(`/uploads/${req.user.id}/`)[1]; const basePath = cleanFilepath.split(`/uploads/${req.user.id}/`)[1];
if (!basePath) { if (!basePath) {
throw new Error(`Invalid file path: ${file.filepath}`); throw new Error(`Invalid file path: ${cleanFilepath}`);
} }
const filepath = path.join(userUploadDir, basePath); const filepath = path.join(userUploadDir, basePath);
const rel = path.relative(userUploadDir, filepath); const rel = path.relative(userUploadDir, filepath);
if (rel.startsWith('..') || path.isAbsolute(rel) || rel.includes(`..${path.sep}`)) { if (rel.startsWith('..') || path.isAbsolute(rel) || rel.includes(`..${path.sep}`)) {
throw new Error(`Invalid file path: ${file.filepath}`); throw new Error(`Invalid file path: ${cleanFilepath}`);
} }
await unlinkFile(filepath); await unlinkFile(filepath);
return; return;
} }
const parts = file.filepath.split(path.sep); const parts = cleanFilepath.split(path.sep);
const subfolder = parts[1]; const subfolder = parts[1];
if (!subfolder && parts[0] === EModelEndpoint.agents) { if (!subfolder && parts[0] === EModelEndpoint.agents) {
logger.warn(`Agent File ${file.file_id} is missing filepath, may have been deleted already`); logger.warn(`Agent File ${file.file_id} is missing filepath, may have been deleted already`);
return; return;
} }
const filepath = path.join(publicPath, file.filepath); const filepath = path.join(publicPath, cleanFilepath);
if (!isValidPath(req, publicPath, subfolder, filepath)) { if (!isValidPath(req, publicPath, subfolder, filepath)) {
throw new Error('Invalid file path'); throw new Error('Invalid file path');

View file

@ -112,10 +112,11 @@ async function prepareImagesLocal(req, file) {
* @param {Buffer} params.buffer - The Buffer containing the avatar image. * @param {Buffer} params.buffer - The Buffer containing the avatar image.
* @param {string} params.userId - The user ID. * @param {string} params.userId - The user ID.
* @param {string} params.manual - A string flag indicating whether the update is manual ('true' or 'false'). * @param {string} params.manual - A string flag indicating whether the update is manual ('true' or 'false').
* @param {string} [params.agentId] - Optional agent ID if this is an agent avatar.
* @returns {Promise<string>} - A promise that resolves with the URL of the uploaded avatar. * @returns {Promise<string>} - A promise that resolves with the URL of the uploaded avatar.
* @throws {Error} - Throws an error if Firebase is not initialized or if there is an error in uploading. * @throws {Error} - Throws an error if Firebase is not initialized or if there is an error in uploading.
*/ */
async function processLocalAvatar({ buffer, userId, manual }) { async function processLocalAvatar({ buffer, userId, manual, agentId }) {
const userDir = path.resolve( const userDir = path.resolve(
__dirname, __dirname,
'..', '..',
@ -132,7 +133,11 @@ async function processLocalAvatar({ buffer, userId, manual }) {
const metadata = await sharp(buffer).metadata(); const metadata = await sharp(buffer).metadata();
const extension = metadata.format === 'gif' ? 'gif' : 'png'; const extension = metadata.format === 'gif' ? 'gif' : 'png';
const fileName = `avatar-${new Date().getTime()}.${extension}`; const timestamp = new Date().getTime();
/** Unique filename with timestamp and optional agent ID */
const fileName = agentId
? `agent-${agentId}-avatar-${timestamp}.${extension}`
: `avatar-${timestamp}.${extension}`;
const urlRoute = `/images/${userId}/${fileName}`; const urlRoute = `/images/${userId}/${fileName}`;
const avatarPath = path.join(userDir, fileName); const avatarPath = path.join(userDir, fileName);
@ -142,7 +147,8 @@ async function processLocalAvatar({ buffer, userId, manual }) {
const isManual = manual === 'true'; const isManual = manual === 'true';
let url = `${urlRoute}?manual=${isManual}`; let url = `${urlRoute}?manual=${isManual}`;
if (isManual) { // Only update user record if this is a user avatar (manual === 'true')
if (isManual && !agentId) {
await updateUser(userId, { avatar: url }); await updateUser(userId, { avatar: url });
} }

View file

@ -94,19 +94,28 @@ async function prepareImageURLS3(req, file) {
* @param {Buffer} params.buffer - Avatar image buffer. * @param {Buffer} params.buffer - Avatar image buffer.
* @param {string} params.userId - User's unique identifier. * @param {string} params.userId - User's unique identifier.
* @param {string} params.manual - 'true' or 'false' flag for manual update. * @param {string} params.manual - 'true' or 'false' flag for manual update.
* @param {string} [params.agentId] - Optional agent ID if this is an agent avatar.
* @param {string} [params.basePath='images'] - Base path in the bucket. * @param {string} [params.basePath='images'] - Base path in the bucket.
* @returns {Promise<string>} Signed URL of the uploaded avatar. * @returns {Promise<string>} Signed URL of the uploaded avatar.
*/ */
async function processS3Avatar({ buffer, userId, manual, basePath = defaultBasePath }) { async function processS3Avatar({ buffer, userId, manual, agentId, basePath = defaultBasePath }) {
try { try {
const metadata = await sharp(buffer).metadata(); const metadata = await sharp(buffer).metadata();
const extension = metadata.format === 'gif' ? 'gif' : 'png'; const extension = metadata.format === 'gif' ? 'gif' : 'png';
const fileName = `avatar.${extension}`; const timestamp = new Date().getTime();
/** Unique filename with timestamp and optional agent ID */
const fileName = agentId
? `agent-${agentId}-avatar-${timestamp}.${extension}`
: `avatar-${timestamp}.${extension}`;
const downloadURL = await saveBufferToS3({ userId, buffer, fileName, basePath }); const downloadURL = await saveBufferToS3({ userId, buffer, fileName, basePath });
if (manual === 'true') {
// Only update user record if this is a user avatar (manual === 'true')
if (manual === 'true' && !agentId) {
await updateUser(userId, { avatar: downloadURL }); await updateUser(userId, { avatar: downloadURL });
} }
return downloadURL; return downloadURL;
} catch (error) { } catch (error) {
logger.error('[processS3Avatar] Error processing S3 avatar:', error.message); logger.error('[processS3Avatar] Error processing S3 avatar:', error.message);

View file

@ -1,14 +0,0 @@
const { EModelEndpoint, agentsEndpointSChema } = require('librechat-data-provider');
/**
* Sets up the Agents configuration from the config (`librechat.yaml`) file.
* @param {TCustomConfig} config - The loaded custom configuration.
* @returns {Partial<TAgentsEndpoint>} The Agents endpoint configuration.
*/
function agentsConfigSetup(config) {
const agentsConfig = config.endpoints[EModelEndpoint.agents];
const parsedConfig = agentsEndpointSChema.parse(agentsConfig);
return parsedConfig;
}
module.exports = { agentsConfigSetup };

View file

@ -31,7 +31,7 @@ const handleExistingUser = async (oldUser, avatarUrl) => {
input: avatarUrl, input: avatarUrl,
}); });
const { processAvatar } = getStrategyFunctions(fileStrategy); const { processAvatar } = getStrategyFunctions(fileStrategy);
updatedAvatar = await processAvatar({ buffer: resizedBuffer, userId }); updatedAvatar = await processAvatar({ buffer: resizedBuffer, userId, manual: 'false' });
} }
if (updatedAvatar) { if (updatedAvatar) {
@ -90,7 +90,11 @@ const createSocialUser = async ({
input: avatarUrl, input: avatarUrl,
}); });
const { processAvatar } = getStrategyFunctions(fileStrategy); const { processAvatar } = getStrategyFunctions(fileStrategy);
const avatar = await processAvatar({ buffer: resizedBuffer, userId: newUserId }); const avatar = await processAvatar({
buffer: resizedBuffer,
userId: newUserId,
manual: 'false',
});
await updateUser(newUserId, { avatar }); await updateUser(newUserId, { avatar });
} }

View file

@ -0,0 +1,24 @@
import { EModelEndpoint, agentsEndpointSchema } from 'librechat-data-provider';
import type { TCustomConfig, TAgentsEndpoint } from 'librechat-data-provider';
/**
* Sets up the Agents configuration from the config (`librechat.yaml`) file.
* If no agents config is defined, uses the provided defaults or parses empty object.
*
* @param config - The loaded custom configuration.
* @param [defaultConfig] - Default configuration from getConfigDefaults.
* @returns The Agents endpoint configuration.
*/
export function agentsConfigSetup(
config: TCustomConfig,
defaultConfig: Partial<TAgentsEndpoint>,
): Partial<TAgentsEndpoint> {
const agentsConfig = config?.endpoints?.[EModelEndpoint.agents];
if (!agentsConfig) {
return defaultConfig || agentsEndpointSchema.parse({});
}
const parsedConfig = agentsEndpointSchema.parse(agentsConfig);
return parsedConfig;
}

View file

@ -1,3 +1,4 @@
export * from './config';
export * from './memory'; export * from './memory';
export * from './resources'; export * from './resources';
export * from './run'; export * from './run';

View file

@ -244,11 +244,12 @@ export const defaultAgentCapabilities = [
AgentCapabilities.ocr, AgentCapabilities.ocr,
]; ];
export const agentsEndpointSChema = baseEndpointSchema.merge( export const agentsEndpointSchema = baseEndpointSchema
.merge(
z.object({ z.object({
/* agents specific */ /* agents specific */
recursionLimit: z.number().optional(), recursionLimit: z.number().optional(),
disableBuilder: z.boolean().optional(), disableBuilder: z.boolean().optional().default(false),
maxRecursionLimit: z.number().optional(), maxRecursionLimit: z.number().optional(),
allowedProviders: z.array(z.union([z.string(), eModelEndpointSchema])).optional(), allowedProviders: z.array(z.union([z.string(), eModelEndpointSchema])).optional(),
capabilities: z capabilities: z
@ -256,9 +257,13 @@ export const agentsEndpointSChema = baseEndpointSchema.merge(
.optional() .optional()
.default(defaultAgentCapabilities), .default(defaultAgentCapabilities),
}), }),
); )
.default({
disableBuilder: false,
capabilities: defaultAgentCapabilities,
});
export type TAgentsEndpoint = z.infer<typeof agentsEndpointSChema>; export type TAgentsEndpoint = z.infer<typeof agentsEndpointSchema>;
export const endpointSchema = baseEndpointSchema.merge( export const endpointSchema = baseEndpointSchema.merge(
z.object({ z.object({
@ -720,7 +725,7 @@ export const configSchema = z.object({
[EModelEndpoint.azureOpenAI]: azureEndpointSchema.optional(), [EModelEndpoint.azureOpenAI]: azureEndpointSchema.optional(),
[EModelEndpoint.azureAssistants]: assistantEndpointSchema.optional(), [EModelEndpoint.azureAssistants]: assistantEndpointSchema.optional(),
[EModelEndpoint.assistants]: assistantEndpointSchema.optional(), [EModelEndpoint.assistants]: assistantEndpointSchema.optional(),
[EModelEndpoint.agents]: agentsEndpointSChema.optional(), [EModelEndpoint.agents]: agentsEndpointSchema.optional(),
[EModelEndpoint.custom]: z.array(endpointSchema.partial()).optional(), [EModelEndpoint.custom]: z.array(endpointSchema.partial()).optional(),
[EModelEndpoint.bedrock]: baseEndpointSchema.optional(), [EModelEndpoint.bedrock]: baseEndpointSchema.optional(),
}) })