🧩 fix: Preserve Deployment Skill IDs on Agents (#14368)

* fix: preserve deployment skills on agents

* fix: expose deployment skills to agent viewers

* refactor: centralize deployment skill ID merging

---------

Co-authored-by: Dennis Schenk <dennis@gridonic.ch>
This commit is contained in:
Danny Avila 2026-07-21 19:44:27 -04:00 committed by GitHub
parent ade02054c8
commit 3e9f07976a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 133 additions and 8 deletions

View file

@ -8,6 +8,7 @@ const {
agentUpdateSchema,
refreshListAvatars,
collectEdgeAgentIds,
mergeDeploymentSkillIds,
mergeAgentOcrConversion,
sanitizeModelParameters,
MAX_AVATAR_REFRESH_AGENTS,
@ -1098,7 +1099,9 @@ const getListAgentsHandler = async (req, res) => {
resourceType: ResourceType.SKILL,
requiredPermissions: PermissionBits.VIEW,
});
accessibleSkillSet = new Set(accessibleSkillIds.map((oid) => oid.toString()));
accessibleSkillSet = new Set(
mergeDeploymentSkillIds(accessibleSkillIds).map((oid) => oid.toString()),
);
}
const publicSet = new Set(publiclyAccessibleIds.map((oid) => oid.toString()));

View file

@ -38,6 +38,7 @@ jest.mock('sharp', () =>
jest.mock('@librechat/api', () => ({
...jest.requireActual('@librechat/api'),
mergeDeploymentSkillIds: jest.fn((ids) => ids),
refreshS3Url: jest.fn(),
}));
@ -92,7 +93,7 @@ const {
getResourcePermissionsMap,
} = require('~/server/services/PermissionService');
const { refreshS3Url } = require('@librechat/api');
const { mergeDeploymentSkillIds, refreshS3Url } = require('@librechat/api');
/**
* @type {import('mongoose').Model<import('@librechat/data-schemas').IAgent>}
@ -155,6 +156,7 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
// Reset all mocks
jest.clearAllMocks();
mergeDeploymentSkillIds.mockImplementation((ids) => ids);
// Setup mock request and response objects
mockReq = {
@ -1850,6 +1852,32 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(response.data[0].skills_enabled).toBeUndefined();
});
test('should preserve deployment skill scope for VIEW list callers', async () => {
const deploymentSkillId = new mongoose.Types.ObjectId();
await Agent.findByIdAndUpdate(agentA1._id, {
skills_enabled: true,
skills: [deploymentSkillId.toString()],
});
mockReq.user.id = userB.toString();
mockReq.query.requiredPermission = String(PermissionBits.VIEW);
findAccessibleResources.mockImplementation(({ resourceType }) => {
if (resourceType === ResourceType.AGENT) {
return Promise.resolve([agentA1._id]);
}
return Promise.resolve([]);
});
findPubliclyAccessibleResources.mockResolvedValue([]);
mergeDeploymentSkillIds.mockImplementation((ids) => [...ids, deploymentSkillId]);
await getListAgentsHandler(mockReq, mockRes);
const response = mockRes.json.mock.calls[0][0];
expect(response.data).toHaveLength(1);
expect(response.data[0].skills_enabled).toBe(true);
expect(response.data[0].skills).toEqual([deploymentSkillId.toString()]);
});
test('should preserve enabled skill scope for VIEW list callers with an empty allowlist', async () => {
await Agent.findByIdAndUpdate(agentA1._id, {
skills_enabled: true,