mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-22 18:13:27 +00:00
👀 feat: Add Agent Management Read Endpoints (#15561)
* feat: add Agent Management read endpoints * fix: protect Agent management configuration reads * fix: type Agent management list filters * test: use typed Agent management assertions
This commit is contained in:
parent
a6cf61abcf
commit
2fe872d2ea
10 changed files with 693 additions and 4 deletions
|
|
@ -5,6 +5,12 @@ const mockMapAgentManagementError = jest.fn(() => ({
|
|||
status: 404,
|
||||
body: { error: { code: 'not_found', message: 'Agent not found' } },
|
||||
}));
|
||||
const mockList = jest.fn((_req, res) => res.status(200).json({ object: 'list', data: [] }));
|
||||
const mockGet = jest.fn((_req, res) => res.status(200).json({ id: 'agent-one' }));
|
||||
const mockCreateAgentManagementReadHandlers = jest.fn(() => ({
|
||||
list: mockList,
|
||||
get: mockGet,
|
||||
}));
|
||||
const mockCheckBan = jest.fn((_req, _res, next) => next());
|
||||
const mockUaParser = jest.fn((_req, _res, next) => next());
|
||||
|
||||
|
|
@ -21,11 +27,22 @@ jest.mock('../middleware', () => ({
|
|||
}));
|
||||
jest.mock('@librechat/api', () => ({
|
||||
mapAgentManagementError: mockMapAgentManagementError,
|
||||
createAgentManagementReadHandlers: mockCreateAgentManagementReadHandlers,
|
||||
}));
|
||||
jest.mock('~/server/middleware', () => ({
|
||||
checkBan: mockCheckBan,
|
||||
uaParser: mockUaParser,
|
||||
}));
|
||||
jest.mock('~/server/middleware/roles/capabilities', () => ({ hasCapability: jest.fn() }));
|
||||
jest.mock('~/server/services/PermissionService', () => ({
|
||||
checkPermission: jest.fn(),
|
||||
findAccessibleResources: jest.fn(),
|
||||
}));
|
||||
jest.mock('~/models', () => ({
|
||||
getRoleByName: jest.fn(),
|
||||
getAgentWithVersionCount: jest.fn(),
|
||||
getAgentManagementListByAccess: jest.fn(),
|
||||
}));
|
||||
|
||||
const router = require('../management');
|
||||
|
||||
|
|
@ -51,7 +68,7 @@ describe('Agent Management route boundary', () => {
|
|||
|
||||
it('allows an authenticated non-browser client into the management router', async () => {
|
||||
const response = await request(app)
|
||||
.get('/api/agents/v1/agents/not-a-management-operation')
|
||||
.post('/api/agents/v1/agents/not-a-management-operation')
|
||||
.set('Authorization', 'Bearer valid-token')
|
||||
.set('User-Agent', 'curl/8.0.0');
|
||||
|
||||
|
|
@ -62,4 +79,18 @@ describe('Agent Management route boundary', () => {
|
|||
expect(mockMapAgentManagementError).toHaveBeenCalledWith('not_found');
|
||||
expect(mockRequireAgentManagementAuth).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('dispatches authenticated list and retrieve requests to the management read handlers', async () => {
|
||||
const listResponse = await request(app)
|
||||
.get('/api/agents/v1/agents')
|
||||
.set('Authorization', 'Bearer valid-token');
|
||||
const getResponse = await request(app)
|
||||
.get('/api/agents/v1/agents/agent-one')
|
||||
.set('Authorization', 'Bearer valid-token');
|
||||
|
||||
expect(listResponse.status).toBe(200);
|
||||
expect(getResponse.status).toBe(200);
|
||||
expect(mockList).toHaveBeenCalledTimes(1);
|
||||
expect(mockGet).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,13 +1,27 @@
|
|||
const express = require('express');
|
||||
const { mapAgentManagementError } = require('@librechat/api');
|
||||
const { createAgentManagementReadHandlers, mapAgentManagementError } = require('@librechat/api');
|
||||
const { checkBan } = require('~/server/middleware');
|
||||
const { hasCapability } = require('~/server/middleware/roles/capabilities');
|
||||
const { checkPermission, findAccessibleResources } = require('~/server/services/PermissionService');
|
||||
const db = require('~/models');
|
||||
const { requireAgentManagementAuth } = require('./middleware');
|
||||
|
||||
const router = express.Router();
|
||||
const handlers = createAgentManagementReadHandlers({
|
||||
getRoleByName: db.getRoleByName,
|
||||
getAgentWithVersionCount: db.getAgentWithVersionCount,
|
||||
getAgentManagementListByAccess: db.getAgentManagementListByAccess,
|
||||
findAccessibleResources,
|
||||
checkPermission,
|
||||
hasCapability,
|
||||
});
|
||||
|
||||
router.use(requireAgentManagementAuth);
|
||||
router.use(checkBan);
|
||||
|
||||
router.get('/', handlers.list);
|
||||
router.get('/:id', handlers.get);
|
||||
|
||||
router.use((_req, res) => {
|
||||
const { status, body } = mapAgentManagementError('not_found');
|
||||
return res.status(status).json(body);
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ export * from './lazySubagents';
|
|||
export * from './lazyHistory';
|
||||
export * from './memory';
|
||||
export * from './management';
|
||||
export * from './reads';
|
||||
export * from './mcpIdentity';
|
||||
export * from './orphans';
|
||||
export * from './migration';
|
||||
|
|
|
|||
|
|
@ -167,6 +167,16 @@ describe('Agent Management contract', () => {
|
|||
expect(response).not.toHaveProperty('is_promoted');
|
||||
});
|
||||
|
||||
it('omits legacy string avatars that are not part of the management contract', () => {
|
||||
const response = projectAgentManagementResponse({
|
||||
...persistedAgent,
|
||||
avatar: 'https://example.com/legacy-avatar.png',
|
||||
});
|
||||
|
||||
expect(JSON.parse(JSON.stringify(response))).not.toHaveProperty('avatar');
|
||||
expect(agentManagementResponseSchema.parse(response)).toEqual(response);
|
||||
});
|
||||
|
||||
it('does not apply the current request admission limit to persisted subagents', () => {
|
||||
const subagents = {
|
||||
enabled: true,
|
||||
|
|
|
|||
|
|
@ -28,11 +28,15 @@ export type AgentManagementResponse = Omit<
|
|||
updatedAt: string;
|
||||
};
|
||||
export type AgentManagementProjectionSource = Partial<
|
||||
Omit<AgentManagementResponse, 'id' | 'provider' | 'model' | 'version' | 'createdAt' | 'updatedAt'>
|
||||
Omit<
|
||||
AgentManagementResponse,
|
||||
'id' | 'provider' | 'model' | 'version' | 'createdAt' | 'updatedAt' | 'avatar'
|
||||
>
|
||||
> & {
|
||||
id?: string;
|
||||
provider?: string;
|
||||
model?: string | null;
|
||||
avatar?: AgentManagementResponse['avatar'] | string;
|
||||
version?: number;
|
||||
versions?: readonly object[];
|
||||
createdAt?: string | Date;
|
||||
|
|
@ -225,7 +229,7 @@ export function projectAgentManagementResponse(
|
|||
name: source.name,
|
||||
description: source.description,
|
||||
instructions: source.instructions,
|
||||
avatar: source.avatar,
|
||||
avatar: typeof source.avatar === 'string' ? undefined : source.avatar,
|
||||
model_parameters: source.model_parameters,
|
||||
tools: source.tools,
|
||||
skills: source.skills,
|
||||
|
|
|
|||
208
packages/api/src/agents/reads.spec.ts
Normal file
208
packages/api/src/agents/reads.spec.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import { Types } from 'mongoose';
|
||||
import { SystemCapabilities } from '@librechat/data-schemas';
|
||||
import {
|
||||
Permissions,
|
||||
PermissionBits,
|
||||
PermissionTypes,
|
||||
ResourceType,
|
||||
} from 'librechat-data-provider';
|
||||
import type { IRole, IUser } from '@librechat/data-schemas';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { AgentManagementReadDeps } from './reads';
|
||||
import { createAgentManagementReadHandlers } from './reads';
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => {
|
||||
const actual = jest.requireActual('@librechat/data-schemas');
|
||||
return {
|
||||
...actual,
|
||||
logger: { warn: jest.fn(), error: jest.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
const tenantId = 'tenant-a';
|
||||
const user = {
|
||||
id: new Types.ObjectId().toString(),
|
||||
tenantId,
|
||||
role: 'USER',
|
||||
idOnTheSource: 'external-user',
|
||||
} as IUser;
|
||||
const objectId = new Types.ObjectId();
|
||||
const agent = {
|
||||
_id: objectId,
|
||||
id: 'agent-one',
|
||||
provider: 'openAI',
|
||||
model: 'gpt-5',
|
||||
name: 'Agent One',
|
||||
version: 2,
|
||||
createdAt: new Date('2026-09-01T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-09-02T10:00:00.000Z'),
|
||||
};
|
||||
|
||||
function makeRequest(overrides: Partial<Request> = {}): Request {
|
||||
return { user, query: {}, params: {}, ...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<AgentManagementReadDeps> = {}): AgentManagementReadDeps {
|
||||
return {
|
||||
getRoleByName: jest.fn().mockResolvedValue({
|
||||
permissions: { [PermissionTypes.AGENTS]: { [Permissions.USE]: true } },
|
||||
} as unknown as IRole),
|
||||
getAgentWithVersionCount: jest.fn().mockResolvedValue(agent),
|
||||
getAgentManagementListByAccess: jest.fn().mockResolvedValue({
|
||||
data: [agent],
|
||||
has_more: false,
|
||||
after: null,
|
||||
}),
|
||||
findAccessibleResources: jest.fn().mockResolvedValue([objectId]),
|
||||
checkPermission: jest.fn().mockResolvedValue(true),
|
||||
hasCapability: jest.fn().mockResolvedValue(false),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Agent Management read handlers', () => {
|
||||
it('lists only ACL-discovered records in the authenticated tenant', async () => {
|
||||
const deps = makeDeps();
|
||||
const response = makeResponse();
|
||||
|
||||
await createAgentManagementReadHandlers(deps).list(
|
||||
makeRequest({ query: { limit: '10' } }),
|
||||
response,
|
||||
);
|
||||
|
||||
expect(deps.findAccessibleResources).toHaveBeenCalledWith({
|
||||
userId: user.id,
|
||||
role: user.role,
|
||||
idOnTheSource: user.idOnTheSource,
|
||||
resourceType: ResourceType.AGENT,
|
||||
requiredPermissions: PermissionBits.EDIT,
|
||||
});
|
||||
expect(deps.getAgentManagementListByAccess).toHaveBeenCalledWith({
|
||||
accessibleIds: [objectId],
|
||||
tenantId,
|
||||
limit: 10,
|
||||
after: undefined,
|
||||
});
|
||||
expect(response.status).toHaveBeenCalledWith(200);
|
||||
expect(response.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
object: 'list',
|
||||
data: [expect.objectContaining({ id: 'agent-one' })],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects malformed cursors before ACL or persistence work', async () => {
|
||||
const deps = makeDeps();
|
||||
const response = makeResponse();
|
||||
|
||||
await createAgentManagementReadHandlers(deps).list(
|
||||
makeRequest({ query: { cursor: 'not-a-cursor' } }),
|
||||
response,
|
||||
);
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(400);
|
||||
expect(deps.findAccessibleResources).not.toHaveBeenCalled();
|
||||
expect(deps.getAgentManagementListByAccess).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('retrieves by public ID and authenticated tenant before applying EDIT access', async () => {
|
||||
const deps = makeDeps();
|
||||
const response = makeResponse();
|
||||
|
||||
await createAgentManagementReadHandlers(deps).get(
|
||||
makeRequest({ params: { id: 'agent-one' } }),
|
||||
response,
|
||||
);
|
||||
|
||||
expect(deps.getAgentWithVersionCount).toHaveBeenCalledWith({ id: 'agent-one', tenantId });
|
||||
expect(deps.checkPermission).toHaveBeenCalledWith({
|
||||
userId: user.id,
|
||||
role: user.role,
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: objectId,
|
||||
requiredPermission: PermissionBits.EDIT,
|
||||
});
|
||||
expect(response.status).toHaveBeenCalledWith(200);
|
||||
expect(response.json).toHaveBeenCalledWith(expect.objectContaining({ id: 'agent-one' }));
|
||||
});
|
||||
|
||||
it('returns not found when the tenant-scoped lookup cannot resolve the ID', async () => {
|
||||
const deps = makeDeps({ getAgentWithVersionCount: jest.fn().mockResolvedValue(null) });
|
||||
const response = makeResponse();
|
||||
|
||||
await createAgentManagementReadHandlers(deps).get(
|
||||
makeRequest({ params: { id: 'agent-in-another-tenant' } }),
|
||||
response,
|
||||
);
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(404);
|
||||
expect(deps.checkPermission).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('denies a known Agent when neither EDIT ACL nor the management capability is held', async () => {
|
||||
const deps = makeDeps({ checkPermission: jest.fn().mockResolvedValue(false) });
|
||||
const response = makeResponse();
|
||||
|
||||
await createAgentManagementReadHandlers(deps).get(
|
||||
makeRequest({ params: { id: 'agent-one' } }),
|
||||
response,
|
||||
);
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(403);
|
||||
});
|
||||
|
||||
it('uses the existing manage-agents capability as the resource ACL bypass', async () => {
|
||||
const deps = makeDeps({ hasCapability: jest.fn().mockResolvedValue(true) });
|
||||
const response = makeResponse();
|
||||
|
||||
await createAgentManagementReadHandlers(deps).get(
|
||||
makeRequest({ params: { id: 'agent-one' } }),
|
||||
response,
|
||||
);
|
||||
|
||||
expect(deps.hasCapability).toHaveBeenCalledWith(user, SystemCapabilities.MANAGE_AGENTS);
|
||||
expect(deps.checkPermission).not.toHaveBeenCalled();
|
||||
expect(response.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
|
||||
it('lists every Agent in the tenant for the existing manage-agents capability', async () => {
|
||||
const deps = makeDeps({ hasCapability: jest.fn().mockResolvedValue(true) });
|
||||
const response = makeResponse();
|
||||
|
||||
await createAgentManagementReadHandlers(deps).list(makeRequest(), response);
|
||||
|
||||
expect(deps.findAccessibleResources).not.toHaveBeenCalled();
|
||||
expect(deps.getAgentManagementListByAccess).toHaveBeenCalledWith({
|
||||
accessibleIds: null,
|
||||
tenantId,
|
||||
limit: 20,
|
||||
after: undefined,
|
||||
});
|
||||
expect(response.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
|
||||
it('requires the same AGENTS:USE role permission as browser reads', async () => {
|
||||
const deps = makeDeps({
|
||||
getRoleByName: jest.fn().mockResolvedValue({
|
||||
permissions: { [PermissionTypes.AGENTS]: { [Permissions.USE]: false } },
|
||||
} as never),
|
||||
});
|
||||
const response = makeResponse();
|
||||
|
||||
await createAgentManagementReadHandlers(deps).list(makeRequest(), response);
|
||||
|
||||
expect(response.status).toHaveBeenCalledWith(403);
|
||||
expect(deps.findAccessibleResources).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
188
packages/api/src/agents/reads.ts
Normal file
188
packages/api/src/agents/reads.ts
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
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 {
|
||||
agentManagementListSchema,
|
||||
mapAgentManagementError,
|
||||
projectAgentManagementListResponse,
|
||||
projectAgentManagementResponse,
|
||||
} from './management';
|
||||
import { checkAccessWithRequestCache } from '../middleware/access';
|
||||
|
||||
type AgentManagementRecord = AgentManagementProjectionSource & { _id: Types.ObjectId };
|
||||
|
||||
export interface AgentManagementReadDeps {
|
||||
getRoleByName: (roleName: string, fieldsToSelect?: string | string[]) => Promise<IRole | null>;
|
||||
getAgentWithVersionCount: (search: {
|
||||
id: string;
|
||||
tenantId: string;
|
||||
}) => Promise<AgentManagementRecord | null>;
|
||||
getAgentManagementListByAccess: (params: {
|
||||
accessibleIds: Types.ObjectId[] | null;
|
||||
tenantId: string;
|
||||
limit: number;
|
||||
after?: string | null;
|
||||
}) => Promise<{
|
||||
data: AgentManagementRecord[];
|
||||
has_more: boolean;
|
||||
after: string | null;
|
||||
}>;
|
||||
findAccessibleResources: (params: {
|
||||
userId: string;
|
||||
role?: string;
|
||||
idOnTheSource?: string;
|
||||
resourceType: ResourceType;
|
||||
requiredPermissions: PermissionBits;
|
||||
}) => Promise<Types.ObjectId[]>;
|
||||
checkPermission: (params: {
|
||||
userId: string;
|
||||
role?: string;
|
||||
resourceType: ResourceType;
|
||||
resourceId: Types.ObjectId;
|
||||
requiredPermission: PermissionBits;
|
||||
}) => Promise<boolean>;
|
||||
hasCapability: (user: IUser, capability: SystemCapability) => Promise<boolean>;
|
||||
}
|
||||
|
||||
function sendError(
|
||||
res: Response,
|
||||
code: Parameters<typeof mapAgentManagementError>[0],
|
||||
error?: unknown,
|
||||
) {
|
||||
const mapped = mapAgentManagementError(code, error);
|
||||
return res.status(mapped.status).json(mapped.body);
|
||||
}
|
||||
|
||||
async function canUseAgents(req: Request, user: IUser, deps: AgentManagementReadDeps) {
|
||||
return await checkAccessWithRequestCache({
|
||||
req,
|
||||
user,
|
||||
permissionType: PermissionTypes.AGENTS,
|
||||
permissions: [Permissions.USE],
|
||||
getRoleByName: deps.getRoleByName,
|
||||
});
|
||||
}
|
||||
|
||||
async function canViewAgent(
|
||||
user: IUser,
|
||||
agent: AgentManagementRecord,
|
||||
deps: AgentManagementReadDeps,
|
||||
canManageAll: boolean,
|
||||
) {
|
||||
if (canManageAll) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return await deps.checkPermission({
|
||||
userId: user.id,
|
||||
role: user.role,
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: agent._id,
|
||||
requiredPermission: PermissionBits.EDIT,
|
||||
});
|
||||
}
|
||||
|
||||
async function hasManageAgentsCapability(user: IUser, deps: AgentManagementReadDeps) {
|
||||
const capability = ResourceCapabilityMap[ResourceType.AGENT];
|
||||
try {
|
||||
if (capability != null && (await deps.hasCapability(user, capability))) {
|
||||
return true;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[AgentManagement] Agent capability check failed, denying bypass: ${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Typed management read handlers; the Express route supplies concrete database and ACL dependencies. */
|
||||
export function createAgentManagementReadHandlers(deps: AgentManagementReadDeps): {
|
||||
list: (req: Request, res: Response) => Promise<Response>;
|
||||
get: (req: Request, res: Response) => Promise<Response>;
|
||||
} {
|
||||
async function list(req: Request, res: Response): Promise<Response> {
|
||||
const parsedQuery = agentManagementListSchema.safeParse(req.query);
|
||||
if (!parsedQuery.success) {
|
||||
return sendError(res, 'invalid_request', parsedQuery.error);
|
||||
}
|
||||
|
||||
try {
|
||||
const user = req.user as IUser | undefined;
|
||||
if (!user?.id || !user.tenantId) {
|
||||
return sendError(res, 'permission_denied');
|
||||
}
|
||||
|
||||
const [canUse, canManageAll] = await Promise.all([
|
||||
canUseAgents(req, user, deps),
|
||||
hasManageAgentsCapability(user, deps),
|
||||
]);
|
||||
if (!canUse) {
|
||||
return sendError(res, 'permission_denied');
|
||||
}
|
||||
|
||||
const accessibleIds = canManageAll
|
||||
? null
|
||||
: await deps.findAccessibleResources({
|
||||
userId: user.id,
|
||||
role: user.role,
|
||||
idOnTheSource: user.idOnTheSource,
|
||||
resourceType: ResourceType.AGENT,
|
||||
requiredPermissions: PermissionBits.EDIT,
|
||||
});
|
||||
const result = await deps.getAgentManagementListByAccess({
|
||||
accessibleIds,
|
||||
tenantId: user.tenantId,
|
||||
limit: parsedQuery.data.limit,
|
||||
after: parsedQuery.data.cursor,
|
||||
});
|
||||
|
||||
return res.status(200).json(projectAgentManagementListResponse(result));
|
||||
} catch (error) {
|
||||
logger.error('[AgentManagement] Error listing Agents', error);
|
||||
return sendError(res, 'internal_error');
|
||||
}
|
||||
}
|
||||
|
||||
async function get(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 [canUse, canManageAll, agent] = await Promise.all([
|
||||
canUseAgents(req, user, deps),
|
||||
hasManageAgentsCapability(user, deps),
|
||||
deps.getAgentWithVersionCount({
|
||||
id: req.params.id,
|
||||
tenantId: user.tenantId,
|
||||
}),
|
||||
]);
|
||||
if (!canUse) {
|
||||
return sendError(res, 'permission_denied');
|
||||
}
|
||||
if (!agent) {
|
||||
return sendError(res, 'not_found');
|
||||
}
|
||||
if (!(await canViewAgent(user, agent, deps, canManageAll))) {
|
||||
return sendError(res, 'permission_denied');
|
||||
}
|
||||
|
||||
return res.status(200).json(projectAgentManagementResponse(agent));
|
||||
} catch (error) {
|
||||
logger.error('[AgentManagement] Error retrieving Agent', error);
|
||||
return sendError(res, 'internal_error');
|
||||
}
|
||||
}
|
||||
|
||||
return { list, get };
|
||||
}
|
||||
|
|
@ -61,6 +61,7 @@ let addAgentResourceFile: AgentMethods['addAgentResourceFile'];
|
|||
let removeAgentResourceFiles: AgentMethods['removeAgentResourceFiles'];
|
||||
let removeAgentResourceFilesFromAllAgents: AgentMethods['removeAgentResourceFilesFromAllAgents'];
|
||||
let getListAgentsByAccess: AgentMethods['getListAgentsByAccess'];
|
||||
let getAgentManagementListByAccess: AgentMethods['getAgentManagementListByAccess'];
|
||||
let generateActionMetadataHash: AgentMethods['generateActionMetadataHash'];
|
||||
|
||||
const getActions = jest.fn().mockResolvedValue([]);
|
||||
|
|
@ -108,6 +109,7 @@ beforeAll(async () => {
|
|||
removeAgentResourceFiles = methods.removeAgentResourceFiles;
|
||||
removeAgentResourceFilesFromAllAgents = methods.removeAgentResourceFilesFromAllAgents;
|
||||
getListAgentsByAccess = methods.getListAgentsByAccess;
|
||||
getAgentManagementListByAccess = methods.getAgentManagementListByAccess;
|
||||
generateActionMetadataHash = methods.generateActionMetadataHash;
|
||||
|
||||
await mongoose.connect(mongoUri);
|
||||
|
|
@ -4733,6 +4735,153 @@ describe('Support Contact Field', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('getAgentManagementListByAccess', () => {
|
||||
beforeEach(async () => {
|
||||
await Agent.deleteMany({});
|
||||
});
|
||||
|
||||
it('returns full configuration with version counts without changing updatedAt', async () => {
|
||||
const tenantId = `tenant-${uuidv4()}`;
|
||||
const created = await tenantStorage.run({ tenantId }, () =>
|
||||
createAgent({
|
||||
id: `agent_${uuidv4()}`,
|
||||
name: 'Managed Agent',
|
||||
instructions: 'Keep this configuration',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: new mongoose.Types.ObjectId(),
|
||||
tools: ['web_search'],
|
||||
}),
|
||||
);
|
||||
await tenantStorage.run({ tenantId }, () =>
|
||||
updateAgent({ id: created.id }, { instructions: 'Updated configuration' }),
|
||||
);
|
||||
const before = await Agent.findById(created._id)
|
||||
.select({ updatedAt: 1 })
|
||||
.lean<{ updatedAt?: Date }>();
|
||||
const beforeUpdatedAt = before?.updatedAt;
|
||||
|
||||
const result = await tenantStorage.run({ tenantId }, () =>
|
||||
getAgentManagementListByAccess({
|
||||
accessibleIds: [created._id],
|
||||
tenantId,
|
||||
limit: 20,
|
||||
}),
|
||||
);
|
||||
const after = await Agent.findById(created._id)
|
||||
.select({ updatedAt: 1 })
|
||||
.lean<{ updatedAt?: Date }>();
|
||||
|
||||
expect(result.data).toHaveLength(1);
|
||||
expect(result.data[0]).toMatchObject({
|
||||
id: created.id,
|
||||
instructions: 'Updated configuration',
|
||||
tools: ['web_search'],
|
||||
version: 2,
|
||||
});
|
||||
expect(result.data[0].versions).toBeUndefined();
|
||||
expect(after?.updatedAt).toEqual(beforeUpdatedAt);
|
||||
});
|
||||
|
||||
it('enforces tenant scope even when accessible IDs contain another tenant record', async () => {
|
||||
const tenantA = `tenant-a-${uuidv4()}`;
|
||||
const tenantB = `tenant-b-${uuidv4()}`;
|
||||
const publicId = `agent_${uuidv4()}`;
|
||||
const author = new mongoose.Types.ObjectId();
|
||||
const agentA = await tenantStorage.run({ tenantId: tenantA }, () =>
|
||||
createAgent({ id: publicId, name: 'A', provider: 'openai', model: 'gpt-4', author }),
|
||||
);
|
||||
const agentB = await tenantStorage.run({ tenantId: tenantB }, () =>
|
||||
createAgent({ id: publicId, name: 'B', provider: 'openai', model: 'gpt-4', author }),
|
||||
);
|
||||
|
||||
const result = await tenantStorage.run({ tenantId: tenantA }, () =>
|
||||
getAgentManagementListByAccess({
|
||||
accessibleIds: [agentA._id, agentB._id],
|
||||
tenantId: tenantA,
|
||||
limit: 20,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.data.map(({ name }) => name)).toEqual(['A']);
|
||||
});
|
||||
|
||||
it('keeps an unrestricted capability listing inside the authenticated tenant', async () => {
|
||||
const tenantA = `tenant-a-${uuidv4()}`;
|
||||
const tenantB = `tenant-b-${uuidv4()}`;
|
||||
const author = new mongoose.Types.ObjectId();
|
||||
await tenantStorage.run({ tenantId: tenantA }, () =>
|
||||
createAgent({
|
||||
id: `agent_${uuidv4()}`,
|
||||
name: 'A',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author,
|
||||
}),
|
||||
);
|
||||
await tenantStorage.run({ tenantId: tenantB }, () =>
|
||||
createAgent({
|
||||
id: `agent_${uuidv4()}`,
|
||||
name: 'B',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author,
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await tenantStorage.run({ tenantId: tenantA }, () =>
|
||||
getAgentManagementListByAccess({
|
||||
accessibleIds: null,
|
||||
tenantId: tenantA,
|
||||
limit: 20,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.data.map(({ name }) => name)).toEqual(['A']);
|
||||
});
|
||||
|
||||
it('paginates deterministically without overlap', async () => {
|
||||
const tenantId = `tenant-${uuidv4()}`;
|
||||
const author = new mongoose.Types.ObjectId();
|
||||
const agents = await tenantStorage.run({ tenantId }, () =>
|
||||
Promise.all(
|
||||
['A', 'B', 'C'].map((name) =>
|
||||
createAgent({
|
||||
id: `agent_${uuidv4()}`,
|
||||
name,
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author,
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
const first = await tenantStorage.run({ tenantId }, () =>
|
||||
getAgentManagementListByAccess({
|
||||
accessibleIds: agents.map(({ _id }) => _id),
|
||||
tenantId,
|
||||
limit: 2,
|
||||
}),
|
||||
);
|
||||
const second = await tenantStorage.run({ tenantId }, () =>
|
||||
getAgentManagementListByAccess({
|
||||
accessibleIds: agents.map(({ _id }) => _id),
|
||||
tenantId,
|
||||
limit: 2,
|
||||
after: first.after,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(first.data).toHaveLength(2);
|
||||
expect(first.has_more).toBe(true);
|
||||
expect(first.after).toBeTruthy();
|
||||
expect(second.data).toHaveLength(1);
|
||||
expect(second.has_more).toBe(false);
|
||||
expect(first.data.map(({ id }) => id)).not.toContain(second.data[0].id);
|
||||
});
|
||||
});
|
||||
|
||||
function createBasicAgent(overrides = {}) {
|
||||
const defaults = {
|
||||
id: `agent_${uuidv4()}`,
|
||||
|
|
|
|||
|
|
@ -532,6 +532,22 @@ export function createAgentMethods(
|
|||
has_more: boolean;
|
||||
after: string | null;
|
||||
}>;
|
||||
getAgentManagementListByAccess: ({
|
||||
accessibleIds,
|
||||
tenantId,
|
||||
limit,
|
||||
after,
|
||||
}: {
|
||||
/** `null` means the caller already passed the unrestricted management-capability check. */
|
||||
accessibleIds: Types.ObjectId[] | null;
|
||||
tenantId: string;
|
||||
limit: number;
|
||||
after?: string | null;
|
||||
}) => Promise<{
|
||||
data: Array<IAgent & { version: number; createdAt: Date; updatedAt: Date }>;
|
||||
has_more: boolean;
|
||||
after: string | null;
|
||||
}>;
|
||||
removeAgentResourceFiles: ({
|
||||
agent_id,
|
||||
files,
|
||||
|
|
@ -1273,6 +1289,72 @@ export function createAgentMethods(
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the full Agent configuration required by the management response projector.
|
||||
* Unlike the browser list path, this query performs no avatar refresh or persistence write.
|
||||
*/
|
||||
async function getAgentManagementListByAccess({
|
||||
accessibleIds,
|
||||
tenantId,
|
||||
limit,
|
||||
after = null,
|
||||
}: {
|
||||
/** `null` means the caller already passed the unrestricted management-capability check. */
|
||||
accessibleIds: Types.ObjectId[] | null;
|
||||
tenantId: string;
|
||||
limit: number;
|
||||
after?: string | null;
|
||||
}): Promise<{
|
||||
data: Array<IAgent & { version: number; createdAt: Date; updatedAt: Date }>;
|
||||
has_more: boolean;
|
||||
after: string | null;
|
||||
}> {
|
||||
const Agent = mongoose.models.Agent as Model<IAgent>;
|
||||
const match: FilterQuery<IAgent> = {
|
||||
tenantId,
|
||||
...(accessibleIds != null ? { _id: { $in: accessibleIds } } : {}),
|
||||
};
|
||||
|
||||
if (after) {
|
||||
const cursor = JSON.parse(Buffer.from(after, 'base64').toString('utf8')) as {
|
||||
updatedAt: string;
|
||||
_id: string;
|
||||
};
|
||||
match.$or = [
|
||||
{ updatedAt: { $lt: new Date(cursor.updatedAt) } },
|
||||
{
|
||||
updatedAt: new Date(cursor.updatedAt),
|
||||
_id: { $gt: new mongoose.Types.ObjectId(cursor._id) },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const agents = await Agent.aggregate<
|
||||
IAgent & { version: number; createdAt: Date; updatedAt: Date }
|
||||
>([
|
||||
{ $match: match },
|
||||
{ $sort: { updatedAt: -1, _id: 1 } },
|
||||
{ $limit: limit + 1 },
|
||||
{ $addFields: { version: { $size: { $ifNull: ['$versions', []] } } } },
|
||||
{ $project: { versions: 0 } },
|
||||
]);
|
||||
|
||||
const hasMore = agents.length > limit;
|
||||
const data = hasMore ? agents.slice(0, limit) : agents;
|
||||
const lastAgent = data[data.length - 1];
|
||||
const nextCursor =
|
||||
hasMore && lastAgent
|
||||
? Buffer.from(
|
||||
JSON.stringify({
|
||||
updatedAt: lastAgent.updatedAt.toISOString(),
|
||||
_id: lastAgent._id.toString(),
|
||||
}),
|
||||
).toString('base64')
|
||||
: null;
|
||||
|
||||
return { data, has_more: hasMore, after: nextCursor };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverts an agent to a specific version in its version history.
|
||||
*/
|
||||
|
|
@ -1396,6 +1478,7 @@ export function createAgentMethods(
|
|||
countPromotedAgents,
|
||||
addAgentResourceFile,
|
||||
getListAgentsByAccess,
|
||||
getAgentManagementListByAccess,
|
||||
removeAgentResourceFiles,
|
||||
generateActionMetadataHash,
|
||||
removeAgentFromUserFavorites,
|
||||
|
|
|
|||
|
|
@ -163,6 +163,7 @@ const agentSchema: Schema<IAgent> = new Schema<IAgent>(
|
|||
agentSchema.index({ id: 1, tenantId: 1 }, { unique: true });
|
||||
agentSchema.index({ mcpServerNames: 1, tenantId: 1 });
|
||||
agentSchema.index({ updatedAt: -1, _id: 1 });
|
||||
agentSchema.index({ tenantId: 1, updatedAt: -1, _id: 1 });
|
||||
agentSchema.index({ 'edges.to': 1 });
|
||||
|
||||
export default agentSchema;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue