mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-22 10:02:41 +00:00
✏️ feat: Add Agent Management Update Endpoint (#15566)
Some checks are pending
Backend Unit Tests / Build packages (push) Waiting to run
Backend Unit Tests / Codegraph select (push) Waiting to run
Backend Unit Tests / TypeScript type checks (push) Blocked by required conditions
Backend Unit Tests / Circular dependency checks (push) Waiting to run
Backend Unit Tests / Tests: api (shard 1/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 2/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 3/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: data-provider (push) Blocked by required conditions
Backend Unit Tests / Tests: data-schemas (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 1/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 2/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 3/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 4/4) (push) Blocked by required conditions
Codegraph E2E Votes / vote (full suite) (push) Waiting to run
Docker Dev Branch Images Build / publish (push) Waiting to run
Frontend Unit Tests / Codegraph select (push) Waiting to run
Frontend Unit Tests / Build packages (push) Waiting to run
Frontend Unit Tests / TypeScript type checks (client) (push) Blocked by required conditions
Frontend Unit Tests / Tests: @librechat/client (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 1/2) (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 2/2) (push) Blocked by required conditions
Frontend Unit Tests / Vite build verification (push) Blocked by required conditions
Some checks are pending
Backend Unit Tests / Build packages (push) Waiting to run
Backend Unit Tests / Codegraph select (push) Waiting to run
Backend Unit Tests / TypeScript type checks (push) Blocked by required conditions
Backend Unit Tests / Circular dependency checks (push) Waiting to run
Backend Unit Tests / Tests: api (shard 1/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 2/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: api (shard 3/3) (push) Blocked by required conditions
Backend Unit Tests / Tests: data-provider (push) Blocked by required conditions
Backend Unit Tests / Tests: data-schemas (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 1/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 2/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 3/4) (push) Blocked by required conditions
Backend Unit Tests / Tests: @librechat/api (shard 4/4) (push) Blocked by required conditions
Codegraph E2E Votes / vote (full suite) (push) Waiting to run
Docker Dev Branch Images Build / publish (push) Waiting to run
Frontend Unit Tests / Codegraph select (push) Waiting to run
Frontend Unit Tests / Build packages (push) Waiting to run
Frontend Unit Tests / TypeScript type checks (client) (push) Blocked by required conditions
Frontend Unit Tests / Tests: @librechat/client (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 1/2) (push) Blocked by required conditions
Frontend Unit Tests / Tests: Ubuntu (shard 2/2) (push) Blocked by required conditions
Frontend Unit Tests / Vite build verification (push) Blocked by required conditions
* feat: add Agent Management update endpoint * fix: reject unsupported Agent update values * test: simplify Agent update mocks
This commit is contained in:
parent
9a43362663
commit
ab0d74b492
8 changed files with 545 additions and 15 deletions
|
|
@ -105,6 +105,7 @@ const {
|
|||
createAgentManagementAuth,
|
||||
createAgentManagementCreateHandler,
|
||||
createAgentManagementReadHandlers,
|
||||
createAgentManagementUpdateHandler,
|
||||
mergeDeploymentSkillIds,
|
||||
refreshS3Url,
|
||||
} = require('@librechat/api');
|
||||
|
|
@ -380,7 +381,7 @@ 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 () => {
|
||||
test('management creation can be updated and read through the authenticated management API', async () => {
|
||||
const tenantId = `tenant-${nanoid(8)}`;
|
||||
const clientId = `client-${nanoid(8)}`;
|
||||
const principal = await tenantStorage.run({ tenantId }, () => createOwner());
|
||||
|
|
@ -435,6 +436,14 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
|
|||
getRoleByName,
|
||||
createAgent: createAgentHandler,
|
||||
});
|
||||
const checkEditPermission = async ({ userId: accessibleUserId, resourceType, resourceId }) =>
|
||||
(await AclEntry.exists({
|
||||
principalId: accessibleUserId,
|
||||
resourceType,
|
||||
resourceId,
|
||||
permBits: { $bitsAllSet: PermissionBits.EDIT },
|
||||
})) != null;
|
||||
const hasCapability = jest.fn().mockResolvedValue(false);
|
||||
const reads = createAgentManagementReadHandlers({
|
||||
getRoleByName,
|
||||
getAgentWithVersionCount: db.getAgentWithVersionCount,
|
||||
|
|
@ -445,14 +454,15 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
|
|||
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),
|
||||
checkPermission: checkEditPermission,
|
||||
hasCapability,
|
||||
});
|
||||
const update = createAgentManagementUpdateHandler({
|
||||
getRoleByName,
|
||||
getAgentWithVersionCount: db.getAgentWithVersionCount,
|
||||
checkPermission: checkEditPermission,
|
||||
hasCapability,
|
||||
updateAgent: updateAgentHandler,
|
||||
});
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
|
@ -463,14 +473,21 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
|
|||
});
|
||||
app.get('/api/agents/v1/agents', reads.list);
|
||||
app.get('/api/agents/v1/agents/:id', reads.get);
|
||||
app.patch('/api/agents/v1/agents/:id', (req, res) => {
|
||||
req.config = {};
|
||||
return update(req, res);
|
||||
});
|
||||
|
||||
const createdResponse = await request(app)
|
||||
.post('/api/agents/v1/agents')
|
||||
.set('Authorization', 'Bearer valid-token')
|
||||
.send({
|
||||
name: 'Managed Agent',
|
||||
description: 'Description that remains unchanged',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
avatar: { filepath: 'avatars/managed.png', source: 'local' },
|
||||
conversation_starters: ['Help me get started'],
|
||||
});
|
||||
|
||||
expect(createdResponse.status).toBe(201);
|
||||
|
|
@ -499,11 +516,63 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
|
|||
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(),
|
||||
const otherTenantId = `tenant-${nanoid(8)}`;
|
||||
await tenantStorage.run({ tenantId: otherTenantId }, () =>
|
||||
Agent.create({
|
||||
id: createdResponse.body.id,
|
||||
name: 'Other tenant Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: new mongoose.Types.ObjectId(),
|
||||
}),
|
||||
);
|
||||
|
||||
const updatedResponse = await request(app)
|
||||
.patch(`/api/agents/v1/agents/${createdResponse.body.id}`)
|
||||
.set('Authorization', 'Bearer valid-token')
|
||||
.send({
|
||||
name: 'Updated Managed Agent',
|
||||
avatar: null,
|
||||
conversation_starters: [],
|
||||
});
|
||||
|
||||
expect(updatedResponse.status).toBe(200);
|
||||
expect(updatedResponse.body).toEqual(
|
||||
expect.objectContaining({
|
||||
id: createdResponse.body.id,
|
||||
name: 'Updated Managed Agent',
|
||||
description: 'Description that remains unchanged',
|
||||
avatar: null,
|
||||
conversation_starters: [],
|
||||
version: 2,
|
||||
}),
|
||||
);
|
||||
expect(updatedResponse.body).not.toHaveProperty('_id');
|
||||
expect(updatedResponse.body).not.toHaveProperty('author');
|
||||
expect(updatedResponse.body).not.toHaveProperty('tenantId');
|
||||
|
||||
const retrievedAfterUpdate = await request(app)
|
||||
.get(`/api/agents/v1/agents/${createdResponse.body.id}`)
|
||||
.set('Authorization', 'Bearer valid-token');
|
||||
expect(retrievedAfterUpdate.status).toBe(200);
|
||||
expect(retrievedAfterUpdate.body).toEqual(updatedResponse.body);
|
||||
|
||||
const created = await Agent.collection.findOne({
|
||||
id: createdResponse.body.id,
|
||||
tenantId,
|
||||
});
|
||||
const otherTenantAgent = await Agent.collection.findOne({
|
||||
id: createdResponse.body.id,
|
||||
tenantId: otherTenantId,
|
||||
});
|
||||
expect(created.tenantId).toBe(tenantId);
|
||||
expect(created.author.toString()).toBe(userId);
|
||||
expect(created.name).toBe('Updated Managed Agent');
|
||||
expect(created.description).toBe('Description that remains unchanged');
|
||||
expect(created.avatar).toBeNull();
|
||||
expect(created.conversation_starters).toEqual([]);
|
||||
expect(created.versions).toHaveLength(2);
|
||||
expect(otherTenantAgent.name).toBe('Other tenant Agent');
|
||||
expect(grantPermission).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
principalType: PrincipalType.USER,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,14 @@ const mockCreateAgentManagementCreateHandler = jest.fn((deps) => {
|
|||
mockCreateDeps = deps;
|
||||
return mockCreate;
|
||||
});
|
||||
const mockUpdate = jest.fn((_req, res) => res.status(200).json({ id: 'agent-updated' }));
|
||||
let mockUpdateDeps;
|
||||
const mockCreateAgentManagementUpdateHandler = jest.fn((deps) => {
|
||||
mockUpdateDeps = deps;
|
||||
return mockUpdate;
|
||||
});
|
||||
const mockBrowserCreate = jest.fn();
|
||||
const mockBrowserUpdate = 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());
|
||||
|
|
@ -37,13 +44,17 @@ jest.mock('@librechat/api', () => ({
|
|||
mapAgentManagementError: mockMapAgentManagementError,
|
||||
createAgentManagementCreateHandler: mockCreateAgentManagementCreateHandler,
|
||||
createAgentManagementReadHandlers: mockCreateAgentManagementReadHandlers,
|
||||
createAgentManagementUpdateHandler: mockCreateAgentManagementUpdateHandler,
|
||||
}));
|
||||
jest.mock('~/server/middleware', () => ({
|
||||
checkBan: mockCheckBan,
|
||||
configMiddleware: mockConfigMiddleware,
|
||||
uaParser: mockUaParser,
|
||||
}));
|
||||
jest.mock('~/server/controllers/agents/v1', () => ({ createAgent: mockBrowserCreate }));
|
||||
jest.mock('~/server/controllers/agents/v1', () => ({
|
||||
createAgent: mockBrowserCreate,
|
||||
updateAgent: mockBrowserUpdate,
|
||||
}));
|
||||
jest.mock('~/server/middleware/roles/capabilities', () => ({ hasCapability: jest.fn() }));
|
||||
jest.mock('~/server/services/PermissionService', () => ({
|
||||
checkPermission: jest.fn(),
|
||||
|
|
@ -121,4 +132,21 @@ describe('Agent Management route boundary', () => {
|
|||
expect(mockCreateDeps.getRoleByName).toEqual(expect.any(Function));
|
||||
expect(mockCreateDeps.createAgent).toBe(mockBrowserCreate);
|
||||
});
|
||||
|
||||
it('loads Agent configuration and dispatches authenticated update requests', async () => {
|
||||
const response = await request(app)
|
||||
.patch('/api/agents/v1/agents/agent-one')
|
||||
.set('Authorization', 'Bearer valid-token')
|
||||
.send({ name: 'Updated Agent' });
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body).toEqual({ id: 'agent-updated' });
|
||||
expect(mockConfigMiddleware).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdate).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateDeps.getRoleByName).toEqual(expect.any(Function));
|
||||
expect(mockUpdateDeps.getAgentWithVersionCount).toEqual(expect.any(Function));
|
||||
expect(mockUpdateDeps.checkPermission).toEqual(expect.any(Function));
|
||||
expect(mockUpdateDeps.hasCapability).toEqual(expect.any(Function));
|
||||
expect(mockUpdateDeps.updateAgent).toBe(mockBrowserUpdate);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ const express = require('express');
|
|||
const {
|
||||
createAgentManagementCreateHandler,
|
||||
createAgentManagementReadHandlers,
|
||||
createAgentManagementUpdateHandler,
|
||||
mapAgentManagementError,
|
||||
} = require('@librechat/api');
|
||||
const { checkBan, configMiddleware } = require('~/server/middleware');
|
||||
|
|
@ -24,6 +25,13 @@ const createHandler = createAgentManagementCreateHandler({
|
|||
getRoleByName: db.getRoleByName,
|
||||
createAgent: v1.createAgent,
|
||||
});
|
||||
const updateHandler = createAgentManagementUpdateHandler({
|
||||
getRoleByName: db.getRoleByName,
|
||||
getAgentWithVersionCount: db.getAgentWithVersionCount,
|
||||
checkPermission,
|
||||
hasCapability,
|
||||
updateAgent: v1.updateAgent,
|
||||
});
|
||||
|
||||
router.use(requireAgentManagementAuth);
|
||||
router.use(checkBan);
|
||||
|
|
@ -31,6 +39,7 @@ router.use(checkBan);
|
|||
router.post('/', configMiddleware, createHandler);
|
||||
router.get('/', readHandlers.list);
|
||||
router.get('/:id', readHandlers.get);
|
||||
router.patch('/:id', configMiddleware, updateHandler);
|
||||
|
||||
router.use((_req, res) => {
|
||||
const { status, body } = mapAgentManagementError('not_found');
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ export * from './lazyHistory';
|
|||
export * from './memory';
|
||||
export * from './management';
|
||||
export * from './reads';
|
||||
export * from './updates';
|
||||
export * from './mcpIdentity';
|
||||
export * from './orphans';
|
||||
export * from './migration';
|
||||
|
|
|
|||
|
|
@ -83,6 +83,22 @@ describe('Agent Management contract', () => {
|
|||
agentManagementCreateSchema.safeParse({ provider: 'openAI', model: null }).success,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: null },
|
||||
{ description: null },
|
||||
{ instructions: null },
|
||||
{ model: null },
|
||||
{ avatar: { filepath: 'avatars/replacement.png', source: 'local' } },
|
||||
])('rejects update values the browser update flow cannot apply: %p', (update) => {
|
||||
expect(agentManagementUpdateSchema.safeParse(update).success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts the explicitly supported update clears', () => {
|
||||
expect(
|
||||
agentManagementUpdateSchema.parse({ avatar: null, code_environment_id: null }),
|
||||
).toEqual({ avatar: null, code_environment_id: null });
|
||||
});
|
||||
});
|
||||
|
||||
describe('pagination', () => {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,16 @@ export type AgentManagementCreate = Omit<z.output<typeof agentCreateSchema>, 'mo
|
|||
type AgentManagementCreateInput = Omit<z.input<typeof agentCreateSchema>, 'model'> & {
|
||||
model: string;
|
||||
};
|
||||
export type AgentManagementUpdate = z.output<typeof agentUpdateSchema>;
|
||||
export type AgentManagementUpdate = Omit<
|
||||
z.output<typeof agentUpdateSchema>,
|
||||
'name' | 'description' | 'instructions' | 'model' | 'avatar'
|
||||
> & {
|
||||
name?: string;
|
||||
description?: string;
|
||||
instructions?: string;
|
||||
model?: string;
|
||||
avatar?: null;
|
||||
};
|
||||
export type AgentManagementList = {
|
||||
limit: number;
|
||||
cursor?: string;
|
||||
|
|
@ -82,8 +91,15 @@ export const agentManagementCreateSchema: z.ZodType<
|
|||
z.ZodTypeDef,
|
||||
AgentManagementCreateInput
|
||||
> = agentCreateSchema.extend({ model: z.string() }).strict();
|
||||
export const agentManagementUpdateSchema: z.ZodType<AgentManagementUpdate> =
|
||||
agentUpdateSchema.strict();
|
||||
export const agentManagementUpdateSchema: z.ZodType<AgentManagementUpdate> = agentUpdateSchema
|
||||
.extend({
|
||||
name: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
instructions: z.string().optional(),
|
||||
model: z.string().optional(),
|
||||
avatar: z.null().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const agentManagementCursorSchema = z
|
||||
.string()
|
||||
|
|
|
|||
232
packages/api/src/agents/updates.spec.ts
Normal file
232
packages/api/src/agents/updates.spec.ts
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
import { Types } from 'mongoose';
|
||||
import {
|
||||
PermissionBits,
|
||||
Permissions,
|
||||
PermissionTypes,
|
||||
ResourceType,
|
||||
} from 'librechat-data-provider';
|
||||
import type { IRole, IUser } from '@librechat/data-schemas';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AgentManagementUpdateDeps } from './updates';
|
||||
import { createAgentManagementUpdateHandler } from './updates';
|
||||
|
||||
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 objectId = new Types.ObjectId();
|
||||
const existingAgent = {
|
||||
_id: objectId,
|
||||
id: 'agent-existing',
|
||||
name: 'Existing Agent',
|
||||
description: 'Keep this description',
|
||||
provider: 'openAI',
|
||||
model: 'gpt-5',
|
||||
version: 1,
|
||||
createdAt: new Date('2026-09-03T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-09-03T10:00:00.000Z'),
|
||||
};
|
||||
const updatedAgent = {
|
||||
...existingAgent,
|
||||
name: 'Updated Agent',
|
||||
version: 2,
|
||||
versions: [{}, {}],
|
||||
updatedAt: new Date('2026-09-03T11:00:00.000Z'),
|
||||
};
|
||||
|
||||
function makeRequest(overrides: Partial<Request> = {}): Request {
|
||||
return {
|
||||
user,
|
||||
params: { id: existingAgent.id },
|
||||
body: { name: updatedAgent.name },
|
||||
...overrides,
|
||||
} as Request;
|
||||
}
|
||||
|
||||
function makeResponse(): Response {
|
||||
const response = {} as Response;
|
||||
response.status = jest.fn(() => response);
|
||||
response.json = jest.fn(() => response);
|
||||
return response;
|
||||
}
|
||||
|
||||
function makeDeps(overrides: Partial<AgentManagementUpdateDeps> = {}): AgentManagementUpdateDeps {
|
||||
return {
|
||||
getRoleByName: jest.fn().mockResolvedValue({
|
||||
permissions: {
|
||||
[PermissionTypes.AGENTS]: {
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.CREATE]: true,
|
||||
},
|
||||
},
|
||||
} as IRole),
|
||||
getAgentWithVersionCount: jest.fn().mockResolvedValue(existingAgent),
|
||||
checkPermission: jest.fn().mockResolvedValue(true),
|
||||
hasCapability: jest.fn().mockResolvedValue(false),
|
||||
updateAgent: jest.fn(async (_req: Request, res: Response) => res.json(updatedAgent)),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Agent Management update handler', () => {
|
||||
it('delegates a partial update and returns the management projection', async () => {
|
||||
const deps = makeDeps();
|
||||
const request = makeRequest();
|
||||
const response = makeResponse();
|
||||
|
||||
await createAgentManagementUpdateHandler(deps)(request, response);
|
||||
|
||||
expect(deps.getAgentWithVersionCount).toHaveBeenCalledWith({
|
||||
id: existingAgent.id,
|
||||
tenantId: user.tenantId,
|
||||
});
|
||||
expect(deps.checkPermission).toHaveBeenCalledWith({
|
||||
userId: user.id,
|
||||
role: user.role,
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: objectId,
|
||||
requiredPermission: PermissionBits.EDIT,
|
||||
});
|
||||
expect(deps.updateAgent).toHaveBeenCalledWith(request, expect.anything());
|
||||
expect(request.body).toEqual({ name: 'Updated Agent' });
|
||||
expect(response.status).toHaveBeenCalledWith(200);
|
||||
expect(response.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: existingAgent.id,
|
||||
name: 'Updated Agent',
|
||||
description: existingAgent.description,
|
||||
provider: existingAgent.provider,
|
||||
model: existingAgent.model,
|
||||
version: 2,
|
||||
createdAt: '2026-09-03T10:00:00.000Z',
|
||||
updatedAt: '2026-09-03T11:00:00.000Z',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects caller-supplied ownership and tenant fields before updating', async () => {
|
||||
const deps = makeDeps();
|
||||
const response = makeResponse();
|
||||
|
||||
await createAgentManagementUpdateHandler(deps)(
|
||||
makeRequest({ body: { name: 'Updated Agent', tenantId: 'tenant-b', author: user.id } }),
|
||||
response,
|
||||
);
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(400);
|
||||
expect(response.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ error: expect.objectContaining({ code: 'invalid_request' }) }),
|
||||
);
|
||||
expect(deps.updateAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires the same AGENTS USE and CREATE permissions as browser updates', async () => {
|
||||
const deps = makeDeps({
|
||||
getRoleByName: jest.fn().mockResolvedValue({
|
||||
permissions: {
|
||||
[PermissionTypes.AGENTS]: {
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.CREATE]: false,
|
||||
},
|
||||
},
|
||||
} as never),
|
||||
});
|
||||
const response = makeResponse();
|
||||
|
||||
await createAgentManagementUpdateHandler(deps)(makeRequest(), response);
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(403);
|
||||
expect(deps.getAgentWithVersionCount).not.toHaveBeenCalled();
|
||||
expect(deps.updateAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fails closed without a tenant-bound authenticated user', async () => {
|
||||
const deps = makeDeps();
|
||||
const response = makeResponse();
|
||||
|
||||
await createAgentManagementUpdateHandler(deps)(
|
||||
makeRequest({ user: { ...user, tenantId: undefined } as IUser }),
|
||||
response,
|
||||
);
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(403);
|
||||
expect(deps.getRoleByName).not.toHaveBeenCalled();
|
||||
expect(deps.updateAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not disclose an Agent outside the authenticated tenant', async () => {
|
||||
const deps = makeDeps({ getAgentWithVersionCount: jest.fn().mockResolvedValue(null) });
|
||||
const response = makeResponse();
|
||||
|
||||
await createAgentManagementUpdateHandler(deps)(makeRequest(), response);
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(404);
|
||||
expect(response.json).toHaveBeenCalledWith({
|
||||
error: { code: 'not_found', message: 'Agent not found' },
|
||||
});
|
||||
expect(deps.checkPermission).not.toHaveBeenCalled();
|
||||
expect(deps.updateAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires EDIT permission on the tenant-scoped Agent', async () => {
|
||||
const deps = makeDeps({ checkPermission: jest.fn().mockResolvedValue(false) });
|
||||
const response = makeResponse();
|
||||
|
||||
await createAgentManagementUpdateHandler(deps)(makeRequest(), response);
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(403);
|
||||
expect(deps.updateAgent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preserves the existing manage-agents capability bypass', async () => {
|
||||
const deps = makeDeps({
|
||||
hasCapability: jest.fn().mockResolvedValue(true),
|
||||
checkPermission: jest.fn().mockResolvedValue(false),
|
||||
});
|
||||
const response = makeResponse();
|
||||
|
||||
await createAgentManagementUpdateHandler(deps)(makeRequest(), response);
|
||||
|
||||
expect(deps.checkPermission).not.toHaveBeenCalled();
|
||||
expect(deps.updateAgent).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('maps shared update conflicts to the stable management error contract', async () => {
|
||||
const deps = makeDeps({
|
||||
updateAgent: jest.fn(async (_req: Request, res: Response) =>
|
||||
res.status(409).json({ error: 'version detail' }),
|
||||
),
|
||||
});
|
||||
const response = makeResponse();
|
||||
|
||||
await createAgentManagementUpdateHandler(deps)(makeRequest(), response);
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(400);
|
||||
expect(response.json).toHaveBeenCalledWith({
|
||||
error: { code: 'invalid_request', message: 'Invalid request' },
|
||||
});
|
||||
});
|
||||
|
||||
it('does not expose errors thrown by the shared update flow', async () => {
|
||||
const deps = makeDeps({
|
||||
updateAgent: jest.fn().mockRejectedValue(new Error('database connection secret')),
|
||||
});
|
||||
const response = makeResponse();
|
||||
|
||||
await createAgentManagementUpdateHandler(deps)(makeRequest(), response);
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(500);
|
||||
expect(response.json).toHaveBeenCalledWith({
|
||||
error: { code: 'internal_error', message: 'Internal server error' },
|
||||
});
|
||||
});
|
||||
});
|
||||
159
packages/api/src/agents/updates.ts
Normal file
159
packages/api/src/agents/updates.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { logger, ResourceCapabilityMap } from '@librechat/data-schemas';
|
||||
import {
|
||||
PermissionBits,
|
||||
Permissions,
|
||||
PermissionTypes,
|
||||
ResourceType,
|
||||
} from 'librechat-data-provider';
|
||||
import type { IRole, IUser, SystemCapability } from '@librechat/data-schemas';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { Types } from 'mongoose';
|
||||
import type { AgentManagementProjectionSource } from './management';
|
||||
import {
|
||||
agentManagementUpdateSchema,
|
||||
mapAgentManagementError,
|
||||
projectAgentManagementResponse,
|
||||
} from './management';
|
||||
import { checkAccessWithRequestCache } from '../middleware/access';
|
||||
|
||||
type AgentUpdateHandler = (
|
||||
req: Request,
|
||||
res: Response,
|
||||
) => Promise<Response | void> | Response | void;
|
||||
|
||||
type AgentManagementRecord = AgentManagementProjectionSource & { _id: Types.ObjectId };
|
||||
|
||||
export interface AgentManagementUpdateDeps {
|
||||
getRoleByName: (roleName: string, fieldsToSelect?: string | string[]) => Promise<IRole | null>;
|
||||
getAgentWithVersionCount: (search: {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
}) => Promise<AgentManagementRecord | null>;
|
||||
checkPermission: (params: {
|
||||
userId: string;
|
||||
role?: string;
|
||||
resourceType: ResourceType;
|
||||
resourceId: Types.ObjectId;
|
||||
requiredPermission: PermissionBits;
|
||||
}) => Promise<boolean>;
|
||||
hasCapability: (user: IUser, capability: SystemCapability) => Promise<boolean>;
|
||||
updateAgent: AgentUpdateHandler;
|
||||
}
|
||||
|
||||
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 mapUpdateStatus(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 >= 200 && statusCode < 300 && body != null) {
|
||||
result = res.status(statusCode).json(projectAgentManagementResponse(body));
|
||||
return result;
|
||||
}
|
||||
result = sendError(res, mapUpdateStatus(statusCode));
|
||||
return result;
|
||||
}) as Response['json'];
|
||||
|
||||
return { response: adapter, getResult: () => result };
|
||||
}
|
||||
|
||||
async function hasManageAgentsCapability(user: IUser, deps: AgentManagementUpdateDeps) {
|
||||
const capability = ResourceCapabilityMap[ResourceType.AGENT];
|
||||
try {
|
||||
return capability != null && (await deps.hasCapability(user, capability));
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[AgentManagement] Agent capability check failed, denying bypass: ${(error as Error).message}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate and authorize Agent Management updates before reusing the browser update flow. */
|
||||
export function createAgentManagementUpdateHandler(
|
||||
deps: AgentManagementUpdateDeps,
|
||||
): (req: Request, res: Response) => Promise<Response> {
|
||||
return async function update(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 canUpdate = await checkAccessWithRequestCache({
|
||||
req,
|
||||
user,
|
||||
permissionType: PermissionTypes.AGENTS,
|
||||
permissions: [Permissions.USE, Permissions.CREATE],
|
||||
getRoleByName: deps.getRoleByName,
|
||||
});
|
||||
if (!canUpdate) {
|
||||
return sendError(res, 'permission_denied');
|
||||
}
|
||||
|
||||
const agent = await deps.getAgentWithVersionCount({
|
||||
id: req.params.id,
|
||||
tenantId: user.tenantId,
|
||||
});
|
||||
if (!agent) {
|
||||
return sendError(res, 'not_found');
|
||||
}
|
||||
|
||||
const canManageAll = await hasManageAgentsCapability(user, deps);
|
||||
if (
|
||||
!canManageAll &&
|
||||
!(await deps.checkPermission({
|
||||
userId: user.id,
|
||||
role: user.role,
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: agent._id,
|
||||
requiredPermission: PermissionBits.EDIT,
|
||||
}))
|
||||
) {
|
||||
return sendError(res, 'permission_denied');
|
||||
}
|
||||
|
||||
const parsedBody = agentManagementUpdateSchema.safeParse(req.body);
|
||||
if (!parsedBody.success) {
|
||||
return sendError(res, 'invalid_request', parsedBody.error);
|
||||
}
|
||||
|
||||
req.body = parsedBody.data;
|
||||
const adapter = createResponseAdapter(res);
|
||||
await deps.updateAgent(req, adapter.response);
|
||||
return adapter.getResult() ?? sendError(res, 'internal_error');
|
||||
} catch (error) {
|
||||
logger.error('[AgentManagement] Error updating Agent', error);
|
||||
return sendError(res, 'internal_error');
|
||||
}
|
||||
};
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue