mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🗂️ 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
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:
parent
6357ea10c1
commit
2c8d54e18c
32 changed files with 1836 additions and 69 deletions
|
|
@ -108,6 +108,10 @@ NODE_MAX_OLD_SPACE_SIZE=6144
|
|||
|
||||
# CONFIG_PATH="/alternative/path/to/librechat.yaml"
|
||||
|
||||
# Deployment skills are loaded read-only at startup and exposed to all users
|
||||
# with the Skills capability enabled. Defaults to project root ./skill.
|
||||
# DEPLOYMENT_SKILLS_DIR=./skill
|
||||
|
||||
#==================#
|
||||
# Langfuse Tracing #
|
||||
#==================#
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ RUN \
|
|||
# Allow mounting of these files, which have no default
|
||||
touch .env ; \
|
||||
# Create directories for the volumes to inherit the correct permissions
|
||||
mkdir -p /app/client/public/images /app/logs /app/uploads ; \
|
||||
mkdir -p /app/client/public/images /app/logs /app/uploads /app/skill ; \
|
||||
npm config set fetch-retry-maxtimeout 600000 ; \
|
||||
npm config set fetch-retries 5 ; \
|
||||
npm config set fetch-retry-mintimeout 15000 ; \
|
||||
|
|
|
|||
|
|
@ -109,6 +109,7 @@ RUN attempt=1; \
|
|||
done
|
||||
COPY api ./api
|
||||
COPY config ./config
|
||||
COPY skill ./skill
|
||||
COPY --from=data-provider-build /app/packages/data-provider/dist ./packages/data-provider/dist
|
||||
COPY --from=data-schemas-build /app/packages/data-schemas/dist ./packages/data-schemas/dist
|
||||
COPY --from=api-package-build /app/packages/api/dist ./packages/api/dist
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ services:
|
|||
- ./images:/app/client/public/images
|
||||
- ./uploads:/app/uploads
|
||||
- ./logs:/app/api/logs
|
||||
- ./skill:/app/skill
|
||||
|
||||
client:
|
||||
image: nginx:1.27.0-alpine
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ services:
|
|||
- ./images:/app/client/public/images
|
||||
- ./uploads:/app/uploads
|
||||
- ./logs:/app/logs
|
||||
- ./skill:/app/skill
|
||||
mongodb:
|
||||
container_name: chat-mongodb
|
||||
image: mongo:8.0.20
|
||||
|
|
|
|||
10
e2e/fixtures/deployment-skills/e2e-deployment-skill/SKILL.md
Normal file
10
e2e/fixtures/deployment-skills/e2e-deployment-skill/SKILL.md
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
---
|
||||
name: e2e-deployment-skill
|
||||
description: Use this deployment skill to verify shared skills load during Playwright startup.
|
||||
always-apply: true
|
||||
user-invocable: true
|
||||
---
|
||||
|
||||
# E2E Deployment Skill
|
||||
|
||||
E2E deployment skill loaded through Playwright from the configured deployment skills directory.
|
||||
|
|
@ -0,0 +1 @@
|
|||
deployment skill file fixture
|
||||
|
|
@ -9,6 +9,7 @@ const fakeModelHookPath = path.resolve(rootPath, 'e2e/setup/fake-model.js');
|
|||
const configTemplatePath = path.resolve(rootPath, 'e2e/config/librechat.e2e.yaml');
|
||||
const configPath = path.resolve(rootPath, 'e2e/.generated/librechat.e2e.yaml');
|
||||
const reportPath = path.resolve(rootPath, 'e2e/playwright-report');
|
||||
const deploymentSkillsPath = path.resolve(rootPath, 'e2e/fixtures/deployment-skills');
|
||||
|
||||
const baseURL = getE2EBaseURL();
|
||||
const chromiumChannel = process.env.E2E_CHROMIUM_CHANNEL || undefined;
|
||||
|
|
@ -27,6 +28,7 @@ const vanillaOverrides = {
|
|||
const baseEnv = {
|
||||
...getLocalE2EEnv(),
|
||||
CONFIG_PATH: configPath,
|
||||
DEPLOYMENT_SKILLS_DIR: deploymentSkillsPath,
|
||||
/** Loaded in-process by `@librechat/api`'s `createRun` to swap in a fake model. */
|
||||
LIBRECHAT_TEST_RUN_HOOK: fakeModelHookPath,
|
||||
...vanillaOverrides,
|
||||
|
|
|
|||
218
e2e/specs/mock/deployment-skills.spec.ts
Normal file
218
e2e/specs/mock/deployment-skills.spec.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
import { expect, test } from '@playwright/test';
|
||||
import type { Page } from '@playwright/test';
|
||||
import { NEW_CHAT_PATH } from './helpers';
|
||||
|
||||
const DEPLOYMENT_SKILL_NAME = 'e2e-deployment-skill';
|
||||
const DEPLOYMENT_SKILL_DESCRIPTION =
|
||||
'Use this deployment skill to verify shared skills load during Playwright startup.';
|
||||
|
||||
type RefreshTokenBody = {
|
||||
token?: string;
|
||||
};
|
||||
|
||||
type SkillSummary = {
|
||||
_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
source?: string;
|
||||
sourceMetadata?: Record<string, unknown>;
|
||||
fileCount?: number;
|
||||
alwaysApply?: boolean;
|
||||
isPublic?: boolean;
|
||||
};
|
||||
|
||||
type SkillDetail = SkillSummary & {
|
||||
body: string;
|
||||
frontmatter?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
type SkillFile = {
|
||||
_id: string;
|
||||
skillId: string;
|
||||
relativePath: string;
|
||||
file_id: string;
|
||||
filename: string;
|
||||
filepath: string;
|
||||
source: string;
|
||||
mimeType: string;
|
||||
bytes: number;
|
||||
category: string;
|
||||
isExecutable: boolean;
|
||||
author: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
type SkillFileContent = Pick<
|
||||
SkillFile,
|
||||
'relativePath' | 'filename' | 'mimeType' | 'bytes' | 'content'
|
||||
> & {
|
||||
isBinary?: boolean;
|
||||
};
|
||||
|
||||
type ApiResult<T> = {
|
||||
ok: boolean;
|
||||
status: number;
|
||||
text: string;
|
||||
json: T | null;
|
||||
};
|
||||
|
||||
async function getAccessToken(page: Page): Promise<string> {
|
||||
const result = await page.evaluate(async () => {
|
||||
const response = await fetch('/api/auth/refresh', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
const text = await response.text();
|
||||
let json: unknown = null;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
return { ok: response.ok, status: response.status, text, json };
|
||||
});
|
||||
|
||||
if (!result.ok) {
|
||||
throw new Error(
|
||||
`Expected /api/auth/refresh to return 2xx, got ${result.status}: ${result.text}`,
|
||||
);
|
||||
}
|
||||
|
||||
const body = result.json as RefreshTokenBody | null;
|
||||
if (!body?.token) {
|
||||
throw new Error(`Expected /api/auth/refresh to return a token, got: ${result.text}`);
|
||||
}
|
||||
|
||||
return body.token;
|
||||
}
|
||||
|
||||
async function apiJson<T>(
|
||||
page: Page,
|
||||
path: string,
|
||||
token: string,
|
||||
init: { method?: string; body?: unknown } = {},
|
||||
): Promise<ApiResult<T>> {
|
||||
return page.evaluate(
|
||||
async ({ accessToken, body, method, urlPath }) => {
|
||||
const headers: Record<string, string> = { Authorization: `Bearer ${accessToken}` };
|
||||
if (body !== undefined) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
const response = await fetch(urlPath, {
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const text = await response.text();
|
||||
let json: unknown = null;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
json = null;
|
||||
}
|
||||
return { ok: response.ok, status: response.status, text, json };
|
||||
},
|
||||
{
|
||||
accessToken: token,
|
||||
body: init.body,
|
||||
method: init.method,
|
||||
urlPath: path,
|
||||
},
|
||||
) as Promise<ApiResult<T>>;
|
||||
}
|
||||
|
||||
async function fetchJson<T>(page: Page, path: string, token: string): Promise<T> {
|
||||
const result = await apiJson<T>(page, path, token);
|
||||
if (!result.ok) {
|
||||
throw new Error(`Expected ${path} to return 2xx, got ${result.status}: ${result.text}`);
|
||||
}
|
||||
return result.json as T;
|
||||
}
|
||||
|
||||
test.describe('deployment skills', () => {
|
||||
test('loads configured deployment skills for every authenticated user as read-only', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto(NEW_CHAT_PATH, { timeout: 10000 });
|
||||
const token = await getAccessToken(page);
|
||||
|
||||
const list = await fetchJson<{ skills?: SkillSummary[] }>(
|
||||
page,
|
||||
`/api/skills?search=${encodeURIComponent(DEPLOYMENT_SKILL_NAME)}&limit=10`,
|
||||
token,
|
||||
);
|
||||
const skill = list.skills?.find((item) => item.name === DEPLOYMENT_SKILL_NAME);
|
||||
expect(skill).toMatchObject({
|
||||
name: DEPLOYMENT_SKILL_NAME,
|
||||
description: DEPLOYMENT_SKILL_DESCRIPTION,
|
||||
source: 'deployment',
|
||||
sourceMetadata: { deployment: true },
|
||||
fileCount: 1,
|
||||
alwaysApply: true,
|
||||
isPublic: true,
|
||||
});
|
||||
|
||||
const detail = await fetchJson<SkillDetail>(
|
||||
page,
|
||||
`/api/skills/${encodeURIComponent(skill!._id)}`,
|
||||
token,
|
||||
);
|
||||
expect(detail.body).toContain('E2E deployment skill loaded through Playwright');
|
||||
expect(detail.frontmatter).toMatchObject({
|
||||
name: DEPLOYMENT_SKILL_NAME,
|
||||
description: DEPLOYMENT_SKILL_DESCRIPTION,
|
||||
'always-apply': true,
|
||||
});
|
||||
|
||||
const files = await fetchJson<{ files?: SkillFile[] }>(
|
||||
page,
|
||||
`/api/skills/${encodeURIComponent(skill!._id)}/files`,
|
||||
token,
|
||||
);
|
||||
expect(files.files).toHaveLength(1);
|
||||
expect(files.files?.[0]).toMatchObject({
|
||||
skillId: skill!._id,
|
||||
relativePath: 'guide.txt',
|
||||
filename: 'guide.txt',
|
||||
source: 'deployment',
|
||||
mimeType: 'text/plain',
|
||||
bytes: 'deployment skill file fixture\n'.length,
|
||||
category: 'other',
|
||||
isExecutable: false,
|
||||
});
|
||||
expect(files.files?.[0]).not.toHaveProperty('content');
|
||||
|
||||
const downloaded = await fetchJson<SkillFileContent>(
|
||||
page,
|
||||
`/api/skills/${encodeURIComponent(skill!._id)}/files/guide.txt`,
|
||||
token,
|
||||
);
|
||||
expect(downloaded).toMatchObject({
|
||||
relativePath: 'guide.txt',
|
||||
filename: 'guide.txt',
|
||||
mimeType: 'text/plain',
|
||||
bytes: 'deployment skill file fixture\n'.length,
|
||||
isBinary: false,
|
||||
content: 'deployment skill file fixture\n',
|
||||
});
|
||||
|
||||
const patch = await apiJson<{ message?: string }>(
|
||||
page,
|
||||
`/api/skills/${encodeURIComponent(skill!._id)}`,
|
||||
token,
|
||||
{
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
description: 'Deployment skills should stay read-only.',
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(patch.status).toBe(403);
|
||||
expect(patch.json).toMatchObject({ message: 'Deployment skills are read-only' });
|
||||
});
|
||||
});
|
||||
|
|
@ -651,6 +651,30 @@ describe('resolveSkillActive', () => {
|
|||
).toBe(false);
|
||||
});
|
||||
|
||||
it('respects explicit override = false even for deployment skills', () => {
|
||||
const deploymentSkill = { ...makeSkill(new Types.ObjectId()), deployment: true };
|
||||
expect(
|
||||
resolveSkillActive({
|
||||
skill: deploymentSkill,
|
||||
skillStates: { [deploymentSkill._id.toString()]: false },
|
||||
userId: undefined,
|
||||
defaultActiveOnShare: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults deployment skills to active without ownership or shared defaults', () => {
|
||||
const deploymentSkill = { ...makeSkill(new Types.ObjectId()), deployment: true };
|
||||
expect(
|
||||
resolveSkillActive({
|
||||
skill: deploymentSkill,
|
||||
skillStates: {},
|
||||
userId: undefined,
|
||||
defaultActiveOnShare: false,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('owned skills default to active when no override is present', () => {
|
||||
const userObjectId = new Types.ObjectId();
|
||||
const userId = userObjectId.toString();
|
||||
|
|
@ -1138,6 +1162,7 @@ describe('resolveManualSkills', () => {
|
|||
author: Types.ObjectId;
|
||||
allowedTools?: string[];
|
||||
userInvocable?: boolean;
|
||||
deployment?: boolean;
|
||||
};
|
||||
|
||||
const buildGetSkillByName =
|
||||
|
|
@ -1347,6 +1372,21 @@ describe('resolveManualSkills', () => {
|
|||
expect(result).toEqual([{ _id: shared._id, name: 'shared', body: 'shared-body' }]);
|
||||
});
|
||||
|
||||
it('allows deployment skills even when shared skills default inactive', async () => {
|
||||
const deployment = {
|
||||
...mkSkill('deployment', otherAuthor, 'deployment-body'),
|
||||
deployment: true,
|
||||
};
|
||||
const result = await resolveManualSkills({
|
||||
names: ['deployment'],
|
||||
getSkillByName: buildGetSkillByName({ deployment }),
|
||||
accessibleSkillIds: [deployment._id],
|
||||
userId,
|
||||
defaultActiveOnShare: false,
|
||||
});
|
||||
expect(result).toEqual([{ _id: deployment._id, name: 'deployment', body: 'deployment-body' }]);
|
||||
});
|
||||
|
||||
it('drops explicitly-deactivated skills (skillStates override wins over ownership default)', async () => {
|
||||
const owned = mkSkill('owned-off', userOid);
|
||||
const result = await resolveManualSkills({
|
||||
|
|
@ -1781,6 +1821,7 @@ describe('resolveAlwaysApplySkills', () => {
|
|||
body: string;
|
||||
author: Types.ObjectId | string;
|
||||
allowedTools?: string[];
|
||||
deployment?: boolean;
|
||||
};
|
||||
|
||||
const mkRow = (
|
||||
|
|
@ -1868,6 +1909,22 @@ describe('resolveAlwaysApplySkills', () => {
|
|||
expect(result).toEqual([{ _id: shared._id, name: 'shared-on', body: 'shared-body' }]);
|
||||
});
|
||||
|
||||
it('allows deployment always-apply skills even when shared skills default inactive', async () => {
|
||||
const deployment: AlwaysApplyRow = {
|
||||
...mkRow('deployment-always', otherAuthor, 'deployment body'),
|
||||
deployment: true,
|
||||
};
|
||||
const result = await resolveAlwaysApplySkills({
|
||||
listAlwaysApplySkills: buildLister([deployment]),
|
||||
accessibleSkillIds: [deployment._id],
|
||||
userId,
|
||||
defaultActiveOnShare: false,
|
||||
});
|
||||
expect(result).toEqual([
|
||||
{ _id: deployment._id, name: 'deployment-always', body: 'deployment body' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('honors explicit deactivation override even for owned skills', async () => {
|
||||
const owned = mkRow('owned-off', userOid);
|
||||
const result = await resolveAlwaysApplySkills({
|
||||
|
|
|
|||
|
|
@ -82,6 +82,8 @@ export interface ToolExecuteOptions {
|
|||
* prior cache entry. */
|
||||
version: number;
|
||||
fileCount: number;
|
||||
/** True for deployment-directory skills that are loaded in memory. */
|
||||
deployment?: boolean;
|
||||
/**
|
||||
* Set when the skill author opted out of model invocation. The handler
|
||||
* rejects the call and returns an instructive error so the model knows
|
||||
|
|
|
|||
|
|
@ -367,6 +367,8 @@ export interface InitializeAgentDbMethods extends EndpointDbMethods {
|
|||
* by the manual-invocation resolver. Defaults to `true`.
|
||||
*/
|
||||
userInvocable?: boolean;
|
||||
/** True for deployment-directory skills that are loaded in memory. */
|
||||
deployment?: boolean;
|
||||
}>;
|
||||
has_more?: boolean;
|
||||
after?: string | null;
|
||||
|
|
@ -412,6 +414,8 @@ export interface InitializeAgentDbMethods extends EndpointDbMethods {
|
|||
* caller can't bypass the popover-side filter.
|
||||
*/
|
||||
userInvocable?: boolean;
|
||||
/** True for deployment-directory skills that are loaded in memory. */
|
||||
deployment?: boolean;
|
||||
} | null>;
|
||||
/**
|
||||
* Load accessible skills with `alwaysApply: true`, eagerly including
|
||||
|
|
@ -431,6 +435,8 @@ export interface InitializeAgentDbMethods extends EndpointDbMethods {
|
|||
body: string;
|
||||
author: import('mongoose').Types.ObjectId;
|
||||
allowedTools?: string[];
|
||||
/** True for deployment-directory skills that are loaded in memory. */
|
||||
deployment?: boolean;
|
||||
}>;
|
||||
has_more?: boolean;
|
||||
after?: string | null;
|
||||
|
|
|
|||
|
|
@ -274,7 +274,7 @@ export function resolveAgentScopedSkillIds(
|
|||
|
||||
export interface ResolveSkillActiveParams {
|
||||
/** Skill being evaluated. Only `_id` and `author` matter for resolution. */
|
||||
skill: { _id: Types.ObjectId | string; author: Types.ObjectId | string };
|
||||
skill: { _id: Types.ObjectId | string; author: Types.ObjectId | string; deployment?: boolean };
|
||||
/** Per-user overrides: `{ [skillId]: boolean }`. Missing entries use the default. */
|
||||
skillStates?: Record<string, boolean>;
|
||||
/** Current user ID. When absent, the function fails closed for all non-overridden skills. */
|
||||
|
|
@ -299,6 +299,9 @@ export function resolveSkillActive(params: ResolveSkillActiveParams): boolean {
|
|||
if (override !== undefined) {
|
||||
return override;
|
||||
}
|
||||
if (skill.deployment === true) {
|
||||
return true;
|
||||
}
|
||||
if (!userId) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -615,6 +618,7 @@ export interface ResolveManualSkillsParams {
|
|||
name: string;
|
||||
body: string;
|
||||
author: Types.ObjectId | string;
|
||||
deployment?: boolean;
|
||||
/**
|
||||
* Skill-declared tool allowlist, forwarded verbatim from the skill doc.
|
||||
* Surfaced on `ResolvedManualSkill` so future runtime enforcement can
|
||||
|
|
@ -785,7 +789,7 @@ export async function resolveManualSkills(
|
|||
return null;
|
||||
}
|
||||
const active = resolveSkillActive({
|
||||
skill: { _id: skill._id, author: skill.author },
|
||||
skill: { _id: skill._id, author: skill.author, deployment: skill.deployment },
|
||||
skillStates,
|
||||
userId,
|
||||
defaultActiveOnShare,
|
||||
|
|
@ -835,6 +839,7 @@ export interface ResolveAlwaysApplySkillsParams {
|
|||
body: string;
|
||||
author: Types.ObjectId | string;
|
||||
allowedTools?: string[];
|
||||
deployment?: boolean;
|
||||
}>;
|
||||
has_more?: boolean;
|
||||
after?: string | null;
|
||||
|
|
@ -933,7 +938,7 @@ export async function resolveAlwaysApplySkills(
|
|||
continue;
|
||||
}
|
||||
const active = resolveSkillActive({
|
||||
skill: { _id: skill._id, author: skill.author },
|
||||
skill: { _id: skill._id, author: skill.author, deployment: skill.deployment },
|
||||
skillStates,
|
||||
userId,
|
||||
defaultActiveOnShare,
|
||||
|
|
|
|||
327
packages/api/src/skills/__tests__/deployment.test.ts
Normal file
327
packages/api/src/skills/__tests__/deployment.test.ts
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
import fs from 'fs';
|
||||
import os from 'os';
|
||||
import path from 'path';
|
||||
import { Types } from 'mongoose';
|
||||
import type { CodeEnvRef } from 'librechat-data-provider';
|
||||
import {
|
||||
DEPLOYMENT_SKILLS_DIR_ENV,
|
||||
createDeploymentSkillMethods,
|
||||
getDeploymentSkillIds,
|
||||
initializeDeploymentSkills,
|
||||
loadDeploymentSkillsFromDirectory,
|
||||
mergeDeploymentSkillIds,
|
||||
resolveDeploymentSkillDirectory,
|
||||
} from '../deployment';
|
||||
import type { DeploymentSkillBaseMethods } from '../deployment';
|
||||
|
||||
const DESCRIPTION = 'Use this skill when the deployment needs a shared testing fixture.';
|
||||
|
||||
let tempRoots: string[] = [];
|
||||
|
||||
async function makeTempRoot(): Promise<string> {
|
||||
const root = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'deployment-skills-'));
|
||||
tempRoots.push(root);
|
||||
return root;
|
||||
}
|
||||
|
||||
async function writeDeploymentSkill(
|
||||
root: string,
|
||||
options: {
|
||||
skillsDir?: string;
|
||||
folder?: string;
|
||||
name?: string;
|
||||
alwaysApply?: boolean;
|
||||
frontmatter?: string;
|
||||
} = {},
|
||||
): Promise<string> {
|
||||
const skillsDir = options.skillsDir ?? 'skill';
|
||||
const folder = options.folder ?? options.name ?? 'analysis-kit';
|
||||
const name = options.name ?? folder;
|
||||
const skillDir = path.join(root, skillsDir, folder);
|
||||
await fs.promises.mkdir(path.join(skillDir, 'references'), { recursive: true });
|
||||
await fs.promises.mkdir(path.join(skillDir, 'assets'), { recursive: true });
|
||||
await fs.promises.writeFile(
|
||||
path.join(skillDir, 'SKILL.md'),
|
||||
options.frontmatter ??
|
||||
[
|
||||
'---',
|
||||
`name: ${name}`,
|
||||
`description: ${DESCRIPTION}`,
|
||||
'allowed-tools:',
|
||||
' - execute_code',
|
||||
'disable-model-invocation: true',
|
||||
'user-invocable: false',
|
||||
`always-apply: ${options.alwaysApply === true ? 'true' : 'false'}`,
|
||||
'---',
|
||||
'',
|
||||
'# Analysis Kit',
|
||||
'',
|
||||
'Read references/guide.txt before answering.',
|
||||
].join('\n'),
|
||||
);
|
||||
await fs.promises.writeFile(path.join(skillDir, 'references', 'guide.txt'), 'reference notes');
|
||||
await fs.promises.writeFile(path.join(skillDir, 'assets', 'pixel.bin'), Buffer.from([0, 1, 2]));
|
||||
return skillDir;
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
const emptyRoot = await makeTempRoot();
|
||||
await initializeDeploymentSkills({ projectRoot: emptyRoot, env: {} });
|
||||
await Promise.all(
|
||||
tempRoots.map((root) => fs.promises.rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
tempRoots = [];
|
||||
});
|
||||
|
||||
describe('resolveDeploymentSkillDirectory', () => {
|
||||
it('defaults to project root ./skill', () => {
|
||||
const root = path.join(os.tmpdir(), 'librechat-root');
|
||||
expect(resolveDeploymentSkillDirectory({ projectRoot: root, env: {} })).toEqual({
|
||||
directory: path.join(root, 'skill'),
|
||||
explicitlyConfigured: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('honors a relative DEPLOYMENT_SKILLS_DIR override', () => {
|
||||
const root = path.join(os.tmpdir(), 'librechat-root');
|
||||
const env = { [DEPLOYMENT_SKILLS_DIR_ENV]: 'config/skills' };
|
||||
expect(resolveDeploymentSkillDirectory({ projectRoot: root, env })).toEqual({
|
||||
directory: path.join(root, 'config', 'skills'),
|
||||
explicitlyConfigured: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadDeploymentSkillsFromDirectory', () => {
|
||||
it('treats a missing default directory as an empty deployment catalog', async () => {
|
||||
const root = await makeTempRoot();
|
||||
const registry = await loadDeploymentSkillsFromDirectory(path.join(root, 'skill'), {
|
||||
projectRoot: root,
|
||||
});
|
||||
expect(registry.list()).toEqual([]);
|
||||
});
|
||||
|
||||
it('fails startup validation when an explicitly configured directory is missing', async () => {
|
||||
const root = await makeTempRoot();
|
||||
await expect(
|
||||
loadDeploymentSkillsFromDirectory(path.join(root, 'missing-skills'), {
|
||||
projectRoot: root,
|
||||
explicitlyConfigured: true,
|
||||
}),
|
||||
).rejects.toThrow(/Deployment skills directory not found/);
|
||||
});
|
||||
|
||||
it('loads bundled skills and their files without touching the DB', async () => {
|
||||
const root = await makeTempRoot();
|
||||
await writeDeploymentSkill(root, { name: 'analysis-kit', alwaysApply: true });
|
||||
|
||||
const registry = await loadDeploymentSkillsFromDirectory(path.join(root, 'skill'), {
|
||||
projectRoot: root,
|
||||
});
|
||||
const [skill] = registry.list();
|
||||
|
||||
expect(skill).toMatchObject({
|
||||
name: 'analysis-kit',
|
||||
description: DESCRIPTION,
|
||||
source: 'deployment',
|
||||
deployment: true,
|
||||
isPublic: true,
|
||||
alwaysApply: true,
|
||||
allowedTools: ['execute_code'],
|
||||
disableModelInvocation: true,
|
||||
userInvocable: false,
|
||||
authorName: 'Deployment',
|
||||
sourceMetadata: { deployment: true, directory: 'skill/analysis-kit' },
|
||||
});
|
||||
expect(skill.fileCount).toBe(2);
|
||||
expect(skill.files.map((file) => [file.relativePath, file.category])).toEqual([
|
||||
['assets/pixel.bin', 'asset'],
|
||||
['references/guide.txt', 'reference'],
|
||||
]);
|
||||
expect(skill.files.find((file) => file.relativePath === 'references/guide.txt')).toMatchObject({
|
||||
source: 'deployment',
|
||||
content: 'reference notes',
|
||||
isBinary: false,
|
||||
mimeType: 'text/plain',
|
||||
});
|
||||
expect(skill.files.find((file) => file.relativePath === 'assets/pixel.bin')).toMatchObject({
|
||||
isBinary: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('validates SKILL.md frontmatter at startup', async () => {
|
||||
const root = await makeTempRoot();
|
||||
await writeDeploymentSkill(root, {
|
||||
name: 'bad-frontmatter',
|
||||
frontmatter: [
|
||||
'---',
|
||||
'name: bad-frontmatter',
|
||||
`description: ${DESCRIPTION}`,
|
||||
'unknown-key: nope',
|
||||
'---',
|
||||
'',
|
||||
'Body',
|
||||
].join('\n'),
|
||||
});
|
||||
|
||||
await expect(
|
||||
loadDeploymentSkillsFromDirectory(path.join(root, 'skill'), { projectRoot: root }),
|
||||
).rejects.toThrow(/frontmatter\.unknown-key/);
|
||||
});
|
||||
|
||||
it('validates bundled file paths at startup', async () => {
|
||||
const root = await makeTempRoot();
|
||||
const skillDir = await writeDeploymentSkill(root, { name: 'bad-path' });
|
||||
await fs.promises.writeFile(path.join(skillDir, 'references', 'bad path.txt'), 'bad');
|
||||
|
||||
await expect(
|
||||
loadDeploymentSkillsFromDirectory(path.join(root, 'skill'), { projectRoot: root }),
|
||||
).rejects.toThrow(/relativePath: Relative path contains invalid characters/);
|
||||
});
|
||||
|
||||
it('rejects duplicate deployment skill names', async () => {
|
||||
const root = await makeTempRoot();
|
||||
await writeDeploymentSkill(root, { folder: 'one', name: 'duplicate-name' });
|
||||
await writeDeploymentSkill(root, { folder: 'two', name: 'duplicate-name' });
|
||||
|
||||
await expect(
|
||||
loadDeploymentSkillsFromDirectory(path.join(root, 'skill'), { projectRoot: root }),
|
||||
).rejects.toThrow(/Duplicate deployment skill name "duplicate-name"/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createDeploymentSkillMethods', () => {
|
||||
it('merges deployment skills into read paths while stripping them from DB calls', async () => {
|
||||
const root = await makeTempRoot();
|
||||
await writeDeploymentSkill(root, {
|
||||
skillsDir: 'config/skills',
|
||||
name: 'analysis-kit',
|
||||
alwaysApply: true,
|
||||
});
|
||||
await initializeDeploymentSkills({
|
||||
projectRoot: root,
|
||||
env: { [DEPLOYMENT_SKILLS_DIR_ENV]: 'config/skills' },
|
||||
});
|
||||
|
||||
const deploymentId = getDeploymentSkillIds()[0];
|
||||
const dbId = new Types.ObjectId();
|
||||
const dbAuthor = new Types.ObjectId();
|
||||
const dbSkill = {
|
||||
_id: dbId,
|
||||
name: 'db-skill',
|
||||
description: 'A persisted skill.',
|
||||
body: 'persisted body',
|
||||
author: dbAuthor,
|
||||
version: 3,
|
||||
fileCount: 0,
|
||||
updatedAt: new Date(0),
|
||||
};
|
||||
const base: DeploymentSkillBaseMethods = {
|
||||
getSkillById: jest.fn(async (id) => (id.toString() === dbId.toString() ? dbSkill : null)),
|
||||
getSkillByName: jest.fn(async (name) => (name === dbSkill.name ? dbSkill : null)),
|
||||
listSkillsByAccess: jest.fn(async () => ({
|
||||
skills: [dbSkill],
|
||||
has_more: false,
|
||||
after: null,
|
||||
})),
|
||||
listAlwaysApplySkills: jest.fn(async () => ({
|
||||
skills: [{ _id: dbId, name: 'db-always', body: 'db always', author: dbAuthor }],
|
||||
has_more: false,
|
||||
after: null,
|
||||
})),
|
||||
listSkillFiles: jest.fn(async () => []),
|
||||
getSkillFileByPath: jest.fn(async () => null),
|
||||
updateSkillFileContent: jest.fn(async () => undefined),
|
||||
updateSkillFileCodeEnvIds: jest.fn(async (updates) => ({
|
||||
matchedCount: updates.length,
|
||||
modifiedCount: updates.length,
|
||||
})),
|
||||
};
|
||||
|
||||
const methods = createDeploymentSkillMethods(base);
|
||||
const mergedIds = mergeDeploymentSkillIds([dbId]);
|
||||
|
||||
expect(mergedIds.map((id) => id.toString())).toEqual([
|
||||
dbId.toString(),
|
||||
deploymentId.toString(),
|
||||
]);
|
||||
|
||||
const deploymentSkill = await methods.getSkillById(deploymentId);
|
||||
expect(deploymentSkill).toMatchObject({ name: 'analysis-kit', source: 'deployment' });
|
||||
expect(base.getSkillById).not.toHaveBeenCalled();
|
||||
|
||||
const listed = await methods.listSkillsByAccess?.({
|
||||
accessibleIds: mergedIds,
|
||||
limit: 10,
|
||||
});
|
||||
expect(listed?.skills.map((skill) => skill.name).sort()).toEqual(['analysis-kit', 'db-skill']);
|
||||
expect(base.listSkillsByAccess).toHaveBeenCalledWith({
|
||||
accessibleIds: [dbId],
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
const alwaysApply = await methods.listAlwaysApplySkills?.({
|
||||
accessibleIds: mergedIds,
|
||||
limit: 10,
|
||||
});
|
||||
expect(alwaysApply?.skills.map((skill) => skill.name).sort()).toEqual([
|
||||
'analysis-kit',
|
||||
'db-always',
|
||||
]);
|
||||
expect(base.listAlwaysApplySkills).toHaveBeenCalledWith({
|
||||
accessibleIds: [dbId],
|
||||
limit: 10,
|
||||
});
|
||||
|
||||
const files = await methods.listSkillFiles?.(deploymentId);
|
||||
expect(files?.map((file) => file.relativePath).sort()).toEqual([
|
||||
'assets/pixel.bin',
|
||||
'references/guide.txt',
|
||||
]);
|
||||
const guideFile = files?.find((file) => file.relativePath === 'references/guide.txt');
|
||||
expect(guideFile?._id).toBeInstanceOf(Types.ObjectId);
|
||||
expect(guideFile?.skillId.toString()).toBe(deploymentId.toString());
|
||||
expect(guideFile).toMatchObject({
|
||||
file_id: expect.any(String),
|
||||
filename: 'guide.txt',
|
||||
source: 'deployment',
|
||||
mimeType: 'text/plain',
|
||||
bytes: 'reference notes'.length,
|
||||
category: 'reference',
|
||||
isExecutable: false,
|
||||
author: expect.any(Types.ObjectId),
|
||||
});
|
||||
expect(guideFile?.createdAt.getTime()).toEqual(expect.any(Number));
|
||||
expect(guideFile?.updatedAt.getTime()).toEqual(expect.any(Number));
|
||||
expect(guideFile).not.toHaveProperty('content');
|
||||
expect(base.listSkillFiles).not.toHaveBeenCalled();
|
||||
|
||||
await methods.updateSkillFileContent?.(deploymentId, 'references/guide.txt', {
|
||||
content: 'updated content',
|
||||
isBinary: false,
|
||||
});
|
||||
const updatedFile = await methods.getSkillFileByPath?.(deploymentId, 'references/guide.txt');
|
||||
expect(updatedFile?.content).toBe('updated content');
|
||||
expect(base.updateSkillFileContent).not.toHaveBeenCalled();
|
||||
|
||||
const codeEnvRef: CodeEnvRef = {
|
||||
kind: 'skill',
|
||||
id: deploymentId.toString(),
|
||||
version: 1,
|
||||
storage_session_id: 'storage-session',
|
||||
file_id: 'file-id',
|
||||
};
|
||||
const updateResult = await methods.updateSkillFileCodeEnvIds?.([
|
||||
{ skillId: deploymentId, relativePath: 'references/guide.txt', codeEnvRef },
|
||||
{ skillId: dbId, relativePath: 'references/db.txt', codeEnvRef },
|
||||
]);
|
||||
|
||||
expect(updateResult).toEqual({ matchedCount: 1, modifiedCount: 1 });
|
||||
expect(base.updateSkillFileCodeEnvIds).toHaveBeenCalledWith([
|
||||
{ skillId: dbId, relativePath: 'references/db.txt', codeEnvRef },
|
||||
]);
|
||||
expect(
|
||||
(await methods.getSkillFileByPath?.(deploymentId, 'references/guide.txt'))?.codeEnvRef,
|
||||
).toEqual(codeEnvRef);
|
||||
});
|
||||
});
|
||||
983
packages/api/src/skills/deployment.ts
Normal file
983
packages/api/src/skills/deployment.ts
Normal file
|
|
@ -0,0 +1,983 @@
|
|||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import crypto from 'crypto';
|
||||
import yaml from 'js-yaml';
|
||||
import { Types } from 'mongoose';
|
||||
import {
|
||||
logger,
|
||||
partitionIssues,
|
||||
validateSkillName,
|
||||
validateSkillBody,
|
||||
validateRelativePath,
|
||||
inferSkillFileCategory,
|
||||
validateSkillFrontmatter,
|
||||
validateSkillDescription,
|
||||
deriveStructuredFrontmatterFields,
|
||||
} from '@librechat/data-schemas';
|
||||
import type { ValidationIssue } from '@librechat/data-schemas';
|
||||
import type { CodeEnvRef } from 'librechat-data-provider';
|
||||
import { parseFrontmatter, guessMimeType } from './import';
|
||||
|
||||
export const DEPLOYMENT_SKILLS_DIR_ENV = 'DEPLOYMENT_SKILLS_DIR';
|
||||
export const DEFAULT_DEPLOYMENT_SKILLS_DIR = 'skill';
|
||||
export const DEPLOYMENT_SKILL_SOURCE = 'deployment';
|
||||
export const DEPLOYMENT_SKILL_FILE_SOURCE = 'deployment';
|
||||
|
||||
const SKILL_MD = 'SKILL.md';
|
||||
const DEPLOYMENT_AUTHOR_ID = new Types.ObjectId('de9100000000000000000000');
|
||||
const MAX_CACHED_TEXT_BYTES = 512 * 1024;
|
||||
|
||||
type SkillId = Types.ObjectId | string;
|
||||
|
||||
export type DeploymentSkillFile = {
|
||||
_id: Types.ObjectId;
|
||||
skillId: Types.ObjectId;
|
||||
relativePath: string;
|
||||
file_id: string;
|
||||
filename: string;
|
||||
filepath: string;
|
||||
source: typeof DEPLOYMENT_SKILL_FILE_SOURCE;
|
||||
mimeType: string;
|
||||
bytes: number;
|
||||
category: 'script' | 'reference' | 'asset' | 'other';
|
||||
isExecutable: boolean;
|
||||
author: Types.ObjectId;
|
||||
content?: string;
|
||||
isBinary?: boolean;
|
||||
codeEnvRef?: CodeEnvRef;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export type DeploymentSkill = {
|
||||
_id: Types.ObjectId;
|
||||
name: string;
|
||||
displayTitle?: string;
|
||||
description: string;
|
||||
body: string;
|
||||
frontmatter: Record<string, unknown>;
|
||||
category: string;
|
||||
disableModelInvocation?: boolean;
|
||||
userInvocable?: boolean;
|
||||
allowedTools?: string[];
|
||||
author: Types.ObjectId;
|
||||
authorName: string;
|
||||
version: number;
|
||||
source: typeof DEPLOYMENT_SKILL_SOURCE;
|
||||
sourceMetadata: { deployment: true; directory: string };
|
||||
fileCount: number;
|
||||
alwaysApply: boolean;
|
||||
isPublic: true;
|
||||
deployment: true;
|
||||
files: DeploymentSkillFile[];
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
type SkillLookupOptions = {
|
||||
preferUserInvocable?: boolean;
|
||||
preferModelInvocable?: boolean;
|
||||
};
|
||||
|
||||
type SkillSummaryRow = {
|
||||
_id: Types.ObjectId;
|
||||
name: string;
|
||||
displayTitle?: string;
|
||||
description: string;
|
||||
category?: string;
|
||||
disableModelInvocation?: boolean;
|
||||
userInvocable?: boolean;
|
||||
allowedTools?: string[];
|
||||
author: Types.ObjectId;
|
||||
authorName?: string;
|
||||
version?: number;
|
||||
source?: string;
|
||||
sourceMetadata?: Record<string, unknown>;
|
||||
fileCount?: number;
|
||||
alwaysApply?: boolean;
|
||||
isPublic?: boolean;
|
||||
tenantId?: string;
|
||||
deployment?: boolean;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
};
|
||||
|
||||
type SkillDetailRow = SkillSummaryRow & {
|
||||
body: string;
|
||||
frontmatter?: Record<string, unknown>;
|
||||
version: number;
|
||||
fileCount: number;
|
||||
};
|
||||
|
||||
type AlwaysApplySkillRow = {
|
||||
_id: Types.ObjectId;
|
||||
name: string;
|
||||
body: string;
|
||||
author: Types.ObjectId | string;
|
||||
allowedTools?: string[];
|
||||
deployment?: boolean;
|
||||
updatedAt?: Date;
|
||||
};
|
||||
|
||||
type ListSkillsByAccessParams = {
|
||||
accessibleIds: Types.ObjectId[];
|
||||
category?: string;
|
||||
search?: string;
|
||||
limit: number;
|
||||
cursor?: string | null;
|
||||
};
|
||||
|
||||
type ListSkillsByAccessResult = {
|
||||
skills: SkillSummaryRow[];
|
||||
has_more?: boolean;
|
||||
after?: string | null;
|
||||
};
|
||||
|
||||
type ListAlwaysApplyParams = {
|
||||
accessibleIds: Types.ObjectId[];
|
||||
limit: number;
|
||||
cursor?: string | null;
|
||||
};
|
||||
|
||||
type ListAlwaysApplyResult = {
|
||||
skills: AlwaysApplySkillRow[];
|
||||
has_more?: boolean;
|
||||
after?: string | null;
|
||||
};
|
||||
|
||||
type SkillFileRow = Omit<DeploymentSkillFile, 'codeEnvRef' | 'content' | 'isBinary'> & {
|
||||
storageKey?: string;
|
||||
storageRegion?: string;
|
||||
tenantId?: string;
|
||||
};
|
||||
|
||||
type SkillFileContentRow = SkillFileRow & {
|
||||
codeEnvRef?: CodeEnvRef;
|
||||
content?: string;
|
||||
isBinary?: boolean;
|
||||
};
|
||||
|
||||
export type DeploymentSkillBaseMethods = {
|
||||
getSkillById?: (id: SkillId) => Promise<SkillDetailRow | null>;
|
||||
getSkillByName?: (
|
||||
name: string,
|
||||
accessibleIds: Types.ObjectId[],
|
||||
options?: SkillLookupOptions,
|
||||
) => Promise<SkillDetailRow | null>;
|
||||
listSkillsByAccess?: (params: ListSkillsByAccessParams) => Promise<ListSkillsByAccessResult>;
|
||||
listAlwaysApplySkills?: (params: ListAlwaysApplyParams) => Promise<ListAlwaysApplyResult>;
|
||||
listSkillFiles?: (skillId: SkillId) => Promise<SkillFileRow[]>;
|
||||
getSkillFileByPath?: (
|
||||
skillId: SkillId,
|
||||
relativePath: string,
|
||||
) => Promise<SkillFileContentRow | null>;
|
||||
updateSkillFileContent?: (
|
||||
skillId: SkillId,
|
||||
relativePath: string,
|
||||
update: { content?: string; isBinary?: boolean },
|
||||
) => Promise<void>;
|
||||
updateSkillFileCodeEnvIds?: (
|
||||
updates: Array<{ skillId: SkillId; relativePath: string; codeEnvRef: CodeEnvRef }>,
|
||||
) => Promise<{ matchedCount: number; modifiedCount: number } | void>;
|
||||
};
|
||||
|
||||
type Cursor = { updatedAt: Date; _id: Types.ObjectId };
|
||||
|
||||
type LoadDeploymentSkillsOptions = {
|
||||
projectRoot?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
};
|
||||
|
||||
type DirectoryResolution = {
|
||||
directory: string;
|
||||
explicitlyConfigured: boolean;
|
||||
};
|
||||
|
||||
type LoadedSkillDirectory = {
|
||||
directory: string;
|
||||
relativeDirectory: string;
|
||||
};
|
||||
|
||||
export class DeploymentSkillRegistry {
|
||||
private readonly skillsById = new Map<string, DeploymentSkill>();
|
||||
private readonly skillsByName = new Map<string, DeploymentSkill>();
|
||||
private readonly filesByPath = new Map<string, DeploymentSkillFile>();
|
||||
|
||||
constructor(
|
||||
private readonly directory: string | null,
|
||||
skills: DeploymentSkill[],
|
||||
) {
|
||||
for (const skill of skills) {
|
||||
this.skillsById.set(skill._id.toString(), skill);
|
||||
this.skillsByName.set(skill.name, skill);
|
||||
for (const file of skill.files) {
|
||||
this.filesByPath.set(file.filepath, file);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getDirectory(): string | null {
|
||||
return this.directory;
|
||||
}
|
||||
|
||||
list(): DeploymentSkill[] {
|
||||
return Array.from(this.skillsById.values());
|
||||
}
|
||||
|
||||
ids(): Types.ObjectId[] {
|
||||
return this.list().map((skill) => skill._id);
|
||||
}
|
||||
|
||||
hasId(id: SkillId | undefined): boolean {
|
||||
return id != null && this.skillsById.has(id.toString());
|
||||
}
|
||||
|
||||
getById(id: SkillId): DeploymentSkill | null {
|
||||
return this.skillsById.get(id.toString()) ?? null;
|
||||
}
|
||||
|
||||
getByName(
|
||||
name: string,
|
||||
accessibleIds: Types.ObjectId[],
|
||||
_options?: SkillLookupOptions,
|
||||
): DeploymentSkill | null {
|
||||
const skill = this.skillsByName.get(name);
|
||||
if (!skill) {
|
||||
return null;
|
||||
}
|
||||
if (!hasAccessibleId(accessibleIds, skill._id)) {
|
||||
return null;
|
||||
}
|
||||
return skill;
|
||||
}
|
||||
|
||||
listByAccess(params: ListSkillsByAccessParams): DeploymentSkill[] {
|
||||
const accessibleSet = new Set(params.accessibleIds.map((id) => id.toString()));
|
||||
const cursor = decodeCursor(params.cursor);
|
||||
const search = params.search?.toLowerCase();
|
||||
return this.list()
|
||||
.filter((skill) => accessibleSet.has(skill._id.toString()))
|
||||
.filter((skill) => !params.category || skill.category === params.category)
|
||||
.filter((skill) => {
|
||||
if (!search) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
skill.name.toLowerCase().includes(search) ||
|
||||
skill.description.toLowerCase().includes(search) ||
|
||||
(skill.displayTitle?.toLowerCase().includes(search) ?? false)
|
||||
);
|
||||
})
|
||||
.filter((skill) => isAfterCursor(skill, cursor))
|
||||
.sort(compareBySkillCursor);
|
||||
}
|
||||
|
||||
listAlwaysApply(params: ListAlwaysApplyParams): DeploymentSkill[] {
|
||||
const accessibleSet = new Set(params.accessibleIds.map((id) => id.toString()));
|
||||
const cursor = decodeCursor(params.cursor);
|
||||
return this.list()
|
||||
.filter((skill) => skill.alwaysApply === true)
|
||||
.filter((skill) => accessibleSet.has(skill._id.toString()))
|
||||
.filter((skill) => isAfterCursor(skill, cursor))
|
||||
.sort(compareBySkillCursor);
|
||||
}
|
||||
|
||||
listFiles(skillId: SkillId): DeploymentSkillFile[] | null {
|
||||
const skill = this.getById(skillId);
|
||||
return skill ? [...skill.files] : null;
|
||||
}
|
||||
|
||||
getFileByPath(skillId: SkillId, relativePath: string): DeploymentSkillFile | null {
|
||||
const skill = this.getById(skillId);
|
||||
if (!skill) {
|
||||
return null;
|
||||
}
|
||||
return skill.files.find((file) => file.relativePath === relativePath) ?? null;
|
||||
}
|
||||
|
||||
hasFilePath(filepath: string): boolean {
|
||||
return this.filesByPath.has(filepath);
|
||||
}
|
||||
|
||||
updateFileCodeEnvRefs(
|
||||
updates: Array<{ skillId: SkillId; relativePath: string; codeEnvRef: CodeEnvRef }>,
|
||||
): Array<{ skillId: SkillId; relativePath: string; codeEnvRef: CodeEnvRef }> {
|
||||
const dbUpdates: Array<{ skillId: SkillId; relativePath: string; codeEnvRef: CodeEnvRef }> = [];
|
||||
for (const update of updates) {
|
||||
const file = this.getFileByPath(update.skillId, update.relativePath);
|
||||
if (!file) {
|
||||
dbUpdates.push(update);
|
||||
continue;
|
||||
}
|
||||
file.codeEnvRef = update.codeEnvRef;
|
||||
}
|
||||
return dbUpdates;
|
||||
}
|
||||
}
|
||||
|
||||
let registry = new DeploymentSkillRegistry(null, []);
|
||||
|
||||
export function getDeploymentSkillRegistry(): DeploymentSkillRegistry {
|
||||
return registry;
|
||||
}
|
||||
|
||||
export function getDeploymentSkillIds(): Types.ObjectId[] {
|
||||
return registry.ids();
|
||||
}
|
||||
|
||||
export function mergeDeploymentSkillIds(ids: Array<SkillId>): Types.ObjectId[] {
|
||||
const seen = new Set<string>();
|
||||
const merged: Types.ObjectId[] = [];
|
||||
for (const id of [...ids, ...registry.ids()]) {
|
||||
const oid = typeof id === 'string' ? new Types.ObjectId(id) : id;
|
||||
const key = oid.toString();
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
merged.push(oid);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function isDeploymentSkillId(id: SkillId | undefined): boolean {
|
||||
return registry.hasId(id);
|
||||
}
|
||||
|
||||
export function isDeploymentSkillFileSource(source: unknown): boolean {
|
||||
return source === DEPLOYMENT_SKILL_FILE_SOURCE;
|
||||
}
|
||||
|
||||
export function getDeploymentSkillById(id: SkillId): DeploymentSkill | null {
|
||||
return registry.getById(id);
|
||||
}
|
||||
|
||||
export function isDeploymentSkillFilePath(filepath: string): boolean {
|
||||
return registry.hasFilePath(filepath);
|
||||
}
|
||||
|
||||
export async function getDeploymentSkillDownloadStream(
|
||||
filepath: string,
|
||||
): Promise<NodeJS.ReadableStream> {
|
||||
if (!registry.hasFilePath(filepath)) {
|
||||
throw new Error('Deployment skill file is not registered');
|
||||
}
|
||||
return fs.createReadStream(filepath);
|
||||
}
|
||||
|
||||
export function updateDeploymentSkillFileCodeEnvRefs(
|
||||
updates: Array<{ skillId: SkillId; relativePath: string; codeEnvRef: CodeEnvRef }>,
|
||||
): Array<{ skillId: SkillId; relativePath: string; codeEnvRef: CodeEnvRef }> {
|
||||
return registry.updateFileCodeEnvRefs(updates);
|
||||
}
|
||||
|
||||
export function resolveDeploymentSkillDirectory(
|
||||
options: LoadDeploymentSkillsOptions = {},
|
||||
): DirectoryResolution {
|
||||
const env = options.env ?? process.env;
|
||||
const projectRoot = options.projectRoot ?? process.cwd();
|
||||
const configured = env[DEPLOYMENT_SKILLS_DIR_ENV]?.trim();
|
||||
const rawDirectory =
|
||||
configured && configured.length > 0 ? configured : DEFAULT_DEPLOYMENT_SKILLS_DIR;
|
||||
return {
|
||||
directory: path.isAbsolute(rawDirectory)
|
||||
? rawDirectory
|
||||
: path.resolve(projectRoot, rawDirectory),
|
||||
explicitlyConfigured: configured != null && configured.length > 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function initializeDeploymentSkills(
|
||||
options: LoadDeploymentSkillsOptions = {},
|
||||
): Promise<DeploymentSkillRegistry> {
|
||||
const resolved = resolveDeploymentSkillDirectory(options);
|
||||
registry = await loadDeploymentSkillsFromDirectory(resolved.directory, {
|
||||
projectRoot: options.projectRoot ?? process.cwd(),
|
||||
explicitlyConfigured: resolved.explicitlyConfigured,
|
||||
});
|
||||
const count = registry.list().length;
|
||||
if (count > 0) {
|
||||
logger.info(
|
||||
`[deploymentSkills] Loaded ${count} deployment skill(s) from ${registry.getDirectory()}`,
|
||||
);
|
||||
} else {
|
||||
logger.debug(`[deploymentSkills] No deployment skills loaded from ${resolved.directory}`);
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
|
||||
export async function loadDeploymentSkillsFromDirectory(
|
||||
directory: string,
|
||||
options: { projectRoot?: string; explicitlyConfigured?: boolean } = {},
|
||||
): Promise<DeploymentSkillRegistry> {
|
||||
let rootStat: fs.Stats;
|
||||
try {
|
||||
rootStat = await fs.promises.stat(directory);
|
||||
} catch (error) {
|
||||
if (
|
||||
(error as NodeJS.ErrnoException).code === 'ENOENT' &&
|
||||
options.explicitlyConfigured !== true
|
||||
) {
|
||||
return new DeploymentSkillRegistry(directory, []);
|
||||
}
|
||||
throw new Error(`Deployment skills directory not found: ${directory}`);
|
||||
}
|
||||
if (!rootStat.isDirectory()) {
|
||||
throw new Error(`Deployment skills path must be a directory: ${directory}`);
|
||||
}
|
||||
|
||||
const skillDirectories = await findSkillDirectories(directory, options.projectRoot ?? directory);
|
||||
const skills = await Promise.all(
|
||||
skillDirectories.map((skillDirectory) => loadDeploymentSkill(skillDirectory, directory)),
|
||||
);
|
||||
validateUniqueNames(skills);
|
||||
return new DeploymentSkillRegistry(directory, skills.sort(compareBySkillCursor));
|
||||
}
|
||||
|
||||
export function createDeploymentSkillMethods<T extends DeploymentSkillBaseMethods>(
|
||||
base: T,
|
||||
): T & Required<Pick<DeploymentSkillBaseMethods, 'getSkillById'>> {
|
||||
const methods = {
|
||||
...base,
|
||||
getSkillById: async (id: SkillId): Promise<SkillDetailRow | null> => {
|
||||
const deployment = registry.getById(id);
|
||||
if (deployment) {
|
||||
return toSkillDetailRow(deployment);
|
||||
}
|
||||
return base.getSkillById ? base.getSkillById(id) : null;
|
||||
},
|
||||
getSkillByName: async (
|
||||
name: string,
|
||||
accessibleIds: Types.ObjectId[],
|
||||
options?: SkillLookupOptions,
|
||||
): Promise<SkillDetailRow | null> => {
|
||||
const dbSkill = base.getSkillByName
|
||||
? await base.getSkillByName(name, stripDeploymentIds(accessibleIds), options)
|
||||
: null;
|
||||
const deployment = registry.getByName(name, accessibleIds, options);
|
||||
return pickPreferredSkill(
|
||||
[dbSkill, deployment ? toSkillDetailRow(deployment) : null],
|
||||
options,
|
||||
);
|
||||
},
|
||||
listSkillsByAccess: async (
|
||||
params: ListSkillsByAccessParams,
|
||||
): Promise<ListSkillsByAccessResult> => {
|
||||
const dbResult = base.listSkillsByAccess
|
||||
? await base.listSkillsByAccess({
|
||||
...params,
|
||||
accessibleIds: stripDeploymentIds(params.accessibleIds),
|
||||
})
|
||||
: { skills: [], has_more: false, after: null };
|
||||
return mergeSkillPage({
|
||||
dbResult,
|
||||
deploymentRows: registry.listByAccess(params).map(toSkillSummaryRow),
|
||||
limit: params.limit,
|
||||
});
|
||||
},
|
||||
listAlwaysApplySkills: async (
|
||||
params: ListAlwaysApplyParams,
|
||||
): Promise<ListAlwaysApplyResult> => {
|
||||
const dbResult = base.listAlwaysApplySkills
|
||||
? await base.listAlwaysApplySkills({
|
||||
...params,
|
||||
accessibleIds: stripDeploymentIds(params.accessibleIds),
|
||||
})
|
||||
: { skills: [], has_more: false, after: null };
|
||||
return mergeAlwaysApplyPage({
|
||||
dbResult,
|
||||
deploymentRows: registry.listAlwaysApply(params).map(toAlwaysApplyRow),
|
||||
limit: params.limit,
|
||||
});
|
||||
},
|
||||
listSkillFiles: async (skillId: SkillId): Promise<SkillFileRow[]> => {
|
||||
const deploymentFiles = registry.listFiles(skillId);
|
||||
if (deploymentFiles) {
|
||||
return deploymentFiles.map(toSkillFileRow);
|
||||
}
|
||||
return base.listSkillFiles ? base.listSkillFiles(skillId) : [];
|
||||
},
|
||||
getSkillFileByPath: async (
|
||||
skillId: SkillId,
|
||||
relativePath: string,
|
||||
): Promise<SkillFileContentRow | null> => {
|
||||
const deploymentFile = registry.getFileByPath(skillId, relativePath);
|
||||
if (deploymentFile) {
|
||||
return toSkillFileContentRow(deploymentFile);
|
||||
}
|
||||
return base.getSkillFileByPath ? base.getSkillFileByPath(skillId, relativePath) : null;
|
||||
},
|
||||
updateSkillFileContent: async (
|
||||
skillId: SkillId,
|
||||
relativePath: string,
|
||||
update: { content?: string; isBinary?: boolean },
|
||||
): Promise<void> => {
|
||||
const deploymentFile = registry.getFileByPath(skillId, relativePath);
|
||||
if (deploymentFile) {
|
||||
if (update.content !== undefined) {
|
||||
deploymentFile.content = update.content;
|
||||
}
|
||||
if (update.isBinary !== undefined) {
|
||||
deploymentFile.isBinary = update.isBinary;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (base.updateSkillFileContent) {
|
||||
await base.updateSkillFileContent(skillId, relativePath, update);
|
||||
}
|
||||
},
|
||||
updateSkillFileCodeEnvIds: async (
|
||||
updates: Array<{ skillId: SkillId; relativePath: string; codeEnvRef: CodeEnvRef }>,
|
||||
): Promise<{ matchedCount: number; modifiedCount: number } | void> => {
|
||||
const dbUpdates = registry.updateFileCodeEnvRefs(updates);
|
||||
if (dbUpdates.length === 0) {
|
||||
return { matchedCount: updates.length, modifiedCount: updates.length };
|
||||
}
|
||||
return base.updateSkillFileCodeEnvIds
|
||||
? base.updateSkillFileCodeEnvIds(dbUpdates)
|
||||
: { matchedCount: 0, modifiedCount: 0 };
|
||||
},
|
||||
};
|
||||
return methods as T & Required<Pick<DeploymentSkillBaseMethods, 'getSkillById'>>;
|
||||
}
|
||||
|
||||
async function findSkillDirectories(
|
||||
directory: string,
|
||||
projectRoot: string,
|
||||
): Promise<LoadedSkillDirectory[]> {
|
||||
const directories: LoadedSkillDirectory[] = [];
|
||||
if (await fileExists(path.join(directory, SKILL_MD))) {
|
||||
directories.push({
|
||||
directory,
|
||||
relativeDirectory: relativeToRoot(projectRoot, directory),
|
||||
});
|
||||
}
|
||||
|
||||
const entries = await fs.promises.readdir(directory, { withFileTypes: true });
|
||||
const childDirectories = entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => path.join(directory, entry.name));
|
||||
const childSkillChecks = await Promise.all(
|
||||
childDirectories.map(async (child) =>
|
||||
(await fileExists(path.join(child, SKILL_MD)))
|
||||
? {
|
||||
directory: child,
|
||||
relativeDirectory: relativeToRoot(projectRoot, child),
|
||||
}
|
||||
: null,
|
||||
),
|
||||
);
|
||||
for (const child of childSkillChecks) {
|
||||
if (child) {
|
||||
directories.push(child);
|
||||
}
|
||||
}
|
||||
return directories;
|
||||
}
|
||||
|
||||
async function loadDeploymentSkill(
|
||||
skillDirectory: LoadedSkillDirectory,
|
||||
rootDirectory: string,
|
||||
): Promise<DeploymentSkill> {
|
||||
const skillMdPath = path.join(skillDirectory.directory, SKILL_MD);
|
||||
const [content, stat] = await Promise.all([
|
||||
fs.promises.readFile(skillMdPath, 'utf8'),
|
||||
fs.promises.stat(skillMdPath),
|
||||
]);
|
||||
const parsed = parseFrontmatter(content);
|
||||
const structured = parseStructuredFrontmatter(content);
|
||||
if ('error' in structured) {
|
||||
throw new Error(`${skillDirectory.relativeDirectory}/${SKILL_MD}: ${structured.error}`);
|
||||
}
|
||||
const frontmatter = structured.frontmatter ?? {};
|
||||
const description =
|
||||
typeof frontmatter.description === 'string' ? frontmatter.description : parsed.description;
|
||||
const name = typeof frontmatter.name === 'string' ? frontmatter.name : parsed.name;
|
||||
const issues: ValidationIssue[] = [
|
||||
...validateSkillName(name),
|
||||
...validateSkillDescription(description),
|
||||
...validateSkillBody(content),
|
||||
...validateSkillFrontmatter(frontmatter),
|
||||
];
|
||||
if (parsed.invalidBooleans.length > 0) {
|
||||
issues.push(
|
||||
...parsed.invalidBooleans.map((key) => ({
|
||||
field: `frontmatter.${key}`,
|
||||
code: 'INVALID_TYPE',
|
||||
message: `"${key}" must be a boolean (true or false)`,
|
||||
})),
|
||||
);
|
||||
}
|
||||
const { errors, warnings } = partitionIssues(issues);
|
||||
if (errors.length > 0) {
|
||||
throw new Error(
|
||||
`${skillDirectory.relativeDirectory}/${SKILL_MD}: ${errors
|
||||
.map((issue) => `${issue.field}: ${issue.message}`)
|
||||
.join('; ')}`,
|
||||
);
|
||||
}
|
||||
if (warnings.length > 0) {
|
||||
logger.warn(
|
||||
`[deploymentSkills] ${skillDirectory.relativeDirectory}/${SKILL_MD}: ${warnings
|
||||
.map((issue) => `${issue.field}: ${issue.message}`)
|
||||
.join('; ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
const derived = deriveStructuredFrontmatterFields(frontmatter);
|
||||
const skillId = stableObjectId(`deployment-skill:${name}`);
|
||||
const files = await loadDeploymentSkillFiles({
|
||||
skillId,
|
||||
skillName: name,
|
||||
skillDirectory: skillDirectory.directory,
|
||||
rootDirectory,
|
||||
});
|
||||
return {
|
||||
_id: skillId,
|
||||
name,
|
||||
description,
|
||||
body: content,
|
||||
frontmatter,
|
||||
category: '',
|
||||
author: DEPLOYMENT_AUTHOR_ID,
|
||||
authorName: 'Deployment',
|
||||
version: 1,
|
||||
source: DEPLOYMENT_SKILL_SOURCE,
|
||||
sourceMetadata: {
|
||||
deployment: true,
|
||||
directory: skillDirectory.relativeDirectory,
|
||||
},
|
||||
fileCount: files.length,
|
||||
alwaysApply: parsed.alwaysApply ?? false,
|
||||
isPublic: true,
|
||||
deployment: true,
|
||||
files,
|
||||
createdAt: stat.birthtime,
|
||||
updatedAt: stat.mtime,
|
||||
...derived,
|
||||
};
|
||||
}
|
||||
|
||||
async function loadDeploymentSkillFiles({
|
||||
skillId,
|
||||
skillName,
|
||||
skillDirectory,
|
||||
rootDirectory,
|
||||
}: {
|
||||
skillId: Types.ObjectId;
|
||||
skillName: string;
|
||||
skillDirectory: string;
|
||||
rootDirectory: string;
|
||||
}): Promise<DeploymentSkillFile[]> {
|
||||
const files = await collectSkillFiles(skillDirectory, skillDirectory);
|
||||
const rows = await Promise.all(
|
||||
files.map(async (filePath) => {
|
||||
const relativePath = normalizePath(path.relative(skillDirectory, filePath));
|
||||
const issues = validateRelativePath(relativePath);
|
||||
if (issues.length > 0) {
|
||||
throw new Error(
|
||||
`${relativeToRoot(rootDirectory, filePath)}: ${issues
|
||||
.map((issue) => `${issue.field}: ${issue.message}`)
|
||||
.join('; ')}`,
|
||||
);
|
||||
}
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
const mimeType = guessMimeType(relativePath);
|
||||
const cache = await readCachedFileContent(filePath, stat.size);
|
||||
return {
|
||||
_id: stableObjectId(`deployment-skill-file:${skillName}:${relativePath}`),
|
||||
skillId,
|
||||
relativePath,
|
||||
file_id: stableObjectId(`deployment-skill-file-id:${skillName}:${relativePath}`).toString(),
|
||||
filename: path.basename(relativePath),
|
||||
filepath: filePath,
|
||||
source: DEPLOYMENT_SKILL_FILE_SOURCE,
|
||||
mimeType,
|
||||
bytes: stat.size,
|
||||
category: inferSkillFileCategory(relativePath),
|
||||
isExecutable: false,
|
||||
author: DEPLOYMENT_AUTHOR_ID,
|
||||
createdAt: stat.birthtime,
|
||||
updatedAt: stat.mtime,
|
||||
...cache,
|
||||
} satisfies DeploymentSkillFile;
|
||||
}),
|
||||
);
|
||||
return rows.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
||||
}
|
||||
|
||||
async function collectSkillFiles(root: string, directory: string): Promise<string[]> {
|
||||
const entries = await fs.promises.readdir(directory, { withFileTypes: true });
|
||||
const results = await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
const filePath = path.join(directory, entry.name);
|
||||
const relativePath = normalizePath(path.relative(root, filePath));
|
||||
if (relativePath === SKILL_MD) {
|
||||
return [];
|
||||
}
|
||||
if (entry.isSymbolicLink()) {
|
||||
throw new Error(`${relativePath}: symlinks are not allowed in deployment skills`);
|
||||
}
|
||||
if (entry.isDirectory()) {
|
||||
return collectSkillFiles(root, filePath);
|
||||
}
|
||||
if (!entry.isFile()) {
|
||||
return [];
|
||||
}
|
||||
return [filePath];
|
||||
}),
|
||||
);
|
||||
return results.flat();
|
||||
}
|
||||
|
||||
async function readCachedFileContent(
|
||||
filePath: string,
|
||||
bytes: number,
|
||||
): Promise<Pick<DeploymentSkillFile, 'content' | 'isBinary'>> {
|
||||
const file = await fs.promises.open(filePath, 'r');
|
||||
try {
|
||||
const probe = Buffer.alloc(Math.min(bytes, 8192));
|
||||
if (probe.length > 0) {
|
||||
await file.read(probe, 0, probe.length, 0);
|
||||
}
|
||||
if (probe.includes(0)) {
|
||||
return { isBinary: true };
|
||||
}
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
if (bytes > MAX_CACHED_TEXT_BYTES) {
|
||||
return { isBinary: false };
|
||||
}
|
||||
return {
|
||||
isBinary: false,
|
||||
content: await fs.promises.readFile(filePath, 'utf8'),
|
||||
};
|
||||
}
|
||||
|
||||
function parseStructuredFrontmatter(
|
||||
content: string,
|
||||
): { frontmatter?: Record<string, unknown>; error?: undefined } | { error: string } {
|
||||
const trimmed = content.trim();
|
||||
if (!trimmed.startsWith('---')) {
|
||||
return { frontmatter: {} };
|
||||
}
|
||||
const after = trimmed.slice(3);
|
||||
const closingIdx = after.indexOf('\n---');
|
||||
if (closingIdx === -1) {
|
||||
return { error: `Invalid ${SKILL_MD} frontmatter: missing closing "---".` };
|
||||
}
|
||||
try {
|
||||
const parsed = yaml.load(after.slice(0, closingIdx));
|
||||
if (parsed == null) {
|
||||
return { frontmatter: {} };
|
||||
}
|
||||
if (typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return { error: `${SKILL_MD} frontmatter must be a YAML mapping.` };
|
||||
}
|
||||
return { frontmatter: parsed as Record<string, unknown> };
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return { error: `Invalid ${SKILL_MD} frontmatter: ${message}` };
|
||||
}
|
||||
}
|
||||
|
||||
function validateUniqueNames(skills: DeploymentSkill[]): void {
|
||||
const seen = new Set<string>();
|
||||
for (const skill of skills) {
|
||||
if (seen.has(skill.name)) {
|
||||
throw new Error(`Duplicate deployment skill name "${skill.name}"`);
|
||||
}
|
||||
seen.add(skill.name);
|
||||
}
|
||||
}
|
||||
|
||||
function mergeSkillPage({
|
||||
dbResult,
|
||||
deploymentRows,
|
||||
limit,
|
||||
}: {
|
||||
dbResult: ListSkillsByAccessResult;
|
||||
deploymentRows: SkillSummaryRow[];
|
||||
limit: number;
|
||||
}): ListSkillsByAccessResult {
|
||||
const boundedLimit = Math.min(Math.max(1, limit || 20), 100);
|
||||
const merged = [...deploymentRows, ...dbResult.skills].sort(compareBySkillCursor);
|
||||
const sliced = merged.slice(0, boundedLimit);
|
||||
const hasMore = merged.length > boundedLimit || dbResult.has_more === true;
|
||||
return {
|
||||
skills: sliced,
|
||||
has_more: hasMore,
|
||||
after: hasMore && sliced.length > 0 ? encodeCursor(sliced[sliced.length - 1]) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeAlwaysApplyPage({
|
||||
dbResult,
|
||||
deploymentRows,
|
||||
limit,
|
||||
}: {
|
||||
dbResult: ListAlwaysApplyResult;
|
||||
deploymentRows: AlwaysApplySkillRow[];
|
||||
limit: number;
|
||||
}): ListAlwaysApplyResult {
|
||||
const boundedLimit = Math.min(Math.max(1, limit || 20), 100);
|
||||
const merged = [...deploymentRows, ...dbResult.skills].sort(compareBySkillCursor);
|
||||
const sliced = merged.slice(0, boundedLimit);
|
||||
const hasMore = merged.length > boundedLimit || dbResult.has_more === true;
|
||||
return {
|
||||
skills: sliced,
|
||||
has_more: hasMore,
|
||||
after: hasMore && sliced.length > 0 ? encodeCursor(sliced[sliced.length - 1]) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function pickPreferredSkill(
|
||||
rows: Array<SkillDetailRow | null>,
|
||||
options?: SkillLookupOptions,
|
||||
): SkillDetailRow | null {
|
||||
const candidates = rows.filter((row): row is SkillDetailRow => row != null);
|
||||
if (candidates.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const preferred = candidates.filter((row) => matchesLookupPreference(row, options));
|
||||
return (preferred.length > 0 ? preferred : candidates).sort(compareBySkillCursor)[0];
|
||||
}
|
||||
|
||||
function matchesLookupPreference(
|
||||
row: Pick<SkillSummaryRow, 'disableModelInvocation' | 'userInvocable'>,
|
||||
options?: SkillLookupOptions,
|
||||
): boolean {
|
||||
if (options?.preferUserInvocable === true && row.userInvocable === false) {
|
||||
return false;
|
||||
}
|
||||
if (options?.preferModelInvocable === true && row.disableModelInvocation === true) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function toSkillSummaryRow(skill: DeploymentSkill): SkillSummaryRow {
|
||||
const { body: _body, frontmatter: _frontmatter, files: _files, ...row } = skill;
|
||||
return row;
|
||||
}
|
||||
|
||||
function toSkillDetailRow(skill: DeploymentSkill): SkillDetailRow {
|
||||
const { files: _files, ...row } = skill;
|
||||
return row;
|
||||
}
|
||||
|
||||
function toAlwaysApplyRow(skill: DeploymentSkill): AlwaysApplySkillRow {
|
||||
return {
|
||||
_id: skill._id,
|
||||
name: skill.name,
|
||||
body: skill.body,
|
||||
author: skill.author,
|
||||
deployment: true,
|
||||
updatedAt: skill.updatedAt,
|
||||
...(skill.allowedTools !== undefined ? { allowedTools: skill.allowedTools } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function toSkillFileRow(file: DeploymentSkillFile): SkillFileRow {
|
||||
const { codeEnvRef: _codeEnvRef, content: _content, isBinary: _isBinary, ...row } = file;
|
||||
return row;
|
||||
}
|
||||
|
||||
function toSkillFileContentRow(file: DeploymentSkillFile): SkillFileContentRow {
|
||||
return {
|
||||
...toSkillFileRow(file),
|
||||
codeEnvRef: file.codeEnvRef,
|
||||
content: file.content,
|
||||
isBinary: file.isBinary,
|
||||
};
|
||||
}
|
||||
|
||||
function stripDeploymentIds(ids: Types.ObjectId[]): Types.ObjectId[] {
|
||||
return ids.filter((id) => !registry.hasId(id));
|
||||
}
|
||||
|
||||
function hasAccessibleId(ids: Types.ObjectId[], skillId: Types.ObjectId): boolean {
|
||||
const key = skillId.toString();
|
||||
return ids.some((id) => id.toString() === key);
|
||||
}
|
||||
|
||||
function compareBySkillCursor(
|
||||
a: Pick<SkillSummaryRow, '_id' | 'updatedAt'>,
|
||||
b: Pick<SkillSummaryRow, '_id' | 'updatedAt'>,
|
||||
): number {
|
||||
const aTime = (a.updatedAt ?? new Date(0)).getTime();
|
||||
const bTime = (b.updatedAt ?? new Date(0)).getTime();
|
||||
if (aTime !== bTime) {
|
||||
return bTime - aTime;
|
||||
}
|
||||
return a._id.toString().localeCompare(b._id.toString());
|
||||
}
|
||||
|
||||
function isAfterCursor(row: Pick<SkillSummaryRow, '_id' | 'updatedAt'>, cursor: Cursor | null) {
|
||||
if (!cursor) {
|
||||
return true;
|
||||
}
|
||||
const rowTime = (row.updatedAt ?? new Date(0)).getTime();
|
||||
const cursorTime = cursor.updatedAt.getTime();
|
||||
if (rowTime < cursorTime) {
|
||||
return true;
|
||||
}
|
||||
if (rowTime > cursorTime) {
|
||||
return false;
|
||||
}
|
||||
return row._id.toString() > cursor._id.toString();
|
||||
}
|
||||
|
||||
function decodeCursor(cursor: string | null | undefined): Cursor | null {
|
||||
if (!cursor || cursor === 'undefined' || cursor === 'null') {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const decoded = JSON.parse(Buffer.from(cursor, 'base64').toString('utf8')) as {
|
||||
updatedAt?: string;
|
||||
_id?: string;
|
||||
};
|
||||
if (!decoded.updatedAt || !decoded._id || !Types.ObjectId.isValid(decoded._id)) {
|
||||
return null;
|
||||
}
|
||||
const updatedAt = new Date(decoded.updatedAt);
|
||||
if (Number.isNaN(updatedAt.getTime())) {
|
||||
return null;
|
||||
}
|
||||
return { updatedAt, _id: new Types.ObjectId(decoded._id) };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function encodeCursor(row: Pick<SkillSummaryRow, '_id' | 'updatedAt'>): string {
|
||||
return Buffer.from(
|
||||
JSON.stringify({
|
||||
updatedAt: (row.updatedAt ?? new Date(0)).toISOString(),
|
||||
_id: row._id.toString(),
|
||||
}),
|
||||
).toString('base64');
|
||||
}
|
||||
|
||||
function stableObjectId(seed: string): Types.ObjectId {
|
||||
return new Types.ObjectId(crypto.createHash('sha1').update(seed).digest('hex').slice(0, 24));
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const stat = await fs.promises.stat(filePath);
|
||||
return stat.isFile();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePath(value: string): string {
|
||||
return value.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function relativeToRoot(root: string, target: string): string {
|
||||
const relative = normalizePath(path.relative(root, target));
|
||||
return relative.length > 0 ? relative : '.';
|
||||
}
|
||||
|
|
@ -679,6 +679,6 @@ const MIME_MAP: Record<string, string> = {
|
|||
'.pdf': 'application/pdf',
|
||||
};
|
||||
|
||||
function guessMimeType(filename: string): string {
|
||||
export function guessMimeType(filename: string): string {
|
||||
return MIME_MAP[path.extname(filename).toLowerCase()] || 'application/octet-stream';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@ export * from './binary';
|
|||
export * from './handlers';
|
||||
export * from './import';
|
||||
export * from './skillStates';
|
||||
export * from './deployment';
|
||||
|
|
|
|||
|
|
@ -25,9 +25,11 @@ export const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|||
/**
|
||||
* Source of a skill — where its canonical definition came from.
|
||||
* `inline` means the skill was authored directly in LibreChat.
|
||||
* `deployment` means the skill was loaded from the server's configured
|
||||
* deployment skill directory and is not persisted as a Skill document.
|
||||
* `github` / `notion` are reserved for future sync integrations.
|
||||
*/
|
||||
export type SkillSource = 'inline' | 'github' | 'notion';
|
||||
export type SkillSource = 'inline' | 'deployment' | 'github' | 'notion';
|
||||
|
||||
/**
|
||||
* Category inferred from a skill file's top-level directory prefix.
|
||||
|
|
@ -92,8 +94,8 @@ export type TSkillWarning = {
|
|||
* - `frontmatter` is the structured YAML bag minus `name`/`description`
|
||||
* (those live as top-level columns). Validated strictly against a known
|
||||
* key set server-side.
|
||||
* - `source`/`sourceMetadata` are reserved for phase 2+ external sync and
|
||||
* always `'inline'` / absent in phase 1.
|
||||
* - `source`/`sourceMetadata` identify whether the row is user-authored,
|
||||
* deployment-provided, or reserved for a future sync provider.
|
||||
*/
|
||||
export type TSkill = {
|
||||
_id: string;
|
||||
|
|
|
|||
|
|
@ -15,6 +15,14 @@ export {
|
|||
premiumTokenValues,
|
||||
defaultRate,
|
||||
permissionBitSupersets,
|
||||
partitionIssues,
|
||||
validateSkillName,
|
||||
validateSkillBody,
|
||||
validateRelativePath,
|
||||
inferSkillFileCategory,
|
||||
validateSkillFrontmatter,
|
||||
validateSkillDescription,
|
||||
deriveStructuredFrontmatterFields,
|
||||
} from './methods';
|
||||
export type * from './types';
|
||||
export type * from './methods';
|
||||
|
|
|
|||
|
|
@ -58,6 +58,14 @@ import { createSpendTokensMethods, type SpendTokensMethods } from './spendTokens
|
|||
import { createPromptMethods, type PromptMethods, type PromptDeps } from './prompt';
|
||||
import {
|
||||
createSkillMethods,
|
||||
partitionIssues,
|
||||
validateSkillName,
|
||||
validateSkillBody,
|
||||
validateRelativePath,
|
||||
validateSkillFrontmatter,
|
||||
validateSkillDescription,
|
||||
deriveStructuredFrontmatterFields,
|
||||
inferSkillFileCategory,
|
||||
type SkillMethods,
|
||||
type SkillDeps,
|
||||
type CreateSkillInput,
|
||||
|
|
@ -77,6 +85,16 @@ import { createConfigMethods, type ConfigMethods } from './config';
|
|||
export { RoleConflictError, DEFAULT_REFRESH_TOKEN_EXPIRY, DEFAULT_SESSION_EXPIRY };
|
||||
export { tokenValues, cacheTokenValues, premiumTokenValues, defaultRate };
|
||||
export { permissionBitSupersets };
|
||||
export {
|
||||
partitionIssues,
|
||||
validateSkillName,
|
||||
validateSkillBody,
|
||||
validateRelativePath,
|
||||
validateSkillFrontmatter,
|
||||
validateSkillDescription,
|
||||
deriveStructuredFrontmatterFields,
|
||||
inferSkillFileCategory,
|
||||
};
|
||||
|
||||
export type AllMethods = UserMethods &
|
||||
SessionMethods &
|
||||
|
|
|
|||
15
skill/README.md
Normal file
15
skill/README.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Deployment Skills
|
||||
|
||||
Place shared deployment skills in this directory. Each skill should live in its own folder with a
|
||||
`SKILL.md` file, for example:
|
||||
|
||||
```text
|
||||
skill/
|
||||
my-shared-skill/
|
||||
SKILL.md
|
||||
references/
|
||||
notes.md
|
||||
```
|
||||
|
||||
These skills are loaded at server startup, exposed read-only to all users with Skills enabled, and
|
||||
are not persisted as Skill documents in MongoDB.
|
||||
Loading…
Add table
Add a link
Reference in a new issue