From fcae1025c0ac4fb1a56384f3ef5867fedf3ef157 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 30 Aug 2026 19:33:19 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=B3=20feat:=20Register=20Principal-Own?= =?UTF-8?q?ed=20Code=20Environments=20(#15365)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- .../controllers/PermissionsController.js | 9 + api/server/controllers/UserController.js | 6 +- api/server/controllers/UserController.spec.js | 4 + .../__tests__/PermissionsController.spec.js | 49 +++ .../controllers/__tests__/deleteUser.spec.js | 7 + .../deleteUserResourceCoverage.spec.js | 1 + api/server/experimental.js | 1 + api/server/index.js | 1 + api/server/routes/accessPermissions.js | 6 + api/server/routes/admin/users.js | 3 + api/server/routes/code-environments.js | 27 ++ api/server/routes/code-environments.test.js | 77 ++++ api/server/routes/endpoints.js | 2 +- api/server/routes/index.js | 2 + .../__tests__/invalidateConfigCaches.spec.js | 8 +- api/server/services/Config/app.js | 44 ++- api/server/services/PermissionService.spec.js | 4 +- .../Sharing/PublicSharingToggle.tsx | 11 +- client/src/utils/resources.ts | 2 +- e2e/specs/mock/stateful-code-bridge.spec.ts | 79 +++- packages/api/src/admin/users.spec.ts | 7 +- packages/api/src/admin/users.ts | 8 + packages/api/src/app/service.spec.ts | 52 ++- packages/api/src/app/service.ts | 48 ++- packages/api/src/code/config.spec.ts | 336 ++++++++++++++++ packages/api/src/code/config.ts | 141 +++++++ .../src/code/environments.integration.spec.ts | 369 ++++++++++++++++++ packages/api/src/code/environments.ts | 316 +++++++++++++++ packages/api/src/code/http.spec.ts | 177 +++++++++ packages/api/src/code/http.ts | 129 ++++++ packages/api/src/code/index.ts | 3 + packages/api/src/index.ts | 1 + packages/api/src/middleware/share.ts | 3 + .../specs/config-schemas.spec.ts | 43 ++ .../data-provider/src/accessPermissions.ts | 7 + packages/data-provider/src/config.ts | 13 +- .../data-schemas/src/admin/capabilities.ts | 1 + .../src/methods/accessRole.spec.ts | 16 +- .../data-schemas/src/methods/accessRole.ts | 21 + .../src/methods/codeEnvironment.ts | 89 +++++ packages/data-schemas/src/methods/index.ts | 4 + .../src/models/codeEnvironment.ts | 14 + packages/data-schemas/src/models/index.ts | 3 + .../data-schemas/src/schema/accessRole.ts | 1 + .../src/schema/codeEnvironment.ts | 47 +++ packages/data-schemas/src/schema/index.ts | 1 + .../data-schemas/src/types/codeEnvironment.ts | 19 + packages/data-schemas/src/types/index.ts | 1 + 48 files changed, 2178 insertions(+), 35 deletions(-) create mode 100644 api/server/routes/code-environments.js create mode 100644 api/server/routes/code-environments.test.js create mode 100644 packages/api/src/code/config.spec.ts create mode 100644 packages/api/src/code/config.ts create mode 100644 packages/api/src/code/environments.integration.spec.ts create mode 100644 packages/api/src/code/environments.ts create mode 100644 packages/api/src/code/http.spec.ts create mode 100644 packages/api/src/code/http.ts create mode 100644 packages/api/src/code/index.ts create mode 100644 packages/data-schemas/src/methods/codeEnvironment.ts create mode 100644 packages/data-schemas/src/models/codeEnvironment.ts create mode 100644 packages/data-schemas/src/schema/codeEnvironment.ts create mode 100644 packages/data-schemas/src/types/codeEnvironment.ts diff --git a/api/server/controllers/PermissionsController.js b/api/server/controllers/PermissionsController.js index 076de31cf3..d112904d7a 100644 --- a/api/server/controllers/PermissionsController.js +++ b/api/server/controllers/PermissionsController.js @@ -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 diff --git a/api/server/controllers/UserController.js b/api/server/controllers/UserController.js index d41e27a072..f4fe17d1e4 100644 --- a/api/server/controllers/UserController.js +++ b/api/server/controllers/UserController.js @@ -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 }); diff --git a/api/server/controllers/UserController.spec.js b/api/server/controllers/UserController.spec.js index f6fbf8cdbd..2c6b70cba9 100644 --- a/api/server/controllers/UserController.spec.js +++ b/api/server/controllers/UserController.spec.js @@ -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', () => ({ diff --git a/api/server/controllers/__tests__/PermissionsController.spec.js b/api/server/controllers/__tests__/PermissionsController.spec.js index 5976f9b29a..95236f2ca3 100644 --- a/api/server/controllers/__tests__/PermissionsController.spec.js +++ b/api/server/controllers/__tests__/PermissionsController.spec.js @@ -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); diff --git a/api/server/controllers/__tests__/deleteUser.spec.js b/api/server/controllers/__tests__/deleteUser.spec.js index c64a40de8d..23c152b30b 100644 --- a/api/server/controllers/__tests__/deleteUser.spec.js +++ b/api/server/controllers/__tests__/deleteUser.spec.js @@ -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], diff --git a/api/server/controllers/__tests__/deleteUserResourceCoverage.spec.js b/api/server/controllers/__tests__/deleteUserResourceCoverage.spec.js index 1bd5b2efaa..a0806d6e3c 100644 --- a/api/server/controllers/__tests__/deleteUserResourceCoverage.spec.js +++ b/api/server/controllers/__tests__/deleteUserResourceCoverage.spec.js @@ -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', diff --git a/api/server/experimental.js b/api/server/experimental.js index ff653bd973..ce21fae0dd 100644 --- a/api/server/experimental.js +++ b/api/server/experimental.js @@ -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); diff --git a/api/server/index.js b/api/server/index.js index e12f213f1e..5f50c0d04c 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -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); diff --git a/api/server/routes/accessPermissions.js b/api/server/routes/accessPermissions.js index 6ef731daba..311f059300 100644 --- a/api/server/routes/accessPermissions.js +++ b/api/server/routes/accessPermissions.js @@ -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, diff --git a/api/server/routes/admin/users.js b/api/server/routes/admin/users.js index 0aa157c179..ab8f5fec8b 100644 --- a/api/server/routes/admin/users.js +++ b/api/server/routes/admin/users.js @@ -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, }); diff --git a/api/server/routes/code-environments.js b/api/server/routes/code-environments.js new file mode 100644 index 0000000000..316ffe1d09 --- /dev/null +++ b/api/server/routes/code-environments.js @@ -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; diff --git a/api/server/routes/code-environments.test.js b/api/server/routes/code-environments.test.js new file mode 100644 index 0000000000..1be0bbca3a --- /dev/null +++ b/api/server/routes/code-environments.test.js @@ -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); + }); +}); diff --git a/api/server/routes/endpoints.js b/api/server/routes/endpoints.js index 8b1fceccc4..2ca373b8ec 100644 --- a/api/server/routes/endpoints.js +++ b/api/server/routes/endpoints.js @@ -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; diff --git a/api/server/routes/index.js b/api/server/routes/index.js index a22b065e7a..c3b6c4bb8e 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -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, diff --git a/api/server/services/Config/__tests__/invalidateConfigCaches.spec.js b/api/server/services/Config/__tests__/invalidateConfigCaches.spec.js index ddc97042b9..8d21fb2e84 100644 --- a/api/server/services/Config/__tests__/invalidateConfigCaches.spec.js +++ b/api/server/services/Config/__tests__/invalidateConfigCaches.spec.js @@ -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 ────────────────────────────────────────────────────────────── diff --git a/api/server/services/Config/app.js b/api/server/services/Config/app.js index 7aa913e636..ce157640fc 100644 --- a/api/server/services/Config/app.js +++ b/api/server/services/Config/app.js @@ -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, }; diff --git a/api/server/services/PermissionService.spec.js b/api/server/services/PermissionService.spec.js index 53b281aefb..7729412c91 100644 --- a/api/server/services/PermissionService.spec.js +++ b/api/server/services/PermissionService.spec.js @@ -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', + ); }); }); diff --git a/client/src/components/Sharing/PublicSharingToggle.tsx b/client/src/components/Sharing/PublicSharingToggle.tsx index f7efcf54c1..bdd8dd99ea 100644 --- a/client/src/components/Sharing/PublicSharingToggle.tsx +++ b/client/src/components/Sharing/PublicSharingToggle.tsx @@ -17,9 +17,11 @@ interface PublicSharingToggleProps { className?: string; } -const accessDescriptions: Record< - ResourceType, - 'com_ui_agent' | 'com_ui_prompt' | 'com_ui_mcp_server' | 'com_ui_skill' | 'com_ui_shared_link' +const accessDescriptions: Partial< + Record< + ResourceType, + 'com_ui_agent' | 'com_ui_prompt' | 'com_ui_mcp_server' | 'com_ui_skill' | 'com_ui_shared_link' + > > = { [ResourceType.AGENT]: 'com_ui_agent', [ResourceType.PROMPTGROUP]: 'com_ui_prompt', @@ -71,7 +73,8 @@ export default function PublicSharingToggle({ side={ESide.Top} text={localize('com_ui_share_everyone_description_var', { resource: - localize(accessDescriptions[resourceType]) || localize('com_ui_resource'), + localize(accessDescriptions[resourceType] ?? 'com_ui_resource') || + localize('com_ui_resource'), })} /> diff --git a/client/src/utils/resources.ts b/client/src/utils/resources.ts index 09623247a2..e925d598ea 100644 --- a/client/src/utils/resources.ts +++ b/client/src/utils/resources.ts @@ -12,7 +12,7 @@ export interface ResourceConfig { getCopyUrlMessage: () => string; } -export const RESOURCE_CONFIGS: Record = { +export const RESOURCE_CONFIGS: Partial> = { [ResourceType.AGENT]: { resourceType: ResourceType.AGENT, defaultViewerRoleId: AccessRoleIds.AGENT_VIEWER, diff --git a/e2e/specs/mock/stateful-code-bridge.spec.ts b/e2e/specs/mock/stateful-code-bridge.spec.ts index f3b0bcd556..32e302b3f2 100644 --- a/e2e/specs/mock/stateful-code-bridge.spec.ts +++ b/e2e/specs/mock/stateful-code-bridge.spec.ts @@ -1,5 +1,6 @@ -import { expect, test } from '@playwright/test'; +import { expect, request as playwrightRequest, test } from '@playwright/test'; import type { AgentDetail } from './agents.helpers'; +import cleanupUser from '../../setup/cleanupUser'; import { cleanupAgent, openAgentBuilder, uniqueAgentName } from './agents.helpers'; import { MOCK_ENDPOINTS, @@ -19,6 +20,13 @@ interface PairingResponse { expiresAt: string; } +interface RegisteredEnvironment { + resourceId: string; + id: string; + name: string; + type: 'attached'; +} + test.describe('attached stateful code environment', () => { test.skip(!process.env.E2E_CODE_BRIDGE_URL, 'E2E_CODE_BRIDGE_URL is required'); @@ -30,6 +38,11 @@ test.describe('attached stateful code environment', () => { const name = uniqueAgentName('E2E Attached Code Agent'); let agentId: string | undefined; + const stranger = { + email: `code-bridge-stranger-${Date.now()}@example.com`, + name: 'Code Bridge Stranger', + password: 'securepassword123', + }; try { const token = await getAccessToken(page); @@ -48,6 +61,68 @@ test.describe('attached stateful code environment', () => { expect(pairing).not.toHaveProperty('token'); expect(Number.isFinite(Date.parse(pairing.expiresAt))).toBe(true); } + const registration = await requestJson<{ environment: RegisteredEnvironment }>(page, { + path: '/api/code-environments', + token, + method: 'POST', + body: { + name: 'E2E principal-owned VM', + controlPlaneId: 'e2e-vm', + /** Neither field is trusted by the server; keep them here as an E2E + * regression check against client-selected routing. */ + workerId: 'attacker-worker', + baseURL: 'https://attacker.invalid', + }, + }); + expect(registration.environment).toMatchObject({ + resourceId: expect.any(String), + id: expect.stringMatching(/^code-/), + name: 'E2E principal-owned VM', + type: 'attached', + }); + expect(registration.environment).not.toHaveProperty('baseURL'); + expect(registration.environment).not.toHaveProperty('workerId'); + + const ownerList = await requestJson<{ environments: RegisteredEnvironment[] }>(page, { + path: '/api/code-environments', + token, + }); + expect(ownerList.environments).toContainEqual(registration.environment); + + await cleanupUser(stranger); + const strangerApi = await playwrightRequest.newContext({ + baseURL: new URL(page.url()).origin, + storageState: { cookies: [], origins: [] }, + }); + try { + expect( + ( + await strangerApi.post('/api/auth/register', { + data: { + email: stranger.email, + name: stranger.name, + password: stranger.password, + confirm_password: stranger.password, + }, + }) + ).ok(), + ).toBe(true); + const strangerLogin = await strangerApi.post('/api/auth/login', { + data: { email: stranger.email, password: stranger.password }, + }); + expect(strangerLogin.ok()).toBe(true); + const strangerToken = ((await strangerLogin.json()) as { token?: string }).token; + expect(strangerToken).toEqual(expect.any(String)); + const strangerList = await strangerApi.get('/api/code-environments', { + headers: { Authorization: `Bearer ${strangerToken}` }, + }); + expect(strangerList.ok()).toBe(true); + expect(await strangerList.json()).toEqual({ environments: [] }); + } finally { + await strangerApi.dispose(); + await cleanupUser(stranger); + } + const agent = await requestJson(page, { path: '/api/agents', token, @@ -61,7 +136,7 @@ test.describe('attached stateful code environment', () => { tools: ['execute_code'], stateful_code_sessions: true, stateful_code_environment: 'conversation', - code_environment_id: 'e2e-vm', + code_environment_id: registration.environment.id, }, }); agentId = agent.id; diff --git a/packages/api/src/admin/users.spec.ts b/packages/api/src/admin/users.spec.ts index e7cae56ad4..83ff375fe2 100644 --- a/packages/api/src/admin/users.spec.ts +++ b/packages/api/src/admin/users.spec.ts @@ -62,6 +62,8 @@ function createDeps(overrides: Partial = {}): AdminUsersDeps { deleteUserById: jest .fn() .mockResolvedValue({ deletedCount: 1, message: 'User was deleted successfully.' }), + deleteUserCodeEnvironments: jest.fn().mockResolvedValue(0), + invalidateCodeEnvironmentConfigCache: jest.fn().mockResolvedValue(undefined), deleteConfig: jest.fn().mockResolvedValue(null), deleteAclEntries: jest.fn().mockResolvedValue(undefined), ...overrides, @@ -435,7 +437,7 @@ describe('createAdminUsersHandlers', () => { expect(deps.countUsers).not.toHaveBeenCalled(); }); - it('cascades cleanup of Config and AclEntries', async () => { + it('cascades cleanup of Config, code environments, and AclEntries', async () => { const result: UserDeleteResult = { deletedCount: 1, message: 'User was deleted successfully.', @@ -448,6 +450,8 @@ describe('createAdminUsersHandlers', () => { expect(status).toHaveBeenCalledWith(200); expect(deps.deleteConfig).toHaveBeenCalledWith(PrincipalType.USER, validUserId); + expect(deps.deleteUserCodeEnvironments).toHaveBeenCalledWith(expect.any(Types.ObjectId)); + expect(deps.invalidateCodeEnvironmentConfigCache).toHaveBeenCalledWith(undefined); expect(deps.deleteAclEntries).toHaveBeenCalledWith({ principalType: PrincipalType.USER, principalId: expect.any(Types.ObjectId), @@ -482,6 +486,7 @@ describe('createAdminUsersHandlers', () => { expect(status).toHaveBeenCalledWith(404); expect(deps.deleteConfig).not.toHaveBeenCalled(); + expect(deps.deleteUserCodeEnvironments).not.toHaveBeenCalled(); expect(deps.deleteAclEntries).not.toHaveBeenCalled(); expect(deps.purgeAgentTriggerDeliveriesForUser).not.toHaveBeenCalled(); expect(deps.cancelAgentTriggerUserDeletion).toHaveBeenCalledWith( diff --git a/packages/api/src/admin/users.ts b/packages/api/src/admin/users.ts index 5a46d3c748..39e0243a79 100644 --- a/packages/api/src/admin/users.ts +++ b/packages/api/src/admin/users.ts @@ -46,6 +46,8 @@ export interface AdminUsersDeps { * A future iteration should consolidate the full cascade into a shared service function. */ deleteUserById: (userId: string) => Promise; + deleteUserCodeEnvironments: (userId: string | Types.ObjectId) => Promise; + invalidateCodeEnvironmentConfigCache: (tenantId?: string) => Promise; deleteConfig: ( principalType: PrincipalType, principalId: string | Types.ObjectId, @@ -71,6 +73,8 @@ export function createAdminUsersHandlers(deps: AdminUsersDeps): { cancelAgentTriggerUserPurge, purgeAgentTriggerDeliveriesForUser, deleteUserById, + deleteUserCodeEnvironments, + invalidateCodeEnvironmentConfigCache, deleteConfig, deleteAclEntries, } = deps; @@ -214,6 +218,7 @@ export function createAdminUsersHandlers(deps: AdminUsersDeps): { const objectId = new Types.ObjectId(id); const cleanupResults = await Promise.allSettled([ deleteConfig(PrincipalType.USER, id), + deleteUserCodeEnvironments(objectId), deleteAclEntries({ principalType: PrincipalType.USER, principalId: objectId }), ]); for (const r of cleanupResults) { @@ -221,6 +226,9 @@ export function createAdminUsersHandlers(deps: AdminUsersDeps): { logger.error('[adminUsers] cascade cleanup failed for user:', id, r.reason); } } + await invalidateCodeEnvironmentConfigCache(targetUser?.tenantId).catch((error: unknown) => { + logger.error('[adminUsers] code environment cache invalidation failed:', id, error); + }); return res.status(200).json({ message: result.message || 'User deleted successfully' }); } catch (error) { diff --git a/packages/api/src/app/service.spec.ts b/packages/api/src/app/service.spec.ts index 5cab1fd2a0..4283c633c5 100644 --- a/packages/api/src/app/service.spec.ts +++ b/packages/api/src/app/service.spec.ts @@ -464,6 +464,56 @@ describe('createAppConfigService', () => { }); }); + it('re-runs mutable principal config augmentation without rebuilding cached overrides', async () => { + const augmentConfig = jest.fn(async ({ appConfig, principals }) => ({ + ...appConfig, + principalCount: principals.length, + })); + const deps = createDeps({ augmentConfig }); + const { getAppConfig } = createAppConfigService(deps); + + const first = await getAppConfig({ role: 'USER', userId: 'uid1' }); + const second = await getAppConfig({ role: 'USER', userId: 'uid1' }); + + expect(first).toEqual(expect.objectContaining({ principalCount: 2 })); + expect(second).toEqual(expect.objectContaining({ principalCount: 2 })); + expect(deps.getUserPrincipals).toHaveBeenCalledTimes(2); + expect(deps.getApplicableConfigs).toHaveBeenCalledTimes(1); + expect(augmentConfig).toHaveBeenCalledTimes(2); + expect(augmentConfig).toHaveBeenCalledWith( + expect.objectContaining({ + baseConfig: deps._baseConfig, + principals: [ + { principalType: 'role', principalId: 'USER' }, + { principalType: 'user', principalId: 'uid1' }, + ], + options: expect.objectContaining({ role: 'USER', userId: 'uid1' }), + }), + ); + }); + + it('preserves resolved principal restrictions when optional augmentation fails', async () => { + const deps = createDeps({ + getApplicableConfigs: jest.fn().mockResolvedValue([ + { + priority: 10, + overrides: { endpoints: ['untrusted-override'] }, + isActive: true, + }, + ]), + augmentConfig: jest.fn().mockRejectedValue(new Error('authorization unavailable')), + }); + const { getAppConfig } = createAppConfigService(deps); + + const config = await getAppConfig({ role: 'USER', userId: 'uid1' }); + + expect(config).toEqual( + expect.objectContaining({ + endpoints: ['untrusted-override'], + }), + ); + }); + it('passes local identity through to getUserPrincipals when provided', async () => { const deps = createDeps(); const { getAppConfig } = createAppConfigService(deps); @@ -484,7 +534,7 @@ describe('createAppConfigService', () => { await getAppConfig({ role: 'USER', userId: 'uid1', idOnTheSource: null }); await getAppConfig({ role: 'USER', userId: 'uid1', idOnTheSource: 'source-user-1' }); - expect(deps.getUserPrincipals).toHaveBeenCalledTimes(1); + expect(deps.getUserPrincipals).toHaveBeenCalledTimes(2); expect(deps.getApplicableConfigs).toHaveBeenCalledTimes(1); expect([...deps._cache._store.keys()]).toEqual( expect.arrayContaining(['app_config:_OVERRIDE_:__default__:USER:uid1']), diff --git a/packages/api/src/app/service.ts b/packages/api/src/app/service.ts index 93cbfb957d..2f5d5043c8 100644 --- a/packages/api/src/app/service.ts +++ b/packages/api/src/app/service.ts @@ -59,6 +59,13 @@ export interface AppConfigServiceDeps { role?: string | null; idOnTheSource?: string | null; }) => Promise>; + /** Add mutable principal-scoped runtime configuration after cached overrides are resolved. */ + augmentConfig?: (context: { + appConfig: AppConfig; + baseConfig: AppConfig; + principals: Array<{ principalType: string; principalId?: string | Types.ObjectId }>; + options: GetAppConfigOptions; + }) => Promise; /** TTL in ms for per-user/role merged config caches. Defaults to 60 000. */ overrideCacheTtl?: number; } @@ -142,6 +149,7 @@ export function createAppConfigService(deps: AppConfigServiceDeps): { cacheKeys, getApplicableConfigs, getUserPrincipals, + augmentConfig, overrideCacheTtl = DEFAULT_OVERRIDE_CACHE_TTL, } = deps; @@ -214,14 +222,6 @@ export function createAppConfigService(deps: AppConfigServiceDeps): { return baseConfig; } - const cacheKey = overrideCacheKey(role, userId, tenantId); - if (!refresh) { - const cachedMerged = (await cache.get(cacheKey)) as AppConfig | undefined; - if (cachedMerged) { - return cachedMerged; - } - } - const principals = await buildPrincipals(role, userId, idOnTheSource).catch( (error: unknown) => { logger.error('[getAppConfig] Error building principals, falling back to base:', error); @@ -249,21 +249,37 @@ export function createAppConfigService(deps: AppConfigServiceDeps): { ); } + const augment = async (appConfig: AppConfig): Promise => { + if (augmentConfig == null) return appConfig; + try { + return await augmentConfig({ appConfig, baseConfig, principals, options }); + } catch (error) { + logger.error('[getAppConfig] Error augmenting principal config:', error); + return appConfig; + } + }; + + const cacheKey = overrideCacheKey(role, userId, tenantId); + if (!refresh) { + const cachedMerged = (await cache.get(cacheKey)) as AppConfig | undefined; + if (cachedMerged) { + return await augment(cachedMerged); + } + } + + let merged = baseConfig; try { const configs = await getApplicableConfigs(principals); - - if (configs.length === 0) { - await cache.set(cacheKey, baseConfig, overrideCacheTtl); - return baseConfig; + if (configs.length > 0) { + merged = materializeConfigModelSpecs(mergeConfigOverrides(baseConfig, configs)); } - - const merged = materializeConfigModelSpecs(mergeConfigOverrides(baseConfig, configs)); - await cache.set(cacheKey, merged, overrideCacheTtl); - return merged; } catch (error) { logger.error('[getAppConfig] Error resolving config overrides, falling back to base:', error); return baseConfig; } + + await cache.set(cacheKey, merged, overrideCacheTtl); + return await augment(merged); } /** diff --git a/packages/api/src/code/config.spec.ts b/packages/api/src/code/config.spec.ts new file mode 100644 index 0000000000..6f8bf71b0a --- /dev/null +++ b/packages/api/src/code/config.spec.ts @@ -0,0 +1,336 @@ +import { EModelEndpoint } from 'librechat-data-provider'; +import type { AppConfig } from '@librechat/data-schemas'; +import { mergeAccessibleCodeEnvironments } from './config'; + +describe('mergeAccessibleCodeEnvironments', () => { + test('adds principal environments without allowing them to shadow deployment entries', async () => { + const appConfig = { + endpoints: { + [EModelEndpoint.agents]: { + statefulCodeSessions: { + allowedEnvironments: ['conversation'], + environments: [ + { + id: 'deployment-vm', + name: 'Deployment VM', + type: 'attached', + baseURL: 'https://deployment.example', + owner: 'deployment', + pairing: { workerId: 'deployment-worker', tokenEnv: 'CODE_ADMIN_TOKEN' }, + }, + ], + }, + }, + }, + } as unknown as AppConfig; + const listAccessibleConfigurations = jest.fn().mockResolvedValue([ + { + id: 'personal-vm', + name: 'Personal VM', + type: 'attached', + baseURL: 'https://retired.example', + controlPlaneId: 'deployment-vm', + owner: 'principal', + }, + { + id: 'deployment-vm', + name: 'Shadow Attempt', + type: 'attached', + baseURL: 'https://shadow.example', + controlPlaneId: 'deployment-vm', + owner: 'principal', + }, + ]); + + const result = await mergeAccessibleCodeEnvironments({ + appConfig, + deploymentConfig: appConfig, + actor: { userId: '68b2f0c498f24c1e78fa0001', role: 'USER', idOnTheSource: null }, + registry: { + listAccessibleConfigurations, + listRegisteredIds: jest.fn().mockResolvedValue(['personal-vm', 'deployment-vm']), + }, + }); + + expect(result).not.toBe(appConfig); + expect(result.endpoints?.agents?.statefulCodeSessions?.environments).toEqual([ + expect.objectContaining({ id: 'deployment-vm', baseURL: 'https://deployment.example' }), + expect.objectContaining({ id: 'personal-vm', baseURL: 'https://deployment.example' }), + ]); + expect(appConfig.endpoints?.agents?.statefulCodeSessions?.environments).toHaveLength(1); + }); + + test('fails closed when a principal environment references a retired control plane', async () => { + const appConfig = { + endpoints: { + [EModelEndpoint.agents]: { + statefulCodeSessions: { + allowedEnvironments: ['conversation'], + environments: [], + }, + }, + }, + } as unknown as AppConfig; + const listAccessibleConfigurations = jest.fn().mockResolvedValue([ + { + id: 'personal-vm', + name: 'Personal VM', + type: 'attached', + baseURL: 'https://retired.example', + controlPlaneId: 'retired-plane', + owner: 'principal', + }, + ]); + + const result = await mergeAccessibleCodeEnvironments({ + appConfig, + deploymentConfig: appConfig, + actor: { userId: '68b2f0c498f24c1e78fa0001', role: 'USER', idOnTheSource: null }, + registry: { + listAccessibleConfigurations, + listRegisteredIds: jest.fn().mockResolvedValue(['personal-vm']), + }, + }); + + expect(result).toBe(appConfig); + }); + + test('resolves principal aliases only against the YAML deployment config', async () => { + const deploymentConfig = { + endpoints: { + [EModelEndpoint.agents]: { + statefulCodeSessions: { + environments: [ + { + id: 'approved-plane', + name: 'Approved Plane', + type: 'attached', + baseURL: 'https://approved.example', + owner: 'deployment', + pairing: { workerId: 'approved-worker', tokenEnv: 'CODE_ADMIN_TOKEN' }, + }, + ], + }, + }, + }, + } as unknown as AppConfig; + const appConfig = { + endpoints: { + [EModelEndpoint.agents]: { + statefulCodeSessions: { + environments: [ + { + id: 'approved-plane', + name: 'Override Plane', + type: 'attached', + baseURL: 'https://override.example', + owner: 'deployment', + pairing: { workerId: 'override-worker', tokenEnv: 'OVERRIDE_TOKEN' }, + }, + ], + }, + }, + }, + } as unknown as AppConfig; + const input = { + appConfig, + deploymentConfig, + actor: { userId: '68b2f0c498f24c1e78fa0001', role: 'USER', idOnTheSource: null }, + registry: { + listRegisteredIds: jest.fn().mockResolvedValue(['personal-vm']), + listAccessibleConfigurations: jest.fn().mockResolvedValue([ + { + id: 'personal-vm', + name: 'Personal VM', + type: 'attached', + baseURL: 'https://persisted.example', + controlPlaneId: 'approved-plane', + owner: 'principal', + }, + ]), + }, + }; + + const result = await mergeAccessibleCodeEnvironments(input); + const environments = result.endpoints?.agents?.statefulCodeSessions?.environments; + + expect(environments?.find((environment) => environment.id === 'personal-vm')?.baseURL).toBe( + 'https://approved.example', + ); + }); + + test('replaces a merged override that shadows an accessible principal environment', async () => { + const deploymentConfig = { + endpoints: { + [EModelEndpoint.agents]: { + statefulCodeSessions: { + environments: [ + { + id: 'approved-plane', + name: 'Approved Plane', + type: 'attached', + baseURL: 'https://approved.example', + owner: 'deployment', + pairing: { workerId: 'approved-worker', tokenEnv: 'CODE_ADMIN_TOKEN' }, + }, + ], + }, + }, + }, + } as unknown as AppConfig; + const appConfig = { + endpoints: { + [EModelEndpoint.agents]: { + statefulCodeSessions: { + environments: [ + { + id: 'personal-vm', + name: 'Shadow Override', + type: 'attached', + baseURL: 'https://shadow.example', + owner: 'deployment', + default: true, + pairing: { workerId: 'shadow-worker', tokenEnv: 'SHADOW_TOKEN' }, + }, + ], + }, + }, + }, + } as unknown as AppConfig; + + const result = await mergeAccessibleCodeEnvironments({ + appConfig, + deploymentConfig, + actor: { userId: '68b2f0c498f24c1e78fa0001', role: 'USER', idOnTheSource: null }, + registry: { + listRegisteredIds: jest.fn().mockResolvedValue(['personal-vm']), + listAccessibleConfigurations: jest.fn().mockResolvedValue([ + { + id: 'personal-vm', + name: 'Personal VM', + type: 'attached', + baseURL: 'https://persisted.example', + controlPlaneId: 'approved-plane', + owner: 'principal', + }, + ]), + }, + }); + const environments = result.endpoints?.agents?.statefulCodeSessions?.environments; + + expect(environments).toEqual([ + expect.objectContaining({ + id: 'personal-vm', + name: 'Personal VM', + baseURL: 'https://approved.example', + owner: 'principal', + default: true, + }), + ]); + }); + + test('suppresses a registered environment shadow after its ACL is revoked', async () => { + const appConfig = { + endpoints: { + [EModelEndpoint.agents]: { + statefulCodeSessions: { + environments: [ + { + id: 'revoked-vm', + name: 'Revoked Shadow', + type: 'attached', + baseURL: 'https://shadow.example', + owner: 'deployment', + default: true, + pairing: { workerId: 'shadow-worker', tokenEnv: 'SHADOW_TOKEN' }, + }, + { + id: 'unrelated-override', + name: 'Unrelated Override', + type: 'attached', + baseURL: 'https://unrelated.example', + owner: 'deployment', + pairing: { workerId: 'other-worker', tokenEnv: 'OTHER_TOKEN' }, + }, + ], + }, + }, + }, + } as unknown as AppConfig; + + const result = await mergeAccessibleCodeEnvironments({ + appConfig, + deploymentConfig: { + endpoints: { + [EModelEndpoint.agents]: { statefulCodeSessions: { environments: [] } }, + }, + } as unknown as AppConfig, + actor: { userId: '68b2f0c498f24c1e78fa0001', role: 'USER', idOnTheSource: null }, + registry: { + listAccessibleConfigurations: jest.fn().mockResolvedValue([]), + listRegisteredIds: jest.fn().mockResolvedValue(['revoked-vm']), + }, + }); + + expect(result.endpoints?.agents?.statefulCodeSessions?.environments).toEqual([ + expect.objectContaining({ id: 'unrelated-override', default: true }), + ]); + }); + + test('preserves unrelated restrictions while failing closed to deployment environments', async () => { + const deploymentEnvironment = { + id: 'approved-plane', + name: 'Approved Plane', + type: 'attached' as const, + baseURL: 'https://approved.example', + owner: 'deployment' as const, + default: true, + pairing: { workerId: 'approved-worker', tokenEnv: 'CODE_ADMIN_TOKEN' }, + }; + const deploymentConfig = { + interfaceConfig: { schedules: true }, + endpoints: { + [EModelEndpoint.agents]: { + statefulCodeSessions: { environments: [deploymentEnvironment] }, + }, + }, + } as unknown as AppConfig; + const appConfig = { + interfaceConfig: { schedules: false }, + endpoints: { + [EModelEndpoint.agents]: { + statefulCodeSessions: { + environments: [ + { + id: 'principal-shadow', + name: 'Principal Shadow', + type: 'attached', + baseURL: 'https://shadow.example', + owner: 'deployment', + default: true, + pairing: { workerId: 'shadow-worker', tokenEnv: 'SHADOW_TOKEN' }, + }, + ], + }, + }, + }, + } as unknown as AppConfig; + + const result = await mergeAccessibleCodeEnvironments({ + appConfig, + deploymentConfig, + actor: { userId: '68b2f0c498f24c1e78fa0001', role: 'USER', idOnTheSource: null }, + registry: { + listAccessibleConfigurations: jest + .fn() + .mockRejectedValue(new Error('authorization unavailable')), + listRegisteredIds: jest.fn().mockResolvedValue(['principal-shadow']), + }, + }); + + expect(result.interfaceConfig?.schedules).toBe(false); + expect(result.endpoints?.agents?.statefulCodeSessions?.environments).toEqual([ + deploymentEnvironment, + ]); + }); +}); diff --git a/packages/api/src/code/config.ts b/packages/api/src/code/config.ts new file mode 100644 index 0000000000..38a8e4088f --- /dev/null +++ b/packages/api/src/code/config.ts @@ -0,0 +1,141 @@ +import { logger } from '@librechat/data-schemas'; +import { EModelEndpoint } from 'librechat-data-provider'; +import type { AppConfig } from '@librechat/data-schemas'; +import type { + AccessibleCodeEnvironmentConfiguration, + CodeEnvironmentPrincipalContext, +} from './environments'; + +type ConfigurationRegistry = { + listAccessibleConfigurations: ( + actor: CodeEnvironmentPrincipalContext, + ) => Promise; + listRegisteredIds: () => Promise; +}; + +function retainDeploymentCodeEnvironments( + appConfig: AppConfig, + deploymentConfig: AppConfig, +): AppConfig { + const agents = appConfig.endpoints?.[EModelEndpoint.agents]; + const sessions = agents?.statefulCodeSessions; + if (sessions == null) return appConfig; + + return { + ...appConfig, + endpoints: { + ...appConfig.endpoints, + [EModelEndpoint.agents]: { + ...agents, + statefulCodeSessions: { + ...sessions, + environments: + deploymentConfig.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions + ?.environments ?? [], + }, + }, + }, + }; +} + +export async function mergeAccessibleCodeEnvironments({ + appConfig, + deploymentConfig, + actor, + registry, +}: { + appConfig: AppConfig; + deploymentConfig: AppConfig; + actor: CodeEnvironmentPrincipalContext; + registry: ConfigurationRegistry; +}): Promise { + const agents = appConfig.endpoints?.[EModelEndpoint.agents]; + const sessions = agents?.statefulCodeSessions; + if (sessions == null) return appConfig; + + let accessible: AccessibleCodeEnvironmentConfiguration[]; + let registeredIds: string[]; + try { + [accessible, registeredIds] = await Promise.all([ + registry.listAccessibleConfigurations(actor), + registry.listRegisteredIds(), + ]); + } catch (error) { + logger.error( + '[mergeAccessibleCodeEnvironments] Unable to authorize principal environments; retaining deployment environments:', + error, + ); + return retainDeploymentCodeEnvironments(appConfig, deploymentConfig); + } + const deploymentSessions = + deploymentConfig.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions; + const deploymentEnvironments = new Map( + deploymentSessions?.environments + ?.filter( + (environment) => + environment.owner === 'deployment' && + environment.type === 'attached' && + environment.pairing != null, + ) + .map((environment) => [environment.id, environment]) ?? [], + ); + const registeredAliasIds = new Set( + registeredIds.filter((environmentId) => !deploymentEnvironments.has(environmentId)), + ); + const principalEnvironments = accessible.flatMap( + ({ controlPlaneId, baseURL: _persistedBaseURL, ...environment }) => { + const controlPlane = deploymentEnvironments.get(controlPlaneId); + if (controlPlane == null || deploymentEnvironments.has(environment.id)) return []; + return [{ ...environment, baseURL: controlPlane.baseURL }]; + }, + ); + const principalEnvironmentIds = new Set( + principalEnvironments.map((environment) => environment.id), + ); + const shadowedDefaultIds = new Set( + (sessions.environments ?? []) + .filter( + (environment) => + environment.default === true && principalEnvironmentIds.has(environment.id), + ) + .map((environment) => environment.id), + ); + const effectivePrincipalEnvironments = principalEnvironments.map((environment) => + shadowedDefaultIds.has(environment.id) + ? { ...environment, default: true as const } + : environment, + ); + const existingEnvironments = sessions.environments ?? []; + const filteredEnvironments = existingEnvironments.filter( + (environment) => !registeredAliasIds.has(environment.id), + ); + if ( + effectivePrincipalEnvironments.length === 0 && + filteredEnvironments.length === existingEnvironments.length + ) { + return appConfig; + } + const mergedEnvironments = [...filteredEnvironments, ...effectivePrincipalEnvironments]; + if ( + mergedEnvironments.length > 0 && + !mergedEnvironments.some( + (environment) => 'default' in environment && environment.default === true, + ) + ) { + mergedEnvironments[0] = { ...mergedEnvironments[0], default: true }; + } + + return { + ...appConfig, + endpoints: { + ...appConfig.endpoints, + [EModelEndpoint.agents]: { + ...agents, + statefulCodeSessions: { + ...sessions, + environments: mergedEnvironments, + }, + }, + }, + }; +} diff --git a/packages/api/src/code/environments.integration.spec.ts b/packages/api/src/code/environments.integration.spec.ts new file mode 100644 index 0000000000..ae52b21bba --- /dev/null +++ b/packages/api/src/code/environments.integration.spec.ts @@ -0,0 +1,369 @@ +import mongoose, { Types } from 'mongoose'; +import { MongoMemoryServer } from 'mongodb-memory-server'; +import { createMethods, createModels } from '@librechat/data-schemas'; +import { AccessRoleIds, PrincipalType, ResourceType } from 'librechat-data-provider'; +import { AccessControlService } from '~/acl/accessControlService'; +import { createCodeEnvironmentRegistry } from './environments'; + +function createSharedCache() { + const values = new Map(); + return { + get: jest.fn(async (key: string) => values.get(key)), + set: jest.fn(async (key: string, value: unknown) => { + values.set(key, value); + return true; + }), + }; +} + +describe('code environment registry', () => { + let mongoServer: MongoMemoryServer; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + createModels(mongoose); + await Promise.all(Object.values(mongoose.models).map((model) => model.init())); + await createMethods(mongoose).seedDefaultRoles(); + }); + + afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + beforeEach(async () => { + await mongoose.connection.dropDatabase(); + await createMethods(mongoose).seedDefaultRoles(); + }); + + test('discovers a registered environment only for its owner principal', async () => { + const registry = createCodeEnvironmentRegistry(mongoose); + const ownerId = new Types.ObjectId(); + const strangerId = new Types.ObjectId(); + + const created = await registry.register({ + actor: { userId: ownerId, role: 'USER', idOnTheSource: null }, + environment: { + id: 'danny-vm', + name: "Danny's VM", + type: 'attached', + baseURL: 'https://code.example.com', + controlPlaneId: 'shared-code-api', + }, + }); + + expect(mongoose.models.CodeEnvironment.schema.path('controlPlaneId')).toBeDefined(); + expect(created).toEqual({ + resourceId: expect.any(String), + id: 'danny-vm', + name: "Danny's VM", + type: 'attached', + }); + await expect(registry.listRegisteredIds()).resolves.toEqual(['danny-vm']); + await expect( + registry.listAccessible({ userId: ownerId, role: 'USER', idOnTheSource: null }), + ).resolves.toEqual([created]); + await expect( + registry.listAccessible({ userId: strangerId, role: 'USER', idOnTheSource: null }), + ).resolves.toEqual([]); + await expect( + registry.listAccessibleConfigurations({ + userId: ownerId, + role: 'USER', + idOnTheSource: null, + }), + ).resolves.toEqual([ + { + id: 'danny-vm', + name: "Danny's VM", + type: 'attached', + baseURL: 'https://code.example.com', + controlPlaneId: 'shared-code-api', + owner: 'principal', + }, + ]); + }); + + test('discovers environments granted through role and group principals', async () => { + const registry = createCodeEnvironmentRegistry(mongoose); + const methods = createMethods(mongoose); + const access = new AccessControlService(mongoose); + const ownerId = new Types.ObjectId(); + const teammateId = new Types.ObjectId(); + const group = await methods.createGroup({ + name: 'Code Team', + source: 'local', + memberIds: [teammateId.toString()], + }); + const roleEnvironment = await registry.register({ + actor: { userId: ownerId, role: 'USER', idOnTheSource: null }, + environment: { + id: 'role-vm', + name: 'Role VM', + type: 'attached', + baseURL: 'https://code.example.com', + controlPlaneId: 'shared-code-api', + }, + }); + const groupEnvironment = await registry.register({ + actor: { userId: ownerId, role: 'USER', idOnTheSource: null }, + environment: { + id: 'group-vm', + name: 'Group VM', + type: 'attached', + baseURL: 'https://code.example.com', + controlPlaneId: 'shared-code-api', + }, + }); + + await access.grantPermission({ + principalType: PrincipalType.ROLE, + principalId: 'CODE_USER', + resourceType: ResourceType.CODE_ENVIRONMENT, + resourceId: roleEnvironment.resourceId, + accessRoleId: AccessRoleIds.CODE_ENVIRONMENT_VIEWER, + grantedBy: ownerId, + }); + await access.grantPermission({ + principalType: PrincipalType.GROUP, + principalId: group._id, + resourceType: ResourceType.CODE_ENVIRONMENT, + resourceId: groupEnvironment.resourceId, + accessRoleId: AccessRoleIds.CODE_ENVIRONMENT_VIEWER, + grantedBy: ownerId, + }); + + await expect( + registry.listAccessible({ + userId: teammateId, + role: 'CODE_USER', + idOnTheSource: null, + }), + ).resolves.toEqual([roleEnvironment, groupEnvironment]); + }); + + test('removes creator-owned environment records and grants when the user is deleted', async () => { + const registry = createCodeEnvironmentRegistry(mongoose); + const methods = createMethods(mongoose); + const ownerId = new Types.ObjectId(); + const environment = await registry.register({ + actor: { userId: ownerId, role: 'USER', idOnTheSource: null }, + environment: { + id: 'departing-user-vm', + name: 'Departing user VM', + type: 'attached', + baseURL: 'https://code.example.com', + controlPlaneId: 'shared-code-api', + }, + }); + + await expect(methods.deleteUserCodeEnvironments(ownerId)).resolves.toBe(1); + await expect( + registry.listAccessible({ userId: ownerId, role: 'USER', idOnTheSource: null }), + ).resolves.toEqual([]); + await expect( + mongoose.models.AclEntry.countDocuments({ + resourceType: ResourceType.CODE_ENVIRONMENT, + resourceId: environment.resourceId, + }), + ).resolves.toBe(0); + }); + + test('invalidates shared configuration caches after registration', async () => { + const cache = createSharedCache(); + const firstWorker = createCodeEnvironmentRegistry(mongoose, { configurationCache: cache }); + const secondWorker = createCodeEnvironmentRegistry(mongoose, { configurationCache: cache }); + const ownerId = new Types.ObjectId(); + const actor = { userId: ownerId, role: 'USER', idOnTheSource: null }; + + await expect(firstWorker.listAccessibleConfigurations(actor)).resolves.toEqual([]); + await secondWorker.register({ + actor, + environment: { + id: 'shared-cache-vm', + name: 'Shared cache VM', + type: 'attached', + baseURL: 'https://code.example.com', + controlPlaneId: 'shared-code-api', + }, + }); + + await expect(firstWorker.listAccessibleConfigurations(actor)).resolves.toEqual([ + expect.objectContaining({ id: 'shared-cache-vm' }), + ]); + }); + + test('caches registered environment ids behind the shared tenant revision', async () => { + const cache = createSharedCache(); + const firstWorker = createCodeEnvironmentRegistry(mongoose, { configurationCache: cache }); + const secondWorker = createCodeEnvironmentRegistry(mongoose, { configurationCache: cache }); + const distinct = jest.spyOn(mongoose.models.CodeEnvironment, 'distinct'); + const ownerId = new Types.ObjectId(); + + await expect(firstWorker.listRegisteredIds()).resolves.toEqual([]); + await expect(firstWorker.listRegisteredIds()).resolves.toEqual([]); + expect(distinct).toHaveBeenCalledTimes(1); + + await secondWorker.register({ + actor: { userId: ownerId, role: 'USER', idOnTheSource: null }, + environment: { + id: 'revision-cached-vm', + name: 'Revision Cached VM', + type: 'attached', + baseURL: 'https://code.example.com', + controlPlaneId: 'shared-code-api', + }, + }); + + await expect(firstWorker.listRegisteredIds()).resolves.toEqual(['revision-cached-vm']); + expect(distinct).toHaveBeenCalledTimes(2); + distinct.mockRestore(); + }); + + test('invalidates shared configuration caches after ACL revocation', async () => { + const cache = createSharedCache(); + const registry = createCodeEnvironmentRegistry(mongoose, { configurationCache: cache }); + const ownerId = new Types.ObjectId(); + const teammateId = new Types.ObjectId(); + const environment = await registry.register({ + actor: { userId: ownerId, role: 'USER', idOnTheSource: null }, + environment: { + id: 'revoked-vm', + name: 'Revoked VM', + type: 'attached', + baseURL: 'https://code.example.com', + controlPlaneId: 'shared-code-api', + }, + }); + const access = new AccessControlService(mongoose); + await access.grantPermission({ + principalType: PrincipalType.USER, + principalId: teammateId, + resourceType: ResourceType.CODE_ENVIRONMENT, + resourceId: environment.resourceId, + accessRoleId: AccessRoleIds.CODE_ENVIRONMENT_VIEWER, + grantedBy: ownerId, + }); + const teammate = { userId: teammateId, role: 'USER', idOnTheSource: null }; + await expect(registry.listAccessibleConfigurations(teammate)).resolves.toHaveLength(1); + + await mongoose.models.AclEntry.deleteMany({ + principalType: PrincipalType.USER, + principalId: teammateId, + resourceType: ResourceType.CODE_ENVIRONMENT, + resourceId: environment.resourceId, + }); + await registry.invalidateAccessibleConfigurations(); + + await expect(registry.listAccessibleConfigurations(teammate)).resolves.toEqual([]); + }); + + test('does not reuse revoked access when cache invalidation fails', async () => { + const cache = createSharedCache(); + const registry = createCodeEnvironmentRegistry(mongoose, { configurationCache: cache }); + const ownerId = new Types.ObjectId(); + const teammateId = new Types.ObjectId(); + const environment = await registry.register({ + actor: { userId: ownerId, role: 'USER', idOnTheSource: null }, + environment: { + id: 'fail-closed-vm', + name: 'Fail Closed VM', + type: 'attached', + baseURL: 'https://code.example.com', + controlPlaneId: 'shared-code-api', + }, + }); + const access = new AccessControlService(mongoose); + await access.grantPermission({ + principalType: PrincipalType.USER, + principalId: teammateId, + resourceType: ResourceType.CODE_ENVIRONMENT, + resourceId: environment.resourceId, + accessRoleId: AccessRoleIds.CODE_ENVIRONMENT_VIEWER, + grantedBy: ownerId, + }); + const teammate = { userId: teammateId, role: 'USER', idOnTheSource: null }; + await expect(registry.listAccessibleConfigurations(teammate)).resolves.toHaveLength(1); + + await mongoose.models.AclEntry.deleteMany({ + principalType: PrincipalType.USER, + principalId: teammateId, + resourceType: ResourceType.CODE_ENVIRONMENT, + resourceId: environment.resourceId, + }); + cache.set.mockRejectedValueOnce(new Error('redis unavailable')); + await expect(registry.invalidateAccessibleConfigurations()).rejects.toThrow( + 'redis unavailable', + ); + + await expect(registry.listAccessibleConfigurations(teammate)).resolves.toEqual([]); + }); + + test('does not reuse cached access after group membership changes', async () => { + const cache = createSharedCache(); + const registry = createCodeEnvironmentRegistry(mongoose, { configurationCache: cache }); + const methods = createMethods(mongoose); + const ownerId = new Types.ObjectId(); + const teammateId = new Types.ObjectId(); + const group = await methods.createGroup({ + name: 'Temporary Code Team', + source: 'local', + memberIds: [teammateId.toString()], + }); + const environment = await registry.register({ + actor: { userId: ownerId, role: 'USER', idOnTheSource: null }, + environment: { + id: 'temporary-group-vm', + name: 'Temporary Group VM', + type: 'attached', + baseURL: 'https://code.example.com', + controlPlaneId: 'shared-code-api', + }, + }); + const access = new AccessControlService(mongoose); + await access.grantPermission({ + principalType: PrincipalType.GROUP, + principalId: group._id, + resourceType: ResourceType.CODE_ENVIRONMENT, + resourceId: environment.resourceId, + accessRoleId: AccessRoleIds.CODE_ENVIRONMENT_VIEWER, + grantedBy: ownerId, + }); + const teammate = { userId: teammateId, role: 'USER', idOnTheSource: null }; + + await expect(registry.listAccessibleConfigurations(teammate)).resolves.toHaveLength(1); + await methods.updateGroupById(group._id, { memberIds: [] }); + await expect(registry.listAccessibleConfigurations(teammate)).resolves.toEqual([]); + }); + + test('rolls registration back when shared cache invalidation fails', async () => { + const registry = createCodeEnvironmentRegistry(mongoose, { + configurationCache: { + get: jest.fn(), + set: jest.fn().mockRejectedValue(new Error('redis unavailable')), + }, + }); + const ownerId = new Types.ObjectId(); + + await expect( + registry.register({ + actor: { userId: ownerId, role: 'USER', idOnTheSource: null }, + environment: { + id: 'rolled-back-vm', + name: 'Rolled Back VM', + type: 'attached', + baseURL: 'https://code.example.com', + controlPlaneId: 'shared-code-api', + }, + }), + ).rejects.toThrow('redis unavailable'); + + await expect( + mongoose.models.CodeEnvironment.countDocuments({ environmentId: 'rolled-back-vm' }), + ).resolves.toBe(0); + await expect( + mongoose.models.AclEntry.countDocuments({ resourceType: ResourceType.CODE_ENVIRONMENT }), + ).resolves.toBe(0); + }); +}); diff --git a/packages/api/src/code/environments.ts b/packages/api/src/code/environments.ts new file mode 100644 index 0000000000..aced59bb9d --- /dev/null +++ b/packages/api/src/code/environments.ts @@ -0,0 +1,316 @@ +import { Types } from 'mongoose'; +import { createHash, randomUUID } from 'node:crypto'; +import { createMethods, getTenantId, logger } from '@librechat/data-schemas'; +import { + AccessRoleIds, + PermissionBits, + PrincipalType, + ResourceType, + isSecureCodeEnvironmentControlURL, +} from 'librechat-data-provider'; +import type { ResolvedPrincipal } from '~/types/principal'; +import { AccessControlService } from '~/acl/accessControlService'; + +export type CodeEnvironmentPrincipalContext = { + userId: string | Types.ObjectId; + role?: string | null; + idOnTheSource?: string | null; + principals?: ResolvedPrincipal[]; +}; + +export type CodeEnvironmentSummary = { + resourceId: string; + id: string; + name: string; + type: 'managed' | 'attached'; +}; + +export type CodeEnvironmentRegistration = { + id: string; + name: string; + type: 'managed' | 'attached'; + baseURL: string; + controlPlaneId: string; + workerId?: string; +}; + +export type AccessibleCodeEnvironmentConfiguration = { + id: string; + name: string; + type: 'managed' | 'attached'; + baseURL: string; + controlPlaneId: string; + owner: 'principal'; +}; + +type CachedAccessibleCodeEnvironmentConfiguration = AccessibleCodeEnvironmentConfiguration & { + resourceId: string; +}; + +const ENVIRONMENT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const WORKER_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const CONFIGURATION_CACHE_TTL_MS = 5_000; +const CONFIGURATION_CACHE_REVISION_PREFIX = 'revision'; +const CONFIGURATION_CACHE_REGISTERED_PREFIX = 'registered'; +const CONFIGURATION_CACHE_USER_PREFIX = 'user'; + +type CodeEnvironmentConfigurationCache = { + get: (key: string) => Promise; + set: (key: string, value: unknown, ttl?: number) => Promise; +}; + +type CodeEnvironmentRegistryOptions = { + /** Shared cache only. Omit this dependency when Redis is unavailable so ACL reads stay live. */ + configurationCache?: CodeEnvironmentConfigurationCache; +}; + +export class CodeEnvironmentValidationError extends Error { + constructor(message: string) { + super(message); + this.name = 'CodeEnvironmentValidationError'; + } +} + +function normalizeRegistration(input: CodeEnvironmentRegistration): CodeEnvironmentRegistration { + const id = input.id.trim(); + const name = input.name.trim(); + const baseURL = input.baseURL.trim().replace(/\/+$/, ''); + const controlPlaneId = input.controlPlaneId.trim(); + const workerId = input.workerId?.trim(); + if (!ENVIRONMENT_ID_PATTERN.test(id)) { + throw new CodeEnvironmentValidationError('Code environment id is invalid'); + } + if (name.length < 1 || name.length > 100) { + throw new CodeEnvironmentValidationError( + 'Code environment name must contain between 1 and 100 characters', + ); + } + if (!isSecureCodeEnvironmentControlURL(baseURL)) { + throw new CodeEnvironmentValidationError('Code environment control requires secure transport'); + } + if (!ENVIRONMENT_ID_PATTERN.test(controlPlaneId)) { + throw new CodeEnvironmentValidationError('Code environment control plane id is invalid'); + } + if (workerId != null && !WORKER_ID_PATTERN.test(workerId)) { + throw new CodeEnvironmentValidationError('Code environment worker id is invalid'); + } + return { ...input, id, name, baseURL, controlPlaneId, workerId }; +} + +function toSummary(environment: { + _id: Types.ObjectId; + environmentId: string; + name: string; + type: 'managed' | 'attached'; +}): CodeEnvironmentSummary { + return { + resourceId: environment._id.toString(), + id: environment.environmentId, + name: environment.name, + type: environment.type, + }; +} + +export function createCodeEnvironmentRegistry( + mongoose: typeof import('mongoose'), + options: CodeEnvironmentRegistryOptions = {}, +): { + register: (params: { + actor: CodeEnvironmentPrincipalContext; + environment: CodeEnvironmentRegistration; + }) => Promise; + listAccessible: (actor: CodeEnvironmentPrincipalContext) => Promise; + listAccessibleConfigurations: ( + actor: CodeEnvironmentPrincipalContext, + ) => Promise; + listRegisteredIds: () => Promise; + invalidateAccessibleConfigurations: (tenantId?: string) => Promise; +} { + const methods = createMethods(mongoose); + const access = new AccessControlService(mongoose); + const configurationCache = options.configurationCache; + + function tenantCacheKey(tenantId?: string): string { + return encodeURIComponent(tenantId ?? getTenantId() ?? '__default__'); + } + + function revisionKey(tenantId?: string): string { + return `${CONFIGURATION_CACHE_REVISION_PREFIX}:${tenantCacheKey(tenantId)}`; + } + + async function invalidateAccessibleConfigurations(tenantId?: string): Promise { + if (configurationCache == null) return; + await configurationCache.set(revisionKey(tenantId), randomUUID()); + } + + async function listRegisteredIds(): Promise { + if (configurationCache == null) return await methods.listCodeEnvironmentIds(); + + const tenant = tenantCacheKey(); + const revision = String((await configurationCache.get(revisionKey())) ?? '0'); + const key = `${CONFIGURATION_CACHE_REGISTERED_PREFIX}:${tenant}:${revision}`; + const cached = await configurationCache.get(key); + if ( + Array.isArray(cached) && + cached.every((environmentId) => typeof environmentId === 'string') + ) { + return cached; + } + + const environmentIds = await methods.listCodeEnvironmentIds(); + const currentRevision = String((await configurationCache.get(revisionKey())) ?? '0'); + if (currentRevision !== revision) { + return await listRegisteredIds(); + } + await configurationCache.set(key, environmentIds, CONFIGURATION_CACHE_TTL_MS); + return environmentIds; + } + + async function register({ + actor, + environment: input, + }: { + actor: CodeEnvironmentPrincipalContext; + environment: CodeEnvironmentRegistration; + }): Promise { + const environment = normalizeRegistration(input); + const created = await methods.createCodeEnvironment({ + environmentId: environment.id, + name: environment.name, + type: environment.type, + baseURL: environment.baseURL, + controlPlaneId: environment.controlPlaneId, + workerId: environment.workerId, + createdBy: new Types.ObjectId(actor.userId), + }); + try { + const permission = await access.grantPermission({ + principalType: PrincipalType.USER, + principalId: actor.userId, + resourceType: ResourceType.CODE_ENVIRONMENT, + resourceId: created._id, + accessRoleId: AccessRoleIds.CODE_ENVIRONMENT_OWNER, + grantedBy: actor.userId, + }); + if (permission == null) { + throw new Error('Unable to grant code environment ownership'); + } + const summary = toSummary(created); + await invalidateAccessibleConfigurations(); + return summary; + } catch (error) { + const cleanup = await Promise.allSettled([ + access.removeAllPermissions({ + resourceType: ResourceType.CODE_ENVIRONMENT, + resourceId: created._id, + }), + methods.deleteCodeEnvironmentById(created._id), + ]); + for (const result of cleanup) { + if (result.status === 'rejected') { + logger.error('[codeEnvironments] registration rollback failed:', result.reason); + } + } + throw error; + } + } + + async function findAccessibleResourceIds(principals: ResolvedPrincipal[]) { + return await access.findAccessibleResourcesForPrincipals({ + principalsList: principals, + resourceType: ResourceType.CODE_ENVIRONMENT, + requiredPermissions: PermissionBits.VIEW, + }); + } + + async function findAccessible(actor: CodeEnvironmentPrincipalContext) { + const principals = actor.principals ?? (await methods.getUserPrincipals(actor)); + const ids = await findAccessibleResourceIds(principals); + return await methods.findCodeEnvironmentsByIds(ids); + } + + async function listAccessible( + actor: CodeEnvironmentPrincipalContext, + ): Promise { + const environments = await findAccessible(actor); + return environments.map(toSummary); + } + + async function listAccessibleConfigurations( + actor: CodeEnvironmentPrincipalContext, + ): Promise { + const principals = actor.principals ?? (await methods.getUserPrincipals(actor)); + const principalFingerprint = createHash('sha256') + .update( + principals + .map( + ({ principalType, principalId }) => `${principalType}:${principalId?.toString() ?? ''}`, + ) + .sort() + .join('\n'), + ) + .digest('base64url'); + const toPublicConfiguration = ({ + resourceId: _resourceId, + ...configuration + }: CachedAccessibleCodeEnvironmentConfiguration): AccessibleCodeEnvironmentConfiguration => + configuration; + const load = async (): Promise => { + const ids = await findAccessibleResourceIds(principals); + const environments = await methods.findCodeEnvironmentsByIds(ids); + return environments.map((environment) => ({ + resourceId: environment._id.toString(), + id: environment.environmentId, + name: environment.name, + type: environment.type, + baseURL: environment.baseURL, + controlPlaneId: environment.controlPlaneId, + owner: 'principal', + })); + }; + if (configurationCache == null) return (await load()).map(toPublicConfiguration); + + const tenant = tenantCacheKey(); + const revision = String((await configurationCache.get(revisionKey())) ?? '0'); + const key = + `${CONFIGURATION_CACHE_USER_PREFIX}:${tenant}:${actor.userId.toString()}:` + + `${principalFingerprint}:${revision}`; + const cached = await configurationCache.get(key); + if ( + Array.isArray(cached) && + cached.every( + (configuration) => + configuration != null && + typeof configuration === 'object' && + typeof (configuration as { resourceId?: unknown }).resourceId === 'string', + ) + ) { + // The cache accelerates configuration lookup, not authorization. Re-check the current + // ACL on every use so a failed revision write can delay grants but can never preserve a + // revocation. Entries written before resourceId was cached are deliberately treated as + // misses during rolling upgrades. + const accessibleIds = new Set( + (await findAccessibleResourceIds(principals)).map((id) => id.toString()), + ); + return (cached as CachedAccessibleCodeEnvironmentConfiguration[]) + .filter(({ resourceId }) => accessibleIds.has(resourceId)) + .map(toPublicConfiguration); + } + + const configurations = await load(); + const currentRevision = String((await configurationCache.get(revisionKey())) ?? '0'); + if (currentRevision !== revision) { + return await listAccessibleConfigurations(actor); + } + await configurationCache.set(key, configurations, CONFIGURATION_CACHE_TTL_MS); + return configurations.map(toPublicConfiguration); + } + + return { + register, + listAccessible, + listAccessibleConfigurations, + listRegisteredIds, + invalidateAccessibleConfigurations, + }; +} diff --git a/packages/api/src/code/http.spec.ts b/packages/api/src/code/http.spec.ts new file mode 100644 index 0000000000..b9b4df55e5 --- /dev/null +++ b/packages/api/src/code/http.spec.ts @@ -0,0 +1,177 @@ +import { EModelEndpoint } from 'librechat-data-provider'; +import type { AppConfig } from '@librechat/data-schemas'; +import { CodeEnvironmentValidationError } from './environments'; +import { createCodeEnvironmentHttpHandlers } from './http'; + +function response() { + const res = { + statusCode: 200, + body: undefined as unknown, + status(code: number) { + this.statusCode = code; + return this; + }, + json(body: unknown) { + this.body = body; + return this; + }, + }; + return res; +} + +describe('code environment HTTP handlers', () => { + test('returns 400 when registration has no request body', async () => { + const register = jest.fn(); + const handlers = createCodeEnvironmentHttpHandlers({ + getAppConfig: jest.fn(), + registry: { register, listAccessible: jest.fn() }, + }); + const res = response(); + + await handlers.register( + { user: { id: '68b2f0c498f24c1e78fa0001', role: 'USER' }, body: null } as never, + res as never, + ); + + expect(res.statusCode).toBe(400); + expect(register).not.toHaveBeenCalled(); + }); + + test('registers against an operator-configured control plane and ignores client URLs', async () => { + const register = jest.fn().mockResolvedValue({ + resourceId: '68b2f0c498f24c1e78fa0111', + id: 'personal-vm', + name: 'Personal VM', + type: 'attached', + }); + const appConfig = { + endpoints: { + [EModelEndpoint.agents]: { + statefulCodeSessions: { + allowedEnvironments: ['user'], + environments: [ + { + id: 'shared-code-api', + name: 'Shared Code API', + type: 'attached', + baseURL: 'https://code.librechat.example', + owner: 'deployment', + pairing: { workerId: 'deployment-worker', tokenEnv: 'CODE_ADMIN_TOKEN' }, + }, + ], + }, + }, + }, + } as AppConfig; + const handlers = createCodeEnvironmentHttpHandlers({ + getAppConfig: jest.fn().mockResolvedValue(appConfig), + registry: { register, listAccessible: jest.fn() }, + createEnvironmentId: () => 'personal-vm', + }); + const req = { + user: { id: '68b2f0c498f24c1e78fa0001', role: 'USER' }, + body: { + name: 'Personal VM', + controlPlaneId: 'shared-code-api', + workerId: 'attacker-worker', + baseURL: 'https://attacker.example', + }, + }; + const res = response(); + + await handlers.register(req as never, res as never); + + expect(res.statusCode).toBe(201); + expect(register).toHaveBeenCalledWith({ + actor: { + userId: '68b2f0c498f24c1e78fa0001', + role: 'USER', + idOnTheSource: null, + }, + environment: { + id: 'personal-vm', + name: 'Personal VM', + type: 'attached', + baseURL: 'https://code.librechat.example', + controlPlaneId: 'shared-code-api', + workerId: 'deployment-worker', + }, + }); + }); + + test('returns a generic 500 for operational registration failures', async () => { + const register = jest.fn().mockRejectedValue(new Error('mongo connection details')); + const handlers = createCodeEnvironmentHttpHandlers({ + getAppConfig: jest.fn().mockResolvedValue({ + endpoints: { + [EModelEndpoint.agents]: { + statefulCodeSessions: { + environments: [ + { + id: 'shared-code-api', + name: 'Shared Code API', + type: 'attached', + baseURL: 'https://code.librechat.example', + owner: 'deployment', + pairing: { workerId: 'deployment-worker' }, + }, + ], + }, + }, + }, + } as AppConfig), + registry: { register, listAccessible: jest.fn() }, + }); + const res = response(); + + await handlers.register( + { + user: { id: '68b2f0c498f24c1e78fa0001' }, + body: { name: 'Personal VM', controlPlaneId: 'shared-code-api' }, + } as never, + res as never, + ); + + expect(res.statusCode).toBe(500); + expect(res.body).toEqual({ error: 'Code environment registration failed' }); + }); + + test('returns 400 for validated registration input failures', async () => { + const register = jest + .fn() + .mockRejectedValue(new CodeEnvironmentValidationError('Code environment id is invalid')); + const handlers = createCodeEnvironmentHttpHandlers({ + getAppConfig: jest.fn().mockResolvedValue({ + endpoints: { + [EModelEndpoint.agents]: { + statefulCodeSessions: { + environments: [ + { + id: 'shared-code-api', + name: 'Shared Code API', + type: 'attached', + baseURL: 'https://code.librechat.example', + owner: 'deployment', + pairing: { workerId: 'deployment-worker' }, + }, + ], + }, + }, + }, + } as AppConfig), + registry: { register, listAccessible: jest.fn() }, + }); + const res = response(); + + await handlers.register( + { + user: { id: '68b2f0c498f24c1e78fa0001' }, + body: { name: 'Personal VM', controlPlaneId: 'shared-code-api' }, + } as never, + res as never, + ); + + expect(res.statusCode).toBe(400); + expect(res.body).toEqual({ error: 'Code environment id is invalid' }); + }); +}); diff --git a/packages/api/src/code/http.ts b/packages/api/src/code/http.ts new file mode 100644 index 0000000000..9cd7354488 --- /dev/null +++ b/packages/api/src/code/http.ts @@ -0,0 +1,129 @@ +import { nanoid } from 'nanoid'; +import { EModelEndpoint } from 'librechat-data-provider'; +import { logger, type AppConfig } from '@librechat/data-schemas'; +import type { Response } from 'express'; +import type { + CodeEnvironmentPrincipalContext, + CodeEnvironmentRegistration, + CodeEnvironmentSummary, +} from './environments'; +import type { GetAppConfigOptions } from '~/app/service'; +import type { ServerRequest } from '~/types/http'; +import { CodeEnvironmentValidationError } from './environments'; + +type Registry = { + register: (params: { + actor: CodeEnvironmentPrincipalContext; + environment: CodeEnvironmentRegistration; + }) => Promise; + listAccessible: (actor: CodeEnvironmentPrincipalContext) => Promise; +}; + +type StatefulCodeConfig = NonNullable< + NonNullable[EModelEndpoint.agents] +>['statefulCodeSessions']; +type ConfiguredCodeEnvironment = NonNullable< + NonNullable['environments'] +>[number]; + +export interface CodeEnvironmentHttpDeps { + getAppConfig: (options: GetAppConfigOptions) => Promise; + registry: Registry; + createEnvironmentId?: () => string; +} + +function actor(req: ServerRequest): CodeEnvironmentPrincipalContext | null { + if (!req.user?.id) return null; + return { + userId: req.user.id, + role: req.user.role ?? null, + idOnTheSource: req.user.idOnTheSource ?? null, + }; +} + +function configuredControlPlane( + appConfig: AppConfig, + controlPlaneId: string, +): ConfiguredCodeEnvironment | undefined { + return appConfig.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.environments?.find( + (environment) => + environment.id === controlPlaneId && + environment.type === 'attached' && + environment.owner === 'deployment' && + environment.pairing != null, + ); +} + +export function createCodeEnvironmentHttpHandlers(deps: CodeEnvironmentHttpDeps): { + list: (req: ServerRequest, res: Response) => Promise; + register: (req: ServerRequest, res: Response) => Promise; +} { + const createEnvironmentId = deps.createEnvironmentId ?? (() => `code-${nanoid(20)}`); + + async function list(req: ServerRequest, res: Response): Promise { + const principal = actor(req); + if (principal == null) { + return res.status(401).json({ error: 'Authentication required' }); + } + const environments = await deps.registry.listAccessible(principal); + return res.status(200).json({ environments }); + } + + async function register(req: ServerRequest, res: Response): Promise { + const principal = actor(req); + if (principal == null) { + return res.status(401).json({ error: 'Authentication required' }); + } + const body = + typeof req.body === 'object' && req.body != null + ? (req.body as unknown as Record) + : {}; + const name = typeof body.name === 'string' ? body.name.trim() : ''; + const controlPlaneId = + typeof body.controlPlaneId === 'string' ? body.controlPlaneId.trim() : ''; + if (!name || !controlPlaneId) { + return res.status(400).json({ + error: 'name and controlPlaneId are required', + }); + } + + /** Control-plane destinations are deployment policy. Client-provided URLs + * are deliberately ignored to prevent an authenticated SSRF primitive. */ + const appConfig = await deps.getAppConfig({ baseOnly: true }); + const controlPlane = configuredControlPlane(appConfig, controlPlaneId); + if (controlPlane == null) { + return res.status(404).json({ error: 'Code control plane was not found' }); + } + + try { + const environment = await deps.registry.register({ + actor: principal, + environment: { + id: createEnvironmentId(), + name, + type: 'attached', + baseURL: controlPlane.baseURL, + controlPlaneId, + workerId: controlPlane.pairing?.workerId, + }, + }); + return res.status(201).json({ environment }); + } catch (error) { + const duplicate = + typeof error === 'object' && + error != null && + 'code' in error && + (error as { code?: number }).code === 11000; + if (duplicate) { + return res.status(409).json({ error: 'Code environment already exists' }); + } + if (error instanceof CodeEnvironmentValidationError) { + return res.status(400).json({ error: error.message }); + } + logger.error('[codeEnvironments] registration failed:', error); + return res.status(500).json({ error: 'Code environment registration failed' }); + } + } + + return { list, register }; +} diff --git a/packages/api/src/code/index.ts b/packages/api/src/code/index.ts new file mode 100644 index 0000000000..494b7431a3 --- /dev/null +++ b/packages/api/src/code/index.ts @@ -0,0 +1,3 @@ +export * from './environments'; +export * from './http'; +export * from './config'; diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index ddd8cd516c..f78709aca8 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -5,6 +5,7 @@ export * from './artifacts'; /* Admin */ export * from './admin'; export * from './cdn'; +export * from './code'; /* Auth */ export * from './auth'; /* API Keys */ diff --git a/packages/api/src/middleware/share.ts b/packages/api/src/middleware/share.ts index 2d265308b6..bd7e05530a 100644 --- a/packages/api/src/middleware/share.ts +++ b/packages/api/src/middleware/share.ts @@ -44,6 +44,9 @@ type ShareMiddleware = ( const resourceToPermissionType: Record = { [ResourceType.AGENT]: PermissionTypes.AGENTS, + /** Environment sharing is capability-gated by MANAGE_CODE_ENVIRONMENTS. + * RUN_CODE intentionally has no public-sharing bit, so public grants remain denied. */ + [ResourceType.CODE_ENVIRONMENT]: PermissionTypes.RUN_CODE, [ResourceType.PROMPTGROUP]: PermissionTypes.PROMPTS, [ResourceType.MCPSERVER]: PermissionTypes.MCP_SERVERS, [ResourceType.REMOTE_AGENT]: PermissionTypes.REMOTE_AGENTS, diff --git a/packages/data-provider/specs/config-schemas.spec.ts b/packages/data-provider/specs/config-schemas.spec.ts index 33c4fc8ba1..4476ac2ce6 100644 --- a/packages/data-provider/specs/config-schemas.spec.ts +++ b/packages/data-provider/specs/config-schemas.spec.ts @@ -556,6 +556,49 @@ describe('agentsEndpointSchema', () => { } }); + it('accepts a principal-owned environment without deployment pairing metadata', () => { + const result = agentsEndpointSchema.safeParse({ + statefulCodeSessions: { + allowedEnvironments: ['user'], + environments: [ + { + id: 'personal-vm', + name: 'Personal VM', + type: 'attached', + baseURL: 'https://bridge.example.com/v1', + owner: 'principal', + default: true, + }, + ], + }, + }); + + expect(result.success).toBe(true); + }); + + it('rejects pairing metadata on a principal-owned environment', () => { + const result = agentsEndpointSchema.safeParse({ + statefulCodeSessions: { + allowedEnvironments: ['user'], + environments: [ + { + id: 'personal-vm', + name: 'Personal VM', + type: 'attached', + baseURL: 'https://bridge.example.com/v1', + owner: 'principal', + pairing: { + workerId: 'vm-1', + tokenEnv: 'CODE_BRIDGE_ADMIN_TOKEN', + }, + }, + ], + }, + }); + + expect(result.success).toBe(false); + }); + it('rejects pairing configuration for a managed environment', () => { const result = agentsEndpointSchema.safeParse({ statefulCodeSessions: { diff --git a/packages/data-provider/src/accessPermissions.ts b/packages/data-provider/src/accessPermissions.ts index 88f6ef954b..ebefb0f0d1 100644 --- a/packages/data-provider/src/accessPermissions.ts +++ b/packages/data-provider/src/accessPermissions.ts @@ -44,6 +44,7 @@ export type TAccessLevel = 'none' | 'viewer' | 'editor' | 'owner'; */ export enum ResourceType { AGENT = 'agent', + CODE_ENVIRONMENT = 'codeEnvironment', PROMPTGROUP = 'promptGroup', MCPSERVER = 'mcpServer', REMOTE_AGENT = 'remoteAgent', @@ -72,6 +73,9 @@ export enum AccessRoleIds { AGENT_VIEWER = 'agent_viewer', AGENT_EDITOR = 'agent_editor', AGENT_OWNER = 'agent_owner', + CODE_ENVIRONMENT_VIEWER = 'codeEnvironment_viewer', + CODE_ENVIRONMENT_EDITOR = 'codeEnvironment_editor', + CODE_ENVIRONMENT_OWNER = 'codeEnvironment_owner', PROMPTGROUP_VIEWER = 'promptGroup_viewer', PROMPTGROUP_EDITOR = 'promptGroup_editor', PROMPTGROUP_OWNER = 'promptGroup_owner', @@ -321,6 +325,7 @@ export function permBitsToAccessLevel(permBits: number): TAccessLevel { export function accessRoleToPermBits(accessRoleId: string): number { switch (accessRoleId) { case AccessRoleIds.AGENT_VIEWER: + case AccessRoleIds.CODE_ENVIRONMENT_VIEWER: case AccessRoleIds.PROMPTGROUP_VIEWER: case AccessRoleIds.MCPSERVER_VIEWER: case AccessRoleIds.REMOTE_AGENT_VIEWER: @@ -328,12 +333,14 @@ export function accessRoleToPermBits(accessRoleId: string): number { case AccessRoleIds.SHARED_LINK_VIEWER: return PermissionBits.VIEW; case AccessRoleIds.AGENT_EDITOR: + case AccessRoleIds.CODE_ENVIRONMENT_EDITOR: case AccessRoleIds.PROMPTGROUP_EDITOR: case AccessRoleIds.MCPSERVER_EDITOR: case AccessRoleIds.REMOTE_AGENT_EDITOR: case AccessRoleIds.SKILL_EDITOR: return PermissionBits.VIEW | PermissionBits.EDIT; case AccessRoleIds.AGENT_OWNER: + case AccessRoleIds.CODE_ENVIRONMENT_OWNER: case AccessRoleIds.PROMPTGROUP_OWNER: case AccessRoleIds.MCPSERVER_OWNER: case AccessRoleIds.REMOTE_AGENT_OWNER: diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index b0f7a97eef..42e39f10e5 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1084,9 +1084,9 @@ export const agentsEndpointSchema = baseEndpointSchema type: z.enum(['managed', 'attached']), baseURL: codeEnvironmentBaseURLSchema, default: z.boolean().optional(), - /** Ownership is explicit even though the first pairing control - * plane supports deployment-owned workers only. */ - owner: z.literal('deployment').optional().default('deployment'), + /** Distinguishes operator policy from a principal-authorized + * environment merged into request-scoped server config. */ + owner: z.enum(['deployment', 'principal']).optional().default('deployment'), /** Server-only enrollment metadata. `tokenEnv` names an * environment variable and never contains the token itself. */ pairing: z @@ -1111,6 +1111,13 @@ export const agentsEndpointSchema = baseEndpointSchema path: ['environments', environment.id, 'pairing'], }); } + if (environment.pairing != null && environment.owner !== 'deployment') { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Only deployment-owned code environments may configure pairing', + path: ['environments', environment.id, 'pairing'], + }); + } if ( environment.pairing != null && !isSecureCodeEnvironmentControlURL(environment.baseURL) diff --git a/packages/data-schemas/src/admin/capabilities.ts b/packages/data-schemas/src/admin/capabilities.ts index d9492655af..c820255275 100644 --- a/packages/data-schemas/src/admin/capabilities.ts +++ b/packages/data-schemas/src/admin/capabilities.ts @@ -179,6 +179,7 @@ export function expandImplications(directCaps: string[]): string[] { */ export const ResourceCapabilityMap: Record = { [ResourceType.AGENT]: SystemCapabilities.MANAGE_AGENTS, + [ResourceType.CODE_ENVIRONMENT]: SystemCapabilities.MANAGE_CODE_ENVIRONMENTS, [ResourceType.PROMPTGROUP]: SystemCapabilities.MANAGE_PROMPTS, [ResourceType.MCPSERVER]: SystemCapabilities.MANAGE_MCP_SERVERS, [ResourceType.REMOTE_AGENT]: SystemCapabilities.MANAGE_AGENTS, diff --git a/packages/data-schemas/src/methods/accessRole.spec.ts b/packages/data-schemas/src/methods/accessRole.spec.ts index ec9e97ed9b..41d6da4d82 100644 --- a/packages/data-schemas/src/methods/accessRole.spec.ts +++ b/packages/data-schemas/src/methods/accessRole.spec.ts @@ -1,6 +1,6 @@ import mongoose from 'mongoose'; -import { AccessRoleIds, ResourceType, PermissionBits } from 'librechat-data-provider'; import { MongoMemoryServer } from 'mongodb-memory-server'; +import { AccessRoleIds, ResourceType, PermissionBits } from 'librechat-data-provider'; import type * as t from '~/types'; import { createAccessRoleMethods } from './accessRole'; import accessRoleSchema from '~/schema/accessRole'; @@ -46,6 +46,17 @@ describe('AccessRole Model Tests', () => { expect(role.permBits).toBe(sampleRole.permBits); }); + test('should create a code environment role', async () => { + const role = await methods.createRole({ + accessRoleId: AccessRoleIds.CODE_ENVIRONMENT_OWNER, + name: 'Code Environment Owner', + resourceType: ResourceType.CODE_ENVIRONMENT, + permBits: PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE, + }); + + expect(role.resourceType).toBe(ResourceType.CODE_ENVIRONMENT); + }); + test('should find a role by its ID', async () => { const createdRole = await methods.createRole(sampleRole); const foundRole = await methods.findRoleById(createdRole._id); @@ -197,6 +208,9 @@ describe('AccessRole Model Tests', () => { AccessRoleIds.AGENT_EDITOR, AccessRoleIds.AGENT_OWNER, AccessRoleIds.AGENT_VIEWER, + AccessRoleIds.CODE_ENVIRONMENT_EDITOR, + AccessRoleIds.CODE_ENVIRONMENT_OWNER, + AccessRoleIds.CODE_ENVIRONMENT_VIEWER, AccessRoleIds.PROMPTGROUP_EDITOR, AccessRoleIds.PROMPTGROUP_OWNER, AccessRoleIds.PROMPTGROUP_VIEWER, diff --git a/packages/data-schemas/src/methods/accessRole.ts b/packages/data-schemas/src/methods/accessRole.ts index 63a49f3dba..0238d6c652 100644 --- a/packages/data-schemas/src/methods/accessRole.ts +++ b/packages/data-schemas/src/methods/accessRole.ts @@ -145,6 +145,27 @@ export function createAccessRoleMethods(mongoose: typeof import('mongoose')): { resourceType: ResourceType.AGENT, permBits: RoleBits.OWNER, }, + { + accessRoleId: AccessRoleIds.CODE_ENVIRONMENT_VIEWER, + name: 'com_ui_role_viewer', + description: 'com_ui_role_viewer_desc', + resourceType: ResourceType.CODE_ENVIRONMENT, + permBits: RoleBits.VIEWER, + }, + { + accessRoleId: AccessRoleIds.CODE_ENVIRONMENT_EDITOR, + name: 'com_ui_role_editor', + description: 'com_ui_role_editor_desc', + resourceType: ResourceType.CODE_ENVIRONMENT, + permBits: RoleBits.EDITOR, + }, + { + accessRoleId: AccessRoleIds.CODE_ENVIRONMENT_OWNER, + name: 'com_ui_role_owner', + description: 'com_ui_role_owner_desc', + resourceType: ResourceType.CODE_ENVIRONMENT, + permBits: RoleBits.OWNER, + }, { accessRoleId: AccessRoleIds.PROMPTGROUP_VIEWER, name: 'com_ui_role_viewer', diff --git a/packages/data-schemas/src/methods/codeEnvironment.ts b/packages/data-schemas/src/methods/codeEnvironment.ts new file mode 100644 index 0000000000..43eff6ec9e --- /dev/null +++ b/packages/data-schemas/src/methods/codeEnvironment.ts @@ -0,0 +1,89 @@ +import { Types } from 'mongoose'; +import { ResourceType } from 'librechat-data-provider'; +import type { Model } from 'mongoose'; +import type { CodeEnvironmentDocument } from '~/types'; +import type { IAclEntry } from '~/types'; + +type CreateCodeEnvironmentInput = Pick< + CodeEnvironmentDocument, + 'environmentId' | 'name' | 'type' | 'baseURL' | 'controlPlaneId' | 'createdBy' +> & + Pick, 'workerId'>; + +export function createCodeEnvironmentMethods(mongoose: typeof import('mongoose')): { + createCodeEnvironment: (input: CreateCodeEnvironmentInput) => Promise; + findCodeEnvironmentsByIds: ( + ids: Array, + ) => Promise; + findCodeEnvironmentByEnvironmentId: ( + environmentId: string, + ) => Promise; + listCodeEnvironmentIds: () => Promise; + deleteCodeEnvironmentById: ( + id: string | Types.ObjectId, + ) => Promise; + deleteUserCodeEnvironments: (userId: string | Types.ObjectId) => Promise; +} { + const model = () => mongoose.models.CodeEnvironment as Model; + + async function createCodeEnvironment( + input: CreateCodeEnvironmentInput, + ): Promise { + return (await model().create(input)).toObject() as CodeEnvironmentDocument; + } + + async function findCodeEnvironmentsByIds( + ids: Array, + ): Promise { + if (ids.length === 0) return []; + return await model() + .find({ _id: { $in: ids } }) + .sort({ createdAt: 1, _id: 1 }) + .lean(); + } + + async function findCodeEnvironmentByEnvironmentId( + environmentId: string, + ): Promise { + return await model().findOne({ environmentId }).lean(); + } + + async function listCodeEnvironmentIds(): Promise { + return await model().distinct('environmentId'); + } + + async function deleteCodeEnvironmentById( + id: string | Types.ObjectId, + ): Promise { + return await model().findByIdAndDelete(id).lean(); + } + + /** User-attached environments represent processes enrolled by their creator. + * Delete the registry records and every grant when that user is removed; + * sharing an environment does not transfer its lifecycle ownership. */ + async function deleteUserCodeEnvironments(userId: string | Types.ObjectId): Promise { + const creatorId = typeof userId === 'string' ? new Types.ObjectId(userId) : userId; + const environments = await model().find({ createdBy: creatorId }).select('_id').lean(); + if (environments.length === 0) return 0; + + const resourceIds = environments.map((environment) => environment._id); + const AclEntry = mongoose.models.AclEntry as Model; + await AclEntry.deleteMany({ + resourceType: ResourceType.CODE_ENVIRONMENT, + resourceId: { $in: resourceIds }, + }); + const result = await model().deleteMany({ _id: { $in: resourceIds } }); + return result.deletedCount ?? 0; + } + + return { + createCodeEnvironment, + findCodeEnvironmentsByIds, + findCodeEnvironmentByEnvironmentId, + listCodeEnvironmentIds, + deleteCodeEnvironmentById, + deleteUserCodeEnvironments, + }; +} + +export type CodeEnvironmentMethods = ReturnType; diff --git a/packages/data-schemas/src/methods/index.ts b/packages/data-schemas/src/methods/index.ts index 65fb1a1a7d..8e959a7daa 100644 --- a/packages/data-schemas/src/methods/index.ts +++ b/packages/data-schemas/src/methods/index.ts @@ -27,6 +27,7 @@ import { createAgentCategoryMethods, type AgentCategoryMethods } from './agentCa import { createAgentApiKeyMethods, type AgentApiKeyMethods } from './agentApiKey'; /* MCP Servers */ import { createMCPServerMethods, type MCPServerMethods } from './mcpServer'; +import { createCodeEnvironmentMethods, type CodeEnvironmentMethods } from './codeEnvironment'; /* Plugin Auth */ import { createPluginAuthMethods, type PluginAuthMethods } from './pluginAuth'; /* Permissions */ @@ -204,6 +205,7 @@ export type AllMethods = UserMethods & AgentCategoryMethods & AgentApiKeyMethods & MCPServerMethods & + CodeEnvironmentMethods & UserGroupMethods & AclEntryMethods & SystemGrantMethods & @@ -354,6 +356,7 @@ export function createMethods( ...createAgentCategoryMethods(mongoose), ...createAgentApiKeyMethods(mongoose), ...createMCPServerMethods(mongoose), + ...createCodeEnvironmentMethods(mongoose), ...createAccessRoleMethods(mongoose), ...userGroupMethods, ...aclEntryMethods, @@ -408,6 +411,7 @@ export type { AgentCategoryMethods, AgentApiKeyMethods, MCPServerMethods, + CodeEnvironmentMethods, UserGroupMethods, AclEntryMethods, SystemGrantMethods, diff --git a/packages/data-schemas/src/models/codeEnvironment.ts b/packages/data-schemas/src/models/codeEnvironment.ts new file mode 100644 index 0000000000..0af6827039 --- /dev/null +++ b/packages/data-schemas/src/models/codeEnvironment.ts @@ -0,0 +1,14 @@ +import type { Model } from 'mongoose'; +import type { CodeEnvironmentDocument } from '~/types'; +import { applyTenantIsolation } from '~/models/plugins/tenantIsolation'; +import codeEnvironmentSchema from '~/schema/codeEnvironment'; + +export function createCodeEnvironmentModel( + mongoose: typeof import('mongoose'), +): Model { + applyTenantIsolation(codeEnvironmentSchema); + return ( + mongoose.models.CodeEnvironment || + mongoose.model('CodeEnvironment', codeEnvironmentSchema) + ); +} diff --git a/packages/data-schemas/src/models/index.ts b/packages/data-schemas/src/models/index.ts index ed68a2fd43..ecf2f610e3 100644 --- a/packages/data-schemas/src/models/index.ts +++ b/packages/data-schemas/src/models/index.ts @@ -7,6 +7,7 @@ import { createRefreshTokenBridgeModel } from './refreshTokenBridge'; import { createAgentTriggerDeliveryModel } from './triggerDelivery'; import { createSkillSyncStatusModel } from './skillSyncStatus'; import { createConversationTagModel } from './conversationTag'; +import { createCodeEnvironmentModel } from './codeEnvironment'; import { createAgentCategoryModel } from './agentCategory'; import { createChatProjectModel } from './chatProject'; import { createAgentApiKeyModel } from './agentApiKey'; @@ -53,6 +54,7 @@ export function createModels(mongoose: typeof import('mongoose')): { Balance: ReturnType; Conversation: ReturnType; ChatProject: ReturnType; + CodeEnvironment: ReturnType; Message: ReturnType; Agent: ReturnType; AgentApiKey: ReturnType; @@ -99,6 +101,7 @@ export function createModels(mongoose: typeof import('mongoose')): { Balance: createBalanceModel(mongoose), Conversation: createConversationModel(mongoose), ChatProject: createChatProjectModel(mongoose), + CodeEnvironment: createCodeEnvironmentModel(mongoose), Message: createMessageModel(mongoose), Agent: createAgentModel(mongoose), AgentApiKey: createAgentApiKeyModel(mongoose), diff --git a/packages/data-schemas/src/schema/accessRole.ts b/packages/data-schemas/src/schema/accessRole.ts index b0a07a7c3e..68039e9eaa 100644 --- a/packages/data-schemas/src/schema/accessRole.ts +++ b/packages/data-schemas/src/schema/accessRole.ts @@ -17,6 +17,7 @@ const accessRoleSchema: Schema = new Schema( type: String, enum: [ 'agent', + 'codeEnvironment', 'project', 'file', 'promptGroup', diff --git a/packages/data-schemas/src/schema/codeEnvironment.ts b/packages/data-schemas/src/schema/codeEnvironment.ts new file mode 100644 index 0000000000..13786c63cd --- /dev/null +++ b/packages/data-schemas/src/schema/codeEnvironment.ts @@ -0,0 +1,47 @@ +import { Schema } from 'mongoose'; +import type { CodeEnvironmentDocument } from '~/types'; + +const codeEnvironmentSchema: Schema = new Schema( + { + environmentId: { + type: String, + required: true, + }, + name: { + type: String, + required: true, + }, + type: { + type: String, + enum: ['managed', 'attached'], + required: true, + }, + baseURL: { + type: String, + required: true, + }, + controlPlaneId: { + type: String, + required: true, + }, + createdBy: { + type: Schema.Types.ObjectId, + ref: 'User', + required: true, + index: true, + }, + workerId: { + type: String, + }, + tenantId: { + type: String, + index: true, + }, + }, + { timestamps: true }, +); + +codeEnvironmentSchema.index({ environmentId: 1, tenantId: 1 }, { unique: true }); +codeEnvironmentSchema.index({ updatedAt: -1, _id: 1 }); + +export default codeEnvironmentSchema; diff --git a/packages/data-schemas/src/schema/index.ts b/packages/data-schemas/src/schema/index.ts index 210a765b90..966d49bf87 100644 --- a/packages/data-schemas/src/schema/index.ts +++ b/packages/data-schemas/src/schema/index.ts @@ -8,6 +8,7 @@ export { default as balanceSchema } from './balance'; export { default as bannerSchema } from './banner'; export { default as categoriesSchema } from './categories'; export { default as chatProjectSchema } from './chatProject'; +export { default as codeEnvironmentSchema } from './codeEnvironment'; export { default as conversationTagSchema } from './conversationTag'; export { default as convoSchema } from './convo'; export { default as fileSchema } from './file'; diff --git a/packages/data-schemas/src/types/codeEnvironment.ts b/packages/data-schemas/src/types/codeEnvironment.ts new file mode 100644 index 0000000000..966e7e4f26 --- /dev/null +++ b/packages/data-schemas/src/types/codeEnvironment.ts @@ -0,0 +1,19 @@ +import type { Document, Types } from 'mongoose'; + +export type CodeEnvironment = { + environmentId: string; + name: string; + type: 'managed' | 'attached'; + baseURL: string; + controlPlaneId: string; + createdBy: Types.ObjectId; + workerId?: string; + tenantId?: string; + createdAt: Date; + updatedAt: Date; +}; + +export type CodeEnvironmentDocument = CodeEnvironment & + Document & { + _id: Types.ObjectId; + }; diff --git a/packages/data-schemas/src/types/index.ts b/packages/data-schemas/src/types/index.ts index f80edb70f6..6d5aa1fbcf 100644 --- a/packages/data-schemas/src/types/index.ts +++ b/packages/data-schemas/src/types/index.ts @@ -18,6 +18,7 @@ export * from './message'; export * from './agent'; export * from './agentApiKey'; export * from './agentCategory'; +export * from './codeEnvironment'; export * from './role'; export * from './action'; export * from './assistant';