LibreChat/api/server/controllers/agents/v1.js
Danny Avila 67b7b441b2
🛂 feat: Filter Model-Bound Content by Source (#14425)
* feat: introduce optional content protection seam

* feat: enforce source-aware content filters

* feat: complete source-aware content enforcement

* test: activate skill file-text fail-close fixtures

* fix: harden source-aware content filters

* fix: harden model-bound content filtering

* fix: preserve legacy filters and generated files

* fix: inspect shared scalar metadata

* test: align mocks with current dev dependencies

* feat: add persisted content filter safeguards

* feat: complete source-aware content filter enforcement

* fix: move resume content preflight into TypeScript

* fix: close content inspection edge cases

* fix: harden content protection boundaries

* fix: complete content protection safeguards

* test: align persisted memory filter coverage

* fix: reconcile content protection with current dev

* fix: reconcile content protection with latest dev

* fix: close content protection review gaps

* fix: enforce source-aware provider boundaries

* fix: preserve legacy PII preflight semantics

* test: stabilize stored branch preflight fixture

* fix: defer agent writes until protected model admission

* perf: harden source-aware model-bound filtering

* fix: canonicalize provider lineage before validation

* fix: satisfy model-bound callback type checks

* perf: Bound content protection filtering work

* fix: Bound submission array traversal

* fix: Stabilize bounded content snapshots

* fix: Scope model-bound traversal overflows

* fix: Preserve scoped content inspection

* fix: Accumulate aggregate traversal scopes

* fix: centralize content policy boundaries

* test: align deferred tool policy context

* test: align controller policy mocks

* style: normalize content protection imports

* fix: close content policy review gaps

* fix: narrow active skill policy config

* fix: address content protection review boundaries

* fix: retain exact provenance overflow sentinel

* fix: preserve literal and scoped provenance updates

* fix: narrow persisted edit provenance

* fix: isolate exact overflow attribution

* fix: centralize stored prompt protection

* fix: fail closed on incomplete transcript evidence

* fix: align canonical transcript routing

* refactor: centralize content policy preflights

* fix: isolate upload policy error typing

* style: sort policy preflight imports

* refactor: centralize content policy boundaries
2026-08-21 22:43:32 -04:00

2111 lines
72 KiB
JavaScript

const { z } = require('zod');
const { load } = require('js-yaml');
const fs = require('fs').promises;
const { nanoid } = require('nanoid');
const { logger } = require('@librechat/data-schemas');
const {
refreshS3Url,
splitMCPToolKey,
buildServerNameAliases,
findShadowedServerNames,
agentCreateSchema,
agentUpdateSchema,
agentSubagentsSchema,
refreshListAvatars,
collectEdgeAgentIds,
replaceEdgeSourceId,
mergeDeploymentSkillIds,
mergeAgentOcrConversion,
sanitizeModelParameters,
MAX_AVATAR_REFRESH_AGENTS,
collectToolResourceFileIds,
convertOcrToContextInPlace,
normalizeToolResourceFiles,
stripFileIdsFromToolResources,
inspectContent,
inspectContentWithTraversal,
extractAgentContent,
extractAssistantActionContent,
extractFileContent,
hasActiveFilePolicy,
hasActiveFileFieldPolicy,
contentFilterBlockResponse,
contentFilterUninspectableResponse,
getBlockedOpaqueFileField,
getBlockedUninspectableFileField,
getContentTraversalFragments,
isContentTraversalProtected,
isContentTraversalLimitError,
resolveCanonicalFileReferences,
} = require('@librechat/api');
const {
Time,
Tools,
CacheKeys,
Constants,
FileSources,
ResourceType,
AccessRoleIds,
PrincipalType,
EToolResources,
isActionTool,
PermissionBits,
actionDelimiter,
AgentCapabilities,
EModelEndpoint,
resolveAllowedStatefulCodeEnvironments,
removeCodeExecutionCaller,
hasActivePiiFields,
hasActivePiiPatterns,
openapiToFunction,
removeNullishValues,
} = require('librechat-data-provider');
const {
findPubliclyAccessibleResources,
getResourcePermissionsMap,
findAccessibleResources,
hasPublicPermission,
grantPermission,
} = require('~/server/services/PermissionService');
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
const { getFileStrategy } = require('~/server/utils/getFileStrategy');
const { filterFile } = require('~/server/services/Files/process');
const { getCachedTools } = require('~/server/services/Config');
const {
createMCPPermissionContext,
resolveConfigServers,
userCanUseMCPServers,
} = require('~/server/services/MCP');
const { attachOwnerContacts } = require('~/server/services/Agents/ownerContact');
const { getMCPServersRegistry } = require('~/config');
const { getLogStores } = require('~/cache');
const db = require('~/models');
const systemTools = {
[Tools.execute_code]: true,
[Tools.file_search]: true,
[Tools.web_search]: true,
[Tools.memory]: true,
};
const MAX_SEARCH_LEN = 100;
const escapeRegex = (str = '') => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const getSafeModelParameters = (modelParameters) => {
const { useResponsesApi } = modelParameters ?? {};
return typeof useResponsesApi === 'boolean' ? { useResponsesApi } : {};
};
const hasEditBit = (permission) => (permission & PermissionBits.EDIT) === PermissionBits.EDIT;
const blockFilteredActionContent = (req, res, actions) => {
const filters = req.config?.filters;
const actionPolicyActive = hasActivePiiPatterns(filters?.actionMetadata?.pii);
const definitionPolicyActive = hasActivePiiPatterns(filters?.agentInstructions?.pii);
const toolPolicyActive = hasActivePiiFields(filters?.toolArguments?.pii, ['name', 'arguments']);
if (
(!actionPolicyActive && !definitionPolicyActive && !toolPolicyActive) ||
actions.length === 0
) {
return false;
}
const filterableActions = actions.map((action) => {
const rawSpec = action.metadata?.raw_spec;
if (typeof rawSpec !== 'string') {
return action;
}
let spec;
try {
spec = JSON.parse(rawSpec);
} catch {
try {
spec = load(rawSpec);
} catch {
return action;
}
}
if (
spec == null ||
typeof spec !== 'object' ||
!Array.isArray(spec.servers) ||
!spec.servers[0]?.url ||
spec.paths == null ||
typeof spec.paths !== 'object' ||
Object.keys(spec.paths).length === 0
) {
return action;
}
try {
const { functionSignatures } = openapiToFunction(spec);
return { ...action, functions: functionSignatures };
} catch {
return action;
}
});
let traversalError;
for (const action of filterableActions) {
const inspection = inspectContentWithTraversal(() => extractAssistantActionContent(action), {
filters,
});
if (inspection.finding != null) {
res.status(400).json(contentFilterBlockResponse(inspection.finding));
return true;
}
traversalError ??= inspection.traversalError ?? undefined;
}
if (traversalError != null) {
res.status(traversalError.statusCode).json(traversalError.body);
return true;
}
return false;
};
const blockFilteredAgentContent = async (req, res, agentData) => {
const filters = req.config?.filters;
const definitionPolicyActive =
hasActivePiiPatterns(filters?.agentInstructions?.pii) ||
hasActivePiiPatterns(filters?.conversationStarters?.pii) ||
hasActivePiiPatterns(filters?.modelParameters?.pii) ||
hasActivePiiFields(filters?.toolArguments?.pii, ['name', 'arguments']);
const filePolicyActive = hasActiveFilePolicy(filters);
if (!definitionPolicyActive && !filePolicyActive) {
return false;
}
let opaqueAgentData = agentData;
let hydratedFiles = [];
if (filePolicyActive) {
try {
const fileInspection = await resolveCanonicalFileReferences({
filters,
input: agentData,
user: req.user,
/**
* Every caller prunes tool-resource IDs against current ownership or
* the existing agent's already-authorized resources before reaching
* this point. Preserve that authorization decision while hydrating the
* canonical rows for content inspection.
*/
getFiles: ({ file_id, tenantId }, sort, select) =>
db.getFiles(
{
file_id,
...(tenantId != null && { tenantId }),
},
sort,
select,
),
});
opaqueAgentData = fileInspection.sanitizedInput;
hydratedFiles = fileInspection.hydratedFiles;
} catch (error) {
if (error?.statusCode === 400 && error?.body != null) {
res.status(error.statusCode).json(error.body);
return true;
}
throw error;
}
}
const avatarPath = agentData?.avatar?.filepath;
const uninspectableField = getBlockedOpaqueFileField(filters, opaqueAgentData);
if (uninspectableField != null) {
res.status(400).json(contentFilterUninspectableResponse(uninspectableField));
return true;
}
const fileFragments = hydratedFiles.flatMap(extractFileContent);
if (typeof avatarPath === 'string' && !avatarPath.toLowerCase().startsWith('data:')) {
fileFragments.push(...extractFileContent({ filepath: avatarPath }));
}
let agentFragments;
let traversalError;
try {
agentFragments = extractAgentContent(agentData);
} catch (error) {
if (!isContentTraversalLimitError(error)) {
throw error;
}
agentFragments = getContentTraversalFragments(error);
traversalError = error;
}
const finding = inspectContent([...agentFragments, ...fileFragments], {
filters,
});
if (finding != null) {
res.status(400).json(contentFilterBlockResponse(finding));
return true;
}
if (traversalError != null && isContentTraversalProtected({ error: traversalError, filters })) {
res.status(traversalError.statusCode).json(traversalError.body);
return true;
}
return false;
};
const sanitizeViewerSkillScope = (agent, accessibleSkillSet) => {
const skillScopeEnabled = agent.skills_enabled === true;
delete agent.skills_enabled;
if (!skillScopeEnabled) {
delete agent.skills;
return agent;
}
const configuredSkills = Array.isArray(agent.skills) ? agent.skills : [];
if (configuredSkills.length === 0) {
// Empty allowlist means the viewer's full accessible catalog.
delete agent.skills;
agent.skills_enabled = true;
return agent;
}
const visibleSkills = configuredSkills
.map((skillId) => String(skillId))
.filter((skillId) => accessibleSkillSet.has(skillId));
if (visibleSkills.length === 0) {
delete agent.skills;
return agent;
}
agent.skills = visibleSkills;
agent.skills_enabled = true;
return agent;
};
/**
* Looks up each referenced agent id in Mongo, splits them into three
* buckets the caller needs for validation: ids that don't exist at all,
* ids the user lacks VIEW permission on, and ids that are fully
* accessible. Missing ids are intentionally NOT treated as unauthorized
* — for `edges`, a self-referential `from` can legitimately name the
* agent being created (no DB record yet); callers that should reject
* missing ids (like the subagent path) read the `missing` bucket
* instead.
* @param {Iterable<string>} agentIds
* @param {string} userId
* @param {string} userRole
* @returns {Promise<{ missing: string[], unauthorized: string[] }>}
*/
const classifyAgentReferences = async (agentIds, userId, userRole) => {
const ids = [...new Set(agentIds)];
if (ids.length === 0) return { missing: [], unauthorized: [] };
const agents = await db.getAgents({ id: { $in: ids } });
const foundIds = new Set(agents.map((a) => a.id));
const missing = ids.filter((id) => !foundIds.has(id));
if (agents.length === 0) return { missing, unauthorized: [] };
const permissionsMap = await getResourcePermissionsMap({
userId,
role: userRole,
resourceType: ResourceType.AGENT,
resourceIds: agents.map((a) => a._id),
});
const unauthorized = agents
.filter((a) => {
const bits = permissionsMap.get(a._id.toString()) ?? 0;
return (bits & PermissionBits.VIEW) === 0;
})
.map((a) => a.id);
return { missing, unauthorized };
};
/**
* Validates that every agent referenced in `edges` exists and is viewable.
* The create path may allow its newly generated self id because that agent
* has not been inserted yet; all other missing references are invalid.
* @param {GraphEdge[]} edges
* @param {string} userId
* @param {string} userRole
* @param {Set<string>} [allowedMissingIds]
* @returns {Promise<{ missing: string[], unauthorized: string[] }>}
*/
const validateEdgeAgentReferences = async (
edges,
userId,
userRole,
allowedMissingIds = new Set(),
) => {
const { missing, unauthorized } = await classifyAgentReferences(
collectEdgeAgentIds(edges),
userId,
userRole,
);
return {
missing: missing.filter((id) => !allowedMissingIds.has(id)),
unauthorized,
};
};
/**
* Collects every saved agent referenced by a spawn target. Graph edge
* endpoints are included defensively even though request validation requires
* them to be declared in the graph's `agent_ids` list.
* @param {import('librechat-data-provider').AgentSubagentsConfig | undefined} subagents
* @returns {string[]}
*/
const collectSubagentAgentIds = (subagents) => {
const ids = new Set(subagents?.agent_ids ?? []);
for (const graph of subagents?.graphs ?? []) {
for (const agentId of graph.agent_ids ?? []) {
ids.add(agentId);
}
for (const edge of graph.edges ?? []) {
for (const agentId of collectEdgeAgentIds([edge])) {
ids.add(agentId);
}
}
}
return [...ids];
};
/**
* Rewrites a duplicated agent's self-references inside saved graph spawn
* targets so the clone remains self-contained.
* @param {import('librechat-data-provider').AgentSubagentsConfig | undefined} subagents
* @param {string} sourceAgentId
* @param {string} targetAgentId
*/
const replaceSubagentGraphAgentId = (subagents, sourceAgentId, targetAgentId) => {
if (!Array.isArray(subagents?.graphs)) {
return subagents;
}
const replaceId = (agentId) => (agentId === sourceAgentId ? targetAgentId : agentId);
return {
...subagents,
graphs: subagents.graphs.map((graph) => ({
...graph,
agent_ids: graph.agent_ids?.map(replaceId),
edges: graph.edges?.map((edge) => ({
...edge,
from: Array.isArray(edge.from) ? edge.from.map(replaceId) : replaceId(edge.from),
to: Array.isArray(edge.to) ? edge.to.map(replaceId) : replaceId(edge.to),
})),
entry_agent_id: replaceId(graph.entry_agent_id),
result_agent_id: replaceId(graph.result_agent_id),
})),
};
};
const replaceAndValidateSubagentGraphAgentId = (subagents, sourceAgentId, targetAgentId) =>
agentSubagentsSchema.parse(replaceSubagentGraphAgentId(subagents, sourceAgentId, targetAgentId));
/**
* Validates saved-agent spawn targets more strictly than top-level edges: both
* missing AND unauthorized ids are errors. Spawn targets
* can't self-reference (subagents spawn *other* agents), so a
* missing id is always a typo or a reference to a deleted agent —
* `initializeClient` would silently drop it at runtime, leaving the
* persisted config out of sync with actual spawn targets (Codex P2).
* Returning the split lets the caller report each bucket with the
* appropriate status.
*/
const validateSubagentReferences = async (
subagents,
userId,
userRole,
allowedMissingIds = new Set(),
) => {
const { missing, unauthorized } = await classifyAgentReferences(
collectSubagentAgentIds(subagents),
userId,
userRole,
);
return {
missing: missing.filter((id) => !allowedMissingIds.has(id)),
unauthorized,
};
};
/**
* Returns true when the agents-endpoint `subagents` capability is
* enabled in this request's resolved app config. When disabled,
* `initializeClient` already strips the `subagents` block at runtime
* so persisted `agent_ids` are inert — gating the ACL check on this
* keeps stale references in legacy records from blocking unrelated
* edits after a capability-off rollback (Codex P2).
* @param {Express.Request} req
*/
const isSubagentsCapabilityEnabled = (req) => {
const capabilities = req.config?.endpoints?.[EModelEndpoint.agents]?.capabilities;
if (!Array.isArray(capabilities)) return false;
return capabilities.includes(AgentCapabilities.subagents);
};
const isCodeInterpreterCapabilityEnabled = (req) => {
const capabilities = req.config?.endpoints?.[EModelEndpoint.agents]?.capabilities;
if (!Array.isArray(capabilities)) return false;
return capabilities.includes(AgentCapabilities.execute_code);
};
/** Reject a newly selected stateful workspace scope that the deployment owner
* has excluded. Disabled sessions and unrelated edits remain saveable so an
* allowlist tightening never silently rewrites or strands an existing agent. */
const validateStatefulCodeEnvironment = (req, res, enabled, environment) => {
if (enabled !== true) {
return true;
}
const allowedEnvironments = resolveAllowedStatefulCodeEnvironments(
req.config?.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.allowedEnvironments,
);
const resolvedEnvironment = environment ?? 'user';
if (allowedEnvironments.includes(resolvedEnvironment)) {
return true;
}
res.status(403).json({
error: `Stateful code environment is not allowed by this deployment: ${resolvedEnvironment}`,
});
return false;
};
/**
* @param {import('librechat-data-provider').AgentSubagentsConfig | undefined} subagents
* @param {Express.Request} req
* @returns {Promise<{ status: number, body: { error: string, agent_ids: string[] } } | null>}
*/
const getSubagentReferenceError = async (subagents, req, allowedMissingIds = new Set()) => {
if (
!isSubagentsCapabilityEnabled(req) ||
subagents?.enabled !== true ||
collectSubagentAgentIds(subagents).length === 0
) {
return null;
}
const { missing, unauthorized } = await validateSubagentReferences(
subagents,
req.user.id,
req.user.role,
allowedMissingIds,
);
if (missing.length > 0) {
return {
status: 400,
body: {
error: 'One or more agents referenced in subagents do not exist',
agent_ids: missing,
},
};
}
if (unauthorized.length > 0) {
return {
status: 403,
body: {
error: 'You do not have access to one or more agents referenced in subagents',
agent_ids: unauthorized,
},
};
}
return null;
};
/**
* Filters tools to only include those the user is authorized to use.
* MCP tools must match the exact format `{toolName}_mcp_{serverName}` (exactly 2 segments).
* Multi-delimiter keys are rejected to prevent authorization/execution mismatch.
* Non-MCP tools must appear in availableTools (global tool cache) or systemTools.
*
* When `existingTools` is provided and the MCP registry is unavailable (e.g. server restart),
* tools already present on the agent are preserved rather than stripped — they were validated
* when originally added, and we cannot re-verify them without the registry.
* @param {object} params
* @param {string[]} params.tools - Raw tool strings from the request
* @param {string} params.userId - Requesting user ID for MCP server access check
* @param {string} [params.role] - Requesting user's role for ACL principal resolution
* @param {object} [params.user] - Requesting user for MCP server use permission checks
* @param {{ canUseServers: (user?: object) => Promise<boolean> }} [params.mcpPermissionContext] - Request-scoped MCP permission context
* @param {Record<string, unknown>} params.availableTools - Global non-MCP tool cache
* @param {string[]} [params.existingTools] - Tools already persisted on the agent document
* @param {Record<string, unknown>} [params.configServers] - Config-source MCP servers resolved from appConfig overrides
* @returns {Promise<string[]>} Only the authorized subset of tools
*/
const filterAuthorizedTools = async ({
tools,
userId,
role,
user,
mcpPermissionContext,
availableTools,
existingTools,
configServers,
resolvedServerNames,
}) => {
const filteredTools = [];
let mcpServerConfigs;
/** normalized server name -> the raw key `mcpServerConfigs` is indexed by */
let configNamesByNormalized = new Map();
let shadowedServerNames = new Set();
let registryUnavailable = false;
const existingToolSet = existingTools?.length ? new Set(existingTools) : null;
const hasMCPTools = tools.some((tool) => tool?.includes(Constants.mcp_delimiter));
const canUseMCP = hasMCPTools
? await (mcpPermissionContext
? mcpPermissionContext.canUseServers(user)
: userCanUseMCPServers(user))
: true;
let loggedMCPDenied = false;
for (const tool of tools) {
const isActionToolName = typeof tool === 'string' && isActionTool(tool);
const isMCPTool = tool?.includes(Constants.mcp_delimiter) && !isActionToolName;
if (!isMCPTool) {
if (availableTools[tool] || systemTools[tool] || isActionToolName) {
filteredTools.push(tool);
}
continue;
}
if (!canUseMCP) {
if (!loggedMCPDenied) {
logger.warn(`[filterAuthorizedTools] User ${userId} lacks MCP server use permission`);
loggedMCPDenied = true;
}
continue;
}
if (mcpServerConfigs === undefined) {
try {
mcpServerConfigs =
(role
? await getMCPServersRegistry().getAllServerConfigs(userId, configServers, role)
: await getMCPServersRegistry().getAllServerConfigs(userId, configServers)) ?? {};
} catch (e) {
logger.warn(
'[filterAuthorizedTools] MCP registry unavailable, filtering all MCP tools',
e.message,
);
mcpServerConfigs = {};
registryUnavailable = true;
}
/** Shared first-wins construction — authorization must resolve a
* colliding normalized key to the SAME server execution routes to,
* or a tool could be authorized against one server and executed
* against another. */
configNamesByNormalized = buildServerNameAliases(Object.keys(mcpServerConfigs));
shadowedServerNames = findShadowedServerNames(Object.keys(mcpServerConfigs));
}
/** Tool keys embed the normalized server name; the config is keyed by the raw name. */
const [, normalizedServerName] = splitMCPToolKey(
tool,
Array.from(configNamesByNormalized.keys()),
);
const serverName = configNamesByNormalized.get(normalizedServerName) ?? normalizedServerName;
if (!serverName) {
logger.warn(
`[filterAuthorizedTools] Rejected malformed MCP tool key "${tool}" for user ${userId}`,
);
continue;
}
if (registryUnavailable && existingToolSet?.has(tool)) {
filteredTools.push(tool);
continue;
}
if (!Object.hasOwn(mcpServerConfigs, serverName)) {
logger.warn(
`[filterAuthorizedTools] Rejected MCP tool "${tool}" — server "${serverName}" not accessible to user ${userId}`,
);
continue;
}
/** A shadowed server's tools (including its `mcp_all` wildcard) produce
* the SAME normalized function names as the winning server's — in-run
* dispatch could execute either. Fail closed at authorization; this map
* is the full accessible set, so DB-vs-config collisions are visible. */
if (shadowedServerNames.has(serverName)) {
logger.warn(
`[filterAuthorizedTools] Rejected MCP tool "${tool}" — server "${serverName}" is shadowed by a name collision; rename one server to use it`,
);
continue;
}
resolvedServerNames?.add(serverName);
filteredTools.push(tool);
}
return filteredTools;
};
/**
* Removes file IDs from tool resources unless they are already attached to the
* agent or owned by an allowed uploader.
* @param {object} params
* @param {object} params.tool_resources
* @param {string | object | Array<string | object>} params.ownerIds
* @param {object} [params.existingToolResources]
* @param {string} params.logPrefix
* @returns {Promise<number>} Count of removed file references.
*/
const pruneToolResourceFileIdsForAgent = async ({
tool_resources,
ownerIds,
existingToolResources,
logPrefix,
}) => {
const referencedFileIds = collectToolResourceFileIds(tool_resources);
if (referencedFileIds.length === 0) {
return 0;
}
const ownerIdSet = new Set(
(Array.isArray(ownerIds) ? ownerIds : [ownerIds])
.filter(Boolean)
.map((ownerId) => ownerId.toString()),
);
const existingFileIds = new Set(collectToolResourceFileIds(existingToolResources ?? {}));
try {
const files = await db.getFiles({ file_id: { $in: referencedFileIds } }, null, {
file_id: 1,
user: 1,
});
const allowedIds = new Set(
(files ?? [])
.filter((file) => {
if (!file.user) {
return false;
}
return existingFileIds.has(file.file_id) || ownerIdSet.has(file.user.toString());
})
.map((file) => file.file_id),
);
const disallowedIds = referencedFileIds.filter((id) => !allowedIds.has(id));
if (disallowedIds.length > 0) {
logger.warn(`${logPrefix} Pruning ${disallowedIds.length} invalid file reference(s)`);
return stripFileIdsFromToolResources(tool_resources, disallowedIds).removedCount;
}
return 0;
} catch (fileCheckError) {
logger.warn(`${logPrefix} File ownership check failed, pruning incoming file references`, {
error: fileCheckError?.message,
});
return stripFileIdsFromToolResources(tool_resources, referencedFileIds).removedCount;
}
};
/**
* Creates an Agent.
* @route POST /Agents
* @param {ServerRequest} req - The request object.
* @param {AgentCreateParams} req.body - The request body.
* @param {ServerResponse} res - The response object.
* @returns {Promise<Agent>} 201 - success response - application/json
*/
const createAgentHandler = async (req, res) => {
try {
/**
* Hydrated resource records are a client transport shape, not a persisted
* Agent shape. Canonicalize them before the strict IDs-only schema strips
* `files`, then let the schema validate the resulting `file_ids`.
*/
normalizeToolResourceFiles(req.body?.tool_resources);
const validatedData = agentCreateSchema.parse(req.body);
const { tools = [], ...agentData } = removeNullishValues(validatedData);
if (
(!isCodeInterpreterCapabilityEnabled(req) || !tools.includes(Tools.execute_code)) &&
agentData.tool_options != null
) {
agentData.tool_options = removeCodeExecutionCaller(agentData.tool_options);
}
if (
!validateStatefulCodeEnvironment(
req,
res,
agentData.stateful_code_sessions,
agentData.stateful_code_environment,
)
) {
return;
}
if (agentData.model_parameters && typeof agentData.model_parameters === 'object') {
agentData.model_parameters = removeNullishValues(
sanitizeModelParameters(agentData.model_parameters),
true,
);
}
const { id: userId, role: userRole } = req.user;
agentData.id = `agent_${nanoid()}`;
agentData.edges = replaceEdgeSourceId(agentData.edges, '', agentData.id);
agentData.subagents = replaceAndValidateSubagentGraphAgentId(
agentData.subagents,
'',
agentData.id,
);
if (agentData.tool_resources) {
await pruneToolResourceFileIdsForAgent({
tool_resources: agentData.tool_resources,
ownerIds: userId,
logPrefix: '[/Agents]',
});
}
if (await blockFilteredAgentContent(req, res, agentData)) {
return;
}
if (agentData.edges?.length) {
const { missing, unauthorized } = await validateEdgeAgentReferences(
agentData.edges,
userId,
userRole,
new Set([agentData.id]),
);
if (missing.length > 0) {
return res.status(400).json({
error: 'One or more agents referenced in edges do not exist',
agent_ids: missing,
});
}
if (unauthorized.length > 0) {
return res.status(403).json({
error: 'You do not have access to one or more agents referenced in edges',
agent_ids: unauthorized,
});
}
}
/**
* Only validate subagent ACL when the feature is actually enabled
* on BOTH the endpoint (capability flag in appConfig) AND the
* agent payload. Runtime (`initializeClient` + `run.ts`) checks
* `subagents?.enabled` as a truthy predicate — so `undefined` /
* `null` / missing `enabled` all disable the feature. The ACL
* check must match exactly: only enforce when `enabled === true`.
* Otherwise a payload that omits `enabled` (e.g. API clients, or
* legacy records that never set the field) could 403 here while
* runtime would happily no-op on the subagent tool. Disable-path
* is also untouched: toggling `enabled: false` always passes the
* gate, so a user who lost VIEW on a child can still save the
* disable edit.
*/
const subagentReferenceError = await getSubagentReferenceError(
agentData.subagents,
req,
new Set([agentData.id]),
);
if (subagentReferenceError) {
return res.status(subagentReferenceError.status).json(subagentReferenceError.body);
}
agentData.author = userId;
agentData.tools = [];
const hasMCPTools = tools.some((t) => t?.includes(Constants.mcp_delimiter));
const [availableTools, configServers] = await Promise.all([
getCachedTools().then((t) => t ?? {}),
hasMCPTools ? resolveConfigServers(req) : Promise.resolve(undefined),
]);
const mcpPermissionContext = createMCPPermissionContext(req);
/** Resolved during authorization, so persistence indexes the real server rather
* than a suffix guess - see the note on `filterAuthorizedTools`. */
const resolvedServerNames = new Set();
agentData.tools = await filterAuthorizedTools({
tools,
userId,
role: req.user.role,
user: req.user,
mcpPermissionContext,
availableTools,
configServers,
resolvedServerNames,
});
if (hasMCPTools) {
agentData.mcpServerNames = Array.from(resolvedServerNames);
}
const agent = await db.createAgent(agentData);
try {
await Promise.all([
grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
accessRoleId: AccessRoleIds.AGENT_OWNER,
grantedBy: userId,
}),
grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.REMOTE_AGENT,
resourceId: agent._id,
accessRoleId: AccessRoleIds.REMOTE_AGENT_OWNER,
grantedBy: userId,
}),
]);
logger.debug(
`[createAgent] Granted owner permissions to user ${userId} for agent ${agent.id}`,
);
} catch (permissionError) {
logger.error(
`[createAgent] Failed to grant owner permissions for agent ${agent.id}:`,
permissionError,
);
}
res.status(201).json(agent);
} catch (error) {
if (error instanceof z.ZodError) {
logger.error('[/Agents] Validation error', error.errors);
return res.status(400).json({ error: 'Invalid request data', details: error.errors });
}
logger.error('[/Agents] Error creating agent', error);
res.status(500).json({ error: error.message });
}
};
/**
* Retrieves an Agent by ID.
* @route GET /Agents/:id
* @param {object} req - Express Request
* @param {object} req.params - Request params
* @param {string} req.params.id - Agent identifier.
* @param {object} req.user - Authenticated user information
* @param {string} req.user.id - User ID
* @returns {Promise<Agent>} 200 - success response - application/json
* @returns {Error} 404 - Agent not found
*/
const getAgentHandler = async (req, res, expandProperties = false) => {
try {
const id = req.params.id;
const author = req.user.id;
// Permissions are validated by middleware before calling this function.
// Load the agent with a `version` count but without the heavy `versions`
// array; version history is fetched lazily via GET /agents/:id/versions.
const agent = await db.getAgentWithVersionCount({ id });
if (!agent) {
return res.status(404).json({ error: 'Agent not found' });
}
if (agent.avatar && agent.avatar?.source === FileSources.s3) {
try {
agent.avatar = {
...agent.avatar,
filepath: await refreshS3Url(agent.avatar),
};
} catch (e) {
logger.warn('[/Agents/:id] Failed to refresh S3 URL', e);
}
}
agent.author = agent.author.toString();
// Check if agent is public
const isPublic = await hasPublicPermission({
resourceType: ResourceType.AGENT,
resourceId: agent._id,
requiredPermissions: PermissionBits.VIEW,
});
agent.isPublic = isPublic;
await attachOwnerContacts([agent]);
if (agent.author !== author) {
delete agent.author;
}
if (!expandProperties) {
// VIEW permission: Basic agent info only
const responseAgent = {
_id: agent._id,
id: agent.id,
name: agent.name,
description: agent.description,
conversation_starters: agent.conversation_starters,
avatar: agent.avatar,
author: agent.author,
provider: agent.provider,
model: agent.model,
model_parameters: getSafeModelParameters(agent.model_parameters),
isPublic: agent.isPublic,
version: agent.version,
// Safe metadata
createdAt: agent.createdAt,
updatedAt: agent.updatedAt,
};
if (agent.support_contact !== undefined) {
responseAgent.support_contact = agent.support_contact;
}
if (agent.owner_contact !== undefined) {
responseAgent.owner_contact = agent.owner_contact;
}
return res.status(200).json(responseAgent);
}
// EDIT permission: Full agent details including sensitive configuration
return res.status(200).json(agent);
} catch (error) {
logger.error('[/Agents/:id] Error retrieving agent', error);
res.status(500).json({ error: error.message });
}
};
/**
* Retrieves an agent's version history.
* Loaded lazily so the editor doesn't transfer large histories up front.
* @route GET /agents/:id/versions
* @param {object} req - Express Request
* @param {object} req.params - Request params
* @param {string} req.params.id - Agent identifier.
* @returns {Promise<Agent[]>} 200 - The agent's version history - application/json
* @returns {Error} 404 - Agent not found
*/
const getAgentVersionsHandler = async (req, res) => {
try {
const id = req.params.id;
const versions = await db.getAgentVersions({ id });
if (versions == null) {
return res.status(404).json({ error: 'Agent not found' });
}
return res.status(200).json(versions);
} catch (error) {
logger.error('[/Agents/:id/versions] Error retrieving agent versions', error);
res.status(500).json({ error: error.message });
}
};
/**
* Updates an Agent.
* @route PATCH /Agents/:id
* @param {object} req - Express Request
* @param {object} req.params - Request params
* @param {string} req.params.id - Agent identifier.
* @param {AgentUpdateParams} req.body - The Agent update parameters.
* @returns {Promise<Agent>} 200 - success response - application/json
*/
const updateAgentHandler = async (req, res) => {
try {
const id = req.params.id;
/** See the create path: retain hydrated file IDs through validation. */
normalizeToolResourceFiles(req.body?.tool_resources);
const validatedData = agentUpdateSchema.parse(req.body);
// Preserve explicit null for avatar to allow resetting the avatar
const { avatar: avatarField, _id, ...rest } = validatedData;
const updateData = removeNullishValues(rest);
let existingAgent;
const includesStatefulConfiguration =
updateData.stateful_code_sessions !== undefined ||
updateData.stateful_code_environment !== undefined;
const includesToolsConfiguration = Array.isArray(updateData.tools);
const includesToolOptionsConfiguration = updateData.tool_options !== undefined;
if (
includesStatefulConfiguration ||
includesToolsConfiguration ||
includesToolOptionsConfiguration
) {
existingAgent = await db.getAgent({ id });
if (!existingAgent) {
return res.status(404).json({ error: 'Agent not found' });
}
const statefulConfigurationChanged =
(updateData.stateful_code_sessions !== undefined &&
(updateData.stateful_code_sessions === true) !==
(existingAgent.stateful_code_sessions === true)) ||
(updateData.stateful_code_environment !== undefined &&
(updateData.stateful_code_environment ?? 'user') !==
(existingAgent.stateful_code_environment ?? 'user'));
const activatesCodeExecution =
includesToolsConfiguration &&
updateData.tools.includes(Tools.execute_code) &&
existingAgent.tools?.includes(Tools.execute_code) !== true;
if (statefulConfigurationChanged || activatesCodeExecution) {
const effectiveStatefulSessions =
updateData.stateful_code_sessions ?? existingAgent.stateful_code_sessions;
const effectiveStatefulEnvironment =
updateData.stateful_code_environment ?? existingAgent.stateful_code_environment;
if (
!validateStatefulCodeEnvironment(
req,
res,
effectiveStatefulSessions,
effectiveStatefulEnvironment,
)
) {
return;
}
}
if (includesToolsConfiguration || includesToolOptionsConfiguration) {
const effectiveTools = updateData.tools ?? existingAgent.tools;
const effectiveToolOptions = updateData.tool_options ?? existingAgent.tool_options;
if (
(!isCodeInterpreterCapabilityEnabled(req) ||
!effectiveTools?.includes(Tools.execute_code)) &&
effectiveToolOptions != null
) {
updateData.tool_options = removeCodeExecutionCaller(effectiveToolOptions);
}
}
}
if (updateData.model_parameters && typeof updateData.model_parameters === 'object') {
updateData.model_parameters = removeNullishValues(
sanitizeModelParameters(updateData.model_parameters),
true,
);
}
if (avatarField === null) {
updateData.avatar = avatarField;
}
if (updateData.edges !== undefined) {
updateData.edges = replaceEdgeSourceId(updateData.edges, '', id);
}
if (updateData.subagents !== undefined) {
updateData.subagents = replaceAndValidateSubagentGraphAgentId(updateData.subagents, '', id);
}
if (updateData.edges?.length) {
const { id: userId, role: userRole } = req.user;
const { missing, unauthorized } = await validateEdgeAgentReferences(
updateData.edges,
userId,
userRole,
);
if (missing.length > 0) {
return res.status(400).json({
error: 'One or more agents referenced in edges do not exist',
agent_ids: missing,
});
}
if (unauthorized.length > 0) {
return res.status(403).json({
error: 'You do not have access to one or more agents referenced in edges',
agent_ids: unauthorized,
});
}
}
/** Same guard as the create path: capability on the endpoint,
* AND `subagents.enabled === true` on the payload (runtime's
* truthy check treats `undefined` / `null` / `false` as
* disabled, so the ACL check must too). Missing or explicitly-
* disabled payloads always pass the gate — that preserves the
* "can always save a disable edit" behavior a user might need
* after losing VIEW on a referenced child. */
const subagentReferenceError = await getSubagentReferenceError(updateData.subagents, req);
if (subagentReferenceError) {
return res.status(subagentReferenceError.status).json(subagentReferenceError.body);
}
// Convert OCR to context in incoming updateData
convertOcrToContextInPlace(updateData);
existingAgent ??= await db.getAgent({ id });
if (!existingAgent) {
return res.status(404).json({ error: 'Agent not found' });
}
// Convert legacy OCR tool resource to context format in existing agent
const ocrConversion = mergeAgentOcrConversion(existingAgent, updateData);
if (ocrConversion.tool_resources) {
updateData.tool_resources = ocrConversion.tool_resources;
}
if (ocrConversion.tools) {
updateData.tools = ocrConversion.tools;
}
if (updateData.tool_resources) {
await pruneToolResourceFileIdsForAgent({
tool_resources: updateData.tool_resources,
ownerIds: req.user.id,
existingToolResources: existingAgent.tool_resources,
logPrefix: `[/Agents/:id] Agent ${id}`,
});
}
if (await blockFilteredAgentContent(req, res, updateData)) {
return;
}
const isMCPTool = (t) =>
typeof t === 'string' && t.includes(Constants.mcp_delimiter) && !isActionTool(t);
const hasToolUpdate = updateData.tools !== undefined;
const editingOwnAgent = existingAgent.author?.toString() === req.user.id;
const existingTools = existingAgent.tools ?? [];
const effectiveTools = (hasToolUpdate ? updateData.tools : existingAgent.tools) ?? [];
const requestedMCPTools = effectiveTools.filter(isMCPTool);
const existingMCPTools = existingTools.filter(isMCPTool);
if (requestedMCPTools.length > 0 || (hasToolUpdate && existingMCPTools.length > 0)) {
const mcpPermissionContext = createMCPPermissionContext(req);
if (!(await mcpPermissionContext.canUseServers(req.user))) {
if (editingOwnAgent) {
updateData.tools = effectiveTools.filter((t) => !isMCPTool(t));
/** Every MCP tool just went away, so nothing should stay indexed. */
updateData.mcpServerNames = [];
} else if (hasToolUpdate) {
const existingMCPToolSet = new Set(existingMCPTools);
const nextTools = updateData.tools.filter(
(t) => !isMCPTool(t) || existingMCPToolSet.has(t),
);
const nextToolSet = new Set(nextTools);
for (const existingMCPTool of existingMCPTools) {
if (!nextToolSet.has(existingMCPTool)) {
nextTools.push(existingMCPTool);
}
}
updateData.tools = nextTools;
/** The agent's MCP tools are retained verbatim here, so carry its resolved
* names across too. Left unset when the agent has none stored, so
* `updateAgent` can still derive rather than being pinned to an empty
* index that would strip agent-scoped access. */
if (existingAgent.mcpServerNames?.length) {
updateData.mcpServerNames = existingAgent.mcpServerNames;
}
}
} else if (hasToolUpdate) {
const existingToolSet = new Set(existingTools);
const newMCPTools = requestedMCPTools.filter((t) => !existingToolSet.has(t));
/** Names resolved during authorization of the newly added tools. */
const resolvedServerNames = new Set();
if (newMCPTools.length > 0) {
const [availableTools, configServers] = await Promise.all([
getCachedTools().then((t) => t ?? {}),
resolveConfigServers(req),
]);
const approvedNew = await filterAuthorizedTools({
tools: newMCPTools,
userId: req.user.id,
role: req.user.role,
user: req.user,
mcpPermissionContext,
availableTools,
configServers,
resolvedServerNames,
});
const rejectedSet = new Set(newMCPTools.filter((t) => !approvedNew.includes(t)));
if (rejectedSet.size > 0) {
updateData.tools = updateData.tools.filter((t) => !rejectedSet.has(t));
}
}
/** Rebuild the index from the tools that survive this edit: carry a prior name
* forward only while some retained tool still resolves to it, so detaching every
* tool for a server revokes agent-scoped access to it. The agent's own persisted
* names are the candidate set, which needs neither a registry query nor a guess. */
const priorNames = existingAgent.mcpServerNames ?? [];
if (priorNames.length > 0) {
const priorNameSet = new Set(priorNames);
for (const tool of updateData.tools ?? []) {
if (typeof tool !== 'string' || !tool.includes(Constants.mcp_delimiter)) {
continue;
}
const [, retainedName] = splitMCPToolKey(tool, priorNames);
if (retainedName && priorNameSet.has(retainedName)) {
resolvedServerNames.add(retainedName);
}
}
}
/** Supplying `[]` would pin the index empty and suppress `updateAgent`'s
* derivation, so only assert it when the result is authoritative: either we
* resolved names, or no MCP tool survives and the index genuinely is empty. */
const retainsMCPTools = (updateData.tools ?? []).some(isMCPTool);
if (resolvedServerNames.size > 0 || !retainsMCPTools) {
updateData.mcpServerNames = Array.from(resolvedServerNames);
}
}
}
let updatedAgent =
Object.keys(updateData).length > 0
? await db.updateAgent({ id }, updateData, {
updatingUserId: req.user.id,
})
: existingAgent;
// Add version count to the response
updatedAgent.version = updatedAgent.versions ? updatedAgent.versions.length : 0;
if (updatedAgent.author) {
updatedAgent.author = updatedAgent.author.toString();
}
await attachOwnerContacts([updatedAgent]);
if (updatedAgent.author !== req.user.id) {
delete updatedAgent.author;
}
return res.json(updatedAgent);
} catch (error) {
if (error instanceof z.ZodError) {
logger.error('[/Agents/:id] Validation error', error.errors);
return res.status(400).json({ error: 'Invalid request data', details: error.errors });
}
logger.error('[/Agents/:id] Error updating Agent', error);
if (error.statusCode === 409) {
return res.status(409).json({
error: error.message,
details: error.details,
});
}
res.status(500).json({ error: error.message });
}
};
/**
* Duplicates an Agent based on the provided ID.
* @route POST /Agents/:id/duplicate
* @param {object} req - Express Request
* @param {object} req.params - Request params
* @param {string} req.params.id - Agent identifier.
* @returns {Promise<Agent>} 201 - success response - application/json
*/
const duplicateAgentHandler = async (req, res) => {
const { id } = req.params;
const { id: userId, role: userRole } = req.user;
const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret'];
try {
const agent = await db.getAgent({ id });
if (!agent) {
return res.status(404).json({
error: 'Agent not found',
status: 'error',
});
}
const {
id: _id,
_id: __id,
author: _author,
createdAt: _createdAt,
updatedAt: _updatedAt,
tool_resources: _tool_resources = {},
versions: _versions,
__v: _v,
...cloneData
} = agent;
cloneData.name = `${agent.name} (${new Date().toLocaleString('en-US', {
dateStyle: 'short',
timeStyle: 'short',
hour12: false,
})})`;
if (_tool_resources?.[EToolResources.context]) {
cloneData.tool_resources = {
[EToolResources.context]: _tool_resources[EToolResources.context],
};
}
if (_tool_resources?.[EToolResources.ocr]) {
cloneData.tool_resources = {
/** Legacy conversion from `ocr` to `context` */
[EToolResources.context]: {
...(_tool_resources[EToolResources.context] ?? {}),
..._tool_resources[EToolResources.ocr],
},
};
}
const newAgentId = `agent_${nanoid()}`;
const newAgentData = Object.assign(cloneData, {
id: newAgentId,
author: userId,
});
if (
!validateStatefulCodeEnvironment(
req,
res,
newAgentData.stateful_code_sessions,
newAgentData.stateful_code_environment,
)
) {
return;
}
newAgentData.edges = replaceEdgeSourceId(newAgentData.edges, id, newAgentId);
newAgentData.edges = replaceEdgeSourceId(newAgentData.edges, '', newAgentId);
newAgentData.subagents = replaceAndValidateSubagentGraphAgentId(
newAgentData.subagents,
id,
newAgentId,
);
newAgentData.subagents = replaceAndValidateSubagentGraphAgentId(
newAgentData.subagents,
'',
newAgentId,
);
if (newAgentData.edges?.length) {
const { missing, unauthorized } = await validateEdgeAgentReferences(
newAgentData.edges,
userId,
userRole,
new Set([newAgentId]),
);
if (missing.length > 0) {
return res.status(400).json({
error: 'One or more agents referenced in edges do not exist',
agent_ids: missing,
});
}
if (unauthorized.length > 0) {
return res.status(403).json({
error: 'You do not have access to one or more agents referenced in edges',
agent_ids: unauthorized,
});
}
}
const subagentReferenceError = await getSubagentReferenceError(
newAgentData.subagents,
req,
new Set([newAgentId]),
);
if (subagentReferenceError) {
return res.status(subagentReferenceError.status).json(subagentReferenceError.body);
}
const originalActions = (await db.getActions({ agent_id: id }, true)) ?? [];
const sanitizedActions = originalActions.map((action) => {
const metadata = { ...(action.metadata || {}) };
for (const field of sensitiveFields) {
delete metadata[field];
}
return { ...action, metadata };
});
if (newAgentData.tools?.length) {
const [availableTools, configServers] = await Promise.all([
getCachedTools().then((t) => t ?? {}),
resolveConfigServers(req),
]);
const mcpPermissionContext = createMCPPermissionContext(req);
/** The duplicate carries the source agent's `mcpServerNames`; replace it with what
* this user is actually authorized for, or the copy would grant the source's servers. */
const resolvedServerNames = new Set();
newAgentData.tools = await filterAuthorizedTools({
tools: newAgentData.tools,
userId,
role: req.user.role,
user: req.user,
mcpPermissionContext,
availableTools,
existingTools: newAgentData.tools,
configServers,
resolvedServerNames,
});
/** When the registry is unavailable, `filterAuthorizedTools` grandfathers the
* source's tools without resolving them, so carry forward the source names those
* retained tools still point at rather than blanking the index. */
const sourceNames = agent.mcpServerNames ?? [];
if (sourceNames.length > 0) {
const sourceNameSet = new Set(sourceNames);
for (const tool of newAgentData.tools ?? []) {
if (typeof tool !== 'string' || !tool.includes(Constants.mcp_delimiter)) {
continue;
}
const [, retainedName] = splitMCPToolKey(tool, sourceNames);
if (retainedName && sourceNameSet.has(retainedName)) {
resolvedServerNames.add(retainedName);
}
}
}
newAgentData.mcpServerNames = Array.from(resolvedServerNames);
}
if (newAgentData.tool_resources) {
normalizeToolResourceFiles(newAgentData.tool_resources);
await pruneToolResourceFileIdsForAgent({
tool_resources: newAgentData.tool_resources,
ownerIds: userId,
logPrefix: '[/Agents/:id/duplicate]',
});
}
if (
(!isCodeInterpreterCapabilityEnabled(req) ||
!newAgentData.tools?.includes(Tools.execute_code)) &&
newAgentData.tool_options != null
) {
newAgentData.tool_options = removeCodeExecutionCaller(newAgentData.tool_options);
}
if (
(await blockFilteredAgentContent(req, res, newAgentData)) ||
blockFilteredActionContent(req, res, sanitizedActions)
) {
return;
}
const newActionsList = [];
/**
* Duplicates an action and returns the new action ID.
* @param {Action} action
* @returns {Promise<string>}
*/
const duplicateAction = async (action) => {
const newActionId = nanoid();
const { domain } = action.metadata;
const fullActionId = `${domain}${actionDelimiter}${newActionId}`;
const newAction = await db.updateAction(
{ action_id: newActionId, agent_id: newAgentId },
{
metadata: action.metadata,
agent_id: newAgentId,
user: userId,
},
);
newActionsList.push(newAction);
return fullActionId;
};
const agentActions = await Promise.all(
sanitizedActions.map((action) =>
duplicateAction(action).catch((error) => {
logger.error('[/agents/:id/duplicate] Error duplicating Action:', error);
}),
),
);
newAgentData.actions = agentActions;
const newAgent = await db.createAgent(newAgentData);
try {
await Promise.all([
grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.AGENT,
resourceId: newAgent._id,
accessRoleId: AccessRoleIds.AGENT_OWNER,
grantedBy: userId,
}),
grantPermission({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.REMOTE_AGENT,
resourceId: newAgent._id,
accessRoleId: AccessRoleIds.REMOTE_AGENT_OWNER,
grantedBy: userId,
}),
]);
logger.debug(
`[duplicateAgent] Granted owner permissions to user ${userId} for duplicated agent ${newAgent.id}`,
);
} catch (permissionError) {
logger.error(
`[duplicateAgent] Failed to grant owner permissions for duplicated agent ${newAgent.id}:`,
permissionError,
);
}
return res.status(201).json({
agent: newAgent,
actions: newActionsList,
});
} catch (error) {
logger.error('[/Agents/:id/duplicate] Error duplicating Agent:', error);
res.status(500).json({ error: error.message });
}
};
/**
* Deletes an Agent based on the provided ID.
* @route DELETE /Agents/:id
* @param {object} req - Express Request
* @param {object} req.params - Request params
* @param {string} req.params.id - Agent identifier.
* @returns {Promise<Agent>} 200 - success response - application/json
*/
const deleteAgentHandler = async (req, res) => {
try {
const id = req.params.id;
const agent = await db.getAgent({ id });
if (!agent) {
return res.status(404).json({ error: 'Agent not found' });
}
await db.deleteAgent({ id });
return res.json({ message: 'Agent deleted' });
} catch (error) {
logger.error('[/Agents/:id] Error deleting Agent', error);
res.status(500).json({ error: error.message });
}
};
/**
* Lists agents using ACL-aware permissions (ownership + explicit shares).
* @route GET /Agents
* @param {object} req - Express Request
* @param {object} req.query - Request query
* @param {string} [req.query.user] - The user ID of the agent's author.
* @returns {Promise<AgentListResponse>} 200 - success response - application/json
*/
const getListAgentsHandler = async (req, res) => {
try {
const userId = req.user.id;
const { category, search, limit = 100, cursor, promoted } = req.query;
let requiredPermission = req.query.requiredPermission;
if (typeof requiredPermission === 'string') {
requiredPermission = parseInt(requiredPermission, 10);
if (isNaN(requiredPermission)) {
requiredPermission = PermissionBits.VIEW;
}
} else if (typeof requiredPermission !== 'number') {
requiredPermission = PermissionBits.VIEW;
}
const canReturnSkillConfig = hasEditBit(requiredPermission);
/**
* Derived from the same bit as `canReturnSkillConfig` but answering a different question:
* skill-config exposure versus edit-permission reporting. An EDIT-scoped request matches
* only editable agents, so it needs no second lookup to know which ones those are.
*/
const needsEditableLookup = !hasEditBit(requiredPermission);
// Base filter
const filter = {};
// Handle category filter - only apply if category is defined
if (category !== undefined && category.trim() !== '') {
filter.category = category;
}
// Handle promoted filter - only from query param
if (promoted === '1') {
filter.is_promoted = true;
} else if (promoted === '0') {
filter.is_promoted = { $ne: true };
}
// Handle search filter (escape regex and cap length)
if (search && search.trim() !== '') {
const safeSearch = escapeRegex(search.trim().slice(0, MAX_SEARCH_LEN));
const regex = new RegExp(safeSearch, 'i');
filter.$or = [{ name: regex }, { description: regex }];
}
const cache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL);
const refreshKey = `${userId}:agents_avatar_refresh`;
/**
* These reads share no inputs, so they resolve together rather than chaining round
* trips ahead of the list query. The viewer skill scope and the editable set are only
* consumed when the page is non-empty; dispatching them here trades a wasted lookup on
* the (cheap) zero-agent path for one less serial hop on every populated page.
*
* `editableIds` lets a VIEW-scoped response mark which agents the caller may also edit,
* so consumers wanting just the editable subset can filter one shared VIEW fetch rather
* than issuing a second full paginated walk under an EDIT-scoped cache key. Requests
* that already ask for EDIT get it for free: everything they match is editable.
*
* `idOnTheSource` is forwarded so `getUserPrincipals` resolves identity without reading
* the user document; the auth strategies already normalize it to a value or null. Each
* omission would cost this handler another `User.findById`, once per lookup.
*/
const { idOnTheSource } = req.user;
const [
accessibleIds,
publiclyAccessibleIds,
cachedRefreshEntry,
accessibleSkillIds,
editableIds,
] = await Promise.all([
findAccessibleResources({
userId,
role: req.user.role,
idOnTheSource,
resourceType: ResourceType.AGENT,
requiredPermissions: requiredPermission,
}),
findPubliclyAccessibleResources({
resourceType: ResourceType.AGENT,
requiredPermissions: PermissionBits.VIEW,
}),
cache.get(refreshKey),
canReturnSkillConfig
? null
: findAccessibleResources({
userId,
role: req.user.role,
idOnTheSource,
resourceType: ResourceType.SKILL,
requiredPermissions: PermissionBits.VIEW,
}),
needsEditableLookup
? findAccessibleResources({
userId,
role: req.user.role,
idOnTheSource,
resourceType: ResourceType.AGENT,
requiredPermissions: PermissionBits.EDIT,
})
: null,
]);
const isValidCachedRefresh =
cachedRefreshEntry != null &&
typeof cachedRefreshEntry === 'object' &&
cachedRefreshEntry.urlCache != null;
/**
* Refresh all S3 avatars for this user's accessible agent set (not only the current page)
* This addresses page-size limits preventing refresh of agents beyond the first page.
*
* Scoped to agents that actually carry an S3 avatar so the `MAX_AVATAR_REFRESH_AGENTS`
* budget is spent on agents that can do work. Unfiltered, that budget is the most
* recently updated accessible agents regardless of avatar, and because a refresh writes
* through `updateAgent` and advances `updatedAt`, the window is self-reinforcing: an
* S3-avatar agent ranked past the budget never enters it and its presigned URL is never
* regenerated. The predicate is not indexed (`avatar` is `Mixed`), so this trades docs
* examined for that coverage.
*
* Must settle BEFORE the list query below, and is deliberately not parallelized with
* it. `updateAgent` writes through `findOneAndUpdate` on a `timestamps: true` schema,
* so refreshing an avatar advances `updatedAt`, the very field
* `getListAgentsByAccess` sorts and cursors on. A refresh landing after the first
* page's snapshot would move that agent ahead of the returned cursor, dropping it
* from every later page and silently truncating the caller's flattened list.
* Serializing costs nothing on the common path: a cache hit returns below without
* issuing any query, so only the once-per-30-minutes miss pays for the ordering.
*/
const resolveAvatarRefresh = async () => {
if (isValidCachedRefresh) {
logger.debug('[/Agents] S3 avatar refresh already checked, skipping');
return cachedRefreshEntry;
}
try {
const fullList = await db.getListAgentsByAccess({
accessibleIds,
otherParams: { 'avatar.source': FileSources.s3 },
limit: MAX_AVATAR_REFRESH_AGENTS,
after: null,
});
const { urlCache } = await refreshListAvatars({
agents: fullList?.data ?? [],
userId,
refreshS3Url,
updateAgent: db.updateAgent,
});
const refreshEntry = { urlCache };
await cache.set(refreshKey, refreshEntry, Time.THIRTY_MINUTES);
return refreshEntry;
} catch (err) {
logger.error('[/Agents] Error refreshing avatars for full list: %o', err);
return null;
}
};
const cachedRefresh = await resolveAvatarRefresh();
// Use the new ACL-aware function
const data = await db.getListAgentsByAccess({
accessibleIds,
otherParams: filter,
limit,
after: cursor,
includeSkillConfig: true,
});
const agents = data?.data ?? [];
if (!agents.length) {
return res.json(data);
}
const accessibleSkillSet = canReturnSkillConfig
? null
: new Set(mergeDeploymentSkillIds(accessibleSkillIds).map((oid) => oid.toString()));
const publicSet = new Set(publiclyAccessibleIds.map((oid) => oid.toString()));
/** Null for EDIT-scoped requests, where every matched agent is editable by definition. */
const editableSet = editableIds ? new Set(editableIds.map((oid) => oid.toString())) : null;
const agentsWithContacts = await attachOwnerContacts(agents);
const urlCache = cachedRefresh?.urlCache;
data.data = agentsWithContacts.map((agent) => {
if (accessibleSkillSet) {
sanitizeViewerSkillScope(agent, accessibleSkillSet);
}
try {
if (agent?._id && publicSet.has(agent._id.toString())) {
agent.isPublic = true;
}
agent.isEditable = editableSet == null || editableSet.has(agent?._id?.toString());
if (
urlCache &&
agent?.id &&
agent?.avatar?.source === FileSources.s3 &&
urlCache[agent.id]
) {
agent.avatar = { ...agent.avatar, filepath: urlCache[agent.id] };
}
} catch (err) {
logger.warn('[/Agents] Error mapping agent %s for list response: %o', agent?.id, err);
}
return agent;
});
return res.json(data);
} catch (error) {
logger.error('[/Agents] Error listing Agents: %o', error);
res.status(500).json({ error: error.message });
}
};
/**
* Uploads and updates an avatar for a specific agent.
* @route POST /:agent_id/avatar
* @param {object} req - Express Request
* @param {object} req.params - Request params
* @param {string} req.params.agent_id - The ID of the agent.
* @param {Express.Multer.File} req.file - The avatar image file.
* @param {object} req.body - Request body
* @param {string} [req.body.avatar] - Optional avatar for the agent's avatar.
* @returns {Promise<void>} 200 - success response - application/json
*/
const uploadAgentAvatarHandler = async (req, res) => {
try {
const appConfig = req.config;
if (!req.file) {
return res.status(400).json({ message: 'No file uploaded' });
}
filterFile({ req, file: req.file, image: true, isAvatar: true });
if (hasActiveFileFieldPolicy(req.config?.filters, ['name', 'content'])) {
const finding = inspectContent(extractFileContent({ name: req.file.originalname }), {
filters: req.config.filters,
});
if (finding != null) {
return res.status(400).json(contentFilterBlockResponse(finding));
}
const uninspectableField = getBlockedUninspectableFileField(req.config.filters, ['content']);
if (uninspectableField != null) {
return res.status(400).json(contentFilterUninspectableResponse(uninspectableField));
}
}
const { agent_id } = req.params;
if (!agent_id) {
return res.status(400).json({ message: 'Agent ID is required' });
}
const existingAgent = await db.getAgent({ id: agent_id });
if (!existingAgent) {
return res.status(404).json({ error: 'Agent not found' });
}
const buffer = await fs.readFile(req.file.path);
const fileStrategy = getFileStrategy(appConfig, { isAvatar: true });
const resizedBuffer = await resizeAvatar({
userId: req.user.id,
input: buffer,
});
const { processAvatar } = getStrategyFunctions(fileStrategy);
const avatarUrl = await processAvatar({
buffer: resizedBuffer,
userId: req.user.id,
manual: 'false',
agentId: agent_id,
tenantId: req.user.tenantId,
});
const image = {
filepath: avatarUrl,
source: fileStrategy,
};
let _avatar = existingAgent.avatar;
if (_avatar && _avatar.source) {
const { deleteFile } = getStrategyFunctions(_avatar.source);
try {
await deleteFile(req, {
filepath: _avatar.filepath,
user: req.user.id,
tenantId: req.user.tenantId,
});
await db.deleteFileByFilter({ user: req.user.id, filepath: _avatar.filepath });
} catch (error) {
logger.error('[/:agent_id/avatar] Error deleting old avatar', error);
}
}
const data = {
avatar: {
filepath: image.filepath,
source: image.source,
},
};
const updatedAgent = await db.updateAgent({ id: agent_id }, data, {
updatingUserId: req.user.id,
});
await attachOwnerContacts([updatedAgent]);
try {
const avatarCache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL);
await avatarCache.delete(`${req.user.id}:agents_avatar_refresh`);
} catch (cacheErr) {
logger.error('[/:agent_id/avatar] Error invalidating avatar refresh cache', cacheErr);
}
res.status(201).json(updatedAgent);
} catch (error) {
const message = 'An error occurred while updating the Agent Avatar';
logger.error(
`[/:agent_id/avatar] ${message} (${req.params?.agent_id ?? 'unknown agent'})`,
error,
);
res.status(500).json({ message });
} finally {
try {
await fs.unlink(req.file.path);
logger.debug('[/:agent_id/avatar] Temp. image upload file deleted');
} catch {
logger.debug('[/:agent_id/avatar] Temp. image upload file already deleted');
}
}
};
/**
* Reverts an agent to a previous version from its version history.
* @route PATCH /agents/:id/revert
* @param {object} req - Express Request object
* @param {object} req.params - Request parameters
* @param {string} req.params.id - The ID of the agent to revert
* @param {object} req.body - Request body
* @param {number} req.body.version_index - The index of the version to revert to
* @param {object} req.user - Authenticated user information
* @param {string} req.user.id - User ID
* @param {string} req.user.role - User role
* @param {ServerResponse} res - Express Response object
* @returns {Promise<Agent>} 200 - The updated agent after reverting to the specified version
* @throws {Error} 400 - If version_index is missing
* @throws {Error} 403 - If user doesn't have permission to modify the agent
* @throws {Error} 404 - If agent not found
* @throws {Error} 500 - If there's an internal server error during the reversion process
*/
const revertAgentVersionHandler = async (req, res) => {
try {
const { id } = req.params;
const { version_index } = req.body;
if (version_index === undefined) {
return res.status(400).json({ error: 'version_index is required' });
}
const existingAgent = await db.getAgent({ id });
if (!existingAgent) {
return res.status(404).json({ error: 'Agent not found' });
}
const revertVersion = existingAgent.versions?.[version_index];
if (
revertVersion &&
!validateStatefulCodeEnvironment(
req,
res,
revertVersion.stateful_code_sessions,
revertVersion.stateful_code_environment,
)
) {
return;
}
const storedRevertEdges = Array.isArray(revertVersion?.edges) ? revertVersion.edges : [];
const revertEdges = replaceEdgeSourceId(storedRevertEdges, '', id);
const hasLegacyEdgeSource = storedRevertEdges.some((edge) =>
Array.isArray(edge.from) ? edge.from.includes('') : edge.from === '',
);
if (revertEdges.length > 0) {
const { missing, unauthorized } = await validateEdgeAgentReferences(
revertEdges,
req.user.id,
req.user.role,
);
if (missing.length > 0) {
return res.status(400).json({
error: 'One or more agents referenced in edges do not exist',
agent_ids: missing,
});
}
if (unauthorized.length > 0) {
return res.status(403).json({
error: 'You do not have access to one or more agents referenced in edges',
agent_ids: unauthorized,
});
}
}
const subagentReferenceError = await getSubagentReferenceError(revertVersion?.subagents, req);
if (subagentReferenceError) {
return res.status(subagentReferenceError.status).json(subagentReferenceError.body);
}
// Permissions are enforced via route middleware (ACL EDIT)
const actionIds = (revertVersion?.actions ?? [])
.map((action) => (typeof action === 'string' ? action.split(actionDelimiter)[1] : undefined))
.filter(Boolean);
const actions =
actionIds.length > 0
? ((await db.getActions({ agent_id: id, action_id: { $in: actionIds } }, true)) ?? [])
: [];
if (
(await blockFilteredAgentContent(req, res, revertVersion)) ||
blockFilteredActionContent(req, res, actions)
) {
return;
}
let updatedAgent = await db.revertAgentVersion({ id }, version_index);
const revertUpdates = {};
if (
revertVersion &&
(hasLegacyEdgeSource || (!Array.isArray(revertVersion.edges) && updatedAgent.edges?.length))
) {
revertUpdates.edges = revertEdges;
}
if (updatedAgent.tools?.length) {
const [availableTools, configServers] = await Promise.all([
getCachedTools().then((t) => t ?? {}),
resolveConfigServers(req),
]);
const mcpPermissionContext = createMCPPermissionContext(req);
const filteredTools = await filterAuthorizedTools({
tools: updatedAgent.tools,
userId: req.user.id,
role: req.user.role,
user: req.user,
mcpPermissionContext,
availableTools,
existingTools: updatedAgent.tools,
configServers,
});
if (filteredTools.length !== updatedAgent.tools.length) {
revertUpdates.tools = filteredTools;
}
}
const effectiveRevertTools = revertUpdates.tools ?? updatedAgent.tools;
const hasCodeExecutionCaller = Object.values(updatedAgent.tool_options ?? {}).some((options) =>
options.allowed_callers?.includes('code_execution'),
);
if (
(!isCodeInterpreterCapabilityEnabled(req) ||
!effectiveRevertTools?.includes(Tools.execute_code)) &&
hasCodeExecutionCaller
) {
revertUpdates.tool_options = removeCodeExecutionCaller(updatedAgent.tool_options);
}
if (updatedAgent.tool_resources) {
const hadHydratedToolResourceFiles = Object.values(updatedAgent.tool_resources).some(
(resource) => Array.isArray(resource?.files),
);
normalizeToolResourceFiles(updatedAgent.tool_resources);
const removedCount = await pruneToolResourceFileIdsForAgent({
tool_resources: updatedAgent.tool_resources,
ownerIds: req.user.id,
existingToolResources: existingAgent.tool_resources,
logPrefix: '[/Agents/:id/revert]',
});
if (hadHydratedToolResourceFiles || removedCount > 0) {
revertUpdates.tool_resources = updatedAgent.tool_resources;
}
}
if (Object.keys(revertUpdates).length > 0) {
updatedAgent = await db.updateAgent({ id }, revertUpdates, { updatingUserId: req.user.id });
}
if (updatedAgent.author) {
updatedAgent.author = updatedAgent.author.toString();
}
await attachOwnerContacts([updatedAgent]);
if (updatedAgent.author !== req.user.id) {
delete updatedAgent.author;
}
return res.json(updatedAgent);
} catch (error) {
logger.error('[/agents/:id/revert] Error reverting Agent version', error);
res.status(500).json({ error: error.message });
}
};
/**
* Get all agent categories with counts
*
* @param {Object} _req - Express request object (unused)
* @param {Object} res - Express response object
*/
const getAgentCategories = async (_req, res) => {
try {
const categories = await db.getCategoriesWithCounts();
const promotedCount = await db.countPromotedAgents();
const formattedCategories = categories.map((category) => ({
value: category.value,
label: category.label,
count: category.agentCount,
description: category.description,
}));
if (promotedCount > 0) {
formattedCategories.unshift({
value: 'promoted',
label: 'Promoted',
count: promotedCount,
description: 'Our recommended agents',
});
}
formattedCategories.push({
value: 'all',
label: 'All',
description: 'All available agents',
});
res.status(200).json(formattedCategories);
} catch (error) {
logger.error('[/Agents/Marketplace] Error fetching agent categories:', error);
res.status(500).json({
error: 'Failed to fetch agent categories',
userMessage: 'Unable to load categories. Please refresh the page.',
suggestion: 'Try refreshing the page or check your network connection',
});
}
};
module.exports = {
createAgent: createAgentHandler,
getAgent: getAgentHandler,
getAgentVersions: getAgentVersionsHandler,
updateAgent: updateAgentHandler,
duplicateAgent: duplicateAgentHandler,
deleteAgent: deleteAgentHandler,
getListAgents: getListAgentsHandler,
uploadAgentAvatar: uploadAgentAvatarHandler,
revertAgentVersion: revertAgentVersionHandler,
getAgentCategories,
filterAuthorizedTools,
};