mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-22 01:53:33 +00:00
➕ 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:
parent
fde46a62f9
commit
2ffa9ec420
8 changed files with 523 additions and 16 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
186
packages/api/src/agents/creates.spec.ts
Normal file
186
packages/api/src/agents/creates.spec.ts
Normal 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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
105
packages/api/src/agents/creates.ts
Normal file
105
packages/api/src/agents/creates.ts
Normal 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');
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue