Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion api/app/clients/tools/util/handleTools.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const {
ASK_USER_QUESTION_TOOL_NAME,
resolveWebSearchSSRFAgents,
buildWebSearchDynamicContext,
codeExecutionAuthHeaders,
resolveCodeExecutionContext,
} = require('@librechat/api');
const {
Expand Down Expand Up @@ -379,7 +380,11 @@ const loadTools = async ({
return createCodeExecutionTool({
user_id: user,
files,
authHeaders: () => getCodeApiAuthHeaders(options.req),
authHeaders: () =>
codeExecutionAuthHeaders(
(bridgeWorkerId) => getCodeApiAuthHeaders(options.req, bridgeWorkerId),
codeExecutionContext,
Comment thread
danny-avila marked this conversation as resolved.
),
...codeExecutionContext,
});
};
Expand Down
2 changes: 2 additions & 0 deletions api/server/controllers/agents/callbacks.js
Original file line number Diff line number Diff line change
Expand Up @@ -1048,6 +1048,7 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null, jo
codeApiBaseUrl: metadata.codeExecutionContext?.baseUrl,
executionProfile: metadata.codeExecutionContext?.executionProfile,
executionRouteKey: metadata.codeExecutionContext?.executionRouteKey,
bridgeWorkerId: metadata.codeExecutionContext?.bridgeWorkerId,
preparedBuffer,
downloadFallback,
});
Expand Down Expand Up @@ -1393,6 +1394,7 @@ function createResponsesToolEndCallback({ req, res, tracker, artifactPromises })
codeApiBaseUrl: metadata.codeExecutionContext?.baseUrl,
executionProfile: metadata.codeExecutionContext?.executionProfile,
executionRouteKey: metadata.codeExecutionContext?.executionRouteKey,
bridgeWorkerId: metadata.codeExecutionContext?.bridgeWorkerId,
preparedBuffer,
downloadFallback,
});
Expand Down
9 changes: 8 additions & 1 deletion api/server/controllers/agents/v1.js
Original file line number Diff line number Diff line change
Expand Up @@ -457,7 +457,14 @@ const validateStatefulCodeEnvironment = (
if (environmentId != null) {
const configuredEnvironments =
req.config?.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.environments ?? [];
if (!configuredEnvironments.some((configured) => configured.id === environmentId)) {
const configuredEnvironment = configuredEnvironments.find(
(configured) => configured.id === environmentId,
);
const pairingOnly =
configuredEnvironment?.pairing?.allowPrincipalWorkers === true &&
configuredEnvironment.pairing.workerId == null &&
configuredEnvironment.workerId == null;
if (configuredEnvironment == null || pairingOnly) {
res.status(400).json({
error: `Stateful code environment is not configured: ${environmentId}`,
});
Expand Down
36 changes: 36 additions & 0 deletions api/server/controllers/agents/v1.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,42 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(await Agent.countDocuments()).toBe(0);
});

test('rejects a pairing-only control plane as a code environment id', async () => {
mockReq.config = {
endpoints: {
agents: {
statefulCodeSessions: {
allowedEnvironments: ['user'],
environments: [
{
id: 'personal-workers',
name: 'Personal workers',
type: 'attached',
baseURL: 'https://code.example.com/v1',
pairing: {
allowPrincipalWorkers: true,
tokenEnv: 'CODE_BRIDGE_ADMIN_TOKEN',
},
},
],
},
},
},
};
mockReq.body = {
name: 'Invalid Control Plane Agent',
provider: 'openai',
model: 'gpt-4',
stateful_code_sessions: true,
code_environment_id: 'personal-workers',
};

await createAgentHandler(mockReq, mockRes);

expect(mockRes.status).toHaveBeenCalledWith(400);
expect(await Agent.countDocuments()).toBe(0);
});

test('should block configured agent instruction content before persistence', async () => {
mockReq.config = {
filters: {
Expand Down
41 changes: 41 additions & 0 deletions api/server/middleware/limiters/code.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
const rateLimit = require('express-rate-limit');
const { limiterCache } = require('@librechat/api');

let configuredCodeEnvironmentPairingLimiter;

function positiveInteger(value, fallback) {
const parsed = Number(value);
return Number.isSafeInteger(parsed) && parsed >= 1 ? parsed : fallback;
}

const codeEnvironmentPairingLimiter = (req, res, next) => {
if (configuredCodeEnvironmentPairingLimiter == null) {
const max = positiveInteger(process.env.CODE_ENVIRONMENT_PAIRING_USER_MAX, 5);
const windowInMinutes = positiveInteger(process.env.CODE_ENVIRONMENT_PAIRING_USER_WINDOW, 60);

configuredCodeEnvironmentPairingLimiter = rateLimit({
windowMs: windowInMinutes * 60 * 1000,
max,
handler: (limitedReq, limitedRes) => {
const resetAt = limitedReq.rateLimit?.resetTime?.getTime?.();
const retryAfterSeconds = Number.isFinite(resetAt)
? Math.max(1, Math.ceil((resetAt - Date.now()) / 1000))
: Math.max(1, Math.ceil(windowInMinutes * 60));
limitedRes.set('Retry-After', String(retryAfterSeconds));
return limitedRes.status(429).json({
error: {
code: 'code_environment_pairing_rate_limited',
message: 'Code environment pairing rate limit exceeded.',
type: 'rate_limit_error',
},
});
},
keyGenerator: (limitedReq) => String(limitedReq.user.id),
store: limiterCache('code_environment_pairing_user_limiter'),
});
}

return configuredCodeEnvironmentPairingLimiter(req, res, next);
};

module.exports = { codeEnvironmentPairingLimiter };
82 changes: 82 additions & 0 deletions api/server/middleware/limiters/code.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
const express = require('express');
const request = require('supertest');

const mockLimiter = jest.fn((_req, _res, next) => next());
const mockRateLimit = jest.fn(() => mockLimiter);

jest.mock('express-rate-limit', () => mockRateLimit);
jest.mock('@librechat/api', () => ({
limiterCache: jest.fn(() => ({})),
}));

describe('code environment pairing rate limiter', () => {
let originalEnv;

beforeEach(() => {
originalEnv = { ...process.env };
jest.resetModules();
});

afterEach(() => {
process.env = originalEnv;
jest.clearAllMocks();
});

it('uses a configurable per-user bucket', () => {
process.env.CODE_ENVIRONMENT_PAIRING_USER_MAX = '3';
process.env.CODE_ENVIRONMENT_PAIRING_USER_WINDOW = '15';
const { codeEnvironmentPairingLimiter } = require('./code');
const { limiterCache } = require('@librechat/api');
const req = { user: { id: 'user-1' } };
const next = jest.fn();

codeEnvironmentPairingLimiter(req, {}, next);

expect(mockRateLimit).toHaveBeenLastCalledWith(
expect.objectContaining({
max: 3,
windowMs: 900_000,
keyGenerator: expect.any(Function),
}),
);
expect(mockRateLimit.mock.calls.at(-1)[0].keyGenerator(req)).toBe('user-1');
expect(limiterCache).toHaveBeenCalledWith('code_environment_pairing_user_limiter');
expect(mockLimiter).toHaveBeenCalledWith(req, {}, next);
});

it('falls back to safe limits for invalid environment values', () => {
process.env.CODE_ENVIRONMENT_PAIRING_USER_MAX = 'NaN';
process.env.CODE_ENVIRONMENT_PAIRING_USER_WINDOW = '-1';
const { codeEnvironmentPairingLimiter } = require('./code');

codeEnvironmentPairingLimiter({ user: { id: 'user-1' } }, {}, jest.fn());

expect(mockRateLimit).toHaveBeenLastCalledWith(
expect.objectContaining({ max: 5, windowMs: 3_600_000 }),
);
});

it('returns an actionable JSON response when the limit is exceeded', async () => {
const { codeEnvironmentPairingLimiter } = require('./code');

codeEnvironmentPairingLimiter({ user: { id: 'user-1' } }, {}, jest.fn());
const options = mockRateLimit.mock.calls.at(-1)[0];
const app = express();
app.use((req, _res, next) => {
req.rateLimit = { resetTime: new Date(Date.now() + 30_000) };
next();
});
app.post('/api/code-environments/pairings', options.handler);
const response = await request(app).post('/api/code-environments/pairings');

expect(response.status).toBe(429);
expect(response.headers['retry-after']).toBe('30');
expect(response.body).toEqual({
error: {
code: 'code_environment_pairing_rate_limited',
message: 'Code environment pairing rate limit exceeded.',
type: 'rate_limit_error',
},
});
});
});
4 changes: 4 additions & 0 deletions api/server/routes/code-environments.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const express = require('express');
const { createCodeEnvironmentHttpHandlers } = require('@librechat/api');
const { SystemCapabilities } = require('@librechat/data-schemas');
const { requireCapability } = require('~/server/middleware/roles/capabilities');
const { codeEnvironmentPairingLimiter } = require('~/server/middleware/limiters/code');
const { getAppConfig, getCodeEnvironmentRegistry } = require('~/server/services/Config');
const { requireJwtAuth } = require('~/server/middleware');

Expand All @@ -20,6 +21,9 @@ const requireCodeEnvironmentManage = requireCapability(SystemCapabilities.MANAGE

router.use(requireJwtAuth);
router.get('/', (req, res, next) => getHandlers().list(req, res, next));
router.post('/pairings', codeEnvironmentPairingLimiter, (req, res, next) =>
getHandlers().pair(req, res, next),
);
router.post('/', requireCodeEnvironmentManage, (req, res, next) =>
getHandlers().register(req, res, next),
);
Expand Down
18 changes: 18 additions & 0 deletions api/server/routes/code-environments.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,16 @@ const mockRequireCapability = jest.fn((capability) => (req, _res, next) => {
middlewareCalls.push(capability);
next();
});
const mockCodeEnvironmentPairingLimiter = jest.fn((_req, _res, next) => {
middlewareCalls.push('pairing-limit');
next();
});
const mockRegistry = {};
const mockGetCodeEnvironmentRegistry = jest.fn(() => mockRegistry);
const mockHandlers = {
list: jest.fn((_req, res) => res.status(200).json({ environments: [] })),
register: jest.fn((_req, res) => res.status(201).json({ environment: { id: 'code-1' } })),
pair: jest.fn((_req, res) => res.status(201).json({ environment: { id: 'code-1' } })),
};

jest.mock('@librechat/data-schemas', () => ({
Expand All @@ -30,6 +35,9 @@ jest.mock('@librechat/api', () => ({
jest.mock('~/server/middleware/roles/capabilities', () => ({
requireCapability: mockRequireCapability,
}));
jest.mock('~/server/middleware/limiters/code', () => ({
codeEnvironmentPairingLimiter: mockCodeEnvironmentPairingLimiter,
}));

jest.mock('~/server/middleware', () => ({ requireJwtAuth: mockRequireJwtAuth }));
jest.mock('~/server/services/Config', () => ({
Expand Down Expand Up @@ -74,4 +82,14 @@ describe('code environment routes', () => {
expect(middlewareCalls).toEqual(['jwt', 'manage:code_environments']);
expect(mockHandlers.register).toHaveBeenCalledTimes(1);
});

it('allows an authenticated user to pair through an opted-in control plane', async () => {
await request(createApp())
.post('/api/code-environments/pairings')
.send({ name: 'Personal VM', controlPlaneId: 'shared-code-api' })
.expect(201);

expect(middlewareCalls).toEqual(['jwt', 'pairing-limit']);
expect(mockHandlers.pair).toHaveBeenCalledTimes(1);
});
});
11 changes: 10 additions & 1 deletion api/server/routes/files/files.js
Original file line number Diff line number Diff line change
Expand Up @@ -398,7 +398,16 @@ router.get('/code/download/:session_id/:fileId', async (req, res) => {
id: req.user.id,
},
req,
{ baseUrl, executionProfile },
{
baseUrl,
executionProfile,
...((configuredEnvironment?.workerId ?? configuredEnvironment?.pairing?.workerId) != null
? {
bridgeWorkerId:
configuredEnvironment?.workerId ?? configuredEnvironment?.pairing?.workerId,
}
: {}),
},
);
res.setHeader('Content-Disposition', 'attachment');
res.setHeader('Content-Type', 'application/octet-stream');
Expand Down
7 changes: 6 additions & 1 deletion api/server/routes/files/files.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1263,6 +1263,7 @@ describe('File Routes - Delete with Agent Access', () => {
name: 'Managed VM',
type: 'managed',
baseURL: 'https://managed-code.example.com/v1',
workerId: 'personal-worker-1',
default: true,
owner: 'deployment',
};
Expand Down Expand Up @@ -1290,7 +1291,11 @@ describe('File Routes - Delete with Agent Access', () => {
`${sessionId}/${codeFileId}`,
{ kind: 'user', id: otherUserId.toString() },
expect.any(Object),
{ baseUrl: environment.baseURL, executionProfile: 'stateful' },
{
baseUrl: environment.baseURL,
executionProfile: 'stateful',
bridgeWorkerId: 'personal-worker-1',
},
);
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ jest.mock('@librechat/api', () => {
flattenArtifactPath: mockFlattenArtifactPath,
createAxiosInstance: jest.fn(() => mockAxios),
getCodeApiAuthHeaders: jest.fn(async () => ({})),
codeExecutionHeaders: jest.fn(() => ({})),
getCodeExecutionBaseUrl: jest.fn(() => 'http://localhost:8000'),
CODE_API_EXPECTED_PROFILE_HEADER: 'X-CodeAPI-Expected-Profile',
classifyCodeArtifact: jest.fn(() => 'other'),
Expand Down
Loading
Loading