From ab0d74b492b07d42a90fd8e7f20f767cb2b3e84d Mon Sep 17 00:00:00 2001 From: Dustin Healy <54083382+dustinhealy@users.noreply.github.com> Date: Thu, 3 Sep 2026 21:16:57 -0700 Subject: [PATCH] =?UTF-8?q?=E2=9C=8F=EF=B8=8F=20feat:=20Add=20Agent=20Mana?= =?UTF-8?q?gement=20Update=20Endpoint=20(#15566)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add Agent Management update endpoint * fix: reject unsupported Agent update values * test: simplify Agent update mocks --- api/server/controllers/agents/v1.spec.js | 91 ++++++- .../agents/__tests__/management.spec.js | 30 ++- api/server/routes/agents/management.js | 9 + packages/api/src/agents/index.ts | 1 + packages/api/src/agents/management.spec.ts | 16 ++ packages/api/src/agents/management.ts | 22 +- packages/api/src/agents/updates.spec.ts | 232 ++++++++++++++++++ packages/api/src/agents/updates.ts | 159 ++++++++++++ 8 files changed, 545 insertions(+), 15 deletions(-) create mode 100644 packages/api/src/agents/updates.spec.ts create mode 100644 packages/api/src/agents/updates.ts diff --git a/api/server/controllers/agents/v1.spec.js b/api/server/controllers/agents/v1.spec.js index ada54a535e..f111233735 100644 --- a/api/server/controllers/agents/v1.spec.js +++ b/api/server/controllers/agents/v1.spec.js @@ -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, diff --git a/api/server/routes/agents/__tests__/management.spec.js b/api/server/routes/agents/__tests__/management.spec.js index 0f8545aa8f..3015bef0bd 100644 --- a/api/server/routes/agents/__tests__/management.spec.js +++ b/api/server/routes/agents/__tests__/management.spec.js @@ -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); + }); }); diff --git a/api/server/routes/agents/management.js b/api/server/routes/agents/management.js index fecca3cffc..5aac27959e 100644 --- a/api/server/routes/agents/management.js +++ b/api/server/routes/agents/management.js @@ -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'); diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index b907c40886..0e8d701956 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -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'; diff --git a/packages/api/src/agents/management.spec.ts b/packages/api/src/agents/management.spec.ts index 4e03386dca..9c89adb1d6 100644 --- a/packages/api/src/agents/management.spec.ts +++ b/packages/api/src/agents/management.spec.ts @@ -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', () => { diff --git a/packages/api/src/agents/management.ts b/packages/api/src/agents/management.ts index 00d8e460cd..440e9af445 100644 --- a/packages/api/src/agents/management.ts +++ b/packages/api/src/agents/management.ts @@ -16,7 +16,16 @@ export type AgentManagementCreate = Omit, 'mo type AgentManagementCreateInput = Omit, 'model'> & { model: string; }; -export type AgentManagementUpdate = z.output; +export type AgentManagementUpdate = Omit< + z.output, + '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 = - agentUpdateSchema.strict(); +export const agentManagementUpdateSchema: z.ZodType = 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() diff --git a/packages/api/src/agents/updates.spec.ts b/packages/api/src/agents/updates.spec.ts new file mode 100644 index 0000000000..f7815c008c --- /dev/null +++ b/packages/api/src/agents/updates.spec.ts @@ -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 { + 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 { + 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' }, + }); + }); +}); diff --git a/packages/api/src/agents/updates.ts b/packages/api/src/agents/updates.ts new file mode 100644 index 0000000000..71f976832f --- /dev/null +++ b/packages/api/src/agents/updates.ts @@ -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; + +type AgentManagementRecord = AgentManagementProjectionSource & { _id: Types.ObjectId }; + +export interface AgentManagementUpdateDeps { + getRoleByName: (roleName: string, fieldsToSelect?: string | string[]) => Promise; + getAgentWithVersionCount: (search: { + id: string; + tenantId: string; + }) => Promise; + checkPermission: (params: { + userId: string; + role?: string; + resourceType: ResourceType; + resourceId: Types.ObjectId; + requiredPermission: PermissionBits; + }) => Promise; + hasCapability: (user: IUser, capability: SystemCapability) => Promise; + updateAgent: AgentUpdateHandler; +} + +function sendError( + res: Response, + code: Parameters[0], + error?: unknown, +) { + const mapped = mapAgentManagementError(code, error); + return res.status(mapped.status).json(mapped.body); +} + +function mapUpdateStatus(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 >= 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 { + return async function update(req: Request, res: Response): Promise { + 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'); + } + }; +}