🏪 feat: Agent Marketplace

bugfix: Enhance Agent and AgentCategory schemas with new fields for category, support contact, and promotion status

refactored and moved agent category methods and schema to data-schema package

🔧 fix: Merge and Rebase Conflicts

- Move AgentCategory from api/models to @packages/data-schemas structure
  - Add schema, types, methods, and model following codebase conventions
  - Implement auto-seeding of default categories during AppService startup
  - Update marketplace controller to use new data-schemas methods
  - Remove old model file and standalone seed script

refactor: unify agent marketplace to single endpoint with cursor pagination

  - Replace multiple marketplace routes with unified /marketplace endpoint
  - Add query string controls: category, search, limit, cursor, promoted, requiredPermission
  - Implement cursor-based pagination replacing page-based system
  - Integrate ACL permissions for proper access control
  - Fix ObjectId constructor error in Agent model
  - Update React components to use unified useGetMarketplaceAgentsQuery hook
  - Enhance type safety and remove deprecated useDynamicAgentQuery
  - Update tests for new marketplace architecture
  -Known issues:
  see more button after category switching + Unit tests

feat: add icon property to ProcessedAgentCategory interface

- Add useMarketplaceAgentsInfiniteQuery and useGetAgentCategoriesQuery to client/src/data-provider/Agents/
  - Replace manual pagination in AgentGrid with infinite query pattern
  - Update imports to use local data provider instead of librechat-data-provider
  - Add proper permission handling with PERMISSION_BITS.VIEW/EDIT constants
  - Improve agent access control by adding requiredPermission validation in backend
  - Remove manual cursor/state management in favor of infinite query built-ins
  - Maintain existing search and category filtering functionality

refactor: consolidate agent marketplace endpoints into main agents API and improve data management consistency

  - Remove dedicated marketplace controller and routes, merging functionality into main agents v1 API
  - Add countPromotedAgents function to Agent model for promoted agents count
  - Enhance getListAgents handler with marketplace filtering (category, search, promoted status)
  - Move getAgentCategories from marketplace to v1 controller with same functionality
  - Update agent mutations to invalidate marketplace queries and handle multiple permission levels
  - Improve cache management by updating all agent query variants (VIEW/EDIT permissions)
  - Consolidate agent data access patterns for better maintainability and consistency
  - Remove duplicate marketplace route definitions and middleware

selected view only agents injected in the drop down

fix: remove minlength validation for support contact name in agent schema

feat: add validation and error messages for agent name in AgentConfig and AgentPanel

fix: update agent permission check logic in AgentPanel to simplify condition

Fix linting WIP

Fix Unit tests WIP

ESLint fixes

eslint fix

refactor: enhance isDuplicateVersion function in Agent model for improved comparison logic

- Introduced handling for undefined/null values in array and object comparisons.
- Normalized array comparisons to treat undefined/null as empty arrays.
- Added deep comparison for objects and improved handling of primitive values.
- Enhanced projectIds comparison to ensure consistent MongoDB ObjectId handling.

refactor: remove redundant properties from IAgent interface in agent schema

chore: update localization for agent detail component and clean up imports

ci: update access middleware tests

chore: remove unused PermissionTypes import from Role model

ci: update AclEntry model tests

ci: update button accessibility labels in AgentDetail tests

refactor: update exhaustive dep. lint warning

🔧 fix: Fixed agent actions access

feat: Add role-level permissions for agent sharing people picker

  - Add PEOPLE_PICKER permission type with VIEW_USERS and VIEW_GROUPS permissions
  - Create custom middleware for query-aware permission validation
  - Implement permission-based type filtering in PeoplePicker component
  - Hide people picker UI when user lacks permissions, show only public toggle
  - Support granular access: users-only, groups-only, or mixed search modes

refactor: Replace marketplace interface config with permission-based system

  - Add MARKETPLACE permission type to handle marketplace access control
  - Update interface configuration to use role-based marketplace settings (admin/user)
  - Replace direct marketplace boolean config with permission-based checks
  - Modify frontend components to use marketplace permissions instead of interface config
  - Update agent query hooks to use marketplace permissions for determining permission levels
  - Add marketplace configuration structure similar to peoplePicker in YAML config
  - Backend now sets MARKETPLACE permissions based on interface configuration
  - When marketplace enabled: users get agents with EDIT permissions in dropdown lists  (builder mode)
  - When marketplace disabled: users get agents with VIEW permissions  in dropdown lists (browse mode)

🔧 fix: Redirect to New Chat if No Marketplace Access and Required Agent Name Placeholder (#8213)

* Fix: Fix the redirect to new chat page if access to marketplace is denied

* Fixed the required agent name placeholder

---------

Co-authored-by: Atef Bellaaj <slalom.bellaaj@external.daimlertruck.com>

chore: fix tests, remove unnecessary imports

refactor: Implement permission checks for file access via agents

- Updated `hasAccessToFilesViaAgent` to utilize permission checks for VIEW and EDIT access.
- Replaced project-based access validation with permission-based checks.
- Enhanced tests to cover new permission logic and ensure proper access control for files associated with agents.
- Cleaned up imports and initialized models in test files for consistency.

refactor: Enhance test setup and cleanup for file access control

- Introduced modelsToCleanup array to track models added during tests for proper cleanup.
- Updated afterAll hooks in test files to ensure all collections are cleared and only added models are deleted.
- Improved consistency in model initialization across test files.
- Added comments for clarity on cleanup processes and test data management.

chore: Update Jest configuration and test setup for improved timeout handling

- Added a global test timeout of 30 seconds in jest.config.js.
- Configured jest.setTimeout in jestSetup.js to allow individual test overrides if needed.
- Enhanced test reliability by ensuring consistent timeout settings across all tests.

refactor: Implement file access filtering based on agent permissions

- Introduced `filterFilesByAgentAccess` function to filter files based on user access through agents.
- Updated `getFiles` and `primeFiles` functions to utilize the new filtering logic.
- Moved `hasAccessToFilesViaAgent` function from the File model to permission services, adjusting imports accordingly
- Enhanced tests to ensure proper access control and filtering behavior for files associated with agents.

fix: make support_contact field a nested object rather than a sub-document

refactor: Update support_contact field initialization in agent model

- Removed handling for empty support_contact object in createAgent function.
- Changed default value of support_contact in agent schema to undefined.

test: Add comprehensive tests for support_contact field handling and versioning

refactor: remove unused avatar upload mutation field and add informational toast for success

chore: add missing SidePanelProvider for AgentMarketplace and organize imports

fix: resolve agent selection race condition in marketplace HandleStartChat
- Set agent in localStorage before newConversation to prevent useSelectorEffects from auto-selecting previous agent

fix: resolve agent dropdown showing raw ID instead of agent info from URL

  - Add proactive agent fetching when agent_id is present in URL parameters
  - Inject fetched agent into agents cache so dropdowns display proper name/avatar
  - Use useAgentsMap dependency to ensure proper cache initialization timing
  - Prevents raw agent IDs from showing in UI when visiting shared agent links

Fix: Agents endpoint renamed to "My Agent" for less confusion with the Marketplace agents.

chore: fix ESLint issues and Test Mocks

ci: update permissions structure in loadDefaultInterface tests

- Refactored permissions for MEMORY and added new permissions for MARKETPLACE and PEOPLE_PICKER.
- Ensured consistent structure for permissions across different types.

feat:  support_contact validation to allow empty email strings
This commit is contained in:
“Praneeth 2025-06-11 22:55:07 +05:30 committed by Danny Avila
parent 66bd419baa
commit 949682ef0f
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
95 changed files with 3770 additions and 2728 deletions

View file

@ -3,6 +3,7 @@ const axios = require('axios');
const { tool } = require('@langchain/core/tools');
const { logger } = require('@librechat/data-schemas');
const { Tools, EToolResources } = require('librechat-data-provider');
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
const { generateShortLivedToken } = require('~/server/services/AuthService');
const { getFiles } = require('~/models/File');
@ -22,14 +23,19 @@ const primeFiles = async (options) => {
const file_ids = tool_resources?.[EToolResources.file_search]?.file_ids ?? [];
const agentResourceIds = new Set(file_ids);
const resourceFiles = tool_resources?.[EToolResources.file_search]?.files ?? [];
const dbFiles = (
(await getFiles(
{ file_id: { $in: file_ids } },
null,
{ text: 0 },
{ userId: req?.user?.id, agentId },
)) ?? []
).concat(resourceFiles);
// Get all files first
const allFiles = (await getFiles({ file_id: { $in: file_ids } }, null, { text: 0 })) ?? [];
// Filter by access if user and agent are provided
let dbFiles;
if (req?.user?.id && agentId) {
dbFiles = await filterFilesByAgentAccess(allFiles, req.user.id, agentId);
} else {
dbFiles = allFiles;
}
dbFiles = dbFiles.concat(resourceFiles);
let toolContext = `- Note: Semantic search is available through the ${Tools.file_search} tool but no files are currently loaded. Request the user to upload documents to search through.`;

View file

@ -3,6 +3,7 @@ module.exports = {
clearMocks: true,
roots: ['<rootDir>'],
coverageDirectory: 'coverage',
testTimeout: 30000, // 30 seconds timeout for all tests
setupFiles: [
'./test/jestSetup.js',
'./test/__mocks__/logger.js',

View file

@ -5,7 +5,6 @@ const { SystemRoles, Tools, actionDelimiter } = require('librechat-data-provider
const { GLOBAL_PROJECT_NAME, EPHEMERAL_AGENT_ID, mcp_delimiter } =
require('librechat-data-provider').Constants;
// Default category value for new agents
const AgentCategory = require('./AgentCategory');
const {
getProjectByName,
addAgentIdsToProject,
@ -15,80 +14,7 @@ const {
const { getCachedTools } = require('~/server/services/Config');
// Category values are now imported from shared constants
// Add category field to the Agent schema if it doesn't already exist
if (!agentSchema.paths.category) {
agentSchema.add({
category: {
type: String,
trim: true,
validate: {
validator: async function (value) {
if (!value) return true; // Allow empty values (will use default)
// Check if category exists in database
const validCategories = await AgentCategory.getValidCategoryValues();
return validCategories.includes(value);
},
message: function (props) {
return `"${props.value}" is not a valid agent category. Please check available categories.`;
},
},
index: true,
default: 'general',
},
});
}
// Add support_contact field to the Agent schema if it doesn't already exist
if (!agentSchema.paths.support_contact) {
agentSchema.add({
support_contact: {
type: Object,
default: {},
name: {
type: String,
minlength: [3, 'Support contact name must be at least 3 characters.'],
trim: true,
},
email: {
type: String,
match: [
/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/,
'Please enter a valid email address.',
],
trim: true,
},
},
});
}
// Add promotion field to the Agent schema if it doesn't already exist
if (!agentSchema.paths.is_promoted) {
agentSchema.add({
is_promoted: {
type: Boolean,
default: false,
index: true, // Index for efficient promoted agent queries
},
});
}
// Add additional indexes for marketplace functionality
agentSchema.index({ projectIds: 1, is_promoted: 1, updatedAt: -1 }); // Optimize promoted agents query
agentSchema.index({ category: 1, projectIds: 1, updatedAt: -1 }); // Optimize category filtering
agentSchema.index({ projectIds: 1, category: 1 }); // Optimize aggregation pipeline
// Text indexes for search functionality
agentSchema.index(
{ name: 'text', description: 'text' },
{
weights: {
name: 3, // Name matches are 3x more important than description matches
description: 1,
},
},
);
// Schema fields (category, support_contact, is_promoted) are defined in @librechat/data-schemas
const { getActions } = require('./Action');
const { Agent } = require('~/db/models');
@ -112,6 +38,7 @@ const createAgent = async (agentData) => {
],
category: agentData.category || 'general',
};
return (await Agent.create(initialAgentData)).toObject();
};
@ -257,54 +184,116 @@ const isDuplicateVersion = (updateData, currentData, versions, actionsHash = nul
let isMatch = true;
for (const field of importantFields) {
if (!wouldBeVersion[field] && !lastVersion[field]) {
const wouldBeValue = wouldBeVersion[field];
const lastVersionValue = lastVersion[field];
// Skip if both are undefined/null
if (!wouldBeValue && !lastVersionValue) {
continue;
}
if (Array.isArray(wouldBeVersion[field]) && Array.isArray(lastVersion[field])) {
if (wouldBeVersion[field].length !== lastVersion[field].length) {
// Handle arrays
if (Array.isArray(wouldBeValue) || Array.isArray(lastVersionValue)) {
// Normalize: treat undefined/null as empty array for comparison
let wouldBeArr;
if (Array.isArray(wouldBeValue)) {
wouldBeArr = wouldBeValue;
} else if (wouldBeValue == null) {
wouldBeArr = [];
} else {
wouldBeArr = [wouldBeValue];
}
let lastVersionArr;
if (Array.isArray(lastVersionValue)) {
lastVersionArr = lastVersionValue;
} else if (lastVersionValue == null) {
lastVersionArr = [];
} else {
lastVersionArr = [lastVersionValue];
}
if (wouldBeArr.length !== lastVersionArr.length) {
isMatch = false;
break;
}
// Special handling for projectIds (MongoDB ObjectIds)
if (field === 'projectIds') {
const wouldBeIds = wouldBeVersion[field].map((id) => id.toString()).sort();
const versionIds = lastVersion[field].map((id) => id.toString()).sort();
const wouldBeIds = wouldBeArr.map((id) => id.toString()).sort();
const versionIds = lastVersionArr.map((id) => id.toString()).sort();
if (!wouldBeIds.every((id, i) => id === versionIds[i])) {
isMatch = false;
break;
}
}
// Handle arrays of objects like tool_kwargs
else if (typeof wouldBeVersion[field][0] === 'object' && wouldBeVersion[field][0] !== null) {
const sortedWouldBe = [...wouldBeVersion[field]].map((item) => JSON.stringify(item)).sort();
const sortedVersion = [...lastVersion[field]].map((item) => JSON.stringify(item)).sort();
// Handle arrays of objects
else if (
wouldBeArr.length > 0 &&
typeof wouldBeArr[0] === 'object' &&
wouldBeArr[0] !== null
) {
const sortedWouldBe = [...wouldBeArr].map((item) => JSON.stringify(item)).sort();
const sortedVersion = [...lastVersionArr].map((item) => JSON.stringify(item)).sort();
if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) {
isMatch = false;
break;
}
} else {
const sortedWouldBe = [...wouldBeVersion[field]].sort();
const sortedVersion = [...lastVersion[field]].sort();
const sortedWouldBe = [...wouldBeArr].sort();
const sortedVersion = [...lastVersionArr].sort();
if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) {
isMatch = false;
break;
}
}
} else if (field === 'model_parameters') {
const wouldBeParams = wouldBeVersion[field] || {};
const lastVersionParams = lastVersion[field] || {};
if (JSON.stringify(wouldBeParams) !== JSON.stringify(lastVersionParams)) {
}
// Handle objects
else if (typeof wouldBeValue === 'object' && wouldBeValue !== null) {
const lastVersionObj =
typeof lastVersionValue === 'object' && lastVersionValue !== null ? lastVersionValue : {};
// For empty objects, normalize the comparison
const wouldBeKeys = Object.keys(wouldBeValue);
const lastVersionKeys = Object.keys(lastVersionObj);
// If both are empty objects, they're equal
if (wouldBeKeys.length === 0 && lastVersionKeys.length === 0) {
continue;
}
// Otherwise do a deep comparison
if (JSON.stringify(wouldBeValue) !== JSON.stringify(lastVersionObj)) {
isMatch = false;
break;
}
}
// Handle primitive values
else {
// For primitives, handle the case where one is undefined and the other is a default value
if (wouldBeValue !== lastVersionValue) {
// Special handling for boolean false vs undefined
if (
typeof wouldBeValue === 'boolean' &&
wouldBeValue === false &&
lastVersionValue === undefined
) {
continue;
}
// Special handling for empty string vs undefined
if (
typeof wouldBeValue === 'string' &&
wouldBeValue === '' &&
lastVersionValue === undefined
) {
continue;
}
isMatch = false;
break;
}
} else if (wouldBeVersion[field] !== lastVersion[field]) {
isMatch = false;
break;
}
}
@ -558,7 +547,7 @@ const getListAgentsByAccess = async ({
const cursorCondition = {
$or: [
{ updatedAt: { $lt: new Date(updatedAt) } },
{ updatedAt: new Date(updatedAt), _id: { $gt: mongoose.Types.ObjectId(_id) } },
{ updatedAt: new Date(updatedAt), _id: { $gt: new mongoose.Types.ObjectId(_id) } },
],
};
@ -586,6 +575,9 @@ const getListAgentsByAccess = async ({
projectIds: 1,
description: 1,
updatedAt: 1,
category: 1,
support_contact: 1,
is_promoted: 1,
}).sort({ updatedAt: -1, _id: 1 });
// Only apply limit if pagination is requested
@ -820,6 +812,14 @@ const generateActionMetadataHash = async (actionIds, actions) => {
return hashHex;
};
/**
* Counts the number of promoted agents.
* @returns {Promise<number>} - The count of promoted agents
*/
const countPromotedAgents = async () => {
const count = await Agent.countDocuments({ is_promoted: true });
return count;
};
/**
* Load a default agent based on the endpoint
@ -840,4 +840,5 @@ module.exports = {
getListAgentsByAccess,
removeAgentResourceFiles,
generateActionMetadataHash,
countPromotedAgents,
};

View file

@ -1237,6 +1237,328 @@ describe('models/Agent', () => {
expect(secondUpdate.versions).toHaveLength(3);
});
test('should detect changes in support_contact fields', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
// Create agent with initial support_contact
await createAgent({
id: agentId,
name: 'Agent with Support Contact',
provider: 'test',
model: 'test-model',
author: authorId,
support_contact: {
name: 'Initial Support',
email: 'initial@support.com',
},
});
// Update support_contact name only
const firstUpdate = await updateAgent(
{ id: agentId },
{
support_contact: {
name: 'Updated Support',
email: 'initial@support.com',
},
},
);
expect(firstUpdate.versions).toHaveLength(2);
expect(firstUpdate.support_contact.name).toBe('Updated Support');
expect(firstUpdate.support_contact.email).toBe('initial@support.com');
// Update support_contact email only
const secondUpdate = await updateAgent(
{ id: agentId },
{
support_contact: {
name: 'Updated Support',
email: 'updated@support.com',
},
},
);
expect(secondUpdate.versions).toHaveLength(3);
expect(secondUpdate.support_contact.email).toBe('updated@support.com');
// Try to update with same support_contact - should be detected as duplicate
await expect(
updateAgent(
{ id: agentId },
{
support_contact: {
name: 'Updated Support',
email: 'updated@support.com',
},
},
),
).rejects.toThrow('Duplicate version');
});
test('should handle support_contact from empty to populated', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
// Create agent without support_contact
const agent = await createAgent({
id: agentId,
name: 'Agent without Support',
provider: 'test',
model: 'test-model',
author: authorId,
});
// Verify support_contact is undefined since it wasn't provided
expect(agent.support_contact).toBeUndefined();
// Update to add support_contact
const updated = await updateAgent(
{ id: agentId },
{
support_contact: {
name: 'New Support Team',
email: 'support@example.com',
},
},
);
expect(updated.versions).toHaveLength(2);
expect(updated.support_contact.name).toBe('New Support Team');
expect(updated.support_contact.email).toBe('support@example.com');
});
test('should handle support_contact edge cases in isDuplicateVersion', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
// Create agent with support_contact
await createAgent({
id: agentId,
name: 'Edge Case Agent',
provider: 'test',
model: 'test-model',
author: authorId,
support_contact: {
name: 'Support',
email: 'support@test.com',
},
});
// Update to empty support_contact
const emptyUpdate = await updateAgent(
{ id: agentId },
{
support_contact: {},
},
);
expect(emptyUpdate.versions).toHaveLength(2);
expect(emptyUpdate.support_contact).toEqual({});
// Update back to populated support_contact
const repopulated = await updateAgent(
{ id: agentId },
{
support_contact: {
name: 'Support',
email: 'support@test.com',
},
},
);
expect(repopulated.versions).toHaveLength(3);
// Verify all versions have correct support_contact
const finalAgent = await getAgent({ id: agentId });
expect(finalAgent.versions[0].support_contact).toEqual({
name: 'Support',
email: 'support@test.com',
});
expect(finalAgent.versions[1].support_contact).toEqual({});
expect(finalAgent.versions[2].support_contact).toEqual({
name: 'Support',
email: 'support@test.com',
});
});
test('should preserve support_contact in version history', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
// Create agent
await createAgent({
id: agentId,
name: 'Version History Test',
provider: 'test',
model: 'test-model',
author: authorId,
support_contact: {
name: 'Initial Contact',
email: 'initial@test.com',
},
});
// Multiple updates with different support_contact values
await updateAgent(
{ id: agentId },
{
support_contact: {
name: 'Second Contact',
email: 'second@test.com',
},
},
);
await updateAgent(
{ id: agentId },
{
support_contact: {
name: 'Third Contact',
email: 'third@test.com',
},
},
);
const finalAgent = await getAgent({ id: agentId });
// Verify version history
expect(finalAgent.versions).toHaveLength(3);
expect(finalAgent.versions[0].support_contact).toEqual({
name: 'Initial Contact',
email: 'initial@test.com',
});
expect(finalAgent.versions[1].support_contact).toEqual({
name: 'Second Contact',
email: 'second@test.com',
});
expect(finalAgent.versions[2].support_contact).toEqual({
name: 'Third Contact',
email: 'third@test.com',
});
// Current state should match last version
expect(finalAgent.support_contact).toEqual({
name: 'Third Contact',
email: 'third@test.com',
});
});
test('should handle partial support_contact updates', async () => {
const agentId = `agent_${uuidv4()}`;
const authorId = new mongoose.Types.ObjectId();
// Create agent with full support_contact
await createAgent({
id: agentId,
name: 'Partial Update Test',
provider: 'test',
model: 'test-model',
author: authorId,
support_contact: {
name: 'Original Name',
email: 'original@email.com',
},
});
// MongoDB's findOneAndUpdate will replace the entire support_contact object
// So we need to verify that partial updates still work correctly
const updated = await updateAgent(
{ id: agentId },
{
support_contact: {
name: 'New Name',
email: '', // Empty email
},
},
);
expect(updated.versions).toHaveLength(2);
expect(updated.support_contact.name).toBe('New Name');
expect(updated.support_contact.email).toBe('');
// Verify isDuplicateVersion works with partial changes
await expect(
updateAgent(
{ id: agentId },
{
support_contact: {
name: 'New Name',
email: '',
},
},
),
).rejects.toThrow('Duplicate version');
});
// Edge Cases
describe.each([
{
operation: 'add',
name: 'empty file_id',
needsAgent: true,
params: { tool_resource: 'file_search', file_id: '' },
shouldResolve: true,
},
{
operation: 'add',
name: 'non-existent agent',
needsAgent: false,
params: { tool_resource: 'file_search', file_id: 'file123' },
shouldResolve: false,
error: 'Agent not found for adding resource file',
},
])('addAgentResourceFile with $name', ({ needsAgent, params, shouldResolve, error }) => {
test(`should ${shouldResolve ? 'resolve' : 'reject'}`, async () => {
const agent = needsAgent ? await createBasicAgent() : null;
const agent_id = needsAgent ? agent.id : `agent_${uuidv4()}`;
if (shouldResolve) {
await expect(addAgentResourceFile({ agent_id, ...params })).resolves.toBeDefined();
} else {
await expect(addAgentResourceFile({ agent_id, ...params })).rejects.toThrow(error);
}
});
});
describe.each([
{
name: 'empty files array',
files: [],
needsAgent: true,
shouldResolve: true,
},
{
name: 'non-existent tool_resource',
files: [{ tool_resource: 'non_existent_tool', file_id: 'file123' }],
needsAgent: true,
shouldResolve: true,
},
{
name: 'non-existent agent',
files: [{ tool_resource: 'file_search', file_id: 'file123' }],
needsAgent: false,
shouldResolve: false,
error: 'Agent not found for removing resource files',
},
])('removeAgentResourceFiles with $name', ({ files, needsAgent, shouldResolve, error }) => {
test(`should ${shouldResolve ? 'resolve' : 'reject'}`, async () => {
const agent = needsAgent ? await createBasicAgent() : null;
const agent_id = needsAgent ? agent.id : `agent_${uuidv4()}`;
if (shouldResolve) {
const result = await removeAgentResourceFiles({ agent_id, files });
expect(result).toBeDefined();
if (agent) {
expect(result.id).toBe(agent.id);
}
} else {
await expect(removeAgentResourceFiles({ agent_id, files })).rejects.toThrow(error);
}
});
});
describe('Edge Cases', () => {
test('should handle extremely large version history', async () => {
const agentId = `agent_${uuidv4()}`;
@ -2565,6 +2887,93 @@ describe('models/Agent', () => {
});
});
describe('Support Contact Field', () => {
let mongoServer;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
await mongoose.connect(mongoUri);
}, 20000);
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await Agent.deleteMany({});
});
it('should not create subdocument with ObjectId for support_contact', async () => {
const userId = new mongoose.Types.ObjectId();
const agentData = {
id: 'agent_test_support',
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: userId,
support_contact: {
name: 'Support Team',
email: 'support@example.com',
},
};
// Create agent
const agent = await createAgent(agentData);
// Verify support_contact is stored correctly
expect(agent.support_contact).toBeDefined();
expect(agent.support_contact.name).toBe('Support Team');
expect(agent.support_contact.email).toBe('support@example.com');
// Verify no _id field is created in support_contact
expect(agent.support_contact._id).toBeUndefined();
// Fetch from database to double-check
const dbAgent = await Agent.findOne({ id: agentData.id });
expect(dbAgent.support_contact).toBeDefined();
expect(dbAgent.support_contact.name).toBe('Support Team');
expect(dbAgent.support_contact.email).toBe('support@example.com');
expect(dbAgent.support_contact._id).toBeUndefined();
});
it('should handle empty support_contact correctly', async () => {
const userId = new mongoose.Types.ObjectId();
const agentData = {
id: 'agent_test_empty_support',
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: userId,
support_contact: {},
};
const agent = await createAgent(agentData);
// Verify empty support_contact is stored as empty object
expect(agent.support_contact).toEqual({});
expect(agent.support_contact._id).toBeUndefined();
});
it('should handle missing support_contact correctly', async () => {
const userId = new mongoose.Types.ObjectId();
const agentData = {
id: 'agent_test_no_support',
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: userId,
};
const agent = await createAgent(agentData);
// Verify support_contact is undefined when not provided
expect(agent.support_contact).toBeUndefined();
});
});
function createBasicAgent(overrides = {}) {
const defaults = {
id: `agent_${uuidv4()}`,

View file

@ -1,125 +0,0 @@
const mongoose = require('mongoose');
/**
* AgentCategory Schema - Dynamic agent category management
* Focused implementation for core features only
*/
const agentCategorySchema = new mongoose.Schema(
{
// Unique identifier for the category (e.g., 'general', 'hr', 'finance')
value: {
type: String,
required: true,
unique: true,
trim: true,
lowercase: true,
index: true,
},
// Display label for the category
label: {
type: String,
required: true,
trim: true,
},
// Description of the category
description: {
type: String,
trim: true,
default: '',
},
// Display order for sorting categories
order: {
type: Number,
default: 0,
index: true,
},
// Whether the category is active and should be displayed
isActive: {
type: Boolean,
default: true,
index: true,
},
},
{
timestamps: true,
}
);
// Indexes for performance
agentCategorySchema.index({ isActive: 1, order: 1 });
/**
* Get all active categories sorted by order
* @returns {Promise<AgentCategory[]>} Array of active categories
*/
agentCategorySchema.statics.getActiveCategories = function() {
return this.find({ isActive: true })
.sort({ order: 1, label: 1 })
.lean();
};
/**
* Get categories with agent counts
* @returns {Promise<AgentCategory[]>} Categories with agent counts
*/
agentCategorySchema.statics.getCategoriesWithCounts = async function() {
const Agent = mongoose.model('agent');
// Aggregate to get agent counts per category
const categoryCounts = await Agent.aggregate([
{ $match: { category: { $exists: true, $ne: null } } },
{ $group: { _id: '$category', count: { $sum: 1 } } },
]);
// Create a map for quick lookup
const countMap = new Map(categoryCounts.map(c => [c._id, c.count]));
// Get all active categories and add counts
const categories = await this.getActiveCategories();
return categories.map(category => ({
...category,
agentCount: countMap.get(category.value) || 0,
}));
};
/**
* Get valid category values for Agent model validation
* @returns {Promise<string[]>} Array of valid category values
*/
agentCategorySchema.statics.getValidCategoryValues = function() {
return this.find({ isActive: true })
.distinct('value')
.lean();
};
/**
* Seed initial categories from existing constants
*/
agentCategorySchema.statics.seedCategories = async function(categories) {
const operations = categories.map((category, index) => ({
updateOne: {
filter: { value: category.value },
update: {
$setOnInsert: {
value: category.value,
label: category.label || category.value,
description: category.description || '',
order: category.order || index,
isActive: true,
},
},
upsert: true,
},
}));
return this.bulkWrite(operations);
};
const AgentCategory = mongoose.model('AgentCategory', agentCategorySchema);
module.exports = AgentCategory;

View file

@ -1,7 +1,5 @@
const { logger } = require('@librechat/data-schemas');
const { EToolResources, FileContext, Constants } = require('librechat-data-provider');
const { getProjectByName } = require('./Project');
const { getAgent } = require('./Agent');
const { EToolResources, FileContext } = require('librechat-data-provider');
const { File } = require('~/db/models');
/**
@ -14,124 +12,17 @@ const findFileById = async (file_id, options = {}) => {
return await File.findOne({ file_id, ...options }).lean();
};
/**
* Checks if a user has access to multiple files through a shared agent (batch operation)
* @param {string} userId - The user ID to check access for
* @param {string[]} fileIds - Array of file IDs to check
* @param {string} agentId - The agent ID that might grant access
* @returns {Promise<Map<string, boolean>>} Map of fileId to access status
*/
const hasAccessToFilesViaAgent = async (userId, fileIds, agentId, checkCollaborative = true) => {
const accessMap = new Map();
// Initialize all files as no access
fileIds.forEach((fileId) => accessMap.set(fileId, false));
try {
const agent = await getAgent({ id: agentId });
if (!agent) {
return accessMap;
}
// Check if user is the author - if so, grant access to all files
if (agent.author.toString() === userId) {
fileIds.forEach((fileId) => accessMap.set(fileId, true));
return accessMap;
}
// Check if agent is shared with the user via projects
if (!agent.projectIds || agent.projectIds.length === 0) {
return accessMap;
}
// Check if agent is in global project
const globalProject = await getProjectByName(Constants.GLOBAL_PROJECT_NAME, '_id');
if (
!globalProject ||
!agent.projectIds.some((pid) => pid.toString() === globalProject._id.toString())
) {
return accessMap;
}
// Agent is globally shared - check if it's collaborative
if (checkCollaborative && !agent.isCollaborative) {
return accessMap;
}
// Check which files are actually attached
const attachedFileIds = new Set();
if (agent.tool_resources) {
for (const [_resourceType, resource] of Object.entries(agent.tool_resources)) {
if (resource?.file_ids && Array.isArray(resource.file_ids)) {
resource.file_ids.forEach((fileId) => attachedFileIds.add(fileId));
}
}
}
// Grant access only to files that are attached to this agent
fileIds.forEach((fileId) => {
if (attachedFileIds.has(fileId)) {
accessMap.set(fileId, true);
}
});
return accessMap;
} catch (error) {
logger.error('[hasAccessToFilesViaAgent] Error checking file access:', error);
return accessMap;
}
};
/**
* Retrieves files matching a given filter, sorted by the most recently updated.
* @param {Object} filter - The filter criteria to apply.
* @param {Object} [_sortOptions] - Optional sort parameters.
* @param {Object|String} [selectFields={ text: 0 }] - Fields to include/exclude in the query results.
* Default excludes the 'text' field.
* @param {Object} [options] - Additional options
* @param {string} [options.userId] - User ID for access control
* @param {string} [options.agentId] - Agent ID that might grant access to files
* @returns {Promise<Array<MongoFile>>} A promise that resolves to an array of file documents.
*/
const getFiles = async (filter, _sortOptions, selectFields = { text: 0 }, options = {}) => {
const getFiles = async (filter, _sortOptions, selectFields = { text: 0 }) => {
const sortOptions = { updatedAt: -1, ..._sortOptions };
const files = await File.find(filter).select(selectFields).sort(sortOptions).lean();
// If userId and agentId are provided, filter files based on access
if (options.userId && options.agentId) {
// Collect file IDs that need access check
const filesToCheck = [];
const ownedFiles = [];
for (const file of files) {
if (file.user && file.user.toString() === options.userId) {
ownedFiles.push(file);
} else {
filesToCheck.push(file);
}
}
if (filesToCheck.length === 0) {
return ownedFiles;
}
// Batch check access for all non-owned files
const fileIds = filesToCheck.map((f) => f.file_id);
const accessMap = await hasAccessToFilesViaAgent(
options.userId,
fileIds,
options.agentId,
false,
);
// Filter files based on access
const accessibleFiles = filesToCheck.filter((file) => accessMap.get(file.file_id));
return [...ownedFiles, ...accessibleFiles];
}
return files;
return await File.find(filter).select(selectFields).sort(sortOptions).lean();
};
/**
@ -285,5 +176,4 @@ module.exports = {
deleteFiles,
deleteFileByFilter,
batchUpdateFiles,
hasAccessToFilesViaAgent,
};

View file

@ -1,17 +1,17 @@
const mongoose = require('mongoose');
const { v4: uuidv4 } = require('uuid');
const { fileSchema } = require('@librechat/data-schemas');
const { agentSchema } = require('@librechat/data-schemas');
const { projectSchema } = require('@librechat/data-schemas');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants;
const { createModels } = require('@librechat/data-schemas');
const { getFiles, createFile } = require('./File');
const { getProjectByName } = require('./Project');
const { createAgent } = require('./Agent');
const { grantPermission } = require('~/server/services/PermissionService');
const { seedDefaultRoles } = require('~/models');
let File;
let Agent;
let Project;
let AclEntry;
let User;
let modelsToCleanup = [];
describe('File Access Control', () => {
let mongoServer;
@ -19,13 +19,41 @@ describe('File Access Control', () => {
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
File = mongoose.models.File || mongoose.model('File', fileSchema);
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
Project = mongoose.models.Project || mongoose.model('Project', projectSchema);
await mongoose.connect(mongoUri);
// Initialize all models
const models = createModels(mongoose);
// Track which models we're adding
modelsToCleanup = Object.keys(models);
// Register models on mongoose.models so methods can access them
const dbModels = require('~/db/models');
Object.assign(mongoose.models, dbModels);
File = dbModels.File;
Agent = dbModels.Agent;
AclEntry = dbModels.AclEntry;
User = dbModels.User;
// Seed default roles
await seedDefaultRoles();
});
afterAll(async () => {
// Clean up all collections before disconnecting
const collections = mongoose.connection.collections;
for (const key in collections) {
await collections[key].deleteMany({});
}
// Clear only the models we added
for (const modelName of modelsToCleanup) {
if (mongoose.models[modelName]) {
delete mongoose.models[modelName];
}
}
await mongoose.disconnect();
await mongoServer.stop();
});
@ -33,16 +61,33 @@ describe('File Access Control', () => {
beforeEach(async () => {
await File.deleteMany({});
await Agent.deleteMany({});
await Project.deleteMany({});
await AclEntry.deleteMany({});
await User.deleteMany({});
// Don't delete AccessRole as they are seeded defaults needed for tests
});
describe('hasAccessToFilesViaAgent', () => {
it('should efficiently check access for multiple files at once', async () => {
const userId = new mongoose.Types.ObjectId().toString();
const authorId = new mongoose.Types.ObjectId().toString();
const userId = new mongoose.Types.ObjectId();
const authorId = new mongoose.Types.ObjectId();
const agentId = uuidv4();
const fileIds = [uuidv4(), uuidv4(), uuidv4(), uuidv4()];
// Create users
await User.create({
_id: userId,
email: 'user@example.com',
emailVerified: true,
provider: 'local',
});
await User.create({
_id: authorId,
email: 'author@example.com',
emailVerified: true,
provider: 'local',
});
// Create files
for (const fileId of fileIds) {
await createFile({
@ -54,13 +99,12 @@ describe('File Access Control', () => {
}
// Create agent with only first two files attached
await createAgent({
const agent = await createAgent({
id: agentId,
name: 'Test Agent',
author: authorId,
model: 'gpt-4',
provider: 'openai',
isCollaborative: true,
tool_resources: {
file_search: {
file_ids: [fileIds[0], fileIds[1]],
@ -68,15 +112,19 @@ describe('File Access Control', () => {
},
});
// Get or create global project
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id');
// Share agent globally
await Agent.updateOne({ id: agentId }, { $push: { projectIds: globalProject._id } });
// Grant EDIT permission to user on the agent
await grantPermission({
principalType: 'user',
principalId: userId,
resourceType: 'agent',
resourceId: agent._id,
accessRoleId: 'agent_editor',
grantedBy: authorId,
});
// Check access for all files
const { hasAccessToFilesViaAgent } = require('./File');
const accessMap = await hasAccessToFilesViaAgent(userId, fileIds, agentId);
const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions');
const accessMap = await hasAccessToFilesViaAgent(userId.toString(), fileIds, agentId);
// Should have access only to the first two files
expect(accessMap.get(fileIds[0])).toBe(true);
@ -86,10 +134,18 @@ describe('File Access Control', () => {
});
it('should grant access to all files when user is the agent author', async () => {
const authorId = new mongoose.Types.ObjectId().toString();
const authorId = new mongoose.Types.ObjectId();
const agentId = uuidv4();
const fileIds = [uuidv4(), uuidv4(), uuidv4()];
// Create author user
await User.create({
_id: authorId,
email: 'author@example.com',
emailVerified: true,
provider: 'local',
});
// Create agent
await createAgent({
id: agentId,
@ -105,8 +161,8 @@ describe('File Access Control', () => {
});
// Check access as the author
const { hasAccessToFilesViaAgent } = require('./File');
const accessMap = await hasAccessToFilesViaAgent(authorId, fileIds, agentId);
const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions');
const accessMap = await hasAccessToFilesViaAgent(authorId.toString(), fileIds, agentId);
// Author should have access to all files
expect(accessMap.get(fileIds[0])).toBe(true);
@ -115,31 +171,57 @@ describe('File Access Control', () => {
});
it('should handle non-existent agent gracefully', async () => {
const userId = new mongoose.Types.ObjectId().toString();
const userId = new mongoose.Types.ObjectId();
const fileIds = [uuidv4(), uuidv4()];
const { hasAccessToFilesViaAgent } = require('./File');
const accessMap = await hasAccessToFilesViaAgent(userId, fileIds, 'non-existent-agent');
// Create user
await User.create({
_id: userId,
email: 'user@example.com',
emailVerified: true,
provider: 'local',
});
const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions');
const accessMap = await hasAccessToFilesViaAgent(
userId.toString(),
fileIds,
'non-existent-agent',
);
// Should have no access to any files
expect(accessMap.get(fileIds[0])).toBe(false);
expect(accessMap.get(fileIds[1])).toBe(false);
});
it('should deny access when agent is not collaborative', async () => {
const userId = new mongoose.Types.ObjectId().toString();
const authorId = new mongoose.Types.ObjectId().toString();
it('should deny access when user only has VIEW permission', async () => {
const userId = new mongoose.Types.ObjectId();
const authorId = new mongoose.Types.ObjectId();
const agentId = uuidv4();
const fileIds = [uuidv4(), uuidv4()];
// Create agent with files but isCollaborative: false
await createAgent({
// Create users
await User.create({
_id: userId,
email: 'user@example.com',
emailVerified: true,
provider: 'local',
});
await User.create({
_id: authorId,
email: 'author@example.com',
emailVerified: true,
provider: 'local',
});
// Create agent with files
const agent = await createAgent({
id: agentId,
name: 'Non-Collaborative Agent',
name: 'View-Only Agent',
author: authorId,
model: 'gpt-4',
provider: 'openai',
isCollaborative: false,
tool_resources: {
file_search: {
file_ids: fileIds,
@ -147,17 +229,21 @@ describe('File Access Control', () => {
},
});
// Get or create global project
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id');
// Share agent globally
await Agent.updateOne({ id: agentId }, { $push: { projectIds: globalProject._id } });
// Grant only VIEW permission to user on the agent
await grantPermission({
principalType: 'user',
principalId: userId,
resourceType: 'agent',
resourceId: agent._id,
accessRoleId: 'agent_viewer',
grantedBy: authorId,
});
// Check access for files
const { hasAccessToFilesViaAgent } = require('./File');
const accessMap = await hasAccessToFilesViaAgent(userId, fileIds, agentId);
const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions');
const accessMap = await hasAccessToFilesViaAgent(userId.toString(), fileIds, agentId);
// Should have no access to any files when isCollaborative is false
// Should have no access to any files when only VIEW permission
expect(accessMap.get(fileIds[0])).toBe(false);
expect(accessMap.get(fileIds[1])).toBe(false);
});
@ -172,18 +258,28 @@ describe('File Access Control', () => {
const sharedFileId = `file_${uuidv4()}`;
const inaccessibleFileId = `file_${uuidv4()}`;
// Create/get global project using getProjectByName which will upsert
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME);
// Create users
await User.create({
_id: userId,
email: 'user@example.com',
emailVerified: true,
provider: 'local',
});
await User.create({
_id: authorId,
email: 'author@example.com',
emailVerified: true,
provider: 'local',
});
// Create agent with shared file
await createAgent({
const agent = await createAgent({
id: agentId,
name: 'Shared Agent',
provider: 'test',
model: 'test-model',
author: authorId,
projectIds: [globalProject._id],
isCollaborative: true,
tool_resources: {
file_search: {
file_ids: [sharedFileId],
@ -191,6 +287,16 @@ describe('File Access Control', () => {
},
});
// Grant EDIT permission to user on the agent
await grantPermission({
principalType: 'user',
principalId: userId,
resourceType: 'agent',
resourceId: agent._id,
accessRoleId: 'agent_editor',
grantedBy: authorId,
});
// Create files
await createFile({
file_id: ownedFileId,
@ -220,14 +326,17 @@ describe('File Access Control', () => {
bytes: 300,
});
// Get files with access control
const files = await getFiles(
// Get all files first
const allFiles = await getFiles(
{ file_id: { $in: [ownedFileId, sharedFileId, inaccessibleFileId] } },
null,
{ text: 0 },
{ userId: userId.toString(), agentId },
);
// Then filter by access control
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
const files = await filterFilesByAgentAccess(allFiles, userId.toString(), agentId);
expect(files).toHaveLength(2);
expect(files.map((f) => f.file_id)).toContain(ownedFileId);
expect(files.map((f) => f.file_id)).toContain(sharedFileId);

View file

@ -2,7 +2,6 @@ const {
CacheKeys,
SystemRoles,
roleDefaults,
PermissionTypes,
permissionsSchema,
removeNullishValues,
} = require('librechat-data-provider');

View file

@ -1,255 +0,0 @@
const AgentCategory = require('~/models/AgentCategory');
const mongoose = require('mongoose');
const { logger } = require('~/config');
// Get the Agent model
const Agent = mongoose.model('agent');
// Default page size for agent browsing
const DEFAULT_PAGE_SIZE = 6;
/**
* Common pagination utility for agent queries
*
* @param {Object} filter - MongoDB filter object
* @param {number} page - Page number (1-based)
* @param {number} limit - Items per page
* @returns {Promise<Object>} Paginated results with agents and pagination info
*/
const paginateAgents = async (filter, page = 1, limit = DEFAULT_PAGE_SIZE) => {
const skip = (page - 1) * limit;
// Get total count for pagination
const total = await Agent.countDocuments(filter);
// Get agents with pagination
const agents = await Agent.find(filter)
.select('id name description avatar category support_contact authorName')
.sort({ updatedAt: -1 })
.skip(skip)
.limit(limit)
.lean();
// Calculate if there are more agents to load
const hasMore = total > page * limit;
return {
agents,
pagination: {
current: page,
hasMore,
total,
},
};
};
/**
* Get promoted/top picks agents with pagination
* Can also return all agents when showAll=true parameter is provided
*
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
const getPromotedAgents = async (req, res) => {
try {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || DEFAULT_PAGE_SIZE;
// Check if this is a request for "all" agents via query parameter
const showAllAgents = req.query.showAll === 'true';
// Base filter for shared agents only
const filter = {
projectIds: { $exists: true, $ne: [] }, // Only get shared agents
};
// Only add promoted filter if not requesting all agents
if (!showAllAgents) {
filter.is_promoted = true; // Only get promoted agents
}
const result = await paginateAgents(filter, page, limit);
res.status(200).json(result);
} catch (error) {
logger.error('[/Agents/Marketplace] Error fetching promoted agents:', error);
res.status(500).json({
error: 'Failed to fetch promoted agents',
userMessage: 'Unable to load agents. Please try refreshing the page.',
suggestion: 'Try refreshing the page or check your network connection',
});
}
};
/**
* Get agents by category with pagination
*
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
const getAgentsByCategory = async (req, res) => {
try {
const { category } = req.params;
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || DEFAULT_PAGE_SIZE;
const filter = {
category,
projectIds: { $exists: true, $ne: [] }, // Only get shared agents
};
const result = await paginateAgents(filter, page, limit);
// Get category description from database
const categoryDoc = await AgentCategory.findOne({ value: category, isActive: true });
const categoryInfo = {
name: category,
description: categoryDoc?.description || '',
total: result.pagination.total,
};
res.status(200).json({
...result,
category: categoryInfo,
});
} catch (error) {
logger.error(
`[/Agents/Marketplace] Error fetching agents for category ${req.params.category}:`,
error,
);
res.status(500).json({
error: 'Failed to fetch agents by category',
userMessage: `Unable to load agents for this category. Please try a different category.`,
suggestion: 'Try selecting a different category or refresh the page',
});
}
};
/**
* Search agents with filters
*
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
const searchAgents = async (req, res) => {
try {
const { q, category } = req.query;
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || DEFAULT_PAGE_SIZE;
if (!q || q.trim() === '') {
return res.status(400).json({
error: 'Search query is required',
userMessage: 'Please enter a search term to find agents',
suggestion: 'Enter a search term to find agents by name or description',
});
}
// Build search filter
const filter = {
projectIds: { $exists: true, $ne: [] }, // Only get shared agents
$or: [
{ name: { $regex: q, $options: 'i' } }, // Case-insensitive name search
{ description: { $regex: q, $options: 'i' } },
],
};
// Add category filter if provided
if (category && category !== 'all') {
filter.category = category;
}
const result = await paginateAgents(filter, page, limit);
res.status(200).json({
...result,
query: q,
});
} catch (error) {
logger.error('[/Agents/Marketplace] Error searching agents:', error);
res.status(500).json({
error: 'Failed to search agents',
userMessage: 'Search is temporarily unavailable. Please try again.',
suggestion: 'Try a different search term or check your network connection',
});
}
};
/**
* Get all agent categories with counts
*
* @param {Object} _req - Express request object (unused)
* @param {Object} res - Express response object
*/
const getAgentCategories = async (_req, res) => {
try {
// Get categories with agent counts from database
const categories = await AgentCategory.getCategoriesWithCounts();
// Get count of promoted agents for Top Picks
const promotedCount = await Agent.countDocuments({
projectIds: { $exists: true, $ne: [] },
is_promoted: true,
});
// Convert to marketplace format (TCategory structure)
const formattedCategories = categories.map((category) => ({
value: category.value,
label: category.label,
count: category.agentCount,
description: category.description,
}));
// Add promoted category if agents exist
if (promotedCount > 0) {
formattedCategories.unshift({
value: 'promoted',
label: 'Promoted',
count: promotedCount,
description: 'Our recommended agents',
});
}
// Get total count of all shared agents for "All" category
const totalAgents = await Agent.countDocuments({
projectIds: { $exists: true, $ne: [] },
});
// Add "All" category at the end
formattedCategories.push({
value: 'all',
label: 'All',
count: totalAgents,
description: 'All available agents',
});
res.status(200).json(formattedCategories);
} catch (error) {
logger.error('[/Agents/Marketplace] Error fetching agent categories:', error);
res.status(500).json({
error: 'Failed to fetch agent categories',
userMessage: 'Unable to load categories. Please refresh the page.',
suggestion: 'Try refreshing the page or check your network connection',
});
}
};
/**
* Get all agents with pagination (for "all" category)
* This is an alias for getPromotedAgents with showAll=true for backwards compatibility
*
* @param {Object} req - Express request object
* @param {Object} res - Express response object
*/
const getAllAgents = async (req, res) => {
// Set showAll parameter and delegate to getPromotedAgents
req.query.showAll = 'true';
return getPromotedAgents(req, res);
};
module.exports = {
getPromotedAgents,
getAgentsByCategory,
searchAgents,
getAgentCategories,
getAllAgents,
};

View file

@ -17,6 +17,8 @@ const {
updateAgent,
deleteAgent,
getListAgentsByAccess,
countPromotedAgents,
revertAgentVersion,
} = require('~/models/Agent');
const {
grantPermission,
@ -30,8 +32,8 @@ const { refreshS3Url } = require('~/server/services/Files/S3/crud');
const { filterFile } = require('~/server/services/Files/process');
const { updateAction, getActions } = require('~/models/Action');
const { getCachedTools } = require('~/server/services/Config');
const { revertAgentVersion } = require('~/models/Agent');
const { deleteFileByFilter } = require('~/models/File');
const { getCategoriesWithCounts } = require('~/models');
const systemTools = {
[Tools.execute_code]: true,
@ -45,7 +47,7 @@ const systemTools = {
* @param {ServerRequest} req - The request object.
* @param {AgentCreateParams} req.body - The request body.
* @param {ServerResponse} res - The response object.
* @returns {Agent} 201 - success response - application/json
* @returns {Promise<Agent>} 201 - success response - application/json
*/
const createAgentHandler = async (req, res) => {
try {
@ -402,12 +404,43 @@ const deleteAgentHandler = async (req, res) => {
const getListAgentsHandler = async (req, res) => {
try {
const userId = req.user.id;
const { category, search, limit, cursor, promoted } = req.query;
let requiredPermission = req.query.requiredPermission;
if (typeof requiredPermission === 'string') {
requiredPermission = parseInt(requiredPermission, 10);
if (isNaN(requiredPermission)) {
requiredPermission = PermissionBits.VIEW;
}
} else if (typeof requiredPermission !== 'number') {
requiredPermission = PermissionBits.VIEW;
}
// Base filter
const filter = {};
// Handle category filter - only apply if category is defined
if (category !== undefined && category.trim() !== '') {
filter.category = category;
}
// Handle promoted filter - only from query param
if (promoted === '1') {
filter.is_promoted = true;
} else if (promoted === '0') {
filter.is_promoted = { $ne: true };
}
// Handle search filter
if (search && search.trim() !== '') {
filter.$or = [
{ name: { $regex: search.trim(), $options: 'i' } },
{ description: { $regex: search.trim(), $options: 'i' } },
];
}
// Get agent IDs the user has VIEW access to via ACL
const accessibleIds = await findAccessibleResources({
userId,
resourceType: 'agent',
requiredPermissions: PermissionBits.VIEW,
requiredPermissions: requiredPermission,
});
const publiclyAccessibleIds = await findPubliclyAccessibleResources({
resourceType: 'agent',
@ -416,7 +449,9 @@ const getListAgentsHandler = async (req, res) => {
// Use the new ACL-aware function
const data = await getListAgentsByAccess({
accessibleIds,
otherParams: {}, // Can add query params here if needed
otherParams: filter,
limit,
after: cursor,
});
if (data?.data?.length) {
data.data = data.data.map((agent) => {
@ -592,7 +627,48 @@ const revertAgentVersionHandler = async (req, res) => {
res.status(500).json({ error: error.message });
}
};
/**
* Get all agent categories with counts
*
* @param {Object} _req - Express request object (unused)
* @param {Object} res - Express response object
*/
const getAgentCategories = async (_req, res) => {
try {
const categories = await getCategoriesWithCounts();
const promotedCount = await countPromotedAgents();
const formattedCategories = categories.map((category) => ({
value: category.value,
label: category.label,
count: category.agentCount,
description: category.description,
}));
if (promotedCount > 0) {
formattedCategories.unshift({
value: 'promoted',
label: 'Promoted',
count: promotedCount,
description: 'Our recommended agents',
});
}
formattedCategories.push({
value: 'all',
label: 'All',
description: 'All available agents',
});
res.status(200).json(formattedCategories);
} catch (error) {
logger.error('[/Agents/Marketplace] Error fetching agent categories:', error);
res.status(500).json({
error: 'Failed to fetch agent categories',
userMessage: 'Unable to load categories. Please refresh the page.',
suggestion: 'Try refreshing the page or check your network connection',
});
}
};
module.exports = {
createAgent: createAgentHandler,
getAgent: getAgentHandler,
@ -602,4 +678,5 @@ module.exports = {
getListAgents: getListAgentsHandler,
uploadAgentAvatar: uploadAgentAvatarHandler,
revertAgentVersion: revertAgentVersionHandler,
getAgentCategories,
};

View file

@ -235,6 +235,81 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(agentInDb.tool_resources.invalid_resource).toBeUndefined();
});
test('should handle support_contact with empty strings', async () => {
const dataWithEmptyContact = {
provider: 'openai',
model: 'gpt-4',
name: 'Agent with Empty Contact',
support_contact: {
name: '',
email: '',
},
};
mockReq.body = dataWithEmptyContact;
await createAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(201);
const createdAgent = mockRes.json.mock.calls[0][0];
expect(createdAgent.name).toBe('Agent with Empty Contact');
expect(createdAgent.support_contact).toBeDefined();
expect(createdAgent.support_contact.name).toBe('');
expect(createdAgent.support_contact.email).toBe('');
});
test('should handle support_contact with valid email', async () => {
const dataWithValidContact = {
provider: 'openai',
model: 'gpt-4',
name: 'Agent with Valid Contact',
support_contact: {
name: 'Support Team',
email: 'support@example.com',
},
};
mockReq.body = dataWithValidContact;
await createAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(201);
const createdAgent = mockRes.json.mock.calls[0][0];
expect(createdAgent.support_contact).toBeDefined();
expect(createdAgent.support_contact.name).toBe('Support Team');
expect(createdAgent.support_contact.email).toBe('support@example.com');
});
test('should reject support_contact with invalid email', async () => {
const dataWithInvalidEmail = {
provider: 'openai',
model: 'gpt-4',
name: 'Agent with Invalid Email',
support_contact: {
name: 'Support',
email: 'not-an-email',
},
};
mockReq.body = dataWithInvalidEmail;
await createAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(mockRes.json).toHaveBeenCalledWith(
expect.objectContaining({
error: 'Invalid request data',
details: expect.arrayContaining([
expect.objectContaining({
path: ['support_contact', 'email'],
}),
]),
}),
);
});
test('should handle avatar validation', async () => {
const dataWithAvatar = {
provider: 'openai',
@ -372,52 +447,6 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(agentInDb.id).toBe(existingAgentId);
});
test('should reject update from non-author when not collaborative', async () => {
const differentUserId = new mongoose.Types.ObjectId().toString();
mockReq.user.id = differentUserId; // Different user
mockReq.params.id = existingAgentId;
mockReq.body = {
name: 'Unauthorized Update',
};
await updateAgentHandler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(403);
expect(mockRes.json).toHaveBeenCalledWith({
error: 'You do not have permission to modify this non-collaborative agent',
});
// Verify agent was not modified in database
const agentInDb = await Agent.findOne({ id: existingAgentId });
expect(agentInDb.name).toBe('Original Agent');
});
test('should allow update from non-author when collaborative', async () => {
// First make the agent collaborative
await Agent.updateOne({ id: existingAgentId }, { isCollaborative: true });
const differentUserId = new mongoose.Types.ObjectId().toString();
mockReq.user.id = differentUserId; // Different user
mockReq.params.id = existingAgentId;
mockReq.body = {
name: 'Collaborative Update',
};
await updateAgentHandler(mockReq, mockRes);
expect(mockRes.status).not.toHaveBeenCalledWith(403);
expect(mockRes.json).toHaveBeenCalled();
const updatedAgent = mockRes.json.mock.calls[0][0];
expect(updatedAgent.name).toBe('Collaborative Update');
// Author field should be removed for non-author
expect(updatedAgent.author).toBeUndefined();
// Verify in database
const agentInDb = await Agent.findOne({ id: existingAgentId });
expect(agentInDb.name).toBe('Collaborative Update');
});
test('should allow admin to update any agent', async () => {
const adminUserId = new mongoose.Types.ObjectId().toString();
mockReq.user.id = adminUserId;
@ -577,45 +606,6 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(agentInDb.__v).not.toBe(99);
});
test('should prevent privilege escalation through isCollaborative', async () => {
// Create a non-collaborative agent
const authorId = new mongoose.Types.ObjectId();
const agent = await Agent.create({
id: `agent_${uuidv4()}`,
name: 'Private Agent',
provider: 'openai',
model: 'gpt-4',
author: authorId,
isCollaborative: false,
versions: [
{
name: 'Private Agent',
provider: 'openai',
model: 'gpt-4',
createdAt: new Date(),
updatedAt: new Date(),
},
],
});
// Try to make it collaborative as a different user
const attackerId = new mongoose.Types.ObjectId().toString();
mockReq.user.id = attackerId;
mockReq.params.id = agent.id;
mockReq.body = {
isCollaborative: true, // Trying to escalate privileges
};
await updateAgentHandler(mockReq, mockRes);
// Should be rejected
expect(mockRes.status).toHaveBeenCalledWith(403);
// Verify in database that it's still not collaborative
const agentInDb = await Agent.findOne({ id: agent.id });
expect(agentInDb.isCollaborative).toBe(false);
});
test('should prevent author hijacking', async () => {
const originalAuthorId = new mongoose.Types.ObjectId();
const attackerId = new mongoose.Types.ObjectId();

View file

@ -0,0 +1,72 @@
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const { getRoleByName } = require('~/models/Role');
const { logger } = require('~/config');
/**
* Middleware to check if user has permission to access people picker functionality
* Checks specific permission based on the 'type' query parameter:
* - type=user: requires VIEW_USERS permission
* - type=group: requires VIEW_GROUPS permission
* - no type (mixed search): requires either VIEW_USERS OR VIEW_GROUPS
*/
const checkPeoplePickerAccess = async (req, res, next) => {
try {
const user = req.user;
if (!user || !user.role) {
return res.status(401).json({
error: 'Unauthorized',
message: 'Authentication required',
});
}
const role = await getRoleByName(user.role);
if (!role || !role.permissions) {
return res.status(403).json({
error: 'Forbidden',
message: 'No permissions configured for user role',
});
}
const { type } = req.query;
const peoplePickerPerms = role.permissions[PermissionTypes.PEOPLE_PICKER] || {};
const canViewUsers = peoplePickerPerms[Permissions.VIEW_USERS] === true;
const canViewGroups = peoplePickerPerms[Permissions.VIEW_GROUPS] === true;
if (type === 'user') {
if (!canViewUsers) {
return res.status(403).json({
error: 'Forbidden',
message: 'Insufficient permissions to search for users',
});
}
} else if (type === 'group') {
if (!canViewGroups) {
return res.status(403).json({
error: 'Forbidden',
message: 'Insufficient permissions to search for groups',
});
}
} else {
if (!canViewUsers || !canViewGroups) {
return res.status(403).json({
error: 'Forbidden',
message: 'Insufficient permissions to search for both users and groups',
});
}
}
next();
} catch (error) {
logger.error(
`[checkPeoplePickerAccess][${req.user?.id}] checkPeoplePickerAccess error for req.query.type = ${req.query.type}`,
error,
);
return res.status(500).json({
error: 'Internal Server Error',
message: 'Failed to check permissions',
});
}
};
module.exports = {
checkPeoplePickerAccess,
};

View file

@ -1,17 +1,31 @@
const mongoose = require('mongoose');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { checkAccess, generateCheckAccess } = require('./access');
const { checkAccess, generateCheckAccess } = require('@librechat/api');
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const { getRoleByName } = require('~/models/Role');
const { Role } = require('~/db/models');
// Mock only the logger
jest.mock('~/config', () => ({
// Mock the logger from @librechat/data-schemas
jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
logger: {
warn: jest.fn(),
error: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
},
}));
// Mock the cache to use a simple in-memory implementation
const mockCache = new Map();
jest.mock('~/cache/getLogStores', () => {
return jest.fn(() => ({
get: jest.fn(async (key) => mockCache.get(key)),
set: jest.fn(async (key, value) => mockCache.set(key, value)),
clear: jest.fn(async () => mockCache.clear()),
}));
});
describe('Access Middleware', () => {
let mongoServer;
let req, res, next;
@ -29,33 +43,86 @@ describe('Access Middleware', () => {
beforeEach(async () => {
await mongoose.connection.dropDatabase();
mockCache.clear(); // Clear the cache between tests
// Create test roles
await Role.create({
name: 'user',
permissions: {
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.PROMPTS]: {
[Permissions.SHARED_GLOBAL]: false,
[Permissions.USE]: true,
[Permissions.CREATE]: true,
},
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: true,
},
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.CREATE]: false,
[Permissions.SHARED_GLOBAL]: false,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
},
});
await Role.create({
name: 'admin',
permissions: {
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.PROMPTS]: {
[Permissions.SHARED_GLOBAL]: true,
[Permissions.USE]: true,
[Permissions.CREATE]: true,
},
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.UPDATE]: true,
[Permissions.READ]: true,
[Permissions.OPT_OUT]: true,
},
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
[Permissions.SHARED_GLOBAL]: true,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
},
});
// Create limited role with no AGENTS permissions
await Role.create({
name: 'limited',
permissions: {
// Explicitly set AGENTS permissions to false
[PermissionTypes.AGENTS]: {
[Permissions.USE]: false,
[Permissions.CREATE]: false,
[Permissions.SHARED_GLOBAL]: false,
},
// Has permissions for other types
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: true,
},
},
});
req = {
user: { id: 'user123', role: 'user' },
body: {},
originalUrl: '/test',
};
res = {
status: jest.fn().mockReturnThis(),
@ -67,92 +134,127 @@ describe('Access Middleware', () => {
describe('checkAccess', () => {
test('should return false if user is not provided', async () => {
const result = await checkAccess(null, PermissionTypes.AGENTS, [Permissions.USE]);
const result = await checkAccess({
user: null,
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
expect(result).toBe(false);
});
test('should return true if user has required permission', async () => {
const result = await checkAccess(req.user, PermissionTypes.AGENTS, [Permissions.USE]);
const result = await checkAccess({
req: {},
user: { id: 'user123', role: 'user' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
expect(result).toBe(true);
});
test('should return false if user lacks required permission', async () => {
const result = await checkAccess(req.user, PermissionTypes.AGENTS, [Permissions.CREATE]);
const result = await checkAccess({
req: {},
user: { id: 'user123', role: 'user' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.CREATE],
getRoleByName,
});
expect(result).toBe(false);
});
test('should return true if user has any of multiple permissions', async () => {
const result = await checkAccess(req.user, PermissionTypes.AGENTS, [
Permissions.USE,
Permissions.CREATE,
]);
test('should return false if user has only some of multiple permissions', async () => {
// User has USE but not CREATE, so should fail when checking for both
const result = await checkAccess({
req: {},
user: { id: 'user123', role: 'user' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.CREATE, Permissions.USE],
getRoleByName,
});
expect(result).toBe(false);
});
test('should return true if user has all of multiple permissions', async () => {
// Admin has both USE and CREATE
const result = await checkAccess({
req: {},
user: { id: 'admin123', role: 'admin' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.CREATE, Permissions.USE],
getRoleByName,
});
expect(result).toBe(true);
});
test('should check body properties when permission is not directly granted', async () => {
// User role doesn't have CREATE permission, but bodyProps allows it
const bodyProps = {
[Permissions.CREATE]: ['agentId', 'name'],
};
const checkObject = { agentId: 'agent123' };
const result = await checkAccess(
req.user,
PermissionTypes.AGENTS,
[Permissions.CREATE],
bodyProps,
checkObject,
);
const req = { body: { id: 'agent123' } };
const result = await checkAccess({
req,
user: { id: 'user123', role: 'user' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.UPDATE],
bodyProps: {
[Permissions.UPDATE]: ['id'],
},
checkObject: req.body,
getRoleByName,
});
expect(result).toBe(true);
});
test('should return false if role is not found', async () => {
req.user.role = 'nonexistent';
const result = await checkAccess(req.user, PermissionTypes.AGENTS, [Permissions.USE]);
const result = await checkAccess({
req: {},
user: { id: 'user123', role: 'nonexistent' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
expect(result).toBe(false);
});
test('should return false if role has no permissions for the requested type', async () => {
await Role.create({
name: 'limited',
permissions: {
// Explicitly set AGENTS permissions to false
[PermissionTypes.AGENTS]: {
[Permissions.USE]: false,
[Permissions.CREATE]: false,
[Permissions.SHARED_GLOBAL]: false,
},
// Has permissions for other types
[PermissionTypes.PROMPTS]: {
[Permissions.USE]: true,
},
},
const result = await checkAccess({
req: {},
user: { id: 'user123', role: 'limited' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
req.user.role = 'limited';
const result = await checkAccess(req.user, PermissionTypes.AGENTS, [Permissions.USE]);
expect(result).toBe(false);
});
test('should handle admin role with all permissions', async () => {
req.user.role = 'admin';
const createResult = await checkAccess(req.user, PermissionTypes.AGENTS, [
Permissions.CREATE,
]);
const createResult = await checkAccess({
req: {},
user: { id: 'admin123', role: 'admin' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.CREATE],
getRoleByName,
});
expect(createResult).toBe(true);
const shareResult = await checkAccess(req.user, PermissionTypes.AGENTS, [
Permissions.SHARED_GLOBAL,
]);
const shareResult = await checkAccess({
req: {},
user: { id: 'admin123', role: 'admin' },
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.SHARED_GLOBAL],
getRoleByName,
});
expect(shareResult).toBe(true);
});
});
describe('generateCheckAccess', () => {
test('should call next() when user has required permission', async () => {
const middleware = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]);
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
@ -160,7 +262,11 @@ describe('Access Middleware', () => {
});
test('should return 403 when user lacks permission', async () => {
const middleware = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.CREATE]);
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.CREATE],
getRoleByName,
});
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
@ -175,11 +281,12 @@ describe('Access Middleware', () => {
[Permissions.CREATE]: ['agentId'],
};
const middleware = generateCheckAccess(
PermissionTypes.AGENTS,
[Permissions.CREATE],
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.CREATE],
bodyProps,
);
getRoleByName,
});
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
@ -187,10 +294,16 @@ describe('Access Middleware', () => {
});
test('should handle database errors gracefully', async () => {
// Create a user with an invalid role that will cause getRoleByName to fail
req.user.role = { invalid: 'object' }; // This will cause an error when querying
// Mock getRoleByName to throw an error
const mockGetRoleByName = jest
.fn()
.mockRejectedValue(new Error('Database connection failed'));
const middleware = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]);
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName: mockGetRoleByName,
});
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
@ -203,11 +316,11 @@ describe('Access Middleware', () => {
test('should work with multiple permission types', async () => {
req.user.role = 'admin';
const middleware = generateCheckAccess(PermissionTypes.AGENTS, [
Permissions.USE,
Permissions.CREATE,
Permissions.SHARED_GLOBAL,
]);
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE, Permissions.CREATE, Permissions.SHARED_GLOBAL],
getRoleByName,
});
await middleware(req, res, next);
expect(next).toHaveBeenCalled();
@ -216,14 +329,16 @@ describe('Access Middleware', () => {
test('should handle missing user gracefully', async () => {
req.user = null;
const middleware = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]);
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(500);
expect(res.json).toHaveBeenCalledWith({
message: expect.stringContaining('Server error:'),
});
expect(res.status).toHaveBeenCalledWith(403);
expect(res.json).toHaveBeenCalledWith({ message: 'Forbidden: Insufficient permissions' });
});
test('should handle role with no AGENTS permissions', async () => {
@ -240,7 +355,11 @@ describe('Access Middleware', () => {
});
req.user.role = 'noaccess';
const middleware = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]);
const middleware = generateCheckAccess({
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE],
getRoleByName,
});
await middleware(req, res, next);
expect(next).not.toHaveBeenCalled();

View file

@ -8,6 +8,7 @@ const {
searchPrincipals,
} = require('~/server/controllers/PermissionsController');
const { requireJwtAuth, checkBan, uaParser, canAccessResource } = require('~/server/middleware');
const { checkPeoplePickerAccess } = require('~/server/middleware/checkPeoplePickerAccess');
const router = express.Router();
@ -25,7 +26,7 @@ router.use(uaParser);
* GET /api/permissions/search-principals
* Search for users and groups to grant permissions
*/
router.get('/search-principals', searchPrincipals);
router.get('/search-principals', checkPeoplePickerAccess, searchPrincipals);
/**
* GET /api/permissions/{resourceType}/roles

View file

@ -9,11 +9,13 @@ const {
removeNullishValues,
} = require('librechat-data-provider');
const { encryptMetadata, domainParser } = require('~/server/services/ActionService');
const { findAccessibleResources } = require('~/server/services/PermissionService');
const { updateAction, getActions, deleteAction } = require('~/models/Action');
const { isActionDomainAllowed } = require('~/server/services/domains');
const { canAccessAgentResource } = require('~/server/middleware');
const { getAgent, updateAgent } = require('~/models/Agent');
const { getRoleByName } = require('~/models/Role');
const { getListAgentsByAccess } = require('~/models/Agent');
const router = express.Router();
@ -31,9 +33,22 @@ const checkAgentCreate = generateCheckAccess({
*/
router.get('/', async (req, res) => {
try {
// Get all actions for the user (admin permissions handled by middleware if needed)
const searchParams = { user: req.user.id };
res.json(await getActions(searchParams));
const userId = req.user.id;
const editableAgentObjectIds = await findAccessibleResources({
userId,
resourceType: 'agent',
requiredPermissions: PermissionBits.EDIT,
});
const agentsResponse = await getListAgentsByAccess({
accessibleIds: editableAgentObjectIds,
});
const editableAgentIds = agentsResponse.data.map((agent) => agent.id);
const actions =
editableAgentIds.length > 0 ? await getActions({ agent_id: { $in: editableAgentIds } }) : [];
res.json(actions);
} catch (error) {
res.status(500).json({ error: error.message });
}

View file

@ -10,7 +10,6 @@ const {
const { isEnabled } = require('~/server/utils');
const { v1 } = require('./v1');
const chat = require('./chat');
const marketplace = require('./marketplace');
const { LIMIT_CONCURRENT_MESSAGES, LIMIT_MESSAGE_IP, LIMIT_MESSAGE_USER } = process.env ?? {};
@ -39,6 +38,5 @@ chatRouter.use('/', chat);
router.use('/chat', chatRouter);
// Add marketplace routes
router.use('/marketplace', marketplace);
module.exports = router;

View file

@ -1,46 +0,0 @@
const express = require('express');
const { requireJwtAuth, checkBan, generateCheckAccess } = require('~/server/middleware');
const { PermissionTypes, Permissions } = require('librechat-data-provider');
const marketplace = require('~/server/controllers/agents/marketplace');
const router = express.Router();
// Apply middleware for authentication and authorization
router.use(requireJwtAuth);
router.use(checkBan);
// Check if user has permission to use agents
const checkAgentAccess = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]);
router.use(checkAgentAccess);
/**
* Get all agent categories with counts
* @route GET /agents/marketplace/categories
*/
router.get('/categories', marketplace.getAgentCategories);
/**
* Get promoted/top picks agents with pagination
* @route GET /agents/marketplace/promoted
*/
router.get('/promoted', marketplace.getPromotedAgents);
/**
* Get all agents with pagination (for "all" category)
* @route GET /agents/marketplace/all
*/
router.get('/all', marketplace.getAllAgents);
/**
* Search agents with filters
* @route GET /agents/marketplace/search
*/
router.get('/search', marketplace.searchAgents);
/**
* Get agents by category with pagination
* @route GET /agents/marketplace/category/:category
*/
router.get('/category/:category', marketplace.getAgentsByCategory);
module.exports = router;

View file

@ -45,6 +45,11 @@ router.use('/actions', actions);
*/
router.use('/tools', tools);
/**
* Get all agent categories with counts
* @route GET /agents/marketplace/categories
*/
router.get('/categories', v1.getAgentCategories);
/**
* Creates an agent.
* @route POST /agents

View file

@ -3,9 +3,11 @@ const request = require('supertest');
const mongoose = require('mongoose');
const { v4: uuidv4 } = require('uuid');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants;
const { createMethods } = require('@librechat/data-schemas');
const { createAgent } = require('~/models/Agent');
const { createFile } = require('~/models/File');
// Mock dependencies
// Only mock the external dependencies that we don't want to test
jest.mock('~/server/services/Files/process', () => ({
processDeleteRequest: jest.fn().mockResolvedValue({}),
filterFile: jest.fn(),
@ -25,31 +27,8 @@ jest.mock('~/server/services/Tools/credentials', () => ({
loadAuthValues: jest.fn(),
}));
jest.mock('~/server/services/Files/S3/crud', () => ({
refreshS3FileUrls: jest.fn(),
}));
jest.mock('~/cache', () => ({
getLogStores: jest.fn(() => ({
get: jest.fn(),
set: jest.fn(),
})),
}));
jest.mock('~/config', () => ({
logger: {
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
},
}));
const { createFile } = require('~/models/File');
const { createAgent } = require('~/models/Agent');
const { getProjectByName } = require('~/models/Project');
// Import the router after mocks
const router = require('./files');
// Import the router
const router = require('~/server/routes/files/files');
describe('File Routes - Agent Files Endpoint', () => {
let app;
@ -60,13 +39,42 @@ describe('File Routes - Agent Files Endpoint', () => {
let fileId1;
let fileId2;
let fileId3;
let File;
let User;
let Agent;
let methods;
let AclEntry;
// eslint-disable-next-line no-unused-vars
let AccessRole;
let modelsToCleanup = [];
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
// Initialize models
require('~/db/models');
// Initialize all models using createModels
const { createModels } = require('@librechat/data-schemas');
const models = createModels(mongoose);
// Track which models we're adding
modelsToCleanup = Object.keys(models);
// Register models on mongoose.models so methods can access them
Object.assign(mongoose.models, models);
// Create methods with our test mongoose instance
methods = createMethods(mongoose);
// Now we can access models from the db/models
File = models.File;
Agent = models.Agent;
AclEntry = models.AclEntry;
User = models.User;
AccessRole = models.AccessRole;
// Seed default roles using our methods
await methods.seedDefaultRoles();
app = express();
app.use(express.json());
@ -82,88 +90,121 @@ describe('File Routes - Agent Files Endpoint', () => {
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
jest.clearAllMocks();
// Clear database
// Clean up all collections before disconnecting
const collections = mongoose.connection.collections;
for (const key in collections) {
await collections[key].deleteMany({});
}
authorId = new mongoose.Types.ObjectId().toString();
otherUserId = new mongoose.Types.ObjectId().toString();
// Clear only the models we added
for (const modelName of modelsToCleanup) {
if (mongoose.models[modelName]) {
delete mongoose.models[modelName];
}
}
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
// Clean up all test data
await File.deleteMany({});
await Agent.deleteMany({});
await User.deleteMany({});
await AclEntry.deleteMany({});
// Don't delete AccessRole as they are seeded defaults needed for tests
// Create test users
authorId = new mongoose.Types.ObjectId();
otherUserId = new mongoose.Types.ObjectId();
agentId = uuidv4();
fileId1 = uuidv4();
fileId2 = uuidv4();
fileId3 = uuidv4();
// Create users in database
await User.create({
_id: authorId,
username: 'author',
email: 'author@test.com',
});
await User.create({
_id: otherUserId,
username: 'other',
email: 'other@test.com',
});
// Create files
await createFile({
user: authorId,
file_id: fileId1,
filename: 'agent-file1.txt',
filepath: `/uploads/${authorId}/${fileId1}`,
bytes: 1024,
filename: 'file1.txt',
filepath: '/uploads/file1.txt',
bytes: 100,
type: 'text/plain',
});
await createFile({
user: authorId,
file_id: fileId2,
filename: 'agent-file2.txt',
filepath: `/uploads/${authorId}/${fileId2}`,
bytes: 2048,
filename: 'file2.txt',
filepath: '/uploads/file2.txt',
bytes: 200,
type: 'text/plain',
});
await createFile({
user: otherUserId,
file_id: fileId3,
filename: 'user-file.txt',
filepath: `/uploads/${otherUserId}/${fileId3}`,
bytes: 512,
filename: 'file3.txt',
filepath: '/uploads/file3.txt',
bytes: 300,
type: 'text/plain',
});
// Create an agent with files attached
await createAgent({
id: agentId,
name: 'Test Agent',
author: authorId,
model: 'gpt-4',
provider: 'openai',
isCollaborative: true,
tool_resources: {
file_search: {
file_ids: [fileId1, fileId2],
},
},
});
// Share the agent globally
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id');
if (globalProject) {
const { updateAgent } = require('~/models/Agent');
await updateAgent({ id: agentId }, { projectIds: [globalProject._id] });
}
});
describe('GET /files/agent/:agent_id', () => {
it('should return files accessible through the agent for non-author', async () => {
it('should return files accessible through the agent for non-author with EDIT permission', async () => {
// Create an agent with files attached
const agent = await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: authorId,
tool_resources: {
file_search: {
file_ids: [fileId1, fileId2],
},
},
});
// Grant EDIT permission to user on the agent using PermissionService
const { grantPermission } = require('~/server/services/PermissionService');
await grantPermission({
principalType: 'user',
principalId: otherUserId,
resourceType: 'agent',
resourceId: agent._id,
accessRoleId: 'agent_editor',
grantedBy: authorId,
});
// Mock req.user for this request
app.use((req, res, next) => {
req.user = { id: otherUserId.toString() };
next();
});
const response = await request(app).get(`/files/agent/${agentId}`);
expect(response.status).toBe(200);
expect(response.body).toHaveLength(2); // Only agent files, not user-owned files
const fileIds = response.body.map((f) => f.file_id);
expect(fileIds).toContain(fileId1);
expect(fileIds).toContain(fileId2);
expect(fileIds).not.toContain(fileId3); // User's own file not included
expect(Array.isArray(response.body)).toBe(true);
expect(response.body).toHaveLength(2);
expect(response.body.map((f) => f.file_id)).toContain(fileId1);
expect(response.body.map((f) => f.file_id)).toContain(fileId2);
});
it('should return 400 when agent_id is not provided', async () => {
@ -176,45 +217,63 @@ describe('File Routes - Agent Files Endpoint', () => {
const response = await request(app).get('/files/agent/non-existent-agent');
expect(response.status).toBe(200);
expect(response.body).toEqual([]); // Empty array for non-existent agent
expect(Array.isArray(response.body)).toBe(true);
expect(response.body).toEqual([]);
});
it('should return empty array when agent is not collaborative', async () => {
// Create a non-collaborative agent
const nonCollabAgentId = uuidv4();
await createAgent({
id: nonCollabAgentId,
name: 'Non-Collaborative Agent',
author: authorId,
model: 'gpt-4',
it('should return empty array when user only has VIEW permission', async () => {
// Create an agent with files attached
const agent = await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'openai',
isCollaborative: false,
model: 'gpt-4',
author: authorId,
tool_resources: {
file_search: {
file_ids: [fileId1],
file_ids: [fileId1, fileId2],
},
},
});
// Share it globally
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id');
if (globalProject) {
const { updateAgent } = require('~/models/Agent');
await updateAgent({ id: nonCollabAgentId }, { projectIds: [globalProject._id] });
}
// Grant only VIEW permission to user on the agent
const { grantPermission } = require('~/server/services/PermissionService');
await grantPermission({
principalType: 'user',
principalId: otherUserId,
resourceType: 'agent',
resourceId: agent._id,
accessRoleId: 'agent_viewer',
grantedBy: authorId,
});
const response = await request(app).get(`/files/agent/${nonCollabAgentId}`);
const response = await request(app).get(`/files/agent/${agentId}`);
expect(response.status).toBe(200);
expect(response.body).toEqual([]); // Empty array when not collaborative
expect(Array.isArray(response.body)).toBe(true);
expect(response.body).toEqual([]);
});
it('should return agent files for agent author', async () => {
// Create an agent with files attached
await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: authorId,
tool_resources: {
file_search: {
file_ids: [fileId1, fileId2],
},
},
});
// Create a new app instance with author authentication
const authorApp = express();
authorApp.use(express.json());
authorApp.use((req, res, next) => {
req.user = { id: authorId };
req.user = { id: authorId.toString() };
req.app = { locals: {} };
next();
});
@ -223,46 +282,48 @@ describe('File Routes - Agent Files Endpoint', () => {
const response = await request(authorApp).get(`/files/agent/${agentId}`);
expect(response.status).toBe(200);
expect(response.body).toHaveLength(2); // Agent files for author
const fileIds = response.body.map((f) => f.file_id);
expect(fileIds).toContain(fileId1);
expect(fileIds).toContain(fileId2);
expect(fileIds).not.toContain(fileId3); // User's own file not included
expect(Array.isArray(response.body)).toBe(true);
expect(response.body).toHaveLength(2);
});
it('should return files uploaded by other users to shared agent for author', async () => {
// Create a file uploaded by another user
const anotherUserId = new mongoose.Types.ObjectId();
const otherUserFileId = uuidv4();
const anotherUserId = new mongoose.Types.ObjectId().toString();
await User.create({
_id: anotherUserId,
username: 'another',
email: 'another@test.com',
});
await createFile({
user: anotherUserId,
file_id: otherUserFileId,
filename: 'other-user-file.txt',
filepath: `/uploads/${anotherUserId}/${otherUserFileId}`,
bytes: 4096,
filepath: '/uploads/other-user-file.txt',
bytes: 400,
type: 'text/plain',
});
// Update agent to include the file uploaded by another user
const { updateAgent } = require('~/models/Agent');
await updateAgent(
{ id: agentId },
{
tool_resources: {
file_search: {
file_ids: [fileId1, fileId2, otherUserFileId],
},
// Create agent to include the file uploaded by another user
await createAgent({
id: agentId,
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: authorId,
tool_resources: {
file_search: {
file_ids: [fileId1, otherUserFileId],
},
},
);
});
// Create app instance with author authentication
// Create a new app instance with author authentication
const authorApp = express();
authorApp.use(express.json());
authorApp.use((req, res, next) => {
req.user = { id: authorId };
req.user = { id: authorId.toString() };
req.app = { locals: {} };
next();
});
@ -271,12 +332,10 @@ describe('File Routes - Agent Files Endpoint', () => {
const response = await request(authorApp).get(`/files/agent/${agentId}`);
expect(response.status).toBe(200);
expect(response.body).toHaveLength(3); // Including file from another user
const fileIds = response.body.map((f) => f.file_id);
expect(fileIds).toContain(fileId1);
expect(fileIds).toContain(fileId2);
expect(fileIds).toContain(otherUserFileId); // File uploaded by another user
expect(Array.isArray(response.body)).toBe(true);
expect(response.body).toHaveLength(2);
expect(response.body.map((f) => f.file_id)).toContain(fileId1);
expect(response.body.map((f) => f.file_id)).toContain(otherUserFileId);
});
});
});

View file

@ -5,8 +5,8 @@ const {
Time,
isUUID,
CacheKeys,
Constants,
FileSources,
PERMISSION_BITS,
EModelEndpoint,
isAgentsEndpoint,
checkOpenAIStorage,
@ -17,12 +17,13 @@ const {
processDeleteRequest,
processAgentFileUpload,
} = require('~/server/services/Files/process');
const { getFiles, batchUpdateFiles, hasAccessToFilesViaAgent } = require('~/models/File');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { getOpenAIClient } = require('~/server/controllers/assistants/helpers');
const { checkPermission } = require('~/server/services/PermissionService');
const { loadAuthValues } = require('~/server/services/Tools/credentials');
const { refreshS3FileUrls } = require('~/server/services/Files/S3/crud');
const { getProjectByName } = require('~/models/Project');
const { hasAccessToFilesViaAgent } = require('~/server/services/Files');
const { getFiles, batchUpdateFiles } = require('~/models/File');
const { getAssistant } = require('~/models/Assistant');
const { getAgent } = require('~/models/Agent');
const { cleanFileName } = require('~/server/utils/files');
@ -123,14 +124,15 @@ router.get('/agent/:agent_id', async (req, res) => {
// Check if user has access to the agent
if (agent.author.toString() !== userId) {
// Non-authors need the agent to be globally shared and collaborative
const globalProject = await getProjectByName(Constants.GLOBAL_PROJECT_NAME, '_id');
// Non-authors need at least EDIT permission to view agent files
const hasEditPermission = await checkPermission({
userId,
resourceType: 'agent',
resourceId: agent._id,
requiredPermission: PERMISSION_BITS.EDIT,
});
if (
!globalProject ||
!agent.projectIds.some((pid) => pid.toString() === globalProject._id.toString()) ||
!agent.isCollaborative
) {
if (!hasEditPermission) {
return res.status(200).json([]);
}
}

View file

@ -2,10 +2,12 @@ const express = require('express');
const request = require('supertest');
const mongoose = require('mongoose');
const { v4: uuidv4 } = require('uuid');
const { createMethods } = require('@librechat/data-schemas');
const { MongoMemoryServer } = require('mongodb-memory-server');
const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants;
const { createAgent } = require('~/models/Agent');
const { createFile } = require('~/models/File');
// Mock dependencies
// Only mock the external dependencies that we don't want to test
jest.mock('~/server/services/Files/process', () => ({
processDeleteRequest: jest.fn().mockResolvedValue({}),
filterFile: jest.fn(),
@ -44,9 +46,6 @@ jest.mock('~/config', () => ({
},
}));
const { createFile } = require('~/models/File');
const { createAgent } = require('~/models/Agent');
const { getProjectByName } = require('~/models/Project');
const { processDeleteRequest } = require('~/server/services/Files/process');
// Import the router after mocks
@ -57,22 +56,51 @@ describe('File Routes - Delete with Agent Access', () => {
let mongoServer;
let authorId;
let otherUserId;
let agentId;
let fileId;
let File;
let Agent;
let AclEntry;
let User;
let AccessRole;
let methods;
let modelsToCleanup = [];
// eslint-disable-next-line no-unused-vars
let agentId;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
await mongoose.connect(mongoServer.getUri());
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
// Initialize models
require('~/db/models');
// Initialize all models using createModels
const { createModels } = require('@librechat/data-schemas');
const models = createModels(mongoose);
// Track which models we're adding
modelsToCleanup = Object.keys(models);
// Register models on mongoose.models so methods can access them
Object.assign(mongoose.models, models);
// Create methods with our test mongoose instance
methods = createMethods(mongoose);
// Now we can access models from the db/models
File = models.File;
Agent = models.Agent;
AclEntry = models.AclEntry;
User = models.User;
AccessRole = models.AccessRole;
// Seed default roles using our methods
await methods.seedDefaultRoles();
app = express();
app.use(express.json());
// Mock authentication middleware
app.use((req, res, next) => {
req.user = { id: otherUserId || 'default-user' };
req.user = { id: otherUserId ? otherUserId.toString() : 'default-user' };
req.app = { locals: {} };
next();
});
@ -81,6 +109,19 @@ describe('File Routes - Delete with Agent Access', () => {
});
afterAll(async () => {
// Clean up all collections before disconnecting
const collections = mongoose.connection.collections;
for (const key in collections) {
await collections[key].deleteMany({});
}
// Clear only the models we added
for (const modelName of modelsToCleanup) {
if (mongoose.models[modelName]) {
delete mongoose.models[modelName];
}
}
await mongoose.disconnect();
await mongoServer.stop();
});
@ -88,48 +129,41 @@ describe('File Routes - Delete with Agent Access', () => {
beforeEach(async () => {
jest.clearAllMocks();
// Clear database
const collections = mongoose.connection.collections;
for (const key in collections) {
await collections[key].deleteMany({});
}
// Clear database - clean up all test data
await File.deleteMany({});
await Agent.deleteMany({});
await User.deleteMany({});
await AclEntry.deleteMany({});
// Don't delete AccessRole as they are seeded defaults needed for tests
authorId = new mongoose.Types.ObjectId().toString();
otherUserId = new mongoose.Types.ObjectId().toString();
// Create test data
authorId = new mongoose.Types.ObjectId();
otherUserId = new mongoose.Types.ObjectId();
agentId = uuidv4();
fileId = uuidv4();
// Create users in database
await User.create({
_id: authorId,
username: 'author',
email: 'author@test.com',
});
await User.create({
_id: otherUserId,
username: 'other',
email: 'other@test.com',
});
// Create a file owned by the author
await createFile({
user: authorId,
file_id: fileId,
filename: 'test.txt',
filepath: `/uploads/${authorId}/${fileId}`,
bytes: 1024,
filepath: '/uploads/test.txt',
bytes: 100,
type: 'text/plain',
});
// Create an agent with the file attached
const agent = await createAgent({
id: uuidv4(),
name: 'Test Agent',
author: authorId,
model: 'gpt-4',
provider: 'openai',
isCollaborative: true,
tool_resources: {
file_search: {
file_ids: [fileId],
},
},
});
agentId = agent.id;
// Share the agent globally
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id');
if (globalProject) {
const { updateAgent } = require('~/models/Agent');
await updateAgent({ id: agentId }, { projectIds: [globalProject._id] });
}
});
describe('DELETE /files', () => {
@ -140,8 +174,8 @@ describe('File Routes - Delete with Agent Access', () => {
user: otherUserId,
file_id: userFileId,
filename: 'user-file.txt',
filepath: `/uploads/${otherUserId}/${userFileId}`,
bytes: 1024,
filepath: '/uploads/user-file.txt',
bytes: 200,
type: 'text/plain',
});
@ -151,7 +185,7 @@ describe('File Routes - Delete with Agent Access', () => {
files: [
{
file_id: userFileId,
filepath: `/uploads/${otherUserId}/${userFileId}`,
filepath: '/uploads/user-file.txt',
},
],
});
@ -168,7 +202,7 @@ describe('File Routes - Delete with Agent Access', () => {
files: [
{
file_id: fileId,
filepath: `/uploads/${authorId}/${fileId}`,
filepath: '/uploads/test.txt',
},
],
});
@ -180,14 +214,39 @@ describe('File Routes - Delete with Agent Access', () => {
});
it('should allow deleting files accessible through shared agent', async () => {
// Create an agent with the file attached
const agent = await createAgent({
id: uuidv4(),
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: authorId,
tool_resources: {
file_search: {
file_ids: [fileId],
},
},
});
// Grant EDIT permission to user on the agent
const { grantPermission } = require('~/server/services/PermissionService');
await grantPermission({
principalType: 'user',
principalId: otherUserId,
resourceType: 'agent',
resourceId: agent._id,
accessRoleId: 'agent_editor',
grantedBy: authorId,
});
const response = await request(app)
.delete('/files')
.send({
agent_id: agentId,
agent_id: agent.id,
files: [
{
file_id: fileId,
filepath: `/uploads/${authorId}/${fileId}`,
filepath: '/uploads/test.txt',
},
],
});
@ -204,19 +263,44 @@ describe('File Routes - Delete with Agent Access', () => {
user: authorId,
file_id: unattachedFileId,
filename: 'unattached.txt',
filepath: `/uploads/${authorId}/${unattachedFileId}`,
bytes: 1024,
filepath: '/uploads/unattached.txt',
bytes: 300,
type: 'text/plain',
});
// Create an agent without the unattached file
const agent = await createAgent({
id: uuidv4(),
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: authorId,
tool_resources: {
file_search: {
file_ids: [fileId], // Only fileId, not unattachedFileId
},
},
});
// Grant EDIT permission to user on the agent
const { grantPermission } = require('~/server/services/PermissionService');
await grantPermission({
principalType: 'user',
principalId: otherUserId,
resourceType: 'agent',
resourceId: agent._id,
accessRoleId: 'agent_editor',
grantedBy: authorId,
});
const response = await request(app)
.delete('/files')
.send({
agent_id: agentId,
agent_id: agent.id,
files: [
{
file_id: unattachedFileId,
filepath: `/uploads/${authorId}/${unattachedFileId}`,
filepath: '/uploads/unattached.txt',
},
],
});
@ -224,6 +308,7 @@ describe('File Routes - Delete with Agent Access', () => {
expect(response.status).toBe(403);
expect(response.body.message).toBe('You can only delete files you have access to');
expect(response.body.unauthorizedFiles).toContain(unattachedFileId);
expect(processDeleteRequest).not.toHaveBeenCalled();
});
it('should handle mixed authorized and unauthorized files', async () => {
@ -233,8 +318,8 @@ describe('File Routes - Delete with Agent Access', () => {
user: otherUserId,
file_id: userFileId,
filename: 'user-file.txt',
filepath: `/uploads/${otherUserId}/${userFileId}`,
bytes: 1024,
filepath: '/uploads/user-file.txt',
bytes: 200,
type: 'text/plain',
});
@ -244,51 +329,87 @@ describe('File Routes - Delete with Agent Access', () => {
user: authorId,
file_id: unauthorizedFileId,
filename: 'unauthorized.txt',
filepath: `/uploads/${authorId}/${unauthorizedFileId}`,
bytes: 1024,
filepath: '/uploads/unauthorized.txt',
bytes: 400,
type: 'text/plain',
});
// Create an agent with only fileId attached
const agent = await createAgent({
id: uuidv4(),
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: authorId,
tool_resources: {
file_search: {
file_ids: [fileId],
},
},
});
// Grant EDIT permission to user on the agent
const { grantPermission } = require('~/server/services/PermissionService');
await grantPermission({
principalType: 'user',
principalId: otherUserId,
resourceType: 'agent',
resourceId: agent._id,
accessRoleId: 'agent_editor',
grantedBy: authorId,
});
const response = await request(app)
.delete('/files')
.send({
agent_id: agentId,
agent_id: agent.id,
files: [
{
file_id: fileId, // Authorized through agent
filepath: `/uploads/${authorId}/${fileId}`,
},
{
file_id: userFileId, // Owned by user
filepath: `/uploads/${otherUserId}/${userFileId}`,
},
{
file_id: unauthorizedFileId, // Not authorized
filepath: `/uploads/${authorId}/${unauthorizedFileId}`,
},
{ file_id: userFileId, filepath: '/uploads/user-file.txt' },
{ file_id: fileId, filepath: '/uploads/test.txt' },
{ file_id: unauthorizedFileId, filepath: '/uploads/unauthorized.txt' },
],
});
expect(response.status).toBe(403);
expect(response.body.message).toBe('You can only delete files you have access to');
expect(response.body.unauthorizedFiles).toContain(unauthorizedFileId);
expect(response.body.unauthorizedFiles).not.toContain(fileId);
expect(response.body.unauthorizedFiles).not.toContain(userFileId);
expect(processDeleteRequest).not.toHaveBeenCalled();
});
it('should prevent deleting files when agent is not collaborative', async () => {
// Update the agent to be non-collaborative
const { updateAgent } = require('~/models/Agent');
await updateAgent({ id: agentId }, { isCollaborative: false });
it('should prevent deleting files when user lacks EDIT permission on agent', async () => {
// Create an agent with the file attached
const agent = await createAgent({
id: uuidv4(),
name: 'Test Agent',
provider: 'openai',
model: 'gpt-4',
author: authorId,
tool_resources: {
file_search: {
file_ids: [fileId],
},
},
});
// Grant only VIEW permission to user on the agent
const { grantPermission } = require('~/server/services/PermissionService');
await grantPermission({
principalType: 'user',
principalId: otherUserId,
resourceType: 'agent',
resourceId: agent._id,
accessRoleId: 'agent_viewer',
grantedBy: authorId,
});
const response = await request(app)
.delete('/files')
.send({
agent_id: agentId,
agent_id: agent.id,
files: [
{
file_id: fileId,
filepath: `/uploads/${authorId}/${fileId}`,
filepath: '/uploads/test.txt',
},
],
});

View file

@ -1,6 +1,7 @@
jest.mock('~/models', () => ({
initializeRoles: jest.fn(),
seedDefaultRoles: jest.fn(),
ensureDefaultCategories: jest.fn(),
}));
jest.mock('~/models/Role', () => ({
updateAccessPermissions: jest.fn(),

View file

@ -15,7 +15,7 @@ const {
const { azureAssistantsDefaults, assistantsConfigSetup } = require('./start/assistants');
const { initializeAzureBlobService } = require('./Files/Azure/initialize');
const { initializeFirebase } = require('./Files/Firebase/initialize');
const { seedDefaultRoles, initializeRoles } = require('~/models');
const { seedDefaultRoles, initializeRoles, ensureDefaultCategories } = require('~/models');
const loadCustomConfig = require('./Config/loadCustomConfig');
const handleRateLimits = require('./Config/handleRateLimits');
const { loadDefaultInterface } = require('./start/interface');
@ -36,6 +36,7 @@ const paths = require('~/config/paths');
const AppService = async (app) => {
await initializeRoles();
await seedDefaultRoles();
await ensureDefaultCategories();
/** @type {TCustomConfig} */
const config = (await loadCustomConfig()) ?? {};
const configDefaults = getConfigDefaults();

View file

@ -29,6 +29,7 @@ jest.mock('./Files/Firebase/initialize', () => ({
jest.mock('~/models', () => ({
initializeRoles: jest.fn(),
seedDefaultRoles: jest.fn(),
ensureDefaultCategories: jest.fn(),
}));
jest.mock('~/models/Role', () => ({
updateAccessPermissions: jest.fn(),

View file

@ -11,6 +11,7 @@ const {
imageExtRegex,
EToolResources,
} = require('librechat-data-provider');
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { convertImage } = require('~/server/services/Files/images/convert');
const { createFile, getFiles, updateFile } = require('~/models/File');
@ -164,14 +165,19 @@ const primeFiles = async (options, apiKey) => {
const file_ids = tool_resources?.[EToolResources.execute_code]?.file_ids ?? [];
const agentResourceIds = new Set(file_ids);
const resourceFiles = tool_resources?.[EToolResources.execute_code]?.files ?? [];
const dbFiles = (
(await getFiles(
{ file_id: { $in: file_ids } },
null,
{ text: 0 },
{ userId: req?.user?.id, agentId },
)) ?? []
).concat(resourceFiles);
// Get all files first
const allFiles = (await getFiles({ file_id: { $in: file_ids } }, null, { text: 0 })) ?? [];
// Filter by access if user and agent are provided
let dbFiles;
if (req?.user?.id && agentId) {
dbFiles = await filterFilesByAgentAccess(allFiles, req.user.id, agentId);
} else {
dbFiles = allFiles;
}
dbFiles = dbFiles.concat(resourceFiles);
const files = [];
const sessions = new Map();

View file

@ -0,0 +1,12 @@
const { processCodeFile } = require('./Code/process');
const { processFileUpload } = require('./process');
const { uploadImageBuffer } = require('./images');
const { hasAccessToFilesViaAgent, filterFilesByAgentAccess } = require('./permissions');
module.exports = {
processCodeFile,
processFileUpload,
uploadImageBuffer,
hasAccessToFilesViaAgent,
filterFilesByAgentAccess,
};

View file

@ -0,0 +1,123 @@
const { logger } = require('@librechat/data-schemas');
const { PERMISSION_BITS } = require('librechat-data-provider');
const { checkPermission } = require('~/server/services/PermissionService');
const { getAgent } = require('~/models/Agent');
/**
* Checks if a user has access to multiple files through a shared agent (batch operation)
* @param {string} userId - The user ID to check access for
* @param {string[]} fileIds - Array of file IDs to check
* @param {string} agentId - The agent ID that might grant access
* @returns {Promise<Map<string, boolean>>} Map of fileId to access status
*/
const hasAccessToFilesViaAgent = async (userId, fileIds, agentId) => {
const accessMap = new Map();
// Initialize all files as no access
fileIds.forEach((fileId) => accessMap.set(fileId, false));
try {
const agent = await getAgent({ id: agentId });
if (!agent) {
return accessMap;
}
// Check if user is the author - if so, grant access to all files
if (agent.author.toString() === userId) {
fileIds.forEach((fileId) => accessMap.set(fileId, true));
return accessMap;
}
// Check if user has at least VIEW permission on the agent
const hasViewPermission = await checkPermission({
userId,
resourceType: 'agent',
resourceId: agent._id,
requiredPermission: PERMISSION_BITS.VIEW,
});
if (!hasViewPermission) {
return accessMap;
}
// Check if user has EDIT permission (which would indicate collaborative access)
const hasEditPermission = await checkPermission({
userId,
resourceType: 'agent',
resourceId: agent._id,
requiredPermission: PERMISSION_BITS.EDIT,
});
// If user only has VIEW permission, they can't access files
// Only users with EDIT permission or higher can access agent files
if (!hasEditPermission) {
return accessMap;
}
// User has edit permissions - check which files are actually attached
const attachedFileIds = new Set();
if (agent.tool_resources) {
for (const [_resourceType, resource] of Object.entries(agent.tool_resources)) {
if (resource?.file_ids && Array.isArray(resource.file_ids)) {
resource.file_ids.forEach((fileId) => attachedFileIds.add(fileId));
}
}
}
// Grant access only to files that are attached to this agent
fileIds.forEach((fileId) => {
if (attachedFileIds.has(fileId)) {
accessMap.set(fileId, true);
}
});
return accessMap;
} catch (error) {
logger.error('[hasAccessToFilesViaAgent] Error checking file access:', error);
return accessMap;
}
};
/**
* Filter files based on user access through agents
* @param {Array<MongoFile>} files - Array of file documents
* @param {string} userId - User ID for access control
* @param {string} agentId - Agent ID that might grant access to files
* @returns {Promise<Array<MongoFile>>} Filtered array of accessible files
*/
const filterFilesByAgentAccess = async (files, userId, agentId) => {
if (!userId || !agentId || !files || files.length === 0) {
return files;
}
// Separate owned files from files that need access check
const filesToCheck = [];
const ownedFiles = [];
for (const file of files) {
if (file.user && file.user.toString() === userId) {
ownedFiles.push(file);
} else {
filesToCheck.push(file);
}
}
if (filesToCheck.length === 0) {
return ownedFiles;
}
// Batch check access for all non-owned files
const fileIds = filesToCheck.map((f) => f.file_id);
const accessMap = await hasAccessToFilesViaAgent(userId, fileIds, agentId);
// Filter files based on access
const accessibleFiles = filesToCheck.filter((file) => accessMap.get(file.file_id));
return [...ownedFiles, ...accessibleFiles];
};
module.exports = {
hasAccessToFilesViaAgent,
filterFilesByAgentAccess,
};

View file

@ -53,6 +53,24 @@ async function loadDefaultInterface(config, configDefaults, roleName = SystemRol
fileSearch: interfaceConfig?.fileSearch ?? defaults.fileSearch,
fileCitations: interfaceConfig?.fileCitations ?? defaults.fileCitations,
customWelcome: interfaceConfig?.customWelcome ?? defaults.customWelcome,
peoplePicker: {
admin: {
users: interfaceConfig?.peoplePicker?.admin?.users ?? defaults.peoplePicker?.admin.users,
groups: interfaceConfig?.peoplePicker?.admin?.groups ?? defaults.peoplePicker?.admin.groups,
},
user: {
users: interfaceConfig?.peoplePicker?.user?.users ?? defaults.peoplePicker?.user.users,
groups: interfaceConfig?.peoplePicker?.user?.groups ?? defaults.peoplePicker?.user.groups,
},
},
marketplace: {
admin: {
use: interfaceConfig?.marketplace?.admin?.use ?? defaults.marketplace?.admin.use,
},
user: {
use: interfaceConfig?.marketplace?.user?.use ?? defaults.marketplace?.user.use,
},
},
});
await updateAccessPermissions(roleName, {
@ -67,6 +85,13 @@ async function loadDefaultInterface(config, configDefaults, roleName = SystemRol
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: loadedInterface.temporaryChat },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: loadedInterface.runCode },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: loadedInterface.webSearch },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_USERS]: loadedInterface.peoplePicker.user?.users,
[Permissions.VIEW_GROUPS]: loadedInterface.peoplePicker.user?.groups,
},
[PermissionTypes.MARKETPLACE]: {
[Permissions.USE]: loadedInterface.marketplace.user?.use,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: loadedInterface.fileSearch },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: loadedInterface.fileCitations },
});
@ -82,6 +107,13 @@ async function loadDefaultInterface(config, configDefaults, roleName = SystemRol
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: loadedInterface.temporaryChat },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: loadedInterface.runCode },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: loadedInterface.webSearch },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_USERS]: loadedInterface.peoplePicker.admin?.users,
[Permissions.VIEW_GROUPS]: loadedInterface.peoplePicker.admin?.groups,
},
[PermissionTypes.MARKETPLACE]: {
[Permissions.USE]: loadedInterface.marketplace.admin?.use,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: loadedInterface.fileSearch },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: loadedInterface.fileCitations },
});

View file

@ -29,12 +29,20 @@ describe('loadDefaultInterface', () => {
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: true },
[PermissionTypes.MEMORIES]: { [Permissions.USE]: true, [Permissions.OPT_OUT]: undefined },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: true,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
[PermissionTypes.AGENTS]: { [Permissions.USE]: true },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
});
@ -68,6 +76,11 @@ describe('loadDefaultInterface', () => {
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: false },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: false },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: false },
});
@ -91,6 +104,11 @@ describe('loadDefaultInterface', () => {
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
});
@ -126,6 +144,11 @@ describe('loadDefaultInterface', () => {
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
});
@ -159,6 +182,11 @@ describe('loadDefaultInterface', () => {
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
});
@ -192,6 +220,11 @@ describe('loadDefaultInterface', () => {
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: true },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
});
@ -215,6 +248,11 @@ describe('loadDefaultInterface', () => {
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
});
@ -238,6 +276,11 @@ describe('loadDefaultInterface', () => {
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
});
@ -261,6 +304,11 @@ describe('loadDefaultInterface', () => {
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
});
@ -292,6 +340,11 @@ describe('loadDefaultInterface', () => {
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
});
@ -324,6 +377,11 @@ describe('loadDefaultInterface', () => {
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: undefined },
});
@ -355,6 +413,11 @@ describe('loadDefaultInterface', () => {
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: undefined },
});
});
@ -372,12 +435,20 @@ describe('loadDefaultInterface', () => {
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
[PermissionTypes.MEMORIES]: { [Permissions.USE]: undefined },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: undefined,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
});
});
@ -395,12 +466,20 @@ describe('loadDefaultInterface', () => {
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
[PermissionTypes.MEMORIES]: { [Permissions.USE]: undefined },
[PermissionTypes.MEMORIES]: {
[Permissions.USE]: undefined,
[Permissions.OPT_OUT]: undefined,
},
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: undefined },
[PermissionTypes.AGENTS]: { [Permissions.USE]: undefined },
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: undefined },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: undefined },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: undefined },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: false },
});
});
@ -432,6 +511,11 @@ describe('loadDefaultInterface', () => {
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
[PermissionTypes.MARKETPLACE]: { [Permissions.USE]: undefined },
[PermissionTypes.PEOPLE_PICKER]: {
[Permissions.VIEW_GROUPS]: undefined,
[Permissions.VIEW_USERS]: undefined,
},
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
[PermissionTypes.FILE_CITATIONS]: { [Permissions.USE]: true },
});

View file

@ -10,4 +10,9 @@ process.env.JWT_SECRET = 'test';
process.env.JWT_REFRESH_SECRET = 'test';
process.env.CREDS_KEY = 'test';
process.env.CREDS_IV = 'test';
process.env.ALLOW_EMAIL_LOGIN = 'true';
// Set global test timeout to 30 seconds
// This can be overridden in individual tests if needed
jest.setTimeout(30000);
process.env.OPENAI_API_KEY = 'test';