🧑‍💻 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,
userId: req.user.id,
manual: 'false',
agentId: agent_id,
});
const image = {

View file

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

View file

@ -2,8 +2,10 @@ const {
FileSources,
EModelEndpoint,
EImageOutputType,
AgentCapabilities,
defaultSocialLogins,
validateAzureGroups,
defaultAgentCapabilities,
deprecatedAzureVariables,
conflictingAzureVariables,
} = require('librechat-data-provider');
@ -151,6 +153,11 @@ describe('AppService', () => {
safeSearch: 1,
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 () => {
const assistantGroups = [azureGroups[0], { ...azureGroups[1], assistants: true }];
require('./Config/loadCustomConfig').mockImplementationOnce(() =>

View file

@ -91,15 +91,28 @@ async function prepareAzureImageURL(req, file) {
* @param {Buffer} params.buffer - The avatar image buffer.
* @param {string} params.userId - The user's id.
* @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.containerName] - The Azure Blob container name.
* @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 {
const metadata = await sharp(buffer).metadata();
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({
userId,
@ -110,9 +123,12 @@ async function processAzureAvatar({ buffer, userId, manual, basePath = 'images',
});
const isManual = manual === 'true';
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 });
}
return url;
} catch (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 {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.agentId] - Optional agent ID if this is an agent 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.
*/
async function processFirebaseAvatar({ buffer, userId, manual }) {
async function processFirebaseAvatar({ buffer, userId, manual, agentId }) {
try {
const metadata = await sharp(buffer).metadata();
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({
userId,
@ -98,10 +104,10 @@ async function processFirebaseAvatar({ buffer, userId, manual }) {
});
const isManual = manual === 'true';
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 });
}

View file

@ -201,6 +201,10 @@ const unlinkFile = async (filepath) => {
*/
const deleteLocalFile = async (req, file) => {
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) {
const jwtToken = req.headers.authorization.split(' ')[1];
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 basePath = file.filepath.split(`/uploads/${req.user.id}/`)[1];
const basePath = cleanFilepath.split(`/uploads/${req.user.id}/`)[1];
if (!basePath) {
throw new Error(`Invalid file path: ${file.filepath}`);
throw new Error(`Invalid file path: ${cleanFilepath}`);
}
const filepath = path.join(userUploadDir, basePath);
const rel = path.relative(userUploadDir, filepath);
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);
return;
}
const parts = file.filepath.split(path.sep);
const parts = cleanFilepath.split(path.sep);
const subfolder = parts[1];
if (!subfolder && parts[0] === EModelEndpoint.agents) {
logger.warn(`Agent File ${file.file_id} is missing filepath, may have been deleted already`);
return;
}
const filepath = path.join(publicPath, file.filepath);
const filepath = path.join(publicPath, cleanFilepath);
if (!isValidPath(req, publicPath, subfolder, filepath)) {
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 {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.agentId] - Optional agent ID if this is an agent 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.
*/
async function processLocalAvatar({ buffer, userId, manual }) {
async function processLocalAvatar({ buffer, userId, manual, agentId }) {
const userDir = path.resolve(
__dirname,
'..',
@ -132,7 +133,11 @@ async function processLocalAvatar({ buffer, userId, manual }) {
const metadata = await sharp(buffer).metadata();
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 avatarPath = path.join(userDir, fileName);
@ -142,7 +147,8 @@ async function processLocalAvatar({ buffer, userId, manual }) {
const isManual = manual === 'true';
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 });
}

View file

@ -94,19 +94,28 @@ async function prepareImageURLS3(req, file) {
* @param {Buffer} params.buffer - Avatar image buffer.
* @param {string} params.userId - User's unique identifier.
* @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.
* @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 {
const metadata = await sharp(buffer).metadata();
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 });
if (manual === 'true') {
// Only update user record if this is a user avatar (manual === 'true')
if (manual === 'true' && !agentId) {
await updateUser(userId, { avatar: downloadURL });
}
return downloadURL;
} catch (error) {
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,
});
const { processAvatar } = getStrategyFunctions(fileStrategy);
updatedAvatar = await processAvatar({ buffer: resizedBuffer, userId });
updatedAvatar = await processAvatar({ buffer: resizedBuffer, userId, manual: 'false' });
}
if (updatedAvatar) {
@ -90,7 +90,11 @@ const createSocialUser = async ({
input: avatarUrl,
});
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 });
}