➕ feat: Add Agent Management Creation Endpoint (#15564)

* feat: add Agent Management creation endpoint

* 🧫 fix: Reject Null Agent Management Models (#15581)

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Dustin Healy 2026-09-03 20:45:04 -07:00 • committed by GitHub
parent fde46a62f9
commit 2ffa9ec420
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 523 additions and 16 deletions

View file

@ -1,12 +1,16 @@
const mongoose = require('mongoose');
const express = require('express');
const request = require('supertest');
const { nanoid } = require('nanoid');
const { v4: uuidv4 } = require('uuid');
const { agentSchema, aclEntrySchema, fileSchema, userSchema } = require('@librechat/data-schemas');
const { createModels, tenantStorage } = require('@librechat/data-schemas');
const {
Tools,
SkillsScope,
FileSources,
Permissions,
PermissionBits,
PermissionTypes,
PrincipalModel,
PrincipalType,
ResourceType,
@ -98,9 +102,14 @@ const {
const {
CONTENT_TRAVERSAL_MAX_DEPTH,
createAgentManagementAuth,
createAgentManagementCreateHandler,
createAgentManagementReadHandlers,
mergeDeploymentSkillIds,
refreshS3Url,
} = require('@librechat/api');
const { grantPermission } = require('~/server/services/PermissionService');
const db = require('~/models');
/**
* @type {import('mongoose').Model<import('@librechat/data-schemas').IAgent>}
@ -153,12 +162,10 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
await mongoose.connect(mongoUri);
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
AclEntry = mongoose.models.AclEntry || mongoose.model('AclEntry', aclEntrySchema);
User = mongoose.models.User || mongoose.model('User', userSchema);
// Register File so orphan-pruning tests (and the tool_resources validation
// test, which now needs real File docs for its ids) have a working model.
mongoose.models.File || mongoose.model('File', fileSchema);
createModels(mongoose);
Agent = mongoose.models.Agent;
AclEntry = mongoose.models.AclEntry;
User = mongoose.models.User;
}, 20000);
afterAll(async () => {
@ -373,6 +380,165 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(agentInDb.author.toString()).toBe(mockReq.user.id);
});
test('management creation can be read back through the authenticated management API', async () => {
const tenantId = `tenant-${nanoid(8)}`;
const clientId = `client-${nanoid(8)}`;
const principal = await tenantStorage.run({ tenantId }, () => createOwner());
const userId = principal._id.toString();
const getRoleByName = jest.fn().mockResolvedValue({
permissions: {
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
},
},
});
grantPermission.mockImplementation(
async ({ principalType, principalId, resourceType, resourceId, grantedBy }) =>
AclEntry.create({
principalType,
principalModel: PrincipalModel.USER,
principalId,
resourceType,
resourceId,
permBits: OWNER_PERMISSION_BITS,
grantedBy,
grantedAt: new Date(),
}),
);
const auth = createAgentManagementAuth({
findUser: db.findUser,
isPrincipalActive: jest.fn().mockResolvedValue(true),
getAppConfig: jest.fn().mockResolvedValue({
endpoints: {
agents: {
managementApi: {
auth: {
oidc: {
enabled: true,
audience: 'agent-management',
issuer: 'https://issuer.example.com/',
},
clients: [{ clientId, tenantId, userId }],
},
},
},
},
}),
verifyAccessToken: jest.fn().mockResolvedValue({
azp: clientId,
sub: `${clientId}@clients`,
exp: Math.floor(Date.now() / 1000) + 300,
}),
});
const create = createAgentManagementCreateHandler({
getRoleByName,
createAgent: createAgentHandler,
});
const reads = createAgentManagementReadHandlers({
getRoleByName,
getAgentWithVersionCount: db.getAgentWithVersionCount,
getAgentManagementListByAccess: db.getAgentManagementListByAccess,
findAccessibleResources: async ({ userId: accessibleUserId, resourceType }) =>
AclEntry.distinct('resourceId', {
principalId: accessibleUserId,
resourceType,
permBits: { $bitsAllSet: PermissionBits.EDIT },
}),
checkPermission: async ({ userId: accessibleUserId, resourceType, resourceId }) =>
(await AclEntry.exists({
principalId: accessibleUserId,
resourceType,
resourceId,
permBits: { $bitsAllSet: PermissionBits.EDIT },
})) != null,
hasCapability: jest.fn().mockResolvedValue(false),
});
const app = express();
app.use(express.json());
app.use('/api/agents/v1/agents', auth);
app.post('/api/agents/v1/agents', (req, res) => {
req.config = {};
return create(req, res);
});
app.get('/api/agents/v1/agents', reads.list);
app.get('/api/agents/v1/agents/:id', reads.get);
const createdResponse = await request(app)
.post('/api/agents/v1/agents')
.set('Authorization', 'Bearer valid-token')
.send({
name: 'Managed Agent',
provider: 'openai',
model: 'gpt-4',
});
expect(createdResponse.status).toBe(201);
expect(createdResponse.body).toEqual(
expect.objectContaining({
id: expect.stringMatching(/^agent_/),
name: 'Managed Agent',
provider: 'openai',
model: 'gpt-4',
version: 1,
}),
);
expect(createdResponse.body).not.toHaveProperty('_id');
expect(createdResponse.body).not.toHaveProperty('author');
expect(createdResponse.body).not.toHaveProperty('tenantId');
const [retrievedResponse, listedResponse] = await Promise.all([
request(app)
.get(`/api/agents/v1/agents/${createdResponse.body.id}`)
.set('Authorization', 'Bearer valid-token'),
request(app).get('/api/agents/v1/agents').set('Authorization', 'Bearer valid-token'),
]);
expect(retrievedResponse.status).toBe(200);
expect(retrievedResponse.body).toEqual(createdResponse.body);
expect(listedResponse.status).toBe(200);
expect(listedResponse.body.data).toEqual([createdResponse.body]);
const created = await tenantStorage.run({ tenantId }, () =>
Agent.findOne({ id: createdResponse.body.id }).lean(),
);
expect(created.tenantId).toBe(tenantId);
expect(created.author.toString()).toBe(userId);
expect(grantPermission).toHaveBeenCalledWith(
expect.objectContaining({
principalType: PrincipalType.USER,
principalId: userId,
resourceType: ResourceType.AGENT,
}),
);
});
test('management creation rejects caller-controlled ownership', async () => {
const handler = createAgentManagementCreateHandler({
getRoleByName: jest.fn().mockResolvedValue({
permissions: {
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
},
},
}),
createAgent: createAgentHandler,
});
mockReq.user.tenantId = 'tenant-a';
mockReq.body = {
name: 'Managed Agent',
provider: 'openai',
model: 'gpt-4',
author: new mongoose.Types.ObjectId().toString(),
};
await handler(mockReq, mockRes);
expect(mockRes.status).toHaveBeenCalledWith(400);
expect(await Agent.countDocuments()).toBe(0);
});
test('should reject creation with unauthorized fields (mass assignment protection)', async () => {
const maliciousData = {
// Required fields

View file

@ -11,7 +11,15 @@ const mockCreateAgentManagementReadHandlers = jest.fn(() => ({
list: mockList,
get: mockGet,
}));
const mockCreate = jest.fn((_req, res) => res.status(201).json({ id: 'agent-created' }));
let mockCreateDeps;
const mockCreateAgentManagementCreateHandler = jest.fn((deps) => {
mockCreateDeps = deps;
return mockCreate;
});
const mockBrowserCreate = jest.fn();
const mockCheckBan = jest.fn((_req, _res, next) => next());
const mockConfigMiddleware = jest.fn((_req, _res, next) => next());
const mockUaParser = jest.fn((_req, _res, next) => next());
const mockRequireAgentManagementAuth = jest.fn((req, res, next) => {
@ -27,12 +35,15 @@ jest.mock('../middleware', () => ({
}));
jest.mock('@librechat/api', () => ({
mapAgentManagementError: mockMapAgentManagementError,
createAgentManagementCreateHandler: mockCreateAgentManagementCreateHandler,
createAgentManagementReadHandlers: mockCreateAgentManagementReadHandlers,
}));
jest.mock('~/server/middleware', () => ({
checkBan: mockCheckBan,
configMiddleware: mockConfigMiddleware,
uaParser: mockUaParser,
}));
jest.mock('~/server/controllers/agents/v1', () => ({ createAgent: mockBrowserCreate }));
jest.mock('~/server/middleware/roles/capabilities', () => ({ hasCapability: jest.fn() }));
jest.mock('~/server/services/PermissionService', () => ({
checkPermission: jest.fn(),
@ -53,12 +64,15 @@ describe('Agent Management route boundary', () => {
beforeEach(() => {
mockRequireAgentManagementAuth.mockClear();
mockCheckBan.mockClear();
mockConfigMiddleware.mockClear();
mockUaParser.mockClear();
mockMapAgentManagementError.mockClear();
});
it('rejects a request before reaching management routes without machine authentication', async () => {
const response = await request(app).get('/api/agents/v1/agents');
const response = await request(app)
.post('/api/agents/v1/agents')
.send({ name: 'Managed Agent', provider: 'openAI', model: 'gpt-5' });
expect(response.status).toBe(401);
expect(mockRequireAgentManagementAuth).toHaveBeenCalledTimes(1);
@ -93,4 +107,18 @@ describe('Agent Management route boundary', () => {
expect(mockList).toHaveBeenCalledTimes(1);
expect(mockGet).toHaveBeenCalledTimes(1);
});
it('loads Agent configuration and dispatches authenticated creation requests', async () => {
const response = await request(app)
.post('/api/agents/v1/agents')
.set('Authorization', 'Bearer valid-token')
.send({ name: 'Managed Agent', provider: 'openAI', model: 'gpt-5' });
expect(response.status).toBe(201);
expect(response.body).toEqual({ id: 'agent-created' });
expect(mockConfigMiddleware).toHaveBeenCalledTimes(1);
expect(mockCreate).toHaveBeenCalledTimes(1);
expect(mockCreateDeps.getRoleByName).toEqual(expect.any(Function));
expect(mockCreateDeps.createAgent).toBe(mockBrowserCreate);
});
});

View file

@ -1,13 +1,18 @@
const express = require('express');
const { createAgentManagementReadHandlers, mapAgentManagementError } = require('@librechat/api');
const { checkBan } = require('~/server/middleware');
const {
createAgentManagementCreateHandler,
createAgentManagementReadHandlers,
mapAgentManagementError,
} = require('@librechat/api');
const { checkBan, configMiddleware } = require('~/server/middleware');
const { hasCapability } = require('~/server/middleware/roles/capabilities');
const { checkPermission, findAccessibleResources } = require('~/server/services/PermissionService');
const v1 = require('~/server/controllers/agents/v1');
const db = require('~/models');
const { requireAgentManagementAuth } = require('./middleware');
const router = express.Router();
const handlers = createAgentManagementReadHandlers({
const readHandlers = createAgentManagementReadHandlers({
getRoleByName: db.getRoleByName,
getAgentWithVersionCount: db.getAgentWithVersionCount,
getAgentManagementListByAccess: db.getAgentManagementListByAccess,
@ -15,12 +20,17 @@ const handlers = createAgentManagementReadHandlers({
checkPermission,
hasCapability,
});
const createHandler = createAgentManagementCreateHandler({
getRoleByName: db.getRoleByName,
createAgent: v1.createAgent,
});
router.use(requireAgentManagementAuth);
router.use(checkBan);
router.get('/', handlers.list);
router.get('/:id', handlers.get);
router.post('/', configMiddleware, createHandler);
router.get('/', readHandlers.list);
router.get('/:id', readHandlers.get);
router.use((_req, res) => {
const { status, body } = mapAgentManagementError('not_found');

View file

@ -0,0 +1,186 @@
import { Types } from 'mongoose';
import { Permissions, PermissionTypes } from 'librechat-data-provider';
import type { IRole, IUser } from '@librechat/data-schemas';
import type { Request, Response } from 'express';
import type { AgentManagementCreateDeps } from './creates';
import { createAgentManagementCreateHandler } from './creates';
jest.mock('@librechat/data-schemas', () => {
const actual = jest.requireActual('@librechat/data-schemas');
return {
...actual,
logger: { warn: jest.fn(), error: jest.fn() },
};
});
const user = {
id: new Types.ObjectId().toString(),
tenantId: 'tenant-a',
role: 'USER',
} as IUser;
const validBody = {
name: 'Managed Agent',
provider: 'openAI',
model: 'gpt-5',
};
const createdAgent = {
_id: new Types.ObjectId(),
id: 'agent-created',
author: new Types.ObjectId(user.id),
tenantId: user.tenantId,
name: validBody.name,
provider: validBody.provider,
model: validBody.model,
versions: [{}],
createdAt: new Date('2026-09-03T10:00:00.000Z'),
updatedAt: new Date('2026-09-03T10:00:00.000Z'),
};
function makeRequest(overrides: Partial<Request> = {}): Request {
return { user, body: validBody, ...overrides } as Request;
}
function makeResponse(): Response {
const response = {
status: jest.fn(),
json: jest.fn(),
};
response.status.mockReturnValue(response);
response.json.mockReturnValue(response);
return response as unknown as Response;
}
function makeDeps(overrides: Partial<AgentManagementCreateDeps> = {}): AgentManagementCreateDeps {
return {
getRoleByName: jest.fn().mockResolvedValue({
permissions: {
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
},
},
} as unknown as IRole),
createAgent: jest.fn(async (_req: Request, res: Response) =>
res.status(201).json(createdAgent),
),
...overrides,
};
}
describe('Agent Management create handler', () => {
it('delegates creation with the authenticated user and returns the management projection', async () => {
const deps = makeDeps();
const request = makeRequest();
const response = makeResponse();
await createAgentManagementCreateHandler(deps)(request, response);
expect(deps.createAgent).toHaveBeenCalledWith(request, expect.anything());
expect(request.user).toBe(user);
expect(response.status).toHaveBeenCalledWith(201);
expect(response.json).toHaveBeenCalledWith({
id: 'agent-created',
name: 'Managed Agent',
provider: 'openAI',
model: 'gpt-5',
version: 1,
createdAt: '2026-09-03T10:00:00.000Z',
updatedAt: '2026-09-03T10:00:00.000Z',
});
});
it('rejects caller-supplied ownership fields before creation', async () => {
const deps = makeDeps();
const response = makeResponse();
await createAgentManagementCreateHandler(deps)(
makeRequest({ body: { ...validBody, author: new Types.ObjectId().toString() } }),
response,
);
expect(response.status).toHaveBeenCalledWith(400);
expect(response.json).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.objectContaining({ code: 'invalid_request' }) }),
);
expect(deps.createAgent).not.toHaveBeenCalled();
});
it('rejects a null model as invalid input before persistence', async () => {
const deps = makeDeps();
const response = makeResponse();
await createAgentManagementCreateHandler(deps)(
makeRequest({ body: { ...validBody, model: null } }),
response,
);
expect(response.status).toHaveBeenCalledWith(400);
expect(response.json).toHaveBeenCalledWith(
expect.objectContaining({ error: expect.objectContaining({ code: 'invalid_request' }) }),
);
expect(deps.createAgent).not.toHaveBeenCalled();
});
it('requires the same AGENTS USE and CREATE permissions as browser creation', async () => {
const deps = makeDeps({
getRoleByName: jest.fn().mockResolvedValue({
permissions: {
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.CREATE]: false,
},
},
} as never),
});
const response = makeResponse();
await createAgentManagementCreateHandler(deps)(makeRequest(), response);
expect(response.status).toHaveBeenCalledWith(403);
expect(deps.createAgent).not.toHaveBeenCalled();
});
it('fails closed without a tenant-bound authenticated user', async () => {
const deps = makeDeps();
const response = makeResponse();
await createAgentManagementCreateHandler(deps)(
makeRequest({ user: { ...user, tenantId: undefined } as IUser }),
response,
);
expect(response.status).toHaveBeenCalledWith(403);
expect(deps.getRoleByName).not.toHaveBeenCalled();
expect(deps.createAgent).not.toHaveBeenCalled();
});
it('maps policy failures from the shared creation flow to the management error contract', async () => {
const deps = makeDeps({
createAgent: jest.fn(async (_req: Request, res: Response) =>
res.status(403).json({ error: 'policy detail' }),
),
});
const response = makeResponse();
await createAgentManagementCreateHandler(deps)(makeRequest(), response);
expect(response.status).toHaveBeenCalledWith(403);
expect(response.json).toHaveBeenCalledWith({
error: { code: 'permission_denied', message: 'Permission denied' },
});
});
it('does not expose errors thrown by the shared creation flow', async () => {
const deps = makeDeps({
createAgent: jest.fn().mockRejectedValue(new Error('database connection secret')),
});
const response = makeResponse();
await createAgentManagementCreateHandler(deps)(makeRequest(), response);
expect(response.status).toHaveBeenCalledWith(500);
expect(response.json).toHaveBeenCalledWith({
error: { code: 'internal_error', message: 'Internal server error' },
});
});
});

View file

@ -0,0 +1,105 @@
import { logger } from '@librechat/data-schemas';
import { Permissions, PermissionTypes } from 'librechat-data-provider';
import type { IRole, IUser } from '@librechat/data-schemas';
import type { Request, Response } from 'express';
import type { AgentManagementProjectionSource } from './management';
import {
agentManagementCreateSchema,
mapAgentManagementError,
projectAgentManagementResponse,
} from './management';
import { checkAccessWithRequestCache } from '../middleware/access';
type AgentCreateHandler = (
req: Request,
res: Response,
) => Promise<Response | void> | Response | void;
export interface AgentManagementCreateDeps {
getRoleByName: (roleName: string, fieldsToSelect?: string | string[]) => Promise<IRole | null>;
createAgent: AgentCreateHandler;
}
function sendError(
res: Response,
code: Parameters<typeof mapAgentManagementError>[0],
error?: unknown,
) {
const mapped = mapAgentManagementError(code, error);
return res.status(mapped.status).json(mapped.body);
}
function mapCreateStatus(status: number): Parameters<typeof mapAgentManagementError>[0] {
if (status === 400 || status === 409) {
return 'invalid_request';
}
if (status === 401 || status === 403) {
return 'permission_denied';
}
if (status === 404) {
return 'not_found';
}
return 'internal_error';
}
function createResponseAdapter(res: Response): {
response: Response;
getResult: () => Response | undefined;
} {
let statusCode = 200;
let result: Response | undefined;
const adapter = Object.create(res) as Response;
adapter.status = ((status: number) => {
statusCode = status;
return adapter;
}) as Response['status'];
adapter.json = ((body?: AgentManagementProjectionSource) => {
if (statusCode === 201 && body != null) {
result = res.status(201).json(projectAgentManagementResponse(body));
return result;
}
result = sendError(res, mapCreateStatus(statusCode));
return result;
}) as Response['json'];
return { response: adapter, getResult: () => result };
}
/** Validate and authorize Agent Management creation before reusing the browser creation flow. */
export function createAgentManagementCreateHandler(
deps: AgentManagementCreateDeps,
): (req: Request, res: Response) => Promise<Response> {
return async function create(req: Request, res: Response): Promise<Response> {
try {
const user = req.user as IUser | undefined;
if (!user?.id || !user.tenantId) {
return sendError(res, 'permission_denied');
}
const canCreate = await checkAccessWithRequestCache({
req,
user,
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE, Permissions.CREATE],
getRoleByName: deps.getRoleByName,
});
if (!canCreate) {
return sendError(res, 'permission_denied');
}
const parsedBody = agentManagementCreateSchema.safeParse(req.body);
if (!parsedBody.success) {
return sendError(res, 'invalid_request', parsedBody.error);
}
req.body = parsedBody.data;
const adapter = createResponseAdapter(res);
await deps.createAgent(req, adapter.response);
return adapter.getResult() ?? sendError(res, 'internal_error');
} catch (error) {
logger.error('[AgentManagement] Error creating Agent', error);
return sendError(res, 'internal_error');
}
};
}

View file

@ -11,6 +11,7 @@ export * from './contact';
export * from './context';
export * from './control';
export * from './conversation';
export * from './creates';
export * from './discovery';
export * from './edges';
export * from './errors';

View file

@ -77,6 +77,12 @@ describe('Agent Management contract', () => {
expect(schema.safeParse({ ...base, versions: [] }).success).toBe(false);
},
);
it('rejects a null model before Agent creation reaches persistence', () => {
expect(
agentManagementCreateSchema.safeParse({ provider: 'openAI', model: null }).success,
).toBe(false);
});
});
describe('pagination', () => {

View file

@ -10,7 +10,12 @@ const MAX_LIST_LIMIT = 100;
const DEFAULT_LIST_LIMIT = 20;
const MAX_CURSOR_LENGTH = 512;
export type AgentManagementCreate = z.output<typeof agentCreateSchema>;
export type AgentManagementCreate = Omit<z.output<typeof agentCreateSchema>, 'model'> & {
model: string;
};
type AgentManagementCreateInput = Omit<z.input<typeof agentCreateSchema>, 'model'> & {
model: string;
};
export type AgentManagementUpdate = z.output<typeof agentUpdateSchema>;
export type AgentManagementList = {
limit: number;
@ -75,8 +80,8 @@ export type AgentManagementError = {
export const agentManagementCreateSchema: z.ZodType<
AgentManagementCreate,
z.ZodTypeDef,
z.input<typeof agentCreateSchema>
> = agentCreateSchema.strict();
AgentManagementCreateInput
> = agentCreateSchema.extend({ model: z.string() }).strict();
export const agentManagementUpdateSchema: z.ZodType<AgentManagementUpdate> =
agentUpdateSchema.strict();