📨 feat: Add Authenticated Agent Event Ingress (#15110)

* feat: add authenticated agent event ingress

* style: sort agent ingress imports

* fix: harden agent event ingress

* fix: bind event provenance to API keys

* fix: inspect event input with legacy PII filters

* fix: scope event status reads to source keys

* fix: bind event status reads to remote sources
This commit is contained in:
Danny Avila 2026-08-22 01:09:04 -04:00 committed by GitHub
parent f384e71f77
commit 3ebef4c84e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 1033 additions and 16 deletions

View file

@ -9,6 +9,8 @@ const {
MESSAGE_IP_WINDOW = 1,
MESSAGE_USER_MAX = 40,
MESSAGE_USER_WINDOW = 1,
AGENT_EVENT_USER_MAX = 40,
AGENT_EVENT_USER_WINDOW = 1,
MESSAGE_VIOLATION_SCORE: score,
} = process.env;
@ -20,6 +22,10 @@ const userWindowMs = MESSAGE_USER_WINDOW * 60 * 1000;
const userMax = MESSAGE_USER_MAX;
const userWindowInMinutes = userWindowMs / 60000;
const agentEventUserWindowMs = AGENT_EVENT_USER_WINDOW * 60 * 1000;
const agentEventUserMax = AGENT_EVENT_USER_MAX;
const agentEventUserWindowInMinutes = agentEventUserWindowMs / 60000;
/**
* Creates either an IP/User message request rate limiter for excessive requests
* that properly logs and denies the violation.
@ -74,7 +80,31 @@ const messageIpLimiter = rateLimit(ipLimiterOptions);
*/
const messageUserLimiter = rateLimit(userLimiterOptions);
/**
* Event admission has its own API-principal bucket. The durable worker later
* consumes the normal message-user bucket when it executes the delivery, so
* sharing that limiter here would charge every event twice.
*/
const agentEventUserLimiter = rateLimit({
windowMs: agentEventUserWindowMs,
max: agentEventUserMax,
handler: async (req, res) => {
const type = ViolationTypes.MESSAGE_LIMIT;
const errorMessage = {
type,
max: agentEventUserMax,
limiter: 'agent_event_principal',
windowInMinutes: agentEventUserWindowInMinutes,
};
await logViolation(req, res, type, errorMessage, score);
return await denyRequest(req, res, errorMessage);
},
keyGenerator: (req) => String(req.apiKeyId ?? req.user?.id),
store: limiterCache('agent_event_user_limiter'),
});
module.exports = {
agentEventUserLimiter,
messageIpLimiter,
messageUserLimiter,
};

View file

@ -64,6 +64,7 @@ jest.mock('~/server/middleware', () => ({
next();
},
moderateText: (req, res, next) => next(),
agentEventUserLimiter: (req, res, next) => next(),
messageIpLimiter: (req, res, next) => next(),
configMiddleware: (req, res, next) => next(),
messageUserLimiter: (req, res, next) => next(),

View file

@ -0,0 +1,82 @@
const express = require('express');
const request = require('supertest');
const mockEnqueueAgentTrigger = jest.fn();
const mockGetAgentTriggerDeliveryStatus = jest.fn();
const mockEnqueueEvent = jest.fn((_req, res) => res.status(202).json({ id: 'trigger-1' }));
const mockGetEvent = jest.fn((_req, res) => res.status(200).json({ status: 'succeeded' }));
let mockIngressDependencies;
const mockCreateAgentTriggerIngressHandlers = jest.fn((dependencies) => {
mockIngressDependencies = dependencies;
return {
enqueueEvent: mockEnqueueEvent,
getEvent: mockGetEvent,
};
});
jest.mock('@librechat/api', () => ({
createAgentTriggerIngressHandlers: mockCreateAgentTriggerIngressHandlers,
createMessageFilterPii: () => (_req, _res, next) => next(),
}));
jest.mock('~/server/controllers/agents/openai', () => ({
OpenAIChatCompletionController: jest.fn(),
ListModelsController: jest.fn(),
GetModelController: jest.fn(),
}));
jest.mock('~/server/services/Agents/triggers', () => ({
enqueueAgentTrigger: mockEnqueueAgentTrigger,
getAgentTriggerDeliveryStatus: mockGetAgentTriggerDeliveryStatus,
}));
jest.mock('~/server/middleware', () => ({
agentEventUserLimiter: (_req, _res, next) => next(),
configMiddleware: (_req, _res, next) => next(),
}));
jest.mock('../middleware', () => ({
preAuthTenantMiddleware: (_req, _res, next) => next(),
requireRemoteAgentAuth: (req, _res, next) => {
req.user = { id: 'user-1' };
next();
},
checkRemoteAgentsFeature: (_req, _res, next) => next(),
checkAgentPermission: (_req, _res, next) => next(),
checkAgentTriggerPermission: (_req, _res, next) => next(),
}));
const router = require('../openai');
describe('Remote Agents event routes', () => {
const app = express();
app.use(express.json());
app.use('/api/agents/v1', router);
beforeEach(() => {
mockEnqueueEvent.mockClear();
mockGetEvent.mockClear();
});
it('wires durable event admission to the trigger service', async () => {
const response = await request(app)
.post('/api/agents/v1/events')
.send({
target: { agentId: 'agent-1' },
});
expect(response.status).toBe(202);
expect(mockIngressDependencies).toEqual({
enqueue: mockEnqueueAgentTrigger,
getDeliveryStatus: mockGetAgentTriggerDeliveryStatus,
});
expect(mockEnqueueEvent).toHaveBeenCalledTimes(1);
});
it('wires owner-scoped event status reads to the ingress handler', async () => {
const response = await request(app).get('/api/agents/v1/events/trigger-1');
expect(response.status).toBe(200);
expect(mockGetEvent).toHaveBeenCalledTimes(1);
});
});

View file

@ -4,6 +4,7 @@ const {
preAuthTenantMiddleware,
createRequireApiKeyAuth,
createRemoteAgentAuth,
createCheckAgentTriggerAccess,
createCheckRemoteAgentAccess,
} = require('@librechat/api');
const { getEffectivePermissions } = require('~/server/services/PermissionService');
@ -29,13 +30,17 @@ const checkRemoteAgentsFeature = generateCheckAccess({
getRoleByName: db.getRoleByName,
});
const checkAgentPermission = createCheckRemoteAgentAccess({
const agentAccessDependencies = {
getAgent: db.getAgent,
getEffectivePermissions,
});
};
const checkAgentPermission = createCheckRemoteAgentAccess(agentAccessDependencies);
const checkAgentTriggerPermission = createCheckAgentTriggerAccess(agentAccessDependencies);
module.exports = {
checkAgentPermission,
checkAgentTriggerPermission,
preAuthTenantMiddleware,
requireRemoteAgentAuth,
checkRemoteAgentsFeature,

View file

@ -6,6 +6,8 @@
*
* Usage:
* POST /v1/chat/completions - Chat with an agent
* POST /v1/events - Durably deliver a source-neutral event
* GET /v1/events/:id - Read an event delivery status and result
* GET /v1/models - List available agents
* GET /v1/models/:model - Get agent details
*
@ -17,26 +19,56 @@
* }
*/
const express = require('express');
const { createAgentTriggerIngressHandlers, createMessageFilterPii } = require('@librechat/api');
const {
OpenAIChatCompletionController,
ListModelsController,
GetModelController,
} = require('~/server/controllers/agents/openai');
const { configMiddleware } = require('~/server/middleware');
const { agentEventUserLimiter, configMiddleware } = require('~/server/middleware');
const {
enqueueAgentTrigger,
getAgentTriggerDeliveryStatus,
} = require('~/server/services/Agents/triggers');
const {
checkAgentPermission,
checkAgentTriggerPermission,
preAuthTenantMiddleware,
requireRemoteAgentAuth,
checkRemoteAgentsFeature,
} = require('./middleware');
const router = express.Router();
const eventHandlers = createAgentTriggerIngressHandlers({
enqueue: enqueueAgentTrigger,
getDeliveryStatus: getAgentTriggerDeliveryStatus,
});
router.use(preAuthTenantMiddleware);
router.use(requireRemoteAgentAuth);
router.use(configMiddleware);
router.use(checkRemoteAgentsFeature);
/**
* @route POST /v1/events
* @desc Durably deliver a source-neutral event to an agent
* @access Private (API key auth required)
*/
router.post(
'/events',
agentEventUserLimiter,
createMessageFilterPii({ getConfig: (req) => req.config?.messageFilter?.pii }),
checkAgentTriggerPermission,
eventHandlers.enqueueEvent,
);
/**
* @route GET /v1/events/:id
* @desc Read the authenticated owner's delivery status and result
* @access Private (API key auth required)
*/
router.get('/events/:id', eventHandlers.getEvent);
/**
* @route POST /v1/chat/completions
* @desc OpenAI-compatible chat completions with agents

View file

@ -20,6 +20,7 @@ module.exports = {
dispatchAgentTrigger: service.dispatch,
enqueueAgentTrigger: service.enqueue,
getAgentTriggerDelivery: service.getDelivery,
getAgentTriggerDeliveryStatus: service.getDeliveryStatus,
getAgentTriggerDeadLetters: service.getDeadLetters,
requeueAgentTrigger: service.requeue,
drainAgentTriggerDeliveriesForUser: service.drainUser,