diff --git a/api/server/controllers/agents/v1.spec.js b/api/server/controllers/agents/v1.spec.js index f3e3131d47..ada54a535e 100644 --- a/api/server/controllers/agents/v1.spec.js +++ b/api/server/controllers/agents/v1.spec.js @@ -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} @@ -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 diff --git a/api/server/routes/agents/__tests__/management.spec.js b/api/server/routes/agents/__tests__/management.spec.js index 696346fb29..0f8545aa8f 100644 --- a/api/server/routes/agents/__tests__/management.spec.js +++ b/api/server/routes/agents/__tests__/management.spec.js @@ -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); + }); }); diff --git a/api/server/routes/agents/management.js b/api/server/routes/agents/management.js index 2b9e3178b5..fecca3cffc 100644 --- a/api/server/routes/agents/management.js +++ b/api/server/routes/agents/management.js @@ -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'); diff --git a/packages/api/src/agents/creates.spec.ts b/packages/api/src/agents/creates.spec.ts new file mode 100644 index 0000000000..32f6316b37 --- /dev/null +++ b/packages/api/src/agents/creates.spec.ts @@ -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 { + 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 { + 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' }, + }); + }); +}); diff --git a/packages/api/src/agents/creates.ts b/packages/api/src/agents/creates.ts new file mode 100644 index 0000000000..e26178e419 --- /dev/null +++ b/packages/api/src/agents/creates.ts @@ -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; + +export interface AgentManagementCreateDeps { + getRoleByName: (roleName: string, fieldsToSelect?: string | string[]) => Promise; + createAgent: AgentCreateHandler; +} + +function sendError( + res: Response, + code: Parameters[0], + error?: unknown, +) { + const mapped = mapAgentManagementError(code, error); + return res.status(mapped.status).json(mapped.body); +} + +function mapCreateStatus(status: number): Parameters[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 { + return async function create(req: Request, res: Response): Promise { + 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'); + } + }; +} diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index 053f0d89c0..b907c40886 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -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'; diff --git a/packages/api/src/agents/management.spec.ts b/packages/api/src/agents/management.spec.ts index 7a939f34cd..4e03386dca 100644 --- a/packages/api/src/agents/management.spec.ts +++ b/packages/api/src/agents/management.spec.ts @@ -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', () => { diff --git a/packages/api/src/agents/management.ts b/packages/api/src/agents/management.ts index 439d761b22..00d8e460cd 100644 --- a/packages/api/src/agents/management.ts +++ b/packages/api/src/agents/management.ts @@ -10,7 +10,12 @@ const MAX_LIST_LIMIT = 100; const DEFAULT_LIST_LIMIT = 20; const MAX_CURSOR_LENGTH = 512; -export type AgentManagementCreate = z.output; +export type AgentManagementCreate = Omit, 'model'> & { + model: string; +}; +type AgentManagementCreateInput = Omit, 'model'> & { + model: string; +}; export type AgentManagementUpdate = z.output; export type AgentManagementList = { limit: number; @@ -75,8 +80,8 @@ export type AgentManagementError = { export const agentManagementCreateSchema: z.ZodType< AgentManagementCreate, z.ZodTypeDef, - z.input -> = agentCreateSchema.strict(); + AgentManagementCreateInput +> = agentCreateSchema.extend({ model: z.string() }).strict(); export const agentManagementUpdateSchema: z.ZodType = agentUpdateSchema.strict();