From 43b8f507e0da322832ef81eb5a003764caa5043a Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 30 Aug 2026 12:04:53 -0400 Subject: [PATCH 01/16] feat: add principal-owned code environments --- api/server/controllers/UserController.js | 1 + api/server/controllers/UserController.spec.js | 1 + .../controllers/__tests__/deleteUser.spec.js | 2 + .../deleteUserResourceCoverage.spec.js | 1 + api/server/experimental.js | 1 + api/server/index.js | 1 + api/server/middleware/config/app.js | 39 ++++- api/server/routes/accessPermissions.js | 6 + api/server/routes/code-environments.js | 21 +++ api/server/routes/code-environments.test.js | 67 ++++++++ api/server/routes/index.js | 2 + e2e/specs/mock/stateful-code-bridge.spec.ts | 79 ++++++++- packages/api/src/code/config.spec.ts | 52 ++++++ packages/api/src/code/config.ts | 48 ++++++ .../src/code/environments.integration.spec.ts | 153 +++++++++++++++++ packages/api/src/code/environments.ts | 155 ++++++++++++++++++ packages/api/src/code/http.spec.ts | 82 +++++++++ packages/api/src/code/http.ts | 118 +++++++++++++ packages/api/src/code/index.ts | 3 + packages/api/src/index.ts | 1 + packages/api/src/middleware/share.ts | 3 + .../data-provider/src/accessPermissions.ts | 7 + .../data-schemas/src/admin/capabilities.ts | 1 + .../data-schemas/src/methods/accessRole.ts | 21 +++ .../src/methods/codeEnvironment.ts | 83 ++++++++++ packages/data-schemas/src/methods/index.ts | 4 + .../src/models/codeEnvironment.ts | 14 ++ packages/data-schemas/src/models/index.ts | 3 + .../src/schema/codeEnvironment.ts | 43 +++++ packages/data-schemas/src/schema/index.ts | 1 + .../data-schemas/src/types/codeEnvironment.ts | 18 ++ packages/data-schemas/src/types/index.ts | 1 + 32 files changed, 1027 insertions(+), 5 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/UserController.js b/api/server/controllers/UserController.js index d41e27a072f..480fb0f3d00 100644 --- a/api/server/controllers/UserController.js +++ b/api/server/controllers/UserController.js @@ -460,6 +460,7 @@ 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 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 f6fbf8cdbd5..59c102b6639 100644 --- a/api/server/controllers/UserController.spec.js +++ b/api/server/controllers/UserController.spec.js @@ -43,6 +43,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), diff --git a/api/server/controllers/__tests__/deleteUser.spec.js b/api/server/controllers/__tests__/deleteUser.spec.js index c64a40de8d1..e012819e42c 100644 --- a/api/server/controllers/__tests__/deleteUser.spec.js +++ b/api/server/controllers/__tests__/deleteUser.spec.js @@ -19,6 +19,7 @@ const mockDeleteToolCalls = jest.fn(); const mockDeleteUserAgents = jest.fn(); const mockDeleteUserPrompts = jest.fn(); const mockDeleteUserSkills = jest.fn(); +const mockDeleteUserCodeEnvironments = jest.fn(); const mockGetCleanupBlockingJobIdsForUser = jest.fn(); const mockAbortJob = jest.fn(); const mockDrainAgentTriggerDeliveriesForUser = jest.fn(); @@ -77,6 +78,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(), diff --git a/api/server/controllers/__tests__/deleteUserResourceCoverage.spec.js b/api/server/controllers/__tests__/deleteUserResourceCoverage.spec.js index 1bd5b2efaa5..a0806d6e3cd 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 ff653bd9735..ce21fae0ddc 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 e12f213f1e9..5f50c0d04c8 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/middleware/config/app.js b/api/server/middleware/config/app.js index 3768089b249..ab3cafce421 100644 --- a/api/server/middleware/config/app.js +++ b/api/server/middleware/config/app.js @@ -1,10 +1,40 @@ const { logger } = require('@librechat/data-schemas'); -const { getAppConfigOptionsFromUser } = require('@librechat/api'); +const mongoose = require('mongoose'); +const { + createCodeEnvironmentRegistry, + getAppConfigOptionsFromUser, + mergeAccessibleCodeEnvironments, +} = require('@librechat/api'); const { getAppConfig } = require('~/server/services/Config'); +const codeEnvironmentRegistry = createCodeEnvironmentRegistry(mongoose); + +async function addPrincipalCodeEnvironments(req, appConfig) { + if (!req.user?.id) return appConfig; + try { + return await mergeAccessibleCodeEnvironments({ + appConfig, + actor: { + userId: req.user.id, + role: req.user.role ?? null, + idOnTheSource: req.user.idOnTheSource ?? null, + }, + registry: codeEnvironmentRegistry, + }); + } catch (error) { + /** Database-backed environments fail closed while deployment-owned YAML + * environments remain available. */ + logger.warn('[configMiddleware] Failed to load principal code environments', error); + return appConfig; + } +} + const configMiddleware = async (req, res, next) => { try { - req.config = await getAppConfig(getAppConfigOptionsFromUser(req.user)); + req.config = await addPrincipalCodeEnvironments( + req, + await getAppConfig(getAppConfigOptionsFromUser(req.user)), + ); next(); } catch (error) { @@ -15,7 +45,10 @@ const configMiddleware = async (req, res, next) => { }); try { - req.config = await getAppConfig({ tenantId: req.user?.tenantId }); + req.config = await addPrincipalCodeEnvironments( + req, + await getAppConfig({ tenantId: req.user?.tenantId }), + ); next(); } catch (fallbackError) { logger.error('Fallback config middleware error:', fallbackError); diff --git a/api/server/routes/accessPermissions.js b/api/server/routes/accessPermissions.js index 6ef731daba7..311f0593007 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/code-environments.js b/api/server/routes/code-environments.js new file mode 100644 index 00000000000..6a7ebdbde74 --- /dev/null +++ b/api/server/routes/code-environments.js @@ -0,0 +1,21 @@ +const express = require('express'); +const mongoose = require('mongoose'); +const { + createCodeEnvironmentRegistry, + createCodeEnvironmentHttpHandlers, +} = require('@librechat/api'); +const { SystemCapabilities } = require('@librechat/data-schemas'); +const { requireCapability } = require('~/server/middleware/roles/capabilities'); +const { getAppConfig } = require('~/server/services/Config'); +const { requireJwtAuth } = require('~/server/middleware'); + +const router = express.Router(); +const registry = createCodeEnvironmentRegistry(mongoose); +const handlers = createCodeEnvironmentHttpHandlers({ getAppConfig, registry }); +const requireCodeEnvironmentManage = requireCapability(SystemCapabilities.MANAGE_CODE_ENVIRONMENTS); + +router.use(requireJwtAuth); +router.get('/', handlers.list); +router.post('/', requireCodeEnvironmentManage, handlers.register); + +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 00000000000..5dbd86f0127 --- /dev/null +++ b/api/server/routes/code-environments.test.js @@ -0,0 +1,67 @@ +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 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() })); + +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('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/index.js b/api/server/routes/index.js index a22b065e7a4..c3b6c4bb8e7 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/e2e/specs/mock/stateful-code-bridge.spec.ts b/e2e/specs/mock/stateful-code-bridge.spec.ts index f3b0bcd556e..32e302b3f2d 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/code/config.spec.ts b/packages/api/src/code/config.spec.ts new file mode 100644 index 00000000000..b9e4714006e --- /dev/null +++ b/packages/api/src/code/config.spec.ts @@ -0,0 +1,52 @@ +import type { AppConfig } from '@librechat/data-schemas'; +import { EModelEndpoint } from 'librechat-data-provider'; +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', + }, + ], + }, + }, + }, + } as AppConfig; + const listAccessibleConfigurations = jest.fn().mockResolvedValue([ + { + id: 'personal-vm', + name: 'Personal VM', + type: 'attached', + baseURL: 'https://personal.example', + }, + { + id: 'deployment-vm', + name: 'Shadow Attempt', + type: 'attached', + baseURL: 'https://shadow.example', + }, + ]); + + const result = await mergeAccessibleCodeEnvironments({ + appConfig, + actor: { userId: '68b2f0c498f24c1e78fa0001', role: 'USER', idOnTheSource: null }, + registry: { listAccessibleConfigurations }, + }); + + 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://personal.example' }), + ]); + expect(appConfig.endpoints?.agents?.statefulCodeSessions?.environments).toHaveLength(1); + }); +}); diff --git a/packages/api/src/code/config.ts b/packages/api/src/code/config.ts new file mode 100644 index 00000000000..44c68e451ac --- /dev/null +++ b/packages/api/src/code/config.ts @@ -0,0 +1,48 @@ +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; +}; + +export async function mergeAccessibleCodeEnvironments({ + appConfig, + actor, + registry, +}: { + appConfig: AppConfig; + actor: CodeEnvironmentPrincipalContext; + registry: ConfigurationRegistry; +}): Promise { + const agents = appConfig.endpoints?.[EModelEndpoint.agents]; + const sessions = agents?.statefulCodeSessions; + if (sessions == null) return appConfig; + + const accessible = await registry.listAccessibleConfigurations(actor); + if (accessible.length === 0) return appConfig; + const deploymentIds = new Set(sessions.environments?.map((environment) => environment.id) ?? []); + const principalEnvironments = accessible.filter( + (environment) => !deploymentIds.has(environment.id), + ); + if (principalEnvironments.length === 0) return appConfig; + + return { + ...appConfig, + endpoints: { + ...appConfig.endpoints, + [EModelEndpoint.agents]: { + ...agents, + statefulCodeSessions: { + ...sessions, + environments: [...(sessions.environments ?? []), ...principalEnvironments], + }, + }, + }, + }; +} 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 00000000000..4ba1be5c04f --- /dev/null +++ b/packages/api/src/code/environments.integration.spec.ts @@ -0,0 +1,153 @@ +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'; + +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', + }, + }); + + expect(created).toEqual({ + resourceId: expect.any(String), + id: 'danny-vm', + name: "Danny's VM", + type: 'attached', + }); + 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', + }, + ]); + }); + + 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', + }, + }); + 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', + }, + }); + + 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', + }, + }); + + 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); + }); +}); diff --git a/packages/api/src/code/environments.ts b/packages/api/src/code/environments.ts new file mode 100644 index 00000000000..59ee7ce6995 --- /dev/null +++ b/packages/api/src/code/environments.ts @@ -0,0 +1,155 @@ +import { Types } from 'mongoose'; +import { createMethods } from '@librechat/data-schemas'; +import { + AccessRoleIds, + PermissionBits, + PrincipalType, + ResourceType, + isSecureCodeEnvironmentControlURL, +} from 'librechat-data-provider'; +import { AccessControlService } from '~/acl/accessControlService'; + +export type CodeEnvironmentPrincipalContext = { + userId: string | Types.ObjectId; + role?: string | null; + idOnTheSource?: string | null; +}; + +export type CodeEnvironmentSummary = { + resourceId: string; + id: string; + name: string; + type: 'managed' | 'attached'; +}; + +export type CodeEnvironmentRegistration = { + id: string; + name: string; + type: 'managed' | 'attached'; + baseURL: string; + workerId?: string; +}; + +export type AccessibleCodeEnvironmentConfiguration = { + id: string; + name: string; + type: 'managed' | 'attached'; + baseURL: 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}$/; + +function normalizeRegistration(input: CodeEnvironmentRegistration): CodeEnvironmentRegistration { + const id = input.id.trim(); + const name = input.name.trim(); + const baseURL = input.baseURL.trim().replace(/\/+$/, ''); + const workerId = input.workerId?.trim(); + if (!ENVIRONMENT_ID_PATTERN.test(id)) { + throw new Error('Code environment id is invalid'); + } + if (name.length < 1 || name.length > 100) { + throw new Error('Code environment name must contain between 1 and 100 characters'); + } + if (!isSecureCodeEnvironmentControlURL(baseURL)) { + throw new Error('Code environment control requires secure transport'); + } + if (workerId != null && !WORKER_ID_PATTERN.test(workerId)) { + throw new Error('Code environment worker id is invalid'); + } + return { ...input, id, name, baseURL, 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')): { + register: (params: { + actor: CodeEnvironmentPrincipalContext; + environment: CodeEnvironmentRegistration; + }) => Promise; + listAccessible: (actor: CodeEnvironmentPrincipalContext) => Promise; + listAccessibleConfigurations: ( + actor: CodeEnvironmentPrincipalContext, + ) => Promise; +} { + const methods = createMethods(mongoose); + const access = new AccessControlService(mongoose); + + 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, + 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'); + } + return toSummary(created); + } catch (error) { + await methods.deleteCodeEnvironmentById(created._id); + throw error; + } + } + + async function findAccessible(actor: CodeEnvironmentPrincipalContext) { + const principals = await methods.getUserPrincipals(actor); + const ids = await access.findAccessibleResourcesForPrincipals({ + principalsList: principals, + resourceType: ResourceType.CODE_ENVIRONMENT, + requiredPermissions: PermissionBits.VIEW, + }); + 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 environments = await findAccessible(actor); + return environments.map((environment) => ({ + id: environment.environmentId, + name: environment.name, + type: environment.type, + baseURL: environment.baseURL, + })); + } + + return { register, listAccessible, listAccessibleConfigurations }; +} diff --git a/packages/api/src/code/http.spec.ts b/packages/api/src/code/http.spec.ts new file mode 100644 index 00000000000..f66a7c0c97f --- /dev/null +++ b/packages/api/src/code/http.spec.ts @@ -0,0 +1,82 @@ +import type { AppConfig } from '@librechat/data-schemas'; +import { EModelEndpoint } from 'librechat-data-provider'; +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('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', + workerId: 'deployment-worker', + }, + }); + }); +}); diff --git a/packages/api/src/code/http.ts b/packages/api/src/code/http.ts new file mode 100644 index 00000000000..aabd70a0af1 --- /dev/null +++ b/packages/api/src/code/http.ts @@ -0,0 +1,118 @@ +import { nanoid } from 'nanoid'; +import { EModelEndpoint } from 'librechat-data-provider'; +import type { AppConfig } from '@librechat/data-schemas'; +import type { Response } from 'express'; +import type { GetAppConfigOptions } from '~/app/service'; +import type { ServerRequest } from '~/types/http'; +import type { + CodeEnvironmentPrincipalContext, + CodeEnvironmentRegistration, + CodeEnvironmentSummary, +} 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 name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''; + const controlPlaneId = + typeof req.body?.controlPlaneId === 'string' ? req.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, + 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; + return res.status(duplicate ? 409 : 400).json({ + error: error instanceof Error ? error.message : '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 00000000000..494b7431a33 --- /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 ddd8cd516c1..f78709aca83 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 2d265308b64..bd7e05530ad 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/src/accessPermissions.ts b/packages/data-provider/src/accessPermissions.ts index 88f6ef954ba..ebefb0f0d1b 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-schemas/src/admin/capabilities.ts b/packages/data-schemas/src/admin/capabilities.ts index d9492655afa..c820255275c 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.ts b/packages/data-schemas/src/methods/accessRole.ts index 63a49f3dba9..0238d6c6525 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 00000000000..37d7a3cc7d6 --- /dev/null +++ b/packages/data-schemas/src/methods/codeEnvironment.ts @@ -0,0 +1,83 @@ +import { Types } from 'mongoose'; +import type { Model } from 'mongoose'; +import { ResourceType } from 'librechat-data-provider'; +import type { IAclEntry } from '~/types'; +import type { CodeEnvironmentDocument } from '~/types'; + +type CreateCodeEnvironmentInput = Pick< + CodeEnvironmentDocument, + 'environmentId' | 'name' | 'type' | 'baseURL' | 'createdBy' +> & + Pick, 'workerId'>; + +export function createCodeEnvironmentMethods(mongoose: typeof import('mongoose')): { + createCodeEnvironment: (input: CreateCodeEnvironmentInput) => Promise; + findCodeEnvironmentsByIds: ( + ids: Array, + ) => Promise; + findCodeEnvironmentByEnvironmentId: ( + environmentId: string, + ) => 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 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, + 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 65fb1a1a7d2..8e959a7daaf 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 00000000000..0af6827039a --- /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 ed68a2fd436..4022d95eaf1 100644 --- a/packages/data-schemas/src/models/index.ts +++ b/packages/data-schemas/src/models/index.ts @@ -9,6 +9,7 @@ import { createSkillSyncStatusModel } from './skillSyncStatus'; import { createConversationTagModel } from './conversationTag'; import { createAgentCategoryModel } from './agentCategory'; import { createChatProjectModel } from './chatProject'; +import { createCodeEnvironmentModel } from './codeEnvironment'; import { createAgentApiKeyModel } from './agentApiKey'; import { createTransactionModel } from './transaction'; import { createPromptGroupModel } from './promptGroup'; @@ -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/codeEnvironment.ts b/packages/data-schemas/src/schema/codeEnvironment.ts new file mode 100644 index 00000000000..abf0ac4a719 --- /dev/null +++ b/packages/data-schemas/src/schema/codeEnvironment.ts @@ -0,0 +1,43 @@ +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, + }, + 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 210a765b905..966d49bf873 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 00000000000..115cb7a3fd8 --- /dev/null +++ b/packages/data-schemas/src/types/codeEnvironment.ts @@ -0,0 +1,18 @@ +import type { Document, Types } from 'mongoose'; + +export type CodeEnvironment = { + environmentId: string; + name: string; + type: 'managed' | 'attached'; + baseURL: 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 f80edb70f60..6d5aa1fbcfe 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'; From d4c687b5775cefd063ff20c46b557c863d825822 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 30 Aug 2026 13:10:39 -0400 Subject: [PATCH 02/16] fix: address code environment CI coverage --- api/server/services/PermissionService.spec.js | 4 +- .../Sharing/PublicSharingToggle.tsx | 11 +++-- client/src/utils/resources.ts | 2 +- packages/api/src/code/config.spec.ts | 4 +- .../src/code/environments.integration.spec.ts | 1 + packages/api/src/code/environments.ts | 2 + packages/api/src/code/http.spec.ts | 2 +- packages/api/src/code/http.ts | 9 ++-- .../specs/config-schemas.spec.ts | 43 +++++++++++++++++++ packages/data-provider/src/config.ts | 13 ++++-- .../src/methods/accessRole.spec.ts | 5 ++- .../src/methods/codeEnvironment.ts | 4 +- packages/data-schemas/src/models/index.ts | 2 +- 13 files changed, 83 insertions(+), 19 deletions(-) diff --git a/api/server/services/PermissionService.spec.js b/api/server/services/PermissionService.spec.js index 53b281aefb7..7729412c917 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 f7efcf54c19..bdd8dd99ea7 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 09623247a29..e925d598ea3 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/packages/api/src/code/config.spec.ts b/packages/api/src/code/config.spec.ts index b9e4714006e..0d765ed0421 100644 --- a/packages/api/src/code/config.spec.ts +++ b/packages/api/src/code/config.spec.ts @@ -1,5 +1,5 @@ -import type { AppConfig } from '@librechat/data-schemas'; import { EModelEndpoint } from 'librechat-data-provider'; +import type { AppConfig } from '@librechat/data-schemas'; import { mergeAccessibleCodeEnvironments } from './config'; describe('mergeAccessibleCodeEnvironments', () => { @@ -27,12 +27,14 @@ describe('mergeAccessibleCodeEnvironments', () => { name: 'Personal VM', type: 'attached', baseURL: 'https://personal.example', + owner: 'principal', }, { id: 'deployment-vm', name: 'Shadow Attempt', type: 'attached', baseURL: 'https://shadow.example', + owner: 'principal', }, ]); diff --git a/packages/api/src/code/environments.integration.spec.ts b/packages/api/src/code/environments.integration.spec.ts index 4ba1be5c04f..568e850cb5d 100644 --- a/packages/api/src/code/environments.integration.spec.ts +++ b/packages/api/src/code/environments.integration.spec.ts @@ -65,6 +65,7 @@ describe('code environment registry', () => { name: "Danny's VM", type: 'attached', baseURL: 'https://code.example.com', + owner: 'principal', }, ]); }); diff --git a/packages/api/src/code/environments.ts b/packages/api/src/code/environments.ts index 59ee7ce6995..d9944a0d69d 100644 --- a/packages/api/src/code/environments.ts +++ b/packages/api/src/code/environments.ts @@ -35,6 +35,7 @@ export type AccessibleCodeEnvironmentConfiguration = { name: string; type: 'managed' | 'attached'; baseURL: string; + owner: 'principal'; }; const ENVIRONMENT_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; @@ -148,6 +149,7 @@ export function createCodeEnvironmentRegistry(mongoose: typeof import('mongoose' name: environment.name, type: environment.type, baseURL: environment.baseURL, + owner: 'principal', })); } diff --git a/packages/api/src/code/http.spec.ts b/packages/api/src/code/http.spec.ts index f66a7c0c97f..03ac1e819ac 100644 --- a/packages/api/src/code/http.spec.ts +++ b/packages/api/src/code/http.spec.ts @@ -1,5 +1,5 @@ -import type { AppConfig } from '@librechat/data-schemas'; import { EModelEndpoint } from 'librechat-data-provider'; +import type { AppConfig } from '@librechat/data-schemas'; import { createCodeEnvironmentHttpHandlers } from './http'; function response() { diff --git a/packages/api/src/code/http.ts b/packages/api/src/code/http.ts index aabd70a0af1..dc1bb3c4db9 100644 --- a/packages/api/src/code/http.ts +++ b/packages/api/src/code/http.ts @@ -2,13 +2,13 @@ import { nanoid } from 'nanoid'; import { EModelEndpoint } from 'librechat-data-provider'; import type { AppConfig } from '@librechat/data-schemas'; import type { Response } from 'express'; -import type { GetAppConfigOptions } from '~/app/service'; -import type { ServerRequest } from '~/types/http'; import type { CodeEnvironmentPrincipalContext, CodeEnvironmentRegistration, CodeEnvironmentSummary, } from './environments'; +import type { GetAppConfigOptions } from '~/app/service'; +import type { ServerRequest } from '~/types/http'; type Registry = { register: (params: { @@ -73,9 +73,10 @@ export function createCodeEnvironmentHttpHandlers(deps: CodeEnvironmentHttpDeps) if (principal == null) { return res.status(401).json({ error: 'Authentication required' }); } - const name = typeof req.body?.name === 'string' ? req.body.name.trim() : ''; + const body = req.body as unknown as Record; + const name = typeof body.name === 'string' ? body.name.trim() : ''; const controlPlaneId = - typeof req.body?.controlPlaneId === 'string' ? req.body.controlPlaneId.trim() : ''; + typeof body.controlPlaneId === 'string' ? body.controlPlaneId.trim() : ''; if (!name || !controlPlaneId) { return res.status(400).json({ error: 'name and controlPlaneId are required', diff --git a/packages/data-provider/specs/config-schemas.spec.ts b/packages/data-provider/specs/config-schemas.spec.ts index 33c4fc8ba1f..4476ac2ce66 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/config.ts b/packages/data-provider/src/config.ts index b0f7a97eefb..42e39f10e58 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/methods/accessRole.spec.ts b/packages/data-schemas/src/methods/accessRole.spec.ts index ec9e97ed9bb..f3b42168462 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'; @@ -197,6 +197,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/codeEnvironment.ts b/packages/data-schemas/src/methods/codeEnvironment.ts index 37d7a3cc7d6..2628c9ec1b8 100644 --- a/packages/data-schemas/src/methods/codeEnvironment.ts +++ b/packages/data-schemas/src/methods/codeEnvironment.ts @@ -1,8 +1,8 @@ import { Types } from 'mongoose'; -import type { Model } from 'mongoose'; import { ResourceType } from 'librechat-data-provider'; -import type { IAclEntry } from '~/types'; +import type { Model } from 'mongoose'; import type { CodeEnvironmentDocument } from '~/types'; +import type { IAclEntry } from '~/types'; type CreateCodeEnvironmentInput = Pick< CodeEnvironmentDocument, diff --git a/packages/data-schemas/src/models/index.ts b/packages/data-schemas/src/models/index.ts index 4022d95eaf1..ecf2f610e30 100644 --- a/packages/data-schemas/src/models/index.ts +++ b/packages/data-schemas/src/models/index.ts @@ -7,9 +7,9 @@ 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 { createCodeEnvironmentModel } from './codeEnvironment'; import { createAgentApiKeyModel } from './agentApiKey'; import { createTransactionModel } from './transaction'; import { createPromptGroupModel } from './promptGroup'; From d25cf36ae0343682a4791bdb68eed9ec6814db2f Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 30 Aug 2026 13:44:48 -0400 Subject: [PATCH 03/16] fix: expose principal code environments in endpoint config --- api/server/routes/endpoints.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/server/routes/endpoints.js b/api/server/routes/endpoints.js index 8b1fceccc40..2ca373b8ecd 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; From 3423b718e1493636503e3fa386af11d6add791df Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 30 Aug 2026 14:40:57 -0400 Subject: [PATCH 04/16] fix: reject missing code environment bodies --- packages/api/src/code/http.spec.ts | 17 +++++++++++++++++ packages/api/src/code/http.ts | 5 ++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/api/src/code/http.spec.ts b/packages/api/src/code/http.spec.ts index 03ac1e819ac..e1cf32c0f00 100644 --- a/packages/api/src/code/http.spec.ts +++ b/packages/api/src/code/http.spec.ts @@ -19,6 +19,23 @@ function response() { } 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', diff --git a/packages/api/src/code/http.ts b/packages/api/src/code/http.ts index dc1bb3c4db9..4f9e45c0ec3 100644 --- a/packages/api/src/code/http.ts +++ b/packages/api/src/code/http.ts @@ -73,7 +73,10 @@ export function createCodeEnvironmentHttpHandlers(deps: CodeEnvironmentHttpDeps) if (principal == null) { return res.status(401).json({ error: 'Authentication required' }); } - const body = req.body as unknown as Record; + 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() : ''; From 8f7edd46661b265602f83c6d5a5df74bcf905fd6 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 30 Aug 2026 15:33:49 -0400 Subject: [PATCH 05/16] fix: harden code environment lifecycle --- api/server/routes/admin/users.js | 1 + packages/api/src/admin/users.spec.ts | 5 +- packages/api/src/admin/users.ts | 3 + packages/api/src/code/environments.ts | 17 +++- packages/api/src/code/http.spec.ts | 77 +++++++++++++++++++ packages/api/src/code/http.ts | 14 +++- .../src/methods/accessRole.spec.ts | 11 +++ .../data-schemas/src/schema/accessRole.ts | 1 + 8 files changed, 120 insertions(+), 9 deletions(-) diff --git a/api/server/routes/admin/users.js b/api/server/routes/admin/users.js index 0aa157c1796..44d60188b68 100644 --- a/api/server/routes/admin/users.js +++ b/api/server/routes/admin/users.js @@ -27,6 +27,7 @@ const handlers = createAdminUsersHandlers({ cancelAgentTriggerUserPurge, purgeAgentTriggerDeliveriesForUser, deleteUserById: db.deleteUserById, + deleteUserCodeEnvironments: db.deleteUserCodeEnvironments, deleteConfig: db.deleteConfig, deleteAclEntries: db.deleteAclEntries, }); diff --git a/packages/api/src/admin/users.spec.ts b/packages/api/src/admin/users.spec.ts index e7cae56ad41..c204d2e2d75 100644 --- a/packages/api/src/admin/users.spec.ts +++ b/packages/api/src/admin/users.spec.ts @@ -62,6 +62,7 @@ function createDeps(overrides: Partial = {}): AdminUsersDeps { deleteUserById: jest .fn() .mockResolvedValue({ deletedCount: 1, message: 'User was deleted successfully.' }), + deleteUserCodeEnvironments: jest.fn().mockResolvedValue(0), deleteConfig: jest.fn().mockResolvedValue(null), deleteAclEntries: jest.fn().mockResolvedValue(undefined), ...overrides, @@ -435,7 +436,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 +449,7 @@ describe('createAdminUsersHandlers', () => { expect(status).toHaveBeenCalledWith(200); expect(deps.deleteConfig).toHaveBeenCalledWith(PrincipalType.USER, validUserId); + expect(deps.deleteUserCodeEnvironments).toHaveBeenCalledWith(expect.any(Types.ObjectId)); expect(deps.deleteAclEntries).toHaveBeenCalledWith({ principalType: PrincipalType.USER, principalId: expect.any(Types.ObjectId), @@ -482,6 +484,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 5a46d3c748a..8a25c6b7e90 100644 --- a/packages/api/src/admin/users.ts +++ b/packages/api/src/admin/users.ts @@ -46,6 +46,7 @@ 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; deleteConfig: ( principalType: PrincipalType, principalId: string | Types.ObjectId, @@ -71,6 +72,7 @@ export function createAdminUsersHandlers(deps: AdminUsersDeps): { cancelAgentTriggerUserPurge, purgeAgentTriggerDeliveriesForUser, deleteUserById, + deleteUserCodeEnvironments, deleteConfig, deleteAclEntries, } = deps; @@ -214,6 +216,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) { diff --git a/packages/api/src/code/environments.ts b/packages/api/src/code/environments.ts index d9944a0d69d..91fc3a04e6a 100644 --- a/packages/api/src/code/environments.ts +++ b/packages/api/src/code/environments.ts @@ -41,22 +41,31 @@ export type AccessibleCodeEnvironmentConfiguration = { 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}$/; +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 workerId = input.workerId?.trim(); if (!ENVIRONMENT_ID_PATTERN.test(id)) { - throw new Error('Code environment id is invalid'); + throw new CodeEnvironmentValidationError('Code environment id is invalid'); } if (name.length < 1 || name.length > 100) { - throw new Error('Code environment name must contain between 1 and 100 characters'); + throw new CodeEnvironmentValidationError( + 'Code environment name must contain between 1 and 100 characters', + ); } if (!isSecureCodeEnvironmentControlURL(baseURL)) { - throw new Error('Code environment control requires secure transport'); + throw new CodeEnvironmentValidationError('Code environment control requires secure transport'); } if (workerId != null && !WORKER_ID_PATTERN.test(workerId)) { - throw new Error('Code environment worker id is invalid'); + throw new CodeEnvironmentValidationError('Code environment worker id is invalid'); } return { ...input, id, name, baseURL, workerId }; } diff --git a/packages/api/src/code/http.spec.ts b/packages/api/src/code/http.spec.ts index e1cf32c0f00..b823281aae0 100644 --- a/packages/api/src/code/http.spec.ts +++ b/packages/api/src/code/http.spec.ts @@ -1,5 +1,6 @@ import { EModelEndpoint } from 'librechat-data-provider'; import type { AppConfig } from '@librechat/data-schemas'; +import { CodeEnvironmentValidationError } from './environments'; import { createCodeEnvironmentHttpHandlers } from './http'; function response() { @@ -96,4 +97,80 @@ describe('code environment HTTP handlers', () => { }, }); }); + + 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 index 4f9e45c0ec3..36eabc79d06 100644 --- a/packages/api/src/code/http.ts +++ b/packages/api/src/code/http.ts @@ -1,6 +1,6 @@ import { nanoid } from 'nanoid'; import { EModelEndpoint } from 'librechat-data-provider'; -import type { AppConfig } from '@librechat/data-schemas'; +import { logger, type AppConfig } from '@librechat/data-schemas'; import type { Response } from 'express'; import type { CodeEnvironmentPrincipalContext, @@ -9,6 +9,7 @@ import type { } from './environments'; import type { GetAppConfigOptions } from '~/app/service'; import type { ServerRequest } from '~/types/http'; +import { CodeEnvironmentValidationError } from './environments'; type Registry = { register: (params: { @@ -112,9 +113,14 @@ export function createCodeEnvironmentHttpHandlers(deps: CodeEnvironmentHttpDeps) error != null && 'code' in error && (error as { code?: number }).code === 11000; - return res.status(duplicate ? 409 : 400).json({ - error: error instanceof Error ? error.message : 'Code environment registration failed', - }); + 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' }); } } diff --git a/packages/data-schemas/src/methods/accessRole.spec.ts b/packages/data-schemas/src/methods/accessRole.spec.ts index f3b42168462..41d6da4d820 100644 --- a/packages/data-schemas/src/methods/accessRole.spec.ts +++ b/packages/data-schemas/src/methods/accessRole.spec.ts @@ -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); diff --git a/packages/data-schemas/src/schema/accessRole.ts b/packages/data-schemas/src/schema/accessRole.ts index b0a07a7c3e4..68039e9eaa6 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', From 22e2804efdb0eb89353b8a91e9923275aff6bcd5 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 30 Aug 2026 17:37:59 -0400 Subject: [PATCH 06/16] fix: revalidate principal code environments --- api/server/middleware/config/app.js | 39 ++----------------- api/server/routes/code-environments.js | 4 +- api/server/routes/code-environments.test.js | 5 ++- .../__tests__/invalidateConfigCaches.spec.js | 8 +++- api/server/services/Config/app.js | 24 +++++++++++- packages/api/src/app/service.spec.ts | 27 +++++++++++++ packages/api/src/app/service.ts | 30 ++++++++++---- packages/api/src/code/config.spec.ts | 39 ++++++++++++++++++- packages/api/src/code/config.ts | 19 +++++++-- .../src/code/environments.integration.spec.ts | 6 +++ packages/api/src/code/environments.ts | 14 ++++++- packages/api/src/code/http.spec.ts | 4 ++ packages/api/src/code/http.ts | 5 +++ .../src/methods/codeEnvironment.ts | 2 +- .../src/schema/codeEnvironment.ts | 4 ++ .../data-schemas/src/types/codeEnvironment.ts | 1 + 16 files changed, 173 insertions(+), 58 deletions(-) diff --git a/api/server/middleware/config/app.js b/api/server/middleware/config/app.js index ab3cafce421..3768089b249 100644 --- a/api/server/middleware/config/app.js +++ b/api/server/middleware/config/app.js @@ -1,40 +1,10 @@ const { logger } = require('@librechat/data-schemas'); -const mongoose = require('mongoose'); -const { - createCodeEnvironmentRegistry, - getAppConfigOptionsFromUser, - mergeAccessibleCodeEnvironments, -} = require('@librechat/api'); +const { getAppConfigOptionsFromUser } = require('@librechat/api'); const { getAppConfig } = require('~/server/services/Config'); -const codeEnvironmentRegistry = createCodeEnvironmentRegistry(mongoose); - -async function addPrincipalCodeEnvironments(req, appConfig) { - if (!req.user?.id) return appConfig; - try { - return await mergeAccessibleCodeEnvironments({ - appConfig, - actor: { - userId: req.user.id, - role: req.user.role ?? null, - idOnTheSource: req.user.idOnTheSource ?? null, - }, - registry: codeEnvironmentRegistry, - }); - } catch (error) { - /** Database-backed environments fail closed while deployment-owned YAML - * environments remain available. */ - logger.warn('[configMiddleware] Failed to load principal code environments', error); - return appConfig; - } -} - const configMiddleware = async (req, res, next) => { try { - req.config = await addPrincipalCodeEnvironments( - req, - await getAppConfig(getAppConfigOptionsFromUser(req.user)), - ); + req.config = await getAppConfig(getAppConfigOptionsFromUser(req.user)); next(); } catch (error) { @@ -45,10 +15,7 @@ const configMiddleware = async (req, res, next) => { }); try { - req.config = await addPrincipalCodeEnvironments( - req, - await getAppConfig({ tenantId: req.user?.tenantId }), - ); + req.config = await getAppConfig({ tenantId: req.user?.tenantId }); next(); } catch (fallbackError) { logger.error('Fallback config middleware error:', fallbackError); diff --git a/api/server/routes/code-environments.js b/api/server/routes/code-environments.js index 6a7ebdbde74..5b35886460a 100644 --- a/api/server/routes/code-environments.js +++ b/api/server/routes/code-environments.js @@ -6,12 +6,12 @@ const { } = require('@librechat/api'); const { SystemCapabilities } = require('@librechat/data-schemas'); const { requireCapability } = require('~/server/middleware/roles/capabilities'); -const { getAppConfig } = require('~/server/services/Config'); +const { getAppConfig, clearOverrideCache } = require('~/server/services/Config'); const { requireJwtAuth } = require('~/server/middleware'); const router = express.Router(); const registry = createCodeEnvironmentRegistry(mongoose); -const handlers = createCodeEnvironmentHttpHandlers({ getAppConfig, registry }); +const handlers = createCodeEnvironmentHttpHandlers({ getAppConfig, clearOverrideCache, registry }); const requireCodeEnvironmentManage = requireCapability(SystemCapabilities.MANAGE_CODE_ENVIRONMENTS); router.use(requireJwtAuth); diff --git a/api/server/routes/code-environments.test.js b/api/server/routes/code-environments.test.js index 5dbd86f0127..7ba6975e8f9 100644 --- a/api/server/routes/code-environments.test.js +++ b/api/server/routes/code-environments.test.js @@ -31,7 +31,10 @@ jest.mock('~/server/middleware/roles/capabilities', () => ({ })); jest.mock('~/server/middleware', () => ({ requireJwtAuth: mockRequireJwtAuth })); -jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn() })); +jest.mock('~/server/services/Config', () => ({ + getAppConfig: jest.fn(), + clearOverrideCache: jest.fn(), +})); function createApp() { delete require.cache[require.resolve('./code-environments')]; diff --git a/api/server/services/Config/__tests__/invalidateConfigCaches.spec.js b/api/server/services/Config/__tests__/invalidateConfigCaches.spec.js index ddc97042b97..8d21fb2e84e 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 7aa913e636d..dedc3a8c3eb 100644 --- a/api/server/services/Config/app.js +++ b/api/server/services/Config/app.js @@ -1,6 +1,12 @@ +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, +} = require('@librechat/api'); const { setCachedTools, invalidateCachedTools } = require('./getCachedTools'); const { loadAndFormatTools } = require('~/server/services/start/tools'); const loadCustomConfig = require('./loadCustomConfig'); @@ -8,6 +14,8 @@ const getLogStores = require('~/cache/getLogStores'); const paths = require('~/config/paths'); const db = require('~/models'); +const codeEnvironmentRegistry = createCodeEnvironmentRegistry(mongoose); + const loadBaseConfig = async () => { /** @type {TCustomConfig} */ const config = (await loadCustomConfig()) ?? {}; @@ -27,6 +35,19 @@ const { getAppConfig, clearAppConfigCache, clearOverrideCache } = createAppConfi cacheKeys: CacheKeys, getApplicableConfigs: db.getApplicableConfigs, getUserPrincipals: db.getUserPrincipals, + augmentConfig: ({ appConfig, principals, options }) => { + if (!options.userId) return appConfig; + return mergeAccessibleCodeEnvironments({ + appConfig, + actor: { + userId: options.userId, + role: options.role ?? null, + idOnTheSource: options.idOnTheSource ?? null, + principals, + }, + registry: codeEnvironmentRegistry, + }); + }, }); /** @@ -58,5 +79,6 @@ async function invalidateConfigCaches(tenantId) { module.exports = { getAppConfig, clearAppConfigCache, + clearOverrideCache, invalidateConfigCaches, }; diff --git a/packages/api/src/app/service.spec.ts b/packages/api/src/app/service.spec.ts index 5cab1fd2a04..9b8d2e15452 100644 --- a/packages/api/src/app/service.spec.ts +++ b/packages/api/src/app/service.spec.ts @@ -464,6 +464,33 @@ describe('createAppConfigService', () => { }); }); + it('caches principal config augmentation with the already-resolved principals', 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(1); + expect(deps.getApplicableConfigs).toHaveBeenCalledTimes(1); + expect(augmentConfig).toHaveBeenCalledTimes(1); + expect(augmentConfig).toHaveBeenCalledWith( + expect.objectContaining({ + principals: [ + { principalType: 'role', principalId: 'USER' }, + { principalType: 'user', principalId: 'uid1' }, + ], + options: expect.objectContaining({ role: 'USER', userId: 'uid1' }), + }), + ); + }); + it('passes local identity through to getUserPrincipals when provided', async () => { const deps = createDeps(); const { getAppConfig } = createAppConfigService(deps); diff --git a/packages/api/src/app/service.ts b/packages/api/src/app/service.ts index 93cbfb957d9..742c45d2978 100644 --- a/packages/api/src/app/service.ts +++ b/packages/api/src/app/service.ts @@ -59,6 +59,12 @@ export interface AppConfigServiceDeps { role?: string | null; idOnTheSource?: string | null; }) => Promise>; + /** Add principal-scoped runtime configuration before the merged config is cached. */ + augmentConfig?: (context: { + appConfig: 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 +148,7 @@ export function createAppConfigService(deps: AppConfigServiceDeps): { cacheKeys, getApplicableConfigs, getUserPrincipals, + augmentConfig, overrideCacheTtl = DEFAULT_OVERRIDE_CACHE_TTL, } = deps; @@ -249,21 +256,28 @@ export function createAppConfigService(deps: AppConfigServiceDeps): { ); } + 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; } + + if (augmentConfig != null) { + try { + merged = await augmentConfig({ appConfig: merged, principals, options }); + } catch (error) { + logger.error('[getAppConfig] Error augmenting principal config:', error); + return merged; + } + } + + await cache.set(cacheKey, merged, overrideCacheTtl); + return merged; } /** diff --git a/packages/api/src/code/config.spec.ts b/packages/api/src/code/config.spec.ts index 0d765ed0421..a43cb2a7cdb 100644 --- a/packages/api/src/code/config.spec.ts +++ b/packages/api/src/code/config.spec.ts @@ -15,6 +15,8 @@ describe('mergeAccessibleCodeEnvironments', () => { name: 'Deployment VM', type: 'attached', baseURL: 'https://deployment.example', + owner: 'deployment', + pairing: { workerId: 'deployment-worker', tokenEnv: 'CODE_ADMIN_TOKEN' }, }, ], }, @@ -26,7 +28,8 @@ describe('mergeAccessibleCodeEnvironments', () => { id: 'personal-vm', name: 'Personal VM', type: 'attached', - baseURL: 'https://personal.example', + baseURL: 'https://retired.example', + controlPlaneId: 'deployment-vm', owner: 'principal', }, { @@ -34,6 +37,7 @@ describe('mergeAccessibleCodeEnvironments', () => { name: 'Shadow Attempt', type: 'attached', baseURL: 'https://shadow.example', + controlPlaneId: 'deployment-vm', owner: 'principal', }, ]); @@ -47,8 +51,39 @@ describe('mergeAccessibleCodeEnvironments', () => { 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://personal.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 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, + actor: { userId: '68b2f0c498f24c1e78fa0001', role: 'USER', idOnTheSource: null }, + registry: { listAccessibleConfigurations }, + }); + + expect(result).toBe(appConfig); + }); }); diff --git a/packages/api/src/code/config.ts b/packages/api/src/code/config.ts index 44c68e451ac..6f7192f5a1c 100644 --- a/packages/api/src/code/config.ts +++ b/packages/api/src/code/config.ts @@ -26,9 +26,22 @@ export async function mergeAccessibleCodeEnvironments({ const accessible = await registry.listAccessibleConfigurations(actor); if (accessible.length === 0) return appConfig; - const deploymentIds = new Set(sessions.environments?.map((environment) => environment.id) ?? []); - const principalEnvironments = accessible.filter( - (environment) => !deploymentIds.has(environment.id), + const deploymentEnvironments = new Map( + sessions.environments + ?.filter( + (environment) => + environment.owner === 'deployment' && + environment.type === 'attached' && + environment.pairing != null, + ) + .map((environment) => [environment.id, environment]) ?? [], + ); + 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 }]; + }, ); if (principalEnvironments.length === 0) return appConfig; diff --git a/packages/api/src/code/environments.integration.spec.ts b/packages/api/src/code/environments.integration.spec.ts index 568e850cb5d..4cbd435e948 100644 --- a/packages/api/src/code/environments.integration.spec.ts +++ b/packages/api/src/code/environments.integration.spec.ts @@ -38,9 +38,11 @@ describe('code environment registry', () => { 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', @@ -65,6 +67,7 @@ describe('code environment registry', () => { name: "Danny's VM", type: 'attached', baseURL: 'https://code.example.com', + controlPlaneId: 'shared-code-api', owner: 'principal', }, ]); @@ -88,6 +91,7 @@ describe('code environment registry', () => { name: 'Role VM', type: 'attached', baseURL: 'https://code.example.com', + controlPlaneId: 'shared-code-api', }, }); const groupEnvironment = await registry.register({ @@ -97,6 +101,7 @@ describe('code environment registry', () => { name: 'Group VM', type: 'attached', baseURL: 'https://code.example.com', + controlPlaneId: 'shared-code-api', }, }); @@ -137,6 +142,7 @@ describe('code environment registry', () => { name: 'Departing user VM', type: 'attached', baseURL: 'https://code.example.com', + controlPlaneId: 'shared-code-api', }, }); diff --git a/packages/api/src/code/environments.ts b/packages/api/src/code/environments.ts index 91fc3a04e6a..21d831e6f49 100644 --- a/packages/api/src/code/environments.ts +++ b/packages/api/src/code/environments.ts @@ -7,12 +7,14 @@ import { 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 = { @@ -27,6 +29,7 @@ export type CodeEnvironmentRegistration = { name: string; type: 'managed' | 'attached'; baseURL: string; + controlPlaneId: string; workerId?: string; }; @@ -35,6 +38,7 @@ export type AccessibleCodeEnvironmentConfiguration = { name: string; type: 'managed' | 'attached'; baseURL: string; + controlPlaneId: string; owner: 'principal'; }; @@ -52,6 +56,7 @@ function normalizeRegistration(input: CodeEnvironmentRegistration): CodeEnvironm 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'); @@ -64,10 +69,13 @@ function normalizeRegistration(input: CodeEnvironmentRegistration): CodeEnvironm 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, workerId }; + return { ...input, id, name, baseURL, controlPlaneId, workerId }; } function toSummary(environment: { @@ -110,6 +118,7 @@ export function createCodeEnvironmentRegistry(mongoose: typeof import('mongoose' name: environment.name, type: environment.type, baseURL: environment.baseURL, + controlPlaneId: environment.controlPlaneId, workerId: environment.workerId, createdBy: new Types.ObjectId(actor.userId), }); @@ -133,7 +142,7 @@ export function createCodeEnvironmentRegistry(mongoose: typeof import('mongoose' } async function findAccessible(actor: CodeEnvironmentPrincipalContext) { - const principals = await methods.getUserPrincipals(actor); + const principals = actor.principals ?? (await methods.getUserPrincipals(actor)); const ids = await access.findAccessibleResourcesForPrincipals({ principalsList: principals, resourceType: ResourceType.CODE_ENVIRONMENT, @@ -158,6 +167,7 @@ export function createCodeEnvironmentRegistry(mongoose: typeof import('mongoose' name: environment.name, type: environment.type, baseURL: environment.baseURL, + controlPlaneId: environment.controlPlaneId, owner: 'principal', })); } diff --git a/packages/api/src/code/http.spec.ts b/packages/api/src/code/http.spec.ts index b823281aae0..f662a82ec92 100644 --- a/packages/api/src/code/http.spec.ts +++ b/packages/api/src/code/http.spec.ts @@ -63,10 +63,12 @@ describe('code environment HTTP handlers', () => { }, }, } as AppConfig; + const clearOverrideCache = jest.fn().mockResolvedValue(undefined); const handlers = createCodeEnvironmentHttpHandlers({ getAppConfig: jest.fn().mockResolvedValue(appConfig), registry: { register, listAccessible: jest.fn() }, createEnvironmentId: () => 'personal-vm', + clearOverrideCache, }); const req = { user: { id: '68b2f0c498f24c1e78fa0001', role: 'USER' }, @@ -93,9 +95,11 @@ describe('code environment HTTP handlers', () => { name: 'Personal VM', type: 'attached', baseURL: 'https://code.librechat.example', + controlPlaneId: 'shared-code-api', workerId: 'deployment-worker', }, }); + expect(clearOverrideCache).toHaveBeenCalledWith(undefined); }); test('returns a generic 500 for operational registration failures', async () => { diff --git a/packages/api/src/code/http.ts b/packages/api/src/code/http.ts index 36eabc79d06..185817e774a 100644 --- a/packages/api/src/code/http.ts +++ b/packages/api/src/code/http.ts @@ -30,6 +30,7 @@ export interface CodeEnvironmentHttpDeps { getAppConfig: (options: GetAppConfigOptions) => Promise; registry: Registry; createEnvironmentId?: () => string; + clearOverrideCache?: (tenantId?: string) => Promise; } function actor(req: ServerRequest): CodeEnvironmentPrincipalContext | null { @@ -103,9 +104,13 @@ export function createCodeEnvironmentHttpHandlers(deps: CodeEnvironmentHttpDeps) name, type: 'attached', baseURL: controlPlane.baseURL, + controlPlaneId, workerId: controlPlane.pairing?.workerId, }, }); + await deps.clearOverrideCache?.(req.user?.tenantId).catch((error: unknown) => { + logger.warn('[codeEnvironments] config cache invalidation failed:', error); + }); return res.status(201).json({ environment }); } catch (error) { const duplicate = diff --git a/packages/data-schemas/src/methods/codeEnvironment.ts b/packages/data-schemas/src/methods/codeEnvironment.ts index 2628c9ec1b8..fcc8b66ddaa 100644 --- a/packages/data-schemas/src/methods/codeEnvironment.ts +++ b/packages/data-schemas/src/methods/codeEnvironment.ts @@ -6,7 +6,7 @@ import type { IAclEntry } from '~/types'; type CreateCodeEnvironmentInput = Pick< CodeEnvironmentDocument, - 'environmentId' | 'name' | 'type' | 'baseURL' | 'createdBy' + 'environmentId' | 'name' | 'type' | 'baseURL' | 'controlPlaneId' | 'createdBy' > & Pick, 'workerId'>; diff --git a/packages/data-schemas/src/schema/codeEnvironment.ts b/packages/data-schemas/src/schema/codeEnvironment.ts index abf0ac4a719..13786c63cd6 100644 --- a/packages/data-schemas/src/schema/codeEnvironment.ts +++ b/packages/data-schemas/src/schema/codeEnvironment.ts @@ -20,6 +20,10 @@ const codeEnvironmentSchema: Schema = new Schema Date: Sun, 30 Aug 2026 17:59:20 -0400 Subject: [PATCH 07/16] fix: synchronize code environment authorization --- .../controllers/PermissionsController.js | 5 + api/server/controllers/UserController.js | 5 +- .../__tests__/PermissionsController.spec.js | 24 ++++ .../controllers/__tests__/deleteUser.spec.js | 5 + api/server/routes/admin/users.js | 2 + api/server/routes/code-environments.js | 12 +- api/server/routes/code-environments.test.js | 2 +- api/server/services/Config/app.js | 23 +++- packages/api/src/admin/users.spec.ts | 2 + packages/api/src/admin/users.ts | 5 + packages/api/src/app/service.spec.ts | 8 +- packages/api/src/app/service.ts | 39 +++---- packages/api/src/code/config.spec.ts | 4 +- .../src/code/environments.integration.spec.ts | 103 ++++++++++++++++++ packages/api/src/code/environments.ts | 97 ++++++++++++++--- packages/api/src/code/http.spec.ts | 3 - packages/api/src/code/http.ts | 4 - 17 files changed, 285 insertions(+), 58 deletions(-) diff --git a/api/server/controllers/PermissionsController.js b/api/server/controllers/PermissionsController.js index 076de31cf33..825a51efa0d 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,10 @@ const updateResourcePermissions = async (req, res) => { grantedBy: userId, }); + if (resourceType === ResourceType.CODE_ENVIRONMENT) { + await invalidateCodeEnvironmentConfigCache(req.user.tenantId); + } + 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 480fb0f3d00..f4fe17d1e42 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, @@ -461,6 +461,9 @@ const deleteUserController = async (req, res) => { 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/__tests__/PermissionsController.spec.js b/api/server/controllers/__tests__/PermissionsController.spec.js index 5976f9b29a5..3abbd6ab4b2 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,24 @@ 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('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 e012819e42c..23c152b30bd 100644 --- a/api/server/controllers/__tests__/deleteUser.spec.js +++ b/api/server/controllers/__tests__/deleteUser.spec.js @@ -20,6 +20,7 @@ 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(); @@ -138,6 +139,8 @@ jest.mock('~/server/services/Schedules', () => ({ jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn(), + invalidateCodeEnvironmentConfigCache: (...args) => + mockInvalidateCodeEnvironmentConfigCache(...args), })); jest.mock('~/cache', () => ({ @@ -171,6 +174,7 @@ function stubDeletionMocks() { mockDeleteUserAgents.mockResolvedValue(); mockDeleteUserPrompts.mockResolvedValue(); mockDeleteUserSkills.mockResolvedValue(0); + mockInvalidateCodeEnvironmentConfigCache.mockResolvedValue(undefined); mockGetCleanupBlockingJobIdsForUser.mockResolvedValue([]); mockAbortJob.mockResolvedValue({ success: true }); mockDrainAgentTriggerDeliveriesForUser.mockResolvedValue(); @@ -203,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/routes/admin/users.js b/api/server/routes/admin/users.js index 44d60188b68..ab8f5fec8b0 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(); @@ -28,6 +29,7 @@ const handlers = createAdminUsersHandlers({ 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 index 5b35886460a..5f6377b1852 100644 --- a/api/server/routes/code-environments.js +++ b/api/server/routes/code-environments.js @@ -1,17 +1,13 @@ const express = require('express'); -const mongoose = require('mongoose'); -const { - createCodeEnvironmentRegistry, - createCodeEnvironmentHttpHandlers, -} = require('@librechat/api'); +const { createCodeEnvironmentHttpHandlers } = require('@librechat/api'); const { SystemCapabilities } = require('@librechat/data-schemas'); const { requireCapability } = require('~/server/middleware/roles/capabilities'); -const { getAppConfig, clearOverrideCache } = require('~/server/services/Config'); +const { getAppConfig, getCodeEnvironmentRegistry } = require('~/server/services/Config'); const { requireJwtAuth } = require('~/server/middleware'); const router = express.Router(); -const registry = createCodeEnvironmentRegistry(mongoose); -const handlers = createCodeEnvironmentHttpHandlers({ getAppConfig, clearOverrideCache, registry }); +const registry = getCodeEnvironmentRegistry(); +const handlers = createCodeEnvironmentHttpHandlers({ getAppConfig, registry }); const requireCodeEnvironmentManage = requireCapability(SystemCapabilities.MANAGE_CODE_ENVIRONMENTS); router.use(requireJwtAuth); diff --git a/api/server/routes/code-environments.test.js b/api/server/routes/code-environments.test.js index 7ba6975e8f9..5e83518f150 100644 --- a/api/server/routes/code-environments.test.js +++ b/api/server/routes/code-environments.test.js @@ -33,7 +33,7 @@ jest.mock('~/server/middleware/roles/capabilities', () => ({ jest.mock('~/server/middleware', () => ({ requireJwtAuth: mockRequireJwtAuth })); jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn(), - clearOverrideCache: jest.fn(), + getCodeEnvironmentRegistry: jest.fn(() => mockRegistry), })); function createApp() { diff --git a/api/server/services/Config/app.js b/api/server/services/Config/app.js index dedc3a8c3eb..f110bc1e0c9 100644 --- a/api/server/services/Config/app.js +++ b/api/server/services/Config/app.js @@ -6,6 +6,8 @@ const { clearMcpConfigCache, createCodeEnvironmentRegistry, mergeAccessibleCodeEnvironments, + cacheConfig, + standardCache, } = require('@librechat/api'); const { setCachedTools, invalidateCachedTools } = require('./getCachedTools'); const { loadAndFormatTools } = require('~/server/services/start/tools'); @@ -14,7 +16,22 @@ const getLogStores = require('~/cache/getLogStores'); const paths = require('~/config/paths'); const db = require('~/models'); -const codeEnvironmentRegistry = createCodeEnvironmentRegistry(mongoose); +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} */ @@ -45,7 +62,7 @@ const { getAppConfig, clearAppConfigCache, clearOverrideCache } = createAppConfi idOnTheSource: options.idOnTheSource ?? null, principals, }, - registry: codeEnvironmentRegistry, + registry: getCodeEnvironmentRegistry(), }); }, }); @@ -81,4 +98,6 @@ module.exports = { clearAppConfigCache, clearOverrideCache, invalidateConfigCaches, + getCodeEnvironmentRegistry, + invalidateCodeEnvironmentConfigCache, }; diff --git a/packages/api/src/admin/users.spec.ts b/packages/api/src/admin/users.spec.ts index c204d2e2d75..83ff375fe2f 100644 --- a/packages/api/src/admin/users.spec.ts +++ b/packages/api/src/admin/users.spec.ts @@ -63,6 +63,7 @@ function createDeps(overrides: Partial = {}): AdminUsersDeps { .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, @@ -450,6 +451,7 @@ 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), diff --git a/packages/api/src/admin/users.ts b/packages/api/src/admin/users.ts index 8a25c6b7e90..39e0243a792 100644 --- a/packages/api/src/admin/users.ts +++ b/packages/api/src/admin/users.ts @@ -47,6 +47,7 @@ export interface AdminUsersDeps { */ deleteUserById: (userId: string) => Promise; deleteUserCodeEnvironments: (userId: string | Types.ObjectId) => Promise; + invalidateCodeEnvironmentConfigCache: (tenantId?: string) => Promise; deleteConfig: ( principalType: PrincipalType, principalId: string | Types.ObjectId, @@ -73,6 +74,7 @@ export function createAdminUsersHandlers(deps: AdminUsersDeps): { purgeAgentTriggerDeliveriesForUser, deleteUserById, deleteUserCodeEnvironments, + invalidateCodeEnvironmentConfigCache, deleteConfig, deleteAclEntries, } = deps; @@ -224,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 9b8d2e15452..e59c01619de 100644 --- a/packages/api/src/app/service.spec.ts +++ b/packages/api/src/app/service.spec.ts @@ -464,7 +464,7 @@ describe('createAppConfigService', () => { }); }); - it('caches principal config augmentation with the already-resolved principals', async () => { + it('re-runs mutable principal config augmentation without rebuilding cached overrides', async () => { const augmentConfig = jest.fn(async ({ appConfig, principals }) => ({ ...appConfig, principalCount: principals.length, @@ -477,9 +477,9 @@ describe('createAppConfigService', () => { expect(first).toEqual(expect.objectContaining({ principalCount: 2 })); expect(second).toEqual(expect.objectContaining({ principalCount: 2 })); - expect(deps.getUserPrincipals).toHaveBeenCalledTimes(1); + expect(deps.getUserPrincipals).toHaveBeenCalledTimes(2); expect(deps.getApplicableConfigs).toHaveBeenCalledTimes(1); - expect(augmentConfig).toHaveBeenCalledTimes(1); + expect(augmentConfig).toHaveBeenCalledTimes(2); expect(augmentConfig).toHaveBeenCalledWith( expect.objectContaining({ principals: [ @@ -511,7 +511,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 742c45d2978..e4ba997179a 100644 --- a/packages/api/src/app/service.ts +++ b/packages/api/src/app/service.ts @@ -59,7 +59,7 @@ export interface AppConfigServiceDeps { role?: string | null; idOnTheSource?: string | null; }) => Promise>; - /** Add principal-scoped runtime configuration before the merged config is cached. */ + /** Add mutable principal-scoped runtime configuration after cached overrides are resolved. */ augmentConfig?: (context: { appConfig: AppConfig; principals: Array<{ principalType: string; principalId?: string | Types.ObjectId }>; @@ -221,14 +221,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); @@ -256,6 +248,24 @@ export function createAppConfigService(deps: AppConfigServiceDeps): { ); } + const augment = async (appConfig: AppConfig): Promise => { + if (augmentConfig == null) return appConfig; + try { + return await augmentConfig({ appConfig, 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); @@ -267,17 +277,8 @@ export function createAppConfigService(deps: AppConfigServiceDeps): { return baseConfig; } - if (augmentConfig != null) { - try { - merged = await augmentConfig({ appConfig: merged, principals, options }); - } catch (error) { - logger.error('[getAppConfig] Error augmenting principal config:', error); - return merged; - } - } - await cache.set(cacheKey, merged, overrideCacheTtl); - return merged; + return await augment(merged); } /** diff --git a/packages/api/src/code/config.spec.ts b/packages/api/src/code/config.spec.ts index a43cb2a7cdb..6b8fbb305a4 100644 --- a/packages/api/src/code/config.spec.ts +++ b/packages/api/src/code/config.spec.ts @@ -22,7 +22,7 @@ describe('mergeAccessibleCodeEnvironments', () => { }, }, }, - } as AppConfig; + } as unknown as AppConfig; const listAccessibleConfigurations = jest.fn().mockResolvedValue([ { id: 'personal-vm', @@ -66,7 +66,7 @@ describe('mergeAccessibleCodeEnvironments', () => { }, }, }, - } as AppConfig; + } as unknown as AppConfig; const listAccessibleConfigurations = jest.fn().mockResolvedValue([ { id: 'personal-vm', diff --git a/packages/api/src/code/environments.integration.spec.ts b/packages/api/src/code/environments.integration.spec.ts index 4cbd435e948..cdd66f24c59 100644 --- a/packages/api/src/code/environments.integration.spec.ts +++ b/packages/api/src/code/environments.integration.spec.ts @@ -5,6 +5,17 @@ import { AccessRoleIds, PrincipalType, ResourceType } from 'librechat-data-provi 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; @@ -157,4 +168,96 @@ describe('code environment registry', () => { }), ).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('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('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 index 21d831e6f49..c5e26ee8400 100644 --- a/packages/api/src/code/environments.ts +++ b/packages/api/src/code/environments.ts @@ -1,5 +1,6 @@ +import { randomUUID } from 'node:crypto'; import { Types } from 'mongoose'; -import { createMethods } from '@librechat/data-schemas'; +import { createMethods, getTenantId, logger } from '@librechat/data-schemas'; import { AccessRoleIds, PermissionBits, @@ -44,6 +45,19 @@ export type AccessibleCodeEnvironmentConfiguration = { 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_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) { @@ -92,7 +106,10 @@ function toSummary(environment: { }; } -export function createCodeEnvironmentRegistry(mongoose: typeof import('mongoose')): { +export function createCodeEnvironmentRegistry( + mongoose: typeof import('mongoose'), + options: CodeEnvironmentRegistryOptions = {}, +): { register: (params: { actor: CodeEnvironmentPrincipalContext; environment: CodeEnvironmentRegistration; @@ -101,9 +118,24 @@ export function createCodeEnvironmentRegistry(mongoose: typeof import('mongoose' listAccessibleConfigurations: ( actor: CodeEnvironmentPrincipalContext, ) => 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 register({ actor, @@ -134,9 +166,22 @@ export function createCodeEnvironmentRegistry(mongoose: typeof import('mongoose' if (permission == null) { throw new Error('Unable to grant code environment ownership'); } - return toSummary(created); + const summary = toSummary(created); + await invalidateAccessibleConfigurations(); + return summary; } catch (error) { - await methods.deleteCodeEnvironmentById(created._id); + 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; } } @@ -161,16 +206,40 @@ export function createCodeEnvironmentRegistry(mongoose: typeof import('mongoose' async function listAccessibleConfigurations( actor: CodeEnvironmentPrincipalContext, ): Promise { - const environments = await findAccessible(actor); - return environments.map((environment) => ({ - id: environment.environmentId, - name: environment.name, - type: environment.type, - baseURL: environment.baseURL, - controlPlaneId: environment.controlPlaneId, - owner: 'principal', - })); + const load = async (): Promise => { + const environments = await findAccessible(actor); + return environments.map((environment) => ({ + id: environment.environmentId, + name: environment.name, + type: environment.type, + baseURL: environment.baseURL, + controlPlaneId: environment.controlPlaneId, + owner: 'principal', + })); + }; + if (configurationCache == null) return await load(); + + const tenant = tenantCacheKey(); + const revision = String((await configurationCache.get(revisionKey())) ?? '0'); + const key = `${CONFIGURATION_CACHE_USER_PREFIX}:${tenant}:${actor.userId.toString()}:${revision}`; + const cached = await configurationCache.get(key); + if (Array.isArray(cached)) { + return cached as AccessibleCodeEnvironmentConfiguration[]; + } + + 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; } - return { register, listAccessible, listAccessibleConfigurations }; + return { + register, + listAccessible, + listAccessibleConfigurations, + invalidateAccessibleConfigurations, + }; } diff --git a/packages/api/src/code/http.spec.ts b/packages/api/src/code/http.spec.ts index f662a82ec92..b9b4df55e51 100644 --- a/packages/api/src/code/http.spec.ts +++ b/packages/api/src/code/http.spec.ts @@ -63,12 +63,10 @@ describe('code environment HTTP handlers', () => { }, }, } as AppConfig; - const clearOverrideCache = jest.fn().mockResolvedValue(undefined); const handlers = createCodeEnvironmentHttpHandlers({ getAppConfig: jest.fn().mockResolvedValue(appConfig), registry: { register, listAccessible: jest.fn() }, createEnvironmentId: () => 'personal-vm', - clearOverrideCache, }); const req = { user: { id: '68b2f0c498f24c1e78fa0001', role: 'USER' }, @@ -99,7 +97,6 @@ describe('code environment HTTP handlers', () => { workerId: 'deployment-worker', }, }); - expect(clearOverrideCache).toHaveBeenCalledWith(undefined); }); test('returns a generic 500 for operational registration failures', async () => { diff --git a/packages/api/src/code/http.ts b/packages/api/src/code/http.ts index 185817e774a..9cd73544889 100644 --- a/packages/api/src/code/http.ts +++ b/packages/api/src/code/http.ts @@ -30,7 +30,6 @@ export interface CodeEnvironmentHttpDeps { getAppConfig: (options: GetAppConfigOptions) => Promise; registry: Registry; createEnvironmentId?: () => string; - clearOverrideCache?: (tenantId?: string) => Promise; } function actor(req: ServerRequest): CodeEnvironmentPrincipalContext | null { @@ -108,9 +107,6 @@ export function createCodeEnvironmentHttpHandlers(deps: CodeEnvironmentHttpDeps) workerId: controlPlane.pairing?.workerId, }, }); - await deps.clearOverrideCache?.(req.user?.tenantId).catch((error: unknown) => { - logger.warn('[codeEnvironments] config cache invalidation failed:', error); - }); return res.status(201).json({ environment }); } catch (error) { const duplicate = From 0eff28adb9c7dc56446a22fed5390b7edfc342a8 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 30 Aug 2026 18:12:46 -0400 Subject: [PATCH 08/16] fix: bind code environments to current principals --- api/server/routes/code-environments.js | 18 +++-- api/server/routes/code-environments.test.js | 9 ++- api/server/services/Config/app.js | 3 +- packages/api/src/app/service.spec.ts | 1 + packages/api/src/app/service.ts | 3 +- packages/api/src/code/config.spec.ts | 65 +++++++++++++++++++ packages/api/src/code/config.ts | 6 +- .../src/code/environments.integration.spec.ts | 37 +++++++++++ packages/api/src/code/environments.ts | 19 +++++- 9 files changed, 150 insertions(+), 11 deletions(-) diff --git a/api/server/routes/code-environments.js b/api/server/routes/code-environments.js index 5f6377b1852..316ffe1d09a 100644 --- a/api/server/routes/code-environments.js +++ b/api/server/routes/code-environments.js @@ -6,12 +6,22 @@ const { getAppConfig, getCodeEnvironmentRegistry } = require('~/server/services/ const { requireJwtAuth } = require('~/server/middleware'); const router = express.Router(); -const registry = getCodeEnvironmentRegistry(); -const handlers = createCodeEnvironmentHttpHandlers({ getAppConfig, registry }); +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('/', handlers.list); -router.post('/', requireCodeEnvironmentManage, handlers.register); +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 index 5e83518f150..1be0bbca3a4 100644 --- a/api/server/routes/code-environments.test.js +++ b/api/server/routes/code-environments.test.js @@ -12,6 +12,7 @@ const mockRequireCapability = jest.fn((capability) => (req, _res, next) => { 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' } })), @@ -33,7 +34,7 @@ jest.mock('~/server/middleware/roles/capabilities', () => ({ jest.mock('~/server/middleware', () => ({ requireJwtAuth: mockRequireJwtAuth })); jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn(), - getCodeEnvironmentRegistry: jest.fn(() => mockRegistry), + getCodeEnvironmentRegistry: mockGetCodeEnvironmentRegistry, })); function createApp() { @@ -51,6 +52,12 @@ describe('code environment routes', () => { 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: [] }); diff --git a/api/server/services/Config/app.js b/api/server/services/Config/app.js index f110bc1e0c9..ce157640fc5 100644 --- a/api/server/services/Config/app.js +++ b/api/server/services/Config/app.js @@ -52,10 +52,11 @@ const { getAppConfig, clearAppConfigCache, clearOverrideCache } = createAppConfi cacheKeys: CacheKeys, getApplicableConfigs: db.getApplicableConfigs, getUserPrincipals: db.getUserPrincipals, - augmentConfig: ({ appConfig, principals, options }) => { + augmentConfig: ({ appConfig, baseConfig, principals, options }) => { if (!options.userId) return appConfig; return mergeAccessibleCodeEnvironments({ appConfig, + deploymentConfig: baseConfig, actor: { userId: options.userId, role: options.role ?? null, diff --git a/packages/api/src/app/service.spec.ts b/packages/api/src/app/service.spec.ts index e59c01619de..d0b4092b6a1 100644 --- a/packages/api/src/app/service.spec.ts +++ b/packages/api/src/app/service.spec.ts @@ -482,6 +482,7 @@ describe('createAppConfigService', () => { expect(augmentConfig).toHaveBeenCalledTimes(2); expect(augmentConfig).toHaveBeenCalledWith( expect.objectContaining({ + baseConfig: deps._baseConfig, principals: [ { principalType: 'role', principalId: 'USER' }, { principalType: 'user', principalId: 'uid1' }, diff --git a/packages/api/src/app/service.ts b/packages/api/src/app/service.ts index e4ba997179a..2f5d5043c87 100644 --- a/packages/api/src/app/service.ts +++ b/packages/api/src/app/service.ts @@ -62,6 +62,7 @@ export interface AppConfigServiceDeps { /** 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; @@ -251,7 +252,7 @@ export function createAppConfigService(deps: AppConfigServiceDeps): { const augment = async (appConfig: AppConfig): Promise => { if (augmentConfig == null) return appConfig; try { - return await augmentConfig({ appConfig, principals, options }); + return await augmentConfig({ appConfig, baseConfig, principals, options }); } catch (error) { logger.error('[getAppConfig] Error augmenting principal config:', error); return appConfig; diff --git a/packages/api/src/code/config.spec.ts b/packages/api/src/code/config.spec.ts index 6b8fbb305a4..dfe4e938e52 100644 --- a/packages/api/src/code/config.spec.ts +++ b/packages/api/src/code/config.spec.ts @@ -44,6 +44,7 @@ describe('mergeAccessibleCodeEnvironments', () => { const result = await mergeAccessibleCodeEnvironments({ appConfig, + deploymentConfig: appConfig, actor: { userId: '68b2f0c498f24c1e78fa0001', role: 'USER', idOnTheSource: null }, registry: { listAccessibleConfigurations }, }); @@ -80,10 +81,74 @@ describe('mergeAccessibleCodeEnvironments', () => { const result = await mergeAccessibleCodeEnvironments({ appConfig, + deploymentConfig: appConfig, actor: { userId: '68b2f0c498f24c1e78fa0001', role: 'USER', idOnTheSource: null }, registry: { listAccessibleConfigurations }, }); 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: { + 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', + ); + }); }); diff --git a/packages/api/src/code/config.ts b/packages/api/src/code/config.ts index 6f7192f5a1c..d44803d6ed0 100644 --- a/packages/api/src/code/config.ts +++ b/packages/api/src/code/config.ts @@ -13,10 +13,12 @@ type ConfigurationRegistry = { export async function mergeAccessibleCodeEnvironments({ appConfig, + deploymentConfig, actor, registry, }: { appConfig: AppConfig; + deploymentConfig: AppConfig; actor: CodeEnvironmentPrincipalContext; registry: ConfigurationRegistry; }): Promise { @@ -26,8 +28,10 @@ export async function mergeAccessibleCodeEnvironments({ const accessible = await registry.listAccessibleConfigurations(actor); if (accessible.length === 0) return appConfig; + const deploymentSessions = + deploymentConfig.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions; const deploymentEnvironments = new Map( - sessions.environments + deploymentSessions?.environments ?.filter( (environment) => environment.owner === 'deployment' && diff --git a/packages/api/src/code/environments.integration.spec.ts b/packages/api/src/code/environments.integration.spec.ts index cdd66f24c59..85d28eb8735 100644 --- a/packages/api/src/code/environments.integration.spec.ts +++ b/packages/api/src/code/environments.integration.spec.ts @@ -231,6 +231,43 @@ describe('code environment registry', () => { 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: { diff --git a/packages/api/src/code/environments.ts b/packages/api/src/code/environments.ts index c5e26ee8400..7cc43bf05f7 100644 --- a/packages/api/src/code/environments.ts +++ b/packages/api/src/code/environments.ts @@ -1,5 +1,5 @@ -import { randomUUID } from 'node:crypto'; import { Types } from 'mongoose'; +import { createHash, randomUUID } from 'node:crypto'; import { createMethods, getTenantId, logger } from '@librechat/data-schemas'; import { AccessRoleIds, @@ -206,8 +206,19 @@ export function createCodeEnvironmentRegistry( 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 load = async (): Promise => { - const environments = await findAccessible(actor); + const environments = await findAccessible({ ...actor, principals }); return environments.map((environment) => ({ id: environment.environmentId, name: environment.name, @@ -221,7 +232,9 @@ export function createCodeEnvironmentRegistry( const tenant = tenantCacheKey(); const revision = String((await configurationCache.get(revisionKey())) ?? '0'); - const key = `${CONFIGURATION_CACHE_USER_PREFIX}:${tenant}:${actor.userId.toString()}:${revision}`; + const key = + `${CONFIGURATION_CACHE_USER_PREFIX}:${tenant}:${actor.userId.toString()}:` + + `${principalFingerprint}:${revision}`; const cached = await configurationCache.get(key); if (Array.isArray(cached)) { return cached as AccessibleCodeEnvironmentConfiguration[]; From e8704a80e057b26b06cf11a61046882be5f3e57d Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 30 Aug 2026 18:26:12 -0400 Subject: [PATCH 09/16] fix: fail closed on code ACL cache errors --- .../controllers/PermissionsController.js | 6 ++- api/server/controllers/UserController.spec.js | 3 ++ .../__tests__/PermissionsController.spec.js | 25 ++++++++++ .../src/code/environments.integration.spec.ts | 41 +++++++++++++++ packages/api/src/code/environments.ts | 50 +++++++++++++++---- 5 files changed, 115 insertions(+), 10 deletions(-) diff --git a/api/server/controllers/PermissionsController.js b/api/server/controllers/PermissionsController.js index 825a51efa0d..d112904d7ab 100644 --- a/api/server/controllers/PermissionsController.js +++ b/api/server/controllers/PermissionsController.js @@ -159,7 +159,11 @@ const updateResourcePermissions = async (req, res) => { }); if (resourceType === ResourceType.CODE_ENVIRONMENT) { - await invalidateCodeEnvironmentConfigCache(req.user.tenantId); + 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 = diff --git a/api/server/controllers/UserController.spec.js b/api/server/controllers/UserController.spec.js index 59c102b6639..2c6b70cba99 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'); @@ -120,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 3abbd6ab4b2..95236f2ca3e 100644 --- a/api/server/controllers/__tests__/PermissionsController.spec.js +++ b/api/server/controllers/__tests__/PermissionsController.spec.js @@ -404,6 +404,31 @@ describe('PermissionsController', () => { 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/packages/api/src/code/environments.integration.spec.ts b/packages/api/src/code/environments.integration.spec.ts index 85d28eb8735..bf2e1b62315 100644 --- a/packages/api/src/code/environments.integration.spec.ts +++ b/packages/api/src/code/environments.integration.spec.ts @@ -231,6 +231,47 @@ describe('code environment registry', () => { 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 }); diff --git a/packages/api/src/code/environments.ts b/packages/api/src/code/environments.ts index 7cc43bf05f7..beee6fd34f8 100644 --- a/packages/api/src/code/environments.ts +++ b/packages/api/src/code/environments.ts @@ -43,6 +43,10 @@ export type AccessibleCodeEnvironmentConfiguration = { 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; @@ -186,13 +190,17 @@ export function createCodeEnvironmentRegistry( } } - async function findAccessible(actor: CodeEnvironmentPrincipalContext) { - const principals = actor.principals ?? (await methods.getUserPrincipals(actor)); - const ids = await access.findAccessibleResourcesForPrincipals({ + 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); } @@ -217,9 +225,16 @@ export function createCodeEnvironmentRegistry( .join('\n'), ) .digest('base64url'); - const load = async (): Promise => { - const environments = await findAccessible({ ...actor, principals }); + 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, @@ -228,7 +243,7 @@ export function createCodeEnvironmentRegistry( owner: 'principal', })); }; - if (configurationCache == null) return await load(); + if (configurationCache == null) return (await load()).map(toPublicConfiguration); const tenant = tenantCacheKey(); const revision = String((await configurationCache.get(revisionKey())) ?? '0'); @@ -236,8 +251,25 @@ export function createCodeEnvironmentRegistry( `${CONFIGURATION_CACHE_USER_PREFIX}:${tenant}:${actor.userId.toString()}:` + `${principalFingerprint}:${revision}`; const cached = await configurationCache.get(key); - if (Array.isArray(cached)) { - return cached as AccessibleCodeEnvironmentConfiguration[]; + 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(); @@ -246,7 +278,7 @@ export function createCodeEnvironmentRegistry( return await listAccessibleConfigurations(actor); } await configurationCache.set(key, configurations, CONFIGURATION_CACHE_TTL_MS); - return configurations; + return configurations.map(toPublicConfiguration); } return { From 414db13ad895195a1df78499915302ed1a6867a7 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 30 Aug 2026 18:32:26 -0400 Subject: [PATCH 10/16] fix: prevent code environment override shadowing --- packages/api/src/code/config.spec.ts | 67 ++++++++++++++++++++++++++++ packages/api/src/code/config.ts | 10 ++++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/packages/api/src/code/config.spec.ts b/packages/api/src/code/config.spec.ts index dfe4e938e52..f725e671a28 100644 --- a/packages/api/src/code/config.spec.ts +++ b/packages/api/src/code/config.spec.ts @@ -151,4 +151,71 @@ describe('mergeAccessibleCodeEnvironments', () => { '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', + 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().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', + }), + ]); + }); }); diff --git a/packages/api/src/code/config.ts b/packages/api/src/code/config.ts index d44803d6ed0..9c3ee1339a6 100644 --- a/packages/api/src/code/config.ts +++ b/packages/api/src/code/config.ts @@ -48,6 +48,9 @@ export async function mergeAccessibleCodeEnvironments({ }, ); if (principalEnvironments.length === 0) return appConfig; + const principalEnvironmentIds = new Set( + principalEnvironments.map((environment) => environment.id), + ); return { ...appConfig, @@ -57,7 +60,12 @@ export async function mergeAccessibleCodeEnvironments({ ...agents, statefulCodeSessions: { ...sessions, - environments: [...(sessions.environments ?? []), ...principalEnvironments], + environments: [ + ...(sessions.environments ?? []).filter( + (environment) => !principalEnvironmentIds.has(environment.id), + ), + ...principalEnvironments, + ], }, }, }, From b27a2eda7aa90af8afb49c9cca5848162893ef6e Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 30 Aug 2026 18:38:42 -0400 Subject: [PATCH 11/16] fix: preserve principal environment defaults --- packages/api/src/code/config.spec.ts | 2 ++ packages/api/src/code/config.ts | 15 ++++++++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/packages/api/src/code/config.spec.ts b/packages/api/src/code/config.spec.ts index f725e671a28..8b6fbd29f68 100644 --- a/packages/api/src/code/config.spec.ts +++ b/packages/api/src/code/config.spec.ts @@ -182,6 +182,7 @@ describe('mergeAccessibleCodeEnvironments', () => { type: 'attached', baseURL: 'https://shadow.example', owner: 'deployment', + default: true, pairing: { workerId: 'shadow-worker', tokenEnv: 'SHADOW_TOKEN' }, }, ], @@ -215,6 +216,7 @@ describe('mergeAccessibleCodeEnvironments', () => { name: 'Personal VM', baseURL: 'https://approved.example', owner: 'principal', + default: true, }), ]); }); diff --git a/packages/api/src/code/config.ts b/packages/api/src/code/config.ts index 9c3ee1339a6..39964dd2933 100644 --- a/packages/api/src/code/config.ts +++ b/packages/api/src/code/config.ts @@ -51,6 +51,19 @@ export async function mergeAccessibleCodeEnvironments({ 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, + ); return { ...appConfig, @@ -64,7 +77,7 @@ export async function mergeAccessibleCodeEnvironments({ ...(sessions.environments ?? []).filter( (environment) => !principalEnvironmentIds.has(environment.id), ), - ...principalEnvironments, + ...effectivePrincipalEnvironments, ], }, }, From 55250601747a71121ed35ffd308816d5c5a96824 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 30 Aug 2026 18:45:55 -0400 Subject: [PATCH 12/16] fix: suppress revoked code environment aliases --- packages/api/src/code/config.spec.ts | 59 ++++++++++++++++++- packages/api/src/code/config.ts | 28 ++++++--- .../src/code/environments.integration.spec.ts | 1 + packages/api/src/code/environments.ts | 6 ++ .../src/methods/codeEnvironment.ts | 6 ++ 5 files changed, 89 insertions(+), 11 deletions(-) diff --git a/packages/api/src/code/config.spec.ts b/packages/api/src/code/config.spec.ts index 8b6fbd29f68..f72a323277e 100644 --- a/packages/api/src/code/config.spec.ts +++ b/packages/api/src/code/config.spec.ts @@ -46,7 +46,10 @@ describe('mergeAccessibleCodeEnvironments', () => { appConfig, deploymentConfig: appConfig, actor: { userId: '68b2f0c498f24c1e78fa0001', role: 'USER', idOnTheSource: null }, - registry: { listAccessibleConfigurations }, + registry: { + listAccessibleConfigurations, + listRegisteredIds: jest.fn().mockResolvedValue(['personal-vm', 'deployment-vm']), + }, }); expect(result).not.toBe(appConfig); @@ -83,7 +86,10 @@ describe('mergeAccessibleCodeEnvironments', () => { appConfig, deploymentConfig: appConfig, actor: { userId: '68b2f0c498f24c1e78fa0001', role: 'USER', idOnTheSource: null }, - registry: { listAccessibleConfigurations }, + registry: { + listAccessibleConfigurations, + listRegisteredIds: jest.fn().mockResolvedValue(['personal-vm']), + }, }); expect(result).toBe(appConfig); @@ -131,6 +137,7 @@ describe('mergeAccessibleCodeEnvironments', () => { deploymentConfig, actor: { userId: '68b2f0c498f24c1e78fa0001', role: 'USER', idOnTheSource: null }, registry: { + listRegisteredIds: jest.fn().mockResolvedValue(['personal-vm']), listAccessibleConfigurations: jest.fn().mockResolvedValue([ { id: 'personal-vm', @@ -196,6 +203,7 @@ describe('mergeAccessibleCodeEnvironments', () => { deploymentConfig, actor: { userId: '68b2f0c498f24c1e78fa0001', role: 'USER', idOnTheSource: null }, registry: { + listRegisteredIds: jest.fn().mockResolvedValue(['personal-vm']), listAccessibleConfigurations: jest.fn().mockResolvedValue([ { id: 'personal-vm', @@ -220,4 +228,51 @@ describe('mergeAccessibleCodeEnvironments', () => { }), ]); }); + + 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', + 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' }), + ]); + }); }); diff --git a/packages/api/src/code/config.ts b/packages/api/src/code/config.ts index 39964dd2933..32c1085971f 100644 --- a/packages/api/src/code/config.ts +++ b/packages/api/src/code/config.ts @@ -9,6 +9,7 @@ type ConfigurationRegistry = { listAccessibleConfigurations: ( actor: CodeEnvironmentPrincipalContext, ) => Promise; + listRegisteredIds: () => Promise; }; export async function mergeAccessibleCodeEnvironments({ @@ -26,8 +27,10 @@ export async function mergeAccessibleCodeEnvironments({ const sessions = agents?.statefulCodeSessions; if (sessions == null) return appConfig; - const accessible = await registry.listAccessibleConfigurations(actor); - if (accessible.length === 0) return appConfig; + const [accessible, registeredIds] = await Promise.all([ + registry.listAccessibleConfigurations(actor), + registry.listRegisteredIds(), + ]); const deploymentSessions = deploymentConfig.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions; const deploymentEnvironments = new Map( @@ -40,6 +43,9 @@ export async function mergeAccessibleCodeEnvironments({ ) .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); @@ -47,7 +53,6 @@ export async function mergeAccessibleCodeEnvironments({ return [{ ...environment, baseURL: controlPlane.baseURL }]; }, ); - if (principalEnvironments.length === 0) return appConfig; const principalEnvironmentIds = new Set( principalEnvironments.map((environment) => environment.id), ); @@ -64,6 +69,16 @@ export async function mergeAccessibleCodeEnvironments({ ? { ...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; + } return { ...appConfig, @@ -73,12 +88,7 @@ export async function mergeAccessibleCodeEnvironments({ ...agents, statefulCodeSessions: { ...sessions, - environments: [ - ...(sessions.environments ?? []).filter( - (environment) => !principalEnvironmentIds.has(environment.id), - ), - ...effectivePrincipalEnvironments, - ], + environments: [...filteredEnvironments, ...effectivePrincipalEnvironments], }, }, }, diff --git a/packages/api/src/code/environments.integration.spec.ts b/packages/api/src/code/environments.integration.spec.ts index bf2e1b62315..f9f31cd6f54 100644 --- a/packages/api/src/code/environments.integration.spec.ts +++ b/packages/api/src/code/environments.integration.spec.ts @@ -60,6 +60,7 @@ describe('code environment registry', () => { 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]); diff --git a/packages/api/src/code/environments.ts b/packages/api/src/code/environments.ts index beee6fd34f8..d6fcfa86a44 100644 --- a/packages/api/src/code/environments.ts +++ b/packages/api/src/code/environments.ts @@ -122,6 +122,7 @@ export function createCodeEnvironmentRegistry( listAccessibleConfigurations: ( actor: CodeEnvironmentPrincipalContext, ) => Promise; + listRegisteredIds: () => Promise; invalidateAccessibleConfigurations: (tenantId?: string) => Promise; } { const methods = createMethods(mongoose); @@ -141,6 +142,10 @@ export function createCodeEnvironmentRegistry( await configurationCache.set(revisionKey(tenantId), randomUUID()); } + async function listRegisteredIds(): Promise { + return await methods.listCodeEnvironmentIds(); + } + async function register({ actor, environment: input, @@ -285,6 +290,7 @@ export function createCodeEnvironmentRegistry( register, listAccessible, listAccessibleConfigurations, + listRegisteredIds, invalidateAccessibleConfigurations, }; } diff --git a/packages/data-schemas/src/methods/codeEnvironment.ts b/packages/data-schemas/src/methods/codeEnvironment.ts index fcc8b66ddaa..43eff6ec9e1 100644 --- a/packages/data-schemas/src/methods/codeEnvironment.ts +++ b/packages/data-schemas/src/methods/codeEnvironment.ts @@ -18,6 +18,7 @@ export function createCodeEnvironmentMethods(mongoose: typeof import('mongoose') findCodeEnvironmentByEnvironmentId: ( environmentId: string, ) => Promise; + listCodeEnvironmentIds: () => Promise; deleteCodeEnvironmentById: ( id: string | Types.ObjectId, ) => Promise; @@ -47,6 +48,10 @@ export function createCodeEnvironmentMethods(mongoose: typeof import('mongoose') return await model().findOne({ environmentId }).lean(); } + async function listCodeEnvironmentIds(): Promise { + return await model().distinct('environmentId'); + } + async function deleteCodeEnvironmentById( id: string | Types.ObjectId, ): Promise { @@ -75,6 +80,7 @@ export function createCodeEnvironmentMethods(mongoose: typeof import('mongoose') createCodeEnvironment, findCodeEnvironmentsByIds, findCodeEnvironmentByEnvironmentId, + listCodeEnvironmentIds, deleteCodeEnvironmentById, deleteUserCodeEnvironments, }; From c0452c288d998f2e5b08b182826082590c443e7f Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 30 Aug 2026 18:54:12 -0400 Subject: [PATCH 13/16] fix: fail closed on environment augmentation --- packages/api/src/app/service.spec.ts | 19 +++++++++++++++++++ packages/api/src/app/service.ts | 7 +++++-- packages/api/src/code/config.spec.ts | 3 ++- packages/api/src/code/config.ts | 9 ++++++++- 4 files changed, 34 insertions(+), 4 deletions(-) diff --git a/packages/api/src/app/service.spec.ts b/packages/api/src/app/service.spec.ts index d0b4092b6a1..2cfa21dffaa 100644 --- a/packages/api/src/app/service.spec.ts +++ b/packages/api/src/app/service.spec.ts @@ -492,6 +492,25 @@ describe('createAppConfigService', () => { ); }); + it('falls back to the immutable base config when principal 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(deps._baseConfig); + expect(config).not.toEqual(expect.objectContaining({ endpoints: ['untrusted-override'] })); + }); + it('passes local identity through to getUserPrincipals when provided', async () => { const deps = createDeps(); const { getAppConfig } = createAppConfigService(deps); diff --git a/packages/api/src/app/service.ts b/packages/api/src/app/service.ts index 2f5d5043c87..ebc85d63b8f 100644 --- a/packages/api/src/app/service.ts +++ b/packages/api/src/app/service.ts @@ -254,8 +254,11 @@ export function createAppConfigService(deps: AppConfigServiceDeps): { try { return await augmentConfig({ appConfig, baseConfig, principals, options }); } catch (error) { - logger.error('[getAppConfig] Error augmenting principal config:', error); - return appConfig; + logger.error( + '[getAppConfig] Error augmenting principal config, falling back to base:', + error, + ); + return baseConfig; } }; diff --git a/packages/api/src/code/config.spec.ts b/packages/api/src/code/config.spec.ts index f72a323277e..8049564c7b2 100644 --- a/packages/api/src/code/config.spec.ts +++ b/packages/api/src/code/config.spec.ts @@ -241,6 +241,7 @@ describe('mergeAccessibleCodeEnvironments', () => { type: 'attached', baseURL: 'https://shadow.example', owner: 'deployment', + default: true, pairing: { workerId: 'shadow-worker', tokenEnv: 'SHADOW_TOKEN' }, }, { @@ -272,7 +273,7 @@ describe('mergeAccessibleCodeEnvironments', () => { }); expect(result.endpoints?.agents?.statefulCodeSessions?.environments).toEqual([ - expect.objectContaining({ id: 'unrelated-override' }), + expect.objectContaining({ id: 'unrelated-override', default: true }), ]); }); }); diff --git a/packages/api/src/code/config.ts b/packages/api/src/code/config.ts index 32c1085971f..2b2a7be4e34 100644 --- a/packages/api/src/code/config.ts +++ b/packages/api/src/code/config.ts @@ -79,6 +79,13 @@ export async function mergeAccessibleCodeEnvironments({ ) { return appConfig; } + const mergedEnvironments = [...filteredEnvironments, ...effectivePrincipalEnvironments]; + if ( + mergedEnvironments.length > 0 && + !mergedEnvironments.some((environment) => environment.default === true) + ) { + mergedEnvironments[0] = { ...mergedEnvironments[0], default: true }; + } return { ...appConfig, @@ -88,7 +95,7 @@ export async function mergeAccessibleCodeEnvironments({ ...agents, statefulCodeSessions: { ...sessions, - environments: [...filteredEnvironments, ...effectivePrincipalEnvironments], + environments: mergedEnvironments, }, }, }, From f01831d0ad6ab0a2a8010dd268a561218e4a2626 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 30 Aug 2026 19:02:44 -0400 Subject: [PATCH 14/16] fix: narrow code environment defaults --- packages/api/src/code/config.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/api/src/code/config.ts b/packages/api/src/code/config.ts index 2b2a7be4e34..cf877deae78 100644 --- a/packages/api/src/code/config.ts +++ b/packages/api/src/code/config.ts @@ -82,7 +82,9 @@ export async function mergeAccessibleCodeEnvironments({ const mergedEnvironments = [...filteredEnvironments, ...effectivePrincipalEnvironments]; if ( mergedEnvironments.length > 0 && - !mergedEnvironments.some((environment) => environment.default === true) + !mergedEnvironments.some( + (environment) => 'default' in environment && environment.default === true, + ) ) { mergedEnvironments[0] = { ...mergedEnvironments[0], default: true }; } From 3f7f0abc66d14db584a37b10428b8929b9e3a109 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 30 Aug 2026 19:09:05 -0400 Subject: [PATCH 15/16] fix: isolate code environment fallback --- packages/api/src/app/service.spec.ts | 9 ++- packages/api/src/app/service.ts | 7 +-- packages/api/src/code/config.spec.ts | 57 +++++++++++++++++++ packages/api/src/code/config.ts | 44 ++++++++++++-- .../src/code/environments.integration.spec.ts | 27 +++++++++ packages/api/src/code/environments.ts | 22 ++++++- 6 files changed, 153 insertions(+), 13 deletions(-) diff --git a/packages/api/src/app/service.spec.ts b/packages/api/src/app/service.spec.ts index 2cfa21dffaa..4283c633c54 100644 --- a/packages/api/src/app/service.spec.ts +++ b/packages/api/src/app/service.spec.ts @@ -492,7 +492,7 @@ describe('createAppConfigService', () => { ); }); - it('falls back to the immutable base config when principal augmentation fails', async () => { + it('preserves resolved principal restrictions when optional augmentation fails', async () => { const deps = createDeps({ getApplicableConfigs: jest.fn().mockResolvedValue([ { @@ -507,8 +507,11 @@ describe('createAppConfigService', () => { const config = await getAppConfig({ role: 'USER', userId: 'uid1' }); - expect(config).toEqual(deps._baseConfig); - expect(config).not.toEqual(expect.objectContaining({ endpoints: ['untrusted-override'] })); + expect(config).toEqual( + expect.objectContaining({ + endpoints: ['untrusted-override'], + }), + ); }); it('passes local identity through to getUserPrincipals when provided', async () => { diff --git a/packages/api/src/app/service.ts b/packages/api/src/app/service.ts index ebc85d63b8f..2f5d5043c87 100644 --- a/packages/api/src/app/service.ts +++ b/packages/api/src/app/service.ts @@ -254,11 +254,8 @@ export function createAppConfigService(deps: AppConfigServiceDeps): { try { return await augmentConfig({ appConfig, baseConfig, principals, options }); } catch (error) { - logger.error( - '[getAppConfig] Error augmenting principal config, falling back to base:', - error, - ); - return baseConfig; + logger.error('[getAppConfig] Error augmenting principal config:', error); + return appConfig; } }; diff --git a/packages/api/src/code/config.spec.ts b/packages/api/src/code/config.spec.ts index 8049564c7b2..6f8bf71b0a0 100644 --- a/packages/api/src/code/config.spec.ts +++ b/packages/api/src/code/config.spec.ts @@ -276,4 +276,61 @@ describe('mergeAccessibleCodeEnvironments', () => { 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 index cf877deae78..a5da50abaad 100644 --- a/packages/api/src/code/config.ts +++ b/packages/api/src/code/config.ts @@ -1,4 +1,5 @@ import { EModelEndpoint } from 'librechat-data-provider'; +import { logger } from '@librechat/data-schemas'; import type { AppConfig } from '@librechat/data-schemas'; import type { AccessibleCodeEnvironmentConfiguration, @@ -12,6 +13,31 @@ type ConfigurationRegistry = { 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, @@ -27,10 +53,20 @@ export async function mergeAccessibleCodeEnvironments({ const sessions = agents?.statefulCodeSessions; if (sessions == null) return appConfig; - const [accessible, registeredIds] = await Promise.all([ - registry.listAccessibleConfigurations(actor), - registry.listRegisteredIds(), - ]); + 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( diff --git a/packages/api/src/code/environments.integration.spec.ts b/packages/api/src/code/environments.integration.spec.ts index f9f31cd6f54..ae52b21bbad 100644 --- a/packages/api/src/code/environments.integration.spec.ts +++ b/packages/api/src/code/environments.integration.spec.ts @@ -194,6 +194,33 @@ describe('code environment registry', () => { ]); }); + 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 }); diff --git a/packages/api/src/code/environments.ts b/packages/api/src/code/environments.ts index d6fcfa86a44..aced59bb9df 100644 --- a/packages/api/src/code/environments.ts +++ b/packages/api/src/code/environments.ts @@ -51,6 +51,7 @@ 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 = { @@ -143,7 +144,26 @@ export function createCodeEnvironmentRegistry( } async function listRegisteredIds(): Promise { - return await methods.listCodeEnvironmentIds(); + 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({ From d31916ff47d00f5f5678bfce2bcb389aebf5c708 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 30 Aug 2026 19:15:19 -0400 Subject: [PATCH 16/16] style: sort code config imports --- packages/api/src/code/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/api/src/code/config.ts b/packages/api/src/code/config.ts index a5da50abaad..38a8e4088f8 100644 --- a/packages/api/src/code/config.ts +++ b/packages/api/src/code/config.ts @@ -1,5 +1,5 @@ -import { EModelEndpoint } from 'librechat-data-provider'; import { logger } from '@librechat/data-schemas'; +import { EModelEndpoint } from 'librechat-data-provider'; import type { AppConfig } from '@librechat/data-schemas'; import type { AccessibleCodeEnvironmentConfiguration,