🧳 feat: Register Principal-Owned Code Environments (#15365)

* feat: add principal-owned code environments

* fix: address code environment CI coverage

* fix: expose principal code environments in endpoint config

* fix: reject missing code environment bodies

* fix: harden code environment lifecycle

* fix: revalidate principal code environments

* fix: synchronize code environment authorization

* fix: bind code environments to current principals

* fix: fail closed on code ACL cache errors

* fix: prevent code environment override shadowing

* fix: preserve principal environment defaults

* fix: suppress revoked code environment aliases

* fix: fail closed on environment augmentation

* fix: narrow code environment defaults

* fix: isolate code environment fallback

* style: sort code config imports
This commit is contained in:
Danny Avila 2026-08-30 19:33:19 -04:00 • committed by GitHub
parent 0389a92452
commit fcae1025c0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
48 changed files with 2178 additions and 35 deletions

View file

@ -20,6 +20,7 @@ const {
searchEntraIdPrincipals,
} = require('~/server/services/GraphApiService');
const db = require('~/models');
const { invalidateCodeEnvironmentConfigCache } = require('~/server/services/Config');
const matchesCurrentTenant = (principal, tenantId) => {
if (!tenantId || tenantId === SYSTEM_TENANT_ID) {
@ -157,6 +158,14 @@ const updateResourcePermissions = async (req, res) => {
grantedBy: userId,
});
if (resourceType === ResourceType.CODE_ENVIRONMENT) {
await invalidateCodeEnvironmentConfigCache(req.user.tenantId).catch((error) => {
// Cached environment metadata is authorization-filtered against the live ACL on every
// read, so a failed revision write may delay a grant but cannot preserve a revocation.
logger.error('[PermissionsController] code environment cache invalidation failed:', error);
});
}
const isAgentResource =
resourceType === ResourceType.AGENT || resourceType === ResourceType.REMOTE_AGENT;
const revokedUserIds = results.revoked

View file

@ -33,7 +33,7 @@ const {
cancelAgentTriggerUserPurge,
purgeAgentTriggerDeliveriesForUser,
} = require('~/server/services/Agents/triggers');
const { getAppConfig } = require('~/server/services/Config');
const { getAppConfig, invalidateCodeEnvironmentConfigCache } = require('~/server/services/Config');
const { randomUUID } = require('node:crypto');
const {
quiesceUserSchedules,
@ -460,6 +460,10 @@ const deleteUserController = async (req, res) => {
await db.deleteAllUserMemories(user.id);
await db.deleteUserPrompts(user.id);
await db.deleteUserSkills(user.id);
await db.deleteUserCodeEnvironments(user.id);
await invalidateCodeEnvironmentConfigCache(user.tenantId).catch((error) => {
logger.error('[deleteUserController] code environment cache invalidation failed:', error);
});
await deleteUserMcpServers(user.id);
await db.deleteActions({ user: user.id });
await db.deleteTokens({ userId: user.id });

View file

@ -11,6 +11,7 @@ const mockCancelAndDrainSubagentThreads = jest.fn().mockResolvedValue(undefined)
const mockQuiesceUserSchedules = jest.fn().mockResolvedValue(true);
const mockRestoreUserSchedules = jest.fn().mockResolvedValue(undefined);
const mockGetWebSearchInstallEntries = jest.fn();
const mockInvalidateCodeEnvironmentConfigCache = jest.fn().mockResolvedValue(undefined);
jest.mock('@librechat/data-schemas', () => {
const actual = jest.requireActual('@librechat/data-schemas');
@ -43,6 +44,7 @@ jest.mock('~/models', () => {
cancelAgentTriggerUserDeletion: jest.fn().mockResolvedValue(true),
deleteUserPrompts: jest.fn().mockResolvedValue(undefined),
deleteUserSkills: jest.fn().mockResolvedValue(undefined),
deleteUserCodeEnvironments: jest.fn().mockResolvedValue(undefined),
deleteMessages: jest.fn().mockResolvedValue(undefined),
deleteBalances: jest.fn().mockResolvedValue(undefined),
deleteActions: jest.fn().mockResolvedValue(undefined),
@ -119,6 +121,8 @@ jest.mock('~/server/services/Config', () => ({
getMCPManager: jest.fn(),
getFlowStateManager: jest.fn(),
getMCPServersRegistry: jest.fn(),
invalidateCodeEnvironmentConfigCache: (...args) =>
mockInvalidateCodeEnvironmentConfigCache(...args),
}));
jest.mock('~/cache', () => ({

View file

@ -21,6 +21,12 @@ jest.mock('@librechat/api', () => ({
backfillRemoteAgentPermissions: jest.fn(),
}));
const mockInvalidateCodeEnvironmentConfigCache = jest.fn().mockResolvedValue(undefined);
jest.mock('~/server/services/Config', () => ({
invalidateCodeEnvironmentConfigCache: (...args) =>
mockInvalidateCodeEnvironmentConfigCache(...args),
}));
const mockBulkUpdateResourcePermissions = jest.fn();
jest.mock('~/server/services/PermissionService', () => ({
@ -380,6 +386,49 @@ describe('PermissionsController', () => {
expect(mockRemoveAgentFromUserFavorites).not.toHaveBeenCalled();
});
it('invalidates shared environment configuration after code environment ACL changes', async () => {
const req = createMockReq({
params: { resourceType: ResourceType.CODE_ENVIRONMENT, resourceId: agentObjectId },
body: {
updated: [{ type: PrincipalType.USER, id: revokedUserId }],
removed: [],
public: false,
},
user: { id: 'user-1', role: 'USER', tenantId: 'tenant-a' },
});
const res = createMockRes();
await updateResourcePermissions(req, res);
expect(mockInvalidateCodeEnvironmentConfigCache).toHaveBeenCalledWith('tenant-a');
expect(res.status).toHaveBeenCalledWith(200);
});
it('keeps the committed ACL response successful when cache invalidation fails', async () => {
mockInvalidateCodeEnvironmentConfigCache.mockRejectedValueOnce(
new Error('redis unavailable'),
);
const req = createMockReq({
params: { resourceType: ResourceType.CODE_ENVIRONMENT, resourceId: agentObjectId },
body: {
updated: [],
removed: [{ type: PrincipalType.USER, id: revokedUserId }],
public: false,
},
user: { id: 'user-1', role: 'USER', tenantId: 'tenant-a' },
});
const res = createMockRes();
await updateResourcePermissions(req, res);
expect(mockInvalidateCodeEnvironmentConfigCache).toHaveBeenCalledWith('tenant-a');
expect(res.status).toHaveBeenCalledWith(200);
expect(mockLogger.error).toHaveBeenCalledWith(
'[PermissionsController] code environment cache invalidation failed:',
expect.any(Error),
);
});
it('handles agent not found gracefully', async () => {
mockRemoveAgentFromUserFavorites.mockResolvedValue(undefined);

View file

@ -19,6 +19,8 @@ const mockDeleteToolCalls = jest.fn();
const mockDeleteUserAgents = jest.fn();
const mockDeleteUserPrompts = jest.fn();
const mockDeleteUserSkills = jest.fn();
const mockDeleteUserCodeEnvironments = jest.fn();
const mockInvalidateCodeEnvironmentConfigCache = jest.fn();
const mockGetCleanupBlockingJobIdsForUser = jest.fn();
const mockAbortJob = jest.fn();
const mockDrainAgentTriggerDeliveriesForUser = jest.fn();
@ -77,6 +79,7 @@ jest.mock('~/models', () => ({
deleteUserAgents: (...args) => mockDeleteUserAgents(...args),
deleteUserPrompts: (...args) => mockDeleteUserPrompts(...args),
deleteUserSkills: (...args) => mockDeleteUserSkills(...args),
deleteUserCodeEnvironments: (...args) => mockDeleteUserCodeEnvironments(...args),
deleteTransactions: jest.fn(),
deleteBalances: jest.fn(),
deleteAllAgentApiKeys: jest.fn(),
@ -136,6 +139,8 @@ jest.mock('~/server/services/Schedules', () => ({
jest.mock('~/server/services/Config', () => ({
getAppConfig: jest.fn(),
invalidateCodeEnvironmentConfigCache: (...args) =>
mockInvalidateCodeEnvironmentConfigCache(...args),
}));
jest.mock('~/cache', () => ({
@ -169,6 +174,7 @@ function stubDeletionMocks() {
mockDeleteUserAgents.mockResolvedValue();
mockDeleteUserPrompts.mockResolvedValue();
mockDeleteUserSkills.mockResolvedValue(0);
mockInvalidateCodeEnvironmentConfigCache.mockResolvedValue(undefined);
mockGetCleanupBlockingJobIdsForUser.mockResolvedValue([]);
mockAbortJob.mockResolvedValue({ success: true });
mockDrainAgentTriggerDeliveriesForUser.mockResolvedValue();
@ -201,6 +207,7 @@ describe('deleteUserController - 2FA enforcement', () => {
expect(mockDeleteUserAgents).toHaveBeenCalledWith('user1');
expect(mockDeleteUserPrompts).toHaveBeenCalledWith('user1');
expect(mockDeleteUserSkills).toHaveBeenCalledWith('user1');
expect(mockInvalidateCodeEnvironmentConfigCache).toHaveBeenCalledWith(undefined);
expect(mockVerifyOTPOrBackupCode).not.toHaveBeenCalled();
expect(mockBeginAgentTriggerUserDeletion.mock.invocationCallOrder[0]).toBeLessThan(
mockPrepareAgentTriggerUserPurge.mock.invocationCallOrder[0],

View file

@ -12,6 +12,7 @@ const { ResourceType } = require('librechat-data-provider');
*/
const HANDLED_RESOURCE_TYPES = {
[ResourceType.AGENT]: 'deleteUserAgents',
[ResourceType.CODE_ENVIRONMENT]: 'deleteUserCodeEnvironments',
[ResourceType.REMOTE_AGENT]: 'deleteUserAgents',
[ResourceType.PROMPTGROUP]: 'deleteUserPrompts',
[ResourceType.MCPSERVER]: 'deleteUserMcpServers',

View file

@ -521,6 +521,7 @@ if (cluster.isMaster) {
app.use('/api/admin', routes.adminAuth);
app.use('/api/admin/skills', routes.adminSkills);
app.use('/api/admin/code-environments', routes.adminCodeEnvironments);
app.use('/api/code-environments', routes.codeEnvironments);
app.use('/api/actions', routes.actions);
app.use('/api/keys', routes.keys);
app.use('/api/api-keys', routes.apiKeys);

View file

@ -368,6 +368,7 @@ const startServer = async () => {
app.use('/api/admin', routes.adminAuth);
app.use('/api/admin/config', routes.adminConfig);
app.use('/api/admin/code-environments', routes.adminCodeEnvironments);
app.use('/api/code-environments', routes.codeEnvironments);
app.use('/api/admin/langfuse', routes.adminLangfuse);
app.use('/api/admin/grants', routes.adminGrants);
app.use('/api/admin/groups', routes.adminGroups);

View file

@ -88,6 +88,12 @@ const checkResourcePermissionAccess = (requiredPermission) => (req, res, next) =
resourceIdParam: 'resourceId',
idResolver: getSkillById,
});
} else if (resourceType === ResourceType.CODE_ENVIRONMENT) {
middleware = canAccessResource({
resourceType: ResourceType.CODE_ENVIRONMENT,
requiredPermission,
resourceIdParam: 'resourceId',
});
} else if (resourceType === ResourceType.SHARED_LINK) {
middleware = canAccessResource({
resourceType: ResourceType.SHARED_LINK,

View file

@ -10,6 +10,7 @@ const {
purgeAgentTriggerDeliveriesForUser,
} = require('~/server/services/Agents/triggers');
const db = require('~/models');
const { invalidateCodeEnvironmentConfigCache } = require('~/server/services/Config');
const router = express.Router();
@ -27,6 +28,8 @@ const handlers = createAdminUsersHandlers({
cancelAgentTriggerUserPurge,
purgeAgentTriggerDeliveriesForUser,
deleteUserById: db.deleteUserById,
deleteUserCodeEnvironments: db.deleteUserCodeEnvironments,
invalidateCodeEnvironmentConfigCache,
deleteConfig: db.deleteConfig,
deleteAclEntries: db.deleteAclEntries,
});

View file

@ -0,0 +1,27 @@
const express = require('express');
const { createCodeEnvironmentHttpHandlers } = require('@librechat/api');
const { SystemCapabilities } = require('@librechat/data-schemas');
const { requireCapability } = require('~/server/middleware/roles/capabilities');
const { getAppConfig, getCodeEnvironmentRegistry } = require('~/server/services/Config');
const { requireJwtAuth } = require('~/server/middleware');
const router = express.Router();
let handlers;
function getHandlers() {
if (handlers == null) {
handlers = createCodeEnvironmentHttpHandlers({
getAppConfig,
registry: getCodeEnvironmentRegistry(),
});
}
return handlers;
}
const requireCodeEnvironmentManage = requireCapability(SystemCapabilities.MANAGE_CODE_ENVIRONMENTS);
router.use(requireJwtAuth);
router.get('/', (req, res, next) => getHandlers().list(req, res, next));
router.post('/', requireCodeEnvironmentManage, (req, res, next) =>
getHandlers().register(req, res, next),
);
module.exports = router;

View file

@ -0,0 +1,77 @@
const express = require('express');
const request = require('supertest');
const middlewareCalls = [];
const mockRequireJwtAuth = jest.fn((req, _res, next) => {
middlewareCalls.push('jwt');
req.user = { id: '68b2f0c498f24c1e78fa0001', role: 'USER' };
next();
});
const mockRequireCapability = jest.fn((capability) => (req, _res, next) => {
middlewareCalls.push(capability);
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' } })),
};
jest.mock('@librechat/data-schemas', () => ({
SystemCapabilities: { MANAGE_CODE_ENVIRONMENTS: 'manage:code_environments' },
}));
jest.mock('@librechat/api', () => ({
createCodeEnvironmentRegistry: jest.fn(() => mockRegistry),
createCodeEnvironmentHttpHandlers: jest.fn(() => mockHandlers),
}));
jest.mock('~/server/middleware/roles/capabilities', () => ({
requireCapability: mockRequireCapability,
}));
jest.mock('~/server/middleware', () => ({ requireJwtAuth: mockRequireJwtAuth }));
jest.mock('~/server/services/Config', () => ({
getAppConfig: jest.fn(),
getCodeEnvironmentRegistry: mockGetCodeEnvironmentRegistry,
}));
function createApp() {
delete require.cache[require.resolve('./code-environments')];
const router = require('./code-environments');
const app = express();
app.use(express.json());
app.use('/api/code-environments', router);
return app;
}
describe('code environment routes', () => {
beforeEach(() => {
middlewareCalls.length = 0;
jest.clearAllMocks();
});
it('defers registry initialization until the route is requested', () => {
createApp();
expect(mockGetCodeEnvironmentRegistry).not.toHaveBeenCalled();
});
it('allows authenticated discovery without the management capability', async () => {
await request(createApp()).get('/api/code-environments').expect(200, { environments: [] });
expect(middlewareCalls).toEqual(['jwt']);
expect(mockHandlers.list).toHaveBeenCalledTimes(1);
});
it('requires the management capability before registration', async () => {
await request(createApp())
.post('/api/code-environments')
.send({ name: 'Personal VM' })
.expect(201);
expect(middlewareCalls).toEqual(['jwt', 'manage:code_environments']);
expect(mockHandlers.register).toHaveBeenCalledTimes(1);
});
});

View file

@ -6,7 +6,7 @@ const tokenConfigController = require('~/server/controllers/TokenConfigControlle
const router = express.Router();
/** Auth required for role/tenant-scoped endpoint config resolution. */
router.get('/', requireJwtAuth, endpointController);
router.get('/', requireJwtAuth, configMiddleware, endpointController);
router.get('/token-config', requireJwtAuth, configMiddleware, tokenConfigController);
module.exports = router;

View file

@ -4,6 +4,7 @@ const categories = require('./categories');
const adminAuth = require('./admin/auth');
const adminConfig = require('./admin/config');
const adminCodeEnvironments = require('./admin/code');
const codeEnvironments = require('./code-environments');
const adminLangfuse = require('./admin/langfuse');
const adminGrants = require('./admin/grants');
const adminGroups = require('./admin/groups');
@ -49,6 +50,7 @@ module.exports = {
adminAuth,
adminConfig,
adminCodeEnvironments,
codeEnvironments,
adminLangfuse,
adminGrants,
adminGroups,

View file

@ -14,8 +14,10 @@ jest.mock('~/server/services/start/tools', () => ({
jest.mock('../loadCustomConfig', () => jest.fn().mockResolvedValue({}));
jest.mock('@librechat/data-schemas', () => {
const actual = jest.requireActual('@librechat/data-schemas');
return { ...actual, AppService: jest.fn(() => ({ availableTools: {} })) };
return {
AppService: jest.fn(() => ({ availableTools: {} })),
logger: { error: jest.fn() },
};
});
jest.mock('~/models', () => ({
@ -37,6 +39,8 @@ jest.mock('@librechat/api', () => ({
clearOverrideCache: mockClearOverrideCache,
})),
clearMcpConfigCache: mockClearMcpConfigCache,
createCodeEnvironmentRegistry: jest.fn(() => ({})),
mergeAccessibleCodeEnvironments: jest.fn(({ appConfig }) => appConfig),
}));
// ── Tests ──────────────────────────────────────────────────────────────

View file

@ -1,6 +1,14 @@
const mongoose = require('mongoose');
const { CacheKeys } = require('librechat-data-provider');
const { AppService, logger } = require('@librechat/data-schemas');
const { createAppConfigService, clearMcpConfigCache } = require('@librechat/api');
const {
createAppConfigService,
clearMcpConfigCache,
createCodeEnvironmentRegistry,
mergeAccessibleCodeEnvironments,
cacheConfig,
standardCache,
} = require('@librechat/api');
const { setCachedTools, invalidateCachedTools } = require('./getCachedTools');
const { loadAndFormatTools } = require('~/server/services/start/tools');
const loadCustomConfig = require('./loadCustomConfig');
@ -8,6 +16,23 @@ const getLogStores = require('~/cache/getLogStores');
const paths = require('~/config/paths');
const db = require('~/models');
let codeEnvironmentRegistry;
function getCodeEnvironmentRegistry() {
if (codeEnvironmentRegistry == null) {
codeEnvironmentRegistry = createCodeEnvironmentRegistry(mongoose, {
configurationCache: cacheConfig.USE_REDIS
? standardCache('CODE_ENVIRONMENT_CONFIG')
: undefined,
});
}
return codeEnvironmentRegistry;
}
async function invalidateCodeEnvironmentConfigCache(tenantId) {
await getCodeEnvironmentRegistry().invalidateAccessibleConfigurations(tenantId);
}
const loadBaseConfig = async () => {
/** @type {TCustomConfig} */
const config = (await loadCustomConfig()) ?? {};
@ -27,6 +52,20 @@ const { getAppConfig, clearAppConfigCache, clearOverrideCache } = createAppConfi
cacheKeys: CacheKeys,
getApplicableConfigs: db.getApplicableConfigs,
getUserPrincipals: db.getUserPrincipals,
augmentConfig: ({ appConfig, baseConfig, principals, options }) => {
if (!options.userId) return appConfig;
return mergeAccessibleCodeEnvironments({
appConfig,
deploymentConfig: baseConfig,
actor: {
userId: options.userId,
role: options.role ?? null,
idOnTheSource: options.idOnTheSource ?? null,
principals,
},
registry: getCodeEnvironmentRegistry(),
});
},
});
/**
@ -58,5 +97,8 @@ async function invalidateConfigCaches(tenantId) {
module.exports = {
getAppConfig,
clearAppConfigCache,
clearOverrideCache,
invalidateConfigCaches,
getCodeEnvironmentRegistry,
invalidateCodeEnvironmentConfigCache,
};

View file

@ -751,7 +751,9 @@ describe('PermissionService', () => {
getAvailableRoles({
resourceType: 'non_existent_type',
}),
).rejects.toThrow('Invalid resourceType: non_existent_type. Valid types: agent, promptGroup');
).rejects.toThrow(
'Invalid resourceType: non_existent_type. Valid types: agent, codeEnvironment, promptGroup, mcpServer, remoteAgent, skill, sharedLink',
);
});
});