🗂️ feat: Add Deployment Skill Directory (#13523)
Some checks are pending
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Waiting to run
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Waiting to run
GitNexus Index / index (push) Waiting to run
GitNexus Index / post-index (push) Blocked by required conditions

* feat: Add deployment skill directory

* chore: Address deployment skill review feedback

* fix: Include deployment skill file metadata

* test: Add deployment skills e2e smoke test
This commit is contained in:
Danny Avila 2026-06-05 10:24:28 -04:00 • committed by GitHub
parent 6357ea10c1
commit 2c8d54e18c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1836 additions and 69 deletions

View file

@ -5,6 +5,8 @@ const {
toSkillStatesRecord,
validateSkillStatesPayload,
pruneOrphanSkillStates,
getDeploymentSkillIds,
mergeDeploymentSkillIds,
} = require('@librechat/api');
const { ResourceType, PermissionBits } = require('librechat-data-provider');
const { findAccessibleResources } = require('~/server/services/PermissionService');
@ -21,15 +23,20 @@ function buildPruneDeps(user) {
const existing = await Skill.find({ _id: { $in: validIds } })
.select('_id')
.lean();
return existing.map((doc) => doc._id.toString());
const deploymentIds = getDeploymentSkillIds()
.map((id) => id.toString())
.filter((id) => validIds.includes(id));
return [...existing.map((doc) => doc._id.toString()), ...deploymentIds];
},
findAccessibleSkillIds: () =>
findAccessibleResources({
userId: user.id,
role: user.role,
resourceType: ResourceType.SKILL,
requiredPermissions: PermissionBits.VIEW,
}),
findAccessibleSkillIds: async () =>
mergeDeploymentSkillIds(
await findAccessibleResources({
userId: user.id,
role: user.role,
resourceType: ResourceType.SKILL,
requiredPermissions: PermissionBits.VIEW,
}),
),
};
}

View file

@ -150,7 +150,9 @@ jest.mock('~/server/services/Files/permissions', () => ({
jest.mock('~/server/services/Endpoints/agents/skillDeps', () => ({
getSkillToolDeps: mockGetSkillToolDeps,
getSkillDbMethods: jest.fn(() => ({})),
canAuthorSkillFiles: mockCanAuthorSkillFiles,
withDeploymentSkillIds: jest.fn((ids = []) => ids),
enrichWithSkillConfigurable: mockEnrichWithSkillConfigurable,
buildSkillPrimedIdsByName: mockBuildSkillPrimedIdsByName,
buildAgentToolContext: mockBuildAgentToolContext,

View file

@ -200,7 +200,9 @@ jest.mock('~/server/services/Files/permissions', () => ({
jest.mock('~/server/services/Endpoints/agents/skillDeps', () => ({
getSkillToolDeps: mockGetSkillToolDeps,
getSkillDbMethods: jest.fn(() => ({})),
canAuthorSkillFiles: mockCanAuthorSkillFiles,
withDeploymentSkillIds: jest.fn((ids = []) => ids),
enrichWithSkillConfigurable: mockEnrichWithSkillConfigurable,
buildSkillPrimedIdsByName: mockBuildSkillPrimedIdsByName,
buildAgentToolContext: mockBuildAgentToolContext,

View file

@ -47,7 +47,9 @@ const {
} = require('~/server/services/PermissionService');
const {
getSkillToolDeps,
getSkillDbMethods,
canAuthorSkillFiles,
withDeploymentSkillIds,
buildAgentToolContext,
enrichLoadedToolsWithAgentContext,
} = require('~/server/services/Endpoints/agents/skillDeps');
@ -229,6 +231,7 @@ const OpenAIChatCompletionController = async (req, res) => {
endpoint: agent.provider,
model_parameters: agent.model_parameters ?? {},
};
const skillDbMethods = getSkillDbMethods();
// `filterFilesByAgentAccess` is intentionally omitted: it calls
// `checkPermission` with `resourceType: AGENT`, but this route
@ -246,21 +249,23 @@ const OpenAIChatCompletionController = async (req, res) => {
getUserCodeFiles: db.getUserCodeFiles,
getToolFilesByIds: db.getToolFilesByIds,
getCodeGeneratedFiles: db.getCodeGeneratedFiles,
listSkillsByAccess: db.listSkillsByAccess,
listAlwaysApplySkills: db.listAlwaysApplySkills,
getSkillByName: db.getSkillByName,
listSkillsByAccess: skillDbMethods.listSkillsByAccess,
listAlwaysApplySkills: skillDbMethods.listAlwaysApplySkills,
getSkillByName: skillDbMethods.getSkillByName,
};
const enabledCapabilities = new Set(agentsEConfig?.capabilities);
const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills);
const ephemeralSkillsToggle = req.body?.ephemeralAgent?.skills === true;
const accessibleSkillIds = skillsCapabilityEnabled
? await findAccessibleResources({
userId: req.user.id,
role: req.user.role,
resourceType: ResourceType.SKILL,
requiredPermissions: PermissionBits.VIEW,
})
? withDeploymentSkillIds(
await findAccessibleResources({
userId: req.user.id,
role: req.user.role,
resourceType: ResourceType.SKILL,
requiredPermissions: PermissionBits.VIEW,
}),
)
: [];
const editableSkillIds = skillsCapabilityEnabled
? await findAccessibleResources({

View file

@ -56,7 +56,9 @@ const {
} = require('~/server/services/PermissionService');
const {
getSkillToolDeps,
getSkillDbMethods,
canAuthorSkillFiles,
withDeploymentSkillIds,
buildAgentToolContext,
enrichLoadedToolsWithAgentContext,
} = require('~/server/services/Endpoints/agents/skillDeps');
@ -351,6 +353,7 @@ const createResponse = async (req, res) => {
// Create tool loader
const loadTools = createToolLoader(abortController.signal);
const skillDbMethods = getSkillDbMethods();
// Initialize the agent first to check for disableStreaming
const endpointOption = {
@ -374,9 +377,9 @@ const createResponse = async (req, res) => {
getUserCodeFiles: db.getUserCodeFiles,
getToolFilesByIds: db.getToolFilesByIds,
getCodeGeneratedFiles: db.getCodeGeneratedFiles,
listSkillsByAccess: db.listSkillsByAccess,
listAlwaysApplySkills: db.listAlwaysApplySkills,
getSkillByName: db.getSkillByName,
listSkillsByAccess: skillDbMethods.listSkillsByAccess,
listAlwaysApplySkills: skillDbMethods.listAlwaysApplySkills,
getSkillByName: skillDbMethods.getSkillByName,
};
const enabledCapabilities = new Set(
@ -385,12 +388,14 @@ const createResponse = async (req, res) => {
const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills);
const ephemeralSkillsToggle = req.body?.ephemeralAgent?.skills === true;
const accessibleSkillIds = skillsCapabilityEnabled
? await findAccessibleResources({
userId: req.user.id,
role: req.user.role,
resourceType: ResourceType.SKILL,
requiredPermissions: PermissionBits.VIEW,
})
? withDeploymentSkillIds(
await findAccessibleResources({
userId: req.user.id,
role: req.user.role,
resourceType: ResourceType.SKILL,
requiredPermissions: PermissionBits.VIEW,
}),
)
: [];
const editableSkillIds = skillsCapabilityEnabled
? await findAccessibleResources({

View file

@ -21,6 +21,7 @@ const {
GenerationJobManager,
createStreamServices,
initializeFileStorage,
initializeDeploymentSkills,
preAuthTenantMiddleware,
setupGracefulShutdown,
updateInterfacePermissions,
@ -116,6 +117,7 @@ const startServer = async () => {
});
const appConfig = await getAppConfig({ baseOnly: true });
initializeFileStorage(appConfig);
await initializeDeploymentSkills({ projectRoot: path.resolve(__dirname, '../..') });
startExpiredFileSweep({ appConfig, loadAppConfig: getAppConfig });
await runAsSystem(async () => {
await performStartupChecks(appConfig);

View file

@ -1,6 +1,7 @@
const { ResourceType } = require('librechat-data-provider');
const { ResourceType, PermissionBits } = require('librechat-data-provider');
const { canAccessResource } = require('./canAccessResource');
const { getSkillById } = require('~/models');
const { getDeploymentSkillById } = require('@librechat/api');
/**
* Skill-specific middleware factory that checks skill access permissions.
@ -19,12 +20,35 @@ const canAccessSkillResource = (options) => {
throw new Error('canAccessSkillResource: requiredPermission is required and must be a number');
}
return canAccessResource({
const aclMiddleware = canAccessResource({
resourceType: ResourceType.SKILL,
requiredPermission,
resourceIdParam,
idResolver: getSkillById,
});
return (req, res, next) => {
const rawResourceId = req.params[resourceIdParam];
const deploymentSkill = rawResourceId ? getDeploymentSkillById(rawResourceId) : null;
if (!deploymentSkill) {
return aclMiddleware(req, res, next);
}
if (requiredPermission !== PermissionBits.VIEW) {
return res.status(403).json({
error: 'Forbidden',
message: 'Deployment skills are read-only',
});
}
req.resourceAccess = {
resourceType: ResourceType.SKILL,
resourceId: deploymentSkill._id,
customResourceId: rawResourceId,
permission: requiredPermission,
userId: req.user?.id,
resourceInfo: deploymentSkill,
};
return next();
};
};
module.exports = {

View file

@ -21,14 +21,11 @@ const {
const {
createSkill,
getSkillById,
listSkillsByAccess,
updateSkill,
deleteSkill,
listSkillFiles,
upsertSkillFile,
deleteSkillFile,
getSkillFileByPath,
updateSkillFileContent,
getRoleByName,
} = require('~/models');
const { requireJwtAuth, canAccessSkillResource } = require('~/server/middleware');
@ -42,6 +39,11 @@ const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { createFileLimiters } = require('~/server/middleware/limiters/uploadLimiters');
const configMiddleware = require('~/server/middleware/config/app');
const { getFileStrategy } = require('~/server/utils/getFileStrategy');
const {
getSkillDbMethods,
withDeploymentSkillIds,
getSkillStrategyFunctions,
} = require('~/server/services/Endpoints/agents/skillDeps');
const router = express.Router();
@ -100,6 +102,7 @@ const checkSkillCreate = generateCheckAccess({
// Rate limiters (reuse existing file upload limiters)
// ---------------------------------------------------------------------------
const { fileUploadIpLimiter, fileUploadUserLimiter } = createFileLimiters();
const skillDbMethods = getSkillDbMethods();
router.use(requireJwtAuth);
router.use(configMiddleware);
@ -110,18 +113,28 @@ router.use(checkSkillAccess);
// ---------------------------------------------------------------------------
const handlers = createSkillsHandlers({
createSkill,
getSkillById,
listSkillsByAccess,
getSkillById: skillDbMethods.getSkillById,
listSkillsByAccess: skillDbMethods.listSkillsByAccess,
updateSkill,
deleteSkill,
listSkillFiles,
listSkillFiles: skillDbMethods.listSkillFiles,
deleteSkillFile,
getSkillFileByPath,
updateSkillFileContent,
getStrategyFunctions,
findAccessibleResources,
findPubliclyAccessibleResources,
hasPublicPermission,
getSkillFileByPath: skillDbMethods.getSkillFileByPath,
updateSkillFileContent: skillDbMethods.updateSkillFileContent,
getStrategyFunctions: getSkillStrategyFunctions,
findAccessibleResources: async (params) =>
params.resourceType === 'skill' && params.requiredPermissions === PermissionBits.VIEW
? withDeploymentSkillIds(await findAccessibleResources(params))
: findAccessibleResources(params),
findPubliclyAccessibleResources: async (params) =>
params.resourceType === 'skill' && params.requiredPermissions === PermissionBits.VIEW
? withDeploymentSkillIds(await findPubliclyAccessibleResources(params))
: findPubliclyAccessibleResources(params),
hasPublicPermission: async (params) =>
params.resourceType === 'skill' && params.requiredPermissions === PermissionBits.VIEW
? withDeploymentSkillIds([]).some((id) => id.toString() === params.resourceId.toString()) ||
hasPublicPermission(params)
: hasPublicPermission(params),
grantPermission,
isValidObjectIdString,
});

View file

@ -32,7 +32,9 @@ const { loadAgentTools, loadToolsForExecution } = require('~/server/services/Too
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
const {
getSkillToolDeps,
getSkillDbMethods,
canAuthorSkillFiles,
withDeploymentSkillIds,
buildAgentToolContext,
enrichLoadedToolsWithAgentContext,
} = require('./skillDeps');
@ -143,14 +145,17 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills);
const codeEnvAvailable = enabledCapabilities.has(AgentCapabilities.execute_code);
const ephemeralSkillsToggle = req.body?.ephemeralAgent?.skills === true;
const skillDbMethods = getSkillDbMethods();
const accessibleSkillIds = skillsCapabilityEnabled
? await findAccessibleResources({
userId: req.user.id,
role: req.user.role,
resourceType: ResourceType.SKILL,
requiredPermissions: PermissionBits.VIEW,
})
? withDeploymentSkillIds(
await findAccessibleResources({
userId: req.user.id,
role: req.user.role,
resourceType: ResourceType.SKILL,
requiredPermissions: PermissionBits.VIEW,
}),
)
: [];
const editableSkillIds = skillsCapabilityEnabled
? await findAccessibleResources({
@ -368,9 +373,9 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
getToolFilesByIds: db.getToolFilesByIds,
getCodeGeneratedFiles: db.getCodeGeneratedFiles,
filterFilesByAgentAccess,
listSkillsByAccess: db.listSkillsByAccess,
listAlwaysApplySkills: db.listAlwaysApplySkills,
getSkillByName: db.getSkillByName,
listSkillsByAccess: skillDbMethods.listSkillsByAccess,
listAlwaysApplySkills: skillDbMethods.listAlwaysApplySkills,
getSkillByName: skillDbMethods.getSkillByName,
},
);
@ -439,9 +444,9 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
getToolFilesByIds: db.getToolFilesByIds,
getCodeGeneratedFiles: db.getCodeGeneratedFiles,
filterFilesByAgentAccess,
listSkillsByAccess: db.listSkillsByAccess,
listAlwaysApplySkills: db.listAlwaysApplySkills,
getSkillByName: db.getSkillByName,
listSkillsByAccess: skillDbMethods.listSkillsByAccess,
listAlwaysApplySkills: skillDbMethods.listAlwaysApplySkills,
getSkillByName: skillDbMethods.getSkillByName,
},
// The callback fires during BFS, before the helper prunes agents
// whose edges end up filtered. Don't populate `agentConfigs` here —
@ -646,9 +651,9 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => {
getToolFilesByIds: db.getToolFilesByIds,
getCodeGeneratedFiles: db.getCodeGeneratedFiles,
filterFilesByAgentAccess,
listSkillsByAccess: db.listSkillsByAccess,
listAlwaysApplySkills: db.listAlwaysApplySkills,
getSkillByName: db.getSkillByName,
listSkillsByAccess: skillDbMethods.listSkillsByAccess,
listAlwaysApplySkills: skillDbMethods.listAlwaysApplySkills,
getSkillByName: skillDbMethods.getSkillByName,
},
);
agentConfigs.set(agentId, config);

View file

@ -12,6 +12,10 @@ const {
getStorageMetadata,
resolveRequestTenantId,
enrichWithSkillConfigurable,
mergeDeploymentSkillIds,
createDeploymentSkillMethods,
isDeploymentSkillFileSource,
getDeploymentSkillDownloadStream,
} = require('@librechat/api');
const {
Permissions,
@ -27,6 +31,34 @@ const { checkPermission, grantPermission } = require('~/server/services/Permissi
const { getFileStrategy } = require('~/server/utils/getFileStrategy');
const db = require('~/models');
const deploymentSkillMethods = createDeploymentSkillMethods({
getSkillById: db.getSkillById,
getSkillByName: db.getSkillByName,
listSkillsByAccess: db.listSkillsByAccess,
listAlwaysApplySkills: db.listAlwaysApplySkills,
listSkillFiles: db.listSkillFiles,
getSkillFileByPath: db.getSkillFileByPath,
updateSkillFileContent: db.updateSkillFileContent,
updateSkillFileCodeEnvIds: db.updateSkillFileCodeEnvIds,
});
function getSkillDbMethods() {
return deploymentSkillMethods;
}
function withDeploymentSkillIds(ids = []) {
return mergeDeploymentSkillIds(ids);
}
function getSkillStrategyFunctions(source) {
if (isDeploymentSkillFileSource(source)) {
return {
getDownloadStream: (_req, filepath) => getDeploymentSkillDownloadStream(filepath),
};
}
return getStrategyFunctions(source);
}
function resolveSkillStorage(req, { isImage = false } = {}) {
const source = getFileStrategy(req.config, { context: FileContext.skill_file, isImage });
const strategy = getStrategyFunctions(source);
@ -290,7 +322,7 @@ function enrichLoadedToolsWithAgentContext({ result, req, ctx = {}, fallback = {
/** Skill-related properties for ToolExecuteOptions (stable references, allocated once). */
const skillToolDeps = {
getSkillByName: db.getSkillByName,
getSkillByName: deploymentSkillMethods.getSkillByName,
getAuthorSkillByName,
createSkill: db.createSkill,
updateSkill: db.updateSkill,
@ -299,14 +331,14 @@ const skillToolDeps = {
canEditSkill,
grantSkillOwner,
saveSkillFileContent,
listSkillFiles: db.listSkillFiles,
getStrategyFunctions,
listSkillFiles: deploymentSkillMethods.listSkillFiles,
getStrategyFunctions: getSkillStrategyFunctions,
batchUploadCodeEnvFiles,
getSessionInfo,
checkIfActive,
updateSkillFileCodeEnvIds: db.updateSkillFileCodeEnvIds,
getSkillFileByPath: db.getSkillFileByPath,
updateSkillFileContent: db.updateSkillFileContent,
updateSkillFileCodeEnvIds: deploymentSkillMethods.updateSkillFileCodeEnvIds,
getSkillFileByPath: deploymentSkillMethods.getSkillFileByPath,
updateSkillFileContent: deploymentSkillMethods.updateSkillFileContent,
/**
* `read_file` falls back to a sandbox `cat` for `/mnt/data/...` paths
* and for `{firstSegment}/...` paths whose first segment isn't a known
@ -327,6 +359,9 @@ module.exports = {
getSkillToolDeps,
canAuthorSkillFiles,
isAgentSkillsEnabledForRun,
getSkillDbMethods,
withDeploymentSkillIds,
getSkillStrategyFunctions,
enrichWithSkillConfigurable,
buildSkillPrimedIdsByName,
buildAgentToolContext,

View file

@ -4,6 +4,7 @@ const mockGetStrategyFunctions = jest.fn();
const mockGetFileStrategy = jest.fn();
const mockGetStorageMetadata = jest.fn();
const mockResolveRequestTenantId = jest.fn();
const mockCreateDeploymentSkillMethods = jest.fn((methods) => methods);
jest.mock('~/server/services/Files/strategies', () => ({
getStrategyFunctions: (...args) => mockGetStrategyFunctions(...args),
@ -22,8 +23,12 @@ jest.mock('~/server/services/Files/Code/process', () => ({
jest.mock('@librechat/api', () => ({
checkAccess: jest.fn(),
createDeploymentSkillMethods: (...args) => mockCreateDeploymentSkillMethods(...args),
enrichWithSkillConfigurable: jest.fn(),
getDeploymentSkillDownloadStream: jest.fn(),
getStorageMetadata: (...args) => mockGetStorageMetadata(...args),
isDeploymentSkillFileSource: jest.fn(() => false),
mergeDeploymentSkillIds: jest.fn((ids = []) => ids),
resolveRequestTenantId: (...args) => mockResolveRequestTenantId(...args),
}));