🧳 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',