Compare commits
17 Commits
feat/group
...
Fev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bef5c26bed | ||
|
|
9e03fef9db | ||
|
|
283c9cff6f | ||
|
|
0aafdc0a86 | ||
|
|
365e3bca95 | ||
|
|
a01536ddb7 | ||
|
|
8a3ff62ee6 | ||
|
|
74d8a3824c | ||
|
|
62c3f135e7 | ||
|
|
baf3b4ad08 | ||
|
|
e5d08ccdf1 | ||
|
|
5178507b1c | ||
|
|
f797e90d79 | ||
|
|
259224d986 | ||
|
|
13789ab261 | ||
|
|
faaba30af1 | ||
|
|
14660d75ae |
15
.env.example
15
.env.example
@@ -490,21 +490,6 @@ SAML_IMAGE_URL=
|
||||
# SAML_USE_AUTHN_RESPONSE_SIGNED=
|
||||
|
||||
|
||||
#===============================================#
|
||||
# Microsoft Graph API / Entra ID Integration #
|
||||
#===============================================#
|
||||
|
||||
# Enable Entra ID people search integration in permissions/sharing system
|
||||
# When enabled, the people picker will search both local database and Entra ID
|
||||
USE_ENTRA_ID_FOR_PEOPLE_SEARCH=false
|
||||
|
||||
# When enabled, entra id groups owners will be considered as members of the group
|
||||
ENTRA_ID_INCLUDE_OWNERS_AS_MEMBERS=false
|
||||
|
||||
# Microsoft Graph API scopes needed for people/group search
|
||||
# Default scopes provide access to user profiles and group memberships
|
||||
OPENID_GRAPH_SCOPES=User.Read,People.Read,GroupMember.Read.All
|
||||
|
||||
# LDAP
|
||||
LDAP_URL=
|
||||
LDAP_BIND_DN=
|
||||
|
||||
2
.github/workflows/backend-review.yml
vendored
2
.github/workflows/backend-review.yml
vendored
@@ -7,7 +7,7 @@ on:
|
||||
- release/*
|
||||
paths:
|
||||
- 'api/**'
|
||||
- 'packages/api/**'
|
||||
- 'packages/**'
|
||||
jobs:
|
||||
tests_Backend:
|
||||
name: Run Backend unit tests
|
||||
|
||||
2
.github/workflows/frontend-review.yml
vendored
2
.github/workflows/frontend-review.yml
vendored
@@ -8,7 +8,7 @@ on:
|
||||
- release/*
|
||||
paths:
|
||||
- 'client/**'
|
||||
- 'packages/**'
|
||||
- 'packages/data-provider/**'
|
||||
|
||||
jobs:
|
||||
tests_frontend_ubuntu:
|
||||
|
||||
3
.vscode/launch.json
vendored
3
.vscode/launch.json
vendored
@@ -8,8 +8,7 @@
|
||||
"skipFiles": ["<node_internals>/**"],
|
||||
"program": "${workspaceFolder}/api/server/index.js",
|
||||
"env": {
|
||||
"NODE_ENV": "production",
|
||||
"NODE_TLS_REJECT_UNAUTHORIZED": "0"
|
||||
"NODE_ENV": "production"
|
||||
},
|
||||
"console": "integratedTerminal",
|
||||
"envFile": "${workspaceFolder}/.env"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# v0.7.9-rc1
|
||||
# v0.7.9
|
||||
|
||||
# Base node image
|
||||
FROM node:20-alpine AS node
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# Dockerfile.multi
|
||||
# v0.7.9-rc1
|
||||
# v0.7.9
|
||||
|
||||
# Base for all builds
|
||||
FROM node:20-alpine AS base-min
|
||||
|
||||
@@ -3,7 +3,6 @@ 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');
|
||||
|
||||
@@ -23,19 +22,14 @@ 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 ?? [];
|
||||
|
||||
// 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 dbFiles = (
|
||||
(await getFiles(
|
||||
{ file_id: { $in: file_ids } },
|
||||
null,
|
||||
{ text: 0 },
|
||||
{ userId: req?.user?.id, agentId },
|
||||
)) ?? []
|
||||
).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.`;
|
||||
|
||||
|
||||
@@ -230,7 +230,7 @@ const loadTools = async ({
|
||||
|
||||
/** @type {Record<string, string>} */
|
||||
const toolContextMap = {};
|
||||
const appTools = (await getCachedTools({ includeGlobal: true })) ?? {};
|
||||
const cachedTools = (await getCachedTools({ userId: user, includeGlobal: true })) ?? {};
|
||||
|
||||
for (const tool of tools) {
|
||||
if (tool === Tools.execute_code) {
|
||||
@@ -298,7 +298,7 @@ Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
|
||||
});
|
||||
};
|
||||
continue;
|
||||
} else if (tool && appTools[tool] && mcpToolPattern.test(tool)) {
|
||||
} else if (tool && cachedTools && mcpToolPattern.test(tool)) {
|
||||
requestedTools[tool] = async () =>
|
||||
createMCPTool({
|
||||
req: options.req,
|
||||
|
||||
@@ -3,7 +3,6 @@ module.exports = {
|
||||
clearMocks: true,
|
||||
roots: ['<rootDir>'],
|
||||
coverageDirectory: 'coverage',
|
||||
testTimeout: 30000, // 30 seconds timeout for all tests
|
||||
setupFiles: [
|
||||
'./test/jestSetup.js',
|
||||
'./test/__mocks__/logger.js',
|
||||
|
||||
@@ -4,7 +4,7 @@ const { logger } = require('@librechat/data-schemas');
|
||||
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 { CONFIG_STORE, STARTUP_CONFIG } = require('librechat-data-provider').CacheKeys;
|
||||
const {
|
||||
getProjectByName,
|
||||
addAgentIdsToProject,
|
||||
@@ -12,9 +12,7 @@ const {
|
||||
removeAgentFromAllProjects,
|
||||
} = require('./Project');
|
||||
const { getCachedTools } = require('~/server/services/Config');
|
||||
|
||||
// Category values are now imported from shared constants
|
||||
// Schema fields (category, support_contact, is_promoted) are defined in @librechat/data-schemas
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const { getActions } = require('./Action');
|
||||
const { Agent } = require('~/db/models');
|
||||
|
||||
@@ -25,7 +23,7 @@ const { Agent } = require('~/db/models');
|
||||
* @throws {Error} If the agent creation fails.
|
||||
*/
|
||||
const createAgent = async (agentData) => {
|
||||
const { author: _author, ...versionData } = agentData;
|
||||
const { author, ...versionData } = agentData;
|
||||
const timestamp = new Date();
|
||||
const initialAgentData = {
|
||||
...agentData,
|
||||
@@ -36,9 +34,7 @@ const createAgent = async (agentData) => {
|
||||
updatedAt: timestamp,
|
||||
},
|
||||
],
|
||||
category: agentData.category || 'general',
|
||||
};
|
||||
|
||||
return (await Agent.create(initialAgentData)).toObject();
|
||||
};
|
||||
|
||||
@@ -65,7 +61,7 @@ const getAgent = async (searchParameter) => await Agent.findOne(searchParameter)
|
||||
const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _m }) => {
|
||||
const { model, ...model_parameters } = _m;
|
||||
/** @type {Record<string, FunctionTool>} */
|
||||
const availableTools = await getCachedTools({ includeGlobal: true });
|
||||
const availableTools = await getCachedTools({ userId: req.user.id, includeGlobal: true });
|
||||
/** @type {TEphemeralAgent | null} */
|
||||
const ephemeralAgent = req.body.ephemeralAgent;
|
||||
const mcpServers = new Set(ephemeralAgent?.mcp);
|
||||
@@ -135,7 +131,29 @@ const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => {
|
||||
}
|
||||
|
||||
agent.version = agent.versions ? agent.versions.length : 0;
|
||||
return agent;
|
||||
|
||||
if (agent.author.toString() === req.user.id) {
|
||||
return agent;
|
||||
}
|
||||
|
||||
if (!agent.projectIds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cache = getLogStores(CONFIG_STORE);
|
||||
/** @type {TStartupConfig} */
|
||||
const cachedStartupConfig = await cache.get(STARTUP_CONFIG);
|
||||
let { instanceProjectId } = cachedStartupConfig ?? {};
|
||||
if (!instanceProjectId) {
|
||||
instanceProjectId = (await getProjectByName(GLOBAL_PROJECT_NAME, '_id'))._id.toString();
|
||||
}
|
||||
|
||||
for (const projectObjectId of agent.projectIds) {
|
||||
const projectId = projectObjectId.toString();
|
||||
if (projectId === instanceProjectId) {
|
||||
return agent;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -165,7 +183,7 @@ const isDuplicateVersion = (updateData, currentData, versions, actionsHash = nul
|
||||
'actionsHash', // Exclude actionsHash from direct comparison
|
||||
];
|
||||
|
||||
const { $push: _$push, $pull: _$pull, $addToSet: _$addToSet, ...directUpdates } = updateData;
|
||||
const { $push, $pull, $addToSet, ...directUpdates } = updateData;
|
||||
|
||||
if (Object.keys(directUpdates).length === 0 && !actionsHash) {
|
||||
return null;
|
||||
@@ -184,116 +202,54 @@ const isDuplicateVersion = (updateData, currentData, versions, actionsHash = nul
|
||||
|
||||
let isMatch = true;
|
||||
for (const field of importantFields) {
|
||||
const wouldBeValue = wouldBeVersion[field];
|
||||
const lastVersionValue = lastVersion[field];
|
||||
|
||||
// Skip if both are undefined/null
|
||||
if (!wouldBeValue && !lastVersionValue) {
|
||||
if (!wouldBeVersion[field] && !lastVersion[field]) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 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) {
|
||||
if (Array.isArray(wouldBeVersion[field]) && Array.isArray(lastVersion[field])) {
|
||||
if (wouldBeVersion[field].length !== lastVersion[field].length) {
|
||||
isMatch = false;
|
||||
break;
|
||||
}
|
||||
|
||||
// Special handling for projectIds (MongoDB ObjectIds)
|
||||
if (field === 'projectIds') {
|
||||
const wouldBeIds = wouldBeArr.map((id) => id.toString()).sort();
|
||||
const versionIds = lastVersionArr.map((id) => id.toString()).sort();
|
||||
const wouldBeIds = wouldBeVersion[field].map((id) => id.toString()).sort();
|
||||
const versionIds = lastVersion[field].map((id) => id.toString()).sort();
|
||||
|
||||
if (!wouldBeIds.every((id, i) => id === versionIds[i])) {
|
||||
isMatch = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// 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();
|
||||
// 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();
|
||||
|
||||
if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) {
|
||||
isMatch = false;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
const sortedWouldBe = [...wouldBeArr].sort();
|
||||
const sortedVersion = [...lastVersionArr].sort();
|
||||
const sortedWouldBe = [...wouldBeVersion[field]].sort();
|
||||
const sortedVersion = [...lastVersion[field]].sort();
|
||||
|
||||
if (!sortedWouldBe.every((item, i) => item === sortedVersion[i])) {
|
||||
isMatch = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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;
|
||||
}
|
||||
} else if (field === 'model_parameters') {
|
||||
const wouldBeParams = wouldBeVersion[field] || {};
|
||||
const lastVersionParams = lastVersion[field] || {};
|
||||
if (JSON.stringify(wouldBeParams) !== JSON.stringify(lastVersionParams)) {
|
||||
isMatch = false;
|
||||
break;
|
||||
}
|
||||
} else if (wouldBeVersion[field] !== lastVersion[field]) {
|
||||
isMatch = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -322,14 +278,7 @@ const updateAgent = async (searchParameter, updateData, options = {}) => {
|
||||
|
||||
const currentAgent = await Agent.findOne(searchParameter);
|
||||
if (currentAgent) {
|
||||
const {
|
||||
__v,
|
||||
_id,
|
||||
id: __id,
|
||||
versions,
|
||||
author: _author,
|
||||
...versionData
|
||||
} = currentAgent.toObject();
|
||||
const { __v, _id, id, versions, author, ...versionData } = currentAgent.toObject();
|
||||
const { $push, $pull, $addToSet, ...directUpdates } = updateData;
|
||||
|
||||
let actionsHash = null;
|
||||
@@ -520,113 +469,8 @@ const deleteAgent = async (searchParameter) => {
|
||||
return agent;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get agents by accessible IDs with optional cursor-based pagination.
|
||||
* @param {Object} params - The parameters for getting accessible agents.
|
||||
* @param {Array} [params.accessibleIds] - Array of agent ObjectIds the user has ACL access to.
|
||||
* @param {Object} [params.otherParams] - Additional query parameters (including author filter).
|
||||
* @param {number} [params.limit] - Number of agents to return (max 100). If not provided, returns all agents.
|
||||
* @param {string} [params.after] - Cursor for pagination - get agents after this cursor. // base64 encoded JSON string with updatedAt and _id.
|
||||
* @returns {Promise<Object>} A promise that resolves to an object containing the agents data and pagination info.
|
||||
*/
|
||||
const getListAgentsByAccess = async ({
|
||||
accessibleIds = [],
|
||||
otherParams = {},
|
||||
limit = null,
|
||||
after = null,
|
||||
}) => {
|
||||
const isPaginated = limit !== null && limit !== undefined;
|
||||
const normalizedLimit = isPaginated ? Math.min(Math.max(1, parseInt(limit) || 20), 100) : null;
|
||||
|
||||
// Build base query combining ACL accessible agents with other filters
|
||||
const baseQuery = { ...otherParams };
|
||||
|
||||
if (accessibleIds.length > 0) {
|
||||
baseQuery._id = { $in: accessibleIds };
|
||||
}
|
||||
|
||||
// Add cursor condition
|
||||
if (after) {
|
||||
try {
|
||||
const cursor = JSON.parse(Buffer.from(after, 'base64').toString('utf8'));
|
||||
const { updatedAt, _id } = cursor;
|
||||
|
||||
const cursorCondition = {
|
||||
$or: [
|
||||
{ updatedAt: { $lt: new Date(updatedAt) } },
|
||||
{ updatedAt: new Date(updatedAt), _id: { $gt: new mongoose.Types.ObjectId(_id) } },
|
||||
],
|
||||
};
|
||||
|
||||
// Merge cursor condition with base query
|
||||
if (Object.keys(baseQuery).length > 0) {
|
||||
baseQuery.$and = [{ ...baseQuery }, cursorCondition];
|
||||
// Remove the original conditions from baseQuery to avoid duplication
|
||||
Object.keys(baseQuery).forEach((key) => {
|
||||
if (key !== '$and') delete baseQuery[key];
|
||||
});
|
||||
} else {
|
||||
Object.assign(baseQuery, cursorCondition);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Invalid cursor:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
let query = Agent.find(baseQuery, {
|
||||
id: 1,
|
||||
_id: 1,
|
||||
name: 1,
|
||||
avatar: 1,
|
||||
author: 1,
|
||||
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
|
||||
if (isPaginated) {
|
||||
query = query.limit(normalizedLimit + 1);
|
||||
}
|
||||
|
||||
const agents = await query.lean();
|
||||
|
||||
const hasMore = isPaginated ? agents.length > normalizedLimit : false;
|
||||
const data = (isPaginated ? agents.slice(0, normalizedLimit) : agents).map((agent) => {
|
||||
if (agent.author) {
|
||||
agent.author = agent.author.toString();
|
||||
}
|
||||
return agent;
|
||||
});
|
||||
|
||||
// Generate next cursor only if paginated
|
||||
let nextCursor = null;
|
||||
if (isPaginated && hasMore && data.length > 0) {
|
||||
const lastAgent = agents[normalizedLimit - 1];
|
||||
nextCursor = Buffer.from(
|
||||
JSON.stringify({
|
||||
updatedAt: lastAgent.updatedAt.toISOString(),
|
||||
_id: lastAgent._id.toString(),
|
||||
}),
|
||||
).toString('base64');
|
||||
}
|
||||
|
||||
return {
|
||||
object: 'list',
|
||||
data,
|
||||
first_id: data.length > 0 ? data[0].id : null,
|
||||
last_id: data.length > 0 ? data[data.length - 1].id : null,
|
||||
has_more: hasMore,
|
||||
after: nextCursor,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all agents.
|
||||
* @deprecated Use getListAgentsByAccess for ACL-aware agent listing
|
||||
* @param {Object} searchParameter - The search parameters to find matching agents.
|
||||
* @param {string} searchParameter.author - The user ID of the agent's author.
|
||||
* @returns {Promise<Object>} A promise that resolves to an object containing the agents data and pagination info.
|
||||
@@ -645,15 +489,13 @@ const getListAgents = async (searchParameter) => {
|
||||
const agents = (
|
||||
await Agent.find(query, {
|
||||
id: 1,
|
||||
_id: 1,
|
||||
_id: 0,
|
||||
name: 1,
|
||||
avatar: 1,
|
||||
author: 1,
|
||||
projectIds: 1,
|
||||
description: 1,
|
||||
// @deprecated - isCollaborative replaced by ACL permissions
|
||||
isCollaborative: 1,
|
||||
category: 1,
|
||||
}).lean()
|
||||
).map((agent) => {
|
||||
if (agent.author?.toString() !== author) {
|
||||
@@ -819,14 +661,6 @@ 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
|
||||
@@ -844,8 +678,6 @@ module.exports = {
|
||||
revertAgentVersion,
|
||||
updateAgentProjects,
|
||||
addAgentResourceFile,
|
||||
getListAgentsByAccess,
|
||||
removeAgentResourceFiles,
|
||||
generateActionMetadataHash,
|
||||
countPromotedAgents,
|
||||
};
|
||||
|
||||
@@ -1258,328 +1258,6 @@ 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()}`;
|
||||
@@ -1955,7 +1633,7 @@ describe('models/Agent', () => {
|
||||
expect(result.version).toBe(1);
|
||||
});
|
||||
|
||||
test('should return agent even when user is not author (permissions checked at route level)', async () => {
|
||||
test('should return null when user is not author and agent has no projectIds', async () => {
|
||||
const authorId = new mongoose.Types.ObjectId();
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const agentId = `agent_${uuidv4()}`;
|
||||
@@ -1976,11 +1654,7 @@ describe('models/Agent', () => {
|
||||
model_parameters: { model: 'gpt-4' },
|
||||
});
|
||||
|
||||
// With the new permission system, loadAgent returns the agent regardless of permissions
|
||||
// Permission checks are handled at the route level via middleware
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.id).toBe(agentId);
|
||||
expect(result.name).toBe('Test Agent');
|
||||
expect(result).toBeFalsy();
|
||||
});
|
||||
|
||||
test('should handle ephemeral agent with no MCP servers', async () => {
|
||||
@@ -2088,7 +1762,7 @@ describe('models/Agent', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('should return agent from different project (permissions checked at route level)', async () => {
|
||||
test('should handle loadAgent with agent from different project', async () => {
|
||||
const authorId = new mongoose.Types.ObjectId();
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const agentId = `agent_${uuidv4()}`;
|
||||
@@ -2111,11 +1785,7 @@ describe('models/Agent', () => {
|
||||
model_parameters: { model: 'gpt-4' },
|
||||
});
|
||||
|
||||
// With the new permission system, loadAgent returns the agent regardless of permissions
|
||||
// Permission checks are handled at the route level via middleware
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.id).toBe(agentId);
|
||||
expect(result.name).toBe('Project Agent');
|
||||
expect(result).toBeFalsy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2900,93 +2570,6 @@ 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()}`,
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { EToolResources, FileContext } = require('librechat-data-provider');
|
||||
const { EToolResources, FileContext, Constants } = require('librechat-data-provider');
|
||||
const { getProjectByName } = require('./Project');
|
||||
const { getAgent } = require('./Agent');
|
||||
const { File } = require('~/db/models');
|
||||
|
||||
/**
|
||||
@@ -12,17 +14,124 @@ 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 }) => {
|
||||
const getFiles = async (filter, _sortOptions, selectFields = { text: 0 }, options = {}) => {
|
||||
const sortOptions = { updatedAt: -1, ..._sortOptions };
|
||||
return await File.find(filter).select(selectFields).sort(sortOptions).lean();
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -176,4 +285,5 @@ module.exports = {
|
||||
deleteFiles,
|
||||
deleteFileByFilter,
|
||||
batchUpdateFiles,
|
||||
hasAccessToFilesViaAgent,
|
||||
};
|
||||
|
||||
@@ -1,18 +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 { createModels } = require('@librechat/data-schemas');
|
||||
const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants;
|
||||
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 AclEntry;
|
||||
let User;
|
||||
let AccessRole;
|
||||
let modelsToCleanup = [];
|
||||
let Project;
|
||||
|
||||
describe('File Access Control', () => {
|
||||
let mongoServer;
|
||||
@@ -20,42 +19,13 @@ 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;
|
||||
AccessRole = dbModels.AccessRole;
|
||||
|
||||
// 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();
|
||||
});
|
||||
@@ -63,33 +33,16 @@ describe('File Access Control', () => {
|
||||
beforeEach(async () => {
|
||||
await File.deleteMany({});
|
||||
await Agent.deleteMany({});
|
||||
await AclEntry.deleteMany({});
|
||||
await User.deleteMany({});
|
||||
// Don't delete AccessRole as they are seeded defaults needed for tests
|
||||
await Project.deleteMany({});
|
||||
});
|
||||
|
||||
describe('hasAccessToFilesViaAgent', () => {
|
||||
it('should efficiently check access for multiple files at once', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const authorId = new mongoose.Types.ObjectId();
|
||||
const userId = new mongoose.Types.ObjectId().toString();
|
||||
const authorId = new mongoose.Types.ObjectId().toString();
|
||||
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({
|
||||
@@ -101,12 +54,13 @@ describe('File Access Control', () => {
|
||||
}
|
||||
|
||||
// Create agent with only first two files attached
|
||||
const agent = await createAgent({
|
||||
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]],
|
||||
@@ -114,19 +68,15 @@ 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,
|
||||
});
|
||||
// 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 } });
|
||||
|
||||
// Check access for all files
|
||||
const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions');
|
||||
const accessMap = await hasAccessToFilesViaAgent(userId.toString(), fileIds, agentId);
|
||||
const { hasAccessToFilesViaAgent } = require('./File');
|
||||
const accessMap = await hasAccessToFilesViaAgent(userId, fileIds, agentId);
|
||||
|
||||
// Should have access only to the first two files
|
||||
expect(accessMap.get(fileIds[0])).toBe(true);
|
||||
@@ -136,20 +86,12 @@ describe('File Access Control', () => {
|
||||
});
|
||||
|
||||
it('should grant access to all files when user is the agent author', async () => {
|
||||
const authorId = new mongoose.Types.ObjectId();
|
||||
const authorId = new mongoose.Types.ObjectId().toString();
|
||||
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
|
||||
const agent = await createAgent({
|
||||
await createAgent({
|
||||
id: agentId,
|
||||
name: 'Test Agent',
|
||||
author: authorId,
|
||||
@@ -163,8 +105,8 @@ describe('File Access Control', () => {
|
||||
});
|
||||
|
||||
// Check access as the author
|
||||
const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions');
|
||||
const accessMap = await hasAccessToFilesViaAgent(authorId.toString(), fileIds, agentId);
|
||||
const { hasAccessToFilesViaAgent } = require('./File');
|
||||
const accessMap = await hasAccessToFilesViaAgent(authorId, fileIds, agentId);
|
||||
|
||||
// Author should have access to all files
|
||||
expect(accessMap.get(fileIds[0])).toBe(true);
|
||||
@@ -173,57 +115,31 @@ describe('File Access Control', () => {
|
||||
});
|
||||
|
||||
it('should handle non-existent agent gracefully', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const userId = new mongoose.Types.ObjectId().toString();
|
||||
const fileIds = [uuidv4(), uuidv4()];
|
||||
|
||||
// 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',
|
||||
);
|
||||
const { hasAccessToFilesViaAgent } = require('./File');
|
||||
const accessMap = await hasAccessToFilesViaAgent(userId, 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 user only has VIEW permission', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const authorId = new mongoose.Types.ObjectId();
|
||||
it('should deny access when agent is not collaborative', async () => {
|
||||
const userId = new mongoose.Types.ObjectId().toString();
|
||||
const authorId = new mongoose.Types.ObjectId().toString();
|
||||
const agentId = uuidv4();
|
||||
const fileIds = [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 agent with files
|
||||
const agent = await createAgent({
|
||||
// Create agent with files but isCollaborative: false
|
||||
await createAgent({
|
||||
id: agentId,
|
||||
name: 'View-Only Agent',
|
||||
name: 'Non-Collaborative Agent',
|
||||
author: authorId,
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
isCollaborative: false,
|
||||
tool_resources: {
|
||||
file_search: {
|
||||
file_ids: fileIds,
|
||||
@@ -231,21 +147,17 @@ describe('File Access Control', () => {
|
||||
},
|
||||
});
|
||||
|
||||
// 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,
|
||||
});
|
||||
// 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 } });
|
||||
|
||||
// Check access for files
|
||||
const { hasAccessToFilesViaAgent } = require('~/server/services/Files/permissions');
|
||||
const accessMap = await hasAccessToFilesViaAgent(userId.toString(), fileIds, agentId);
|
||||
const { hasAccessToFilesViaAgent } = require('./File');
|
||||
const accessMap = await hasAccessToFilesViaAgent(userId, fileIds, agentId);
|
||||
|
||||
// Should have no access to any files when only VIEW permission
|
||||
// Should have no access to any files when isCollaborative is false
|
||||
expect(accessMap.get(fileIds[0])).toBe(false);
|
||||
expect(accessMap.get(fileIds[1])).toBe(false);
|
||||
});
|
||||
@@ -260,28 +172,18 @@ describe('File Access Control', () => {
|
||||
const sharedFileId = `file_${uuidv4()}`;
|
||||
const inaccessibleFileId = `file_${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/get global project using getProjectByName which will upsert
|
||||
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME);
|
||||
|
||||
// Create agent with shared file
|
||||
const agent = await createAgent({
|
||||
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],
|
||||
@@ -289,16 +191,6 @@ 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,
|
||||
@@ -328,17 +220,14 @@ describe('File Access Control', () => {
|
||||
bytes: 300,
|
||||
});
|
||||
|
||||
// Get all files first
|
||||
const allFiles = await getFiles(
|
||||
// Get files with access control
|
||||
const files = 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);
|
||||
|
||||
@@ -2,6 +2,7 @@ const {
|
||||
CacheKeys,
|
||||
SystemRoles,
|
||||
roleDefaults,
|
||||
PermissionTypes,
|
||||
permissionsSchema,
|
||||
removeNullishValues,
|
||||
} = require('librechat-data-provider');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@librechat/backend",
|
||||
"version": "v0.7.9-rc1",
|
||||
"version": "v0.7.9",
|
||||
"description": "",
|
||||
"scripts": {
|
||||
"start": "echo 'please run this from the root directory'",
|
||||
@@ -49,11 +49,10 @@
|
||||
"@langchain/google-vertexai": "^0.2.13",
|
||||
"@langchain/openai": "^0.5.18",
|
||||
"@langchain/textsplitters": "^0.1.0",
|
||||
"@librechat/agents": "^2.4.63",
|
||||
"@librechat/agents": "^2.4.67",
|
||||
"@librechat/api": "*",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@node-saml/passport-saml": "^5.0.0",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
"@waylaidwanderer/fetch-event-source": "^3.0.1",
|
||||
"axios": "^1.8.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
@@ -72,6 +71,7 @@
|
||||
"express-static-gzip": "^2.2.0",
|
||||
"file-type": "^18.7.0",
|
||||
"firebase": "^11.0.2",
|
||||
"form-data": "^4.0.4",
|
||||
"googleapis": "^126.0.1",
|
||||
"handlebars": "^4.7.7",
|
||||
"https-proxy-agent": "^7.0.6",
|
||||
@@ -94,7 +94,7 @@
|
||||
"node-fetch": "^2.7.0",
|
||||
"nodemailer": "^6.9.15",
|
||||
"ollama": "^0.5.0",
|
||||
"openai": "^4.96.2",
|
||||
"openai": "^5.10.1",
|
||||
"openai-chat-tokens": "^0.2.8",
|
||||
"openid-client": "^6.5.0",
|
||||
"passport": "^0.6.0",
|
||||
|
||||
@@ -1,437 +0,0 @@
|
||||
/**
|
||||
* @import { TUpdateResourcePermissionsRequest, TUpdateResourcePermissionsResponse } from 'librechat-data-provider'
|
||||
*/
|
||||
|
||||
const mongoose = require('mongoose');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const {
|
||||
getAvailableRoles,
|
||||
ensurePrincipalExists,
|
||||
getEffectivePermissions,
|
||||
ensureGroupPrincipalExists,
|
||||
bulkUpdateResourcePermissions,
|
||||
} = require('~/server/services/PermissionService');
|
||||
const { AclEntry } = require('~/db/models');
|
||||
const {
|
||||
searchPrincipals: searchLocalPrincipals,
|
||||
sortPrincipalsByRelevance,
|
||||
calculateRelevanceScore,
|
||||
} = require('~/models');
|
||||
const {
|
||||
searchEntraIdPrincipals,
|
||||
entraIdPrincipalFeatureEnabled,
|
||||
} = require('~/server/services/GraphApiService');
|
||||
|
||||
/**
|
||||
* Generic controller for resource permission endpoints
|
||||
* Delegates validation and logic to PermissionService
|
||||
*/
|
||||
|
||||
/**
|
||||
* Bulk update permissions for a resource (grant, update, remove)
|
||||
* @route PUT /api/{resourceType}/{resourceId}/permissions
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} req.params - Route parameters
|
||||
* @param {string} req.params.resourceType - Resource type (e.g., 'agent')
|
||||
* @param {string} req.params.resourceId - Resource ID
|
||||
* @param {TUpdateResourcePermissionsRequest} req.body - Request body
|
||||
* @param {Object} res - Express response object
|
||||
* @returns {Promise<TUpdateResourcePermissionsResponse>} Updated permissions response
|
||||
*/
|
||||
const updateResourcePermissions = async (req, res) => {
|
||||
try {
|
||||
const { resourceType, resourceId } = req.params;
|
||||
/** @type {TUpdateResourcePermissionsRequest} */
|
||||
const { updated, removed, public: isPublic, publicAccessRoleId } = req.body;
|
||||
const { id: userId } = req.user;
|
||||
|
||||
// Prepare principals for the service call
|
||||
const updatedPrincipals = [];
|
||||
const revokedPrincipals = [];
|
||||
|
||||
// Add updated principals
|
||||
if (updated && Array.isArray(updated)) {
|
||||
updatedPrincipals.push(...updated);
|
||||
}
|
||||
|
||||
// Add public permission if enabled
|
||||
if (isPublic && publicAccessRoleId) {
|
||||
updatedPrincipals.push({
|
||||
type: 'public',
|
||||
id: null,
|
||||
accessRoleId: publicAccessRoleId,
|
||||
});
|
||||
}
|
||||
|
||||
// Prepare authentication context for enhanced group member fetching
|
||||
const useEntraId = entraIdPrincipalFeatureEnabled(req.user);
|
||||
const authHeader = req.headers.authorization;
|
||||
const accessToken =
|
||||
authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : null;
|
||||
const authContext =
|
||||
useEntraId && accessToken
|
||||
? {
|
||||
accessToken,
|
||||
sub: req.user.openidId,
|
||||
}
|
||||
: null;
|
||||
|
||||
// Ensure updated principals exist in the database before processing permissions
|
||||
const validatedPrincipals = [];
|
||||
for (const principal of updatedPrincipals) {
|
||||
try {
|
||||
let principalId;
|
||||
|
||||
if (principal.type === 'public') {
|
||||
principalId = null; // Public principals don't need database records
|
||||
} else if (principal.type === 'user') {
|
||||
principalId = await ensurePrincipalExists(principal);
|
||||
} else if (principal.type === 'group') {
|
||||
// Pass authContext to enable member fetching for Entra ID groups when available
|
||||
principalId = await ensureGroupPrincipalExists(principal, authContext);
|
||||
} else {
|
||||
logger.error(`Unsupported principal type: ${principal.type}`);
|
||||
continue; // Skip invalid principal types
|
||||
}
|
||||
|
||||
// Update the principal with the validated ID for ACL operations
|
||||
validatedPrincipals.push({
|
||||
...principal,
|
||||
id: principalId,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error ensuring principal exists:', {
|
||||
principal: {
|
||||
type: principal.type,
|
||||
id: principal.id,
|
||||
name: principal.name,
|
||||
source: principal.source,
|
||||
},
|
||||
error: error.message,
|
||||
});
|
||||
// Continue with other principals instead of failing the entire operation
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Add removed principals
|
||||
if (removed && Array.isArray(removed)) {
|
||||
revokedPrincipals.push(...removed);
|
||||
}
|
||||
|
||||
// If public is disabled, add public to revoked list
|
||||
if (!isPublic) {
|
||||
revokedPrincipals.push({
|
||||
type: 'public',
|
||||
id: null,
|
||||
});
|
||||
}
|
||||
|
||||
const results = await bulkUpdateResourcePermissions({
|
||||
resourceType,
|
||||
resourceId,
|
||||
updatedPrincipals: validatedPrincipals,
|
||||
revokedPrincipals,
|
||||
grantedBy: userId,
|
||||
});
|
||||
|
||||
/** @type {TUpdateResourcePermissionsResponse} */
|
||||
const response = {
|
||||
message: 'Permissions updated successfully',
|
||||
results: {
|
||||
principals: results.granted,
|
||||
public: isPublic || false,
|
||||
publicAccessRoleId: isPublic ? publicAccessRoleId : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error('Error updating resource permissions:', error);
|
||||
res.status(400).json({
|
||||
error: 'Failed to update permissions',
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get principals with their permission roles for a resource (UI-friendly format)
|
||||
* Uses efficient aggregation pipeline to join User/Group data in single query
|
||||
* @route GET /api/permissions/{resourceType}/{resourceId}
|
||||
*/
|
||||
const getResourcePermissions = async (req, res) => {
|
||||
try {
|
||||
const { resourceType, resourceId } = req.params;
|
||||
|
||||
// Use aggregation pipeline for efficient single-query data retrieval
|
||||
const results = await AclEntry.aggregate([
|
||||
// Match ACL entries for this resource
|
||||
{
|
||||
$match: {
|
||||
resourceType,
|
||||
resourceId: mongoose.Types.ObjectId.isValid(resourceId)
|
||||
? mongoose.Types.ObjectId.createFromHexString(resourceId)
|
||||
: resourceId,
|
||||
},
|
||||
},
|
||||
// Lookup AccessRole information
|
||||
{
|
||||
$lookup: {
|
||||
from: 'accessroles',
|
||||
localField: 'roleId',
|
||||
foreignField: '_id',
|
||||
as: 'role',
|
||||
},
|
||||
},
|
||||
// Lookup User information (for user principals)
|
||||
{
|
||||
$lookup: {
|
||||
from: 'users',
|
||||
localField: 'principalId',
|
||||
foreignField: '_id',
|
||||
as: 'userInfo',
|
||||
},
|
||||
},
|
||||
// Lookup Group information (for group principals)
|
||||
{
|
||||
$lookup: {
|
||||
from: 'groups',
|
||||
localField: 'principalId',
|
||||
foreignField: '_id',
|
||||
as: 'groupInfo',
|
||||
},
|
||||
},
|
||||
// Project final structure
|
||||
{
|
||||
$project: {
|
||||
principalType: 1,
|
||||
principalId: 1,
|
||||
accessRoleId: { $arrayElemAt: ['$role.accessRoleId', 0] },
|
||||
userInfo: { $arrayElemAt: ['$userInfo', 0] },
|
||||
groupInfo: { $arrayElemAt: ['$groupInfo', 0] },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const principals = [];
|
||||
let publicPermission = null;
|
||||
|
||||
// Process aggregation results
|
||||
for (const result of results) {
|
||||
if (result.principalType === 'public') {
|
||||
publicPermission = {
|
||||
public: true,
|
||||
publicAccessRoleId: result.accessRoleId,
|
||||
};
|
||||
} else if (result.principalType === 'user' && result.userInfo) {
|
||||
principals.push({
|
||||
type: 'user',
|
||||
id: result.userInfo._id.toString(),
|
||||
name: result.userInfo.name || result.userInfo.username,
|
||||
email: result.userInfo.email,
|
||||
avatar: result.userInfo.avatar,
|
||||
source: !result.userInfo._id ? 'entra' : 'local',
|
||||
idOnTheSource: result.userInfo.idOnTheSource || result.userInfo._id.toString(),
|
||||
accessRoleId: result.accessRoleId,
|
||||
});
|
||||
} else if (result.principalType === 'group' && result.groupInfo) {
|
||||
principals.push({
|
||||
type: 'group',
|
||||
id: result.groupInfo._id.toString(),
|
||||
name: result.groupInfo.name,
|
||||
email: result.groupInfo.email,
|
||||
description: result.groupInfo.description,
|
||||
avatar: result.groupInfo.avatar,
|
||||
source: result.groupInfo.source || 'local',
|
||||
idOnTheSource: result.groupInfo.idOnTheSource || result.groupInfo._id.toString(),
|
||||
accessRoleId: result.accessRoleId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Return response in format expected by frontend
|
||||
const response = {
|
||||
resourceType,
|
||||
resourceId,
|
||||
principals,
|
||||
public: publicPermission?.public || false,
|
||||
...(publicPermission?.publicAccessRoleId && {
|
||||
publicAccessRoleId: publicPermission.publicAccessRoleId,
|
||||
}),
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error('Error getting resource permissions principals:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to get permissions principals',
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get available roles for a resource type
|
||||
* @route GET /api/{resourceType}/roles
|
||||
*/
|
||||
const getResourceRoles = async (req, res) => {
|
||||
try {
|
||||
const { resourceType } = req.params;
|
||||
|
||||
const roles = await getAvailableRoles({ resourceType });
|
||||
|
||||
res.status(200).json(
|
||||
roles.map((role) => ({
|
||||
accessRoleId: role.accessRoleId,
|
||||
name: role.name,
|
||||
description: role.description,
|
||||
permBits: role.permBits,
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Error getting resource roles:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to get roles',
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get user's effective permission bitmask for a resource
|
||||
* @route GET /api/{resourceType}/{resourceId}/effective
|
||||
*/
|
||||
const getUserEffectivePermissions = async (req, res) => {
|
||||
try {
|
||||
const { resourceType, resourceId } = req.params;
|
||||
const { id: userId } = req.user;
|
||||
|
||||
const permissionBits = await getEffectivePermissions({
|
||||
userId,
|
||||
resourceType,
|
||||
resourceId,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
permissionBits,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error getting user effective permissions:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to get effective permissions',
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Search for users and groups to grant permissions
|
||||
* Supports hybrid local database + Entra ID search when configured
|
||||
* @route GET /api/permissions/search-principals
|
||||
*/
|
||||
const searchPrincipals = async (req, res) => {
|
||||
try {
|
||||
const { q: query, limit = 20, type } = req.query;
|
||||
|
||||
if (!query || query.trim().length === 0) {
|
||||
return res.status(400).json({
|
||||
error: 'Query parameter "q" is required and must not be empty',
|
||||
});
|
||||
}
|
||||
|
||||
if (query.trim().length < 2) {
|
||||
return res.status(400).json({
|
||||
error: 'Query must be at least 2 characters long',
|
||||
});
|
||||
}
|
||||
|
||||
const searchLimit = Math.min(Math.max(1, parseInt(limit) || 10), 50);
|
||||
const typeFilter = ['user', 'group'].includes(type) ? type : null;
|
||||
|
||||
const localResults = await searchLocalPrincipals(query.trim(), searchLimit, typeFilter);
|
||||
let allPrincipals = [...localResults];
|
||||
|
||||
const useEntraId = entraIdPrincipalFeatureEnabled(req.user);
|
||||
|
||||
if (useEntraId && localResults.length < searchLimit) {
|
||||
try {
|
||||
const graphTypeMap = {
|
||||
user: 'users',
|
||||
group: 'groups',
|
||||
null: 'all',
|
||||
};
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
const accessToken =
|
||||
authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : null;
|
||||
|
||||
if (accessToken) {
|
||||
const graphResults = await searchEntraIdPrincipals(
|
||||
accessToken,
|
||||
req.user.openidId,
|
||||
query.trim(),
|
||||
graphTypeMap[typeFilter],
|
||||
searchLimit - localResults.length,
|
||||
);
|
||||
|
||||
const localEmails = new Set(
|
||||
localResults.map((p) => p.email?.toLowerCase()).filter(Boolean),
|
||||
);
|
||||
const localGroupSourceIds = new Set(
|
||||
localResults.map((p) => p.idOnTheSource).filter(Boolean),
|
||||
);
|
||||
|
||||
for (const principal of graphResults) {
|
||||
const isDuplicateByEmail =
|
||||
principal.email && localEmails.has(principal.email.toLowerCase());
|
||||
const isDuplicateBySourceId =
|
||||
principal.idOnTheSource && localGroupSourceIds.has(principal.idOnTheSource);
|
||||
|
||||
if (!isDuplicateByEmail && !isDuplicateBySourceId) {
|
||||
allPrincipals.push(principal);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (graphError) {
|
||||
logger.warn('Graph API search failed, falling back to local results:', graphError.message);
|
||||
}
|
||||
}
|
||||
const scoredResults = allPrincipals.map((item) => ({
|
||||
...item,
|
||||
_searchScore: calculateRelevanceScore(item, query.trim()),
|
||||
}));
|
||||
|
||||
allPrincipals = sortPrincipalsByRelevance(scoredResults)
|
||||
.slice(0, searchLimit)
|
||||
.map((result) => {
|
||||
const { _searchScore, ...resultWithoutScore } = result;
|
||||
return resultWithoutScore;
|
||||
});
|
||||
res.status(200).json({
|
||||
query: query.trim(),
|
||||
limit: searchLimit,
|
||||
type: typeFilter,
|
||||
results: allPrincipals,
|
||||
count: allPrincipals.length,
|
||||
sources: {
|
||||
local: allPrincipals.filter((r) => r.source === 'local').length,
|
||||
entra: allPrincipals.filter((r) => r.source === 'entra').length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error searching principals:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to search principals',
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
updateResourcePermissions,
|
||||
getResourcePermissions,
|
||||
getResourceRoles,
|
||||
getUserEffectivePermissions,
|
||||
searchPrincipals,
|
||||
};
|
||||
@@ -138,15 +138,21 @@ function createGetServerTools() {
|
||||
*/
|
||||
const getAvailableTools = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
const customConfig = await getCustomConfig();
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
const cachedToolsArray = await cache.get(CacheKeys.TOOLS);
|
||||
if (cachedToolsArray) {
|
||||
res.status(200).json(cachedToolsArray);
|
||||
const cachedUserTools = await getCachedTools({ userId });
|
||||
const userPlugins = convertMCPToolsToPlugins(cachedUserTools, customConfig);
|
||||
|
||||
if (cachedToolsArray && userPlugins) {
|
||||
const dedupedTools = filterUniquePlugins([...userPlugins, ...cachedToolsArray]);
|
||||
res.status(200).json(dedupedTools);
|
||||
return;
|
||||
}
|
||||
|
||||
// If not in cache, build from manifest
|
||||
let pluginManifest = availableTools;
|
||||
const customConfig = await getCustomConfig();
|
||||
if (customConfig?.mcpServers != null) {
|
||||
const mcpManager = getMCPManager();
|
||||
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
||||
@@ -217,16 +223,70 @@ const getAvailableTools = async (req, res) => {
|
||||
|
||||
toolsOutput.push(toolToAdd);
|
||||
}
|
||||
|
||||
const finalTools = filterUniquePlugins(toolsOutput);
|
||||
await cache.set(CacheKeys.TOOLS, finalTools);
|
||||
res.status(200).json(finalTools);
|
||||
|
||||
const dedupedTools = filterUniquePlugins([...userPlugins, ...finalTools]);
|
||||
|
||||
res.status(200).json(dedupedTools);
|
||||
} catch (error) {
|
||||
logger.error('[getAvailableTools]', error);
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Converts MCP function format tools to plugin format
|
||||
* @param {Object} functionTools - Object with function format tools
|
||||
* @param {Object} customConfig - Custom configuration for MCP servers
|
||||
* @returns {Array} Array of plugin objects
|
||||
*/
|
||||
function convertMCPToolsToPlugins(functionTools, customConfig) {
|
||||
const plugins = [];
|
||||
|
||||
for (const [toolKey, toolData] of Object.entries(functionTools)) {
|
||||
if (!toolData.function || !toolKey.includes(Constants.mcp_delimiter)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const functionData = toolData.function;
|
||||
const parts = toolKey.split(Constants.mcp_delimiter);
|
||||
const serverName = parts[parts.length - 1];
|
||||
|
||||
const serverConfig = customConfig?.mcpServers?.[serverName];
|
||||
|
||||
const plugin = {
|
||||
name: parts[0], // Use the tool name without server suffix
|
||||
pluginKey: toolKey,
|
||||
description: functionData.description || '',
|
||||
authenticated: true,
|
||||
icon: serverConfig?.iconPath,
|
||||
};
|
||||
|
||||
// Build authConfig for MCP tools
|
||||
if (!serverConfig?.customUserVars) {
|
||||
plugin.authConfig = [];
|
||||
plugins.push(plugin);
|
||||
continue;
|
||||
}
|
||||
|
||||
const customVarKeys = Object.keys(serverConfig.customUserVars);
|
||||
if (customVarKeys.length === 0) {
|
||||
plugin.authConfig = [];
|
||||
} else {
|
||||
plugin.authConfig = Object.entries(serverConfig.customUserVars).map(([key, value]) => ({
|
||||
authField: key,
|
||||
label: value.title || key,
|
||||
description: value.description || '',
|
||||
}));
|
||||
}
|
||||
|
||||
plugins.push(plugin);
|
||||
}
|
||||
|
||||
return plugins;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAvailableTools,
|
||||
getAvailablePluginsController,
|
||||
|
||||
89
api/server/controllers/PluginController.spec.js
Normal file
89
api/server/controllers/PluginController.spec.js
Normal file
@@ -0,0 +1,89 @@
|
||||
const { Constants } = require('librechat-data-provider');
|
||||
const { getCustomConfig, getCachedTools } = require('~/server/services/Config');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
// Mock the dependencies
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: {
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
getCustomConfig: jest.fn(),
|
||||
getCachedTools: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/ToolService', () => ({
|
||||
getToolkitKey: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/config', () => ({
|
||||
getMCPManager: jest.fn(() => ({
|
||||
loadManifestTools: jest.fn().mockResolvedValue([]),
|
||||
})),
|
||||
getFlowStateManager: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/app/clients/tools', () => ({
|
||||
availableTools: [],
|
||||
}));
|
||||
|
||||
jest.mock('~/cache', () => ({
|
||||
getLogStores: jest.fn(),
|
||||
}));
|
||||
|
||||
// Import the actual module with the function we want to test
|
||||
const { getAvailableTools } = require('./PluginController');
|
||||
|
||||
describe('PluginController', () => {
|
||||
describe('plugin.icon behavior', () => {
|
||||
let mockReq, mockRes, mockCache;
|
||||
|
||||
const callGetAvailableToolsWithMCPServer = async (mcpServers) => {
|
||||
mockCache.get.mockResolvedValue(null);
|
||||
getCustomConfig.mockResolvedValue({ mcpServers });
|
||||
|
||||
const functionTools = {
|
||||
[`test-tool${Constants.mcp_delimiter}test-server`]: {
|
||||
function: { name: 'test-tool', description: 'A test tool' },
|
||||
},
|
||||
};
|
||||
getCachedTools.mockResolvedValueOnce(functionTools);
|
||||
getCachedTools.mockResolvedValueOnce({
|
||||
[`test-tool${Constants.mcp_delimiter}test-server`]: true,
|
||||
});
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
const responseData = mockRes.json.mock.calls[0][0];
|
||||
return responseData.find((tool) => tool.name === 'test-tool');
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockReq = { user: { id: 'test-user-id' } };
|
||||
mockRes = { status: jest.fn().mockReturnThis(), json: jest.fn() };
|
||||
mockCache = { get: jest.fn(), set: jest.fn() };
|
||||
getLogStores.mockReturnValue(mockCache);
|
||||
});
|
||||
|
||||
it('should set plugin.icon when iconPath is defined', async () => {
|
||||
const mcpServers = {
|
||||
'test-server': {
|
||||
iconPath: '/path/to/icon.png',
|
||||
},
|
||||
};
|
||||
const testTool = await callGetAvailableToolsWithMCPServer(mcpServers);
|
||||
expect(testTool.icon).toBe('/path/to/icon.png');
|
||||
});
|
||||
|
||||
it('should set plugin.icon to undefined when iconPath is not defined', async () => {
|
||||
const mcpServers = {
|
||||
'test-server': {},
|
||||
};
|
||||
const testTool = await callGetAvailableToolsWithMCPServer(mcpServers);
|
||||
expect(testTool.icon).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -175,14 +175,16 @@ const updateUserPluginsController = async (req, res) => {
|
||||
try {
|
||||
const mcpManager = getMCPManager(user.id);
|
||||
if (mcpManager) {
|
||||
// Extract server name from pluginKey (format: "mcp_<serverName>")
|
||||
const serverName = pluginKey.replace(Constants.mcp_prefix, '');
|
||||
logger.info(
|
||||
`[updateUserPluginsController] Disconnecting MCP connections for user ${user.id} after plugin auth update for ${pluginKey}.`,
|
||||
`[updateUserPluginsController] Disconnecting MCP server ${serverName} for user ${user.id} after plugin auth update for ${pluginKey}.`,
|
||||
);
|
||||
await mcpManager.disconnectUserConnections(user.id);
|
||||
await mcpManager.disconnectUserConnection(user.id, serverName);
|
||||
}
|
||||
} catch (disconnectError) {
|
||||
logger.error(
|
||||
`[updateUserPluginsController] Error disconnecting MCP connections for user ${user.id} after plugin auth update:`,
|
||||
`[updateUserPluginsController] Error disconnecting MCP connection for user ${user.id} after plugin auth update:`,
|
||||
disconnectError,
|
||||
);
|
||||
// Do not fail the request for this, but log it.
|
||||
|
||||
@@ -15,6 +15,7 @@ const {
|
||||
Callback,
|
||||
Providers,
|
||||
GraphEvents,
|
||||
TitleMethod,
|
||||
formatMessage,
|
||||
formatAgentMessages,
|
||||
getTokenCountForMessage,
|
||||
@@ -70,7 +71,11 @@ const payloadParser = ({ req, agent, endpoint }) => {
|
||||
if (isAgentsEndpoint(endpoint)) {
|
||||
return { model: undefined };
|
||||
} else if (endpoint === EModelEndpoint.bedrock) {
|
||||
return bedrockInputSchema.parse(agent.model_parameters);
|
||||
const parsedValues = bedrockInputSchema.parse(agent.model_parameters);
|
||||
if (parsedValues.thinking == null) {
|
||||
parsedValues.thinking = false;
|
||||
}
|
||||
return parsedValues;
|
||||
}
|
||||
return req.body.endpointOption.model_parameters;
|
||||
};
|
||||
@@ -730,6 +735,9 @@ class AgentClient extends BaseClient {
|
||||
if (i > 0) {
|
||||
this.model = agent.model_parameters.model;
|
||||
}
|
||||
if (i > 0 && config.signal == null) {
|
||||
config.signal = abortController.signal;
|
||||
}
|
||||
if (agent.recursion_limit && typeof agent.recursion_limit === 'number') {
|
||||
config.recursionLimit = agent.recursion_limit;
|
||||
}
|
||||
@@ -1009,7 +1017,7 @@ class AgentClient extends BaseClient {
|
||||
}
|
||||
const { handleLLMEnd, collected: collectedMetadata } = createMetadataAggregator();
|
||||
const { req, res, agent } = this.options;
|
||||
const endpoint = agent.endpoint;
|
||||
let endpoint = agent.endpoint;
|
||||
|
||||
/** @type {import('@librechat/agents').ClientOptions} */
|
||||
let clientOptions = {
|
||||
@@ -1017,17 +1025,32 @@ class AgentClient extends BaseClient {
|
||||
model: agent.model_parameters.model,
|
||||
};
|
||||
|
||||
const { getOptions, overrideProvider, customEndpointConfig } =
|
||||
await getProviderConfig(endpoint);
|
||||
let titleProviderConfig = await getProviderConfig(endpoint);
|
||||
|
||||
/** @type {TEndpoint | undefined} */
|
||||
const endpointConfig = req.app.locals[endpoint] ?? customEndpointConfig;
|
||||
const endpointConfig =
|
||||
req.app.locals.all ?? req.app.locals[endpoint] ?? titleProviderConfig.customEndpointConfig;
|
||||
if (!endpointConfig) {
|
||||
logger.warn(
|
||||
'[api/server/controllers/agents/client.js #titleConvo] Error getting endpoint config',
|
||||
);
|
||||
}
|
||||
|
||||
if (endpointConfig?.titleEndpoint && endpointConfig.titleEndpoint !== endpoint) {
|
||||
try {
|
||||
titleProviderConfig = await getProviderConfig(endpointConfig.titleEndpoint);
|
||||
endpoint = endpointConfig.titleEndpoint;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[api/server/controllers/agents/client.js #titleConvo] Error getting title endpoint config for ${endpointConfig.titleEndpoint}, falling back to default`,
|
||||
error,
|
||||
);
|
||||
// Fall back to original provider config
|
||||
endpoint = agent.endpoint;
|
||||
titleProviderConfig = await getProviderConfig(endpoint);
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
endpointConfig &&
|
||||
endpointConfig.titleModel &&
|
||||
@@ -1036,7 +1059,7 @@ class AgentClient extends BaseClient {
|
||||
clientOptions.model = endpointConfig.titleModel;
|
||||
}
|
||||
|
||||
const options = await getOptions({
|
||||
const options = await titleProviderConfig.getOptions({
|
||||
req,
|
||||
res,
|
||||
optionsOnly: true,
|
||||
@@ -1045,7 +1068,7 @@ class AgentClient extends BaseClient {
|
||||
endpointOption: { model_parameters: clientOptions },
|
||||
});
|
||||
|
||||
let provider = options.provider ?? overrideProvider ?? agent.provider;
|
||||
let provider = options.provider ?? titleProviderConfig.overrideProvider ?? agent.provider;
|
||||
if (
|
||||
endpoint === EModelEndpoint.azureOpenAI &&
|
||||
options.llmConfig?.azureOpenAIApiInstanceName == null
|
||||
@@ -1078,16 +1101,23 @@ class AgentClient extends BaseClient {
|
||||
),
|
||||
);
|
||||
|
||||
if (provider === Providers.GOOGLE) {
|
||||
if (
|
||||
provider === Providers.GOOGLE &&
|
||||
(endpointConfig?.titleMethod === TitleMethod.FUNCTIONS ||
|
||||
endpointConfig?.titleMethod === TitleMethod.STRUCTURED)
|
||||
) {
|
||||
clientOptions.json = true;
|
||||
}
|
||||
|
||||
try {
|
||||
const titleResult = await this.run.generateTitle({
|
||||
provider,
|
||||
clientOptions,
|
||||
inputText: text,
|
||||
contentParts: this.contentParts,
|
||||
clientOptions,
|
||||
titleMethod: endpointConfig?.titleMethod,
|
||||
titlePrompt: endpointConfig?.titlePrompt,
|
||||
titlePromptTemplate: endpointConfig?.titlePromptTemplate,
|
||||
chainOptions: {
|
||||
signal: abortController.signal,
|
||||
callbacks: [
|
||||
|
||||
730
api/server/controllers/agents/client.test.js
Normal file
730
api/server/controllers/agents/client.test.js
Normal file
@@ -0,0 +1,730 @@
|
||||
const { Providers } = require('@librechat/agents');
|
||||
const { Constants, EModelEndpoint } = require('librechat-data-provider');
|
||||
const AgentClient = require('./client');
|
||||
|
||||
jest.mock('@librechat/agents', () => ({
|
||||
...jest.requireActual('@librechat/agents'),
|
||||
createMetadataAggregator: () => ({
|
||||
handleLLMEnd: jest.fn(),
|
||||
collected: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('AgentClient - titleConvo', () => {
|
||||
let client;
|
||||
let mockRun;
|
||||
let mockReq;
|
||||
let mockRes;
|
||||
let mockAgent;
|
||||
let mockOptions;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset all mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Mock run object
|
||||
mockRun = {
|
||||
generateTitle: jest.fn().mockResolvedValue({
|
||||
title: 'Generated Title',
|
||||
}),
|
||||
};
|
||||
|
||||
// Mock agent - with both endpoint and provider
|
||||
mockAgent = {
|
||||
id: 'agent-123',
|
||||
endpoint: EModelEndpoint.openAI, // Use a valid provider as endpoint for getProviderConfig
|
||||
provider: EModelEndpoint.openAI, // Add provider property
|
||||
model_parameters: {
|
||||
model: 'gpt-4',
|
||||
},
|
||||
};
|
||||
|
||||
// Mock request and response
|
||||
mockReq = {
|
||||
app: {
|
||||
locals: {
|
||||
[EModelEndpoint.openAI]: {
|
||||
// Match the agent endpoint
|
||||
titleModel: 'gpt-3.5-turbo',
|
||||
titlePrompt: 'Custom title prompt',
|
||||
titleMethod: 'structured',
|
||||
titlePromptTemplate: 'Template: {{content}}',
|
||||
},
|
||||
},
|
||||
},
|
||||
user: {
|
||||
id: 'user-123',
|
||||
},
|
||||
body: {
|
||||
model: 'gpt-4',
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
key: null,
|
||||
},
|
||||
};
|
||||
|
||||
mockRes = {};
|
||||
|
||||
// Mock options
|
||||
mockOptions = {
|
||||
req: mockReq,
|
||||
res: mockRes,
|
||||
agent: mockAgent,
|
||||
endpointTokenConfig: {},
|
||||
};
|
||||
|
||||
// Create client instance
|
||||
client = new AgentClient(mockOptions);
|
||||
client.run = mockRun;
|
||||
client.responseMessageId = 'response-123';
|
||||
client.conversationId = 'convo-123';
|
||||
client.contentParts = [{ type: 'text', text: 'Test content' }];
|
||||
client.recordCollectedUsage = jest.fn().mockResolvedValue(); // Mock as async function that resolves
|
||||
});
|
||||
|
||||
describe('titleConvo method', () => {
|
||||
it('should throw error if run is not initialized', async () => {
|
||||
client.run = null;
|
||||
|
||||
await expect(
|
||||
client.titleConvo({ text: 'Test', abortController: new AbortController() }),
|
||||
).rejects.toThrow('Run not initialized');
|
||||
});
|
||||
|
||||
it('should use titlePrompt from endpoint config', async () => {
|
||||
const text = 'Test conversation text';
|
||||
const abortController = new AbortController();
|
||||
|
||||
await client.titleConvo({ text, abortController });
|
||||
|
||||
expect(mockRun.generateTitle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
titlePrompt: 'Custom title prompt',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use titlePromptTemplate from endpoint config', async () => {
|
||||
const text = 'Test conversation text';
|
||||
const abortController = new AbortController();
|
||||
|
||||
await client.titleConvo({ text, abortController });
|
||||
|
||||
expect(mockRun.generateTitle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
titlePromptTemplate: 'Template: {{content}}',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use titleMethod from endpoint config', async () => {
|
||||
const text = 'Test conversation text';
|
||||
const abortController = new AbortController();
|
||||
|
||||
await client.titleConvo({ text, abortController });
|
||||
|
||||
expect(mockRun.generateTitle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: Providers.OPENAI,
|
||||
titleMethod: 'structured',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use titleModel from endpoint config when provided', async () => {
|
||||
const text = 'Test conversation text';
|
||||
const abortController = new AbortController();
|
||||
|
||||
await client.titleConvo({ text, abortController });
|
||||
|
||||
// Check that generateTitle was called with correct clientOptions
|
||||
const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
|
||||
expect(generateTitleCall.clientOptions.model).toBe('gpt-3.5-turbo');
|
||||
});
|
||||
|
||||
it('should handle missing endpoint config gracefully', async () => {
|
||||
// Remove endpoint config
|
||||
mockReq.app.locals[EModelEndpoint.openAI] = undefined;
|
||||
|
||||
const text = 'Test conversation text';
|
||||
const abortController = new AbortController();
|
||||
|
||||
await client.titleConvo({ text, abortController });
|
||||
|
||||
expect(mockRun.generateTitle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
titlePrompt: undefined,
|
||||
titlePromptTemplate: undefined,
|
||||
titleMethod: undefined,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use agent model when titleModel is not provided', async () => {
|
||||
// Remove titleModel from config
|
||||
delete mockReq.app.locals[EModelEndpoint.openAI].titleModel;
|
||||
|
||||
const text = 'Test conversation text';
|
||||
const abortController = new AbortController();
|
||||
|
||||
await client.titleConvo({ text, abortController });
|
||||
|
||||
const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
|
||||
expect(generateTitleCall.clientOptions.model).toBe('gpt-4'); // Should use agent's model
|
||||
});
|
||||
|
||||
it('should not use titleModel when it equals CURRENT_MODEL constant', async () => {
|
||||
mockReq.app.locals[EModelEndpoint.openAI].titleModel = Constants.CURRENT_MODEL;
|
||||
|
||||
const text = 'Test conversation text';
|
||||
const abortController = new AbortController();
|
||||
|
||||
await client.titleConvo({ text, abortController });
|
||||
|
||||
const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
|
||||
expect(generateTitleCall.clientOptions.model).toBe('gpt-4'); // Should use agent's model
|
||||
});
|
||||
|
||||
it('should pass all required parameters to generateTitle', async () => {
|
||||
const text = 'Test conversation text';
|
||||
const abortController = new AbortController();
|
||||
|
||||
await client.titleConvo({ text, abortController });
|
||||
|
||||
expect(mockRun.generateTitle).toHaveBeenCalledWith({
|
||||
provider: expect.any(String),
|
||||
inputText: text,
|
||||
contentParts: client.contentParts,
|
||||
clientOptions: expect.objectContaining({
|
||||
model: 'gpt-3.5-turbo',
|
||||
}),
|
||||
titlePrompt: 'Custom title prompt',
|
||||
titlePromptTemplate: 'Template: {{content}}',
|
||||
titleMethod: 'structured',
|
||||
chainOptions: expect.objectContaining({
|
||||
signal: abortController.signal,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('should record collected usage after title generation', async () => {
|
||||
const text = 'Test conversation text';
|
||||
const abortController = new AbortController();
|
||||
|
||||
await client.titleConvo({ text, abortController });
|
||||
|
||||
expect(client.recordCollectedUsage).toHaveBeenCalledWith({
|
||||
model: 'gpt-3.5-turbo',
|
||||
context: 'title',
|
||||
collectedUsage: expect.any(Array),
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the generated title', async () => {
|
||||
const text = 'Test conversation text';
|
||||
const abortController = new AbortController();
|
||||
|
||||
const result = await client.titleConvo({ text, abortController });
|
||||
|
||||
expect(result).toBe('Generated Title');
|
||||
});
|
||||
|
||||
it('should handle errors gracefully and return undefined', async () => {
|
||||
mockRun.generateTitle.mockRejectedValue(new Error('Title generation failed'));
|
||||
|
||||
const text = 'Test conversation text';
|
||||
const abortController = new AbortController();
|
||||
|
||||
const result = await client.titleConvo({ text, abortController });
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should pass titleEndpoint configuration to generateTitle', async () => {
|
||||
// Mock the API key just for this test
|
||||
const originalApiKey = process.env.ANTHROPIC_API_KEY;
|
||||
process.env.ANTHROPIC_API_KEY = 'test-api-key';
|
||||
|
||||
// Add titleEndpoint to the config
|
||||
mockReq.app.locals[EModelEndpoint.openAI].titleEndpoint = EModelEndpoint.anthropic;
|
||||
mockReq.app.locals[EModelEndpoint.openAI].titleMethod = 'structured';
|
||||
mockReq.app.locals[EModelEndpoint.openAI].titlePrompt = 'Custom title prompt';
|
||||
mockReq.app.locals[EModelEndpoint.openAI].titlePromptTemplate = 'Custom template';
|
||||
|
||||
const text = 'Test conversation text';
|
||||
const abortController = new AbortController();
|
||||
|
||||
await client.titleConvo({ text, abortController });
|
||||
|
||||
// Verify generateTitle was called with the custom configuration
|
||||
expect(mockRun.generateTitle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
titleMethod: 'structured',
|
||||
provider: Providers.ANTHROPIC,
|
||||
titlePrompt: 'Custom title prompt',
|
||||
titlePromptTemplate: 'Custom template',
|
||||
}),
|
||||
);
|
||||
|
||||
// Restore the original API key
|
||||
if (originalApiKey) {
|
||||
process.env.ANTHROPIC_API_KEY = originalApiKey;
|
||||
} else {
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
it('should use all config when endpoint config is missing', async () => {
|
||||
// Remove endpoint-specific config
|
||||
delete mockReq.app.locals[EModelEndpoint.openAI].titleModel;
|
||||
delete mockReq.app.locals[EModelEndpoint.openAI].titlePrompt;
|
||||
delete mockReq.app.locals[EModelEndpoint.openAI].titleMethod;
|
||||
delete mockReq.app.locals[EModelEndpoint.openAI].titlePromptTemplate;
|
||||
|
||||
// Set 'all' config
|
||||
mockReq.app.locals.all = {
|
||||
titleModel: 'gpt-4o-mini',
|
||||
titlePrompt: 'All config title prompt',
|
||||
titleMethod: 'completion',
|
||||
titlePromptTemplate: 'All config template: {{content}}',
|
||||
};
|
||||
|
||||
const text = 'Test conversation text';
|
||||
const abortController = new AbortController();
|
||||
|
||||
await client.titleConvo({ text, abortController });
|
||||
|
||||
// Verify generateTitle was called with 'all' config values
|
||||
expect(mockRun.generateTitle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
titleMethod: 'completion',
|
||||
titlePrompt: 'All config title prompt',
|
||||
titlePromptTemplate: 'All config template: {{content}}',
|
||||
}),
|
||||
);
|
||||
|
||||
// Check that the model was set from 'all' config
|
||||
const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
|
||||
expect(generateTitleCall.clientOptions.model).toBe('gpt-4o-mini');
|
||||
});
|
||||
|
||||
it('should prioritize all config over endpoint config for title settings', async () => {
|
||||
// Set both endpoint and 'all' config
|
||||
mockReq.app.locals[EModelEndpoint.openAI].titleModel = 'gpt-3.5-turbo';
|
||||
mockReq.app.locals[EModelEndpoint.openAI].titlePrompt = 'Endpoint title prompt';
|
||||
mockReq.app.locals[EModelEndpoint.openAI].titleMethod = 'structured';
|
||||
// Remove titlePromptTemplate from endpoint config to test fallback
|
||||
delete mockReq.app.locals[EModelEndpoint.openAI].titlePromptTemplate;
|
||||
|
||||
mockReq.app.locals.all = {
|
||||
titleModel: 'gpt-4o-mini',
|
||||
titlePrompt: 'All config title prompt',
|
||||
titleMethod: 'completion',
|
||||
titlePromptTemplate: 'All config template',
|
||||
};
|
||||
|
||||
const text = 'Test conversation text';
|
||||
const abortController = new AbortController();
|
||||
|
||||
await client.titleConvo({ text, abortController });
|
||||
|
||||
// Verify 'all' config takes precedence over endpoint config
|
||||
expect(mockRun.generateTitle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
titleMethod: 'completion',
|
||||
titlePrompt: 'All config title prompt',
|
||||
titlePromptTemplate: 'All config template',
|
||||
}),
|
||||
);
|
||||
|
||||
// Check that the model was set from 'all' config
|
||||
const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
|
||||
expect(generateTitleCall.clientOptions.model).toBe('gpt-4o-mini');
|
||||
});
|
||||
|
||||
it('should use all config with titleEndpoint and verify provider switch', async () => {
|
||||
// Mock the API key for the titleEndpoint provider
|
||||
const originalApiKey = process.env.ANTHROPIC_API_KEY;
|
||||
process.env.ANTHROPIC_API_KEY = 'test-anthropic-key';
|
||||
|
||||
// Remove endpoint-specific config to test 'all' config
|
||||
delete mockReq.app.locals[EModelEndpoint.openAI];
|
||||
|
||||
// Set comprehensive 'all' config with all new title options
|
||||
mockReq.app.locals.all = {
|
||||
titleConvo: true,
|
||||
titleModel: 'claude-3-haiku-20240307',
|
||||
titleMethod: 'completion', // Testing the new default method
|
||||
titlePrompt: 'Generate a concise, descriptive title for this conversation',
|
||||
titlePromptTemplate: 'Conversation summary: {{content}}',
|
||||
titleEndpoint: EModelEndpoint.anthropic, // Should switch provider to Anthropic
|
||||
};
|
||||
|
||||
const text = 'Test conversation about AI and machine learning';
|
||||
const abortController = new AbortController();
|
||||
|
||||
await client.titleConvo({ text, abortController });
|
||||
|
||||
// Verify all config values were used
|
||||
expect(mockRun.generateTitle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: Providers.ANTHROPIC, // Critical: Verify provider switched to Anthropic
|
||||
titleMethod: 'completion',
|
||||
titlePrompt: 'Generate a concise, descriptive title for this conversation',
|
||||
titlePromptTemplate: 'Conversation summary: {{content}}',
|
||||
inputText: text,
|
||||
contentParts: client.contentParts,
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify the model was set from 'all' config
|
||||
const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
|
||||
expect(generateTitleCall.clientOptions.model).toBe('claude-3-haiku-20240307');
|
||||
|
||||
// Verify other client options are set correctly
|
||||
expect(generateTitleCall.clientOptions).toMatchObject({
|
||||
model: 'claude-3-haiku-20240307',
|
||||
// Note: Anthropic's getOptions may set its own maxTokens value
|
||||
});
|
||||
|
||||
// Restore the original API key
|
||||
if (originalApiKey) {
|
||||
process.env.ANTHROPIC_API_KEY = originalApiKey;
|
||||
} else {
|
||||
delete process.env.ANTHROPIC_API_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
it('should test all titleMethod options from all config', async () => {
|
||||
// Test each titleMethod: 'completion', 'functions', 'structured'
|
||||
const titleMethods = ['completion', 'functions', 'structured'];
|
||||
|
||||
for (const method of titleMethods) {
|
||||
// Clear previous calls
|
||||
mockRun.generateTitle.mockClear();
|
||||
|
||||
// Remove endpoint config
|
||||
delete mockReq.app.locals[EModelEndpoint.openAI];
|
||||
|
||||
// Set 'all' config with specific titleMethod
|
||||
mockReq.app.locals.all = {
|
||||
titleModel: 'gpt-4o-mini',
|
||||
titleMethod: method,
|
||||
titlePrompt: `Testing ${method} method`,
|
||||
titlePromptTemplate: `Template for ${method}: {{content}}`,
|
||||
};
|
||||
|
||||
const text = `Test conversation for ${method} method`;
|
||||
const abortController = new AbortController();
|
||||
|
||||
await client.titleConvo({ text, abortController });
|
||||
|
||||
// Verify the correct titleMethod was used
|
||||
expect(mockRun.generateTitle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
titleMethod: method,
|
||||
titlePrompt: `Testing ${method} method`,
|
||||
titlePromptTemplate: `Template for ${method}: {{content}}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
describe('Azure-specific title generation', () => {
|
||||
let originalEnv;
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Save original environment variables
|
||||
originalEnv = { ...process.env };
|
||||
|
||||
// Mock Azure API keys
|
||||
process.env.AZURE_OPENAI_API_KEY = 'test-azure-key';
|
||||
process.env.AZURE_API_KEY = 'test-azure-key';
|
||||
process.env.EASTUS_API_KEY = 'test-eastus-key';
|
||||
process.env.EASTUS2_API_KEY = 'test-eastus2-key';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Restore environment variables
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('should use OPENAI provider for Azure serverless endpoints', async () => {
|
||||
// Set up Azure endpoint with serverless config
|
||||
mockAgent.endpoint = EModelEndpoint.azureOpenAI;
|
||||
mockAgent.provider = EModelEndpoint.azureOpenAI;
|
||||
mockReq.app.locals[EModelEndpoint.azureOpenAI] = {
|
||||
titleConvo: true,
|
||||
titleModel: 'grok-3',
|
||||
titleMethod: 'completion',
|
||||
titlePrompt: 'Azure serverless title prompt',
|
||||
streamRate: 35,
|
||||
modelGroupMap: {
|
||||
'grok-3': {
|
||||
group: 'Azure AI Foundry',
|
||||
deploymentName: 'grok-3',
|
||||
},
|
||||
},
|
||||
groupMap: {
|
||||
'Azure AI Foundry': {
|
||||
apiKey: '${AZURE_API_KEY}',
|
||||
baseURL: 'https://test.services.ai.azure.com/models',
|
||||
version: '2024-05-01-preview',
|
||||
serverless: true,
|
||||
models: {
|
||||
'grok-3': {
|
||||
deploymentName: 'grok-3',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
mockReq.body.endpoint = EModelEndpoint.azureOpenAI;
|
||||
mockReq.body.model = 'grok-3';
|
||||
|
||||
const text = 'Test Azure serverless conversation';
|
||||
const abortController = new AbortController();
|
||||
|
||||
await client.titleConvo({ text, abortController });
|
||||
|
||||
// Verify provider was switched to OPENAI for serverless
|
||||
expect(mockRun.generateTitle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: Providers.OPENAI, // Should be OPENAI for serverless
|
||||
titleMethod: 'completion',
|
||||
titlePrompt: 'Azure serverless title prompt',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use AZURE provider for Azure endpoints with instanceName', async () => {
|
||||
// Set up Azure endpoint
|
||||
mockAgent.endpoint = EModelEndpoint.azureOpenAI;
|
||||
mockAgent.provider = EModelEndpoint.azureOpenAI;
|
||||
mockReq.app.locals[EModelEndpoint.azureOpenAI] = {
|
||||
titleConvo: true,
|
||||
titleModel: 'gpt-4o',
|
||||
titleMethod: 'structured',
|
||||
titlePrompt: 'Azure instance title prompt',
|
||||
streamRate: 35,
|
||||
modelGroupMap: {
|
||||
'gpt-4o': {
|
||||
group: 'eastus',
|
||||
deploymentName: 'gpt-4o',
|
||||
},
|
||||
},
|
||||
groupMap: {
|
||||
eastus: {
|
||||
apiKey: '${EASTUS_API_KEY}',
|
||||
instanceName: 'region-instance',
|
||||
version: '2024-02-15-preview',
|
||||
models: {
|
||||
'gpt-4o': {
|
||||
deploymentName: 'gpt-4o',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
mockReq.body.endpoint = EModelEndpoint.azureOpenAI;
|
||||
mockReq.body.model = 'gpt-4o';
|
||||
|
||||
const text = 'Test Azure instance conversation';
|
||||
const abortController = new AbortController();
|
||||
|
||||
await client.titleConvo({ text, abortController });
|
||||
|
||||
// Verify provider remains AZURE with instanceName
|
||||
expect(mockRun.generateTitle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: Providers.AZURE,
|
||||
titleMethod: 'structured',
|
||||
titlePrompt: 'Azure instance title prompt',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle Azure titleModel with CURRENT_MODEL constant', async () => {
|
||||
// Set up Azure endpoint
|
||||
mockAgent.endpoint = EModelEndpoint.azureOpenAI;
|
||||
mockAgent.provider = EModelEndpoint.azureOpenAI;
|
||||
mockAgent.model_parameters.model = 'gpt-4o-latest';
|
||||
mockReq.app.locals[EModelEndpoint.azureOpenAI] = {
|
||||
titleConvo: true,
|
||||
titleModel: Constants.CURRENT_MODEL,
|
||||
titleMethod: 'functions',
|
||||
streamRate: 35,
|
||||
modelGroupMap: {
|
||||
'gpt-4o-latest': {
|
||||
group: 'region-eastus',
|
||||
deploymentName: 'gpt-4o-mini',
|
||||
version: '2024-02-15-preview',
|
||||
},
|
||||
},
|
||||
groupMap: {
|
||||
'region-eastus': {
|
||||
apiKey: '${EASTUS2_API_KEY}',
|
||||
instanceName: 'test-instance',
|
||||
version: '2024-12-01-preview',
|
||||
models: {
|
||||
'gpt-4o-latest': {
|
||||
deploymentName: 'gpt-4o-mini',
|
||||
version: '2024-02-15-preview',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
mockReq.body.endpoint = EModelEndpoint.azureOpenAI;
|
||||
mockReq.body.model = 'gpt-4o-latest';
|
||||
|
||||
const text = 'Test Azure current model';
|
||||
const abortController = new AbortController();
|
||||
|
||||
await client.titleConvo({ text, abortController });
|
||||
|
||||
// Verify it uses the correct model when titleModel is CURRENT_MODEL
|
||||
const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
|
||||
// When CURRENT_MODEL is used with Azure, the model gets mapped to the deployment name
|
||||
// In this case, 'gpt-4o-latest' is mapped to 'gpt-4o-mini' deployment
|
||||
expect(generateTitleCall.clientOptions.model).toBe('gpt-4o-mini');
|
||||
// Also verify that CURRENT_MODEL constant was not passed as the model
|
||||
expect(generateTitleCall.clientOptions.model).not.toBe(Constants.CURRENT_MODEL);
|
||||
});
|
||||
|
||||
it('should handle Azure with multiple model groups', async () => {
|
||||
// Set up Azure endpoint
|
||||
mockAgent.endpoint = EModelEndpoint.azureOpenAI;
|
||||
mockAgent.provider = EModelEndpoint.azureOpenAI;
|
||||
mockReq.app.locals[EModelEndpoint.azureOpenAI] = {
|
||||
titleConvo: true,
|
||||
titleModel: 'o1-mini',
|
||||
titleMethod: 'completion',
|
||||
streamRate: 35,
|
||||
modelGroupMap: {
|
||||
'gpt-4o': {
|
||||
group: 'eastus',
|
||||
deploymentName: 'gpt-4o',
|
||||
},
|
||||
'o1-mini': {
|
||||
group: 'region-eastus',
|
||||
deploymentName: 'o1-mini',
|
||||
},
|
||||
'codex-mini': {
|
||||
group: 'codex-mini',
|
||||
deploymentName: 'codex-mini',
|
||||
},
|
||||
},
|
||||
groupMap: {
|
||||
eastus: {
|
||||
apiKey: '${EASTUS_API_KEY}',
|
||||
instanceName: 'region-eastus',
|
||||
version: '2024-02-15-preview',
|
||||
models: {
|
||||
'gpt-4o': {
|
||||
deploymentName: 'gpt-4o',
|
||||
},
|
||||
},
|
||||
},
|
||||
'region-eastus': {
|
||||
apiKey: '${EASTUS2_API_KEY}',
|
||||
instanceName: 'region-eastus2',
|
||||
version: '2024-12-01-preview',
|
||||
models: {
|
||||
'o1-mini': {
|
||||
deploymentName: 'o1-mini',
|
||||
},
|
||||
},
|
||||
},
|
||||
'codex-mini': {
|
||||
apiKey: '${AZURE_API_KEY}',
|
||||
baseURL: 'https://example.cognitiveservices.azure.com/openai/',
|
||||
version: '2025-04-01-preview',
|
||||
serverless: true,
|
||||
models: {
|
||||
'codex-mini': {
|
||||
deploymentName: 'codex-mini',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
mockReq.body.endpoint = EModelEndpoint.azureOpenAI;
|
||||
mockReq.body.model = 'o1-mini';
|
||||
|
||||
const text = 'Test Azure multi-group conversation';
|
||||
const abortController = new AbortController();
|
||||
|
||||
await client.titleConvo({ text, abortController });
|
||||
|
||||
// Verify correct model and provider are used
|
||||
expect(mockRun.generateTitle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: Providers.AZURE,
|
||||
titleMethod: 'completion',
|
||||
}),
|
||||
);
|
||||
|
||||
const generateTitleCall = mockRun.generateTitle.mock.calls[0][0];
|
||||
expect(generateTitleCall.clientOptions.model).toBe('o1-mini');
|
||||
expect(generateTitleCall.clientOptions.maxTokens).toBeUndefined(); // o1 models shouldn't have maxTokens
|
||||
});
|
||||
|
||||
it('should use all config as fallback for Azure endpoints', async () => {
|
||||
// Set up Azure endpoint with minimal config
|
||||
mockAgent.endpoint = EModelEndpoint.azureOpenAI;
|
||||
mockAgent.provider = EModelEndpoint.azureOpenAI;
|
||||
mockReq.body.endpoint = EModelEndpoint.azureOpenAI;
|
||||
mockReq.body.model = 'gpt-4';
|
||||
|
||||
// Remove Azure-specific config
|
||||
delete mockReq.app.locals[EModelEndpoint.azureOpenAI];
|
||||
|
||||
// Set 'all' config as fallback with a serverless Azure config
|
||||
mockReq.app.locals.all = {
|
||||
titleConvo: true,
|
||||
titleModel: 'gpt-4',
|
||||
titleMethod: 'structured',
|
||||
titlePrompt: 'Fallback title prompt from all config',
|
||||
titlePromptTemplate: 'Template: {{content}}',
|
||||
modelGroupMap: {
|
||||
'gpt-4': {
|
||||
group: 'default-group',
|
||||
deploymentName: 'gpt-4',
|
||||
},
|
||||
},
|
||||
groupMap: {
|
||||
'default-group': {
|
||||
apiKey: '${AZURE_API_KEY}',
|
||||
baseURL: 'https://default.openai.azure.com/',
|
||||
version: '2024-02-15-preview',
|
||||
serverless: true,
|
||||
models: {
|
||||
'gpt-4': {
|
||||
deploymentName: 'gpt-4',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const text = 'Test Azure with all config fallback';
|
||||
const abortController = new AbortController();
|
||||
|
||||
await client.titleConvo({ text, abortController });
|
||||
|
||||
// Verify all config is used
|
||||
expect(mockRun.generateTitle).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
provider: Providers.OPENAI, // Should be OPENAI when no instanceName
|
||||
titleMethod: 'structured',
|
||||
titlePrompt: 'Fallback title prompt from all config',
|
||||
titlePromptTemplate: 'Template: {{content}}',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,13 @@
|
||||
const { z } = require('zod');
|
||||
const fs = require('fs').promises;
|
||||
const { nanoid } = require('nanoid');
|
||||
const { logger, PermissionBits } = require('@librechat/data-schemas');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { agentCreateSchema, agentUpdateSchema } = require('@librechat/api');
|
||||
const {
|
||||
Tools,
|
||||
SystemRoles,
|
||||
Constants,
|
||||
FileSources,
|
||||
SystemRoles,
|
||||
EToolResources,
|
||||
actionDelimiter,
|
||||
removeNullishValues,
|
||||
@@ -16,24 +17,18 @@ const {
|
||||
createAgent,
|
||||
updateAgent,
|
||||
deleteAgent,
|
||||
getListAgentsByAccess,
|
||||
countPromotedAgents,
|
||||
revertAgentVersion,
|
||||
getListAgents,
|
||||
} = require('~/models/Agent');
|
||||
const {
|
||||
grantPermission,
|
||||
findAccessibleResources,
|
||||
findPubliclyAccessibleResources,
|
||||
hasPublicPermission,
|
||||
} = require('~/server/services/PermissionService');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
|
||||
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 { updateAgentProjects } = require('~/models/Agent');
|
||||
const { getProjectByName } = require('~/models/Project');
|
||||
const { revertAgentVersion } = require('~/models/Agent');
|
||||
const { deleteFileByFilter } = require('~/models/File');
|
||||
const { getCategoriesWithCounts } = require('~/models');
|
||||
|
||||
const systemTools = {
|
||||
[Tools.execute_code]: true,
|
||||
@@ -72,27 +67,6 @@ const createAgentHandler = async (req, res) => {
|
||||
}
|
||||
|
||||
const agent = await createAgent(agentData);
|
||||
|
||||
// Automatically grant owner permissions to the creator
|
||||
try {
|
||||
await grantPermission({
|
||||
principalType: 'user',
|
||||
principalId: userId,
|
||||
resourceType: 'agent',
|
||||
resourceId: agent._id,
|
||||
accessRoleId: 'agent_owner',
|
||||
grantedBy: userId,
|
||||
});
|
||||
logger.debug(
|
||||
`[createAgent] Granted owner permissions to user ${userId} for agent ${agent.id}`,
|
||||
);
|
||||
} catch (permissionError) {
|
||||
logger.error(
|
||||
`[createAgent] Failed to grant owner permissions for agent ${agent.id}:`,
|
||||
permissionError,
|
||||
);
|
||||
}
|
||||
|
||||
res.status(201).json(agent);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
@@ -115,14 +89,21 @@ const createAgentHandler = async (req, res) => {
|
||||
* @returns {Promise<Agent>} 200 - success response - application/json
|
||||
* @returns {Error} 404 - Agent not found
|
||||
*/
|
||||
const getAgentHandler = async (req, res, expandProperties = false) => {
|
||||
const getAgentHandler = async (req, res) => {
|
||||
try {
|
||||
const id = req.params.id;
|
||||
const author = req.user.id;
|
||||
|
||||
// Permissions are validated by middleware before calling this function
|
||||
// Simply load the agent by ID
|
||||
const agent = await getAgent({ id });
|
||||
let query = { id, author };
|
||||
|
||||
const globalProject = await getProjectByName(Constants.GLOBAL_PROJECT_NAME, ['agentIds']);
|
||||
if (globalProject && (globalProject.agentIds?.length ?? 0) > 0) {
|
||||
query = {
|
||||
$or: [{ id, $in: globalProject.agentIds }, query],
|
||||
};
|
||||
}
|
||||
|
||||
const agent = await getAgent(query);
|
||||
|
||||
if (!agent) {
|
||||
return res.status(404).json({ error: 'Agent not found' });
|
||||
@@ -139,45 +120,23 @@ const getAgentHandler = async (req, res, expandProperties = false) => {
|
||||
}
|
||||
|
||||
agent.author = agent.author.toString();
|
||||
|
||||
// @deprecated - isCollaborative replaced by ACL permissions
|
||||
agent.isCollaborative = !!agent.isCollaborative;
|
||||
|
||||
// Check if agent is public
|
||||
const isPublic = await hasPublicPermission({
|
||||
resourceType: 'agent',
|
||||
resourceId: agent._id,
|
||||
requiredPermissions: PermissionBits.VIEW,
|
||||
});
|
||||
agent.isPublic = isPublic;
|
||||
|
||||
if (agent.author !== author) {
|
||||
delete agent.author;
|
||||
}
|
||||
|
||||
if (!expandProperties) {
|
||||
// VIEW permission: Basic agent info only
|
||||
if (!agent.isCollaborative && agent.author !== author && req.user.role !== SystemRoles.ADMIN) {
|
||||
return res.status(200).json({
|
||||
_id: agent._id,
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
description: agent.description,
|
||||
avatar: agent.avatar,
|
||||
author: agent.author,
|
||||
provider: agent.provider,
|
||||
model: agent.model,
|
||||
projectIds: agent.projectIds,
|
||||
// @deprecated - isCollaborative replaced by ACL permissions
|
||||
isCollaborative: agent.isCollaborative,
|
||||
isPublic: agent.isPublic,
|
||||
version: agent.version,
|
||||
// Safe metadata
|
||||
createdAt: agent.createdAt,
|
||||
updatedAt: agent.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
// EDIT permission: Full agent details including sensitive configuration
|
||||
return res.status(200).json(agent);
|
||||
} catch (error) {
|
||||
logger.error('[/Agents/:id] Error retrieving agent', error);
|
||||
@@ -198,20 +157,43 @@ const updateAgentHandler = async (req, res) => {
|
||||
try {
|
||||
const id = req.params.id;
|
||||
const validatedData = agentUpdateSchema.parse(req.body);
|
||||
const { _id, ...updateData } = removeNullishValues(validatedData);
|
||||
const { projectIds, removeProjectIds, ...updateData } = removeNullishValues(validatedData);
|
||||
const isAdmin = req.user.role === SystemRoles.ADMIN;
|
||||
const existingAgent = await getAgent({ id });
|
||||
|
||||
if (!existingAgent) {
|
||||
return res.status(404).json({ error: 'Agent not found' });
|
||||
}
|
||||
|
||||
const isAuthor = existingAgent.author.toString() === req.user.id;
|
||||
const hasEditPermission = existingAgent.isCollaborative || isAdmin || isAuthor;
|
||||
|
||||
if (!hasEditPermission) {
|
||||
return res.status(403).json({
|
||||
error: 'You do not have permission to modify this non-collaborative agent',
|
||||
});
|
||||
}
|
||||
|
||||
/** @type {boolean} */
|
||||
const isProjectUpdate = (projectIds?.length ?? 0) > 0 || (removeProjectIds?.length ?? 0) > 0;
|
||||
|
||||
let updatedAgent =
|
||||
Object.keys(updateData).length > 0
|
||||
? await updateAgent({ id }, updateData, {
|
||||
updatingUserId: req.user.id,
|
||||
skipVersioning: isProjectUpdate,
|
||||
})
|
||||
: existingAgent;
|
||||
|
||||
if (isProjectUpdate) {
|
||||
updatedAgent = await updateAgentProjects({
|
||||
user: req.user,
|
||||
agentId: id,
|
||||
projectIds,
|
||||
removeProjectIds,
|
||||
});
|
||||
}
|
||||
|
||||
if (updatedAgent.author) {
|
||||
updatedAgent.author = updatedAgent.author.toString();
|
||||
}
|
||||
@@ -336,26 +318,6 @@ const duplicateAgentHandler = async (req, res) => {
|
||||
newAgentData.actions = agentActions;
|
||||
const newAgent = await createAgent(newAgentData);
|
||||
|
||||
// Automatically grant owner permissions to the duplicator
|
||||
try {
|
||||
await grantPermission({
|
||||
principalType: 'user',
|
||||
principalId: userId,
|
||||
resourceType: 'agent',
|
||||
resourceId: newAgent._id,
|
||||
accessRoleId: 'agent_owner',
|
||||
grantedBy: userId,
|
||||
});
|
||||
logger.debug(
|
||||
`[duplicateAgent] Granted owner permissions to user ${userId} for duplicated agent ${newAgent.id}`,
|
||||
);
|
||||
} catch (permissionError) {
|
||||
logger.error(
|
||||
`[duplicateAgent] Failed to grant owner permissions for duplicated agent ${newAgent.id}:`,
|
||||
permissionError,
|
||||
);
|
||||
}
|
||||
|
||||
return res.status(201).json({
|
||||
agent: newAgent,
|
||||
actions: newActionsList,
|
||||
@@ -382,7 +344,7 @@ const deleteAgentHandler = async (req, res) => {
|
||||
if (!agent) {
|
||||
return res.status(404).json({ error: 'Agent not found' });
|
||||
}
|
||||
await deleteAgent({ id });
|
||||
await deleteAgent({ id, author: req.user.id });
|
||||
return res.json({ message: 'Agent deleted' });
|
||||
} catch (error) {
|
||||
logger.error('[/Agents/:id] Error deleting Agent', error);
|
||||
@@ -391,7 +353,7 @@ const deleteAgentHandler = async (req, res) => {
|
||||
};
|
||||
|
||||
/**
|
||||
* Lists agents using ACL-aware permissions (ownership + explicit shares).
|
||||
*
|
||||
* @route GET /Agents
|
||||
* @param {object} req - Express Request
|
||||
* @param {object} req.query - Request query
|
||||
@@ -400,64 +362,9 @@ 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: requiredPermission,
|
||||
const data = await getListAgents({
|
||||
author: req.user.id,
|
||||
});
|
||||
const publiclyAccessibleIds = await findPubliclyAccessibleResources({
|
||||
resourceType: 'agent',
|
||||
requiredPermissions: PermissionBits.VIEW,
|
||||
});
|
||||
// Use the new ACL-aware function
|
||||
const data = await getListAgentsByAccess({
|
||||
accessibleIds,
|
||||
otherParams: filter,
|
||||
limit,
|
||||
after: cursor,
|
||||
});
|
||||
if (data?.data?.length) {
|
||||
data.data = data.data.map((agent) => {
|
||||
if (publiclyAccessibleIds.some((id) => id.equals(agent._id))) {
|
||||
agent.isPublic = true;
|
||||
}
|
||||
return agent;
|
||||
});
|
||||
}
|
||||
return res.json(data);
|
||||
} catch (error) {
|
||||
logger.error('[/Agents] Error listing Agents', error);
|
||||
@@ -624,48 +531,7 @@ 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,
|
||||
@@ -675,5 +541,4 @@ module.exports = {
|
||||
getListAgents: getListAgentsHandler,
|
||||
uploadAgentAvatar: uploadAgentAvatarHandler,
|
||||
revertAgentVersion: revertAgentVersionHandler,
|
||||
getAgentCategories,
|
||||
};
|
||||
|
||||
@@ -372,6 +372,52 @@ 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;
|
||||
@@ -509,6 +555,45 @@ 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();
|
||||
|
||||
@@ -16,7 +16,7 @@ const { connectDb, indexSync } = require('~/db');
|
||||
const validateImageRequest = require('./middleware/validateImageRequest');
|
||||
const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
|
||||
const errorController = require('./controllers/ErrorController');
|
||||
const initializeMCP = require('./services/initializeMCP');
|
||||
const initializeMCPs = require('./services/initializeMCPs');
|
||||
const configureSocialLogins = require('./socialLogins');
|
||||
const AppService = require('./services/AppService');
|
||||
const staticCache = require('./utils/staticCache');
|
||||
@@ -117,8 +117,6 @@ const startServer = async () => {
|
||||
app.use('/api/agents', routes.agents);
|
||||
app.use('/api/banner', routes.banner);
|
||||
app.use('/api/memories', routes.memories);
|
||||
app.use('/api/permissions', routes.accessPermissions);
|
||||
|
||||
app.use('/api/tags', routes.tags);
|
||||
app.use('/api/mcp', routes.mcp);
|
||||
|
||||
@@ -148,7 +146,7 @@ const startServer = async () => {
|
||||
logger.info(`Server listening at http://${host == '0.0.0.0' ? 'localhost' : host}:${port}`);
|
||||
}
|
||||
|
||||
initializeMCP(app);
|
||||
initializeMCPs(app);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { Constants, isAgentsEndpoint } = require('librechat-data-provider');
|
||||
const { canAccessResource } = require('./canAccessResource');
|
||||
const { getAgent } = require('~/models/Agent');
|
||||
|
||||
/**
|
||||
* Agent ID resolver function for agent_id from request body
|
||||
* Resolves custom agent ID (e.g., "agent_abc123") to MongoDB ObjectId
|
||||
* This is used specifically for chat routes where agent_id comes from request body
|
||||
*
|
||||
* @param {string} agentCustomId - Custom agent ID from request body
|
||||
* @returns {Promise<Object|null>} Agent document with _id field, or null if not found
|
||||
*/
|
||||
const resolveAgentIdFromBody = async (agentCustomId) => {
|
||||
// Handle ephemeral agents - they don't need permission checks
|
||||
if (agentCustomId === Constants.EPHEMERAL_AGENT_ID) {
|
||||
return null; // No permission check needed for ephemeral agents
|
||||
}
|
||||
|
||||
return await getAgent({ id: agentCustomId });
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware factory that creates middleware to check agent access permissions from request body.
|
||||
* This middleware is specifically designed for chat routes where the agent_id comes from req.body
|
||||
* instead of route parameters.
|
||||
*
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
|
||||
* @returns {Function} Express middleware function
|
||||
*
|
||||
* @example
|
||||
* // Basic usage for agent chat (requires VIEW permission)
|
||||
* router.post('/chat',
|
||||
* canAccessAgentFromBody({ requiredPermission: PermissionBits.VIEW }),
|
||||
* buildEndpointOption,
|
||||
* chatController
|
||||
* );
|
||||
*/
|
||||
const canAccessAgentFromBody = (options) => {
|
||||
const { requiredPermission } = options;
|
||||
|
||||
// Validate required options
|
||||
if (!requiredPermission || typeof requiredPermission !== 'number') {
|
||||
throw new Error('canAccessAgentFromBody: requiredPermission is required and must be a number');
|
||||
}
|
||||
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
const { endpoint, agent_id } = req.body;
|
||||
let agentId = agent_id;
|
||||
|
||||
if (!isAgentsEndpoint(endpoint)) {
|
||||
agentId = Constants.EPHEMERAL_AGENT_ID;
|
||||
}
|
||||
|
||||
if (!agentId) {
|
||||
return res.status(400).json({
|
||||
error: 'Bad Request',
|
||||
message: 'agent_id is required in request body',
|
||||
});
|
||||
}
|
||||
|
||||
// Skip permission checks for ephemeral agents
|
||||
if (agentId === Constants.EPHEMERAL_AGENT_ID) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const agentAccessMiddleware = canAccessResource({
|
||||
resourceType: 'agent',
|
||||
requiredPermission,
|
||||
resourceIdParam: 'agent_id', // This will be ignored since we use custom resolver
|
||||
idResolver: () => resolveAgentIdFromBody(agentId),
|
||||
});
|
||||
|
||||
const tempReq = {
|
||||
...req,
|
||||
params: {
|
||||
...req.params,
|
||||
agent_id: agentId,
|
||||
},
|
||||
};
|
||||
|
||||
return agentAccessMiddleware(tempReq, res, next);
|
||||
} catch (error) {
|
||||
logger.error('Failed to validate agent access permissions', error);
|
||||
return res.status(500).json({
|
||||
error: 'Internal Server Error',
|
||||
message: 'Failed to validate agent access permissions',
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
canAccessAgentFromBody,
|
||||
};
|
||||
@@ -1,58 +0,0 @@
|
||||
const { getAgent } = require('~/models/Agent');
|
||||
const { canAccessResource } = require('./canAccessResource');
|
||||
|
||||
/**
|
||||
* Agent ID resolver function
|
||||
* Resolves custom agent ID (e.g., "agent_abc123") to MongoDB ObjectId
|
||||
*
|
||||
* @param {string} agentCustomId - Custom agent ID from route parameter
|
||||
* @returns {Promise<Object|null>} Agent document with _id field, or null if not found
|
||||
*/
|
||||
const resolveAgentId = async (agentCustomId) => {
|
||||
return await getAgent({ id: agentCustomId });
|
||||
};
|
||||
|
||||
/**
|
||||
* Agent-specific middleware factory that creates middleware to check agent access permissions.
|
||||
* This middleware extends the generic canAccessResource to handle agent custom ID resolution.
|
||||
*
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
|
||||
* @param {string} [options.resourceIdParam='id'] - The name of the route parameter containing the agent custom ID
|
||||
* @returns {Function} Express middleware function
|
||||
*
|
||||
* @example
|
||||
* // Basic usage for viewing agents
|
||||
* router.get('/agents/:id',
|
||||
* canAccessAgentResource({ requiredPermission: 1 }),
|
||||
* getAgent
|
||||
* );
|
||||
*
|
||||
* @example
|
||||
* // Custom resource ID parameter and edit permission
|
||||
* router.patch('/agents/:agent_id',
|
||||
* canAccessAgentResource({
|
||||
* requiredPermission: 2,
|
||||
* resourceIdParam: 'agent_id'
|
||||
* }),
|
||||
* updateAgent
|
||||
* );
|
||||
*/
|
||||
const canAccessAgentResource = (options) => {
|
||||
const { requiredPermission, resourceIdParam = 'id' } = options;
|
||||
|
||||
if (!requiredPermission || typeof requiredPermission !== 'number') {
|
||||
throw new Error('canAccessAgentResource: requiredPermission is required and must be a number');
|
||||
}
|
||||
|
||||
return canAccessResource({
|
||||
resourceType: 'agent',
|
||||
requiredPermission,
|
||||
resourceIdParam,
|
||||
idResolver: resolveAgentId,
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
canAccessAgentResource,
|
||||
};
|
||||
@@ -1,384 +0,0 @@
|
||||
const mongoose = require('mongoose');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
const { canAccessAgentResource } = require('./canAccessAgentResource');
|
||||
const { User, Role, AclEntry } = require('~/db/models');
|
||||
const { createAgent } = require('~/models/Agent');
|
||||
|
||||
describe('canAccessAgentResource middleware', () => {
|
||||
let mongoServer;
|
||||
let req, res, next;
|
||||
let testUser;
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
const mongoUri = mongoServer.getUri();
|
||||
await mongoose.connect(mongoUri);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
await mongoServer.stop();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await mongoose.connection.dropDatabase();
|
||||
await Role.create({
|
||||
name: 'test-role',
|
||||
permissions: {
|
||||
AGENTS: {
|
||||
USE: true,
|
||||
CREATE: true,
|
||||
SHARED_GLOBAL: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create a test user
|
||||
testUser = await User.create({
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
username: 'testuser',
|
||||
role: 'test-role',
|
||||
});
|
||||
|
||||
req = {
|
||||
user: { id: testUser._id.toString(), role: 'test-role' },
|
||||
params: {},
|
||||
};
|
||||
res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn(),
|
||||
};
|
||||
next = jest.fn();
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('middleware factory', () => {
|
||||
test('should throw error if requiredPermission is not provided', () => {
|
||||
expect(() => canAccessAgentResource({})).toThrow(
|
||||
'canAccessAgentResource: requiredPermission is required and must be a number',
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw error if requiredPermission is not a number', () => {
|
||||
expect(() => canAccessAgentResource({ requiredPermission: '1' })).toThrow(
|
||||
'canAccessAgentResource: requiredPermission is required and must be a number',
|
||||
);
|
||||
});
|
||||
|
||||
test('should create middleware with default resourceIdParam', () => {
|
||||
const middleware = canAccessAgentResource({ requiredPermission: 1 });
|
||||
expect(typeof middleware).toBe('function');
|
||||
expect(middleware.length).toBe(3); // Express middleware signature
|
||||
});
|
||||
|
||||
test('should create middleware with custom resourceIdParam', () => {
|
||||
const middleware = canAccessAgentResource({
|
||||
requiredPermission: 2,
|
||||
resourceIdParam: 'agent_id',
|
||||
});
|
||||
expect(typeof middleware).toBe('function');
|
||||
expect(middleware.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission checking with real agents', () => {
|
||||
test('should allow access when user is the agent author', async () => {
|
||||
// Create an agent owned by the test user
|
||||
const agent = await createAgent({
|
||||
id: `agent_${Date.now()}`,
|
||||
name: 'Test Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: testUser._id,
|
||||
});
|
||||
|
||||
// Create ACL entry for the author (owner permissions)
|
||||
await AclEntry.create({
|
||||
principalType: 'user',
|
||||
principalId: testUser._id,
|
||||
principalModel: 'User',
|
||||
resourceType: 'agent',
|
||||
resourceId: agent._id,
|
||||
permBits: 15, // All permissions (1+2+4+8)
|
||||
grantedBy: testUser._id,
|
||||
});
|
||||
|
||||
req.params.id = agent.id;
|
||||
|
||||
const middleware = canAccessAgentResource({ requiredPermission: 1 }); // VIEW permission
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should deny access when user is not the author and has no ACL entry', async () => {
|
||||
// Create an agent owned by a different user
|
||||
const otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
name: 'Other User',
|
||||
username: 'otheruser',
|
||||
role: 'test-role',
|
||||
});
|
||||
|
||||
const agent = await createAgent({
|
||||
id: `agent_${Date.now()}`,
|
||||
name: 'Other User Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: otherUser._id,
|
||||
});
|
||||
|
||||
// Create ACL entry for the other user (owner)
|
||||
await AclEntry.create({
|
||||
principalType: 'user',
|
||||
principalId: otherUser._id,
|
||||
principalModel: 'User',
|
||||
resourceType: 'agent',
|
||||
resourceId: agent._id,
|
||||
permBits: 15, // All permissions
|
||||
grantedBy: otherUser._id,
|
||||
});
|
||||
|
||||
req.params.id = agent.id;
|
||||
|
||||
const middleware = canAccessAgentResource({ requiredPermission: 1 }); // VIEW permission
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Forbidden',
|
||||
message: 'Insufficient permissions to access this agent',
|
||||
});
|
||||
});
|
||||
|
||||
test('should allow access when user has ACL entry with sufficient permissions', async () => {
|
||||
// Create an agent owned by a different user
|
||||
const otherUser = await User.create({
|
||||
email: 'other2@example.com',
|
||||
name: 'Other User 2',
|
||||
username: 'otheruser2',
|
||||
role: 'test-role',
|
||||
});
|
||||
|
||||
const agent = await createAgent({
|
||||
id: `agent_${Date.now()}`,
|
||||
name: 'Shared Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: otherUser._id,
|
||||
});
|
||||
|
||||
// Create ACL entry granting view permission to test user
|
||||
await AclEntry.create({
|
||||
principalType: 'user',
|
||||
principalId: testUser._id,
|
||||
principalModel: 'User',
|
||||
resourceType: 'agent',
|
||||
resourceId: agent._id,
|
||||
permBits: 1, // VIEW permission
|
||||
grantedBy: otherUser._id,
|
||||
});
|
||||
|
||||
req.params.id = agent.id;
|
||||
|
||||
const middleware = canAccessAgentResource({ requiredPermission: 1 }); // VIEW permission
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should deny access when ACL permissions are insufficient', async () => {
|
||||
// Create an agent owned by a different user
|
||||
const otherUser = await User.create({
|
||||
email: 'other3@example.com',
|
||||
name: 'Other User 3',
|
||||
username: 'otheruser3',
|
||||
role: 'test-role',
|
||||
});
|
||||
|
||||
const agent = await createAgent({
|
||||
id: `agent_${Date.now()}`,
|
||||
name: 'Limited Access Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: otherUser._id,
|
||||
});
|
||||
|
||||
// Create ACL entry granting only view permission
|
||||
await AclEntry.create({
|
||||
principalType: 'user',
|
||||
principalId: testUser._id,
|
||||
principalModel: 'User',
|
||||
resourceType: 'agent',
|
||||
resourceId: agent._id,
|
||||
permBits: 1, // VIEW permission only
|
||||
grantedBy: otherUser._id,
|
||||
});
|
||||
|
||||
req.params.id = agent.id;
|
||||
|
||||
const middleware = canAccessAgentResource({ requiredPermission: 2 }); // EDIT permission required
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Forbidden',
|
||||
message: 'Insufficient permissions to access this agent',
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle non-existent agent', async () => {
|
||||
req.params.id = 'agent_nonexistent';
|
||||
|
||||
const middleware = canAccessAgentResource({ requiredPermission: 1 });
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Not Found',
|
||||
message: 'agent not found',
|
||||
});
|
||||
});
|
||||
|
||||
test('should use custom resourceIdParam', async () => {
|
||||
const agent = await createAgent({
|
||||
id: `agent_${Date.now()}`,
|
||||
name: 'Custom Param Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: testUser._id,
|
||||
});
|
||||
|
||||
// Create ACL entry for the author
|
||||
await AclEntry.create({
|
||||
principalType: 'user',
|
||||
principalId: testUser._id,
|
||||
principalModel: 'User',
|
||||
resourceType: 'agent',
|
||||
resourceId: agent._id,
|
||||
permBits: 15, // All permissions
|
||||
grantedBy: testUser._id,
|
||||
});
|
||||
|
||||
req.params.agent_id = agent.id; // Using custom param name
|
||||
|
||||
const middleware = canAccessAgentResource({
|
||||
requiredPermission: 1,
|
||||
resourceIdParam: 'agent_id',
|
||||
});
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission levels', () => {
|
||||
let agent;
|
||||
|
||||
beforeEach(async () => {
|
||||
agent = await createAgent({
|
||||
id: `agent_${Date.now()}`,
|
||||
name: 'Permission Test Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: testUser._id,
|
||||
});
|
||||
|
||||
// Create ACL entry with all permissions for the owner
|
||||
await AclEntry.create({
|
||||
principalType: 'user',
|
||||
principalId: testUser._id,
|
||||
principalModel: 'User',
|
||||
resourceType: 'agent',
|
||||
resourceId: agent._id,
|
||||
permBits: 15, // All permissions (1+2+4+8)
|
||||
grantedBy: testUser._id,
|
||||
});
|
||||
|
||||
req.params.id = agent.id;
|
||||
});
|
||||
|
||||
test('should support view permission (1)', async () => {
|
||||
const middleware = canAccessAgentResource({ requiredPermission: 1 });
|
||||
await middleware(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should support edit permission (2)', async () => {
|
||||
const middleware = canAccessAgentResource({ requiredPermission: 2 });
|
||||
await middleware(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should support delete permission (4)', async () => {
|
||||
const middleware = canAccessAgentResource({ requiredPermission: 4 });
|
||||
await middleware(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should support share permission (8)', async () => {
|
||||
const middleware = canAccessAgentResource({ requiredPermission: 8 });
|
||||
await middleware(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should support combined permissions', async () => {
|
||||
const viewAndEdit = 1 | 2; // 3
|
||||
const middleware = canAccessAgentResource({ requiredPermission: viewAndEdit });
|
||||
await middleware(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration with agent operations', () => {
|
||||
test('should work with agent CRUD operations', async () => {
|
||||
const agentId = `agent_${Date.now()}`;
|
||||
|
||||
// Create agent
|
||||
const agent = await createAgent({
|
||||
id: agentId,
|
||||
name: 'Integration Test Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: testUser._id,
|
||||
description: 'Testing integration',
|
||||
});
|
||||
|
||||
// Create ACL entry for the author
|
||||
await AclEntry.create({
|
||||
principalType: 'user',
|
||||
principalId: testUser._id,
|
||||
principalModel: 'User',
|
||||
resourceType: 'agent',
|
||||
resourceId: agent._id,
|
||||
permBits: 15, // All permissions
|
||||
grantedBy: testUser._id,
|
||||
});
|
||||
|
||||
req.params.id = agentId;
|
||||
|
||||
// Test view access
|
||||
const viewMiddleware = canAccessAgentResource({ requiredPermission: 1 });
|
||||
await viewMiddleware(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Update the agent
|
||||
const { updateAgent } = require('~/models/Agent');
|
||||
await updateAgent({ id: agentId }, { description: 'Updated description' });
|
||||
|
||||
// Test edit access
|
||||
const editMiddleware = canAccessAgentResource({ requiredPermission: 2 });
|
||||
await editMiddleware(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,157 +0,0 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { SystemRoles } = require('librechat-data-provider');
|
||||
const { checkPermission } = require('~/server/services/PermissionService');
|
||||
|
||||
/**
|
||||
* Generic base middleware factory that creates middleware to check resource access permissions.
|
||||
* This middleware expects MongoDB ObjectIds as resource identifiers for ACL permission checks.
|
||||
*
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {string} options.resourceType - The type of resource (e.g., 'agent', 'file', 'project')
|
||||
* @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
|
||||
* @param {string} [options.resourceIdParam='resourceId'] - The name of the route parameter containing the resource ID
|
||||
* @param {Function} [options.idResolver] - Optional function to resolve custom IDs to ObjectIds
|
||||
* @returns {Function} Express middleware function
|
||||
*
|
||||
* @example
|
||||
* // Direct usage with ObjectId (for resources that use MongoDB ObjectId in routes)
|
||||
* router.get('/prompts/:promptId',
|
||||
* canAccessResource({ resourceType: 'prompt', requiredPermission: 1 }),
|
||||
* getPrompt
|
||||
* );
|
||||
*
|
||||
* @example
|
||||
* // Usage with custom ID resolver (for resources that use custom string IDs)
|
||||
* router.get('/agents/:id',
|
||||
* canAccessResource({
|
||||
* resourceType: 'agent',
|
||||
* requiredPermission: 1,
|
||||
* resourceIdParam: 'id',
|
||||
* idResolver: (customId) => resolveAgentId(customId)
|
||||
* }),
|
||||
* getAgent
|
||||
* );
|
||||
*/
|
||||
const canAccessResource = (options) => {
|
||||
const {
|
||||
resourceType,
|
||||
requiredPermission,
|
||||
resourceIdParam = 'resourceId',
|
||||
idResolver = null,
|
||||
} = options;
|
||||
|
||||
if (!resourceType || typeof resourceType !== 'string') {
|
||||
throw new Error('canAccessResource: resourceType is required and must be a string');
|
||||
}
|
||||
|
||||
if (!requiredPermission || typeof requiredPermission !== 'number') {
|
||||
throw new Error('canAccessResource: requiredPermission is required and must be a number');
|
||||
}
|
||||
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
// Extract resource ID from route parameters
|
||||
const rawResourceId = req.params[resourceIdParam];
|
||||
|
||||
if (!rawResourceId) {
|
||||
logger.warn(`[canAccessResource] Missing ${resourceIdParam} in route parameters`);
|
||||
return res.status(400).json({
|
||||
error: 'Bad Request',
|
||||
message: `${resourceIdParam} is required`,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user is authenticated
|
||||
if (!req.user || !req.user.id) {
|
||||
logger.warn(
|
||||
`[canAccessResource] Unauthenticated request for ${resourceType} ${rawResourceId}`,
|
||||
);
|
||||
return res.status(401).json({
|
||||
error: 'Unauthorized',
|
||||
message: 'Authentication required',
|
||||
});
|
||||
}
|
||||
// if system admin let through
|
||||
if (req.user.role === SystemRoles.ADMIN) {
|
||||
return next();
|
||||
}
|
||||
const userId = req.user.id;
|
||||
let resourceId = rawResourceId;
|
||||
let resourceInfo = null;
|
||||
|
||||
// Resolve custom ID to ObjectId if resolver is provided
|
||||
if (idResolver) {
|
||||
logger.debug(
|
||||
`[canAccessResource] Resolving ${resourceType} custom ID ${rawResourceId} to ObjectId`,
|
||||
);
|
||||
|
||||
const resolutionResult = await idResolver(rawResourceId);
|
||||
|
||||
if (!resolutionResult) {
|
||||
logger.warn(`[canAccessResource] ${resourceType} not found: ${rawResourceId}`);
|
||||
return res.status(404).json({
|
||||
error: 'Not Found',
|
||||
message: `${resourceType} not found`,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle different resolver return formats
|
||||
if (typeof resolutionResult === 'string' || resolutionResult._id) {
|
||||
resourceId = resolutionResult._id || resolutionResult;
|
||||
resourceInfo = typeof resolutionResult === 'object' ? resolutionResult : null;
|
||||
} else {
|
||||
resourceId = resolutionResult;
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`[canAccessResource] Resolved ${resourceType} ${rawResourceId} to ObjectId ${resourceId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check permissions using PermissionService with ObjectId
|
||||
const hasPermission = await checkPermission({
|
||||
userId,
|
||||
resourceType,
|
||||
resourceId,
|
||||
requiredPermission,
|
||||
});
|
||||
|
||||
if (hasPermission) {
|
||||
logger.debug(
|
||||
`[canAccessResource] User ${userId} has permission ${requiredPermission} on ${resourceType} ${rawResourceId} (${resourceId})`,
|
||||
);
|
||||
|
||||
req.resourceAccess = {
|
||||
resourceType,
|
||||
resourceId, // MongoDB ObjectId for ACL operations
|
||||
customResourceId: rawResourceId, // Original ID from route params
|
||||
permission: requiredPermission,
|
||||
userId,
|
||||
...(resourceInfo && { resourceInfo }),
|
||||
};
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`[canAccessResource] User ${userId} denied access to ${resourceType} ${rawResourceId} ` +
|
||||
`(required permission: ${requiredPermission})`,
|
||||
);
|
||||
|
||||
return res.status(403).json({
|
||||
error: 'Forbidden',
|
||||
message: `Insufficient permissions to access this ${resourceType}`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`[canAccessResource] Error checking access for ${resourceType}:`, error);
|
||||
return res.status(500).json({
|
||||
error: 'Internal Server Error',
|
||||
message: 'Failed to check resource access permissions',
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
canAccessResource,
|
||||
};
|
||||
@@ -1,9 +0,0 @@
|
||||
const { canAccessResource } = require('./canAccessResource');
|
||||
const { canAccessAgentResource } = require('./canAccessAgentResource');
|
||||
const { canAccessAgentFromBody } = require('./canAccessAgentFromBody');
|
||||
|
||||
module.exports = {
|
||||
canAccessResource,
|
||||
canAccessAgentResource,
|
||||
canAccessAgentFromBody,
|
||||
};
|
||||
@@ -1,72 +0,0 @@
|
||||
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,
|
||||
};
|
||||
@@ -8,7 +8,6 @@ const concurrentLimiter = require('./concurrentLimiter');
|
||||
const validateEndpoint = require('./validateEndpoint');
|
||||
const requireLocalAuth = require('./requireLocalAuth');
|
||||
const canDeleteAccount = require('./canDeleteAccount');
|
||||
const accessResources = require('./accessResources');
|
||||
const setBalanceConfig = require('./setBalanceConfig');
|
||||
const requireLdapAuth = require('./requireLdapAuth');
|
||||
const abortMiddleware = require('./abortMiddleware');
|
||||
@@ -30,7 +29,6 @@ module.exports = {
|
||||
...validate,
|
||||
...limiters,
|
||||
...roles,
|
||||
...accessResources,
|
||||
noIndex,
|
||||
checkBan,
|
||||
uaParser,
|
||||
|
||||
@@ -1,370 +0,0 @@
|
||||
const mongoose = require('mongoose');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
const { checkAccess, generateCheckAccess } = require('@librechat/api');
|
||||
const { PermissionTypes, Permissions } = require('librechat-data-provider');
|
||||
const { getRoleByName } = require('~/models/Role');
|
||||
const { Role } = require('~/db/models');
|
||||
|
||||
// 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;
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
const mongoUri = mongoServer.getUri();
|
||||
await mongoose.connect(mongoUri);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
await mongoServer.stop();
|
||||
});
|
||||
|
||||
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(),
|
||||
json: jest.fn(),
|
||||
};
|
||||
next = jest.fn();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('checkAccess', () => {
|
||||
test('should return false if user is not provided', async () => {
|
||||
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: { 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: { id: 'user123', role: 'user' },
|
||||
permissionType: PermissionTypes.AGENTS,
|
||||
permissions: [Permissions.CREATE],
|
||||
getRoleByName,
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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 () => {
|
||||
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 () => {
|
||||
const result = await checkAccess({
|
||||
req: {},
|
||||
user: { id: 'user123', role: 'limited' },
|
||||
permissionType: PermissionTypes.AGENTS,
|
||||
permissions: [Permissions.USE],
|
||||
getRoleByName,
|
||||
});
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test('should handle admin role with all permissions', async () => {
|
||||
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: { 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({
|
||||
permissionType: PermissionTypes.AGENTS,
|
||||
permissions: [Permissions.USE],
|
||||
getRoleByName,
|
||||
});
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should return 403 when user lacks permission', async () => {
|
||||
const middleware = generateCheckAccess({
|
||||
permissionType: PermissionTypes.AGENTS,
|
||||
permissions: [Permissions.CREATE],
|
||||
getRoleByName,
|
||||
});
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({ message: 'Forbidden: Insufficient permissions' });
|
||||
});
|
||||
|
||||
test('should check body properties when configured', async () => {
|
||||
req.body = { agentId: 'agent123', description: 'test' };
|
||||
|
||||
const bodyProps = {
|
||||
[Permissions.CREATE]: ['agentId'],
|
||||
};
|
||||
|
||||
const middleware = generateCheckAccess({
|
||||
permissionType: PermissionTypes.AGENTS,
|
||||
permissions: [Permissions.CREATE],
|
||||
bodyProps,
|
||||
getRoleByName,
|
||||
});
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should handle database errors gracefully', async () => {
|
||||
// Mock getRoleByName to throw an error
|
||||
const mockGetRoleByName = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Database connection failed'));
|
||||
|
||||
const middleware = generateCheckAccess({
|
||||
permissionType: PermissionTypes.AGENTS,
|
||||
permissions: [Permissions.USE],
|
||||
getRoleByName: mockGetRoleByName,
|
||||
});
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
message: expect.stringContaining('Server error:'),
|
||||
});
|
||||
});
|
||||
|
||||
test('should work with multiple permission types', async () => {
|
||||
req.user.role = 'admin';
|
||||
|
||||
const middleware = generateCheckAccess({
|
||||
permissionType: PermissionTypes.AGENTS,
|
||||
permissions: [Permissions.USE, Permissions.CREATE, Permissions.SHARED_GLOBAL],
|
||||
getRoleByName,
|
||||
});
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should handle missing user gracefully', async () => {
|
||||
req.user = null;
|
||||
|
||||
const middleware = generateCheckAccess({
|
||||
permissionType: PermissionTypes.AGENTS,
|
||||
permissions: [Permissions.USE],
|
||||
getRoleByName,
|
||||
});
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({ message: 'Forbidden: Insufficient permissions' });
|
||||
});
|
||||
|
||||
test('should handle role with no AGENTS permissions', async () => {
|
||||
await Role.create({
|
||||
name: 'noaccess',
|
||||
permissions: {
|
||||
// Explicitly set AGENTS with all permissions false
|
||||
[PermissionTypes.AGENTS]: {
|
||||
[Permissions.USE]: false,
|
||||
[Permissions.CREATE]: false,
|
||||
[Permissions.SHARED_GLOBAL]: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
req.user.role = 'noaccess';
|
||||
|
||||
const middleware = generateCheckAccess({
|
||||
permissionType: PermissionTypes.AGENTS,
|
||||
permissions: [Permissions.USE],
|
||||
getRoleByName,
|
||||
});
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({ message: 'Forbidden: Insufficient permissions' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
const express = require('express');
|
||||
const { PermissionBits } = require('@librechat/data-schemas');
|
||||
const {
|
||||
getUserEffectivePermissions,
|
||||
updateResourcePermissions,
|
||||
getResourcePermissions,
|
||||
getResourceRoles,
|
||||
searchPrincipals,
|
||||
} = require('~/server/controllers/PermissionsController');
|
||||
const { requireJwtAuth, checkBan, uaParser, canAccessResource } = require('~/server/middleware');
|
||||
const { checkPeoplePickerAccess } = require('~/server/middleware/checkPeoplePickerAccess');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Apply common middleware
|
||||
router.use(requireJwtAuth);
|
||||
router.use(checkBan);
|
||||
router.use(uaParser);
|
||||
|
||||
/**
|
||||
* Generic routes for resource permissions
|
||||
* Pattern: /api/permissions/{resourceType}/{resourceId}
|
||||
*/
|
||||
|
||||
/**
|
||||
* GET /api/permissions/search-principals
|
||||
* Search for users and groups to grant permissions
|
||||
*/
|
||||
router.get('/search-principals', checkPeoplePickerAccess, searchPrincipals);
|
||||
|
||||
/**
|
||||
* GET /api/permissions/{resourceType}/roles
|
||||
* Get available roles for a resource type
|
||||
*/
|
||||
router.get('/:resourceType/roles', getResourceRoles);
|
||||
|
||||
/**
|
||||
* GET /api/permissions/{resourceType}/{resourceId}
|
||||
* Get all permissions for a specific resource
|
||||
*/
|
||||
router.get('/:resourceType/:resourceId', getResourcePermissions);
|
||||
|
||||
/**
|
||||
* PUT /api/permissions/{resourceType}/{resourceId}
|
||||
* Bulk update permissions for a specific resource
|
||||
*/
|
||||
router.put(
|
||||
'/:resourceType/:resourceId',
|
||||
canAccessResource({
|
||||
resourceType: 'agent',
|
||||
requiredPermission: PermissionBits.SHARE,
|
||||
resourceIdParam: 'resourceId',
|
||||
}),
|
||||
updateResourcePermissions,
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/permissions/{resourceType}/{resourceId}/effective
|
||||
* Get user's effective permissions for a specific resource
|
||||
*/
|
||||
router.get('/:resourceType/:resourceId/effective', getUserEffectivePermissions);
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,21 +1,19 @@
|
||||
const express = require('express');
|
||||
const { nanoid } = require('nanoid');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { generateCheckAccess } = require('@librechat/api');
|
||||
const { logger, PermissionBits } = require('@librechat/data-schemas');
|
||||
const {
|
||||
SystemRoles,
|
||||
Permissions,
|
||||
PermissionTypes,
|
||||
actionDelimiter,
|
||||
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();
|
||||
|
||||
@@ -25,6 +23,12 @@ const checkAgentCreate = generateCheckAccess({
|
||||
getRoleByName,
|
||||
});
|
||||
|
||||
// If the user has ADMIN role
|
||||
// then action edition is possible even if not owner of the assistant
|
||||
const isAdmin = (req) => {
|
||||
return req.user.role === SystemRoles.ADMIN;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves all user's actions
|
||||
* @route GET /actions/
|
||||
@@ -33,22 +37,10 @@ const checkAgentCreate = generateCheckAccess({
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
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);
|
||||
const admin = isAdmin(req);
|
||||
// If admin, get all actions, otherwise only user's actions
|
||||
const searchParams = admin ? {} : { user: req.user.id };
|
||||
res.json(await getActions(searchParams));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
@@ -63,111 +55,106 @@ router.get('/', async (req, res) => {
|
||||
* @param {ActionMetadata} req.body.metadata - Metadata for the action.
|
||||
* @returns {Object} 200 - success response - application/json
|
||||
*/
|
||||
router.post(
|
||||
'/:agent_id',
|
||||
canAccessAgentResource({
|
||||
requiredPermission: PermissionBits.EDIT,
|
||||
resourceIdParam: 'agent_id',
|
||||
}),
|
||||
checkAgentCreate,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { agent_id } = req.params;
|
||||
router.post('/:agent_id', checkAgentCreate, async (req, res) => {
|
||||
try {
|
||||
const { agent_id } = req.params;
|
||||
|
||||
/** @type {{ functions: FunctionTool[], action_id: string, metadata: ActionMetadata }} */
|
||||
const { functions, action_id: _action_id, metadata: _metadata } = req.body;
|
||||
if (!functions.length) {
|
||||
return res.status(400).json({ message: 'No functions provided' });
|
||||
}
|
||||
|
||||
let metadata = await encryptMetadata(removeNullishValues(_metadata, true));
|
||||
const isDomainAllowed = await isActionDomainAllowed(metadata.domain);
|
||||
if (!isDomainAllowed) {
|
||||
return res.status(400).json({ message: 'Domain not allowed' });
|
||||
}
|
||||
|
||||
let { domain } = metadata;
|
||||
domain = await domainParser(domain, true);
|
||||
|
||||
if (!domain) {
|
||||
return res.status(400).json({ message: 'No domain provided' });
|
||||
}
|
||||
|
||||
const action_id = _action_id ?? nanoid();
|
||||
const initialPromises = [];
|
||||
|
||||
// Permissions already validated by middleware - load agent directly
|
||||
initialPromises.push(getAgent({ id: agent_id }));
|
||||
if (_action_id) {
|
||||
initialPromises.push(getActions({ action_id }, true));
|
||||
}
|
||||
|
||||
/** @type {[Agent, [Action|undefined]]} */
|
||||
const [agent, actions_result] = await Promise.all(initialPromises);
|
||||
if (!agent) {
|
||||
return res.status(404).json({ message: 'Agent not found for adding action' });
|
||||
}
|
||||
|
||||
if (actions_result && actions_result.length) {
|
||||
const action = actions_result[0];
|
||||
metadata = { ...action.metadata, ...metadata };
|
||||
}
|
||||
|
||||
const { actions: _actions = [], author: agent_author } = agent ?? {};
|
||||
const actions = [];
|
||||
for (const action of _actions) {
|
||||
const [_action_domain, current_action_id] = action.split(actionDelimiter);
|
||||
if (current_action_id === action_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
actions.push(action);
|
||||
}
|
||||
|
||||
actions.push(`${domain}${actionDelimiter}${action_id}`);
|
||||
|
||||
/** @type {string[]}} */
|
||||
const { tools: _tools = [] } = agent;
|
||||
|
||||
const tools = _tools
|
||||
.filter((tool) => !(tool && (tool.includes(domain) || tool.includes(action_id))))
|
||||
.concat(functions.map((tool) => `${tool.function.name}${actionDelimiter}${domain}`));
|
||||
|
||||
// Force version update since actions are changing
|
||||
const updatedAgent = await updateAgent(
|
||||
{ id: agent_id },
|
||||
{ tools, actions },
|
||||
{
|
||||
updatingUserId: req.user.id,
|
||||
forceVersion: true,
|
||||
},
|
||||
);
|
||||
|
||||
// Only update user field for new actions
|
||||
const actionUpdateData = { metadata, agent_id };
|
||||
if (!actions_result || !actions_result.length) {
|
||||
// For new actions, use the agent owner's user ID
|
||||
actionUpdateData.user = agent_author || req.user.id;
|
||||
}
|
||||
|
||||
/** @type {[Action]} */
|
||||
const updatedAction = await updateAction({ action_id }, actionUpdateData);
|
||||
|
||||
const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret'];
|
||||
for (let field of sensitiveFields) {
|
||||
if (updatedAction.metadata[field]) {
|
||||
delete updatedAction.metadata[field];
|
||||
}
|
||||
}
|
||||
|
||||
res.json([updatedAgent, updatedAction]);
|
||||
} catch (error) {
|
||||
const message = 'Trouble updating the Agent Action';
|
||||
logger.error(message, error);
|
||||
res.status(500).json({ message });
|
||||
/** @type {{ functions: FunctionTool[], action_id: string, metadata: ActionMetadata }} */
|
||||
const { functions, action_id: _action_id, metadata: _metadata } = req.body;
|
||||
if (!functions.length) {
|
||||
return res.status(400).json({ message: 'No functions provided' });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
let metadata = await encryptMetadata(removeNullishValues(_metadata, true));
|
||||
const isDomainAllowed = await isActionDomainAllowed(metadata.domain);
|
||||
if (!isDomainAllowed) {
|
||||
return res.status(400).json({ message: 'Domain not allowed' });
|
||||
}
|
||||
|
||||
let { domain } = metadata;
|
||||
domain = await domainParser(domain, true);
|
||||
|
||||
if (!domain) {
|
||||
return res.status(400).json({ message: 'No domain provided' });
|
||||
}
|
||||
|
||||
const action_id = _action_id ?? nanoid();
|
||||
const initialPromises = [];
|
||||
const admin = isAdmin(req);
|
||||
|
||||
// If admin, can edit any agent, otherwise only user's agents
|
||||
const agentQuery = admin ? { id: agent_id } : { id: agent_id, author: req.user.id };
|
||||
// TODO: share agents
|
||||
initialPromises.push(getAgent(agentQuery));
|
||||
if (_action_id) {
|
||||
initialPromises.push(getActions({ action_id }, true));
|
||||
}
|
||||
|
||||
/** @type {[Agent, [Action|undefined]]} */
|
||||
const [agent, actions_result] = await Promise.all(initialPromises);
|
||||
if (!agent) {
|
||||
return res.status(404).json({ message: 'Agent not found for adding action' });
|
||||
}
|
||||
|
||||
if (actions_result && actions_result.length) {
|
||||
const action = actions_result[0];
|
||||
metadata = { ...action.metadata, ...metadata };
|
||||
}
|
||||
|
||||
const { actions: _actions = [], author: agent_author } = agent ?? {};
|
||||
const actions = [];
|
||||
for (const action of _actions) {
|
||||
const [_action_domain, current_action_id] = action.split(actionDelimiter);
|
||||
if (current_action_id === action_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
actions.push(action);
|
||||
}
|
||||
|
||||
actions.push(`${domain}${actionDelimiter}${action_id}`);
|
||||
|
||||
/** @type {string[]}} */
|
||||
const { tools: _tools = [] } = agent;
|
||||
|
||||
const tools = _tools
|
||||
.filter((tool) => !(tool && (tool.includes(domain) || tool.includes(action_id))))
|
||||
.concat(functions.map((tool) => `${tool.function.name}${actionDelimiter}${domain}`));
|
||||
|
||||
// Force version update since actions are changing
|
||||
const updatedAgent = await updateAgent(
|
||||
agentQuery,
|
||||
{ tools, actions },
|
||||
{
|
||||
updatingUserId: req.user.id,
|
||||
forceVersion: true,
|
||||
},
|
||||
);
|
||||
|
||||
// Only update user field for new actions
|
||||
const actionUpdateData = { metadata, agent_id };
|
||||
if (!actions_result || !actions_result.length) {
|
||||
// For new actions, use the agent owner's user ID
|
||||
actionUpdateData.user = agent_author || req.user.id;
|
||||
}
|
||||
|
||||
/** @type {[Action]} */
|
||||
const updatedAction = await updateAction({ action_id }, actionUpdateData);
|
||||
|
||||
const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret'];
|
||||
for (let field of sensitiveFields) {
|
||||
if (updatedAction.metadata[field]) {
|
||||
delete updatedAction.metadata[field];
|
||||
}
|
||||
}
|
||||
|
||||
res.json([updatedAgent, updatedAction]);
|
||||
} catch (error) {
|
||||
const message = 'Trouble updating the Agent Action';
|
||||
logger.error(message, error);
|
||||
res.status(500).json({ message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Deletes an action for a specific agent.
|
||||
@@ -176,56 +163,52 @@ router.post(
|
||||
* @param {string} req.params.action_id - The ID of the action to delete.
|
||||
* @returns {Object} 200 - success response - application/json
|
||||
*/
|
||||
router.delete(
|
||||
'/:agent_id/:action_id',
|
||||
canAccessAgentResource({
|
||||
requiredPermission: PermissionBits.EDIT,
|
||||
resourceIdParam: 'agent_id',
|
||||
}),
|
||||
checkAgentCreate,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { agent_id, action_id } = req.params;
|
||||
router.delete('/:agent_id/:action_id', checkAgentCreate, async (req, res) => {
|
||||
try {
|
||||
const { agent_id, action_id } = req.params;
|
||||
const admin = isAdmin(req);
|
||||
|
||||
// Permissions already validated by middleware - load agent directly
|
||||
const agent = await getAgent({ id: agent_id });
|
||||
if (!agent) {
|
||||
return res.status(404).json({ message: 'Agent not found for deleting action' });
|
||||
}
|
||||
|
||||
const { tools = [], actions = [] } = agent;
|
||||
|
||||
let domain = '';
|
||||
const updatedActions = actions.filter((action) => {
|
||||
if (action.includes(action_id)) {
|
||||
[domain] = action.split(actionDelimiter);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
domain = await domainParser(domain, true);
|
||||
|
||||
if (!domain) {
|
||||
return res.status(400).json({ message: 'No domain provided' });
|
||||
}
|
||||
|
||||
const updatedTools = tools.filter((tool) => !(tool && tool.includes(domain)));
|
||||
|
||||
// Force version update since actions are being removed
|
||||
await updateAgent(
|
||||
{ id: agent_id },
|
||||
{ tools: updatedTools, actions: updatedActions },
|
||||
{ updatingUserId: req.user.id, forceVersion: true },
|
||||
);
|
||||
await deleteAction({ action_id });
|
||||
res.status(200).json({ message: 'Action deleted successfully' });
|
||||
} catch (error) {
|
||||
const message = 'Trouble deleting the Agent Action';
|
||||
logger.error(message, error);
|
||||
res.status(500).json({ message });
|
||||
// If admin, can delete any agent, otherwise only user's agents
|
||||
const agentQuery = admin ? { id: agent_id } : { id: agent_id, author: req.user.id };
|
||||
const agent = await getAgent(agentQuery);
|
||||
if (!agent) {
|
||||
return res.status(404).json({ message: 'Agent not found for deleting action' });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
const { tools = [], actions = [] } = agent;
|
||||
|
||||
let domain = '';
|
||||
const updatedActions = actions.filter((action) => {
|
||||
if (action.includes(action_id)) {
|
||||
[domain] = action.split(actionDelimiter);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
domain = await domainParser(domain, true);
|
||||
|
||||
if (!domain) {
|
||||
return res.status(400).json({ message: 'No domain provided' });
|
||||
}
|
||||
|
||||
const updatedTools = tools.filter((tool) => !(tool && tool.includes(domain)));
|
||||
|
||||
// Force version update since actions are being removed
|
||||
await updateAgent(
|
||||
agentQuery,
|
||||
{ tools: updatedTools, actions: updatedActions },
|
||||
{ updatingUserId: req.user.id, forceVersion: true },
|
||||
);
|
||||
// If admin, can delete any action, otherwise only user's actions
|
||||
const actionQuery = admin ? { action_id } : { action_id, user: req.user.id };
|
||||
await deleteAction(actionQuery);
|
||||
res.status(200).json({ message: 'Action deleted successfully' });
|
||||
} catch (error) {
|
||||
const message = 'Trouble deleting the Agent Action';
|
||||
logger.error(message, error);
|
||||
res.status(500).json({ message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
const express = require('express');
|
||||
const { PermissionBits } = require('@librechat/data-schemas');
|
||||
const { generateCheckAccess, skipAgentCheck } = require('@librechat/api');
|
||||
const { PermissionTypes, Permissions } = require('librechat-data-provider');
|
||||
const {
|
||||
@@ -8,7 +7,6 @@ const {
|
||||
// validateModel,
|
||||
validateConvoAccess,
|
||||
buildEndpointOption,
|
||||
canAccessAgentFromBody,
|
||||
} = require('~/server/middleware');
|
||||
const { initializeClient } = require('~/server/services/Endpoints/agents');
|
||||
const AgentController = require('~/server/controllers/agents/request');
|
||||
@@ -25,12 +23,8 @@ const checkAgentAccess = generateCheckAccess({
|
||||
skipCheck: skipAgentCheck,
|
||||
getRoleByName,
|
||||
});
|
||||
const checkAgentResourceAccess = canAccessAgentFromBody({
|
||||
requiredPermission: PermissionBits.VIEW,
|
||||
});
|
||||
|
||||
router.use(checkAgentAccess);
|
||||
router.use(checkAgentResourceAccess);
|
||||
router.use(validateConvoAccess);
|
||||
router.use(buildEndpointOption);
|
||||
router.use(setHeaders);
|
||||
|
||||
@@ -37,6 +37,4 @@ if (isEnabled(LIMIT_MESSAGE_USER)) {
|
||||
chatRouter.use('/', chat);
|
||||
router.use('/chat', chatRouter);
|
||||
|
||||
// Add marketplace routes
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
const express = require('express');
|
||||
const { generateCheckAccess } = require('@librechat/api');
|
||||
const { PermissionBits } = require('@librechat/data-schemas');
|
||||
const { PermissionTypes, Permissions } = require('librechat-data-provider');
|
||||
const { requireJwtAuth, canAccessAgentResource } = require('~/server/middleware');
|
||||
const { requireJwtAuth } = require('~/server/middleware');
|
||||
const v1 = require('~/server/controllers/agents/v1');
|
||||
const { getRoleByName } = require('~/models/Role');
|
||||
const actions = require('./actions');
|
||||
@@ -45,11 +44,6 @@ 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
|
||||
@@ -59,38 +53,13 @@ router.get('/categories', v1.getAgentCategories);
|
||||
router.post('/', checkAgentCreate, v1.createAgent);
|
||||
|
||||
/**
|
||||
* Retrieves basic agent information (VIEW permission required).
|
||||
* Returns safe, non-sensitive agent data for viewing purposes.
|
||||
* Retrieves an agent.
|
||||
* @route GET /agents/:id
|
||||
* @param {string} req.params.id - Agent identifier.
|
||||
* @returns {Agent} 200 - Basic agent info - application/json
|
||||
* @returns {Agent} 200 - Success response - application/json
|
||||
*/
|
||||
router.get(
|
||||
'/:id',
|
||||
checkAgentAccess,
|
||||
canAccessAgentResource({
|
||||
requiredPermission: PermissionBits.VIEW,
|
||||
resourceIdParam: 'id',
|
||||
}),
|
||||
v1.getAgent,
|
||||
);
|
||||
router.get('/:id', checkAgentAccess, v1.getAgent);
|
||||
|
||||
/**
|
||||
* Retrieves full agent details including sensitive configuration (EDIT permission required).
|
||||
* Returns complete agent data for editing/configuration purposes.
|
||||
* @route GET /agents/:id/expanded
|
||||
* @param {string} req.params.id - Agent identifier.
|
||||
* @returns {Agent} 200 - Full agent details - application/json
|
||||
*/
|
||||
router.get(
|
||||
'/:id/expanded',
|
||||
checkAgentAccess,
|
||||
canAccessAgentResource({
|
||||
requiredPermission: PermissionBits.EDIT,
|
||||
resourceIdParam: 'id',
|
||||
}),
|
||||
(req, res) => v1.getAgent(req, res, true), // Expanded version
|
||||
);
|
||||
/**
|
||||
* Updates an agent.
|
||||
* @route PATCH /agents/:id
|
||||
@@ -98,15 +67,7 @@ router.get(
|
||||
* @param {AgentUpdateParams} req.body - The agent update parameters.
|
||||
* @returns {Agent} 200 - Success response - application/json
|
||||
*/
|
||||
router.patch(
|
||||
'/:id',
|
||||
checkGlobalAgentShare,
|
||||
canAccessAgentResource({
|
||||
requiredPermission: PermissionBits.EDIT,
|
||||
resourceIdParam: 'id',
|
||||
}),
|
||||
v1.updateAgent,
|
||||
);
|
||||
router.patch('/:id', checkGlobalAgentShare, v1.updateAgent);
|
||||
|
||||
/**
|
||||
* Duplicates an agent.
|
||||
@@ -114,15 +75,7 @@ router.patch(
|
||||
* @param {string} req.params.id - Agent identifier.
|
||||
* @returns {Agent} 201 - Success response - application/json
|
||||
*/
|
||||
router.post(
|
||||
'/:id/duplicate',
|
||||
checkAgentCreate,
|
||||
canAccessAgentResource({
|
||||
requiredPermission: PermissionBits.VIEW,
|
||||
resourceIdParam: 'id',
|
||||
}),
|
||||
v1.duplicateAgent,
|
||||
);
|
||||
router.post('/:id/duplicate', checkAgentCreate, v1.duplicateAgent);
|
||||
|
||||
/**
|
||||
* Deletes an agent.
|
||||
@@ -130,15 +83,7 @@ router.post(
|
||||
* @param {string} req.params.id - Agent identifier.
|
||||
* @returns {Agent} 200 - success response - application/json
|
||||
*/
|
||||
router.delete(
|
||||
'/:id',
|
||||
checkAgentCreate,
|
||||
canAccessAgentResource({
|
||||
requiredPermission: PermissionBits.DELETE,
|
||||
resourceIdParam: 'id',
|
||||
}),
|
||||
v1.deleteAgent,
|
||||
);
|
||||
router.delete('/:id', checkAgentCreate, v1.deleteAgent);
|
||||
|
||||
/**
|
||||
* Reverts an agent to a previous version.
|
||||
@@ -165,14 +110,6 @@ router.get('/', checkAgentAccess, v1.getListAgents);
|
||||
* @param {string} [req.body.metadata] - Optional metadata for the agent's avatar.
|
||||
* @returns {Object} 200 - success response - application/json
|
||||
*/
|
||||
avatar.post(
|
||||
'/:agent_id/avatar/',
|
||||
checkAgentAccess,
|
||||
canAccessAgentResource({
|
||||
requiredPermission: PermissionBits.EDIT,
|
||||
resourceIdParam: 'agent_id',
|
||||
}),
|
||||
v1.uploadAgentAvatar,
|
||||
);
|
||||
avatar.post('/:agent_id/avatar/', checkAgentAccess, v1.uploadAgentAvatar);
|
||||
|
||||
module.exports = { v1: router, avatar };
|
||||
|
||||
@@ -2,13 +2,10 @@ const express = require('express');
|
||||
const request = require('supertest');
|
||||
const mongoose = require('mongoose');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { PERMISSION_BITS } = require('librechat-data-provider');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
const { createMethods } = require('@librechat/data-schemas');
|
||||
const { createAgent } = require('~/models/Agent');
|
||||
const { createFile } = require('~/models/File');
|
||||
const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants;
|
||||
|
||||
// Only mock the external dependencies that we don't want to test
|
||||
// Mock dependencies
|
||||
jest.mock('~/server/services/Files/process', () => ({
|
||||
processDeleteRequest: jest.fn().mockResolvedValue({}),
|
||||
filterFile: jest.fn(),
|
||||
@@ -28,8 +25,31 @@ jest.mock('~/server/services/Tools/credentials', () => ({
|
||||
loadAuthValues: jest.fn(),
|
||||
}));
|
||||
|
||||
// Import the router
|
||||
const router = require('~/server/routes/files/files');
|
||||
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');
|
||||
|
||||
describe('File Routes - Agent Files Endpoint', () => {
|
||||
let app;
|
||||
@@ -40,42 +60,13 @@ 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();
|
||||
const mongoUri = mongoServer.getUri();
|
||||
await mongoose.connect(mongoUri);
|
||||
await mongoose.connect(mongoServer.getUri());
|
||||
|
||||
// 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();
|
||||
// Initialize models
|
||||
require('~/db/models');
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
@@ -91,121 +82,88 @@ describe('File Routes - Agent Files Endpoint', () => {
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Create test users
|
||||
authorId = new mongoose.Types.ObjectId();
|
||||
otherUserId = new mongoose.Types.ObjectId();
|
||||
// Clear database
|
||||
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();
|
||||
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: 'file1.txt',
|
||||
filepath: '/uploads/file1.txt',
|
||||
bytes: 100,
|
||||
filename: 'agent-file1.txt',
|
||||
filepath: `/uploads/${authorId}/${fileId1}`,
|
||||
bytes: 1024,
|
||||
type: 'text/plain',
|
||||
});
|
||||
|
||||
await createFile({
|
||||
user: authorId,
|
||||
file_id: fileId2,
|
||||
filename: 'file2.txt',
|
||||
filepath: '/uploads/file2.txt',
|
||||
bytes: 200,
|
||||
filename: 'agent-file2.txt',
|
||||
filepath: `/uploads/${authorId}/${fileId2}`,
|
||||
bytes: 2048,
|
||||
type: 'text/plain',
|
||||
});
|
||||
|
||||
await createFile({
|
||||
user: otherUserId,
|
||||
file_id: fileId3,
|
||||
filename: 'file3.txt',
|
||||
filepath: '/uploads/file3.txt',
|
||||
bytes: 300,
|
||||
filename: 'user-file.txt',
|
||||
filepath: `/uploads/${otherUserId}/${fileId3}`,
|
||||
bytes: 512,
|
||||
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 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();
|
||||
});
|
||||
|
||||
it('should return files accessible through the agent for non-author', async () => {
|
||||
const response = await request(app).get(`/files/agent/${agentId}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
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);
|
||||
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
|
||||
});
|
||||
|
||||
it('should return 400 when agent_id is not provided', async () => {
|
||||
@@ -218,63 +176,45 @@ describe('File Routes - Agent Files Endpoint', () => {
|
||||
const response = await request(app).get('/files/agent/non-existent-agent');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(response.body)).toBe(true);
|
||||
expect(response.body).toEqual([]);
|
||||
expect(response.body).toEqual([]); // Empty array for non-existent agent
|
||||
});
|
||||
|
||||
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',
|
||||
model: 'gpt-4',
|
||||
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',
|
||||
provider: 'openai',
|
||||
isCollaborative: false,
|
||||
tool_resources: {
|
||||
file_search: {
|
||||
file_ids: [fileId1, fileId2],
|
||||
file_ids: [fileId1],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 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,
|
||||
});
|
||||
// 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] });
|
||||
}
|
||||
|
||||
const response = await request(app).get(`/files/agent/${agentId}`);
|
||||
const response = await request(app).get(`/files/agent/${nonCollabAgentId}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(response.body)).toBe(true);
|
||||
expect(response.body).toEqual([]);
|
||||
expect(response.body).toEqual([]); // Empty array when not collaborative
|
||||
});
|
||||
|
||||
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.toString() };
|
||||
req.user = { id: authorId };
|
||||
req.app = { locals: {} };
|
||||
next();
|
||||
});
|
||||
@@ -283,48 +223,46 @@ describe('File Routes - Agent Files Endpoint', () => {
|
||||
const response = await request(authorApp).get(`/files/agent/${agentId}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(Array.isArray(response.body)).toBe(true);
|
||||
expect(response.body).toHaveLength(2);
|
||||
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
|
||||
});
|
||||
|
||||
it('should return files uploaded by other users to shared agent for author', async () => {
|
||||
const anotherUserId = new mongoose.Types.ObjectId();
|
||||
// Create a file uploaded by another user
|
||||
const otherUserFileId = uuidv4();
|
||||
|
||||
await User.create({
|
||||
_id: anotherUserId,
|
||||
username: 'another',
|
||||
email: 'another@test.com',
|
||||
});
|
||||
const anotherUserId = new mongoose.Types.ObjectId().toString();
|
||||
|
||||
await createFile({
|
||||
user: anotherUserId,
|
||||
file_id: otherUserFileId,
|
||||
filename: 'other-user-file.txt',
|
||||
filepath: '/uploads/other-user-file.txt',
|
||||
bytes: 400,
|
||||
filepath: `/uploads/${anotherUserId}/${otherUserFileId}`,
|
||||
bytes: 4096,
|
||||
type: 'text/plain',
|
||||
});
|
||||
|
||||
// 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],
|
||||
// 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 a new app instance with author authentication
|
||||
// Create app instance with author authentication
|
||||
const authorApp = express();
|
||||
authorApp.use(express.json());
|
||||
authorApp.use((req, res, next) => {
|
||||
req.user = { id: authorId.toString() };
|
||||
req.user = { id: authorId };
|
||||
req.app = { locals: {} };
|
||||
next();
|
||||
});
|
||||
@@ -333,10 +271,12 @@ describe('File Routes - Agent Files Endpoint', () => {
|
||||
const response = await request(authorApp).get(`/files/agent/${agentId}`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
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);
|
||||
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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,8 +5,8 @@ const {
|
||||
Time,
|
||||
isUUID,
|
||||
CacheKeys,
|
||||
Constants,
|
||||
FileSources,
|
||||
PERMISSION_BITS,
|
||||
EModelEndpoint,
|
||||
isAgentsEndpoint,
|
||||
checkOpenAIStorage,
|
||||
@@ -17,13 +17,12 @@ 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 { hasAccessToFilesViaAgent } = require('~/server/services/Files');
|
||||
const { getFiles, batchUpdateFiles } = require('~/models/File');
|
||||
const { getProjectByName } = require('~/models/Project');
|
||||
const { getAssistant } = require('~/models/Assistant');
|
||||
const { getAgent } = require('~/models/Agent');
|
||||
const { getLogStores } = require('~/cache');
|
||||
@@ -78,15 +77,14 @@ router.get('/agent/:agent_id', async (req, res) => {
|
||||
|
||||
// Check if user has access to the agent
|
||||
if (agent.author.toString() !== userId) {
|
||||
// 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,
|
||||
});
|
||||
// Non-authors need the agent to be globally shared and collaborative
|
||||
const globalProject = await getProjectByName(Constants.GLOBAL_PROJECT_NAME, '_id');
|
||||
|
||||
if (!hasEditPermission) {
|
||||
if (
|
||||
!globalProject ||
|
||||
!agent.projectIds.some((pid) => pid.toString() === globalProject._id.toString()) ||
|
||||
!agent.isCollaborative
|
||||
) {
|
||||
return res.status(200).json([]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,10 @@ 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 { createAgent } = require('~/models/Agent');
|
||||
const { createFile } = require('~/models/File');
|
||||
const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants;
|
||||
|
||||
// Only mock the external dependencies that we don't want to test
|
||||
// Mock dependencies
|
||||
jest.mock('~/server/services/Files/process', () => ({
|
||||
processDeleteRequest: jest.fn().mockResolvedValue({}),
|
||||
filterFile: jest.fn(),
|
||||
@@ -46,6 +44,9 @@ 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
|
||||
@@ -56,51 +57,22 @@ describe('File Routes - Delete with Agent Access', () => {
|
||||
let mongoServer;
|
||||
let authorId;
|
||||
let otherUserId;
|
||||
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;
|
||||
let fileId;
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
const mongoUri = mongoServer.getUri();
|
||||
await mongoose.connect(mongoUri);
|
||||
await mongoose.connect(mongoServer.getUri());
|
||||
|
||||
// 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();
|
||||
// Initialize models
|
||||
require('~/db/models');
|
||||
|
||||
app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// Mock authentication middleware
|
||||
app.use((req, res, next) => {
|
||||
req.user = { id: otherUserId ? otherUserId.toString() : 'default-user' };
|
||||
req.user = { id: otherUserId || 'default-user' };
|
||||
req.app = { locals: {} };
|
||||
next();
|
||||
});
|
||||
@@ -109,19 +81,6 @@ 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();
|
||||
});
|
||||
@@ -129,41 +88,48 @@ describe('File Routes - Delete with Agent Access', () => {
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// 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
|
||||
// Clear database
|
||||
const collections = mongoose.connection.collections;
|
||||
for (const key in collections) {
|
||||
await collections[key].deleteMany({});
|
||||
}
|
||||
|
||||
// Create test data
|
||||
authorId = new mongoose.Types.ObjectId();
|
||||
otherUserId = new mongoose.Types.ObjectId();
|
||||
agentId = uuidv4();
|
||||
authorId = new mongoose.Types.ObjectId().toString();
|
||||
otherUserId = new mongoose.Types.ObjectId().toString();
|
||||
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/test.txt',
|
||||
bytes: 100,
|
||||
filepath: `/uploads/${authorId}/${fileId}`,
|
||||
bytes: 1024,
|
||||
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', () => {
|
||||
@@ -174,8 +140,8 @@ describe('File Routes - Delete with Agent Access', () => {
|
||||
user: otherUserId,
|
||||
file_id: userFileId,
|
||||
filename: 'user-file.txt',
|
||||
filepath: '/uploads/user-file.txt',
|
||||
bytes: 200,
|
||||
filepath: `/uploads/${otherUserId}/${userFileId}`,
|
||||
bytes: 1024,
|
||||
type: 'text/plain',
|
||||
});
|
||||
|
||||
@@ -185,7 +151,7 @@ describe('File Routes - Delete with Agent Access', () => {
|
||||
files: [
|
||||
{
|
||||
file_id: userFileId,
|
||||
filepath: '/uploads/user-file.txt',
|
||||
filepath: `/uploads/${otherUserId}/${userFileId}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -202,7 +168,7 @@ describe('File Routes - Delete with Agent Access', () => {
|
||||
files: [
|
||||
{
|
||||
file_id: fileId,
|
||||
filepath: '/uploads/test.txt',
|
||||
filepath: `/uploads/${authorId}/${fileId}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -214,39 +180,14 @@ 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: agent.id,
|
||||
agent_id: agentId,
|
||||
files: [
|
||||
{
|
||||
file_id: fileId,
|
||||
filepath: '/uploads/test.txt',
|
||||
filepath: `/uploads/${authorId}/${fileId}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -263,44 +204,19 @@ describe('File Routes - Delete with Agent Access', () => {
|
||||
user: authorId,
|
||||
file_id: unattachedFileId,
|
||||
filename: 'unattached.txt',
|
||||
filepath: '/uploads/unattached.txt',
|
||||
bytes: 300,
|
||||
filepath: `/uploads/${authorId}/${unattachedFileId}`,
|
||||
bytes: 1024,
|
||||
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: agent.id,
|
||||
agent_id: agentId,
|
||||
files: [
|
||||
{
|
||||
file_id: unattachedFileId,
|
||||
filepath: '/uploads/unattached.txt',
|
||||
filepath: `/uploads/${authorId}/${unattachedFileId}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -308,7 +224,6 @@ 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 () => {
|
||||
@@ -318,8 +233,8 @@ describe('File Routes - Delete with Agent Access', () => {
|
||||
user: otherUserId,
|
||||
file_id: userFileId,
|
||||
filename: 'user-file.txt',
|
||||
filepath: '/uploads/user-file.txt',
|
||||
bytes: 200,
|
||||
filepath: `/uploads/${otherUserId}/${userFileId}`,
|
||||
bytes: 1024,
|
||||
type: 'text/plain',
|
||||
});
|
||||
|
||||
@@ -329,87 +244,51 @@ describe('File Routes - Delete with Agent Access', () => {
|
||||
user: authorId,
|
||||
file_id: unauthorizedFileId,
|
||||
filename: 'unauthorized.txt',
|
||||
filepath: '/uploads/unauthorized.txt',
|
||||
bytes: 400,
|
||||
filepath: `/uploads/${authorId}/${unauthorizedFileId}`,
|
||||
bytes: 1024,
|
||||
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: agent.id,
|
||||
agent_id: agentId,
|
||||
files: [
|
||||
{ file_id: userFileId, filepath: '/uploads/user-file.txt' },
|
||||
{ file_id: fileId, filepath: '/uploads/test.txt' },
|
||||
{ file_id: unauthorizedFileId, filepath: '/uploads/unauthorized.txt' },
|
||||
{
|
||||
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}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
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(processDeleteRequest).not.toHaveBeenCalled();
|
||||
expect(response.body.unauthorizedFiles).not.toContain(fileId);
|
||||
expect(response.body.unauthorizedFiles).not.toContain(userFileId);
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
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 });
|
||||
|
||||
const response = await request(app)
|
||||
.delete('/files')
|
||||
.send({
|
||||
agent_id: agent.id,
|
||||
agent_id: agentId,
|
||||
files: [
|
||||
{
|
||||
file_id: fileId,
|
||||
filepath: '/uploads/test.txt',
|
||||
filepath: `/uploads/${authorId}/${fileId}`,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
const accessPermissions = require('./accessPermissions');
|
||||
const assistants = require('./assistants');
|
||||
const categories = require('./categories');
|
||||
const tokenizer = require('./tokenizer');
|
||||
@@ -29,7 +28,6 @@ const user = require('./user');
|
||||
const mcp = require('./mcp');
|
||||
|
||||
module.exports = {
|
||||
mcp,
|
||||
edit,
|
||||
auth,
|
||||
keys,
|
||||
@@ -57,5 +55,5 @@ module.exports = {
|
||||
assistants,
|
||||
categories,
|
||||
staticRoute,
|
||||
accessPermissions,
|
||||
mcp,
|
||||
};
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
const { Router } = require('express');
|
||||
const { MCPOAuthHandler } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { CacheKeys } = require('librechat-data-provider');
|
||||
const { MCPOAuthHandler } = require('@librechat/api');
|
||||
const { CacheKeys, Constants } = require('librechat-data-provider');
|
||||
const { findToken, updateToken, createToken, deleteTokens } = require('~/models');
|
||||
const { setCachedTools, getCachedTools, loadCustomConfig } = require('~/server/services/Config');
|
||||
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
|
||||
const { getMCPManager, getFlowStateManager } = require('~/config');
|
||||
const { requireJwtAuth } = require('~/server/middleware');
|
||||
const { getFlowStateManager } = require('~/config');
|
||||
const { getLogStores } = require('~/cache');
|
||||
|
||||
const router = Router();
|
||||
@@ -117,9 +120,73 @@ router.get('/:serverName/oauth/callback', async (req, res) => {
|
||||
const tokens = await MCPOAuthHandler.completeOAuthFlow(flowId, code, flowManager);
|
||||
logger.info('[MCP OAuth] OAuth flow completed, tokens received in callback route');
|
||||
|
||||
// For system-level OAuth, we need to store the tokens and retry the connection
|
||||
if (flowState.userId === 'system') {
|
||||
logger.debug(`[MCP OAuth] System-level OAuth completed for ${serverName}`);
|
||||
// Try to establish the MCP connection with the new tokens
|
||||
try {
|
||||
const mcpManager = getMCPManager(flowState.userId);
|
||||
logger.debug(`[MCP OAuth] Attempting to reconnect ${serverName} with new OAuth tokens`);
|
||||
|
||||
// For user-level OAuth, try to establish the connection
|
||||
if (flowState.userId !== 'system') {
|
||||
// We need to get the user object - in this case we'll need to reconstruct it
|
||||
const user = { id: flowState.userId };
|
||||
|
||||
// Try to establish connection with the new tokens
|
||||
const userConnection = await mcpManager.getUserConnection({
|
||||
user,
|
||||
serverName,
|
||||
flowManager,
|
||||
tokenMethods: {
|
||||
findToken,
|
||||
updateToken,
|
||||
createToken,
|
||||
deleteTokens,
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`[MCP OAuth] Successfully reconnected ${serverName} for user ${flowState.userId}`,
|
||||
);
|
||||
|
||||
// Fetch and cache tools now that we have a successful connection
|
||||
const userTools = (await getCachedTools({ userId: flowState.userId })) || {};
|
||||
|
||||
// Remove any old tools from this server in the user's cache
|
||||
const mcpDelimiter = Constants.mcp_delimiter;
|
||||
for (const key of Object.keys(userTools)) {
|
||||
if (key.endsWith(`${mcpDelimiter}${serverName}`)) {
|
||||
delete userTools[key];
|
||||
}
|
||||
}
|
||||
|
||||
// Add the new tools from this server
|
||||
const tools = await userConnection.fetchTools();
|
||||
for (const tool of tools) {
|
||||
const name = `${tool.name}${Constants.mcp_delimiter}${serverName}`;
|
||||
userTools[name] = {
|
||||
type: 'function',
|
||||
['function']: {
|
||||
name,
|
||||
description: tool.description,
|
||||
parameters: tool.inputSchema,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Save the updated user tool cache
|
||||
await setCachedTools(userTools, { userId: flowState.userId });
|
||||
|
||||
logger.debug(
|
||||
`[MCP OAuth] Cached ${tools.length} tools for ${serverName} user ${flowState.userId}`,
|
||||
);
|
||||
} else {
|
||||
logger.debug(`[MCP OAuth] System-level OAuth completed for ${serverName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Don't fail the OAuth callback if reconnection fails - the tokens are still saved
|
||||
logger.warn(
|
||||
`[MCP OAuth] Failed to reconnect ${serverName} after OAuth, but tokens are saved:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
/** ID of the flow that the tool/connection is waiting for */
|
||||
@@ -202,4 +269,359 @@ router.get('/oauth/status/:flowId', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Cancel OAuth flow
|
||||
* This endpoint cancels a pending OAuth flow
|
||||
*/
|
||||
router.post('/oauth/cancel/:serverName', requireJwtAuth, async (req, res) => {
|
||||
try {
|
||||
const { serverName } = req.params;
|
||||
const user = req.user;
|
||||
|
||||
if (!user?.id) {
|
||||
return res.status(401).json({ error: 'User not authenticated' });
|
||||
}
|
||||
|
||||
logger.info(`[MCP OAuth Cancel] Cancelling OAuth flow for ${serverName} by user ${user.id}`);
|
||||
|
||||
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
||||
const flowManager = getFlowStateManager(flowsCache);
|
||||
|
||||
// Generate the flow ID for this user/server combination
|
||||
const flowId = MCPOAuthHandler.generateFlowId(user.id, serverName);
|
||||
|
||||
// Check if flow exists
|
||||
const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
|
||||
|
||||
if (!flowState) {
|
||||
logger.debug(`[MCP OAuth Cancel] No active flow found for ${serverName}`);
|
||||
return res.json({
|
||||
success: true,
|
||||
message: 'No active OAuth flow to cancel',
|
||||
});
|
||||
}
|
||||
|
||||
// Cancel the flow by marking it as failed
|
||||
await flowManager.completeFlow(flowId, 'mcp_oauth', null, 'User cancelled OAuth flow');
|
||||
|
||||
logger.info(`[MCP OAuth Cancel] Successfully cancelled OAuth flow for ${serverName}`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `OAuth flow for ${serverName} cancelled successfully`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[MCP OAuth Cancel] Failed to cancel OAuth flow', error);
|
||||
res.status(500).json({ error: 'Failed to cancel OAuth flow' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Reinitialize MCP server
|
||||
* This endpoint allows reinitializing a specific MCP server
|
||||
*/
|
||||
router.post('/:serverName/reinitialize', requireJwtAuth, async (req, res) => {
|
||||
try {
|
||||
const { serverName } = req.params;
|
||||
const user = req.user;
|
||||
|
||||
if (!user?.id) {
|
||||
return res.status(401).json({ error: 'User not authenticated' });
|
||||
}
|
||||
|
||||
logger.info(`[MCP Reinitialize] Reinitializing server: ${serverName}`);
|
||||
|
||||
const config = await loadCustomConfig();
|
||||
if (!config || !config.mcpServers || !config.mcpServers[serverName]) {
|
||||
return res.status(404).json({
|
||||
error: `MCP server '${serverName}' not found in configuration`,
|
||||
});
|
||||
}
|
||||
|
||||
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
||||
const flowManager = getFlowStateManager(flowsCache);
|
||||
const mcpManager = getMCPManager();
|
||||
|
||||
await mcpManager.disconnectServer(serverName);
|
||||
logger.info(`[MCP Reinitialize] Disconnected existing server: ${serverName}`);
|
||||
|
||||
const serverConfig = config.mcpServers[serverName];
|
||||
mcpManager.mcpConfigs[serverName] = serverConfig;
|
||||
let customUserVars = {};
|
||||
if (serverConfig.customUserVars && typeof serverConfig.customUserVars === 'object') {
|
||||
for (const varName of Object.keys(serverConfig.customUserVars)) {
|
||||
try {
|
||||
const value = await getUserPluginAuthValue(user.id, varName, false);
|
||||
if (value) {
|
||||
customUserVars[varName] = value;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`[MCP Reinitialize] Error fetching ${varName} for user ${user.id}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let userConnection = null;
|
||||
let oauthRequired = false;
|
||||
let oauthUrl = null;
|
||||
|
||||
try {
|
||||
userConnection = await mcpManager.getUserConnection({
|
||||
user,
|
||||
serverName,
|
||||
flowManager,
|
||||
customUserVars,
|
||||
tokenMethods: {
|
||||
findToken,
|
||||
updateToken,
|
||||
createToken,
|
||||
deleteTokens,
|
||||
},
|
||||
returnOnOAuth: true, // Return immediately when OAuth is initiated
|
||||
// Add OAuth handlers to capture the OAuth URL when needed
|
||||
oauthStart: async (authURL) => {
|
||||
logger.info(`[MCP Reinitialize] OAuth URL received: ${authURL}`);
|
||||
oauthUrl = authURL;
|
||||
oauthRequired = true;
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(`[MCP Reinitialize] Successfully established connection for ${serverName}`);
|
||||
} catch (err) {
|
||||
logger.info(`[MCP Reinitialize] getUserConnection threw error: ${err.message}`);
|
||||
logger.info(
|
||||
`[MCP Reinitialize] OAuth state - oauthRequired: ${oauthRequired}, oauthUrl: ${oauthUrl ? 'present' : 'null'}`,
|
||||
);
|
||||
|
||||
// Check if this is an OAuth error - if so, the flow state should be set up now
|
||||
const isOAuthError =
|
||||
err.message?.includes('OAuth') ||
|
||||
err.message?.includes('authentication') ||
|
||||
err.message?.includes('401');
|
||||
|
||||
const isOAuthFlowInitiated = err.message === 'OAuth flow initiated - return early';
|
||||
|
||||
if (isOAuthError || oauthRequired || isOAuthFlowInitiated) {
|
||||
logger.info(
|
||||
`[MCP Reinitialize] OAuth required for ${serverName} (isOAuthError: ${isOAuthError}, oauthRequired: ${oauthRequired}, isOAuthFlowInitiated: ${isOAuthFlowInitiated})`,
|
||||
);
|
||||
oauthRequired = true;
|
||||
// Don't return error - continue so frontend can handle OAuth
|
||||
} else {
|
||||
logger.error(
|
||||
`[MCP Reinitialize] Error initializing MCP server ${serverName} for user:`,
|
||||
err,
|
||||
);
|
||||
return res.status(500).json({ error: 'Failed to reinitialize MCP server for user' });
|
||||
}
|
||||
}
|
||||
|
||||
// Only fetch and cache tools if we successfully connected (no OAuth required)
|
||||
if (userConnection && !oauthRequired) {
|
||||
const userTools = (await getCachedTools({ userId: user.id })) || {};
|
||||
|
||||
// Remove any old tools from this server in the user's cache
|
||||
const mcpDelimiter = Constants.mcp_delimiter;
|
||||
for (const key of Object.keys(userTools)) {
|
||||
if (key.endsWith(`${mcpDelimiter}${serverName}`)) {
|
||||
delete userTools[key];
|
||||
}
|
||||
}
|
||||
|
||||
// Add the new tools from this server
|
||||
const tools = await userConnection.fetchTools();
|
||||
for (const tool of tools) {
|
||||
const name = `${tool.name}${Constants.mcp_delimiter}${serverName}`;
|
||||
userTools[name] = {
|
||||
type: 'function',
|
||||
['function']: {
|
||||
name,
|
||||
description: tool.description,
|
||||
parameters: tool.inputSchema,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Save the updated user tool cache
|
||||
await setCachedTools(userTools, { userId: user.id });
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`[MCP Reinitialize] Sending response for ${serverName} - oauthRequired: ${oauthRequired}, oauthUrl: ${oauthUrl ? 'present' : 'null'}`,
|
||||
);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: oauthRequired
|
||||
? `MCP server '${serverName}' ready for OAuth authentication`
|
||||
: `MCP server '${serverName}' reinitialized successfully`,
|
||||
serverName,
|
||||
oauthRequired,
|
||||
oauthUrl,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[MCP Reinitialize] Unexpected error', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Get connection status for all MCP servers
|
||||
* This endpoint returns the actual connection status from MCPManager without disconnecting idle connections
|
||||
*/
|
||||
router.get('/connection/status', requireJwtAuth, async (req, res) => {
|
||||
try {
|
||||
const user = req.user;
|
||||
|
||||
if (!user?.id) {
|
||||
return res.status(401).json({ error: 'User not authenticated' });
|
||||
}
|
||||
|
||||
const mcpManager = getMCPManager(user.id);
|
||||
const connectionStatus = {};
|
||||
|
||||
const printConfig = false;
|
||||
const config = await loadCustomConfig(printConfig);
|
||||
const mcpConfig = config?.mcpServers;
|
||||
|
||||
const appConnections = mcpManager.getAllConnections() || new Map();
|
||||
const userConnections = mcpManager.getUserConnections(user.id) || new Map();
|
||||
const oauthServers = mcpManager.getOAuthServers() || new Set();
|
||||
|
||||
if (!mcpConfig) {
|
||||
return res.status(404).json({ error: 'MCP config not found' });
|
||||
}
|
||||
|
||||
// Get flow manager to check for active/timed-out OAuth flows
|
||||
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
||||
const flowManager = getFlowStateManager(flowsCache);
|
||||
|
||||
for (const [serverName] of Object.entries(mcpConfig)) {
|
||||
const getConnectionState = (serverName) =>
|
||||
appConnections.get(serverName)?.connectionState ??
|
||||
userConnections.get(serverName)?.connectionState ??
|
||||
'disconnected';
|
||||
|
||||
const baseConnectionState = getConnectionState(serverName);
|
||||
|
||||
let hasActiveOAuthFlow = false;
|
||||
let hasFailedOAuthFlow = false;
|
||||
|
||||
if (baseConnectionState === 'disconnected' && oauthServers.has(serverName)) {
|
||||
try {
|
||||
// Check for user-specific OAuth flows
|
||||
const flowId = MCPOAuthHandler.generateFlowId(user.id, serverName);
|
||||
const flowState = await flowManager.getFlowState(flowId, 'mcp_oauth');
|
||||
if (flowState) {
|
||||
// Check if flow failed or timed out
|
||||
const flowAge = Date.now() - flowState.createdAt;
|
||||
const flowTTL = flowState.ttl || 180000; // Default 3 minutes
|
||||
|
||||
if (flowState.status === 'FAILED' || flowAge > flowTTL) {
|
||||
hasFailedOAuthFlow = true;
|
||||
logger.debug(`[MCP Connection Status] Found failed OAuth flow for ${serverName}`, {
|
||||
flowId,
|
||||
status: flowState.status,
|
||||
flowAge,
|
||||
flowTTL,
|
||||
timedOut: flowAge > flowTTL,
|
||||
});
|
||||
} else if (flowState.status === 'PENDING') {
|
||||
hasActiveOAuthFlow = true;
|
||||
logger.debug(`[MCP Connection Status] Found active OAuth flow for ${serverName}`, {
|
||||
flowId,
|
||||
flowAge,
|
||||
flowTTL,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[MCP Connection Status] Error checking OAuth flows for ${serverName}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the final connection state
|
||||
let finalConnectionState = baseConnectionState;
|
||||
if (hasFailedOAuthFlow) {
|
||||
finalConnectionState = 'error'; // Report as error if OAuth failed
|
||||
} else if (hasActiveOAuthFlow && baseConnectionState === 'disconnected') {
|
||||
finalConnectionState = 'connecting'; // Still waiting for OAuth
|
||||
}
|
||||
|
||||
connectionStatus[serverName] = {
|
||||
requiresOAuth: oauthServers.has(serverName),
|
||||
connectionState: finalConnectionState,
|
||||
};
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
connectionStatus,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[MCP Connection Status] Failed to get connection status', error);
|
||||
res.status(500).json({ error: 'Failed to get connection status' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Check which authentication values exist for a specific MCP server
|
||||
* This endpoint returns only boolean flags indicating if values are set, not the actual values
|
||||
*/
|
||||
router.get('/:serverName/auth-values', requireJwtAuth, async (req, res) => {
|
||||
try {
|
||||
const { serverName } = req.params;
|
||||
const user = req.user;
|
||||
|
||||
if (!user?.id) {
|
||||
return res.status(401).json({ error: 'User not authenticated' });
|
||||
}
|
||||
|
||||
const printConfig = false;
|
||||
const config = await loadCustomConfig(printConfig);
|
||||
if (!config || !config.mcpServers || !config.mcpServers[serverName]) {
|
||||
return res.status(404).json({
|
||||
error: `MCP server '${serverName}' not found in configuration`,
|
||||
});
|
||||
}
|
||||
|
||||
const serverConfig = config.mcpServers[serverName];
|
||||
const pluginKey = `${Constants.mcp_prefix}${serverName}`;
|
||||
const authValueFlags = {};
|
||||
|
||||
// Check existence of saved values for each custom user variable (don't fetch actual values)
|
||||
if (serverConfig.customUserVars && typeof serverConfig.customUserVars === 'object') {
|
||||
for (const varName of Object.keys(serverConfig.customUserVars)) {
|
||||
try {
|
||||
const value = await getUserPluginAuthValue(user.id, varName, false, pluginKey);
|
||||
// Only store boolean flag indicating if value exists
|
||||
authValueFlags[varName] = !!(value && value.length > 0);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`[MCP Auth Value Flags] Error checking ${varName} for user ${user.id}:`,
|
||||
err,
|
||||
);
|
||||
// Default to false if we can't check
|
||||
authValueFlags[varName] = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
serverName,
|
||||
authValueFlags,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[MCP Auth Value Flags] Failed to check auth value flags for ${req.params.serverName}`,
|
||||
error,
|
||||
);
|
||||
res.status(500).json({ error: 'Failed to check auth value flags' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -9,7 +9,6 @@ const {
|
||||
setBalanceConfig,
|
||||
checkDomainAllowed,
|
||||
} = require('~/server/middleware');
|
||||
const { syncUserEntraGroupMemberships } = require('~/server/services/PermissionService');
|
||||
const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const { logger } = require('~/config');
|
||||
@@ -36,7 +35,6 @@ const oauthHandler = async (req, res) => {
|
||||
req.user.provider == 'openid' &&
|
||||
isEnabled(process.env.OPENID_REUSE_TOKENS) === true
|
||||
) {
|
||||
await syncUserEntraGroupMemberships(req.user, req.user.tokenset.access_token);
|
||||
setOpenIDAuthTokens(req.user.tokenset, res);
|
||||
} else {
|
||||
await setAuthTokens(req.user._id, res);
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
jest.mock('~/models', () => ({
|
||||
initializeRoles: jest.fn(),
|
||||
seedDefaultRoles: jest.fn(),
|
||||
ensureDefaultCategories: jest.fn(),
|
||||
}));
|
||||
jest.mock('~/models/Role', () => ({
|
||||
updateAccessPermissions: jest.fn(),
|
||||
|
||||
@@ -16,7 +16,6 @@ const {
|
||||
const { azureAssistantsDefaults, assistantsConfigSetup } = require('./start/assistants');
|
||||
const { initializeAzureBlobService } = require('./Files/Azure/initialize');
|
||||
const { initializeFirebase } = require('./Files/Firebase/initialize');
|
||||
const { seedDefaultRoles, initializeRoles, ensureDefaultCategories } = require('~/models');
|
||||
const loadCustomConfig = require('./Config/loadCustomConfig');
|
||||
const handleRateLimits = require('./Config/handleRateLimits');
|
||||
const { loadDefaultInterface } = require('./start/interface');
|
||||
@@ -26,6 +25,7 @@ const { processModelSpecs } = require('./start/modelSpecs');
|
||||
const { initializeS3 } = require('./Files/S3/initialize');
|
||||
const { loadAndFormatTools } = require('./ToolService');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const { initializeRoles } = require('~/models');
|
||||
const { setCachedTools } = require('./Config');
|
||||
const paths = require('~/config/paths');
|
||||
|
||||
@@ -36,8 +36,6 @@ const paths = require('~/config/paths');
|
||||
*/
|
||||
const AppService = async (app) => {
|
||||
await initializeRoles();
|
||||
await seedDefaultRoles();
|
||||
await ensureDefaultCategories();
|
||||
/** @type {TCustomConfig} */
|
||||
const config = (await loadCustomConfig()) ?? {};
|
||||
const configDefaults = getConfigDefaults();
|
||||
@@ -159,6 +157,10 @@ const AppService = async (app) => {
|
||||
}
|
||||
});
|
||||
|
||||
if (endpoints?.all) {
|
||||
endpointLocals.all = endpoints.all;
|
||||
}
|
||||
|
||||
app.locals = {
|
||||
...defaultLocals,
|
||||
fileConfig: config?.fileConfig,
|
||||
|
||||
@@ -28,8 +28,6 @@ jest.mock('./Files/Firebase/initialize', () => ({
|
||||
}));
|
||||
jest.mock('~/models', () => ({
|
||||
initializeRoles: jest.fn(),
|
||||
seedDefaultRoles: jest.fn(),
|
||||
ensureDefaultCategories: jest.fn(),
|
||||
}));
|
||||
jest.mock('~/models/Role', () => ({
|
||||
updateAccessPermissions: jest.fn(),
|
||||
@@ -545,6 +543,206 @@ describe('AppService', () => {
|
||||
expect(process.env.IMPORT_USER_MAX).toEqual('initialUserMax');
|
||||
expect(process.env.IMPORT_USER_WINDOW).toEqual('initialUserWindow');
|
||||
});
|
||||
|
||||
it('should correctly configure endpoint with titlePrompt, titleMethod, and titlePromptTemplate', async () => {
|
||||
require('./Config/loadCustomConfig').mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
endpoints: {
|
||||
[EModelEndpoint.openAI]: {
|
||||
titleConvo: true,
|
||||
titleModel: 'gpt-3.5-turbo',
|
||||
titleMethod: 'structured',
|
||||
titlePrompt: 'Custom title prompt for conversation',
|
||||
titlePromptTemplate: 'Summarize this conversation: {{conversation}}',
|
||||
},
|
||||
[EModelEndpoint.assistants]: {
|
||||
titleMethod: 'functions',
|
||||
titlePrompt: 'Generate a title for this assistant conversation',
|
||||
titlePromptTemplate: 'Assistant conversation template: {{messages}}',
|
||||
},
|
||||
[EModelEndpoint.azureOpenAI]: {
|
||||
groups: azureGroups,
|
||||
titleConvo: true,
|
||||
titleMethod: 'completion',
|
||||
titleModel: 'gpt-4',
|
||||
titlePrompt: 'Azure title prompt',
|
||||
titlePromptTemplate: 'Azure conversation: {{context}}',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await AppService(app);
|
||||
|
||||
// Check OpenAI endpoint configuration
|
||||
expect(app.locals).toHaveProperty(EModelEndpoint.openAI);
|
||||
expect(app.locals[EModelEndpoint.openAI]).toEqual(
|
||||
expect.objectContaining({
|
||||
titleConvo: true,
|
||||
titleModel: 'gpt-3.5-turbo',
|
||||
titleMethod: 'structured',
|
||||
titlePrompt: 'Custom title prompt for conversation',
|
||||
titlePromptTemplate: 'Summarize this conversation: {{conversation}}',
|
||||
}),
|
||||
);
|
||||
|
||||
// Check Assistants endpoint configuration
|
||||
expect(app.locals).toHaveProperty(EModelEndpoint.assistants);
|
||||
expect(app.locals[EModelEndpoint.assistants]).toMatchObject({
|
||||
titleMethod: 'functions',
|
||||
titlePrompt: 'Generate a title for this assistant conversation',
|
||||
titlePromptTemplate: 'Assistant conversation template: {{messages}}',
|
||||
});
|
||||
|
||||
// Check Azure OpenAI endpoint configuration
|
||||
expect(app.locals).toHaveProperty(EModelEndpoint.azureOpenAI);
|
||||
expect(app.locals[EModelEndpoint.azureOpenAI]).toEqual(
|
||||
expect.objectContaining({
|
||||
titleConvo: true,
|
||||
titleMethod: 'completion',
|
||||
titleModel: 'gpt-4',
|
||||
titlePrompt: 'Azure title prompt',
|
||||
titlePromptTemplate: 'Azure conversation: {{context}}',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should configure Agent endpoint with title generation settings', async () => {
|
||||
require('./Config/loadCustomConfig').mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
endpoints: {
|
||||
[EModelEndpoint.agents]: {
|
||||
disableBuilder: false,
|
||||
titleConvo: true,
|
||||
titleModel: 'gpt-4',
|
||||
titleMethod: 'structured',
|
||||
titlePrompt: 'Generate a descriptive title for this agent conversation',
|
||||
titlePromptTemplate: 'Agent conversation summary: {{content}}',
|
||||
recursionLimit: 15,
|
||||
capabilities: [AgentCapabilities.tools, AgentCapabilities.actions],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await AppService(app);
|
||||
|
||||
expect(app.locals).toHaveProperty(EModelEndpoint.agents);
|
||||
expect(app.locals[EModelEndpoint.agents]).toMatchObject({
|
||||
disableBuilder: false,
|
||||
titleConvo: true,
|
||||
titleModel: 'gpt-4',
|
||||
titleMethod: 'structured',
|
||||
titlePrompt: 'Generate a descriptive title for this agent conversation',
|
||||
titlePromptTemplate: 'Agent conversation summary: {{content}}',
|
||||
recursionLimit: 15,
|
||||
capabilities: expect.arrayContaining([AgentCapabilities.tools, AgentCapabilities.actions]),
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle missing title configuration options with defaults', async () => {
|
||||
require('./Config/loadCustomConfig').mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
endpoints: {
|
||||
[EModelEndpoint.openAI]: {
|
||||
titleConvo: true,
|
||||
// titlePrompt and titlePromptTemplate are not provided
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await AppService(app);
|
||||
|
||||
expect(app.locals).toHaveProperty(EModelEndpoint.openAI);
|
||||
expect(app.locals[EModelEndpoint.openAI]).toMatchObject({
|
||||
titleConvo: true,
|
||||
});
|
||||
// Check that the optional fields are undefined when not provided
|
||||
expect(app.locals[EModelEndpoint.openAI].titlePrompt).toBeUndefined();
|
||||
expect(app.locals[EModelEndpoint.openAI].titlePromptTemplate).toBeUndefined();
|
||||
expect(app.locals[EModelEndpoint.openAI].titleMethod).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should correctly configure titleEndpoint when specified', async () => {
|
||||
require('./Config/loadCustomConfig').mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
endpoints: {
|
||||
[EModelEndpoint.openAI]: {
|
||||
titleConvo: true,
|
||||
titleModel: 'gpt-3.5-turbo',
|
||||
titleEndpoint: EModelEndpoint.anthropic,
|
||||
titlePrompt: 'Generate a concise title',
|
||||
},
|
||||
[EModelEndpoint.agents]: {
|
||||
titleEndpoint: 'custom-provider',
|
||||
titleMethod: 'structured',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await AppService(app);
|
||||
|
||||
// Check OpenAI endpoint has titleEndpoint
|
||||
expect(app.locals).toHaveProperty(EModelEndpoint.openAI);
|
||||
expect(app.locals[EModelEndpoint.openAI]).toMatchObject({
|
||||
titleConvo: true,
|
||||
titleModel: 'gpt-3.5-turbo',
|
||||
titleEndpoint: EModelEndpoint.anthropic,
|
||||
titlePrompt: 'Generate a concise title',
|
||||
});
|
||||
|
||||
// Check Agents endpoint has titleEndpoint
|
||||
expect(app.locals).toHaveProperty(EModelEndpoint.agents);
|
||||
expect(app.locals[EModelEndpoint.agents]).toMatchObject({
|
||||
titleEndpoint: 'custom-provider',
|
||||
titleMethod: 'structured',
|
||||
});
|
||||
});
|
||||
|
||||
it('should correctly configure all endpoint when specified', async () => {
|
||||
require('./Config/loadCustomConfig').mockImplementationOnce(() =>
|
||||
Promise.resolve({
|
||||
endpoints: {
|
||||
all: {
|
||||
titleConvo: true,
|
||||
titleModel: 'gpt-4o-mini',
|
||||
titleMethod: 'structured',
|
||||
titlePrompt: 'Default title prompt for all endpoints',
|
||||
titlePromptTemplate: 'Default template: {{conversation}}',
|
||||
titleEndpoint: EModelEndpoint.anthropic,
|
||||
streamRate: 50,
|
||||
},
|
||||
[EModelEndpoint.openAI]: {
|
||||
titleConvo: true,
|
||||
titleModel: 'gpt-3.5-turbo',
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await AppService(app);
|
||||
|
||||
// Check that 'all' endpoint config is loaded
|
||||
expect(app.locals).toHaveProperty('all');
|
||||
expect(app.locals.all).toMatchObject({
|
||||
titleConvo: true,
|
||||
titleModel: 'gpt-4o-mini',
|
||||
titleMethod: 'structured',
|
||||
titlePrompt: 'Default title prompt for all endpoints',
|
||||
titlePromptTemplate: 'Default template: {{conversation}}',
|
||||
titleEndpoint: EModelEndpoint.anthropic,
|
||||
streamRate: 50,
|
||||
});
|
||||
|
||||
// Check that OpenAI endpoint has its own config
|
||||
expect(app.locals).toHaveProperty(EModelEndpoint.openAI);
|
||||
expect(app.locals[EModelEndpoint.openAI]).toMatchObject({
|
||||
titleConvo: true,
|
||||
titleModel: 'gpt-3.5-turbo',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AppService updating app.locals and issuing warnings', () => {
|
||||
|
||||
@@ -25,7 +25,7 @@ let i = 0;
|
||||
* @function loadCustomConfig
|
||||
* @returns {Promise<TCustomConfig | null>} A promise that resolves to null or the custom config object.
|
||||
* */
|
||||
async function loadCustomConfig() {
|
||||
async function loadCustomConfig(printConfig = true) {
|
||||
// Use CONFIG_PATH if set, otherwise fallback to defaultConfigPath
|
||||
const configPath = process.env.CONFIG_PATH || defaultConfigPath;
|
||||
|
||||
@@ -108,9 +108,11 @@ https://www.librechat.ai/docs/configuration/stt_tts`);
|
||||
|
||||
return null;
|
||||
} else {
|
||||
logger.info('Custom config file loaded:');
|
||||
logger.info(JSON.stringify(customConfig, null, 2));
|
||||
logger.debug('Custom config:', customConfig);
|
||||
if (printConfig) {
|
||||
logger.info('Custom config file loaded:');
|
||||
logger.info(JSON.stringify(customConfig, null, 2));
|
||||
logger.debug('Custom config:', customConfig);
|
||||
}
|
||||
}
|
||||
|
||||
(customConfig.endpoints?.custom ?? [])
|
||||
|
||||
@@ -104,7 +104,7 @@ const initializeAgent = async ({
|
||||
|
||||
agent.endpoint = provider;
|
||||
const { getOptions, overrideProvider } = await getProviderConfig(provider);
|
||||
if (overrideProvider) {
|
||||
if (overrideProvider !== agent.provider) {
|
||||
agent.provider = overrideProvider;
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ const initializeAgent = async ({
|
||||
);
|
||||
const agentMaxContextTokens = optionalChainWithEmptyCheck(
|
||||
maxContextTokens,
|
||||
getModelMaxTokens(tokensModel, providerEndpointMap[provider]),
|
||||
getModelMaxTokens(tokensModel, providerEndpointMap[provider], options.endpointTokenConfig),
|
||||
4096,
|
||||
);
|
||||
|
||||
@@ -191,7 +191,7 @@ const initializeAgent = async ({
|
||||
resendFiles,
|
||||
toolContextMap,
|
||||
useLegacyContent: !!options.useLegacyContent,
|
||||
maxContextTokens: (agentMaxContextTokens - maxTokens) * 0.9,
|
||||
maxContextTokens: Math.round((agentMaxContextTokens - maxTokens) * 0.9),
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const OpenAI = require('openai');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { ProxyAgent } = require('undici');
|
||||
const { ErrorTypes, EModelEndpoint } = require('librechat-data-provider');
|
||||
const {
|
||||
getUserKeyValues,
|
||||
@@ -59,7 +59,10 @@ const initializeClient = async ({ req, res, endpointOption, version, initAppClie
|
||||
}
|
||||
|
||||
if (PROXY) {
|
||||
opts.httpAgent = new HttpsProxyAgent(PROXY);
|
||||
const proxyAgent = new ProxyAgent(PROXY);
|
||||
opts.fetchOptions = {
|
||||
dispatcher: proxyAgent,
|
||||
};
|
||||
}
|
||||
|
||||
if (OPENAI_ORGANIZATION) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// const OpenAI = require('openai');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { ProxyAgent } = require('undici');
|
||||
const { ErrorTypes } = require('librechat-data-provider');
|
||||
const { getUserKey, getUserKeyExpiry, getUserKeyValues } = require('~/server/services/UserService');
|
||||
const initializeClient = require('./initalize');
|
||||
@@ -107,6 +107,7 @@ describe('initializeClient', () => {
|
||||
const res = {};
|
||||
|
||||
const { openai } = await initializeClient({ req, res });
|
||||
expect(openai.httpAgent).toBeInstanceOf(HttpsProxyAgent);
|
||||
expect(openai.fetchOptions).toBeDefined();
|
||||
expect(openai.fetchOptions.dispatcher).toBeInstanceOf(ProxyAgent);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const OpenAI = require('openai');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { ProxyAgent } = require('undici');
|
||||
const { constructAzureURL, isUserProvided, resolveHeaders } = require('@librechat/api');
|
||||
const { ErrorTypes, EModelEndpoint, mapModelToAzureConfig } = require('librechat-data-provider');
|
||||
const {
|
||||
@@ -158,7 +158,10 @@ const initializeClient = async ({ req, res, version, endpointOption, initAppClie
|
||||
}
|
||||
|
||||
if (PROXY) {
|
||||
opts.httpAgent = new HttpsProxyAgent(PROXY);
|
||||
const proxyAgent = new ProxyAgent(PROXY);
|
||||
opts.fetchOptions = {
|
||||
dispatcher: proxyAgent,
|
||||
};
|
||||
}
|
||||
|
||||
if (OPENAI_ORGANIZATION) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// const OpenAI = require('openai');
|
||||
const { HttpsProxyAgent } = require('https-proxy-agent');
|
||||
const { ProxyAgent } = require('undici');
|
||||
const { ErrorTypes } = require('librechat-data-provider');
|
||||
const { getUserKey, getUserKeyExpiry, getUserKeyValues } = require('~/server/services/UserService');
|
||||
const initializeClient = require('./initialize');
|
||||
@@ -107,6 +107,7 @@ describe('initializeClient', () => {
|
||||
const res = {};
|
||||
|
||||
const { openai } = await initializeClient({ req, res });
|
||||
expect(openai.httpAgent).toBeInstanceOf(HttpsProxyAgent);
|
||||
expect(openai.fetchOptions).toBeDefined();
|
||||
expect(openai.fetchOptions.dispatcher).toBeInstanceOf(ProxyAgent);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -141,8 +141,9 @@ const initializeClient = async ({ req, res, endpointOption, optionsOnly, overrid
|
||||
const options = getOpenAIConfig(apiKey, clientOptions, endpoint);
|
||||
if (options != null) {
|
||||
options.useLegacyContent = true;
|
||||
options.endpointTokenConfig = endpointTokenConfig;
|
||||
}
|
||||
if (!customOptions.streamRate) {
|
||||
if (!clientOptions.streamRate) {
|
||||
return options;
|
||||
}
|
||||
options.llmConfig.callbacks = [
|
||||
|
||||
@@ -34,13 +34,13 @@ const providerConfigMap = {
|
||||
* @param {string} provider - The provider string
|
||||
* @returns {Promise<{
|
||||
* getOptions: Function,
|
||||
* overrideProvider?: string,
|
||||
* overrideProvider: string,
|
||||
* customEndpointConfig?: TEndpoint
|
||||
* }>}
|
||||
*/
|
||||
async function getProviderConfig(provider) {
|
||||
let getOptions = providerConfigMap[provider];
|
||||
let overrideProvider;
|
||||
let overrideProvider = provider;
|
||||
/** @type {TEndpoint | undefined} */
|
||||
let customEndpointConfig;
|
||||
|
||||
@@ -56,7 +56,7 @@ async function getProviderConfig(provider) {
|
||||
overrideProvider = Providers.OPENAI;
|
||||
}
|
||||
|
||||
if (isKnownCustomProvider(overrideProvider || provider) && !customEndpointConfig) {
|
||||
if (isKnownCustomProvider(overrideProvider) && !customEndpointConfig) {
|
||||
customEndpointConfig = await getCustomEndpointConfig(provider);
|
||||
if (!customEndpointConfig) {
|
||||
throw new Error(`Provider ${provider} not supported`);
|
||||
|
||||
@@ -11,7 +11,6 @@ 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');
|
||||
@@ -165,19 +164,14 @@ 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 ?? [];
|
||||
|
||||
// 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 dbFiles = (
|
||||
(await getFiles(
|
||||
{ file_id: { $in: file_ids } },
|
||||
null,
|
||||
{ text: 0 },
|
||||
{ userId: req?.user?.id, agentId },
|
||||
)) ?? []
|
||||
).concat(resourceFiles);
|
||||
|
||||
const files = [];
|
||||
const sessions = new Map();
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
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,
|
||||
};
|
||||
@@ -1,123 +0,0 @@
|
||||
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,
|
||||
};
|
||||
@@ -1,525 +0,0 @@
|
||||
const client = require('openid-client');
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { CacheKeys } = require('librechat-data-provider');
|
||||
const { Client } = require('@microsoft/microsoft-graph-client');
|
||||
const { getOpenIdConfig } = require('~/strategies/openidStrategy');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
|
||||
/**
|
||||
* @import { TPrincipalSearchResult, TGraphPerson, TGraphUser, TGraphGroup, TGraphPeopleResponse, TGraphUsersResponse, TGraphGroupsResponse } from 'librechat-data-provider'
|
||||
*/
|
||||
|
||||
/**
|
||||
* Checks if Entra ID principal search feature is enabled based on environment variables and user authentication
|
||||
* @param {Object} user - User object from request
|
||||
* @param {string} user.provider - Authentication provider
|
||||
* @param {string} user.openidId - OpenID subject identifier
|
||||
* @returns {boolean} True if Entra ID principal search is enabled and user is authenticated via OpenID
|
||||
*/
|
||||
const entraIdPrincipalFeatureEnabled = (user) => {
|
||||
return (
|
||||
isEnabled(process.env.USE_ENTRA_ID_FOR_PEOPLE_SEARCH) &&
|
||||
isEnabled(process.env.OPENID_REUSE_TOKENS) &&
|
||||
user?.provider === 'openid' &&
|
||||
user?.openidId
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a Microsoft Graph client with on-behalf-of token exchange
|
||||
* @param {string} accessToken - OpenID Connect access token from user
|
||||
* @param {string} sub - Subject identifier from token claims
|
||||
* @returns {Promise<Client>} Authenticated Graph API client
|
||||
*/
|
||||
const createGraphClient = async (accessToken, sub) => {
|
||||
try {
|
||||
// Reason: Use existing OpenID configuration and token exchange pattern from openidStrategy.js
|
||||
const openidConfig = getOpenIdConfig();
|
||||
const exchangedToken = await exchangeTokenForGraphAccess(openidConfig, accessToken, sub);
|
||||
|
||||
const graphClient = Client.init({
|
||||
authProvider: (done) => {
|
||||
done(null, exchangedToken);
|
||||
},
|
||||
});
|
||||
|
||||
return graphClient;
|
||||
} catch (error) {
|
||||
logger.error('[createGraphClient] Error creating Graph client:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Exchange OpenID token for Graph API access using on-behalf-of flow
|
||||
* Similar to exchangeAccessTokenIfNeeded in openidStrategy.js but for Graph scopes
|
||||
* @param {Configuration} config - OpenID configuration
|
||||
* @param {string} accessToken - Original access token
|
||||
* @param {string} sub - Subject identifier
|
||||
* @returns {Promise<string>} Graph API access token
|
||||
*/
|
||||
const exchangeTokenForGraphAccess = async (config, accessToken, sub) => {
|
||||
try {
|
||||
const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS);
|
||||
const cacheKey = `${sub}:graph`;
|
||||
|
||||
const cachedToken = await tokensCache.get(cacheKey);
|
||||
if (cachedToken) {
|
||||
return cachedToken.access_token;
|
||||
}
|
||||
|
||||
const graphScopes = process.env.OPENID_GRAPH_SCOPES || 'User.Read,People.Read,Group.Read.All';
|
||||
const scopeString = graphScopes
|
||||
.split(',')
|
||||
.map((scope) => `https://graph.microsoft.com/${scope}`)
|
||||
.join(' ');
|
||||
|
||||
const grantResponse = await client.genericGrantRequest(
|
||||
config,
|
||||
'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
{
|
||||
scope: scopeString,
|
||||
assertion: accessToken,
|
||||
requested_token_use: 'on_behalf_of',
|
||||
},
|
||||
);
|
||||
|
||||
await tokensCache.set(
|
||||
cacheKey,
|
||||
{
|
||||
access_token: grantResponse.access_token,
|
||||
},
|
||||
grantResponse.expires_in * 1000,
|
||||
);
|
||||
|
||||
return grantResponse.access_token;
|
||||
} catch (error) {
|
||||
logger.error('[exchangeTokenForGraphAccess] Token exchange failed:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Search for principals (people and groups) using Microsoft Graph API
|
||||
* Uses searchContacts first, then searchUsers and searchGroups to fill remaining slots
|
||||
* @param {string} accessToken - OpenID Connect access token
|
||||
* @param {string} sub - Subject identifier
|
||||
* @param {string} query - Search query string
|
||||
* @param {string} type - Type filter ('users', 'groups', or 'all')
|
||||
* @param {number} limit - Maximum number of results
|
||||
* @returns {Promise<TPrincipalSearchResult[]>} Array of principal search results
|
||||
*/
|
||||
const searchEntraIdPrincipals = async (accessToken, sub, query, type = 'all', limit = 10) => {
|
||||
try {
|
||||
if (!query || query.trim().length < 2) {
|
||||
return [];
|
||||
}
|
||||
const graphClient = await createGraphClient(accessToken, sub);
|
||||
let allResults = [];
|
||||
|
||||
if (type === 'users' || type === 'all') {
|
||||
const contactResults = await searchContacts(graphClient, query, limit);
|
||||
allResults.push(...contactResults);
|
||||
}
|
||||
if (allResults.length >= limit) {
|
||||
return allResults.slice(0, limit);
|
||||
}
|
||||
|
||||
if (type === 'users') {
|
||||
const userResults = await searchUsers(graphClient, query, limit);
|
||||
allResults.push(...userResults);
|
||||
} else if (type === 'groups') {
|
||||
const groupResults = await searchGroups(graphClient, query, limit);
|
||||
allResults.push(...groupResults);
|
||||
} else if (type === 'all') {
|
||||
const [userResults, groupResults] = await Promise.all([
|
||||
searchUsers(graphClient, query, limit),
|
||||
searchGroups(graphClient, query, limit),
|
||||
]);
|
||||
|
||||
allResults.push(...userResults, ...groupResults);
|
||||
}
|
||||
|
||||
const seenIds = new Set();
|
||||
const uniqueResults = allResults.filter((result) => {
|
||||
if (seenIds.has(result.idOnTheSource)) {
|
||||
return false;
|
||||
}
|
||||
seenIds.add(result.idOnTheSource);
|
||||
return true;
|
||||
});
|
||||
|
||||
return uniqueResults.slice(0, limit);
|
||||
} catch (error) {
|
||||
logger.error('[searchEntraIdPrincipals] Error searching principals:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current user's Entra ID group memberships from Microsoft Graph
|
||||
* Uses /me/memberOf endpoint to get groups the user is a member of
|
||||
* @param {string} accessToken - OpenID Connect access token
|
||||
* @param {string} sub - Subject identifier
|
||||
* @returns {Promise<Array<string>>} Array of group ID strings (GUIDs)
|
||||
*/
|
||||
const getUserEntraGroups = async (accessToken, sub) => {
|
||||
try {
|
||||
const graphClient = await createGraphClient(accessToken, sub);
|
||||
|
||||
const groupsResponse = await graphClient.api('/me/memberOf').select('id').get();
|
||||
|
||||
return (groupsResponse.value || []).map((group) => group.id);
|
||||
} catch (error) {
|
||||
logger.error('[getUserEntraGroups] Error fetching user groups:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current user's owned Entra ID groups from Microsoft Graph
|
||||
* Uses /me/ownedObjects/microsoft.graph.group endpoint to get groups the user owns
|
||||
* @param {string} accessToken - OpenID Connect access token
|
||||
* @param {string} sub - Subject identifier
|
||||
* @returns {Promise<Array<string>>} Array of group ID strings (GUIDs)
|
||||
*/
|
||||
const getUserOwnedEntraGroups = async (accessToken, sub) => {
|
||||
try {
|
||||
const graphClient = await createGraphClient(accessToken, sub);
|
||||
|
||||
const groupsResponse = await graphClient
|
||||
.api('/me/ownedObjects/microsoft.graph.group')
|
||||
.select('id')
|
||||
.get();
|
||||
|
||||
return (groupsResponse.value || []).map((group) => group.id);
|
||||
} catch (error) {
|
||||
logger.error('[getUserOwnedEntraGroups] Error fetching user owned groups:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get group members from Microsoft Graph API
|
||||
* Recursively fetches all members using pagination (@odata.nextLink)
|
||||
* @param {string} accessToken - OpenID Connect access token
|
||||
* @param {string} sub - Subject identifier
|
||||
* @param {string} groupId - Entra ID group object ID
|
||||
* @returns {Promise<Array>} Array of member IDs (idOnTheSource values)
|
||||
*/
|
||||
const getGroupMembers = async (accessToken, sub, groupId) => {
|
||||
try {
|
||||
const graphClient = await createGraphClient(accessToken, sub);
|
||||
const allMembers = [];
|
||||
let nextLink = `/groups/${groupId}/members`;
|
||||
|
||||
while (nextLink) {
|
||||
const membersResponse = await graphClient.api(nextLink).select('id').top(999).get();
|
||||
|
||||
const members = membersResponse.value || [];
|
||||
allMembers.push(...members.map((member) => member.id));
|
||||
|
||||
nextLink = membersResponse['@odata.nextLink']
|
||||
? membersResponse['@odata.nextLink'].split('/v1.0')[1]
|
||||
: null;
|
||||
}
|
||||
|
||||
return allMembers;
|
||||
} catch (error) {
|
||||
logger.error('[getGroupMembers] Error fetching group members:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Get group owners from Microsoft Graph API
|
||||
* Recursively fetches all owners using pagination (@odata.nextLink)
|
||||
* @param {string} accessToken - OpenID Connect access token
|
||||
* @param {string} sub - Subject identifier
|
||||
* @param {string} groupId - Entra ID group object ID
|
||||
* @returns {Promise<Array>} Array of owner IDs (idOnTheSource values)
|
||||
*/
|
||||
const getGroupOwners = async (accessToken, sub, groupId) => {
|
||||
try {
|
||||
const graphClient = await createGraphClient(accessToken, sub);
|
||||
const allOwners = [];
|
||||
let nextLink = `/groups/${groupId}/owners`;
|
||||
|
||||
while (nextLink) {
|
||||
const ownersResponse = await graphClient.api(nextLink).select('id').top(999).get();
|
||||
|
||||
const owners = ownersResponse.value || [];
|
||||
allOwners.push(...owners.map((member) => member.id));
|
||||
|
||||
nextLink = ownersResponse['@odata.nextLink']
|
||||
? ownersResponse['@odata.nextLink'].split('/v1.0')[1]
|
||||
: null;
|
||||
}
|
||||
|
||||
return allOwners;
|
||||
} catch (error) {
|
||||
logger.error('[getGroupOwners] Error fetching group owners:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Search for contacts (users only) using Microsoft Graph /me/people endpoint
|
||||
* Returns mapped TPrincipalSearchResult objects for users only
|
||||
* @param {Client} graphClient - Authenticated Microsoft Graph client
|
||||
* @param {string} query - Search query string
|
||||
* @param {number} limit - Maximum number of results (default: 10)
|
||||
* @returns {Promise<TPrincipalSearchResult[]>} Array of mapped user contact results
|
||||
*/
|
||||
const searchContacts = async (graphClient, query, limit = 10) => {
|
||||
try {
|
||||
if (!query || query.trim().length < 2) {
|
||||
return [];
|
||||
}
|
||||
if (
|
||||
process.env.OPENID_GRAPH_SCOPES &&
|
||||
!process.env.OPENID_GRAPH_SCOPES.toLowerCase().includes('people.read')
|
||||
) {
|
||||
logger.warn('[searchContacts] People.Read scope is not enabled, skipping contact search');
|
||||
return [];
|
||||
}
|
||||
// Reason: Search only for OrganizationUser (person) type, not groups
|
||||
const filter = "personType/subclass eq 'OrganizationUser'";
|
||||
|
||||
let apiCall = graphClient
|
||||
.api('/me/people')
|
||||
.search(`"${query}"`)
|
||||
.select(
|
||||
'id,displayName,givenName,surname,userPrincipalName,jobTitle,department,companyName,scoredEmailAddresses,personType,phones',
|
||||
)
|
||||
.header('ConsistencyLevel', 'eventual')
|
||||
.filter(filter)
|
||||
.top(limit);
|
||||
|
||||
const contactsResponse = await apiCall.get();
|
||||
return (contactsResponse.value || []).map(mapContactToTPrincipalSearchResult);
|
||||
} catch (error) {
|
||||
logger.error('[searchContacts] Error searching contacts:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Search for users using Microsoft Graph /users endpoint
|
||||
* Returns mapped TPrincipalSearchResult objects
|
||||
* @param {Client} graphClient - Authenticated Microsoft Graph client
|
||||
* @param {string} query - Search query string
|
||||
* @param {number} limit - Maximum number of results (default: 10)
|
||||
* @returns {Promise<TPrincipalSearchResult[]>} Array of mapped user results
|
||||
*/
|
||||
const searchUsers = async (graphClient, query, limit = 10) => {
|
||||
try {
|
||||
if (!query || query.trim().length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Reason: Search users by display name, email, and user principal name
|
||||
const usersResponse = await graphClient
|
||||
.api('/users')
|
||||
.search(
|
||||
`"displayName:${query}" OR "userPrincipalName:${query}" OR "mail:${query}" OR "givenName:${query}" OR "surname:${query}"`,
|
||||
)
|
||||
.select(
|
||||
'id,displayName,givenName,surname,userPrincipalName,jobTitle,department,companyName,mail,phones',
|
||||
)
|
||||
.header('ConsistencyLevel', 'eventual')
|
||||
.top(limit)
|
||||
.get();
|
||||
|
||||
return (usersResponse.value || []).map(mapUserToTPrincipalSearchResult);
|
||||
} catch (error) {
|
||||
logger.error('[searchUsers] Error searching users:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Search for groups using Microsoft Graph /groups endpoint
|
||||
* Returns mapped TPrincipalSearchResult objects, includes all group types
|
||||
* @param {Client} graphClient - Authenticated Microsoft Graph client
|
||||
* @param {string} query - Search query string
|
||||
* @param {number} limit - Maximum number of results (default: 10)
|
||||
* @returns {Promise<TPrincipalSearchResult[]>} Array of mapped group results
|
||||
*/
|
||||
const searchGroups = async (graphClient, query, limit = 10) => {
|
||||
try {
|
||||
if (!query || query.trim().length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Reason: Search all groups by display name and email without filtering group types
|
||||
const groupsResponse = await graphClient
|
||||
.api('/groups')
|
||||
.search(`"displayName:${query}" OR "mail:${query}" OR "mailNickname:${query}"`)
|
||||
.select('id,displayName,mail,mailNickname,description,groupTypes,resourceProvisioningOptions')
|
||||
.header('ConsistencyLevel', 'eventual')
|
||||
.top(limit)
|
||||
.get();
|
||||
|
||||
return (groupsResponse.value || []).map(mapGroupToTPrincipalSearchResult);
|
||||
} catch (error) {
|
||||
logger.error('[searchGroups] Error searching groups:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Test Graph API connectivity and permissions
|
||||
* @param {string} accessToken - OpenID Connect access token
|
||||
* @param {string} sub - Subject identifier
|
||||
* @returns {Promise<Object>} Test results with available permissions
|
||||
*/
|
||||
const testGraphApiAccess = async (accessToken, sub) => {
|
||||
try {
|
||||
const graphClient = await createGraphClient(accessToken, sub);
|
||||
const results = {
|
||||
userAccess: false,
|
||||
peopleAccess: false,
|
||||
groupsAccess: false,
|
||||
usersEndpointAccess: false,
|
||||
groupsEndpointAccess: false,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
// Test User.Read permission
|
||||
try {
|
||||
await graphClient.api('/me').select('id,displayName').get();
|
||||
results.userAccess = true;
|
||||
} catch (error) {
|
||||
results.errors.push(`User.Read: ${error.message}`);
|
||||
}
|
||||
|
||||
// Test People.Read permission with OrganizationUser filter
|
||||
try {
|
||||
await graphClient
|
||||
.api('/me/people')
|
||||
.filter("personType/subclass eq 'OrganizationUser'")
|
||||
.top(1)
|
||||
.get();
|
||||
results.peopleAccess = true;
|
||||
} catch (error) {
|
||||
results.errors.push(`People.Read (OrganizationUser): ${error.message}`);
|
||||
}
|
||||
|
||||
// Test People.Read permission with UnifiedGroup filter
|
||||
try {
|
||||
await graphClient
|
||||
.api('/me/people')
|
||||
.filter("personType/subclass eq 'UnifiedGroup'")
|
||||
.top(1)
|
||||
.get();
|
||||
results.groupsAccess = true;
|
||||
} catch (error) {
|
||||
results.errors.push(`People.Read (UnifiedGroup): ${error.message}`);
|
||||
}
|
||||
|
||||
// Test /users endpoint access (requires User.Read.All or similar)
|
||||
try {
|
||||
await graphClient
|
||||
.api('/users')
|
||||
.search('"displayName:test"')
|
||||
.select('id,displayName,userPrincipalName')
|
||||
.top(1)
|
||||
.get();
|
||||
results.usersEndpointAccess = true;
|
||||
} catch (error) {
|
||||
results.errors.push(`Users endpoint: ${error.message}`);
|
||||
}
|
||||
|
||||
// Test /groups endpoint access (requires Group.Read.All or similar)
|
||||
try {
|
||||
await graphClient
|
||||
.api('/groups')
|
||||
.search('"displayName:test"')
|
||||
.select('id,displayName,mail')
|
||||
.top(1)
|
||||
.get();
|
||||
results.groupsEndpointAccess = true;
|
||||
} catch (error) {
|
||||
results.errors.push(`Groups endpoint: ${error.message}`);
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.error('[testGraphApiAccess] Error testing Graph API access:', error);
|
||||
return {
|
||||
userAccess: false,
|
||||
peopleAccess: false,
|
||||
groupsAccess: false,
|
||||
usersEndpointAccess: false,
|
||||
groupsEndpointAccess: false,
|
||||
errors: [error.message],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Map Graph API user object to TPrincipalSearchResult format
|
||||
* @param {TGraphUser} user - Raw user object from Graph API
|
||||
* @returns {TPrincipalSearchResult} Mapped user result
|
||||
*/
|
||||
const mapUserToTPrincipalSearchResult = (user) => {
|
||||
return {
|
||||
id: null,
|
||||
type: 'user',
|
||||
name: user.displayName,
|
||||
email: user.mail || user.userPrincipalName,
|
||||
username: user.userPrincipalName,
|
||||
source: 'entra',
|
||||
idOnTheSource: user.id,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Map Graph API group object to TPrincipalSearchResult format
|
||||
* @param {TGraphGroup} group - Raw group object from Graph API
|
||||
* @returns {TPrincipalSearchResult} Mapped group result
|
||||
*/
|
||||
const mapGroupToTPrincipalSearchResult = (group) => {
|
||||
return {
|
||||
id: null,
|
||||
type: 'group',
|
||||
name: group.displayName,
|
||||
email: group.mail || group.userPrincipalName,
|
||||
description: group.description,
|
||||
source: 'entra',
|
||||
idOnTheSource: group.id,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Map Graph API /me/people contact object to TPrincipalSearchResult format
|
||||
* Handles both user and group contacts from the people endpoint
|
||||
* @param {TGraphPerson} contact - Raw contact object from Graph API /me/people
|
||||
* @returns {TPrincipalSearchResult} Mapped contact result
|
||||
*/
|
||||
const mapContactToTPrincipalSearchResult = (contact) => {
|
||||
const isGroup = contact.personType?.class === 'Group';
|
||||
const primaryEmail = contact.scoredEmailAddresses?.[0]?.address;
|
||||
|
||||
return {
|
||||
id: null,
|
||||
type: isGroup ? 'group' : 'user',
|
||||
name: contact.displayName,
|
||||
email: primaryEmail,
|
||||
username: !isGroup ? contact.userPrincipalName : undefined,
|
||||
source: 'entra',
|
||||
idOnTheSource: contact.id,
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getGroupMembers,
|
||||
getGroupOwners,
|
||||
createGraphClient,
|
||||
getUserEntraGroups,
|
||||
getUserOwnedEntraGroups,
|
||||
testGraphApiAccess,
|
||||
searchEntraIdPrincipals,
|
||||
exchangeTokenForGraphAccess,
|
||||
entraIdPrincipalFeatureEnabled,
|
||||
};
|
||||
@@ -1,720 +0,0 @@
|
||||
jest.mock('@microsoft/microsoft-graph-client');
|
||||
jest.mock('~/strategies/openidStrategy');
|
||||
jest.mock('~/cache/getLogStores');
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
...jest.requireActual('@librechat/data-schemas'),
|
||||
logger: {
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
},
|
||||
}));
|
||||
jest.mock('~/config', () => ({
|
||||
logger: {
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
},
|
||||
createAxiosInstance: jest.fn(() => ({
|
||||
create: jest.fn(),
|
||||
defaults: {},
|
||||
})),
|
||||
}));
|
||||
jest.mock('~/utils', () => ({
|
||||
logAxiosError: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Config', () => ({}));
|
||||
jest.mock('~/server/services/Files/strategies', () => ({
|
||||
getStrategyFunctions: jest.fn(),
|
||||
}));
|
||||
|
||||
const mongoose = require('mongoose');
|
||||
const client = require('openid-client');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
const { Client } = require('@microsoft/microsoft-graph-client');
|
||||
const { getOpenIdConfig } = require('~/strategies/openidStrategy');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const GraphApiService = require('./GraphApiService');
|
||||
|
||||
describe('GraphApiService', () => {
|
||||
let mongoServer;
|
||||
let mockGraphClient;
|
||||
let mockTokensCache;
|
||||
let mockOpenIdConfig;
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
const mongoUri = mongoServer.getUri();
|
||||
await mongoose.connect(mongoUri);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
await mongoServer.stop();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up environment variables
|
||||
delete process.env.OPENID_GRAPH_SCOPES;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
await mongoose.connection.dropDatabase();
|
||||
|
||||
// Set up environment variable for People.Read scope
|
||||
process.env.OPENID_GRAPH_SCOPES = 'User.Read,People.Read,Group.Read.All';
|
||||
|
||||
// Mock Graph client
|
||||
mockGraphClient = {
|
||||
api: jest.fn().mockReturnThis(),
|
||||
search: jest.fn().mockReturnThis(),
|
||||
filter: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockReturnThis(),
|
||||
header: jest.fn().mockReturnThis(),
|
||||
top: jest.fn().mockReturnThis(),
|
||||
get: jest.fn(),
|
||||
};
|
||||
|
||||
Client.init.mockReturnValue(mockGraphClient);
|
||||
|
||||
// Mock tokens cache
|
||||
mockTokensCache = {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
};
|
||||
getLogStores.mockReturnValue(mockTokensCache);
|
||||
|
||||
// Mock OpenID config
|
||||
mockOpenIdConfig = {
|
||||
client_id: 'test-client-id',
|
||||
issuer: 'https://test-issuer.com',
|
||||
};
|
||||
getOpenIdConfig.mockReturnValue(mockOpenIdConfig);
|
||||
|
||||
// Mock openid-client (using the existing jest mock configuration)
|
||||
if (client.genericGrantRequest) {
|
||||
client.genericGrantRequest.mockResolvedValue({
|
||||
access_token: 'mocked-graph-token',
|
||||
expires_in: 3600,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('Dependency Contract Tests', () => {
|
||||
it('should fail if getOpenIdConfig interface changes', () => {
|
||||
// Reason: Ensure getOpenIdConfig returns expected structure
|
||||
const config = getOpenIdConfig();
|
||||
|
||||
expect(config).toBeDefined();
|
||||
expect(typeof config).toBe('object');
|
||||
// Add specific property checks that GraphApiService depends on
|
||||
expect(config).toHaveProperty('client_id');
|
||||
expect(config).toHaveProperty('issuer');
|
||||
|
||||
// Ensure the function is callable
|
||||
expect(typeof getOpenIdConfig).toBe('function');
|
||||
});
|
||||
|
||||
it('should fail if openid-client.genericGrantRequest interface changes', () => {
|
||||
// Reason: Ensure client.genericGrantRequest maintains expected signature
|
||||
if (client.genericGrantRequest) {
|
||||
expect(typeof client.genericGrantRequest).toBe('function');
|
||||
|
||||
// Test that it accepts the expected parameters
|
||||
const mockCall = client.genericGrantRequest(
|
||||
mockOpenIdConfig,
|
||||
'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
{
|
||||
scope: 'test-scope',
|
||||
assertion: 'test-token',
|
||||
requested_token_use: 'on_behalf_of',
|
||||
},
|
||||
);
|
||||
|
||||
expect(mockCall).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should fail if Microsoft Graph Client interface changes', () => {
|
||||
// Reason: Ensure Graph Client maintains expected fluent API
|
||||
expect(typeof Client.init).toBe('function');
|
||||
|
||||
const client = Client.init({ authProvider: jest.fn() });
|
||||
expect(client).toHaveProperty('api');
|
||||
expect(typeof client.api).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createGraphClient', () => {
|
||||
it('should create graph client with exchanged token', async () => {
|
||||
const accessToken = 'test-access-token';
|
||||
const sub = 'test-user-id';
|
||||
|
||||
const result = await GraphApiService.createGraphClient(accessToken, sub);
|
||||
|
||||
expect(getOpenIdConfig).toHaveBeenCalled();
|
||||
expect(Client.init).toHaveBeenCalledWith({
|
||||
authProvider: expect.any(Function),
|
||||
});
|
||||
expect(result).toBe(mockGraphClient);
|
||||
});
|
||||
|
||||
it('should handle token exchange errors gracefully', async () => {
|
||||
if (client.genericGrantRequest) {
|
||||
client.genericGrantRequest.mockRejectedValue(new Error('Token exchange failed'));
|
||||
}
|
||||
|
||||
await expect(GraphApiService.createGraphClient('invalid-token', 'test-user')).rejects.toThrow(
|
||||
'Token exchange failed',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exchangeTokenForGraphAccess', () => {
|
||||
it('should return cached token if available', async () => {
|
||||
const cachedToken = { access_token: 'cached-token' };
|
||||
mockTokensCache.get.mockResolvedValue(cachedToken);
|
||||
|
||||
const result = await GraphApiService.exchangeTokenForGraphAccess(
|
||||
mockOpenIdConfig,
|
||||
'test-token',
|
||||
'test-user',
|
||||
);
|
||||
|
||||
expect(result).toBe('cached-token');
|
||||
expect(mockTokensCache.get).toHaveBeenCalledWith('test-user:graph');
|
||||
if (client.genericGrantRequest) {
|
||||
expect(client.genericGrantRequest).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('should exchange token and cache result', async () => {
|
||||
mockTokensCache.get.mockResolvedValue(null);
|
||||
|
||||
const result = await GraphApiService.exchangeTokenForGraphAccess(
|
||||
mockOpenIdConfig,
|
||||
'test-token',
|
||||
'test-user',
|
||||
);
|
||||
|
||||
if (client.genericGrantRequest) {
|
||||
expect(client.genericGrantRequest).toHaveBeenCalledWith(
|
||||
mockOpenIdConfig,
|
||||
'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
{
|
||||
scope:
|
||||
'https://graph.microsoft.com/User.Read https://graph.microsoft.com/People.Read https://graph.microsoft.com/Group.Read.All',
|
||||
assertion: 'test-token',
|
||||
requested_token_use: 'on_behalf_of',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
expect(mockTokensCache.set).toHaveBeenCalledWith(
|
||||
'test-user:graph',
|
||||
{ access_token: 'mocked-graph-token' },
|
||||
3600000,
|
||||
);
|
||||
|
||||
expect(result).toBe('mocked-graph-token');
|
||||
});
|
||||
|
||||
it('should use custom scopes from environment', async () => {
|
||||
const originalEnv = process.env.OPENID_GRAPH_SCOPES;
|
||||
process.env.OPENID_GRAPH_SCOPES = 'Custom.Read,Custom.Write';
|
||||
|
||||
mockTokensCache.get.mockResolvedValue(null);
|
||||
|
||||
await GraphApiService.exchangeTokenForGraphAccess(
|
||||
mockOpenIdConfig,
|
||||
'test-token',
|
||||
'test-user',
|
||||
);
|
||||
|
||||
if (client.genericGrantRequest) {
|
||||
expect(client.genericGrantRequest).toHaveBeenCalledWith(
|
||||
mockOpenIdConfig,
|
||||
'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
{
|
||||
scope:
|
||||
'https://graph.microsoft.com/Custom.Read https://graph.microsoft.com/Custom.Write',
|
||||
assertion: 'test-token',
|
||||
requested_token_use: 'on_behalf_of',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
process.env.OPENID_GRAPH_SCOPES = originalEnv;
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchEntraIdPrincipals', () => {
|
||||
// Mock data used by multiple tests
|
||||
const mockContactsResponse = {
|
||||
value: [
|
||||
{
|
||||
id: 'contact-user-1',
|
||||
displayName: 'John Doe',
|
||||
userPrincipalName: 'john@company.com',
|
||||
mail: 'john@company.com',
|
||||
personType: { class: 'Person', subclass: 'OrganizationUser' },
|
||||
scoredEmailAddresses: [{ address: 'john@company.com', relevanceScore: 0.9 }],
|
||||
},
|
||||
{
|
||||
id: 'contact-group-1',
|
||||
displayName: 'Marketing Team',
|
||||
mail: 'marketing@company.com',
|
||||
personType: { class: 'Group', subclass: 'UnifiedGroup' },
|
||||
scoredEmailAddresses: [{ address: 'marketing@company.com', relevanceScore: 0.8 }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockUsersResponse = {
|
||||
value: [
|
||||
{
|
||||
id: 'dir-user-1',
|
||||
displayName: 'Jane Smith',
|
||||
userPrincipalName: 'jane@company.com',
|
||||
mail: 'jane@company.com',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockGroupsResponse = {
|
||||
value: [
|
||||
{
|
||||
id: 'dir-group-1',
|
||||
displayName: 'Development Team',
|
||||
mail: 'dev@company.com',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mock call history for each test
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Re-apply the Client.init mock after clearAllMocks
|
||||
Client.init.mockReturnValue(mockGraphClient);
|
||||
|
||||
// Re-apply openid-client mock
|
||||
if (client.genericGrantRequest) {
|
||||
client.genericGrantRequest.mockResolvedValue({
|
||||
access_token: 'mocked-graph-token',
|
||||
expires_in: 3600,
|
||||
});
|
||||
}
|
||||
|
||||
// Re-apply cache mock
|
||||
mockTokensCache.get.mockResolvedValue(null); // Force token exchange
|
||||
mockTokensCache.set.mockResolvedValue();
|
||||
getLogStores.mockReturnValue(mockTokensCache);
|
||||
getOpenIdConfig.mockReturnValue(mockOpenIdConfig);
|
||||
});
|
||||
|
||||
it('should return empty results for short queries', async () => {
|
||||
const result = await GraphApiService.searchEntraIdPrincipals('token', 'user', 'a', 'all', 10);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(mockGraphClient.api).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should search contacts first and additional users for users type', async () => {
|
||||
// Mock responses for this specific test
|
||||
const contactsFilteredResponse = {
|
||||
value: [
|
||||
{
|
||||
id: 'contact-user-1',
|
||||
displayName: 'John Doe',
|
||||
userPrincipalName: 'john@company.com',
|
||||
mail: 'john@company.com',
|
||||
personType: { class: 'Person', subclass: 'OrganizationUser' },
|
||||
scoredEmailAddresses: [{ address: 'john@company.com', relevanceScore: 0.9 }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockGraphClient.get
|
||||
.mockResolvedValueOnce(contactsFilteredResponse) // contacts call
|
||||
.mockResolvedValueOnce(mockUsersResponse); // users call
|
||||
|
||||
const result = await GraphApiService.searchEntraIdPrincipals(
|
||||
'token',
|
||||
'user',
|
||||
'john',
|
||||
'users',
|
||||
10,
|
||||
);
|
||||
|
||||
// Should call contacts first with user filter
|
||||
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/people');
|
||||
expect(mockGraphClient.search).toHaveBeenCalledWith('"john"');
|
||||
expect(mockGraphClient.filter).toHaveBeenCalledWith(
|
||||
"personType/subclass eq 'OrganizationUser'",
|
||||
);
|
||||
|
||||
// Should call users endpoint for additional results
|
||||
expect(mockGraphClient.api).toHaveBeenCalledWith('/users');
|
||||
expect(mockGraphClient.search).toHaveBeenCalledWith(
|
||||
'"displayName:john" OR "userPrincipalName:john" OR "mail:john" OR "givenName:john" OR "surname:john"',
|
||||
);
|
||||
|
||||
// Should return TPrincipalSearchResult array
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result).toHaveLength(2); // 1 from contacts + 1 from users
|
||||
expect(result[0]).toMatchObject({
|
||||
id: null,
|
||||
type: 'user',
|
||||
name: 'John Doe',
|
||||
email: 'john@company.com',
|
||||
source: 'entra',
|
||||
idOnTheSource: 'contact-user-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should search groups endpoint only for groups type', async () => {
|
||||
// Mock responses for this specific test - only groups endpoint called
|
||||
mockGraphClient.get.mockResolvedValueOnce(mockGroupsResponse); // only groups call
|
||||
|
||||
const result = await GraphApiService.searchEntraIdPrincipals(
|
||||
'token',
|
||||
'user',
|
||||
'team',
|
||||
'groups',
|
||||
10,
|
||||
);
|
||||
|
||||
// Should NOT call contacts for groups type
|
||||
expect(mockGraphClient.api).not.toHaveBeenCalledWith('/me/people');
|
||||
|
||||
// Should call groups endpoint only
|
||||
expect(mockGraphClient.api).toHaveBeenCalledWith('/groups');
|
||||
expect(mockGraphClient.search).toHaveBeenCalledWith(
|
||||
'"displayName:team" OR "mail:team" OR "mailNickname:team"',
|
||||
);
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result).toHaveLength(1); // 1 from groups only
|
||||
});
|
||||
|
||||
it('should search all endpoints for all type', async () => {
|
||||
// Mock responses for this specific test
|
||||
mockGraphClient.get
|
||||
.mockResolvedValueOnce(mockContactsResponse) // contacts call (both user and group)
|
||||
.mockResolvedValueOnce(mockUsersResponse) // users call
|
||||
.mockResolvedValueOnce(mockGroupsResponse); // groups call
|
||||
|
||||
const result = await GraphApiService.searchEntraIdPrincipals(
|
||||
'token',
|
||||
'user',
|
||||
'test',
|
||||
'all',
|
||||
10,
|
||||
);
|
||||
|
||||
// Should call contacts first with user filter
|
||||
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/people');
|
||||
expect(mockGraphClient.search).toHaveBeenCalledWith('"test"');
|
||||
expect(mockGraphClient.filter).toHaveBeenCalledWith(
|
||||
"personType/subclass eq 'OrganizationUser'",
|
||||
);
|
||||
|
||||
// Should call both users and groups endpoints
|
||||
expect(mockGraphClient.api).toHaveBeenCalledWith('/users');
|
||||
expect(mockGraphClient.api).toHaveBeenCalledWith('/groups');
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result).toHaveLength(4); // 2 from contacts + 1 from users + 1 from groups
|
||||
});
|
||||
|
||||
it('should early exit if contacts reach limit', async () => {
|
||||
// Mock contacts to return exactly the limit
|
||||
const limitedContactsResponse = {
|
||||
value: Array(10).fill({
|
||||
id: 'contact-1',
|
||||
displayName: 'Contact User',
|
||||
mail: 'contact@company.com',
|
||||
personType: { class: 'Person', subclass: 'OrganizationUser' },
|
||||
}),
|
||||
};
|
||||
|
||||
mockGraphClient.get.mockResolvedValueOnce(limitedContactsResponse);
|
||||
|
||||
const result = await GraphApiService.searchEntraIdPrincipals(
|
||||
'token',
|
||||
'user',
|
||||
'test',
|
||||
'all',
|
||||
10,
|
||||
);
|
||||
|
||||
// Should call contacts first
|
||||
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/people');
|
||||
expect(mockGraphClient.search).toHaveBeenCalledWith('"test"');
|
||||
// Should not call users endpoint since limit was reached
|
||||
expect(mockGraphClient.api).not.toHaveBeenCalledWith('/users');
|
||||
|
||||
expect(result).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('should deduplicate results based on idOnTheSource', async () => {
|
||||
// Mock responses with duplicate IDs
|
||||
const duplicateContactsResponse = {
|
||||
value: [
|
||||
{
|
||||
id: 'duplicate-id',
|
||||
displayName: 'John Doe',
|
||||
mail: 'john@company.com',
|
||||
personType: { class: 'Person', subclass: 'OrganizationUser' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const duplicateUsersResponse = {
|
||||
value: [
|
||||
{
|
||||
id: 'duplicate-id', // Same ID as contact
|
||||
displayName: 'John Doe',
|
||||
mail: 'john@company.com',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockGraphClient.get
|
||||
.mockResolvedValueOnce(duplicateContactsResponse)
|
||||
.mockResolvedValueOnce(duplicateUsersResponse);
|
||||
|
||||
const result = await GraphApiService.searchEntraIdPrincipals(
|
||||
'token',
|
||||
'user',
|
||||
'john',
|
||||
'users',
|
||||
10,
|
||||
);
|
||||
|
||||
// Should only return one result despite duplicate IDs
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].idOnTheSource).toBe('duplicate-id');
|
||||
});
|
||||
|
||||
it('should handle Graph API errors gracefully', async () => {
|
||||
mockGraphClient.get.mockRejectedValue(new Error('Graph API error'));
|
||||
|
||||
const result = await GraphApiService.searchEntraIdPrincipals(
|
||||
'token',
|
||||
'user',
|
||||
'test',
|
||||
'all',
|
||||
10,
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserEntraGroups', () => {
|
||||
it('should fetch user groups from memberOf endpoint', async () => {
|
||||
const mockGroupsResponse = {
|
||||
value: [
|
||||
{
|
||||
id: 'group-1',
|
||||
},
|
||||
{
|
||||
id: 'group-2',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockGraphClient.get.mockResolvedValue(mockGroupsResponse);
|
||||
|
||||
const result = await GraphApiService.getUserEntraGroups('token', 'user');
|
||||
|
||||
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/memberOf');
|
||||
expect(mockGraphClient.select).toHaveBeenCalledWith('id');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toEqual(['group-1', 'group-2']);
|
||||
});
|
||||
|
||||
it('should return empty array on error', async () => {
|
||||
mockGraphClient.get.mockRejectedValue(new Error('API error'));
|
||||
|
||||
const result = await GraphApiService.getUserEntraGroups('token', 'user');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle empty response', async () => {
|
||||
const mockGroupsResponse = {
|
||||
value: [],
|
||||
};
|
||||
|
||||
mockGraphClient.get.mockResolvedValue(mockGroupsResponse);
|
||||
|
||||
const result = await GraphApiService.getUserEntraGroups('token', 'user');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle missing value property', async () => {
|
||||
mockGraphClient.get.mockResolvedValue({});
|
||||
|
||||
const result = await GraphApiService.getUserEntraGroups('token', 'user');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('testGraphApiAccess', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should test all permissions and return success results', async () => {
|
||||
// Mock successful responses for all tests
|
||||
mockGraphClient.get
|
||||
.mockResolvedValueOnce({ id: 'user-123', displayName: 'Test User' }) // /me test
|
||||
.mockResolvedValueOnce({ value: [] }) // people OrganizationUser test
|
||||
.mockResolvedValueOnce({ value: [] }) // people UnifiedGroup test
|
||||
.mockResolvedValueOnce({ value: [] }) // /users endpoint test
|
||||
.mockResolvedValueOnce({ value: [] }); // /groups endpoint test
|
||||
|
||||
const result = await GraphApiService.testGraphApiAccess('token', 'user');
|
||||
|
||||
expect(result).toEqual({
|
||||
userAccess: true,
|
||||
peopleAccess: true,
|
||||
groupsAccess: true,
|
||||
usersEndpointAccess: true,
|
||||
groupsEndpointAccess: true,
|
||||
errors: [],
|
||||
});
|
||||
|
||||
// Verify all endpoints were tested
|
||||
expect(mockGraphClient.api).toHaveBeenCalledWith('/me');
|
||||
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/people');
|
||||
expect(mockGraphClient.api).toHaveBeenCalledWith('/users');
|
||||
expect(mockGraphClient.api).toHaveBeenCalledWith('/groups');
|
||||
expect(mockGraphClient.filter).toHaveBeenCalledWith(
|
||||
"personType/subclass eq 'OrganizationUser'",
|
||||
);
|
||||
expect(mockGraphClient.filter).toHaveBeenCalledWith("personType/subclass eq 'UnifiedGroup'");
|
||||
expect(mockGraphClient.search).toHaveBeenCalledWith('"displayName:test"');
|
||||
});
|
||||
|
||||
it('should handle partial failures and record errors', async () => {
|
||||
// Mock mixed success/failure responses
|
||||
mockGraphClient.get
|
||||
.mockResolvedValueOnce({ id: 'user-123', displayName: 'Test User' }) // /me success
|
||||
.mockRejectedValueOnce(new Error('People access denied')) // people OrganizationUser fail
|
||||
.mockResolvedValueOnce({ value: [] }) // people UnifiedGroup success
|
||||
.mockRejectedValueOnce(new Error('Users endpoint access denied')) // /users fail
|
||||
.mockResolvedValueOnce({ value: [] }); // /groups success
|
||||
|
||||
const result = await GraphApiService.testGraphApiAccess('token', 'user');
|
||||
|
||||
expect(result).toEqual({
|
||||
userAccess: true,
|
||||
peopleAccess: false,
|
||||
groupsAccess: true,
|
||||
usersEndpointAccess: false,
|
||||
groupsEndpointAccess: true,
|
||||
errors: [
|
||||
'People.Read (OrganizationUser): People access denied',
|
||||
'Users endpoint: Users endpoint access denied',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle complete Graph client creation failure', async () => {
|
||||
// Mock token exchange failure to test error handling
|
||||
if (client.genericGrantRequest) {
|
||||
client.genericGrantRequest.mockRejectedValue(new Error('Token exchange failed'));
|
||||
}
|
||||
|
||||
const result = await GraphApiService.testGraphApiAccess('invalid-token', 'user');
|
||||
|
||||
expect(result).toEqual({
|
||||
userAccess: false,
|
||||
peopleAccess: false,
|
||||
groupsAccess: false,
|
||||
usersEndpointAccess: false,
|
||||
groupsEndpointAccess: false,
|
||||
errors: ['Token exchange failed'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should record all permission errors', async () => {
|
||||
// Mock all requests to fail
|
||||
mockGraphClient.get
|
||||
.mockRejectedValueOnce(new Error('User.Read denied'))
|
||||
.mockRejectedValueOnce(new Error('People.Read OrganizationUser denied'))
|
||||
.mockRejectedValueOnce(new Error('People.Read UnifiedGroup denied'))
|
||||
.mockRejectedValueOnce(new Error('Users directory access denied'))
|
||||
.mockRejectedValueOnce(new Error('Groups directory access denied'));
|
||||
|
||||
const result = await GraphApiService.testGraphApiAccess('token', 'user');
|
||||
|
||||
expect(result).toEqual({
|
||||
userAccess: false,
|
||||
peopleAccess: false,
|
||||
groupsAccess: false,
|
||||
usersEndpointAccess: false,
|
||||
groupsEndpointAccess: false,
|
||||
errors: [
|
||||
'User.Read: User.Read denied',
|
||||
'People.Read (OrganizationUser): People.Read OrganizationUser denied',
|
||||
'People.Read (UnifiedGroup): People.Read UnifiedGroup denied',
|
||||
'Users endpoint: Users directory access denied',
|
||||
'Groups endpoint: Groups directory access denied',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should test new endpoints with correct search patterns', async () => {
|
||||
// Mock successful responses for endpoint testing
|
||||
mockGraphClient.get
|
||||
.mockResolvedValueOnce({ id: 'user-123', displayName: 'Test User' }) // /me
|
||||
.mockResolvedValueOnce({ value: [] }) // people OrganizationUser
|
||||
.mockResolvedValueOnce({ value: [] }) // people UnifiedGroup
|
||||
.mockResolvedValueOnce({ value: [] }) // /users
|
||||
.mockResolvedValueOnce({ value: [] }); // /groups
|
||||
|
||||
await GraphApiService.testGraphApiAccess('token', 'user');
|
||||
|
||||
// Verify /users endpoint test
|
||||
expect(mockGraphClient.api).toHaveBeenCalledWith('/users');
|
||||
expect(mockGraphClient.search).toHaveBeenCalledWith('"displayName:test"');
|
||||
expect(mockGraphClient.select).toHaveBeenCalledWith('id,displayName,userPrincipalName');
|
||||
|
||||
// Verify /groups endpoint test
|
||||
expect(mockGraphClient.api).toHaveBeenCalledWith('/groups');
|
||||
expect(mockGraphClient.select).toHaveBeenCalledWith('id,displayName,mail');
|
||||
});
|
||||
|
||||
it('should handle endpoint-specific permission failures', async () => {
|
||||
// Mock specific endpoint failures
|
||||
mockGraphClient.get
|
||||
.mockResolvedValueOnce({ id: 'user-123', displayName: 'Test User' }) // /me success
|
||||
.mockResolvedValueOnce({ value: [] }) // people OrganizationUser success
|
||||
.mockResolvedValueOnce({ value: [] }) // people UnifiedGroup success
|
||||
.mockRejectedValueOnce(new Error('Insufficient privileges')) // /users fail (User.Read.All needed)
|
||||
.mockRejectedValueOnce(new Error('Access denied to groups')); // /groups fail (Group.Read.All needed)
|
||||
|
||||
const result = await GraphApiService.testGraphApiAccess('token', 'user');
|
||||
|
||||
expect(result).toEqual({
|
||||
userAccess: true,
|
||||
peopleAccess: true,
|
||||
groupsAccess: true,
|
||||
usersEndpointAccess: false,
|
||||
groupsEndpointAccess: false,
|
||||
errors: [
|
||||
'Users endpoint: Insufficient privileges',
|
||||
'Groups endpoint: Access denied to groups',
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -104,7 +104,7 @@ function createAbortHandler({ userId, serverName, toolName, flowManager }) {
|
||||
* @returns { Promise<typeof tool | { _call: (toolInput: Object | string) => unknown}> } An object with `_call` method to execute the tool input.
|
||||
*/
|
||||
async function createMCPTool({ req, res, toolKey, provider: _provider }) {
|
||||
const availableTools = await getCachedTools({ includeGlobal: true });
|
||||
const availableTools = await getCachedTools({ userId: req.user?.id, includeGlobal: true });
|
||||
const toolDefinition = availableTools?.[toolKey]?.function;
|
||||
if (!toolDefinition) {
|
||||
logger.error(`Tool ${toolKey} not found in available tools`);
|
||||
|
||||
@@ -1,721 +0,0 @@
|
||||
const mongoose = require('mongoose');
|
||||
const { getTransactionSupport, logger } = require('@librechat/data-schemas');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const {
|
||||
entraIdPrincipalFeatureEnabled,
|
||||
getUserEntraGroups,
|
||||
getUserOwnedEntraGroups,
|
||||
getGroupMembers,
|
||||
getGroupOwners,
|
||||
} = require('~/server/services/GraphApiService');
|
||||
const {
|
||||
findGroupByExternalId,
|
||||
findRoleByIdentifier,
|
||||
getUserPrincipals,
|
||||
createGroup,
|
||||
createUser,
|
||||
updateUser,
|
||||
findUser,
|
||||
grantPermission: grantPermissionACL,
|
||||
findAccessibleResources: findAccessibleResourcesACL,
|
||||
hasPermission,
|
||||
getEffectivePermissions: getEffectivePermissionsACL,
|
||||
findEntriesByPrincipalsAndResource,
|
||||
} = require('~/models');
|
||||
const { AclEntry, AccessRole, Group } = require('~/db/models');
|
||||
|
||||
/** @type {boolean|null} */
|
||||
let transactionSupportCache = null;
|
||||
|
||||
/**
|
||||
* @import { TPrincipal } from 'librechat-data-provider'
|
||||
*/
|
||||
/**
|
||||
* Grant a permission to a principal for a resource using a role
|
||||
* @param {Object} params - Parameters for granting role-based permission
|
||||
* @param {string} params.principalType - 'user', 'group', or 'public'
|
||||
* @param {string|mongoose.Types.ObjectId|null} params.principalId - The ID of the principal (null for 'public')
|
||||
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
|
||||
* @param {string|mongoose.Types.ObjectId} params.resourceId - The ID of the resource
|
||||
* @param {string} params.accessRoleId - The ID of the role (e.g., 'agent_viewer', 'agent_editor')
|
||||
* @param {string|mongoose.Types.ObjectId} params.grantedBy - User ID granting the permission
|
||||
* @param {mongoose.ClientSession} [params.session] - Optional MongoDB session for transactions
|
||||
* @returns {Promise<Object>} The created or updated ACL entry
|
||||
*/
|
||||
const grantPermission = async ({
|
||||
principalType,
|
||||
principalId,
|
||||
resourceType,
|
||||
resourceId,
|
||||
accessRoleId,
|
||||
grantedBy,
|
||||
session,
|
||||
}) => {
|
||||
try {
|
||||
if (!['user', 'group', 'public'].includes(principalType)) {
|
||||
throw new Error(`Invalid principal type: ${principalType}`);
|
||||
}
|
||||
|
||||
if (principalType !== 'public' && !principalId) {
|
||||
throw new Error('Principal ID is required for user and group principals');
|
||||
}
|
||||
|
||||
if (principalId && !mongoose.Types.ObjectId.isValid(principalId)) {
|
||||
throw new Error(`Invalid principal ID: ${principalId}`);
|
||||
}
|
||||
|
||||
if (!resourceId || !mongoose.Types.ObjectId.isValid(resourceId)) {
|
||||
throw new Error(`Invalid resource ID: ${resourceId}`);
|
||||
}
|
||||
|
||||
// Get the role to determine permission bits
|
||||
const role = await findRoleByIdentifier(accessRoleId);
|
||||
if (!role) {
|
||||
throw new Error(`Role ${accessRoleId} not found`);
|
||||
}
|
||||
|
||||
// Ensure the role is for the correct resource type
|
||||
if (role.resourceType !== resourceType) {
|
||||
throw new Error(
|
||||
`Role ${accessRoleId} is for ${role.resourceType} resources, not ${resourceType}`,
|
||||
);
|
||||
}
|
||||
return await grantPermissionACL(
|
||||
principalType,
|
||||
principalId,
|
||||
resourceType,
|
||||
resourceId,
|
||||
role.permBits,
|
||||
grantedBy,
|
||||
session,
|
||||
role._id,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`[PermissionService.grantPermission] Error: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a user has specific permission bits on a resource
|
||||
* @param {Object} params - Parameters for checking permissions
|
||||
* @param {string|mongoose.Types.ObjectId} params.userId - The ID of the user
|
||||
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
|
||||
* @param {string|mongoose.Types.ObjectId} params.resourceId - The ID of the resource
|
||||
* @param {number} params.requiredPermissions - The permission bits required (e.g., 1 for VIEW, 3 for VIEW+EDIT)
|
||||
* @returns {Promise<boolean>} Whether the user has the required permission bits
|
||||
*/
|
||||
const checkPermission = async ({ userId, resourceType, resourceId, requiredPermission }) => {
|
||||
try {
|
||||
if (typeof requiredPermission !== 'number' || requiredPermission < 1) {
|
||||
throw new Error('requiredPermission must be a positive number');
|
||||
}
|
||||
|
||||
// Get all principals for the user (user + groups + public)
|
||||
const principals = await getUserPrincipals(userId);
|
||||
|
||||
if (principals.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await hasPermission(principals, resourceType, resourceId, requiredPermission);
|
||||
} catch (error) {
|
||||
logger.error(`[PermissionService.checkPermission] Error: ${error.message}`);
|
||||
// Re-throw validation errors
|
||||
if (error.message.includes('requiredPermission must be')) {
|
||||
throw error;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get effective permission bitmask for a user on a resource
|
||||
* @param {Object} params - Parameters for getting effective permissions
|
||||
* @param {string|mongoose.Types.ObjectId} params.userId - The ID of the user
|
||||
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
|
||||
* @param {string|mongoose.Types.ObjectId} params.resourceId - The ID of the resource
|
||||
* @returns {Promise<number>} Effective permission bitmask
|
||||
*/
|
||||
const getEffectivePermissions = async ({ userId, resourceType, resourceId }) => {
|
||||
try {
|
||||
// Get all principals for the user (user + groups + public)
|
||||
const principals = await getUserPrincipals(userId);
|
||||
|
||||
if (principals.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return await getEffectivePermissionsACL(principals, resourceType, resourceId);
|
||||
} catch (error) {
|
||||
logger.error(`[PermissionService.getEffectivePermissions] Error: ${error.message}`);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Find all resources of a specific type that a user has access to with specific permission bits
|
||||
* @param {Object} params - Parameters for finding accessible resources
|
||||
* @param {string|mongoose.Types.ObjectId} params.userId - The ID of the user
|
||||
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
|
||||
* @param {number} params.requiredPermissions - The minimum permission bits required (e.g., 1 for VIEW, 3 for VIEW+EDIT)
|
||||
* @returns {Promise<Array>} Array of resource IDs
|
||||
*/
|
||||
const findAccessibleResources = async ({ userId, resourceType, requiredPermissions }) => {
|
||||
try {
|
||||
if (typeof requiredPermissions !== 'number' || requiredPermissions < 1) {
|
||||
throw new Error('requiredPermissions must be a positive number');
|
||||
}
|
||||
|
||||
// Get all principals for the user (user + groups + public)
|
||||
const principalsList = await getUserPrincipals(userId);
|
||||
|
||||
if (principalsList.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return await findAccessibleResourcesACL(principalsList, resourceType, requiredPermissions);
|
||||
} catch (error) {
|
||||
logger.error(`[PermissionService.findAccessibleResources] Error: ${error.message}`);
|
||||
// Re-throw validation errors
|
||||
if (error.message.includes('requiredPermissions must be')) {
|
||||
throw error;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Find all publicly accessible resources of a specific type
|
||||
* @param {Object} params - Parameters for finding publicly accessible resources
|
||||
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
|
||||
* @param {number} params.requiredPermissions - The minimum permission bits required (e.g., 1 for VIEW, 3 for VIEW+EDIT)
|
||||
* @returns {Promise<Array>} Array of resource IDs
|
||||
*/
|
||||
const findPubliclyAccessibleResources = async ({ resourceType, requiredPermissions }) => {
|
||||
try {
|
||||
if (typeof requiredPermissions !== 'number' || requiredPermissions < 1) {
|
||||
throw new Error('requiredPermissions must be a positive number');
|
||||
}
|
||||
|
||||
// Find all public ACL entries where the public principal has at least the required permission bits
|
||||
const entries = await AclEntry.find({
|
||||
principalType: 'public',
|
||||
resourceType,
|
||||
permBits: { $bitsAllSet: requiredPermissions },
|
||||
}).distinct('resourceId');
|
||||
|
||||
return entries;
|
||||
} catch (error) {
|
||||
logger.error(`[PermissionService.findPubliclyAccessibleResources] Error: ${error.message}`);
|
||||
// Re-throw validation errors
|
||||
if (error.message.includes('requiredPermissions must be')) {
|
||||
throw error;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get available roles for a resource type
|
||||
* @param {Object} params - Parameters for getting available roles
|
||||
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
|
||||
* @returns {Promise<Array>} Array of role definitions
|
||||
*/
|
||||
const getAvailableRoles = async ({ resourceType }) => {
|
||||
try {
|
||||
return await AccessRole.find({ resourceType }).lean();
|
||||
} catch (error) {
|
||||
logger.error(`[PermissionService.getAvailableRoles] Error: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensures a principal exists in the database based on TPrincipal data
|
||||
* Creates user if it doesn't exist locally (for Entra ID users)
|
||||
* @param {Object} principal - TPrincipal object from frontend
|
||||
* @param {string} principal.type - 'user', 'group', or 'public'
|
||||
* @param {string} [principal.id] - Local database ID (null for Entra ID principals not yet synced)
|
||||
* @param {string} principal.name - Display name
|
||||
* @param {string} [principal.email] - Email address
|
||||
* @param {string} [principal.source] - 'local' or 'entra'
|
||||
* @param {string} [principal.idOnTheSource] - Entra ID object ID for external principals
|
||||
* @returns {Promise<string|null>} Returns the principalId for database operations, null for public
|
||||
*/
|
||||
const ensurePrincipalExists = async function (principal) {
|
||||
if (principal.type === 'public') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (principal.id) {
|
||||
return principal.id;
|
||||
}
|
||||
|
||||
if (principal.type === 'user' && principal.source === 'entra') {
|
||||
if (!principal.email || !principal.idOnTheSource) {
|
||||
throw new Error('Entra ID user principals must have email and idOnTheSource');
|
||||
}
|
||||
|
||||
let existingUser = await findUser({ idOnTheSource: principal.idOnTheSource });
|
||||
|
||||
if (!existingUser) {
|
||||
existingUser = await findUser({ email: principal.email.toLowerCase() });
|
||||
}
|
||||
|
||||
if (existingUser) {
|
||||
if (!existingUser.idOnTheSource && principal.idOnTheSource) {
|
||||
await updateUser(existingUser._id, {
|
||||
idOnTheSource: principal.idOnTheSource,
|
||||
provider: 'openid',
|
||||
});
|
||||
}
|
||||
return existingUser._id.toString();
|
||||
}
|
||||
|
||||
const userData = {
|
||||
name: principal.name,
|
||||
email: principal.email.toLowerCase(),
|
||||
emailVerified: false,
|
||||
provider: 'openid',
|
||||
idOnTheSource: principal.idOnTheSource,
|
||||
};
|
||||
|
||||
const userId = await createUser(userData, true, false);
|
||||
return userId.toString();
|
||||
}
|
||||
|
||||
if (principal.type === 'group') {
|
||||
throw new Error('Group principals should be handled by group-specific methods');
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported principal type: ${principal.type}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensures a group principal exists in the database based on TPrincipal data
|
||||
* Creates group if it doesn't exist locally (for Entra ID groups)
|
||||
* For Entra ID groups, always synchronizes member IDs when authentication context is provided
|
||||
* @param {Object} principal - TPrincipal object from frontend
|
||||
* @param {string} principal.type - Must be 'group'
|
||||
* @param {string} [principal.id] - Local database ID (null for Entra ID principals not yet synced)
|
||||
* @param {string} principal.name - Display name
|
||||
* @param {string} [principal.email] - Email address
|
||||
* @param {string} [principal.description] - Group description
|
||||
* @param {string} [principal.source] - 'local' or 'entra'
|
||||
* @param {string} [principal.idOnTheSource] - Entra ID object ID for external principals
|
||||
* @param {Object} [authContext] - Optional authentication context for fetching member data
|
||||
* @param {string} [authContext.accessToken] - Access token for Graph API calls
|
||||
* @param {string} [authContext.sub] - Subject identifier
|
||||
* @returns {Promise<string>} Returns the groupId for database operations
|
||||
*/
|
||||
const ensureGroupPrincipalExists = async function (principal, authContext = null) {
|
||||
if (principal.type !== 'group') {
|
||||
throw new Error(`Invalid principal type: ${principal.type}. Expected 'group'`);
|
||||
}
|
||||
|
||||
if (principal.source === 'entra') {
|
||||
if (!principal.name || !principal.idOnTheSource) {
|
||||
throw new Error('Entra ID group principals must have name and idOnTheSource');
|
||||
}
|
||||
|
||||
let memberIds = [];
|
||||
if (authContext && authContext.accessToken && authContext.sub) {
|
||||
try {
|
||||
memberIds = await getGroupMembers(
|
||||
authContext.accessToken,
|
||||
authContext.sub,
|
||||
principal.idOnTheSource,
|
||||
);
|
||||
|
||||
// Include group owners as members if feature is enabled
|
||||
if (isEnabled(process.env.ENTRA_ID_INCLUDE_OWNERS_AS_MEMBERS)) {
|
||||
const ownerIds = await getGroupOwners(
|
||||
authContext.accessToken,
|
||||
authContext.sub,
|
||||
principal.idOnTheSource,
|
||||
);
|
||||
if (ownerIds && ownerIds.length > 0) {
|
||||
memberIds.push(...ownerIds);
|
||||
// Remove duplicates
|
||||
memberIds = [...new Set(memberIds)];
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch group members from Graph API:', error);
|
||||
}
|
||||
}
|
||||
|
||||
let existingGroup = await findGroupByExternalId(principal.idOnTheSource, 'entra');
|
||||
|
||||
if (!existingGroup && principal.email) {
|
||||
existingGroup = await Group.findOne({ email: principal.email.toLowerCase() }).lean();
|
||||
}
|
||||
|
||||
if (existingGroup) {
|
||||
const updateData = {};
|
||||
let needsUpdate = false;
|
||||
|
||||
if (!existingGroup.idOnTheSource && principal.idOnTheSource) {
|
||||
updateData.idOnTheSource = principal.idOnTheSource;
|
||||
updateData.source = 'entra';
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (principal.description && existingGroup.description !== principal.description) {
|
||||
updateData.description = principal.description;
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (principal.email && existingGroup.email !== principal.email.toLowerCase()) {
|
||||
updateData.email = principal.email.toLowerCase();
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (authContext && authContext.accessToken && authContext.sub) {
|
||||
updateData.memberIds = memberIds;
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
await Group.findByIdAndUpdate(existingGroup._id, { $set: updateData }, { new: true });
|
||||
}
|
||||
|
||||
return existingGroup._id.toString();
|
||||
}
|
||||
|
||||
const groupData = {
|
||||
name: principal.name,
|
||||
source: 'entra',
|
||||
idOnTheSource: principal.idOnTheSource,
|
||||
memberIds: memberIds, // Store idOnTheSource values of group members (empty if no auth context)
|
||||
};
|
||||
|
||||
if (principal.email) {
|
||||
groupData.email = principal.email.toLowerCase();
|
||||
}
|
||||
|
||||
if (principal.description) {
|
||||
groupData.description = principal.description;
|
||||
}
|
||||
|
||||
const newGroup = await createGroup(groupData);
|
||||
return newGroup._id.toString();
|
||||
}
|
||||
if (principal.id && authContext == null) {
|
||||
return principal.id;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported group principal source: ${principal.source}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Synchronize user's Entra ID group memberships on sign-in
|
||||
* Gets user's group IDs from GraphAPI and updates memberships only for existing groups in database
|
||||
* Optionally includes groups the user owns if ENTRA_ID_INCLUDE_OWNERS_AS_MEMBERS is enabled
|
||||
* @param {Object} user - User object with authentication context
|
||||
* @param {string} user.openidId - User's OpenID subject identifier
|
||||
* @param {string} user.idOnTheSource - User's Entra ID (oid from token claims)
|
||||
* @param {string} user.provider - Authentication provider ('openid')
|
||||
* @param {string} accessToken - Access token for Graph API calls
|
||||
* @param {mongoose.ClientSession} [session] - Optional MongoDB session for transactions
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const syncUserEntraGroupMemberships = async (user, accessToken, session = null) => {
|
||||
try {
|
||||
if (!entraIdPrincipalFeatureEnabled(user) || !accessToken || !user.idOnTheSource) {
|
||||
return;
|
||||
}
|
||||
|
||||
const memberGroupIds = await getUserEntraGroups(accessToken, user.openidId);
|
||||
let allGroupIds = [...(memberGroupIds || [])];
|
||||
|
||||
// Include owned groups if feature is enabled
|
||||
if (isEnabled(process.env.ENTRA_ID_INCLUDE_OWNERS_AS_MEMBERS)) {
|
||||
const ownedGroupIds = await getUserOwnedEntraGroups(accessToken, user.openidId);
|
||||
if (ownedGroupIds && ownedGroupIds.length > 0) {
|
||||
allGroupIds.push(...ownedGroupIds);
|
||||
// Remove duplicates
|
||||
allGroupIds = [...new Set(allGroupIds)];
|
||||
}
|
||||
}
|
||||
|
||||
if (!allGroupIds || allGroupIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionOptions = session ? { session } : {};
|
||||
|
||||
await Group.updateMany(
|
||||
{
|
||||
idOnTheSource: { $in: allGroupIds },
|
||||
source: 'entra',
|
||||
memberIds: { $ne: user.idOnTheSource },
|
||||
},
|
||||
{ $addToSet: { memberIds: user.idOnTheSource } },
|
||||
sessionOptions,
|
||||
);
|
||||
|
||||
await Group.updateMany(
|
||||
{
|
||||
source: 'entra',
|
||||
memberIds: user.idOnTheSource,
|
||||
idOnTheSource: { $nin: allGroupIds },
|
||||
},
|
||||
{ $pull: { memberIds: user.idOnTheSource } },
|
||||
sessionOptions,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`[PermissionService.syncUserEntraGroupMemberships] Error syncing groups:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if public has a specific permission on a resource
|
||||
* @param {Object} params - Parameters for checking public permission
|
||||
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
|
||||
* @param {string|mongoose.Types.ObjectId} params.resourceId - The ID of the resource
|
||||
* @param {number} params.requiredPermissions - The permission bits required (e.g., 1 for VIEW, 3 for VIEW+EDIT)
|
||||
* @returns {Promise<boolean>} Whether public has the required permission bits
|
||||
*/
|
||||
const hasPublicPermission = async ({ resourceType, resourceId, requiredPermissions }) => {
|
||||
try {
|
||||
if (typeof requiredPermissions !== 'number' || requiredPermissions < 1) {
|
||||
throw new Error('requiredPermissions must be a positive number');
|
||||
}
|
||||
|
||||
// Use public principal to check permissions
|
||||
const publicPrincipal = [{ principalType: 'public' }];
|
||||
|
||||
const entries = await findEntriesByPrincipalsAndResource(
|
||||
publicPrincipal,
|
||||
resourceType,
|
||||
resourceId,
|
||||
);
|
||||
|
||||
// Check if any entry has the required permission bits
|
||||
return entries.some((entry) => (entry.permBits & requiredPermissions) === requiredPermissions);
|
||||
} catch (error) {
|
||||
logger.error(`[PermissionService.hasPublicPermission] Error: ${error.message}`);
|
||||
// Re-throw validation errors
|
||||
if (error.message.includes('requiredPermissions must be')) {
|
||||
throw error;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Bulk update permissions for a resource (grant, update, revoke)
|
||||
* Efficiently handles multiple permission changes in a single transaction
|
||||
*
|
||||
* @param {Object} params - Parameters for bulk permission update
|
||||
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
|
||||
* @param {string|mongoose.Types.ObjectId} params.resourceId - The ID of the resource
|
||||
* @param {Array<TPrincipal>} params.updatedPrincipals - Array of principals to grant/update permissions for
|
||||
* @param {Array<TPrincipal>} params.revokedPrincipals - Array of principals to revoke permissions from
|
||||
* @param {string|mongoose.Types.ObjectId} params.grantedBy - User ID making the changes
|
||||
* @param {mongoose.ClientSession} [params.session] - Optional MongoDB session for transactions
|
||||
* @returns {Promise<Object>} Results object with granted, updated, revoked arrays and error details
|
||||
*/
|
||||
const bulkUpdateResourcePermissions = async ({
|
||||
resourceType,
|
||||
resourceId,
|
||||
updatedPrincipals = [],
|
||||
revokedPrincipals = [],
|
||||
grantedBy,
|
||||
session,
|
||||
}) => {
|
||||
const supportsTransactions = await getTransactionSupport(mongoose, transactionSupportCache);
|
||||
transactionSupportCache = supportsTransactions;
|
||||
let localSession = session;
|
||||
let shouldEndSession = false;
|
||||
|
||||
try {
|
||||
if (!Array.isArray(updatedPrincipals)) {
|
||||
throw new Error('updatedPrincipals must be an array');
|
||||
}
|
||||
|
||||
if (!Array.isArray(revokedPrincipals)) {
|
||||
throw new Error('revokedPrincipals must be an array');
|
||||
}
|
||||
|
||||
if (!resourceId || !mongoose.Types.ObjectId.isValid(resourceId)) {
|
||||
throw new Error(`Invalid resource ID: ${resourceId}`);
|
||||
}
|
||||
|
||||
if (!localSession && supportsTransactions) {
|
||||
localSession = await mongoose.startSession();
|
||||
localSession.startTransaction();
|
||||
shouldEndSession = true;
|
||||
}
|
||||
|
||||
const sessionOptions = localSession ? { session: localSession } : {};
|
||||
|
||||
const roles = await AccessRole.find({ resourceType }).lean();
|
||||
const rolesMap = new Map();
|
||||
roles.forEach((role) => {
|
||||
rolesMap.set(role.accessRoleId, role);
|
||||
});
|
||||
|
||||
const results = {
|
||||
granted: [],
|
||||
updated: [],
|
||||
revoked: [],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
const bulkWrites = [];
|
||||
|
||||
for (const principal of updatedPrincipals) {
|
||||
try {
|
||||
if (!principal.accessRoleId) {
|
||||
results.errors.push({
|
||||
principal,
|
||||
error: 'accessRoleId is required for updated principals',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const role = rolesMap.get(principal.accessRoleId);
|
||||
if (!role) {
|
||||
results.errors.push({
|
||||
principal,
|
||||
error: `Role ${principal.accessRoleId} not found`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const query = {
|
||||
principalType: principal.type,
|
||||
resourceType,
|
||||
resourceId,
|
||||
};
|
||||
|
||||
if (principal.type !== 'public') {
|
||||
query.principalId = principal.id;
|
||||
}
|
||||
|
||||
const update = {
|
||||
$set: {
|
||||
permBits: role.permBits,
|
||||
roleId: role._id,
|
||||
grantedBy,
|
||||
grantedAt: new Date(),
|
||||
},
|
||||
$setOnInsert: {
|
||||
principalType: principal.type,
|
||||
resourceType,
|
||||
resourceId,
|
||||
...(principal.type !== 'public' && {
|
||||
principalId: principal.id,
|
||||
principalModel: principal.type === 'user' ? 'User' : 'Group',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
bulkWrites.push({
|
||||
updateOne: {
|
||||
filter: query,
|
||||
update: update,
|
||||
upsert: true,
|
||||
},
|
||||
});
|
||||
|
||||
results.granted.push({
|
||||
type: principal.type,
|
||||
id: principal.id,
|
||||
name: principal.name,
|
||||
email: principal.email,
|
||||
source: principal.source,
|
||||
avatar: principal.avatar,
|
||||
description: principal.description,
|
||||
idOnTheSource: principal.idOnTheSource,
|
||||
accessRoleId: principal.accessRoleId,
|
||||
memberCount: principal.memberCount,
|
||||
memberIds: principal.memberIds,
|
||||
});
|
||||
} catch (error) {
|
||||
results.errors.push({
|
||||
principal,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (bulkWrites.length > 0) {
|
||||
await AclEntry.bulkWrite(bulkWrites, sessionOptions);
|
||||
}
|
||||
|
||||
const deleteQueries = [];
|
||||
for (const principal of revokedPrincipals) {
|
||||
try {
|
||||
const query = {
|
||||
principalType: principal.type,
|
||||
resourceType,
|
||||
resourceId,
|
||||
};
|
||||
|
||||
if (principal.type !== 'public') {
|
||||
query.principalId = principal.id;
|
||||
}
|
||||
|
||||
deleteQueries.push(query);
|
||||
|
||||
results.revoked.push({
|
||||
type: principal.type,
|
||||
id: principal.id,
|
||||
name: principal.name,
|
||||
email: principal.email,
|
||||
source: principal.source,
|
||||
avatar: principal.avatar,
|
||||
description: principal.description,
|
||||
idOnTheSource: principal.idOnTheSource,
|
||||
memberCount: principal.memberCount,
|
||||
});
|
||||
} catch (error) {
|
||||
results.errors.push({
|
||||
principal,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (deleteQueries.length > 0) {
|
||||
await AclEntry.deleteMany(
|
||||
{
|
||||
$or: deleteQueries,
|
||||
},
|
||||
sessionOptions,
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldEndSession && supportsTransactions) {
|
||||
await localSession.commitTransaction();
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
if (shouldEndSession && supportsTransactions) {
|
||||
await localSession.abortTransaction();
|
||||
}
|
||||
logger.error(`[PermissionService.bulkUpdateResourcePermissions] Error: ${error.message}`);
|
||||
throw error;
|
||||
} finally {
|
||||
if (shouldEndSession && localSession) {
|
||||
localSession.endSession();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
grantPermission,
|
||||
checkPermission,
|
||||
getEffectivePermissions,
|
||||
findAccessibleResources,
|
||||
findPubliclyAccessibleResources,
|
||||
hasPublicPermission,
|
||||
getAvailableRoles,
|
||||
bulkUpdateResourcePermissions,
|
||||
ensurePrincipalExists,
|
||||
ensureGroupPrincipalExists,
|
||||
syncUserEntraGroupMemberships,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ const { findOnePluginAuth, updatePluginAuth, deletePluginAuth } = require('~/mod
|
||||
* @param {string} userId - The unique identifier of the user for whom the plugin authentication value is to be retrieved.
|
||||
* @param {string} authField - The specific authentication field (e.g., 'API_KEY', 'URL') whose value is to be retrieved and decrypted.
|
||||
* @param {boolean} throwError - Whether to throw an error if the authentication value does not exist. Defaults to `true`.
|
||||
* @param {string} [pluginKey] - Optional plugin key to make the lookup more specific to a particular plugin.
|
||||
* @returns {Promise<string|null>} A promise that resolves to the decrypted authentication value if found, or `null` if no such authentication value exists for the given user and field.
|
||||
*
|
||||
* The function throws an error if it encounters any issue during the retrieval or decryption process, or if the authentication value does not exist.
|
||||
@@ -20,14 +21,28 @@ const { findOnePluginAuth, updatePluginAuth, deletePluginAuth } = require('~/mod
|
||||
* console.error(err);
|
||||
* });
|
||||
*
|
||||
* @example
|
||||
* // To get the decrypted value of the 'API_KEY' field for a specific plugin:
|
||||
* getUserPluginAuthValue('12345', 'API_KEY', true, 'mcp-server-name').then(value => {
|
||||
* console.log(value);
|
||||
* }).catch(err => {
|
||||
* console.error(err);
|
||||
* });
|
||||
*
|
||||
* @throws {Error} Throws an error if there's an issue during the retrieval or decryption process, or if the authentication value does not exist.
|
||||
* @async
|
||||
*/
|
||||
const getUserPluginAuthValue = async (userId, authField, throwError = true) => {
|
||||
const getUserPluginAuthValue = async (userId, authField, throwError = true, pluginKey) => {
|
||||
try {
|
||||
const pluginAuth = await findOnePluginAuth({ userId, authField });
|
||||
const searchParams = { userId, authField };
|
||||
if (pluginKey) {
|
||||
searchParams.pluginKey = pluginKey;
|
||||
}
|
||||
|
||||
const pluginAuth = await findOnePluginAuth(searchParams);
|
||||
if (!pluginAuth) {
|
||||
throw new Error(`No plugin auth ${authField} found for user ${userId}`);
|
||||
const pluginInfo = pluginKey ? ` for plugin ${pluginKey}` : '';
|
||||
throw new Error(`No plugin auth ${authField} found for user ${userId}${pluginInfo}`);
|
||||
}
|
||||
|
||||
const decryptedValue = await decrypt(pluginAuth.value);
|
||||
|
||||
@@ -226,7 +226,7 @@ async function processRequiredActions(client, requiredActions) {
|
||||
`[required actions] user: ${client.req.user.id} | thread_id: ${requiredActions[0].thread_id} | run_id: ${requiredActions[0].run_id}`,
|
||||
requiredActions,
|
||||
);
|
||||
const toolDefinitions = await getCachedTools({ includeGlobal: true });
|
||||
const toolDefinitions = await getCachedTools({ userId: client.req.user.id, includeGlobal: true });
|
||||
const seenToolkits = new Set();
|
||||
const tools = requiredActions
|
||||
.map((action) => {
|
||||
|
||||
@@ -9,20 +9,35 @@ const { getLogStores } = require('~/cache');
|
||||
* Initialize MCP servers
|
||||
* @param {import('express').Application} app - Express app instance
|
||||
*/
|
||||
async function initializeMCP(app) {
|
||||
async function initializeMCPs(app) {
|
||||
const mcpServers = app.locals.mcpConfig;
|
||||
if (!mcpServers) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter out servers with startup: false
|
||||
const filteredServers = {};
|
||||
for (const [name, config] of Object.entries(mcpServers)) {
|
||||
if (config.startup === false) {
|
||||
logger.info(`Skipping MCP server '${name}' due to startup: false`);
|
||||
continue;
|
||||
}
|
||||
filteredServers[name] = config;
|
||||
}
|
||||
|
||||
if (Object.keys(filteredServers).length === 0) {
|
||||
logger.info('[MCP] No MCP servers to initialize (all skipped or none configured)');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('Initializing MCP servers...');
|
||||
const mcpManager = getMCPManager();
|
||||
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
||||
const flowManager = flowsCache ? getFlowStateManager(flowsCache) : null;
|
||||
|
||||
try {
|
||||
await mcpManager.initializeMCP({
|
||||
mcpServers,
|
||||
await mcpManager.initializeMCPs({
|
||||
mcpServers: filteredServers,
|
||||
flowManager,
|
||||
tokenMethods: {
|
||||
findToken,
|
||||
@@ -47,10 +62,11 @@ async function initializeMCP(app) {
|
||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||
await cache.delete(CacheKeys.TOOLS);
|
||||
logger.debug('Cleared tools array cache after MCP initialization');
|
||||
|
||||
logger.info('MCP servers initialized successfully');
|
||||
} catch (error) {
|
||||
logger.error('Failed to initialize MCP servers:', error);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = initializeMCP;
|
||||
module.exports = initializeMCPs;
|
||||
@@ -52,6 +52,11 @@ function assistantsConfigSetup(config, assistantsEndpoint, prevConfig = {}) {
|
||||
privateAssistants: parsedConfig.privateAssistants,
|
||||
timeoutMs: parsedConfig.timeoutMs,
|
||||
streamRate: parsedConfig.streamRate,
|
||||
titlePrompt: parsedConfig.titlePrompt,
|
||||
titleMethod: parsedConfig.titleMethod,
|
||||
titleModel: parsedConfig.titleModel,
|
||||
titleEndpoint: parsedConfig.titleEndpoint,
|
||||
titlePromptTemplate: parsedConfig.titlePromptTemplate,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -50,25 +50,8 @@ async function loadDefaultInterface(config, configDefaults, roleName = SystemRol
|
||||
temporaryChat: interfaceConfig?.temporaryChat ?? defaults.temporaryChat,
|
||||
runCode: interfaceConfig?.runCode ?? defaults.runCode,
|
||||
webSearch: interfaceConfig?.webSearch ?? defaults.webSearch,
|
||||
fileSearch: interfaceConfig?.fileSearch ?? defaults.fileSearch,
|
||||
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, {
|
||||
@@ -83,13 +66,7 @@ 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 },
|
||||
});
|
||||
await updateAccessPermissions(SystemRoles.ADMIN, {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: loadedInterface.prompts },
|
||||
@@ -103,13 +80,7 @@ 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 },
|
||||
});
|
||||
|
||||
let i = 0;
|
||||
|
||||
@@ -18,6 +18,7 @@ describe('loadDefaultInterface', () => {
|
||||
temporaryChat: true,
|
||||
runCode: true,
|
||||
webSearch: true,
|
||||
fileSearch: true,
|
||||
},
|
||||
};
|
||||
const configDefaults = { interface: {} };
|
||||
@@ -33,11 +34,7 @@ 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 },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -52,6 +49,7 @@ describe('loadDefaultInterface', () => {
|
||||
temporaryChat: false,
|
||||
runCode: false,
|
||||
webSearch: false,
|
||||
fileSearch: false,
|
||||
},
|
||||
};
|
||||
const configDefaults = { interface: {} };
|
||||
@@ -67,11 +65,7 @@ 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 },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -93,11 +87,7 @@ 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 },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,6 +102,7 @@ describe('loadDefaultInterface', () => {
|
||||
temporaryChat: undefined,
|
||||
runCode: undefined,
|
||||
webSearch: undefined,
|
||||
fileSearch: undefined,
|
||||
},
|
||||
};
|
||||
const configDefaults = { interface: {} };
|
||||
@@ -130,11 +121,7 @@ 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 },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -149,6 +136,7 @@ describe('loadDefaultInterface', () => {
|
||||
temporaryChat: undefined,
|
||||
runCode: false,
|
||||
webSearch: true,
|
||||
fileSearch: false,
|
||||
},
|
||||
};
|
||||
const configDefaults = { interface: {} };
|
||||
@@ -164,11 +152,7 @@ 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 },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -184,6 +168,7 @@ describe('loadDefaultInterface', () => {
|
||||
temporaryChat: true,
|
||||
runCode: true,
|
||||
webSearch: true,
|
||||
fileSearch: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -198,11 +183,7 @@ 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 },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -224,11 +205,7 @@ 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 },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -250,11 +227,7 @@ 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 },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -276,11 +249,7 @@ 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 },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -294,6 +263,7 @@ describe('loadDefaultInterface', () => {
|
||||
agents: false,
|
||||
temporaryChat: true,
|
||||
runCode: false,
|
||||
fileSearch: true,
|
||||
},
|
||||
};
|
||||
const configDefaults = { interface: {} };
|
||||
@@ -309,11 +279,7 @@ 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 },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -329,6 +295,7 @@ describe('loadDefaultInterface', () => {
|
||||
temporaryChat: undefined,
|
||||
runCode: undefined,
|
||||
webSearch: undefined,
|
||||
fileSearch: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -343,11 +310,7 @@ 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 },
|
||||
});
|
||||
});
|
||||
|
||||
@@ -376,11 +339,84 @@ 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 },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call updateAccessPermissions with the correct parameters when FILE_SEARCH is true', async () => {
|
||||
const config = {
|
||||
interface: {
|
||||
fileSearch: true,
|
||||
},
|
||||
};
|
||||
const configDefaults = { interface: {} };
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.MEMORIES]: { [Permissions.USE]: 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.FILE_SEARCH]: { [Permissions.USE]: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call updateAccessPermissions with false when FILE_SEARCH is false', async () => {
|
||||
const config = {
|
||||
interface: {
|
||||
fileSearch: false,
|
||||
},
|
||||
};
|
||||
const configDefaults = { interface: {} };
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: undefined },
|
||||
[PermissionTypes.MEMORIES]: { [Permissions.USE]: 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.FILE_SEARCH]: { [Permissions.USE]: false },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call updateAccessPermissions with all interface options including fileSearch', async () => {
|
||||
const config = {
|
||||
interface: {
|
||||
prompts: true,
|
||||
bookmarks: false,
|
||||
memories: true,
|
||||
multiConvo: true,
|
||||
agents: false,
|
||||
temporaryChat: true,
|
||||
runCode: false,
|
||||
webSearch: true,
|
||||
fileSearch: true,
|
||||
},
|
||||
};
|
||||
const configDefaults = { interface: {} };
|
||||
|
||||
await loadDefaultInterface(config, configDefaults);
|
||||
|
||||
expect(updateAccessPermissions).toHaveBeenCalledWith(SystemRoles.USER, {
|
||||
[PermissionTypes.PROMPTS]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.BOOKMARKS]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.MEMORIES]: { [Permissions.USE]: true, [Permissions.OPT_OUT]: undefined },
|
||||
[PermissionTypes.MULTI_CONVO]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.AGENTS]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.RUN_CODE]: { [Permissions.USE]: false },
|
||||
[PermissionTypes.WEB_SEARCH]: { [Permissions.USE]: true },
|
||||
[PermissionTypes.FILE_SEARCH]: { [Permissions.USE]: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -365,7 +365,6 @@ async function setupOpenId() {
|
||||
email: userinfo.email || '',
|
||||
emailVerified: userinfo.email_verified || false,
|
||||
name: fullName,
|
||||
idOnTheSource: userinfo.oid,
|
||||
};
|
||||
|
||||
const balanceConfig = await getBalanceConfig();
|
||||
@@ -376,7 +375,6 @@ async function setupOpenId() {
|
||||
user.openidId = userinfo.sub;
|
||||
user.username = username;
|
||||
user.name = fullName;
|
||||
user.idOnTheSource = userinfo.oid;
|
||||
}
|
||||
|
||||
if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) {
|
||||
|
||||
@@ -10,8 +10,4 @@ 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';
|
||||
|
||||
@@ -226,7 +226,14 @@ const xAIModels = {
|
||||
'grok-4': 256000, // 256K context
|
||||
};
|
||||
|
||||
const aggregateModels = { ...openAIModels, ...googleModels, ...bedrockModels, ...xAIModels };
|
||||
const aggregateModels = {
|
||||
...openAIModels,
|
||||
...googleModels,
|
||||
...bedrockModels,
|
||||
...xAIModels,
|
||||
// misc.
|
||||
kimi: 131000,
|
||||
};
|
||||
|
||||
const maxTokensMap = {
|
||||
[EModelEndpoint.azureOpenAI]: openAIModels,
|
||||
|
||||
@@ -714,3 +714,45 @@ describe('Claude Model Tests', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Kimi Model Tests', () => {
|
||||
describe('getModelMaxTokens', () => {
|
||||
test('should return correct tokens for Kimi models', () => {
|
||||
expect(getModelMaxTokens('kimi')).toBe(131000);
|
||||
expect(getModelMaxTokens('kimi-k2')).toBe(131000);
|
||||
expect(getModelMaxTokens('kimi-vl')).toBe(131000);
|
||||
});
|
||||
|
||||
test('should return correct tokens for Kimi models with provider prefix', () => {
|
||||
expect(getModelMaxTokens('moonshotai/kimi-k2')).toBe(131000);
|
||||
expect(getModelMaxTokens('moonshotai/kimi')).toBe(131000);
|
||||
expect(getModelMaxTokens('moonshotai/kimi-vl')).toBe(131000);
|
||||
});
|
||||
|
||||
test('should handle partial matches for Kimi models', () => {
|
||||
expect(getModelMaxTokens('kimi-k2-latest')).toBe(131000);
|
||||
expect(getModelMaxTokens('kimi-vl-preview')).toBe(131000);
|
||||
expect(getModelMaxTokens('kimi-2024')).toBe(131000);
|
||||
});
|
||||
});
|
||||
|
||||
describe('matchModelName', () => {
|
||||
test('should match exact Kimi model names', () => {
|
||||
expect(matchModelName('kimi')).toBe('kimi');
|
||||
expect(matchModelName('kimi-k2')).toBe('kimi');
|
||||
expect(matchModelName('kimi-vl')).toBe('kimi');
|
||||
});
|
||||
|
||||
test('should match Kimi model variations with provider prefix', () => {
|
||||
expect(matchModelName('moonshotai/kimi')).toBe('kimi');
|
||||
expect(matchModelName('moonshotai/kimi-k2')).toBe('kimi');
|
||||
expect(matchModelName('moonshotai/kimi-vl')).toBe('kimi');
|
||||
});
|
||||
|
||||
test('should match Kimi model variations with suffixes', () => {
|
||||
expect(matchModelName('kimi-k2-latest')).toBe('kimi');
|
||||
expect(matchModelName('kimi-vl-preview')).toBe('kimi');
|
||||
expect(matchModelName('kimi-2024')).toBe('kimi');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
{
|
||||
"name": "@librechat/frontend",
|
||||
"version": "v0.7.9-rc1",
|
||||
"version": "v0.7.9",
|
||||
"description": "",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit",
|
||||
"data-provider": "cd .. && npm run build:data-provider",
|
||||
"build:file": "cross-env NODE_ENV=production vite build --debug > vite-output.log 2>&1",
|
||||
"build": "cross-env NODE_ENV=production vite build && node ./scripts/post-build.cjs",
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { AgentCapabilities, ArtifactModes } from 'librechat-data-provider';
|
||||
import type {
|
||||
Agent,
|
||||
AgentProvider,
|
||||
AgentModelParameters,
|
||||
SupportContact,
|
||||
} from 'librechat-data-provider';
|
||||
import type { Agent, AgentProvider, AgentModelParameters } from 'librechat-data-provider';
|
||||
import type { OptionWithIcon, ExtendedFile } from './types';
|
||||
|
||||
export type TAgentOption = OptionWithIcon &
|
||||
@@ -12,7 +7,6 @@ export type TAgentOption = OptionWithIcon &
|
||||
knowledge_files?: Array<[string, ExtendedFile]>;
|
||||
context_files?: Array<[string, ExtendedFile]>;
|
||||
code_files?: Array<[string, ExtendedFile]>;
|
||||
_id?: string;
|
||||
};
|
||||
|
||||
export type TAgentCapabilities = {
|
||||
@@ -36,6 +30,4 @@ export type AgentForm = {
|
||||
agent_ids?: string[];
|
||||
[AgentCapabilities.artifacts]?: ArtifactModes | string;
|
||||
recursion_limit?: number;
|
||||
support_contact?: SupportContact;
|
||||
category: string;
|
||||
} & TAgentCapabilities;
|
||||
|
||||
@@ -16,7 +16,6 @@ const defaultInterface = getConfigDefaults().interface;
|
||||
export default function Header() {
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const { navVisible, setNavVisible } = useOutletContext<ContextType>();
|
||||
|
||||
const interfaceConfig = useMemo(
|
||||
() => startupConfig?.interface ?? defaultInterface,
|
||||
[startupConfig],
|
||||
|
||||
@@ -1,14 +1,24 @@
|
||||
import React, { memo } from 'react';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import CheckboxButton from '~/components/ui/CheckboxButton';
|
||||
import { useLocalize, useHasAccess } from '~/hooks';
|
||||
import { useBadgeRowContext } from '~/Providers';
|
||||
import { VectorIcon } from '~/components/svg';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
function FileSearch() {
|
||||
const localize = useLocalize();
|
||||
const { fileSearch } = useBadgeRowContext();
|
||||
const { toggleState: fileSearchEnabled, debouncedChange, isPinned } = fileSearch;
|
||||
|
||||
const canUseFileSearch = useHasAccess({
|
||||
permissionType: PermissionTypes.FILE_SEARCH,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
|
||||
if (!canUseFileSearch) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{(fileSearchEnabled || isPinned) && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { ImageUpIcon, FileSearch, TerminalSquareIcon, FileType2Icon } from 'lucide-react';
|
||||
import { EToolResources, defaultAgentCapabilities } from 'librechat-data-provider';
|
||||
import { FileSearch, ImageUpIcon, FileType2Icon, TerminalSquareIcon } from 'lucide-react';
|
||||
import { useLocalize, useGetAgentsConfig, useAgentCapabilities } from '~/hooks';
|
||||
import { OGDialog, OGDialogTemplate } from '~/components/ui';
|
||||
|
||||
|
||||
@@ -1,32 +1,45 @@
|
||||
import React, { memo, useCallback, useState } from 'react';
|
||||
import { SettingsIcon } from 'lucide-react';
|
||||
import { Constants } from 'librechat-data-provider';
|
||||
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Constants, QueryKeys } from 'librechat-data-provider';
|
||||
import type { TUpdateUserPlugins, TPlugin } from 'librechat-data-provider';
|
||||
import MCPConfigDialog, { type ConfigFieldDetail } from '~/components/ui/MCPConfigDialog';
|
||||
import React, { memo, useCallback, useState, useMemo, useRef } from 'react';
|
||||
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
|
||||
import MCPConfigDialog, { ConfigFieldDetail } from '~/components/ui/MCP/MCPConfigDialog';
|
||||
import { useMCPServerInitialization } from '~/hooks/MCP/useMCPServerInitialization';
|
||||
import MCPServerStatusIcon from '~/components/ui/MCP/MCPServerStatusIcon';
|
||||
import { useToastContext, useBadgeRowContext } from '~/Providers';
|
||||
import MultiSelect from '~/components/ui/MultiSelect';
|
||||
import { MCPIcon } from '~/components/svg';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
const getBaseMCPPluginKey = (fullPluginKey: string): string => {
|
||||
const parts = fullPluginKey.split(Constants.mcp_delimiter);
|
||||
return Constants.mcp_prefix + parts[parts.length - 1];
|
||||
};
|
||||
|
||||
function MCPSelect() {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const { mcpSelect, startupConfig } = useBadgeRowContext();
|
||||
const { mcpValues, setMCPValues, mcpServerNames, mcpToolDetails, isPinned } = mcpSelect;
|
||||
const { mcpValues, setMCPValues, mcpToolDetails, isPinned } = mcpSelect;
|
||||
|
||||
// Get all configured MCP servers from config
|
||||
const configuredServers = useMemo(() => {
|
||||
return Object.keys(startupConfig?.mcpServers || {});
|
||||
}, [startupConfig?.mcpServers]);
|
||||
|
||||
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
|
||||
const [selectedToolForConfig, setSelectedToolForConfig] = useState<TPlugin | null>(null);
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const updateUserPluginsMutation = useUpdateUserPluginsMutation({
|
||||
onSuccess: () => {
|
||||
setIsConfigModalOpen(false);
|
||||
onSuccess: async () => {
|
||||
showToast({ message: localize('com_nav_mcp_vars_updated'), status: 'success' });
|
||||
|
||||
// tools so we dont leave tools available for use in chat if we revoke and thus kill mcp server
|
||||
// auth values so customUserVars flags are updated in customUserVarsSection
|
||||
// connection status so connection indicators are updated in the dropdown
|
||||
await Promise.all([
|
||||
queryClient.refetchQueries([QueryKeys.tools]),
|
||||
queryClient.refetchQueries([QueryKeys.mcpAuthValues]),
|
||||
queryClient.refetchQueries([QueryKeys.mcpConnectionStatus]),
|
||||
]);
|
||||
},
|
||||
onError: (error: unknown) => {
|
||||
console.error('Error updating MCP auth:', error);
|
||||
@@ -37,6 +50,61 @@ function MCPSelect() {
|
||||
},
|
||||
});
|
||||
|
||||
// Use the shared initialization hook
|
||||
const { initializeServer, isInitializing, connectionStatus, cancelOAuthFlow, isCancellable } =
|
||||
useMCPServerInitialization({
|
||||
onSuccess: (serverName) => {
|
||||
// Add to selected values after successful initialization
|
||||
const currentValues = mcpValues ?? [];
|
||||
if (!currentValues.includes(serverName)) {
|
||||
setMCPValues([...currentValues, serverName]);
|
||||
}
|
||||
},
|
||||
onError: (serverName) => {
|
||||
// Find the tool/server configuration
|
||||
const tool = mcpToolDetails?.find((t) => t.name === serverName);
|
||||
const serverConfig = startupConfig?.mcpServers?.[serverName];
|
||||
const serverStatus = connectionStatus[serverName];
|
||||
|
||||
// Check if this server would show a config button
|
||||
const hasAuthConfig =
|
||||
(tool?.authConfig && tool.authConfig.length > 0) ||
|
||||
(serverConfig?.customUserVars && Object.keys(serverConfig.customUserVars).length > 0);
|
||||
|
||||
// Only open dialog if the server would have shown a config button
|
||||
// (disconnected/error states always show button, connected only shows if hasAuthConfig)
|
||||
const wouldShowButton =
|
||||
!serverStatus ||
|
||||
serverStatus.connectionState === 'disconnected' ||
|
||||
serverStatus.connectionState === 'error' ||
|
||||
(serverStatus.connectionState === 'connected' && hasAuthConfig);
|
||||
|
||||
if (!wouldShowButton) {
|
||||
return; // Don't open dialog if no button would be shown
|
||||
}
|
||||
|
||||
// Create tool object if it doesn't exist
|
||||
const configTool = tool || {
|
||||
name: serverName,
|
||||
pluginKey: `${Constants.mcp_prefix}${serverName}`,
|
||||
authConfig: serverConfig?.customUserVars
|
||||
? Object.entries(serverConfig.customUserVars).map(([key, config]) => ({
|
||||
authField: key,
|
||||
label: config.title,
|
||||
description: config.description,
|
||||
}))
|
||||
: [],
|
||||
authenticated: false,
|
||||
};
|
||||
|
||||
previousFocusRef.current = document.activeElement as HTMLElement;
|
||||
|
||||
// Open the config dialog on error
|
||||
setSelectedToolForConfig(configTool);
|
||||
setIsConfigModalOpen(true);
|
||||
},
|
||||
});
|
||||
|
||||
const renderSelectedValues = useCallback(
|
||||
(values: string[], placeholder?: string) => {
|
||||
if (values.length === 0) {
|
||||
@@ -53,10 +121,12 @@ function MCPSelect() {
|
||||
const handleConfigSave = useCallback(
|
||||
(targetName: string, authData: Record<string, string>) => {
|
||||
if (selectedToolForConfig && selectedToolForConfig.name === targetName) {
|
||||
const basePluginKey = getBaseMCPPluginKey(selectedToolForConfig.pluginKey);
|
||||
|
||||
// Use the pluginKey directly since it's already in the correct format
|
||||
console.log(
|
||||
`[MCP Select] Saving config for ${targetName}, pluginKey: ${`${Constants.mcp_prefix}${targetName}`}`,
|
||||
);
|
||||
const payload: TUpdateUserPlugins = {
|
||||
pluginKey: basePluginKey,
|
||||
pluginKey: `${Constants.mcp_prefix}${targetName}`,
|
||||
action: 'install',
|
||||
auth: authData,
|
||||
};
|
||||
@@ -69,54 +139,165 @@ function MCPSelect() {
|
||||
const handleConfigRevoke = useCallback(
|
||||
(targetName: string) => {
|
||||
if (selectedToolForConfig && selectedToolForConfig.name === targetName) {
|
||||
const basePluginKey = getBaseMCPPluginKey(selectedToolForConfig.pluginKey);
|
||||
|
||||
// Use the pluginKey directly since it's already in the correct format
|
||||
const payload: TUpdateUserPlugins = {
|
||||
pluginKey: basePluginKey,
|
||||
pluginKey: `${Constants.mcp_prefix}${targetName}`,
|
||||
action: 'uninstall',
|
||||
auth: {},
|
||||
};
|
||||
updateUserPluginsMutation.mutate(payload);
|
||||
|
||||
// Remove the server from selected values after revoke
|
||||
const currentValues = mcpValues ?? [];
|
||||
const filteredValues = currentValues.filter((name) => name !== targetName);
|
||||
setMCPValues(filteredValues);
|
||||
}
|
||||
},
|
||||
[selectedToolForConfig, updateUserPluginsMutation],
|
||||
[selectedToolForConfig, updateUserPluginsMutation, mcpValues, setMCPValues],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(
|
||||
(authData: Record<string, string>) => {
|
||||
if (selectedToolForConfig) {
|
||||
handleConfigSave(selectedToolForConfig.name, authData);
|
||||
}
|
||||
},
|
||||
[selectedToolForConfig, handleConfigSave],
|
||||
);
|
||||
|
||||
const handleRevoke = useCallback(() => {
|
||||
if (selectedToolForConfig) {
|
||||
handleConfigRevoke(selectedToolForConfig.name);
|
||||
}
|
||||
}, [selectedToolForConfig, handleConfigRevoke]);
|
||||
|
||||
const handleDialogOpenChange = useCallback((open: boolean) => {
|
||||
setIsConfigModalOpen(open);
|
||||
|
||||
// Restore focus when dialog closes
|
||||
if (!open && previousFocusRef.current) {
|
||||
// Use setTimeout to ensure the dialog has fully closed before restoring focus
|
||||
setTimeout(() => {
|
||||
if (previousFocusRef.current && typeof previousFocusRef.current.focus === 'function') {
|
||||
previousFocusRef.current.focus();
|
||||
}
|
||||
previousFocusRef.current = null;
|
||||
}, 0);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Get connection status for all MCP servers (now from hook)
|
||||
// Remove the duplicate useMCPConnectionStatusQuery since it's in the hook
|
||||
|
||||
// Modified setValue function that attempts to initialize disconnected servers
|
||||
const filteredSetMCPValues = useCallback(
|
||||
(values: string[]) => {
|
||||
// Separate connected and disconnected servers
|
||||
const connectedServers: string[] = [];
|
||||
const disconnectedServers: string[] = [];
|
||||
|
||||
values.forEach((serverName) => {
|
||||
const serverStatus = connectionStatus[serverName];
|
||||
if (serverStatus?.connectionState === 'connected') {
|
||||
connectedServers.push(serverName);
|
||||
} else {
|
||||
disconnectedServers.push(serverName);
|
||||
}
|
||||
});
|
||||
|
||||
// Only set connected servers as selected values
|
||||
setMCPValues(connectedServers);
|
||||
|
||||
// Attempt to initialize each disconnected server (once)
|
||||
disconnectedServers.forEach((serverName) => {
|
||||
initializeServer(serverName);
|
||||
});
|
||||
},
|
||||
[connectionStatus, setMCPValues, initializeServer],
|
||||
);
|
||||
|
||||
const renderItemContent = useCallback(
|
||||
(serverName: string, defaultContent: React.ReactNode) => {
|
||||
const tool = mcpToolDetails?.find((t) => t.name === serverName);
|
||||
const hasAuthConfig = tool?.authConfig && tool.authConfig.length > 0;
|
||||
const serverStatus = connectionStatus[serverName];
|
||||
const serverConfig = startupConfig?.mcpServers?.[serverName];
|
||||
|
||||
const handleConfigClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
|
||||
previousFocusRef.current = document.activeElement as HTMLElement;
|
||||
|
||||
const configTool = tool || {
|
||||
name: serverName,
|
||||
pluginKey: `${Constants.mcp_prefix}${serverName}`,
|
||||
authConfig: serverConfig?.customUserVars
|
||||
? Object.entries(serverConfig.customUserVars).map(([key, config]) => ({
|
||||
authField: key,
|
||||
label: config.title,
|
||||
description: config.description,
|
||||
}))
|
||||
: [],
|
||||
authenticated: false,
|
||||
};
|
||||
setSelectedToolForConfig(configTool);
|
||||
setIsConfigModalOpen(true);
|
||||
};
|
||||
|
||||
const handleCancelClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
cancelOAuthFlow(serverName);
|
||||
};
|
||||
|
||||
// Common wrapper for the main content (check mark + text)
|
||||
// Ensures Check & Text are adjacent and the group takes available space.
|
||||
const mainContentWrapper = (
|
||||
<div className="flex flex-grow items-center">{defaultContent}</div>
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-grow items-center rounded bg-transparent p-0 text-left transition-colors focus:outline-none"
|
||||
tabIndex={0}
|
||||
>
|
||||
{defaultContent}
|
||||
</button>
|
||||
);
|
||||
|
||||
if (tool && hasAuthConfig) {
|
||||
// Check if this server has customUserVars to configure
|
||||
const hasCustomUserVars =
|
||||
serverConfig?.customUserVars && Object.keys(serverConfig.customUserVars).length > 0;
|
||||
|
||||
const statusIcon = (
|
||||
<MCPServerStatusIcon
|
||||
serverName={serverName}
|
||||
serverStatus={serverStatus}
|
||||
tool={tool}
|
||||
onConfigClick={handleConfigClick}
|
||||
isInitializing={isInitializing(serverName)}
|
||||
canCancel={isCancellable(serverName)}
|
||||
onCancel={handleCancelClick}
|
||||
hasCustomUserVars={hasCustomUserVars}
|
||||
/>
|
||||
);
|
||||
|
||||
if (statusIcon) {
|
||||
return (
|
||||
<div className="flex w-full items-center justify-between">
|
||||
{mainContentWrapper}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setSelectedToolForConfig(tool);
|
||||
setIsConfigModalOpen(true);
|
||||
}}
|
||||
className="ml-2 flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
|
||||
aria-label={`Configure ${serverName}`}
|
||||
>
|
||||
<SettingsIcon className={`h-4 w-4 ${tool.authenticated ? 'text-green-500' : ''}`} />
|
||||
</button>
|
||||
<div className="ml-2 flex items-center">{statusIcon}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
// For items without a settings icon, return the consistently wrapped main content.
|
||||
|
||||
return mainContentWrapper;
|
||||
},
|
||||
[mcpToolDetails, setSelectedToolForConfig, setIsConfigModalOpen],
|
||||
[
|
||||
isInitializing,
|
||||
isCancellable,
|
||||
mcpToolDetails,
|
||||
cancelOAuthFlow,
|
||||
connectionStatus,
|
||||
startupConfig?.mcpServers,
|
||||
],
|
||||
);
|
||||
|
||||
// Don't render if no servers are selected and not pinned
|
||||
@@ -124,7 +305,8 @@ function MCPSelect() {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!mcpToolDetails || mcpToolDetails.length === 0) {
|
||||
// Don't render if no MCP servers are configured
|
||||
if (!configuredServers || configuredServers.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -133,9 +315,9 @@ function MCPSelect() {
|
||||
return (
|
||||
<>
|
||||
<MultiSelect
|
||||
items={mcpServerNames}
|
||||
items={configuredServers}
|
||||
selectedValues={mcpValues ?? []}
|
||||
setSelectedValues={setMCPValues}
|
||||
setSelectedValues={filteredSetMCPValues}
|
||||
defaultSelectedValues={mcpValues ?? []}
|
||||
renderSelectedValues={renderSelectedValues}
|
||||
renderItemContent={renderItemContent}
|
||||
@@ -148,9 +330,10 @@ function MCPSelect() {
|
||||
/>
|
||||
{selectedToolForConfig && (
|
||||
<MCPConfigDialog
|
||||
isOpen={isConfigModalOpen}
|
||||
onOpenChange={setIsConfigModalOpen}
|
||||
serverName={selectedToolForConfig.name}
|
||||
serverStatus={connectionStatus[selectedToolForConfig.name]}
|
||||
isOpen={isConfigModalOpen}
|
||||
onOpenChange={handleDialogOpenChange}
|
||||
fieldsSchema={(() => {
|
||||
const schema: Record<string, ConfigFieldDetail> = {};
|
||||
if (selectedToolForConfig?.authConfig) {
|
||||
@@ -173,16 +356,8 @@ function MCPSelect() {
|
||||
}
|
||||
return initial;
|
||||
})()}
|
||||
onSave={(authData) => {
|
||||
if (selectedToolForConfig) {
|
||||
handleConfigSave(selectedToolForConfig.name, authData);
|
||||
}
|
||||
}}
|
||||
onRevoke={() => {
|
||||
if (selectedToolForConfig) {
|
||||
handleConfigRevoke(selectedToolForConfig.name);
|
||||
}
|
||||
}}
|
||||
onSave={handleSave}
|
||||
onRevoke={handleRevoke}
|
||||
isSubmitting={updateUserPluginsMutation.isLoading}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -72,6 +72,11 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
|
||||
const canUseFileSearch = useHasAccess({
|
||||
permissionType: PermissionTypes.FILE_SEARCH,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
|
||||
const showWebSearchSettings = useMemo(() => {
|
||||
const authTypes = webSearchAuthData?.authTypes ?? [];
|
||||
if (authTypes.length === 0) return true;
|
||||
@@ -140,7 +145,7 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => {
|
||||
|
||||
const dropdownItems: MenuItemProps[] = [];
|
||||
|
||||
if (fileSearchEnabled) {
|
||||
if (fileSearchEnabled && canUseFileSearch) {
|
||||
dropdownItems.push({
|
||||
onClick: handleFileSearchToggle,
|
||||
hideOnClick: false,
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function OpenSidebar({
|
||||
variant="outline"
|
||||
data-testid="open-sidebar-button"
|
||||
aria-label={localize('com_nav_open_sidebar')}
|
||||
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover"
|
||||
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover max-md:hidden"
|
||||
onClick={() =>
|
||||
setNavVisible((prev) => {
|
||||
localStorage.setItem('navVisible', JSON.stringify(!prev));
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import React, { useCallback, useContext } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useRecoilValue } from 'recoil';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { QueryKeys, Constants, PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import type { TMessage } from 'librechat-data-provider';
|
||||
import { QueryKeys, Constants } from 'librechat-data-provider';
|
||||
import type { TMessage, TStartupConfig } from 'librechat-data-provider';
|
||||
import { NewChatIcon, MobileSidebar, Sidebar } from '~/components/svg';
|
||||
import { getDefaultModelSpec, getModelSpecPreset } from '~/utils';
|
||||
import { TooltipAnchor, Button } from '~/components/ui';
|
||||
import { useLocalize, useNewConvo, useHasAccess } from '~/hooks';
|
||||
import { AuthContext } from '~/hooks/AuthContext';
|
||||
import { LayoutGrid } from 'lucide-react';
|
||||
import { useLocalize, useNewConvo } from '~/hooks';
|
||||
import store from '~/store';
|
||||
|
||||
export default function NewChat({
|
||||
@@ -29,15 +29,6 @@ export default function NewChat({
|
||||
const navigate = useNavigate();
|
||||
const localize = useLocalize();
|
||||
const { conversation } = store.useCreateConversationAtom(index);
|
||||
const authContext = useContext(AuthContext);
|
||||
const hasAccessToAgents = useHasAccess({
|
||||
permissionType: PermissionTypes.AGENTS,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
const hasAccessToMarketplace = useHasAccess({
|
||||
permissionType: PermissionTypes.MARKETPLACE,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
|
||||
const clickHandler: React.MouseEventHandler<HTMLButtonElement> = useCallback(
|
||||
(e) => {
|
||||
@@ -59,21 +50,6 @@ export default function NewChat({
|
||||
[queryClient, conversation, newConvo, navigate, toggleNav, isSmallScreen],
|
||||
);
|
||||
|
||||
const handleAgentMarketplace = useCallback(() => {
|
||||
navigate('/agents');
|
||||
if (isSmallScreen) {
|
||||
toggleNav();
|
||||
}
|
||||
}, [navigate, isSmallScreen, toggleNav]);
|
||||
|
||||
// Check if auth is ready (avoid race conditions)
|
||||
const authReady =
|
||||
authContext?.isAuthenticated !== undefined &&
|
||||
(authContext?.isAuthenticated === false || authContext?.user !== undefined);
|
||||
|
||||
// Show agent marketplace when marketplace permission is enabled, auth is ready, and user has access to agents
|
||||
const showAgentMarketplace = authReady && hasAccessToAgents && hasAccessToMarketplace;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between py-[2px] md:py-2">
|
||||
@@ -112,29 +88,6 @@ export default function NewChat({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Marketplace button - separate row like ChatGPT */}
|
||||
{showAgentMarketplace && (
|
||||
<div className="flex px-2 pb-4 pt-2 md:px-3">
|
||||
<TooltipAnchor
|
||||
description={localize('com_nav_agents_marketplace')}
|
||||
render={
|
||||
<Button
|
||||
variant="outline"
|
||||
data-testid="nav-agents-marketplace-button"
|
||||
aria-label={localize('com_nav_agents_marketplace')}
|
||||
className="flex w-full items-center justify-start gap-3 rounded-xl border-none bg-transparent p-3 text-left hover:bg-surface-hover"
|
||||
onClick={handleAgentMarketplace}
|
||||
>
|
||||
<LayoutGrid className="h-5 w-5 flex-shrink-0" />
|
||||
<span className="truncate text-base font-medium">
|
||||
{localize('com_nav_agents_marketplace')}
|
||||
</span>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{subHeaders != null ? subHeaders : null}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -6,25 +6,18 @@ import { useGetStartupConfig } from '~/data-provider';
|
||||
import type { TDialogProps } from '~/common';
|
||||
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
|
||||
import {
|
||||
GearIcon,
|
||||
DataIcon,
|
||||
SpeechIcon,
|
||||
UserIcon,
|
||||
ExperimentIcon,
|
||||
PersonalizationIcon,
|
||||
} from '~/components/svg';
|
||||
import {
|
||||
General,
|
||||
Chat,
|
||||
Speech,
|
||||
Personalization,
|
||||
Commands,
|
||||
Data,
|
||||
General,
|
||||
Account,
|
||||
Balance,
|
||||
Personalization,
|
||||
Speech,
|
||||
Data,
|
||||
Chat,
|
||||
} from './SettingsTabs';
|
||||
import { useMediaQuery, useLocalize, TranslationKeys } from '~/hooks';
|
||||
import { GearIcon, DataIcon, SpeechIcon, UserIcon, PersonalizationIcon } from '~/components/svg';
|
||||
import usePersonalizationAccess from '~/hooks/usePersonalizationAccess';
|
||||
import { useMediaQuery, useLocalize, TranslationKeys } from '~/hooks';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
export default function Settings({ open, onOpenChange }: TDialogProps) {
|
||||
@@ -39,7 +32,6 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
||||
const tabs: SettingsTabValues[] = [
|
||||
SettingsTabValues.GENERAL,
|
||||
SettingsTabValues.CHAT,
|
||||
SettingsTabValues.BETA,
|
||||
SettingsTabValues.COMMANDS,
|
||||
SettingsTabValues.SPEECH,
|
||||
...(hasAnyPersonalizationFeature ? [SettingsTabValues.PERSONALIZATION] : []),
|
||||
@@ -84,11 +76,6 @@ export default function Settings({ open, onOpenChange }: TDialogProps) {
|
||||
icon: <MessageSquare className="icon-sm" />,
|
||||
label: 'com_nav_setting_chat',
|
||||
},
|
||||
{
|
||||
value: SettingsTabValues.BETA,
|
||||
icon: <ExperimentIcon />,
|
||||
label: 'com_nav_setting_beta',
|
||||
},
|
||||
{
|
||||
value: SettingsTabValues.COMMANDS,
|
||||
icon: <Command className="icon-sm" />,
|
||||
|
||||
@@ -9,10 +9,6 @@ import {
|
||||
PlaneTakeoffIcon,
|
||||
GraduationCapIcon,
|
||||
TerminalSquareIcon,
|
||||
// NEW: Add these for agent categories
|
||||
Users as UsersIcon,
|
||||
Beaker as BeakerIcon,
|
||||
Settings as SettingsIcon,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
@@ -26,13 +22,6 @@ const categoryIconMap: Record<string, React.ElementType> = {
|
||||
code: TerminalSquareIcon,
|
||||
travel: PlaneTakeoffIcon,
|
||||
teach_or_explain: GraduationCapIcon,
|
||||
// NEW: Agent categories
|
||||
general: BoxIcon,
|
||||
hr: UsersIcon,
|
||||
rd: BeakerIcon,
|
||||
it: TerminalSquareIcon,
|
||||
sales: LineChartIcon,
|
||||
aftersales: SettingsIcon,
|
||||
};
|
||||
|
||||
const categoryColorMap: Record<string, string> = {
|
||||
@@ -45,13 +34,6 @@ const categoryColorMap: Record<string, string> = {
|
||||
finance: 'text-orange-400',
|
||||
roleplay: 'text-orange-400',
|
||||
teach_or_explain: 'text-blue-300',
|
||||
// NEW: Agent categories
|
||||
general: 'text-blue-500',
|
||||
hr: 'text-green-500',
|
||||
rd: 'text-purple-500',
|
||||
it: 'text-red-500',
|
||||
sales: 'text-orange-500',
|
||||
aftersales: 'text-yellow-500',
|
||||
};
|
||||
|
||||
export default function CategoryIcon({
|
||||
|
||||
@@ -2,9 +2,10 @@ import * as Popover from '@radix-ui/react-popover';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
QueryKeys,
|
||||
mergeFileConfig,
|
||||
fileConfig as defaultFileConfig,
|
||||
QueryKeys,
|
||||
defaultOrderQuery,
|
||||
mergeFileConfig,
|
||||
} from 'librechat-data-provider';
|
||||
import type { UseMutationResult } from '@tanstack/react-query';
|
||||
import type {
|
||||
@@ -13,12 +14,7 @@ import type {
|
||||
AgentCreateParams,
|
||||
AgentListResponse,
|
||||
} from 'librechat-data-provider';
|
||||
import {
|
||||
useUploadAgentAvatarMutation,
|
||||
useGetFileConfig,
|
||||
allAgentViewAndEditQueryKeys,
|
||||
invalidateAgentMarketplaceQueries,
|
||||
} from '~/data-provider';
|
||||
import { useUploadAgentAvatarMutation, useGetFileConfig } from '~/data-provider';
|
||||
import { AgentAvatarRender, NoImage, AvatarMenu } from './Images';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
@@ -50,41 +46,41 @@ function Avatar({
|
||||
onMutate: () => {
|
||||
setProgress(0.4);
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
if (lastSeenCreatedId.current !== createMutation.data?.id) {
|
||||
onSuccess: (data, vars) => {
|
||||
if (vars.postCreation === false) {
|
||||
showToast({ message: localize('com_ui_upload_success') });
|
||||
} else if (lastSeenCreatedId.current !== createMutation.data?.id) {
|
||||
lastSeenCreatedId.current = createMutation.data?.id ?? '';
|
||||
}
|
||||
showToast({ message: localize('com_ui_upload_agent_avatar') });
|
||||
|
||||
setInput(null);
|
||||
const newUrl = data.avatar?.filepath ?? '';
|
||||
setPreviewUrl(newUrl);
|
||||
|
||||
((keys) => {
|
||||
keys.forEach((key) => {
|
||||
const res = queryClient.getQueryData<AgentListResponse>([QueryKeys.agents, key]);
|
||||
const res = queryClient.getQueryData<AgentListResponse>([
|
||||
QueryKeys.agents,
|
||||
defaultOrderQuery,
|
||||
]);
|
||||
|
||||
if (!res?.data) {
|
||||
return;
|
||||
}
|
||||
if (!res?.data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const agents = res.data.map((agent) => {
|
||||
if (agent.id === agent_id) {
|
||||
return {
|
||||
...agent,
|
||||
...data,
|
||||
};
|
||||
}
|
||||
return agent;
|
||||
});
|
||||
const agents = res.data.map((agent) => {
|
||||
if (agent.id === agent_id) {
|
||||
return {
|
||||
...agent,
|
||||
...data,
|
||||
};
|
||||
}
|
||||
return agent;
|
||||
});
|
||||
|
||||
queryClient.setQueryData<AgentListResponse>([QueryKeys.agents, defaultOrderQuery], {
|
||||
...res,
|
||||
data: agents,
|
||||
});
|
||||
|
||||
queryClient.setQueryData<AgentListResponse>([QueryKeys.agents, key], {
|
||||
...res,
|
||||
data: agents,
|
||||
});
|
||||
});
|
||||
})(allAgentViewAndEditQueryKeys);
|
||||
invalidateAgentMarketplaceQueries(queryClient);
|
||||
setProgress(1);
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -141,6 +137,7 @@ function Avatar({
|
||||
|
||||
uploadAvatar({
|
||||
agent_id: createMutation.data.id,
|
||||
postCreation: true,
|
||||
formData,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import type t from 'librechat-data-provider';
|
||||
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import { renderAgentAvatar, getContactDisplayName } from '~/utils/agents';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface AgentCardProps {
|
||||
agent: t.Agent; // The agent data to display
|
||||
onClick: () => void; // Callback when card is clicked
|
||||
className?: string; // Additional CSS classes
|
||||
}
|
||||
|
||||
/**
|
||||
* Card component to display agent information
|
||||
*/
|
||||
const AgentCard: React.FC<AgentCardProps> = ({ agent, onClick, className = '' }) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex overflow-hidden rounded-2xl',
|
||||
'cursor-pointer transition-colors duration-200',
|
||||
'aspect-[5/2.5] w-full',
|
||||
'bg-gray-50 hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600',
|
||||
className,
|
||||
)}
|
||||
onClick={onClick}
|
||||
aria-label={localize('com_agents_agent_card_label', {
|
||||
name: agent.name,
|
||||
description: agent.description || localize('com_agents_no_description'),
|
||||
})}
|
||||
aria-describedby={`agent-${agent.id}-description`}
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex h-full gap-2 px-3 py-2 sm:gap-3 sm:px-4 sm:py-3">
|
||||
{/* Agent avatar section - left side, responsive */}
|
||||
<div className="flex flex-shrink-0 items-center">
|
||||
{renderAgentAvatar(agent, { size: 'md' })}
|
||||
</div>
|
||||
|
||||
{/* Agent info section - right side, responsive */}
|
||||
<div className="flex min-w-0 flex-1 flex-col justify-center">
|
||||
{/* Agent name - responsive text sizing */}
|
||||
<h3 className="mb-1 line-clamp-1 text-base font-bold text-gray-900 dark:text-white sm:mb-2 sm:text-lg">
|
||||
{agent.name}
|
||||
</h3>
|
||||
|
||||
{/* Agent description - responsive text sizing and spacing */}
|
||||
<p
|
||||
id={`agent-${agent.id}-description`}
|
||||
className={cn(
|
||||
'mb-1 line-clamp-2 text-xs leading-relaxed text-gray-600 dark:text-gray-300',
|
||||
'sm:mb-2 sm:text-sm',
|
||||
)}
|
||||
aria-label={`Description: ${agent.description || localize('com_agents_no_description')}`}
|
||||
>
|
||||
{agent.description || (
|
||||
<span className="italic text-gray-400">{localize('com_agents_no_description')}</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{/* Owner info - responsive text sizing */}
|
||||
{(() => {
|
||||
const displayName = getContactDisplayName(agent);
|
||||
|
||||
if (displayName) {
|
||||
return (
|
||||
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400 sm:text-sm">
|
||||
<span className="font-light">{localize('com_agents_created_by')}</span>
|
||||
<span className="ml-1 font-bold">{displayName}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentCard;
|
||||
@@ -1,62 +0,0 @@
|
||||
import React from 'react';
|
||||
import { useAgentCategories } from '~/hooks/Agents';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
interface AgentCategoryDisplayProps {
|
||||
category?: string;
|
||||
className?: string;
|
||||
showIcon?: boolean;
|
||||
iconClassName?: string;
|
||||
showEmptyFallback?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component to display an agent category with proper translation
|
||||
*
|
||||
* @param category - The category value (e.g., "general", "hr", etc.)
|
||||
* @param className - Optional className for the container
|
||||
* @param showIcon - Whether to show the category icon
|
||||
* @param iconClassName - Optional className for the icon
|
||||
* @param showEmptyFallback - Whether to show a fallback for empty categories
|
||||
*/
|
||||
const AgentCategoryDisplay: React.FC<AgentCategoryDisplayProps> = ({
|
||||
category,
|
||||
className = '',
|
||||
showIcon = true,
|
||||
iconClassName = 'h-4 w-4 mr-2',
|
||||
showEmptyFallback = false,
|
||||
}) => {
|
||||
const { categories, emptyCategory } = useAgentCategories();
|
||||
|
||||
// Find the category in our processed categories list
|
||||
const categoryItem = categories.find((c) => c.value === category);
|
||||
|
||||
// Handle empty string case differently than undefined/null
|
||||
if (category === '') {
|
||||
if (!showEmptyFallback) {
|
||||
return null;
|
||||
}
|
||||
// Show the empty category placeholder
|
||||
return (
|
||||
<div className={cn('flex items-center text-gray-400', className)}>
|
||||
<span>{emptyCategory.label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No category or unknown category
|
||||
if (!category || !categoryItem) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center', className)}>
|
||||
{showIcon && categoryItem.icon && (
|
||||
<span className={cn('flex-shrink-0', iconClassName)}>{categoryItem.icon}</span>
|
||||
)}
|
||||
<span>{categoryItem.label}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentCategoryDisplay;
|
||||
@@ -1,96 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
useFormContext,
|
||||
Controller,
|
||||
useWatch,
|
||||
ControllerRenderProps,
|
||||
FieldValues,
|
||||
FieldPath,
|
||||
} from 'react-hook-form';
|
||||
import ControlCombobox from '~/components/ui/ControlCombobox';
|
||||
import { useAgentCategories } from '~/hooks/Agents';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
/**
|
||||
* Custom hook to handle category synchronization
|
||||
*/
|
||||
const useCategorySync = (agent_id: string | null) => {
|
||||
const [handled, setHandled] = useState(false);
|
||||
|
||||
return {
|
||||
syncCategory: <T extends FieldPath<FieldValues>>(
|
||||
field: ControllerRenderProps<FieldValues, T>,
|
||||
) => {
|
||||
// Only run once and only for new agents
|
||||
if (!handled && agent_id === '' && !field.value) {
|
||||
field.onChange('general');
|
||||
setHandled(true);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* A component for selecting agent categories with form validation
|
||||
*/
|
||||
const AgentCategorySelector: React.FC<{ className?: string }> = ({ className }) => {
|
||||
const { t } = useTranslation();
|
||||
const formContext = useFormContext();
|
||||
const { categories } = useAgentCategories();
|
||||
|
||||
// Always call useWatch
|
||||
const agent_id = useWatch({
|
||||
name: 'id',
|
||||
control: formContext.control,
|
||||
});
|
||||
|
||||
// Use custom hook for category sync
|
||||
const { syncCategory } = useCategorySync(agent_id);
|
||||
|
||||
// Transform categories to the format expected by ControlCombobox
|
||||
const comboboxItems = categories.map((category) => ({
|
||||
label: category.label,
|
||||
value: category.value,
|
||||
}));
|
||||
|
||||
const getCategoryDisplayValue = (value: string) => {
|
||||
const categoryItem = comboboxItems.find((c) => c.value === value);
|
||||
return categoryItem?.label || comboboxItems.find((c) => c.value === 'general')?.label;
|
||||
};
|
||||
|
||||
const searchPlaceholder = t('com_ui_search_agent_category', 'Search categories...');
|
||||
const ariaLabel = t('com_ui_agent_category_selector_aria', "Agent's category selector");
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name="category"
|
||||
control={formContext.control}
|
||||
defaultValue="general"
|
||||
render={({ field }) => {
|
||||
// Sync category if needed (without using useEffect in render)
|
||||
syncCategory(field);
|
||||
|
||||
const displayValue = getCategoryDisplayValue(field.value);
|
||||
|
||||
return (
|
||||
<ControlCombobox
|
||||
selectedValue={field.value}
|
||||
displayValue={displayValue}
|
||||
searchPlaceholder={searchPlaceholder}
|
||||
setValue={(value) => {
|
||||
field.onChange(value);
|
||||
}}
|
||||
items={comboboxItems}
|
||||
className={cn(className)}
|
||||
ariaLabel={ariaLabel}
|
||||
isCollapsed={false}
|
||||
showCarat={true}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentCategorySelector;
|
||||
@@ -2,14 +2,20 @@ import React, { useState, useMemo, useCallback } from 'react';
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
import { Controller, useWatch, useFormContext } from 'react-hook-form';
|
||||
import type { AgentForm, AgentPanelProps, IconComponentTypes } from '~/common';
|
||||
import { cn, defaultTextProps, removeFocusOutlines, getEndpointField, getIconKey } from '~/utils';
|
||||
import {
|
||||
removeFocusOutlines,
|
||||
processAgentOption,
|
||||
getEndpointField,
|
||||
defaultTextProps,
|
||||
getIconKey,
|
||||
cn,
|
||||
} from '~/utils';
|
||||
import { useToastContext, useFileMapContext, useAgentPanelContext } from '~/Providers';
|
||||
import useAgentCapabilities from '~/hooks/Agents/useAgentCapabilities';
|
||||
import Action from '~/components/SidePanel/Builder/Action';
|
||||
import { ToolSelectDialog } from '~/components/Tools';
|
||||
import { useGetAgentFiles } from '~/data-provider';
|
||||
import { icons } from '~/hooks/Endpoint/Icons';
|
||||
import { processAgentOption } from '~/utils';
|
||||
import Instructions from './Instructions';
|
||||
import AgentAvatar from './AgentAvatar';
|
||||
import FileContext from './FileContext';
|
||||
@@ -19,7 +25,6 @@ import FileSearch from './FileSearch';
|
||||
import Artifacts from './Artifacts';
|
||||
import AgentTool from './AgentTool';
|
||||
import CodeForm from './Code/Form';
|
||||
import AgentCategorySelector from './AgentCategorySelector';
|
||||
import { Panel } from '~/common';
|
||||
|
||||
const labelClass = 'mb-2 text-token-text-primary block font-medium';
|
||||
@@ -44,10 +49,7 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
|
||||
groupedTools: allTools,
|
||||
} = useAgentPanelContext();
|
||||
|
||||
const {
|
||||
control,
|
||||
formState: { errors },
|
||||
} = methods;
|
||||
const { control } = methods;
|
||||
const provider = useWatch({ control, name: 'provider' });
|
||||
const model = useWatch({ control, name: 'model' });
|
||||
const agent = useWatch({ control, name: 'agent' });
|
||||
@@ -191,33 +193,21 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
|
||||
/>
|
||||
<label className={labelClass} htmlFor="name">
|
||||
{localize('com_ui_name')}
|
||||
<span className="text-red-500">*</span>
|
||||
</label>
|
||||
<Controller
|
||||
name="name"
|
||||
rules={{ required: localize('com_ui_agent_name_is_required') }}
|
||||
control={control}
|
||||
render={({ field }) => (
|
||||
<>
|
||||
<input
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
maxLength={256}
|
||||
className={inputClass}
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder={localize('com_agents_name_placeholder')}
|
||||
aria-label="Agent name"
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'mt-1 w-56 text-sm text-red-500',
|
||||
errors.name ? 'visible h-auto' : 'invisible h-0',
|
||||
)}
|
||||
>
|
||||
{errors.name ? errors.name.message : ' '}
|
||||
</div>
|
||||
</>
|
||||
<input
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
maxLength={256}
|
||||
className={inputClass}
|
||||
id="name"
|
||||
type="text"
|
||||
placeholder={localize('com_agents_name_placeholder')}
|
||||
aria-label="Agent name"
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
@@ -252,13 +242,6 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{/* Category */}
|
||||
<div className="mb-4">
|
||||
<label className={labelClass} htmlFor="category-selector">
|
||||
{localize('com_ui_category')} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<AgentCategorySelector className="w-full" />
|
||||
</div>
|
||||
{/* Instructions */}
|
||||
<Instructions />
|
||||
{/* Model and Provider */}
|
||||
@@ -378,93 +361,6 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
|
||||
</div>
|
||||
{/* MCP Section */}
|
||||
{/* <MCPSection /> */}
|
||||
|
||||
{/* Support Contact (Optional) */}
|
||||
<div className="mb-4">
|
||||
<div className="mb-1.5 flex items-center gap-2">
|
||||
<span>
|
||||
<label className="text-token-text-primary block font-medium">
|
||||
{localize('com_ui_support_contact')}
|
||||
</label>
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{/* Support Contact Name */}
|
||||
<div className="flex flex-col">
|
||||
<label
|
||||
className="mb-1 flex items-center justify-between"
|
||||
htmlFor="support-contact-name"
|
||||
>
|
||||
<span className="text-sm">{localize('com_ui_support_contact_name')}</span>
|
||||
</label>
|
||||
<Controller
|
||||
name="support_contact.name"
|
||||
control={control}
|
||||
rules={{
|
||||
minLength: {
|
||||
value: 3,
|
||||
message: localize('com_ui_support_contact_name_min_length', { minLength: 3 }),
|
||||
},
|
||||
}}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<>
|
||||
<input
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
className={cn(inputClass, error ? 'border-2 border-red-500' : '')}
|
||||
id="support-contact-name"
|
||||
type="text"
|
||||
placeholder={localize('com_ui_support_contact_name_placeholder')}
|
||||
aria-label="Support contact name"
|
||||
/>
|
||||
{error && (
|
||||
<span className="text-sm text-red-500 transition duration-300 ease-in-out">
|
||||
{error.message}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{/* Support Contact Email */}
|
||||
<div className="flex flex-col">
|
||||
<label
|
||||
className="mb-1 flex items-center justify-between"
|
||||
htmlFor="support-contact-email"
|
||||
>
|
||||
<span className="text-sm">{localize('com_ui_support_contact_email')}</span>
|
||||
</label>
|
||||
<Controller
|
||||
name="support_contact.email"
|
||||
control={control}
|
||||
rules={{
|
||||
pattern: {
|
||||
value: /^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/,
|
||||
message: localize('com_ui_support_contact_email_invalid'),
|
||||
},
|
||||
}}
|
||||
render={({ field, fieldState: { error } }) => (
|
||||
<>
|
||||
<input
|
||||
{...field}
|
||||
value={field.value ?? ''}
|
||||
className={cn(inputClass, error ? 'border-2 border-red-500' : '')}
|
||||
id="support-contact-email"
|
||||
type="email"
|
||||
placeholder={localize('com_ui_support_contact_email_placeholder')}
|
||||
aria-label="Support contact email"
|
||||
/>
|
||||
{error && (
|
||||
<span className="text-sm text-red-500 transition duration-300 ease-in-out">
|
||||
{error.message}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ToolSelectDialog
|
||||
isOpen={showToolDialog}
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import type t from 'librechat-data-provider';
|
||||
import { AgentListResponse, PERMISSION_BITS, QueryKeys } from 'librechat-data-provider';
|
||||
interface SupportContact {
|
||||
name?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
interface AgentWithSupport extends t.Agent {
|
||||
support_contact?: SupportContact;
|
||||
}
|
||||
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Dialog, DialogContent, Button } from '~/components/ui';
|
||||
import { renderAgentAvatar } from '~/utils/agents';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import { DotsIcon } from '~/components/svg';
|
||||
import { useToast } from '~/hooks';
|
||||
|
||||
interface AgentDetailProps {
|
||||
agent: AgentWithSupport; // The agent data to display
|
||||
isOpen: boolean; // Whether the detail dialog is open
|
||||
onClose: () => void; // Callback when dialog is closed
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog for displaying agent details
|
||||
*/
|
||||
const AgentDetail: React.FC<AgentDetailProps> = ({ agent, isOpen, onClose }) => {
|
||||
const localize = useLocalize();
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const [dropdownOpen, setDropdownOpen] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
// Close dropdown when clicking outside the dropdown menu
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownOpen &&
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setDropdownOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [dropdownOpen]);
|
||||
|
||||
/**
|
||||
* Navigate to chat with the selected agent
|
||||
*/
|
||||
const handleStartChat = () => {
|
||||
if (agent) {
|
||||
const keys = [QueryKeys.agents, { requiredPermission: PERMISSION_BITS.EDIT }];
|
||||
const listResp = queryClient.getQueryData<AgentListResponse>(keys);
|
||||
if (listResp != null) {
|
||||
if (!listResp.data.some((a) => a.id === agent.id)) {
|
||||
const currentAgents = [agent, ...JSON.parse(JSON.stringify(listResp.data))];
|
||||
queryClient.setQueryData<AgentListResponse>(keys, { ...listResp, data: currentAgents });
|
||||
}
|
||||
}
|
||||
navigate(`/c/new?agent_id=${agent.id}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Copy the agent's shareable link to clipboard
|
||||
*/
|
||||
const handleCopyLink = () => {
|
||||
const baseUrl = new URL(window.location.origin);
|
||||
const chatUrl = `${baseUrl.origin}/c/new?agent_id=${agent.id}`;
|
||||
navigator.clipboard
|
||||
.writeText(chatUrl)
|
||||
.then(() => {
|
||||
showToast({
|
||||
message: localize('com_agents_link_copied'),
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
showToast({
|
||||
message: localize('com_agents_link_copy_failed'),
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Format contact information with mailto links when appropriate
|
||||
*/
|
||||
const formatContact = () => {
|
||||
if (!agent?.support_contact) return null;
|
||||
|
||||
const { name, email } = agent.support_contact;
|
||||
|
||||
if (name && email) {
|
||||
return (
|
||||
<a href={`mailto:${email}`} className="text-primary hover:underline">
|
||||
{name}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (email) {
|
||||
return (
|
||||
<a href={`mailto:${email}`} className="text-primary hover:underline">
|
||||
{email}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (name) {
|
||||
return <span>{name}</span>;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent ref={dialogRef} className="max-h-[90vh] overflow-y-auto py-8 sm:max-w-[450px]">
|
||||
{/* Context menu - top right */}
|
||||
<div ref={dropdownRef} className="absolute right-12 top-5 z-50">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-lg text-text-secondary hover:bg-surface-hover hover:text-text-primary dark:hover:bg-surface-hover"
|
||||
aria-label={localize('com_agents_more_options')}
|
||||
aria-expanded={dropdownOpen}
|
||||
aria-haspopup="menu"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDropdownOpen(!dropdownOpen);
|
||||
}}
|
||||
>
|
||||
<DotsIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* Simple dropdown menu */}
|
||||
{dropdownOpen && (
|
||||
<div className="absolute right-0 top-10 z-[9999] w-48 rounded-xl border border-border-light bg-surface-primary py-1 shadow-lg dark:bg-surface-secondary dark:shadow-2xl">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDropdownOpen(false);
|
||||
handleCopyLink();
|
||||
}}
|
||||
className="w-full px-3 py-2 text-left text-sm text-text-primary transition-colors hover:bg-surface-hover focus:bg-surface-hover focus:outline-none"
|
||||
>
|
||||
{localize('com_agents_copy_link')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Agent avatar - top center */}
|
||||
<div className="mt-6 flex justify-center">{renderAgentAvatar(agent, { size: 'xl' })}</div>
|
||||
|
||||
{/* Agent name - center aligned below image */}
|
||||
<div className="mt-3 text-center">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{agent?.name || localize('com_agents_loading')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Contact info - center aligned below name */}
|
||||
{agent?.support_contact && formatContact() && (
|
||||
<div className="mt-1 text-center text-sm text-gray-600 dark:text-gray-400">
|
||||
{localize('com_agents_contact')}: {formatContact()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent description - below contact */}
|
||||
<div className="mt-4 whitespace-pre-wrap px-6 text-center text-base text-gray-700 dark:text-gray-300">
|
||||
{agent?.description || (
|
||||
<span className="italic text-gray-400">{localize('com_agents_no_description')}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Action button */}
|
||||
<div className="mb-4 mt-6 flex justify-center">
|
||||
<Button className="w-full max-w-xs" onClick={handleStartChat} disabled={!agent}>
|
||||
{localize('com_agents_start_chat')}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentDetail;
|
||||
@@ -1,21 +1,16 @@
|
||||
import { useWatch, useFormContext } from 'react-hook-form';
|
||||
import {
|
||||
SystemRoles,
|
||||
Permissions,
|
||||
PermissionTypes,
|
||||
PERMISSION_BITS,
|
||||
} from 'librechat-data-provider';
|
||||
import { SystemRoles, Permissions, PermissionTypes } from 'librechat-data-provider';
|
||||
import type { AgentForm, AgentPanelProps } from '~/common';
|
||||
import { useLocalize, useAuthContext, useHasAccess, useResourcePermissions } from '~/hooks';
|
||||
import GrantAccessDialog from './Sharing/GrantAccessDialog';
|
||||
import { useLocalize, useAuthContext, useHasAccess } from '~/hooks';
|
||||
import { useUpdateAgentMutation } from '~/data-provider';
|
||||
import AdvancedButton from './Advanced/AdvancedButton';
|
||||
import VersionButton from './Version/VersionButton';
|
||||
import DuplicateAgent from './DuplicateAgent';
|
||||
import AdminSettings from './AdminSettings';
|
||||
import DeleteButton from './DeleteButton';
|
||||
import { Spinner } from '~/components';
|
||||
import ShareAgent from './ShareAgent';
|
||||
import { Panel } from '~/common';
|
||||
import VersionButton from './Version/VersionButton';
|
||||
|
||||
export default function AgentFooter({
|
||||
activePanel,
|
||||
@@ -37,17 +32,12 @@ export default function AgentFooter({
|
||||
const { control } = methods;
|
||||
const agent = useWatch({ control, name: 'agent' });
|
||||
const agent_id = useWatch({ control, name: 'id' });
|
||||
|
||||
const hasAccessToShareAgents = useHasAccess({
|
||||
permissionType: PermissionTypes.AGENTS,
|
||||
permission: Permissions.SHARED_GLOBAL,
|
||||
});
|
||||
const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions(
|
||||
'agent',
|
||||
agent?._id || '',
|
||||
);
|
||||
|
||||
const canShareThisAgent = hasPermission(PERMISSION_BITS.SHARE);
|
||||
const canDeleteThisAgent = hasPermission(PERMISSION_BITS.DELETE);
|
||||
const renderSaveButton = () => {
|
||||
if (createMutation.isLoading || updateMutation.isLoading) {
|
||||
return <Spinner className="icon-md" aria-hidden="true" />;
|
||||
@@ -69,21 +59,18 @@ export default function AgentFooter({
|
||||
{user?.role === SystemRoles.ADMIN && showButtons && <AdminSettings />}
|
||||
{/* Context Button */}
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{(agent?.author === user?.id || user?.role === SystemRoles.ADMIN || canDeleteThisAgent) &&
|
||||
!permissionsLoading && (
|
||||
<DeleteButton
|
||||
<DeleteButton
|
||||
agent_id={agent_id}
|
||||
setCurrentAgentId={setCurrentAgentId}
|
||||
createMutation={createMutation}
|
||||
/>
|
||||
{(agent?.author === user?.id || user?.role === SystemRoles.ADMIN) &&
|
||||
hasAccessToShareAgents && (
|
||||
<ShareAgent
|
||||
agent_id={agent_id}
|
||||
setCurrentAgentId={setCurrentAgentId}
|
||||
createMutation={createMutation}
|
||||
/>
|
||||
)}
|
||||
{(agent?.author === user?.id || user?.role === SystemRoles.ADMIN || canShareThisAgent) &&
|
||||
hasAccessToShareAgents &&
|
||||
!permissionsLoading && (
|
||||
<GrantAccessDialog
|
||||
agentDbId={agent?._id}
|
||||
agentId={agent_id}
|
||||
agentName={agent?.name ?? ''}
|
||||
projectIds={agent?.projectIds ?? []}
|
||||
isCollaborative={agent?.isCollaborative}
|
||||
/>
|
||||
)}
|
||||
{agent && agent.author === user?.id && <DuplicateAgent agent_id={agent_id} />}
|
||||
|
||||
@@ -1,288 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import type t from 'librechat-data-provider';
|
||||
|
||||
import { useMarketplaceAgentsInfiniteQuery } from '~/data-provider/Agents';
|
||||
import { useAgentCategories } from '~/hooks/Agents';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import { Button } from '~/components/ui';
|
||||
import { Spinner } from '~/components/svg';
|
||||
import { useHasData } from './SmartLoader';
|
||||
import ErrorDisplay from './ErrorDisplay';
|
||||
import AgentCard from './AgentCard';
|
||||
import { cn } from '~/utils';
|
||||
import { PERMISSION_BITS } from 'librechat-data-provider';
|
||||
interface AgentGridProps {
|
||||
category: string; // Currently selected category
|
||||
searchQuery: string; // Current search query
|
||||
onSelectAgent: (agent: t.Agent) => void; // Callback when agent is selected
|
||||
}
|
||||
|
||||
/**
|
||||
* Component for displaying a grid of agent cards
|
||||
*/
|
||||
const AgentGrid: React.FC<AgentGridProps> = ({ category, searchQuery, onSelectAgent }) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
// Get category data from API
|
||||
const { categories } = useAgentCategories();
|
||||
|
||||
// Build query parameters based on current state
|
||||
const queryParams = useMemo(() => {
|
||||
const params: {
|
||||
requiredPermission: number;
|
||||
category?: string;
|
||||
search?: string;
|
||||
limit: number;
|
||||
promoted?: 0 | 1;
|
||||
} = {
|
||||
requiredPermission: PERMISSION_BITS.VIEW, // View permission for marketplace viewing
|
||||
limit: 6,
|
||||
};
|
||||
|
||||
// Handle search
|
||||
if (searchQuery) {
|
||||
params.search = searchQuery;
|
||||
// Include category filter for search if it's not 'all' or 'promoted'
|
||||
if (category !== 'all' && category !== 'promoted') {
|
||||
params.category = category;
|
||||
}
|
||||
} else {
|
||||
// Handle category-based queries
|
||||
if (category === 'promoted') {
|
||||
params.promoted = 1;
|
||||
} else if (category !== 'all') {
|
||||
params.category = category;
|
||||
}
|
||||
// For 'all' category, no additional filters needed
|
||||
}
|
||||
|
||||
return params;
|
||||
}, [category, searchQuery]);
|
||||
|
||||
// Use infinite query for marketplace agents
|
||||
const {
|
||||
data,
|
||||
isLoading,
|
||||
error,
|
||||
isFetching,
|
||||
fetchNextPage,
|
||||
hasNextPage,
|
||||
refetch,
|
||||
isFetchingNextPage,
|
||||
} = useMarketplaceAgentsInfiniteQuery(queryParams);
|
||||
|
||||
// Flatten all pages into a single array of agents
|
||||
const currentAgents = useMemo(() => {
|
||||
if (!data?.pages) return [];
|
||||
return data.pages.flatMap((page) => page.data || []);
|
||||
}, [data?.pages]);
|
||||
|
||||
// Check if we have meaningful data to prevent unnecessary loading states
|
||||
const hasData = useHasData(data?.pages?.[0]);
|
||||
|
||||
/**
|
||||
* Get category display name from API data or use fallback
|
||||
*/
|
||||
const getCategoryDisplayName = (categoryValue: string) => {
|
||||
const categoryData = categories.find((cat) => cat.value === categoryValue);
|
||||
if (categoryData) {
|
||||
return categoryData.label;
|
||||
}
|
||||
|
||||
// Fallback for special categories or unknown categories
|
||||
if (categoryValue === 'promoted') {
|
||||
return localize('com_agents_top_picks');
|
||||
}
|
||||
if (categoryValue === 'all') {
|
||||
return 'All';
|
||||
}
|
||||
|
||||
// Simple capitalization for unknown categories
|
||||
return categoryValue.charAt(0).toUpperCase() + categoryValue.slice(1);
|
||||
};
|
||||
|
||||
/**
|
||||
* Load more agents when "See More" button is clicked
|
||||
*/
|
||||
const handleLoadMore = () => {
|
||||
if (hasNextPage && !isFetching) {
|
||||
fetchNextPage();
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the appropriate title for the agents grid based on current state
|
||||
*/
|
||||
const getGridTitle = () => {
|
||||
if (searchQuery) {
|
||||
return localize('com_agents_results_for', { query: searchQuery });
|
||||
}
|
||||
|
||||
return getCategoryDisplayName(category);
|
||||
};
|
||||
|
||||
// Loading skeleton component
|
||||
const loadingSkeleton = (
|
||||
<div className="space-y-6">
|
||||
<div className="mb-4">
|
||||
<div className="mb-2 h-6 w-48 animate-pulse rounded-md bg-gray-200 dark:bg-gray-700"></div>
|
||||
<div className="h-4 w-64 animate-pulse rounded-md bg-gray-200 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-6 md:grid-cols-2">
|
||||
{Array(6)
|
||||
.fill(0)
|
||||
.map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'flex h-[250px] animate-pulse flex-col overflow-hidden rounded-lg',
|
||||
'bg-gray-200 dark:bg-gray-800',
|
||||
)}
|
||||
>
|
||||
<div className="h-40 bg-gray-300 dark:bg-gray-700"></div>
|
||||
<div className="flex-1 p-5">
|
||||
<div className="mb-3 h-4 w-3/4 rounded bg-gray-300 dark:bg-gray-700"></div>
|
||||
<div className="mb-2 h-3 w-full rounded bg-gray-300 dark:bg-gray-700"></div>
|
||||
<div className="h-3 w-2/3 rounded bg-gray-300 dark:bg-gray-700"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Handle error state with enhanced error display
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorDisplay
|
||||
error={error || 'Unknown error occurred'}
|
||||
onRetry={() => refetch()}
|
||||
context={{
|
||||
searchQuery,
|
||||
category,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Main content component with proper semantic structure
|
||||
const mainContent = (
|
||||
<div
|
||||
className="space-y-6"
|
||||
role="tabpanel"
|
||||
id={`category-panel-${category}`}
|
||||
aria-labelledby={`category-tab-${category}`}
|
||||
aria-live="polite"
|
||||
aria-busy={isLoading && !hasData}
|
||||
>
|
||||
{/* Grid title - only show for search results */}
|
||||
{searchQuery && (
|
||||
<div className="mb-4">
|
||||
<h2
|
||||
className="text-xl font-bold text-gray-900 dark:text-white"
|
||||
id={`category-heading-${category}`}
|
||||
aria-label={`${getGridTitle()}, ${currentAgents.length || 0} agents available`}
|
||||
>
|
||||
{getGridTitle()}
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Handle empty results with enhanced accessibility */}
|
||||
{(!currentAgents || currentAgents.length === 0) && !isLoading && !isFetching ? (
|
||||
<div
|
||||
className="py-12 text-center text-gray-500"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={
|
||||
searchQuery
|
||||
? localize('com_agents_search_empty_heading')
|
||||
: localize('com_agents_empty_state_heading')
|
||||
}
|
||||
>
|
||||
<h3 className="mb-2 text-lg font-medium">
|
||||
{searchQuery
|
||||
? localize('com_agents_search_empty_heading')
|
||||
: localize('com_agents_empty_state_heading')}
|
||||
</h3>
|
||||
<p className="text-sm">
|
||||
{searchQuery
|
||||
? localize('com_agents_no_results')
|
||||
: localize('com_agents_none_in_category')}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Announcement for screen readers */}
|
||||
<div id="search-results-count" className="sr-only" aria-live="polite" aria-atomic="true">
|
||||
{localize('com_agents_grid_announcement', {
|
||||
count: currentAgents?.length || 0,
|
||||
category: getCategoryDisplayName(category),
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Agent grid - 2 per row with proper semantic structure */}
|
||||
{currentAgents && currentAgents.length > 0 && (
|
||||
<div
|
||||
className="grid grid-cols-1 gap-6 md:grid-cols-2"
|
||||
role="grid"
|
||||
aria-label={localize('com_agents_grid_announcement', {
|
||||
count: currentAgents.length,
|
||||
category: getCategoryDisplayName(category),
|
||||
})}
|
||||
>
|
||||
{currentAgents.map((agent: t.Agent, index: number) => (
|
||||
<div key={`${agent.id}-${index}`} role="gridcell">
|
||||
<AgentCard agent={agent} onClick={() => onSelectAgent(agent)} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading indicator when fetching more with accessibility */}
|
||||
{isFetching && hasNextPage && (
|
||||
<div
|
||||
className="flex justify-center py-4"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
aria-label={localize('com_agents_loading')}
|
||||
>
|
||||
<Spinner className="h-6 w-6 text-primary" />
|
||||
<span className="sr-only">{localize('com_agents_loading')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Load more button with enhanced accessibility */}
|
||||
{hasNextPage && !isFetching && (
|
||||
<div className="mt-8 flex justify-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleLoadMore}
|
||||
className={cn(
|
||||
'min-w-[160px] border-2 border-gray-300 bg-white px-6 py-3 font-medium text-gray-700',
|
||||
'shadow-sm transition-all duration-200 hover:border-gray-400 hover:bg-gray-50',
|
||||
'hover:shadow-md focus:ring-2 focus:ring-blue-500 focus:ring-offset-2',
|
||||
'dark:border-gray-600 dark:bg-gray-800 dark:text-gray-200',
|
||||
'dark:hover:border-gray-500 dark:hover:bg-gray-700 dark:focus:ring-blue-400',
|
||||
)}
|
||||
aria-label={localize('com_agents_load_more_label', {
|
||||
category: getCategoryDisplayName(category),
|
||||
})}
|
||||
>
|
||||
{localize('com_agents_see_more')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isLoading || (isFetching && !isFetchingNextPage)) {
|
||||
return loadingSkeleton;
|
||||
}
|
||||
return mainContent;
|
||||
};
|
||||
|
||||
export default AgentGrid;
|
||||
@@ -1,324 +0,0 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useOutletContext } from 'react-router-dom';
|
||||
import { useSetRecoilState, useRecoilValue } from 'recoil';
|
||||
import { PermissionTypes, Permissions } from 'librechat-data-provider';
|
||||
import { useSearchParams, useParams, useNavigate } from 'react-router-dom';
|
||||
import type t from 'librechat-data-provider';
|
||||
import type { ContextType } from '~/common';
|
||||
import { useGetEndpointsQuery, useGetAgentCategoriesQuery } from '~/data-provider';
|
||||
import { MarketplaceProvider } from './MarketplaceContext';
|
||||
import { useDocumentTitle, useHasAccess } from '~/hooks';
|
||||
import { TooltipAnchor, Button } from '~/components/ui';
|
||||
import { SidePanelGroup } from '~/components/SidePanel';
|
||||
import { OpenSidebar } from '~/components/Chat/Menus';
|
||||
import { SidePanelProvider } from '~/Providers';
|
||||
import { NewChatIcon } from '~/components/svg';
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import CategoryTabs from './CategoryTabs';
|
||||
import AgentDetail from './AgentDetail';
|
||||
import SearchBar from './SearchBar';
|
||||
import AgentGrid from './AgentGrid';
|
||||
import store from '~/store';
|
||||
|
||||
interface AgentMarketplaceProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AgentMarketplace - Main component for browsing and discovering agents
|
||||
*
|
||||
* Provides tabbed navigation for different agent categories,
|
||||
* search functionality, and detailed agent view through a modal dialog.
|
||||
* Uses URL parameters for state persistence and deep linking.
|
||||
*/
|
||||
const AgentMarketplace: React.FC<AgentMarketplaceProps> = ({ className = '' }) => {
|
||||
const localize = useLocalize();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { category } = useParams();
|
||||
const setHideSidePanel = useSetRecoilState(store.hideSidePanel);
|
||||
const hideSidePanel = useRecoilValue(store.hideSidePanel);
|
||||
const { navVisible, setNavVisible } = useOutletContext<ContextType>();
|
||||
|
||||
// Get URL parameters (default to 'promoted' instead of 'all')
|
||||
const activeTab = category || 'promoted';
|
||||
const searchQuery = searchParams.get('q') || '';
|
||||
const selectedAgentId = searchParams.get('agent_id') || '';
|
||||
|
||||
// Local state
|
||||
const [isDetailOpen, setIsDetailOpen] = useState(false);
|
||||
const [selectedAgent, setSelectedAgent] = useState<t.Agent | null>(null);
|
||||
|
||||
// Set page title
|
||||
useDocumentTitle(`${localize('com_agents_marketplace')} | LibreChat`);
|
||||
|
||||
// Ensure right sidebar is always visible in marketplace
|
||||
useEffect(() => {
|
||||
setHideSidePanel(false);
|
||||
|
||||
// Also try to force expand via localStorage
|
||||
localStorage.setItem('hideSidePanel', 'false');
|
||||
localStorage.setItem('fullPanelCollapse', 'false');
|
||||
}, [setHideSidePanel, hideSidePanel]);
|
||||
|
||||
// Ensure endpoints config is loaded first (required for agent queries)
|
||||
useGetEndpointsQuery();
|
||||
|
||||
// Fetch categories using existing query pattern
|
||||
const categoriesQuery = useGetAgentCategoriesQuery({
|
||||
staleTime: 1000 * 60 * 15, // 15 minutes - categories rarely change
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
|
||||
/**
|
||||
* Handle agent card selection
|
||||
*
|
||||
* @param agent - The selected agent object
|
||||
*/
|
||||
const handleAgentSelect = (agent: t.Agent) => {
|
||||
// Update URL with selected agent
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set('agent_id', agent.id);
|
||||
setSearchParams(newParams);
|
||||
setSelectedAgent(agent);
|
||||
setIsDetailOpen(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle closing the agent detail dialog
|
||||
*/
|
||||
const handleDetailClose = () => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.delete('agent_id');
|
||||
setSearchParams(newParams);
|
||||
setSelectedAgent(null);
|
||||
setIsDetailOpen(false);
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle category tab selection changes
|
||||
*
|
||||
* @param tabValue - The selected category value
|
||||
*/
|
||||
const handleTabChange = (tabValue: string) => {
|
||||
const currentSearchParams = searchParams.toString();
|
||||
const searchParamsStr = currentSearchParams ? `?${currentSearchParams}` : '';
|
||||
|
||||
// Navigate to the selected category
|
||||
if (tabValue === 'promoted') {
|
||||
navigate(`/agents${searchParamsStr}`);
|
||||
} else {
|
||||
navigate(`/agents/${tabValue}${searchParamsStr}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle search query changes
|
||||
*
|
||||
* @param query - The search query string
|
||||
*/
|
||||
const handleSearch = (query: string) => {
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
if (query.trim()) {
|
||||
newParams.set('q', query.trim());
|
||||
// Switch to "all" category when starting a new search
|
||||
navigate(`/agents/all?${newParams.toString()}`);
|
||||
} else {
|
||||
newParams.delete('q');
|
||||
// Preserve current category when clearing search
|
||||
const currentCategory = activeTab;
|
||||
if (currentCategory === 'promoted') {
|
||||
navigate(`/agents${newParams.toString() ? `?${newParams.toString()}` : ''}`);
|
||||
} else {
|
||||
navigate(
|
||||
`/agents/${currentCategory}${newParams.toString() ? `?${newParams.toString()}` : ''}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle new chat button click
|
||||
*/
|
||||
const handleNewChat = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
if (e.button === 0 && (e.ctrlKey || e.metaKey)) {
|
||||
window.open('/c/new', '_blank');
|
||||
return;
|
||||
}
|
||||
navigate('/c/new');
|
||||
};
|
||||
|
||||
// Check if a detail view should be open based on URL
|
||||
useEffect(() => {
|
||||
setIsDetailOpen(!!selectedAgentId);
|
||||
}, [selectedAgentId]);
|
||||
|
||||
// Layout configuration for SidePanelGroup
|
||||
const defaultLayout = useMemo(() => {
|
||||
const resizableLayout = localStorage.getItem('react-resizable-panels:layout');
|
||||
return typeof resizableLayout === 'string' ? JSON.parse(resizableLayout) : undefined;
|
||||
}, []);
|
||||
|
||||
const defaultCollapsed = useMemo(() => {
|
||||
const collapsedPanels = localStorage.getItem('react-resizable-panels:collapsed');
|
||||
return typeof collapsedPanels === 'string' ? JSON.parse(collapsedPanels) : true;
|
||||
}, []);
|
||||
|
||||
const fullCollapse = useMemo(() => localStorage.getItem('fullPanelCollapse') === 'true', []);
|
||||
|
||||
const hasAccessToMarketplace = useHasAccess({
|
||||
permissionType: PermissionTypes.MARKETPLACE,
|
||||
permission: Permissions.USE,
|
||||
});
|
||||
useEffect(() => {
|
||||
let timeoutId: ReturnType<typeof setTimeout>;
|
||||
if (!hasAccessToMarketplace) {
|
||||
timeoutId = setTimeout(() => {
|
||||
navigate('/c/new');
|
||||
}, 1000);
|
||||
}
|
||||
return () => {
|
||||
clearTimeout(timeoutId);
|
||||
};
|
||||
}, [hasAccessToMarketplace, navigate]);
|
||||
|
||||
if (!hasAccessToMarketplace) {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<div className={`relative flex w-full grow overflow-hidden bg-presentation ${className}`}>
|
||||
<MarketplaceProvider>
|
||||
<SidePanelProvider>
|
||||
<SidePanelGroup
|
||||
defaultLayout={defaultLayout}
|
||||
fullPanelCollapse={fullCollapse}
|
||||
defaultCollapsed={defaultCollapsed}
|
||||
>
|
||||
<main className="flex h-full flex-col overflow-y-auto" role="main">
|
||||
{/* Simplified header for agents marketplace - only show nav controls when needed */}
|
||||
<div className="sticky top-0 z-10 flex h-14 w-full items-center justify-between bg-white p-2 font-semibold text-text-primary dark:bg-gray-800">
|
||||
<div className="mx-1 flex items-center gap-2">
|
||||
{!navVisible && <OpenSidebar setNavVisible={setNavVisible} />}
|
||||
{!navVisible && (
|
||||
<TooltipAnchor
|
||||
description={localize('com_ui_new_chat')}
|
||||
render={
|
||||
<Button
|
||||
size="icon"
|
||||
variant="outline"
|
||||
data-testid="agents-new-chat-button"
|
||||
aria-label={localize('com_ui_new_chat')}
|
||||
className="rounded-xl border border-border-light bg-surface-secondary p-2 hover:bg-surface-hover max-md:hidden"
|
||||
onClick={handleNewChat}
|
||||
>
|
||||
<NewChatIcon />
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="container mx-auto max-w-4xl px-4 py-8">
|
||||
{/* Hero Section - ChatGPT Style */}
|
||||
<div className="mb-8 mt-12 text-center">
|
||||
<h1 className="mb-3 text-5xl font-bold tracking-tight text-gray-900 dark:text-white">
|
||||
{localize('com_agents_marketplace')}
|
||||
</h1>
|
||||
<p className="mx-auto mb-6 max-w-2xl text-lg text-gray-600 dark:text-gray-300">
|
||||
{localize('com_agents_marketplace_subtitle')}
|
||||
</p>
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<SearchBar value={searchQuery} onSearch={handleSearch} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category tabs */}
|
||||
<CategoryTabs
|
||||
categories={categoriesQuery.data || []}
|
||||
activeTab={activeTab}
|
||||
isLoading={categoriesQuery.isLoading}
|
||||
onChange={handleTabChange}
|
||||
/>
|
||||
|
||||
{/* Category header - only show when not searching */}
|
||||
{!searchQuery && (
|
||||
<div className="mb-6">
|
||||
{(() => {
|
||||
// Get category data for display
|
||||
const getCategoryData = () => {
|
||||
if (activeTab === 'promoted') {
|
||||
return {
|
||||
name: localize('com_agents_top_picks'),
|
||||
description: localize('com_agents_recommended'),
|
||||
};
|
||||
}
|
||||
if (activeTab === 'all') {
|
||||
return {
|
||||
name: 'All Agents',
|
||||
description: 'Browse all shared agents across all categories',
|
||||
};
|
||||
}
|
||||
|
||||
// Find the category in the API data
|
||||
const categoryData = categoriesQuery.data?.find(
|
||||
(cat) => cat.value === activeTab,
|
||||
);
|
||||
if (categoryData) {
|
||||
return {
|
||||
name: categoryData.label,
|
||||
description: categoryData.description || '',
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback for unknown categories
|
||||
return {
|
||||
name: activeTab.charAt(0).toUpperCase() + activeTab.slice(1),
|
||||
description: '',
|
||||
};
|
||||
};
|
||||
|
||||
const { name, description } = getCategoryData();
|
||||
|
||||
return (
|
||||
<div className="text-left">
|
||||
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{name}
|
||||
</h2>
|
||||
{description && (
|
||||
<p className="mt-2 text-gray-600 dark:text-gray-300">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Agent grid */}
|
||||
<AgentGrid
|
||||
category={activeTab}
|
||||
searchQuery={searchQuery}
|
||||
onSelectAgent={handleAgentSelect}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Agent detail dialog */}
|
||||
{isDetailOpen && selectedAgent && (
|
||||
<AgentDetail
|
||||
agent={selectedAgent}
|
||||
isOpen={isDetailOpen}
|
||||
onClose={handleDetailClose}
|
||||
/>
|
||||
)}
|
||||
</main>
|
||||
</SidePanelGroup>
|
||||
</SidePanelProvider>
|
||||
</MarketplaceProvider>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AgentMarketplace;
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
Constants,
|
||||
SystemRoles,
|
||||
EModelEndpoint,
|
||||
PERMISSION_BITS,
|
||||
isAssistantsEndpoint,
|
||||
} from 'librechat-data-provider';
|
||||
import type { AgentForm, StringOption } from '~/common';
|
||||
@@ -15,10 +14,8 @@ import {
|
||||
useCreateAgentMutation,
|
||||
useUpdateAgentMutation,
|
||||
useGetAgentByIdQuery,
|
||||
useGetExpandedAgentByIdQuery,
|
||||
} from '~/data-provider';
|
||||
import { createProviderOption, getDefaultAgentFormValues } from '~/utils';
|
||||
import { useResourcePermissions } from '~/hooks/useResourcePermissions';
|
||||
import { useSelectAgent, useLocalize, useAuthContext } from '~/hooks';
|
||||
import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
|
||||
import AgentPanelSkeleton from './AgentPanelSkeleton';
|
||||
@@ -47,29 +44,10 @@ export default function AgentPanel() {
|
||||
const { onSelect: onSelectAgent } = useSelectAgent();
|
||||
|
||||
const modelsQuery = useGetModelsQuery();
|
||||
|
||||
// Basic agent query for initial permission check
|
||||
const basicAgentQuery = useGetAgentByIdQuery(current_agent_id ?? '', {
|
||||
const agentQuery = useGetAgentByIdQuery(current_agent_id ?? '', {
|
||||
enabled: !!(current_agent_id ?? '') && current_agent_id !== Constants.EPHEMERAL_AGENT_ID,
|
||||
});
|
||||
|
||||
const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions(
|
||||
'agent',
|
||||
basicAgentQuery.data?._id || '',
|
||||
);
|
||||
|
||||
const canEdit = hasPermission(PERMISSION_BITS.EDIT);
|
||||
|
||||
const expandedAgentQuery = useGetExpandedAgentByIdQuery(current_agent_id ?? '', {
|
||||
enabled:
|
||||
!!(current_agent_id ?? '') &&
|
||||
current_agent_id !== Constants.EPHEMERAL_AGENT_ID &&
|
||||
canEdit &&
|
||||
!permissionsLoading,
|
||||
});
|
||||
|
||||
const agentQuery = canEdit && expandedAgentQuery.data ? expandedAgentQuery : basicAgentQuery;
|
||||
|
||||
const models = useMemo(() => modelsQuery.data ?? {}, [modelsQuery.data]);
|
||||
const methods = useForm<AgentForm>({
|
||||
defaultValues: getDefaultAgentFormValues(),
|
||||
@@ -199,8 +177,6 @@ export default function AgentPanel() {
|
||||
end_after_tools,
|
||||
hide_sequential_outputs,
|
||||
recursion_limit,
|
||||
category,
|
||||
support_contact,
|
||||
} = data;
|
||||
|
||||
const model = _model ?? '';
|
||||
@@ -223,8 +199,6 @@ export default function AgentPanel() {
|
||||
end_after_tools,
|
||||
hide_sequential_outputs,
|
||||
recursion_limit,
|
||||
category,
|
||||
support_contact,
|
||||
},
|
||||
});
|
||||
return;
|
||||
@@ -236,12 +210,6 @@ export default function AgentPanel() {
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
if (!name) {
|
||||
return showToast({
|
||||
message: localize('com_agents_missing_name'),
|
||||
status: 'error',
|
||||
});
|
||||
}
|
||||
|
||||
create.mutate({
|
||||
name,
|
||||
@@ -256,8 +224,6 @@ export default function AgentPanel() {
|
||||
end_after_tools,
|
||||
hide_sequential_outputs,
|
||||
recursion_limit,
|
||||
category,
|
||||
support_contact,
|
||||
});
|
||||
},
|
||||
[agent_id, create, update, showToast, localize],
|
||||
@@ -270,16 +236,19 @@ export default function AgentPanel() {
|
||||
}, [agent_id, onSelectAgent]);
|
||||
|
||||
const canEditAgent = useMemo(() => {
|
||||
if (!agentQuery.data?.id) {
|
||||
return true;
|
||||
}
|
||||
const canEdit =
|
||||
(agentQuery.data?.isCollaborative ?? false)
|
||||
? true
|
||||
: agentQuery.data?.author === user?.id || user?.role === SystemRoles.ADMIN;
|
||||
|
||||
if (user?.role === SystemRoles.ADMIN) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return canEdit;
|
||||
}, [agentQuery.data?.id, user?.role, canEdit]);
|
||||
return agentQuery.data?.id != null && agentQuery.data.id ? canEdit : true;
|
||||
}, [
|
||||
agentQuery.data?.isCollaborative,
|
||||
agentQuery.data?.author,
|
||||
agentQuery.data?.id,
|
||||
user?.id,
|
||||
user?.role,
|
||||
]);
|
||||
|
||||
return (
|
||||
<FormProvider {...methods}>
|
||||
@@ -288,7 +257,7 @@ export default function AgentPanel() {
|
||||
className="scrollbar-gutter-stable h-auto w-full flex-shrink-0 overflow-x-hidden"
|
||||
aria-label="Agent configuration form"
|
||||
>
|
||||
<div className="mx-1 mt-2 flex w-full flex-wrap gap-2">
|
||||
<div className="mt-2 flex w-full flex-wrap gap-2">
|
||||
<div className="w-full">
|
||||
<AgentSelect
|
||||
createMutation={create}
|
||||
|
||||
@@ -6,11 +6,7 @@ import type { UseMutationResult, QueryObserverResult } from '@tanstack/react-que
|
||||
import type { Agent, AgentCreateParams } from 'librechat-data-provider';
|
||||
import type { TAgentCapabilities, AgentForm } from '~/common';
|
||||
import { cn, createProviderOption, processAgentOption, getDefaultAgentFormValues } from '~/utils';
|
||||
import {
|
||||
useListAgentsQuery,
|
||||
useGetStartupConfig,
|
||||
useAgentListingDefaultPermissionLevel,
|
||||
} from '~/data-provider';
|
||||
import { useListAgentsQuery, useGetStartupConfig } from '~/data-provider';
|
||||
import ControlCombobox from '~/components/ui/ControlCombobox';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
@@ -32,27 +28,24 @@ export default function AgentSelect({
|
||||
const { control, reset } = useFormContext();
|
||||
|
||||
const { data: startupConfig } = useGetStartupConfig();
|
||||
const permissionLevel = useAgentListingDefaultPermissionLevel();
|
||||
|
||||
const { data: agents = null } = useListAgentsQuery(
|
||||
{ requiredPermission: permissionLevel },
|
||||
{
|
||||
select: (res) =>
|
||||
res.data.map((agent) =>
|
||||
processAgentOption({
|
||||
agent: {
|
||||
...agent,
|
||||
name: agent.name || agent.id,
|
||||
},
|
||||
instanceProjectId: startupConfig?.instanceProjectId,
|
||||
}),
|
||||
),
|
||||
},
|
||||
);
|
||||
const { data: agents = null } = useListAgentsQuery(undefined, {
|
||||
select: (res) =>
|
||||
res.data.map((agent) =>
|
||||
processAgentOption({
|
||||
agent: {
|
||||
...agent,
|
||||
name: agent.name || agent.id,
|
||||
},
|
||||
instanceProjectId: startupConfig?.instanceProjectId,
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
const resetAgentForm = useCallback(
|
||||
(fullAgent: Agent) => {
|
||||
const isGlobal = fullAgent.isPublic ?? false;
|
||||
const { instanceProjectId } = startupConfig ?? {};
|
||||
const isGlobal =
|
||||
(instanceProjectId != null && fullAgent.projectIds?.includes(instanceProjectId)) ?? false;
|
||||
const update = {
|
||||
...fullAgent,
|
||||
provider: createProviderOption(fullAgent.provider),
|
||||
@@ -84,10 +77,6 @@ export default function AgentSelect({
|
||||
agent: update,
|
||||
model: update.model,
|
||||
tools: agentTools,
|
||||
// Ensure the category is properly set for the form
|
||||
category: fullAgent.category || 'general',
|
||||
// Make sure support_contact is properly loaded
|
||||
support_contact: fullAgent.support_contact,
|
||||
};
|
||||
|
||||
Object.entries(fullAgent).forEach(([name, value]) => {
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import type t from 'librechat-data-provider';
|
||||
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import { SmartLoader } from './SmartLoader';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
/**
|
||||
* Props for the CategoryTabs component
|
||||
*/
|
||||
interface CategoryTabsProps {
|
||||
/** Array of agent categories to display as tabs */
|
||||
categories: t.TMarketplaceCategory[];
|
||||
/** Currently selected tab value */
|
||||
activeTab: string;
|
||||
/** Whether categories are currently loading */
|
||||
isLoading: boolean;
|
||||
/** Callback fired when a tab is selected */
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* CategoryTabs - Component for displaying category tabs with counts
|
||||
*
|
||||
* Renders a tabbed navigation interface showing agent categories.
|
||||
* Includes loading states, empty state handling, and displays counts for each category.
|
||||
* Uses database-driven category labels with no hardcoded values.
|
||||
*/
|
||||
const CategoryTabs: React.FC<CategoryTabsProps> = ({
|
||||
categories,
|
||||
activeTab,
|
||||
isLoading,
|
||||
onChange,
|
||||
}) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
// Helper function to get category display name from database data
|
||||
const getCategoryDisplayName = (category: t.TCategory) => {
|
||||
// Special cases for system categories
|
||||
if (category.value === 'promoted') {
|
||||
return localize('com_agents_top_picks');
|
||||
}
|
||||
if (category.value === 'all') {
|
||||
return 'All';
|
||||
}
|
||||
// Use database label or fallback to capitalized value
|
||||
return category.label || category.value.charAt(0).toUpperCase() + category.value.slice(1);
|
||||
};
|
||||
|
||||
// Loading skeleton component
|
||||
const loadingSkeleton = (
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-center">
|
||||
<div className="flex flex-wrap items-center justify-center gap-6">
|
||||
{[...Array(6)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-6 min-w-[60px] animate-pulse rounded bg-gray-200 dark:bg-gray-700"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Handle keyboard navigation between tabs
|
||||
const handleKeyDown = (e: React.KeyboardEvent, currentCategory: string) => {
|
||||
const currentIndex = categories.findIndex((cat) => cat.value === currentCategory);
|
||||
let newIndex = currentIndex;
|
||||
|
||||
switch (e.key) {
|
||||
case 'ArrowLeft':
|
||||
case 'ArrowUp':
|
||||
e.preventDefault();
|
||||
newIndex = currentIndex > 0 ? currentIndex - 1 : categories.length - 1;
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
case 'ArrowDown':
|
||||
e.preventDefault();
|
||||
newIndex = currentIndex < categories.length - 1 ? currentIndex + 1 : 0;
|
||||
break;
|
||||
case 'Home':
|
||||
e.preventDefault();
|
||||
newIndex = 0;
|
||||
break;
|
||||
case 'End':
|
||||
e.preventDefault();
|
||||
newIndex = categories.length - 1;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
const newCategory = categories[newIndex];
|
||||
if (newCategory) {
|
||||
onChange(newCategory.value);
|
||||
// Focus the new tab
|
||||
setTimeout(() => {
|
||||
const newTab = document.getElementById(`category-tab-${newCategory.value}`);
|
||||
newTab?.focus();
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
// Early return if no categories available
|
||||
if (!isLoading && (!categories || categories.length === 0)) {
|
||||
return (
|
||||
<div className="mb-8 text-center text-gray-500">{localize('com_agents_no_categories')}</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Main tabs content
|
||||
const tabsContent = (
|
||||
<div className="mb-8">
|
||||
<div className="flex justify-center">
|
||||
{/* Accessible tab navigation with proper ARIA attributes */}
|
||||
<div
|
||||
className="flex flex-wrap items-center justify-center gap-6"
|
||||
role="tablist"
|
||||
aria-label={localize('com_agents_category_tabs_label')}
|
||||
aria-orientation="horizontal"
|
||||
>
|
||||
{categories.map((category, index) => (
|
||||
<button
|
||||
key={category.value}
|
||||
id={`category-tab-${category.value}`}
|
||||
onClick={() => onChange(category.value)}
|
||||
onKeyDown={(e) => handleKeyDown(e, category.value)}
|
||||
className={cn(
|
||||
'relative px-4 py-2 text-sm font-medium transition-colors duration-200',
|
||||
'focus:bg-gray-100 focus:outline-none dark:focus:bg-gray-800',
|
||||
'hover:text-gray-900 dark:hover:text-white',
|
||||
activeTab === category.value
|
||||
? 'text-gray-900 dark:text-white'
|
||||
: 'text-gray-600 dark:text-gray-400',
|
||||
)}
|
||||
role="tab"
|
||||
aria-selected={activeTab === category.value}
|
||||
aria-controls={`tabpanel-${category.value}`}
|
||||
tabIndex={activeTab === category.value ? 0 : -1}
|
||||
aria-label={`${getCategoryDisplayName(category)} tab (${index + 1} of ${categories.length})`}
|
||||
>
|
||||
<span className="truncate">{getCategoryDisplayName(category)}</span>
|
||||
{/* Underline for active tab */}
|
||||
{activeTab === category.value && (
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 h-0.5 rounded-full bg-gray-900 dark:bg-white"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Use SmartLoader to prevent category loading flashes
|
||||
return (
|
||||
<SmartLoader
|
||||
isLoading={isLoading}
|
||||
hasData={categories?.length > 0}
|
||||
delay={100} // Very short delay since categories should load quickly
|
||||
loadingComponent={loadingSkeleton}
|
||||
>
|
||||
{tabsContent}
|
||||
</SmartLoader>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoryTabs;
|
||||
@@ -1,252 +0,0 @@
|
||||
import React from 'react';
|
||||
|
||||
import { useLocalize } from '~/hooks';
|
||||
import { Button } from '~/components/ui';
|
||||
import { cn } from '~/utils';
|
||||
|
||||
// Comprehensive error type that handles all possible error structures
|
||||
type ApiError =
|
||||
| string
|
||||
| Error
|
||||
| {
|
||||
message?: string;
|
||||
status?: number;
|
||||
code?: string;
|
||||
response?: {
|
||||
data?: {
|
||||
userMessage?: string;
|
||||
suggestion?: string;
|
||||
message?: string;
|
||||
};
|
||||
status?: number;
|
||||
};
|
||||
data?: {
|
||||
userMessage?: string;
|
||||
suggestion?: string;
|
||||
message?: string;
|
||||
};
|
||||
};
|
||||
|
||||
interface ErrorDisplayProps {
|
||||
error: ApiError;
|
||||
onRetry?: () => void;
|
||||
context?: {
|
||||
searchQuery?: string;
|
||||
category?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* User-friendly error display component with actionable suggestions
|
||||
*/
|
||||
export const ErrorDisplay: React.FC<ErrorDisplayProps> = ({ error, onRetry, context }) => {
|
||||
const localize = useLocalize();
|
||||
|
||||
// Type guards
|
||||
const isErrorObject = (err: ApiError): err is { [key: string]: unknown } => {
|
||||
return typeof err === 'object' && err !== null && !(err instanceof Error);
|
||||
};
|
||||
|
||||
const isErrorInstance = (err: ApiError): err is Error => {
|
||||
return err instanceof Error;
|
||||
};
|
||||
|
||||
// Extract user-friendly error information
|
||||
const getErrorInfo = (): { title: string; message: string; suggestion: string } => {
|
||||
// Handle different error types
|
||||
let errorData: unknown;
|
||||
|
||||
if (typeof error === 'string') {
|
||||
errorData = { message: error };
|
||||
} else if (isErrorInstance(error)) {
|
||||
errorData = { message: error.message };
|
||||
} else if (isErrorObject(error)) {
|
||||
// Handle axios error response structure
|
||||
errorData = (error as any)?.response?.data || (error as any)?.data || error;
|
||||
} else {
|
||||
errorData = error;
|
||||
}
|
||||
|
||||
// Handle network errors first
|
||||
let errorMessage = '';
|
||||
if (isErrorInstance(error)) {
|
||||
errorMessage = error.message;
|
||||
} else if (isErrorObject(error) && (error as any)?.message) {
|
||||
errorMessage = (error as any).message;
|
||||
}
|
||||
|
||||
const errorCode = isErrorObject(error) ? (error as any)?.code : '';
|
||||
|
||||
// Handle timeout errors specifically
|
||||
if (errorCode === 'ECONNABORTED' || errorMessage?.includes('timeout')) {
|
||||
return {
|
||||
title: localize('com_agents_error_timeout_title'),
|
||||
message: localize('com_agents_error_timeout_message'),
|
||||
suggestion: localize('com_agents_error_timeout_suggestion'),
|
||||
};
|
||||
}
|
||||
|
||||
if (errorCode === 'NETWORK_ERROR' || errorMessage?.includes('Network Error')) {
|
||||
return {
|
||||
title: localize('com_agents_error_network_title'),
|
||||
message: localize('com_agents_error_network_message'),
|
||||
suggestion: localize('com_agents_error_network_suggestion'),
|
||||
};
|
||||
}
|
||||
|
||||
// Handle specific HTTP status codes before generic userMessage
|
||||
const status = isErrorObject(error) ? (error as any)?.response?.status : null;
|
||||
if (status) {
|
||||
if (status === 404) {
|
||||
return {
|
||||
title: localize('com_agents_error_not_found_title'),
|
||||
message: getNotFoundMessage(),
|
||||
suggestion: localize('com_agents_error_not_found_suggestion'),
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 400) {
|
||||
return {
|
||||
title: localize('com_agents_error_invalid_request'),
|
||||
message:
|
||||
(errorData as any)?.userMessage || localize('com_agents_error_bad_request_message'),
|
||||
suggestion:
|
||||
(errorData as any)?.suggestion || localize('com_agents_error_bad_request_suggestion'),
|
||||
};
|
||||
}
|
||||
|
||||
if (status >= 500) {
|
||||
return {
|
||||
title: localize('com_agents_error_server_title'),
|
||||
message: localize('com_agents_error_server_message'),
|
||||
suggestion: localize('com_agents_error_server_suggestion'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Use user-friendly message from backend if available (after specific status code handling)
|
||||
if (errorData && typeof errorData === 'object' && (errorData as any)?.userMessage) {
|
||||
return {
|
||||
title: getContextualTitle(),
|
||||
message: (errorData as any).userMessage,
|
||||
suggestion:
|
||||
(errorData as any).suggestion || localize('com_agents_error_suggestion_generic'),
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback to generic error with contextual title
|
||||
return {
|
||||
title: getContextualTitle(),
|
||||
message: localize('com_agents_error_generic'),
|
||||
suggestion: localize('com_agents_error_suggestion_generic'),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Get contextual title based on current operation
|
||||
*/
|
||||
const getContextualTitle = (): string => {
|
||||
if (context?.searchQuery) {
|
||||
return localize('com_agents_error_search_title');
|
||||
}
|
||||
|
||||
if (context?.category) {
|
||||
return localize('com_agents_error_category_title');
|
||||
}
|
||||
|
||||
return localize('com_agents_error_title');
|
||||
};
|
||||
|
||||
/**
|
||||
* Get context-specific not found message
|
||||
*/
|
||||
const getNotFoundMessage = (): string => {
|
||||
if (context?.searchQuery) {
|
||||
return localize('com_agents_search_no_results', { query: context.searchQuery });
|
||||
}
|
||||
|
||||
if (context?.category && context.category !== 'all') {
|
||||
return localize('com_agents_category_empty', { category: context.category });
|
||||
}
|
||||
|
||||
return localize('com_agents_error_not_found_message');
|
||||
};
|
||||
|
||||
const { title, message, suggestion } = getErrorInfo();
|
||||
|
||||
return (
|
||||
<div className="py-12 text-center" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div className="mx-auto max-w-md space-y-4">
|
||||
{/* Error icon with proper accessibility */}
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
className={cn(
|
||||
'flex h-12 w-12 items-center justify-center rounded-full',
|
||||
'bg-red-100 dark:bg-red-900/20',
|
||||
)}
|
||||
>
|
||||
<svg
|
||||
className="h-6 w-6 text-red-600 dark:text-red-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={2}
|
||||
stroke="currentColor"
|
||||
aria-hidden="true"
|
||||
role="img"
|
||||
aria-label="Error icon"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error content with proper headings and structure */}
|
||||
<div className="space-y-3">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white" id="error-title">
|
||||
{title}
|
||||
</h3>
|
||||
<p
|
||||
className="text-gray-600 dark:text-gray-400"
|
||||
id="error-message"
|
||||
aria-describedby="error-title"
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
<p
|
||||
className="text-sm text-gray-500 dark:text-gray-500"
|
||||
id="error-suggestion"
|
||||
role="note"
|
||||
aria-label={`Suggestion: ${suggestion}`}
|
||||
>
|
||||
💡 {suggestion}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Retry button with enhanced accessibility */}
|
||||
{onRetry && (
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
onClick={onRetry}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn(
|
||||
'border-red-300 text-red-700 hover:bg-red-50 focus:ring-2 focus:ring-red-500',
|
||||
'dark:border-red-600 dark:text-red-400 dark:hover:bg-red-900/20 dark:focus:ring-red-400',
|
||||
)}
|
||||
aria-describedby="error-message error-suggestion"
|
||||
aria-label={`Retry action. ${message}`}
|
||||
>
|
||||
{localize('com_agents_error_retry')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ErrorDisplay;
|
||||
@@ -19,7 +19,7 @@ export default function ImageVision() {
|
||||
{...field}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
|
||||
className="relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer"
|
||||
value={field.value?.toString()}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
import { EModelEndpoint } from 'librechat-data-provider';
|
||||
|
||||
import { ChatContext } from '~/Providers';
|
||||
|
||||
/**
|
||||
* Minimal marketplace provider that provides only what SidePanel actually needs
|
||||
* Replaces the bloated 44-function ChatContext implementation
|
||||
*/
|
||||
interface MarketplaceProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export const MarketplaceProvider: React.FC<MarketplaceProviderProps> = ({ children }) => {
|
||||
// Create more complete context to prevent FileRow and other component errors
|
||||
// when agents with files are opened in the marketplace
|
||||
const marketplaceContext = useMemo(
|
||||
() => ({
|
||||
conversation: {
|
||||
endpoint: EModelEndpoint.agents,
|
||||
conversationId: 'marketplace',
|
||||
title: 'Agent Marketplace',
|
||||
},
|
||||
// File-related context properties to prevent FileRow errors
|
||||
files: new Map(),
|
||||
setFiles: () => {},
|
||||
setFilesLoading: () => {},
|
||||
// Other commonly used context properties to prevent undefined errors
|
||||
isSubmitting: false,
|
||||
setIsSubmitting: () => {},
|
||||
latestMessage: null,
|
||||
setLatestMessage: () => {},
|
||||
// Minimal functions to prevent errors when components try to use them
|
||||
ask: () => {},
|
||||
regenerate: () => {},
|
||||
stopGenerating: () => {},
|
||||
submitMessage: () => {},
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return <ChatContext.Provider value={marketplaceContext as any}>{children}</ChatContext.Provider>;
|
||||
};
|
||||
@@ -1,111 +0,0 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Search, X } from 'lucide-react';
|
||||
|
||||
import useLocalize from '~/hooks/useLocalize';
|
||||
import { useDebounce } from '~/hooks';
|
||||
import { Input } from '~/components/ui';
|
||||
|
||||
/**
|
||||
* Props for the SearchBar component
|
||||
*/
|
||||
interface SearchBarProps {
|
||||
/** Current search query value */
|
||||
value: string;
|
||||
/** Callback fired when the search query changes */
|
||||
onSearch: (query: string) => void;
|
||||
/** Additional CSS classes */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* SearchBar - Component for searching agents with debounced input
|
||||
*
|
||||
* Provides a search input with clear button and debounced search functionality.
|
||||
* Includes proper ARIA attributes for accessibility and visual indicators.
|
||||
* Uses 300ms debounce delay to prevent excessive API calls during typing.
|
||||
*/
|
||||
const SearchBar: React.FC<SearchBarProps> = ({ value, onSearch, className = '' }) => {
|
||||
const localize = useLocalize();
|
||||
const [searchTerm, setSearchTerm] = useState(value);
|
||||
|
||||
// Debounced search value (300ms delay)
|
||||
const debouncedSearchTerm = useDebounce(searchTerm, 300);
|
||||
|
||||
// Update internal state when props change
|
||||
useEffect(() => {
|
||||
setSearchTerm(value);
|
||||
}, [value]);
|
||||
|
||||
// Trigger search when debounced value changes
|
||||
useEffect(() => {
|
||||
// Only trigger search if the debounced value matches current searchTerm
|
||||
// This prevents stale debounced values from triggering after clear
|
||||
if (debouncedSearchTerm !== value && debouncedSearchTerm === searchTerm) {
|
||||
onSearch(debouncedSearchTerm);
|
||||
}
|
||||
}, [debouncedSearchTerm, onSearch, value, searchTerm]);
|
||||
|
||||
/**
|
||||
* Handle search input changes
|
||||
*
|
||||
* @param e - Input change event
|
||||
*/
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchTerm(e.target.value);
|
||||
};
|
||||
|
||||
/**
|
||||
* Clear the search input and reset results
|
||||
*/
|
||||
const handleClear = useCallback(() => {
|
||||
// Immediately call parent onSearch to clear the URL parameter
|
||||
onSearch('');
|
||||
// Also clear local state
|
||||
setSearchTerm('');
|
||||
}, [onSearch]);
|
||||
|
||||
return (
|
||||
<div className={`relative w-full max-w-4xl ${className}`} role="search">
|
||||
<label htmlFor="agent-search" className="sr-only">
|
||||
{localize('com_agents_search_instructions')}
|
||||
</label>
|
||||
<Input
|
||||
id="agent-search"
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={handleChange}
|
||||
placeholder={localize('com_agents_search_placeholder')}
|
||||
className="h-14 rounded-2xl border-2 border-gray-200 bg-white pl-12 pr-12 text-lg text-gray-900 shadow-lg placeholder:text-gray-500 focus:border-gray-300 focus:ring-0 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder:text-gray-400 dark:focus:border-gray-500"
|
||||
aria-label={localize('com_agents_search_aria')}
|
||||
aria-describedby="search-instructions search-results-count"
|
||||
autoComplete="off"
|
||||
spellCheck="false"
|
||||
/>
|
||||
|
||||
{/* Search icon with proper accessibility */}
|
||||
<div className="absolute inset-y-0 left-0 flex items-center pl-4" aria-hidden="true">
|
||||
<Search className="h-6 w-6 text-gray-400" />
|
||||
</div>
|
||||
|
||||
{/* Hidden instructions for screen readers */}
|
||||
<div id="search-instructions" className="sr-only">
|
||||
{localize('com_agents_search_instructions')}
|
||||
</div>
|
||||
|
||||
{/* Show clear button only when search has value - Google style */}
|
||||
{searchTerm && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="group absolute right-3 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center rounded-full bg-gray-400 transition-colors duration-150 hover:bg-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:bg-gray-500 dark:hover:bg-gray-400"
|
||||
aria-label={localize('com_agents_clear_search')}
|
||||
title={localize('com_agents_clear_search')}
|
||||
>
|
||||
<X className="h-3 w-3 text-white group-hover:text-white" strokeWidth={2.5} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchBar;
|
||||
270
client/src/components/SidePanel/Agents/ShareAgent.tsx
Normal file
270
client/src/components/SidePanel/Agents/ShareAgent.tsx
Normal file
@@ -0,0 +1,270 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { Share2Icon } from 'lucide-react';
|
||||
import { useForm, Controller } from 'react-hook-form';
|
||||
import { Permissions } from 'librechat-data-provider';
|
||||
import type { TStartupConfig, AgentUpdateParams } from 'librechat-data-provider';
|
||||
import {
|
||||
Button,
|
||||
Switch,
|
||||
OGDialog,
|
||||
OGDialogTitle,
|
||||
OGDialogClose,
|
||||
OGDialogContent,
|
||||
OGDialogTrigger,
|
||||
} from '~/components/ui';
|
||||
import { useUpdateAgentMutation, useGetStartupConfig } from '~/data-provider';
|
||||
import { cn, removeFocusOutlines } from '~/utils';
|
||||
import { useToastContext } from '~/Providers';
|
||||
import { useLocalize } from '~/hooks';
|
||||
|
||||
type FormValues = {
|
||||
[Permissions.SHARED_GLOBAL]: boolean;
|
||||
[Permissions.UPDATE]: boolean;
|
||||
};
|
||||
|
||||
export default function ShareAgent({
|
||||
agent_id = '',
|
||||
agentName,
|
||||
projectIds = [],
|
||||
isCollaborative = false,
|
||||
}: {
|
||||
agent_id?: string;
|
||||
agentName?: string;
|
||||
projectIds?: string[];
|
||||
isCollaborative?: boolean;
|
||||
}) {
|
||||
const localize = useLocalize();
|
||||
const { showToast } = useToastContext();
|
||||
const { data: startupConfig = {} as TStartupConfig, isFetching } = useGetStartupConfig();
|
||||
const { instanceProjectId } = startupConfig;
|
||||
const agentIsGlobal = useMemo(
|
||||
() => !!projectIds.includes(instanceProjectId),
|
||||
[projectIds, instanceProjectId],
|
||||
);
|
||||
|
||||
const {
|
||||
watch,
|
||||
control,
|
||||
setValue,
|
||||
getValues,
|
||||
handleSubmit,
|
||||
formState: { isSubmitting },
|
||||
} = useForm<FormValues>({
|
||||
mode: 'onChange',
|
||||
defaultValues: {
|
||||
[Permissions.SHARED_GLOBAL]: agentIsGlobal,
|
||||
[Permissions.UPDATE]: isCollaborative,
|
||||
},
|
||||
});
|
||||
|
||||
const sharedGlobalValue = watch(Permissions.SHARED_GLOBAL);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sharedGlobalValue) {
|
||||
setValue(Permissions.UPDATE, false);
|
||||
}
|
||||
}, [sharedGlobalValue, setValue]);
|
||||
|
||||
useEffect(() => {
|
||||
setValue(Permissions.SHARED_GLOBAL, agentIsGlobal);
|
||||
setValue(Permissions.UPDATE, isCollaborative);
|
||||
}, [agentIsGlobal, isCollaborative, setValue]);
|
||||
|
||||
const updateAgent = useUpdateAgentMutation({
|
||||
onSuccess: (data) => {
|
||||
showToast({
|
||||
message: `${localize('com_assistants_update_success')} ${
|
||||
data.name ?? localize('com_ui_agent')
|
||||
}`,
|
||||
status: 'success',
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
const error = err as Error;
|
||||
showToast({
|
||||
message: `${localize('com_agents_update_error')}${
|
||||
error.message ? ` ${localize('com_ui_error')}: ${error.message}` : ''
|
||||
}`,
|
||||
status: 'error',
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
if (!agent_id || !instanceProjectId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onSubmit = (data: FormValues) => {
|
||||
if (!agent_id || !instanceProjectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {} as AgentUpdateParams;
|
||||
|
||||
if (data[Permissions.UPDATE] !== isCollaborative) {
|
||||
payload.isCollaborative = data[Permissions.UPDATE];
|
||||
}
|
||||
|
||||
if (data[Permissions.SHARED_GLOBAL] !== agentIsGlobal) {
|
||||
if (data[Permissions.SHARED_GLOBAL]) {
|
||||
payload.projectIds = [startupConfig.instanceProjectId];
|
||||
} else {
|
||||
payload.removeProjectIds = [startupConfig.instanceProjectId];
|
||||
payload.isCollaborative = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(payload).length > 0) {
|
||||
updateAgent.mutate({
|
||||
agent_id,
|
||||
data: payload,
|
||||
});
|
||||
} else {
|
||||
showToast({
|
||||
message: localize('com_ui_no_changes'),
|
||||
status: 'info',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<OGDialog>
|
||||
<OGDialogTrigger asChild>
|
||||
<button
|
||||
className={cn(
|
||||
'btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium',
|
||||
removeFocusOutlines,
|
||||
)}
|
||||
aria-label={localize('com_ui_share_var', {
|
||||
0: agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent'),
|
||||
})}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2 text-blue-500">
|
||||
<Share2Icon className="icon-md h-4 w-4" />
|
||||
</div>
|
||||
</button>
|
||||
</OGDialogTrigger>
|
||||
<OGDialogContent className="w-11/12 md:max-w-xl">
|
||||
<OGDialogTitle>
|
||||
{localize('com_ui_share_var', {
|
||||
0: agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent'),
|
||||
})}
|
||||
</OGDialogTitle>
|
||||
<form
|
||||
className="p-2"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleSubmit(onSubmit)(e);
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2 py-2">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="mr-2 cursor-pointer"
|
||||
disabled={isFetching || updateAgent.isLoading || !instanceProjectId}
|
||||
onClick={() =>
|
||||
setValue(Permissions.SHARED_GLOBAL, !getValues(Permissions.SHARED_GLOBAL), {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setValue(Permissions.SHARED_GLOBAL, !getValues(Permissions.SHARED_GLOBAL), {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
aria-checked={getValues(Permissions.SHARED_GLOBAL)}
|
||||
role="checkbox"
|
||||
>
|
||||
{localize('com_ui_share_to_all_users')}
|
||||
</button>
|
||||
<label htmlFor={Permissions.SHARED_GLOBAL} className="select-none">
|
||||
{agentIsGlobal && (
|
||||
<span className="ml-2 text-xs">{localize('com_ui_agent_shared_to_all')}</span>
|
||||
)}
|
||||
</label>
|
||||
</div>
|
||||
<Controller
|
||||
name={Permissions.SHARED_GLOBAL}
|
||||
control={control}
|
||||
disabled={isFetching || updateAgent.isLoading || !instanceProjectId}
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
value={field.value.toString()}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4 flex items-center justify-between gap-2 py-2">
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="mr-2 cursor-pointer"
|
||||
disabled={
|
||||
isFetching || updateAgent.isLoading || !instanceProjectId || !sharedGlobalValue
|
||||
}
|
||||
onClick={() =>
|
||||
setValue(Permissions.UPDATE, !getValues(Permissions.UPDATE), {
|
||||
shouldDirty: true,
|
||||
})
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setValue(Permissions.UPDATE, !getValues(Permissions.UPDATE), {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}
|
||||
}}
|
||||
aria-checked={getValues(Permissions.UPDATE)}
|
||||
role="checkbox"
|
||||
>
|
||||
{localize('com_agents_allow_editing')}
|
||||
</button>
|
||||
{/* <label htmlFor={Permissions.UPDATE} className="select-none">
|
||||
{agentIsGlobal && (
|
||||
<span className="ml-2 text-xs">{localize('com_ui_agent_editing_allowed')}</span>
|
||||
)}
|
||||
</label> */}
|
||||
</div>
|
||||
<Controller
|
||||
name={Permissions.UPDATE}
|
||||
control={control}
|
||||
disabled={
|
||||
isFetching || updateAgent.isLoading || !instanceProjectId || !sharedGlobalValue
|
||||
}
|
||||
render={({ field }) => (
|
||||
<Switch
|
||||
{...field}
|
||||
checked={field.value}
|
||||
onCheckedChange={field.onChange}
|
||||
value={field.value.toString()}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<OGDialogClose asChild>
|
||||
<Button
|
||||
variant="submit"
|
||||
size="sm"
|
||||
type="submit"
|
||||
disabled={isSubmitting || isFetching}
|
||||
>
|
||||
{localize('com_ui_save')}
|
||||
</Button>
|
||||
</OGDialogClose>
|
||||
</div>
|
||||
</form>
|
||||
</OGDialogContent>
|
||||
</OGDialog>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user