diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index 961ffbc4ef..cc5ba64662 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -370,6 +370,7 @@ const loadTools = async ({ codeApiBaseUrl: codeExecutionContext.baseUrl, executionProfile: codeExecutionContext.executionProfile, executionRouteKey: codeExecutionContext.executionRouteKey, + bridgeWorkerId: codeExecutionContext.bridgeWorkerId, }); if (toolContext) { dynamicToolContextMap[tool] = toolContext; diff --git a/api/app/clients/tools/util/handleTools.test.js b/api/app/clients/tools/util/handleTools.test.js index 6ceaa578cf..4b9f38e70b 100644 --- a/api/app/clients/tools/util/handleTools.test.js +++ b/api/app/clients/tools/util/handleTools.test.js @@ -11,8 +11,10 @@ const mockCreateMCPTool = jest.fn(); const mockCreateMCPTools = jest.fn(); const mockGetServerConfig = jest.fn(); const mockGetAccessibleMcpServerNames = jest.fn(async () => []); +const mockPrimeCodeFiles = jest.fn(async () => ({ files: [], toolContext: undefined })); const mockCreateSearchTool = jest.fn(() => ({ name: 'web_search' })); +const mockCreateCodeExecutionTool = jest.fn(() => ({ name: 'execute_code' })); const mockLoadWebSearchAuth = jest.fn(async () => ({ authenticated: true, authResult: { searchProvider: 'serper', searxngInstanceUrl: 'http://searxng.internal:8080' }, @@ -21,6 +23,11 @@ const mockLoadWebSearchAuth = jest.fn(async () => ({ jest.mock('@librechat/agents', () => ({ ...jest.requireActual('@librechat/agents'), createSearchTool: (...args) => mockCreateSearchTool(...args), + createCodeExecutionTool: (...args) => mockCreateCodeExecutionTool(...args), +})); + +jest.mock('~/server/services/Files/Code/process', () => ({ + primeFiles: (...args) => mockPrimeCodeFiles(...args), })); jest.mock('@librechat/api', () => ({ @@ -365,6 +372,32 @@ describe('Tool Handlers', () => { expect(loadedTools[0].name).toBe(ASK_USER_QUESTION_TOOL_NAME); }); + it('routes code file priming to the selected bridge worker', async () => { + const bridgeWorkerId = 'principal-worker'; + const toolMap = await loadTools({ + user: fakeUser._id.toString(), + tools: [Tools.execute_code], + returnMap: true, + agent: { id: 'agent-1' }, + options: { + codeExecutionContext: { + baseUrl: 'https://code.example.com/v1', + bridgeWorkerId, + }, + }, + }); + + await toolMap[Tools.execute_code](); + + expect(mockPrimeCodeFiles).toHaveBeenCalledWith( + expect.objectContaining({ + agentId: 'agent-1', + codeApiBaseUrl: 'https://code.example.com/v1', + bridgeWorkerId, + }), + ); + }); + it('passes request body to chat MCP tool creation and skips stale cache for BODY-scoped servers', async () => { const serverName = 'body-scoped'; const toolKey = `search${Constants.mcp_delimiter}${serverName}`; diff --git a/api/server/controllers/UserController.js b/api/server/controllers/UserController.js index f4fe17d1e4..d90f7f0f5b 100644 --- a/api/server/controllers/UserController.js +++ b/api/server/controllers/UserController.js @@ -12,6 +12,7 @@ const { getWebSearchUninstallFields, deleteAgentCheckpoints, deleteAllSharedLinksWithCleanup, + revokeUserCodeEnvironmentWorkers, } = require('@librechat/api'); const { Tools, @@ -404,6 +405,7 @@ const deleteUserController = async (req, res) => { if (triggerDeletionFence != null) { await prepareAgentTriggerUserPurge(user.id, triggerDeletionFence, user.tenantId); } + const deletionAppConfig = await getAppConfig({ baseOnly: true }); await drainAgentTriggerDeliveriesForUser(user.id); await subagentThreadTaskStore.cancelAndDrainForOwner(user.id, user.tenantId); // Reversibly suspend the user's schedules under a per-attempt token BEFORE draining. @@ -460,10 +462,6 @@ const deleteUserController = async (req, res) => { await db.deleteAllUserMemories(user.id); await db.deleteUserPrompts(user.id); await db.deleteUserSkills(user.id); - await db.deleteUserCodeEnvironments(user.id); - await invalidateCodeEnvironmentConfigCache(user.tenantId).catch((error) => { - logger.error('[deleteUserController] code environment cache invalidation failed:', error); - }); await deleteUserMcpServers(user.id); await db.deleteActions({ user: user.id }); await db.deleteTokens({ userId: user.id }); @@ -475,6 +473,27 @@ const deleteUserController = async (req, res) => { throw new Error('User disappeared before account deletion could commit'); } userDeleted = true; + let codeEnvironmentCleanupSafe = true; + try { + await revokeUserCodeEnvironmentWorkers({ + mongoose, + userId: user.id, + appConfig: deletionAppConfig, + }); + } catch (error) { + codeEnvironmentCleanupSafe = false; + logger.error('[deleteUserController] Failed to revoke code environment workers', error); + } + if (codeEnvironmentCleanupSafe) { + try { + await db.deleteUserCodeEnvironments(user.id); + } catch (error) { + logger.error('[deleteUserController] Failed to delete code environments', error); + } + } + await invalidateCodeEnvironmentConfigCache(user.tenantId).catch((error) => { + logger.error('[deleteUserController] code environment cache invalidation failed:', error); + }); await purgeAgentTriggerDeliveriesForUser(user.id); logger.info(`User deleted account. Email: ${user.email} ID: ${user.id}`); res.status(200).send({ message: 'User deleted' }); diff --git a/api/server/controllers/UserController.spec.js b/api/server/controllers/UserController.spec.js index 2c6b70cba9..7d3fd9ff8e 100644 --- a/api/server/controllers/UserController.spec.js +++ b/api/server/controllers/UserController.spec.js @@ -12,6 +12,7 @@ const mockQuiesceUserSchedules = jest.fn().mockResolvedValue(true); const mockRestoreUserSchedules = jest.fn().mockResolvedValue(undefined); const mockGetWebSearchInstallEntries = jest.fn(); const mockInvalidateCodeEnvironmentConfigCache = jest.fn().mockResolvedValue(undefined); +const mockRevokeUserCodeEnvironmentWorkers = jest.fn().mockResolvedValue(0); jest.mock('@librechat/data-schemas', () => { const actual = jest.requireActual('@librechat/data-schemas'); @@ -90,6 +91,7 @@ jest.mock('@librechat/api', () => ({ needsRefresh: jest.fn(), getNewS3URL: jest.fn(), getWebSearchInstallEntries: (...args) => mockGetWebSearchInstallEntries(...args), + revokeUserCodeEnvironmentWorkers: (...args) => mockRevokeUserCodeEnvironmentWorkers(...args), GenerationJobManager: { getCleanupBlockingJobIdsForUser: (...args) => mockGetActiveJobIdsForUser(...args), abortJob: (...args) => mockAbortJob(...args), @@ -161,6 +163,7 @@ const { deleteConvos, acceptTerms, deleteUserById, + deleteUserCodeEnvironments, deleteMessages, beginAgentTriggerUserDeletion, cancelAgentTriggerUserDeletion, @@ -491,6 +494,12 @@ describe('deleteUserController', () => { expect(deleteUserById.mock.invocationCallOrder[0]).toBeLessThan( mockPurgeAgentTriggerDeliveriesForUser.mock.invocationCallOrder[0], ); + expect(deleteUserById.mock.invocationCallOrder[0]).toBeLessThan( + mockRevokeUserCodeEnvironmentWorkers.mock.invocationCallOrder[0], + ); + expect(mockRevokeUserCodeEnvironmentWorkers.mock.invocationCallOrder[0]).toBeLessThan( + deleteUserCodeEnvironments.mock.invocationCallOrder[0], + ); expect(mockPurgeAgentTriggerDeliveriesForUser).toHaveBeenCalledWith(userId.toString()); expect(cancelAgentTriggerUserDeletion).not.toHaveBeenCalled(); expect(mockCancelAgentTriggerUserPurge).not.toHaveBeenCalled(); diff --git a/api/server/controllers/__tests__/deleteUser.spec.js b/api/server/controllers/__tests__/deleteUser.spec.js index 23c152b30b..aabf4a63c0 100644 --- a/api/server/controllers/__tests__/deleteUser.spec.js +++ b/api/server/controllers/__tests__/deleteUser.spec.js @@ -32,6 +32,7 @@ const mockCancelAgentTriggerUserDeletion = jest.fn(); const mockCancelAndDrainSubagentThreads = jest.fn(); const mockQuiesceUserSchedules = jest.fn(); const mockDeleteSchedulesByUser = jest.fn(); +const mockRevokeUserCodeEnvironmentWorkers = jest.fn(); jest.mock('@librechat/data-schemas', () => ({ logger: { error: jest.fn(), info: jest.fn() }, @@ -53,6 +54,7 @@ jest.mock('@librechat/api', () => ({ needsRefresh: jest.fn(), getNewS3URL: jest.fn(), deleteAllSharedLinksWithCleanup: (...args) => mockDeleteAllSharedLinksWithCleanup(...args), + revokeUserCodeEnvironmentWorkers: (...args) => mockRevokeUserCodeEnvironmentWorkers(...args), GenerationJobManager: { getCleanupBlockingJobIdsForUser: (...args) => mockGetCleanupBlockingJobIdsForUser(...args), abortJob: (...args) => mockAbortJob(...args), @@ -138,7 +140,7 @@ jest.mock('~/server/services/Schedules', () => ({ })); jest.mock('~/server/services/Config', () => ({ - getAppConfig: jest.fn(), + getAppConfig: jest.fn().mockResolvedValue({}), invalidateCodeEnvironmentConfigCache: (...args) => mockInvalidateCodeEnvironmentConfigCache(...args), })); @@ -186,6 +188,7 @@ function stubDeletionMocks() { mockCancelAndDrainSubagentThreads.mockResolvedValue(); mockQuiesceUserSchedules.mockResolvedValue(true); mockDeleteSchedulesByUser.mockResolvedValue(); + mockRevokeUserCodeEnvironmentWorkers.mockResolvedValue(0); } beforeEach(() => { @@ -208,6 +211,9 @@ describe('deleteUserController - 2FA enforcement', () => { expect(mockDeleteUserPrompts).toHaveBeenCalledWith('user1'); expect(mockDeleteUserSkills).toHaveBeenCalledWith('user1'); expect(mockInvalidateCodeEnvironmentConfigCache).toHaveBeenCalledWith(undefined); + expect(mockRevokeUserCodeEnvironmentWorkers).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'user1' }), + ); expect(mockVerifyOTPOrBackupCode).not.toHaveBeenCalled(); expect(mockBeginAgentTriggerUserDeletion.mock.invocationCallOrder[0]).toBeLessThan( mockPrepareAgentTriggerUserPurge.mock.invocationCallOrder[0], @@ -221,6 +227,12 @@ describe('deleteUserController - 2FA enforcement', () => { expect(mockDeleteMessages.mock.invocationCallOrder[0]).toBeLessThan( mockDeleteUserById.mock.invocationCallOrder[0], ); + expect(mockDeleteUserById.mock.invocationCallOrder[0]).toBeLessThan( + mockRevokeUserCodeEnvironmentWorkers.mock.invocationCallOrder[0], + ); + expect(mockRevokeUserCodeEnvironmentWorkers.mock.invocationCallOrder[0]).toBeLessThan( + mockDeleteUserCodeEnvironments.mock.invocationCallOrder[0], + ); expect(mockCancelAgentTriggerUserDeletion).not.toHaveBeenCalled(); expect(mockCancelAgentTriggerUserPurge).not.toHaveBeenCalled(); }); @@ -243,6 +255,23 @@ describe('deleteUserController - 2FA enforcement', () => { ); }); + it('preserves code environment records when revocation marking fails after deletion', async () => { + const req = { user: { id: 'user1', _id: 'user1', email: 'a@b.com' }, body: {} }; + const res = createRes(); + mockGetUserById.mockResolvedValue({ _id: 'user1', twoFactorEnabled: false }); + mockRevokeUserCodeEnvironmentWorkers.mockRejectedValueOnce(new Error('worker unavailable')); + + await deleteUserController(req, res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(mockBeginAgentTriggerUserDeletion).toHaveBeenCalledTimes(1); + expect(mockDeleteMessages).toHaveBeenCalled(); + expect(mockDeleteUserById).toHaveBeenCalledWith('user1'); + expect(mockDeleteUserCodeEnvironments).not.toHaveBeenCalled(); + expect(mockCancelAgentTriggerUserPurge).not.toHaveBeenCalled(); + expect(mockCancelAgentTriggerUserDeletion).not.toHaveBeenCalled(); + }); + it('proceeds with deletion when user has no 2FA record', async () => { const req = { user: { id: 'user1', _id: 'user1', email: 'a@b.com' }, body: {} }; const res = createRes(); diff --git a/api/server/controllers/agents/__tests__/v1.duplicate-actions.spec.js b/api/server/controllers/agents/__tests__/v1.duplicate-actions.spec.js index cc298bd03a..f0a4bf6095 100644 --- a/api/server/controllers/agents/__tests__/v1.duplicate-actions.spec.js +++ b/api/server/controllers/agents/__tests__/v1.duplicate-actions.spec.js @@ -102,6 +102,49 @@ describe('duplicateAgentHandler — action domain extraction', () => { expect(duplicatedAction.metadata.domain).toBe('api.example.com'); }); + it('removes cloned actions when Agent creation is rejected', async () => { + const userId = new mongoose.Types.ObjectId(); + const originalAgentId = 'agent_conflicted_clone_source'; + const agent = await mongoose.models.Agent.create({ + id: originalAgentId, + name: 'Conflicted clone source', + author: userId.toString(), + provider: 'openai', + model: 'gpt-4', + tools: [], + actions: [`api.example.com${actionDelimiter}act_original`], + versions: [{ name: 'Conflicted clone source', createdAt: new Date(), updatedAt: new Date() }], + }); + await mongoose.models.Action.create({ + user: userId, + action_id: 'act_original', + agent_id: originalAgentId, + metadata: { domain: 'api.example.com' }, + }); + const createAgent = jest + .spyOn(mongoose.models.Agent, 'create') + .mockRejectedValueOnce( + Object.assign(new Error('Code environment is being removed'), { statusCode: 409 }), + ); + const req = { + params: { id: agent.id }, + user: { id: userId.toString() }, + }; + const res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + + await duplicateAgent(req, res); + + expect(res.status).toHaveBeenCalledWith(409); + await expect(mongoose.models.Action.countDocuments()).resolves.toBe(1); + await expect( + mongoose.models.Action.findOne({ action_id: 'act_original', agent_id: originalAgentId }), + ).resolves.not.toBeNull(); + createAgent.mockRestore(); + }); + it('strips sensitive metadata fields from duplicated actions', async () => { const userId = new mongoose.Types.ObjectId(); const originalAgentId = 'agent_sensitive'; diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index f22217529b..20c1478697 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -918,6 +918,9 @@ const createAgentHandler = async (req, res) => { return res.status(400).json({ error: 'Invalid request data', details: error.errors }); } logger.error('[/Agents] Error creating agent', error); + if (error?.statusCode === 409) { + return res.status(409).json({ error: error.message }); + } res.status(500).json({ error: error.message }); } }; @@ -1568,7 +1571,18 @@ const duplicateAgentHandler = async (req, res) => { ); newAgentData.actions = agentActions; - const newAgent = await db.createAgent(newAgentData); + let newAgent; + try { + newAgent = await db.createAgent(newAgentData); + } catch (error) { + await db.deleteActions({ agent_id: newAgentId, user: userId }).catch((cleanupError) => { + logger.error( + '[/agents/:id/duplicate] Failed to clean up cloned Actions after Agent creation failed:', + cleanupError, + ); + }); + throw error; + } try { await Promise.all([ @@ -1605,7 +1619,9 @@ const duplicateAgentHandler = async (req, res) => { }); } catch (error) { logger.error('[/Agents/:id/duplicate] Error duplicating Agent:', error); - + if (error?.statusCode === 409) { + return res.status(409).json({ error: error.message }); + } res.status(500).json({ error: error.message }); } }; @@ -2134,6 +2150,9 @@ const revertAgentVersionHandler = async (req, res) => { return res.json(updatedAgent); } catch (error) { logger.error('[/agents/:id/revert] Error reverting Agent version', error); + if (error?.statusCode === 409) { + return res.status(409).json({ error: error.message }); + } res.status(500).json({ error: error.message }); } }; diff --git a/api/server/routes/admin/users.js b/api/server/routes/admin/users.js index ab8f5fec8b..b07c222c77 100644 --- a/api/server/routes/admin/users.js +++ b/api/server/routes/admin/users.js @@ -1,5 +1,6 @@ const express = require('express'); -const { createAdminUsersHandlers } = require('@librechat/api'); +const mongoose = require('mongoose'); +const { createAdminUsersHandlers, revokeUserCodeEnvironmentWorkers } = require('@librechat/api'); const { SystemCapabilities } = require('@librechat/data-schemas'); const { requireCapability } = require('~/server/middleware/roles/capabilities'); const { requireJwtAuth } = require('~/server/middleware'); @@ -10,7 +11,7 @@ const { purgeAgentTriggerDeliveriesForUser, } = require('~/server/services/Agents/triggers'); const db = require('~/models'); -const { invalidateCodeEnvironmentConfigCache } = require('~/server/services/Config'); +const { getAppConfig, invalidateCodeEnvironmentConfigCache } = require('~/server/services/Config'); const router = express.Router(); @@ -27,6 +28,12 @@ const handlers = createAdminUsersHandlers({ prepareAgentTriggerUserPurge, cancelAgentTriggerUserPurge, purgeAgentTriggerDeliveriesForUser, + revokeUserCodeEnvironmentWorkers: async (userId) => + revokeUserCodeEnvironmentWorkers({ + mongoose, + userId, + appConfig: await getAppConfig({ baseOnly: true }), + }), deleteUserById: db.deleteUserById, deleteUserCodeEnvironments: db.deleteUserCodeEnvironments, invalidateCodeEnvironmentConfigCache, diff --git a/api/server/routes/code-environments.js b/api/server/routes/code-environments.js index 9d66bdff4e..4e866dbe92 100644 --- a/api/server/routes/code-environments.js +++ b/api/server/routes/code-environments.js @@ -1,18 +1,25 @@ const express = require('express'); -const { createCodeEnvironmentHttpHandlers } = require('@librechat/api'); +const mongoose = require('mongoose'); +const { + createCodeEnvironmentHttpHandlers, + startCodeEnvironmentLifecycleReconciler, +} = require('@librechat/api'); const { SystemCapabilities } = require('@librechat/data-schemas'); const { requireCapability } = require('~/server/middleware/roles/capabilities'); const { codeEnvironmentPairingLimiter } = require('~/server/middleware/limiters/code'); const { getAppConfig, getCodeEnvironmentRegistry } = require('~/server/services/Config'); const { requireJwtAuth } = require('~/server/middleware'); +const db = require('~/models'); const router = express.Router(); +startCodeEnvironmentLifecycleReconciler({ mongoose }); let handlers; function getHandlers() { if (handlers == null) { handlers = createCodeEnvironmentHttpHandlers({ getAppConfig, registry: getCodeEnvironmentRegistry(), + principalIsActive: db.isAgentTriggerPrincipalActive, }); } return handlers; @@ -27,5 +34,6 @@ router.post('/pairings', codeEnvironmentPairingLimiter, (req, res, next) => router.post('/', requireCodeEnvironmentManage, (req, res, next) => getHandlers().register(req, res, next), ); +router.delete('/:environmentId', (req, res, next) => getHandlers().remove(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 d483920040..eda05129a1 100644 --- a/api/server/routes/code-environments.test.js +++ b/api/server/routes/code-environments.test.js @@ -21,6 +21,7 @@ const mockHandlers = { list: jest.fn((_req, res) => res.status(200).json({ environments: [] })), register: jest.fn((_req, res) => res.status(201).json({ environment: { id: 'code-1' } })), pair: jest.fn((_req, res) => res.status(201).json({ environment: { id: 'code-1' } })), + remove: jest.fn((_req, res) => res.status(200).json({ environment: { id: 'code-1' } })), }; jest.mock('@librechat/data-schemas', () => ({ @@ -30,6 +31,7 @@ jest.mock('@librechat/data-schemas', () => ({ jest.mock('@librechat/api', () => ({ createCodeEnvironmentRegistry: jest.fn(() => mockRegistry), createCodeEnvironmentHttpHandlers: jest.fn(() => mockHandlers), + startCodeEnvironmentLifecycleReconciler: jest.fn(), })); jest.mock('~/server/middleware/roles/capabilities', () => ({ @@ -44,6 +46,7 @@ jest.mock('~/server/services/Config', () => ({ getAppConfig: jest.fn(), getCodeEnvironmentRegistry: mockGetCodeEnvironmentRegistry, })); +jest.mock('~/models', () => ({ isAgentTriggerPrincipalActive: jest.fn() })); function createApp() { delete require.cache[require.resolve('./code-environments')]; @@ -92,4 +95,11 @@ describe('code environment routes', () => { expect(middlewareCalls).toEqual(['jwt', 'pairing-limit']); expect(mockHandlers.pair).toHaveBeenCalledTimes(1); }); + + it('allows an authenticated owner to remove an environment', async () => { + await request(createApp()).delete('/api/code-environments/code-1').expect(200); + + expect(middlewareCalls).toEqual(['jwt']); + expect(mockHandlers.remove).toHaveBeenCalledTimes(1); + }); }); diff --git a/client/src/components/Nav/Settings/CodeEnvironments.tsx b/client/src/components/Nav/Settings/CodeEnvironments.tsx new file mode 100644 index 0000000000..6b7204f2ba --- /dev/null +++ b/client/src/components/Nav/Settings/CodeEnvironments.tsx @@ -0,0 +1,213 @@ +import { useEffect, useState } from 'react'; +import { + Button, + Input, + Label, + OGDialog, + OGDialogTemplate, + OGDialogTrigger, + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + Spinner, + useToastContext, +} from '@librechat/client'; +import type { CodeEnvironmentPairingResponse } from '~/data-provider/CodeEnvironments'; +import { + useCodeEnvironmentsQuery, + useDeleteCodeEnvironmentMutation, + usePairCodeEnvironmentMutation, +} from '~/data-provider'; +import { useLocalize } from '~/hooks'; + +function WorkerCommand({ pairing }: { pairing: CodeEnvironmentPairingResponse['pairing'] }) { + const { showToast } = useToastContext(); + const localize = useLocalize(); + const shellQuote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`; + const command = `librechat-code pair ${shellQuote(pairing.endpoint)} ${shellQuote(pairing.code)} --worker-id ${shellQuote(pairing.workerId)}`; + return ( +
+

+ {localize('com_ui_code_environment_pairing_ready')} +

+

+ {localize('com_ui_code_environment_pairing_description')} +

+
+        {command}
+      
+ +
+ ); +} + +export default function CodeEnvironments() { + const localize = useLocalize(); + const { showToast } = useToastContext(); + const query = useCodeEnvironmentsQuery(); + const pairMutation = usePairCodeEnvironmentMutation(); + const deleteMutation = useDeleteCodeEnvironmentMutation(); + const [name, setName] = useState(''); + const [controlPlaneId, setControlPlaneId] = useState(''); + const [pairing, setPairing] = useState(); + const controlPlanes = query.data?.controlPlanes ?? []; + const selectedControlPlane = controlPlaneId || controlPlanes[0]?.id || ''; + + useEffect(() => { + if (pairing == null) return; + const remaining = Date.parse(pairing.expiresAt) - Date.now(); + const delay = Number.isFinite(remaining) ? Math.max(0, remaining) : 0; + const timeout = window.setTimeout(() => setPairing(undefined), delay); + return () => window.clearTimeout(timeout); + }, [pairing]); + + const pair = () => { + const trimmedName = name.trim(); + if (!trimmedName || !selectedControlPlane) return; + pairMutation.mutate( + { name: trimmedName, controlPlaneId: selectedControlPlane }, + { + onSuccess: (result) => { + setName(''); + setPairing(result.pairing); + showToast({ + message: localize('com_ui_code_environment_pairing_created'), + status: 'success', + }); + }, + onError: () => + showToast({ + message: localize('com_ui_code_environment_pairing_error'), + status: 'error', + }), + }, + ); + }; + + let pairingControls = ( +

{localize('com_ui_code_environment_unavailable')}

+ ); + if (query.isLoading) { + pairingControls = ( +
+ + {localize('com_ui_loading')} +
+ ); + } else if (query.isError) { + pairingControls = ( +

+ {localize('com_ui_code_environment_load_error')} +

+ ); + } else if (controlPlanes.length > 0) { + pairingControls = ( +
+ setName(event.target.value)} + /> + + +
+ ); + } + + return ( +
+
+ +

+ {localize('com_ui_code_environments_description')} +

+
+ + {pairingControls} + + {pairing != null && } + +
+ {(query.data?.environments ?? []).map((environment) => ( +
+
+

{environment.name}

+

{environment.id}

+
+ {environment.canDelete && ( + + + + + + {localize('com_ui_code_environment_remove_description')} +

+ } + selection={{ + selectHandler: () => + deleteMutation.mutate(environment.id, { + onSuccess: () => + showToast({ + message: localize('com_ui_code_environment_removed'), + status: 'success', + }), + onError: () => + showToast({ + message: localize('com_ui_code_environment_remove_error'), + status: 'error', + }), + }), + selectClasses: + 'bg-surface-destructive text-text-on-status transition-all duration-200 hover:bg-surface-destructive-hover', + selectText: localize('com_ui_delete'), + }} + /> +
+ )} +
+ ))} +
+
+ ); +} diff --git a/client/src/components/Nav/Settings/registry.tsx b/client/src/components/Nav/Settings/registry.tsx index 2a42db8759..8da07921fa 100644 --- a/client/src/components/Nav/Settings/registry.tsx +++ b/client/src/components/Nav/Settings/registry.tsx @@ -47,6 +47,7 @@ import { showThinkingAtom } from '~/store/showThinking'; import ProviderKeys from '../SettingsTabs/ProviderKeys'; import { autoScrollAtom } from '~/store/autoScroll'; import Avatar from '../SettingsTabs/Account/Avatar'; +import CodeEnvironments from './CodeEnvironments'; import About from '../SettingsTabs/About/About'; import ApiKeys from '../SettingsTabs/ApiKeys'; import MemoryToggle from './MemoryToggle'; @@ -588,6 +589,15 @@ export const registry: SettingEntry[] = [ show: (ctx) => ctx.hasStatefulCodeSessions, Component: StatefulWorkspaceDefault, }, + { + id: 'codeEnvironments', + tab: DATA, + section: 'codeExecution', + labelKey: 'com_ui_code_environments', + keywords: ['bridge', 'cli', 'code', 'environment', 'sandbox', 'vm', 'worker'], + show: (ctx) => ctx.hasStatefulCodeSessions, + Component: CodeEnvironments, + }, // Data controls · Your data { id: 'importConversations', diff --git a/client/src/data-provider/CodeEnvironments.ts b/client/src/data-provider/CodeEnvironments.ts new file mode 100644 index 0000000000..07c3ca65ea --- /dev/null +++ b/client/src/data-provider/CodeEnvironments.ts @@ -0,0 +1,44 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { MutationKeys, QueryKeys, dataService } from 'librechat-data-provider'; +import type { + TCodeEnvironmentPairingResponse, + TCodeEnvironmentsResponse, +} from 'librechat-data-provider'; + +export type CodeEnvironmentPairingResponse = TCodeEnvironmentPairingResponse; + +export function useCodeEnvironmentsQuery(enabled = true) { + return useQuery( + [QueryKeys.codeEnvironments], + () => dataService.getCodeEnvironments(), + { enabled }, + ); +} + +export function usePairCodeEnvironmentMutation() { + const queryClient = useQueryClient(); + return useMutation< + TCodeEnvironmentPairingResponse, + Error, + { name: string; controlPlaneId: string } + >([MutationKeys.pairCodeEnvironment], dataService.pairCodeEnvironment, { + onSuccess: () => { + queryClient.invalidateQueries([QueryKeys.codeEnvironments]); + queryClient.invalidateQueries([QueryKeys.endpoints]); + }, + }); +} + +export function useDeleteCodeEnvironmentMutation() { + const queryClient = useQueryClient(); + return useMutation< + { environment: TCodeEnvironmentsResponse['environments'][number] }, + Error, + string + >([MutationKeys.deleteCodeEnvironment], dataService.deleteCodeEnvironment, { + onSuccess: () => { + queryClient.invalidateQueries([QueryKeys.codeEnvironments]); + queryClient.invalidateQueries([QueryKeys.endpoints]); + }, + }); +} diff --git a/client/src/data-provider/index.ts b/client/src/data-provider/index.ts index 090c8b9689..38bb19a958 100644 --- a/client/src/data-provider/index.ts +++ b/client/src/data-provider/index.ts @@ -16,6 +16,7 @@ export * from './Subagents'; export * from './Tools'; export * from './connection'; export * from './Favorites'; +export * from './CodeEnvironments'; export * from './mutations'; export * from './prompts'; export * from './queries'; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 59cffed4f1..db284326f6 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -2183,6 +2183,21 @@ "com_ui_stateful_code_environment_conversation": "Conversation workspace", "com_ui_default_stateful_workspace": "Default stateful workspace", "com_ui_default_stateful_workspace_description": "Applied when you create a new agent. Existing agents keep their current workspace scope.", + "com_ui_code_environments": "Code environments", + "com_ui_code_environments_description": "Connect a sandboxed librechat-code worker running on a VM you control. The worker connects outbound and must be configured with its own sandbox and network policy.", + "com_ui_code_environment_name_placeholder": "My development VM", + "com_ui_code_environment_control_plane": "Code control plane", + "com_ui_code_environment_pair": "Connect VM", + "com_ui_code_environment_unavailable": "This LibreChat instance has not enabled self-service code workers.", + "com_ui_code_environment_load_error": "Code environments could not be loaded. Try again shortly.", + "com_ui_code_environment_pairing_ready": "One-time worker command", + "com_ui_code_environment_pairing_description": "Run this on your VM, then start librechat-code with LIBRECHAT_CODE_SANDBOX_ENDPOINT set to your sandbox service. The command expires shortly and is shown only once.", + "com_ui_code_environment_pairing_created": "Code environment created", + "com_ui_code_environment_pairing_error": "Could not create the code environment", + "com_ui_code_environment_remove_title": "Remove code environment?", + "com_ui_code_environment_remove_description": "This revokes the worker before removing it from LibreChat. Its sandbox files remain on your VM.", + "com_ui_code_environment_removed": "Code environment removed", + "com_ui_code_environment_remove_error": "Could not revoke the code environment", "com_ui_status_prefix": "Status:", "com_ui_steer": "Steer", "com_ui_steer_already_applied": "That steering message already reached the agent, so it was left in the response", diff --git a/config/__tests__/delete-user.spec.js b/config/__tests__/delete-user.spec.js index 282fe37767..3ea21b63e8 100644 --- a/config/__tests__/delete-user.spec.js +++ b/config/__tests__/delete-user.spec.js @@ -18,6 +18,7 @@ const mockMethods = { restoreUserSchedulesFromDeletion: jest.fn(), countActiveAgentTriggerDeliveriesByUser: jest.fn(), deleteSchedulesByUser: jest.fn(), + deleteUserCodeEnvironments: jest.fn(), deleteUserById: jest.fn(), deleteAgentTriggerDeliveriesByUser: jest.fn(), cancelAgentTriggerUserPurge: jest.fn(), @@ -28,6 +29,8 @@ const mockAbortJob = jest.fn(); const mockDestroy = jest.fn(); const mockSilentExit = jest.fn(); const mockAskQuestion = jest.fn(); +const mockGetAppConfig = jest.fn(); +const mockRevokeUserCodeEnvironmentWorkers = jest.fn(); jest.mock('../connect', () => jest.fn(async () => undefined)); jest.mock('mongoose', () => ({ disconnect: jest.fn(async () => undefined) })); @@ -46,8 +49,12 @@ jest.mock('@librechat/api', () => ({ abortJob: (...args) => mockAbortJob(...args), destroy: (...args) => mockDestroy(...args), }, + revokeUserCodeEnvironmentWorkers: (...args) => mockRevokeUserCodeEnvironmentWorkers(...args), })); jest.mock('~/cache/getLogStores', () => jest.fn()); +jest.mock('~/server/services/Config', () => ({ + getAppConfig: (...args) => mockGetAppConfig(...args), +})); jest.mock('../helpers', () => ({ ...jest.requireActual('../helpers'), askQuestion: mockAskQuestion, @@ -96,6 +103,7 @@ describe('Delete user CLI', () => { mockMethods.restoreUserSchedulesFromDeletion.mockReset().mockResolvedValue(undefined); mockMethods.countActiveAgentTriggerDeliveriesByUser.mockReset().mockResolvedValue(0); mockMethods.deleteSchedulesByUser.mockReset().mockResolvedValue(undefined); + mockMethods.deleteUserCodeEnvironments.mockReset().mockResolvedValue(0); mockMethods.deleteUserById.mockReset().mockResolvedValue({ deletedCount: 1 }); mockMethods.deleteAgentTriggerDeliveriesByUser.mockReset().mockResolvedValue(undefined); mockMethods.cancelAgentTriggerUserPurge.mockReset().mockResolvedValue(true); @@ -103,6 +111,8 @@ describe('Delete user CLI', () => { mockGetCleanupBlockingJobIdsForUser.mockReset().mockResolvedValue([]); mockAbortJob.mockReset().mockResolvedValue({ success: true }); mockDestroy.mockReset().mockResolvedValue(undefined); + mockGetAppConfig.mockReset().mockResolvedValue({}); + mockRevokeUserCodeEnvironmentWorkers.mockReset().mockResolvedValue(0); mockAskQuestion.mockReset().mockResolvedValueOnce('y').mockResolvedValueOnce('n'); }); @@ -200,6 +210,30 @@ describe('Delete user CLI', () => { expect(mockMethods.restoreUserSchedulesFromDeletion).not.toHaveBeenCalled(); }); + it('commits the account before revoking workers and removing environment records', async () => { + expect(await runCli()).toBe(0); + + expect(mockGetAppConfig).toHaveBeenCalledWith({ baseOnly: true }); + expect(mockRevokeUserCodeEnvironmentWorkers).toHaveBeenCalledWith( + expect.objectContaining({ userId: USER_ID }), + ); + expect(mockMethods.deleteUserCodeEnvironments).toHaveBeenCalledWith(USER_ID); + expect(mockMethods.deleteUserById.mock.invocationCallOrder[0]).toBeLessThan( + mockRevokeUserCodeEnvironmentWorkers.mock.invocationCallOrder[0], + ); + expect(mockRevokeUserCodeEnvironmentWorkers.mock.invocationCallOrder[0]).toBeLessThan( + mockMethods.deleteUserCodeEnvironments.mock.invocationCallOrder[0], + ); + }); + + it('preserves code environment records when revocation marking fails', async () => { + mockRevokeUserCodeEnvironmentWorkers.mockRejectedValueOnce(new Error('mongo unavailable')); + + expect(await runCli()).toBe(0); + + expect(mockMethods.deleteUserCodeEnvironments).not.toHaveBeenCalled(); + }); + it('deletes nothing and releases both fences when provider drain fails', async () => { mockGetCleanupBlockingJobIdsForUser.mockResolvedValueOnce(['stream-1']); mockAbortJob.mockRejectedValueOnce(new Error('provider drain failed')); diff --git a/config/delete-user.js b/config/delete-user.js index 533a239f2f..333d50d9a7 100644 --- a/config/delete-user.js +++ b/config/delete-user.js @@ -34,9 +34,11 @@ require('module-alias')({ base: path.resolve(__dirname, '..', 'api') }); const { GenerationJobManager, createStreamServices, + revokeUserCodeEnvironmentWorkers, waitForKeyvRedisClient, } = require('@librechat/api'); const getLogStores = require('~/cache/getLogStores'); +const { getAppConfig } = require('~/server/services/Config'); const { askQuestion, silentExit } = require('./helpers'); const connect = require('./connect'); @@ -194,6 +196,8 @@ async function gracefulExit(code = 0) { ); } + const deletionAppConfig = await getAppConfig({ baseOnly: true }); + // 5) Build and run deletion tasks const tasks = [ Action.deleteMany({ user: uid }), @@ -234,6 +238,22 @@ async function gracefulExit(code = 0) { throw new Error('User disappeared before account deletion could commit'); } userDeleted = true; + let codeEnvironmentCleanupSafe = true; + try { + await revokeUserCodeEnvironmentWorkers({ + mongoose, + userId: uid, + appConfig: deletionAppConfig, + }); + } catch (error) { + codeEnvironmentCleanupSafe = false; + console.error('Failed to revoke code environment workers after account deletion:', error); + } + if (codeEnvironmentCleanupSafe) { + await runAsSystem(() => methods.deleteUserCodeEnvironments(uid)).catch((error) => + console.error('Failed to delete code environment records after account deletion:', error), + ); + } await runAsSystem(() => methods.deleteAgentTriggerDeliveriesByUser(uid)); } finally { // RESTORE BEFORE RELEASING THE FENCE. While the user-deletion fence is still armed, new diff --git a/packages/api/src/admin/users.spec.ts b/packages/api/src/admin/users.spec.ts index 83ff375fe2..824dd72514 100644 --- a/packages/api/src/admin/users.spec.ts +++ b/packages/api/src/admin/users.spec.ts @@ -59,6 +59,7 @@ function createDeps(overrides: Partial = {}): AdminUsersDeps { prepareAgentTriggerUserPurge: jest.fn().mockResolvedValue(undefined), cancelAgentTriggerUserPurge: jest.fn().mockResolvedValue(true), purgeAgentTriggerDeliveriesForUser: jest.fn().mockResolvedValue(undefined), + revokeUserCodeEnvironmentWorkers: jest.fn().mockResolvedValue(0), deleteUserById: jest .fn() .mockResolvedValue({ deletedCount: 1, message: 'User was deleted successfully.' }), @@ -476,6 +477,19 @@ describe('createAdminUsersHandlers', () => { expect(json).toHaveBeenCalledWith({ message: 'User was deleted successfully.' }); }); + it('preserves code environment records when revocation marking fails', async () => { + const deps = createDeps({ + revokeUserCodeEnvironmentWorkers: jest.fn().mockRejectedValue(new Error('mongo down')), + }); + const handlers = createAdminUsersHandlers(deps); + const { req, res } = createReqRes({ params: { id: validUserId } }); + + await handlers.deleteUser(req, res); + + expect(deps.deleteUserById).toHaveBeenCalledWith(validUserId); + expect(deps.deleteUserCodeEnvironments).not.toHaveBeenCalled(); + }); + it('does not cascade when user is not found', async () => { const result: UserDeleteResult = { deletedCount: 0, message: '' }; const deps = createDeps({ deleteUserById: jest.fn().mockResolvedValue(result) }); @@ -584,12 +598,18 @@ describe('createAdminUsersHandlers', () => { const drainOrder = (deps.drainAgentTriggerDeliveriesForUser as jest.Mock).mock .invocationCallOrder[0]; const deleteOrder = (deps.deleteUserById as jest.Mock).mock.invocationCallOrder[0]; + const revokeCodeOrder = (deps.revokeUserCodeEnvironmentWorkers as jest.Mock).mock + .invocationCallOrder[0]; + const deleteCodeOrder = (deps.deleteUserCodeEnvironments as jest.Mock).mock + .invocationCallOrder[0]; const purgeOrder = (deps.purgeAgentTriggerDeliveriesForUser as jest.Mock).mock .invocationCallOrder[0]; expect(beginOrder).toBeLessThan(drainOrder); expect(beginOrder).toBeLessThan(prepareOrder); expect(prepareOrder).toBeLessThan(drainOrder); expect(drainOrder).toBeLessThan(deleteOrder); + expect(deleteOrder).toBeLessThan(revokeCodeOrder); + expect(revokeCodeOrder).toBeLessThan(deleteCodeOrder); expect(deleteOrder).toBeLessThan(purgeOrder); }); diff --git a/packages/api/src/admin/users.ts b/packages/api/src/admin/users.ts index 39e0243a79..0103c72696 100644 --- a/packages/api/src/admin/users.ts +++ b/packages/api/src/admin/users.ts @@ -37,6 +37,7 @@ export interface AdminUsersDeps { ) => Promise; cancelAgentTriggerUserPurge: (userId: string, fenceStartedAt: Date) => Promise; purgeAgentTriggerDeliveriesForUser: (userId: string) => Promise; + revokeUserCodeEnvironmentWorkers?: (userId: string) => Promise; /** * Thin data-layer delete — removes the User document only. * Full cascade of user-owned resources (conversations, messages, files, tokens, etc.) @@ -72,8 +73,9 @@ export function createAdminUsersHandlers(deps: AdminUsersDeps): { prepareAgentTriggerUserPurge, cancelAgentTriggerUserPurge, purgeAgentTriggerDeliveriesForUser, - deleteUserById, + revokeUserCodeEnvironmentWorkers, deleteUserCodeEnvironments, + deleteUserById, invalidateCodeEnvironmentConfigCache, deleteConfig, deleteAclEntries, @@ -203,6 +205,13 @@ export function createAdminUsersHandlers(deps: AdminUsersDeps): { return res.status(404).json({ error: 'User not found' }); } userDeleted = true; + let codeEnvironmentCleanupSafe = true; + try { + await revokeUserCodeEnvironmentWorkers?.(id); + } catch (error) { + codeEnvironmentCleanupSafe = false; + logger.error('[adminUsers] failed to revoke code environment workers:', id, error); + } await purgeAgentTriggerDeliveriesForUser(id); if (targetUser?.role === SystemRoles.ADMIN) { @@ -218,7 +227,7 @@ export function createAdminUsersHandlers(deps: AdminUsersDeps): { const objectId = new Types.ObjectId(id); const cleanupResults = await Promise.allSettled([ deleteConfig(PrincipalType.USER, id), - deleteUserCodeEnvironments(objectId), + ...(codeEnvironmentCleanupSafe ? [deleteUserCodeEnvironments(objectId)] : []), deleteAclEntries({ principalType: PrincipalType.USER, principalId: objectId }), ]); for (const r of cleanupResults) { diff --git a/packages/api/src/code/bridge.ts b/packages/api/src/code/bridge.ts index 77b88d48fa..ff476cdee3 100644 --- a/packages/api/src/code/bridge.ts +++ b/packages/api/src/code/bridge.ts @@ -32,6 +32,16 @@ export class CodeBridgePairingError extends Error { } } +export class CodeBridgeLifecycleError extends Error { + constructor( + public readonly reason: 'rejected' | 'invalid' | 'timeout' | 'failed', + public readonly upstreamStatus?: number, + ) { + super(`Code bridge lifecycle request ${reason}`); + this.name = 'CodeBridgeLifecycleError'; + } +} + function validPairing(value: unknown, workerId: string): value is CodeBridgePairing { if (typeof value !== 'object' || value == null) return false; const pairing = value as Partial; @@ -87,6 +97,48 @@ export async function createCodeBridgePairing({ } } +export async function revokeCodeBridgeWorker({ + baseURL, + token, + workerId, + fetchImpl = fetch, +}: { + baseURL: string; + token: string; + workerId: string; + fetchImpl?: CodeBridgeFetch; +}): Promise { + try { + const response = await fetchImpl( + `${baseURL.trim().replace(/\/+$/, '')}/bridge/workers/${encodeURIComponent(workerId)}/revoke`, + { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + redirect: 'error', + signal: AbortSignal.timeout(CODE_BRIDGE_REQUEST_TIMEOUT_MS), + }, + ); + if (!response.ok) { + throw new CodeBridgeLifecycleError('rejected', response.status); + } + const payload = (await response.json()) as unknown; + if ( + typeof payload !== 'object' || + payload == null || + (payload as { protocolVersion?: unknown }).protocolVersion !== 1 || + (payload as { revoked?: unknown }).revoked !== true + ) { + throw new CodeBridgeLifecycleError('invalid'); + } + } catch (error) { + if (error instanceof CodeBridgeLifecycleError) throw error; + if (error instanceof Error && error.name === 'TimeoutError') { + throw new CodeBridgeLifecycleError('timeout'); + } + throw new CodeBridgeLifecycleError('failed'); + } +} + export function readCodeBridgeSecret(name: string): string | undefined { return Object.prototype.hasOwnProperty.call(process.env, name) ? process.env[name] : undefined; } diff --git a/packages/api/src/code/environments.integration.spec.ts b/packages/api/src/code/environments.integration.spec.ts index cf30112c2b..47477bfe1d 100644 --- a/packages/api/src/code/environments.integration.spec.ts +++ b/packages/api/src/code/environments.integration.spec.ts @@ -1,7 +1,8 @@ import mongoose, { Types } from 'mongoose'; import { MongoMemoryServer } from 'mongodb-memory-server'; -import { createMethods, createModels } from '@librechat/data-schemas'; +import { createMethods, createModels, tenantStorage } from '@librechat/data-schemas'; import { AccessRoleIds, PrincipalType, ResourceType } from 'librechat-data-provider'; +import { reconcileCodeEnvironmentLifecycle, revokeUserCodeEnvironmentWorkers } from './lifecycle'; import { AccessControlService } from '~/acl/accessControlService'; import { createCodeEnvironmentRegistry } from './environments'; @@ -60,6 +61,7 @@ describe('code environment registry', () => { id: 'danny-vm', name: "Danny's VM", type: 'attached', + canDelete: true, }); await expect(registry.listRegisteredIds()).resolves.toEqual(['danny-vm']); await expect( @@ -142,7 +144,72 @@ describe('code environment registry', () => { role: 'CODE_USER', idOnTheSource: null, }), - ).resolves.toEqual([roleEnvironment, groupEnvironment]); + ).resolves.toEqual([ + { ...roleEnvironment, canDelete: false }, + { ...groupEnvironment, canDelete: false }, + ]); + }); + + test('computes delete permissions for an environment list in one batch', async () => { + const batchSpy = jest.spyOn(AccessControlService.prototype, 'getResourcePermissionsMap'); + const singleSpy = jest.spyOn(AccessControlService.prototype, 'checkPermission'); + const registry = createCodeEnvironmentRegistry(mongoose); + const ownerId = new Types.ObjectId(); + for (const id of ['batch-one', 'batch-two']) { + await registry.register({ + actor: { userId: ownerId, role: 'USER', idOnTheSource: null }, + environment: { + id, + name: id, + type: 'attached', + baseURL: 'https://code.example.com', + controlPlaneId: 'shared-code-api', + }, + }); + } + + await expect( + registry.listAccessible({ userId: ownerId, role: 'USER', idOnTheSource: null }), + ).resolves.toHaveLength(2); + expect(batchSpy).toHaveBeenCalledTimes(1); + expect(singleSpy).not.toHaveBeenCalled(); + + batchSpy.mockRestore(); + singleSpy.mockRestore(); + }); + + test('atomically limits concurrent environment registrations for one owner', async () => { + await mongoose.models.CodeEnvironment.createCollection(); + await expect(mongoose.models.CodeEnvironment.collection.indexes()).resolves.toEqual([ + expect.objectContaining({ name: '_id_' }), + ]); + const registry = createCodeEnvironmentRegistry(mongoose); + const ownerId = new Types.ObjectId(); + + const results = await Promise.allSettled( + ['quota-one', 'quota-two', 'quota-three'].map((id) => + registry.register({ + actor: { userId: ownerId, role: 'USER', idOnTheSource: null }, + environment: { + id, + name: id, + type: 'attached', + baseURL: 'https://code.example.com', + controlPlaneId: 'shared-code-api', + }, + maxOwned: 2, + } as never), + ), + ); + + expect(results.filter(({ status }) => status === 'fulfilled')).toHaveLength(2); + expect(results.filter(({ status }) => status === 'rejected')).toHaveLength(1); + expect(results.find(({ status }) => status === 'rejected')).toMatchObject({ + reason: { name: 'CodeEnvironmentLimitError' }, + }); + await expect( + mongoose.models.CodeEnvironment.countDocuments({ createdBy: ownerId }), + ).resolves.toBe(2); }); test('keeps a user-bound worker private even if its ACL is granted to a role', async () => { @@ -157,8 +224,8 @@ describe('code environment registry', () => { name: 'Owner worker', type: 'attached', baseURL: 'https://code.example.com', - workerId: 'owner-worker', controlPlaneId: 'shared-code-api', + workerId: 'owner-worker', workerPrincipal: { type: 'user', id: ownerId.toString() }, }, }); @@ -179,6 +246,364 @@ describe('code environment registry', () => { ).resolves.toEqual([environment]); }); + test('deletes an owner environment and its ACL after lifecycle cleanup succeeds', async () => { + const registry = createCodeEnvironmentRegistry(mongoose); + const ownerId = new Types.ObjectId(); + const environment = await registry.register({ + actor: { userId: ownerId, role: 'USER', idOnTheSource: null }, + environment: { + id: 'remove-me', + name: 'Remove me', + type: 'attached', + baseURL: 'https://code.example.com', + workerId: 'remove-me', + controlPlaneId: 'self-service', + workerPrincipal: { type: 'user', id: ownerId.toString() }, + }, + }); + const beforeDelete = jest.fn().mockResolvedValue(undefined); + + await expect( + registry.remove({ + actor: { userId: ownerId, role: 'USER', idOnTheSource: null }, + environmentId: 'remove-me', + beforeDelete, + }), + ).resolves.toEqual(environment); + expect(beforeDelete).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'remove-me', + workerId: 'remove-me', + controlPlaneId: 'self-service', + }), + ); + await expect( + mongoose.models.AclEntry.countDocuments({ resourceId: environment.resourceId }), + ).resolves.toBe(0); + await expect( + registry.listAccessible({ userId: ownerId, role: 'USER', idOnTheSource: null }), + ).resolves.toEqual([]); + }); + + test('reconciles an interrupted removal after the remote lifecycle has started', async () => { + const registry = createCodeEnvironmentRegistry(mongoose); + const ownerId = new Types.ObjectId(); + await registry.register({ + actor: { userId: ownerId, role: 'USER', idOnTheSource: null }, + environment: { + id: 'interrupted-removal', + name: 'Interrupted removal', + type: 'attached', + baseURL: 'https://code.example.com/v1', + workerId: 'interrupted-removal', + controlPlaneId: 'self-service', + revocationTokenEnv: 'CODE_ADMIN_TOKEN', + workerPrincipal: { type: 'user', id: ownerId.toString() }, + }, + }); + const beforeDelete = jest.fn().mockResolvedValue(undefined); + const commit = jest + .spyOn(mongoose.models.CodeEnvironment, 'updateOne') + .mockRejectedValueOnce(new Error('mongo unavailable after revoke')); + + await expect( + registry.remove({ + actor: { userId: ownerId, role: 'USER', idOnTheSource: null }, + environmentId: 'interrupted-removal', + beforeDelete, + }), + ).rejects.toThrow('mongo unavailable after revoke'); + expect(beforeDelete).toHaveBeenCalledTimes(1); + await expect( + registry.listAccessible({ userId: ownerId, role: 'USER', idOnTheSource: null }), + ).resolves.toEqual([]); + + commit.mockRestore(); + await mongoose.models.CodeEnvironment.updateOne( + { environmentId: 'interrupted-removal' }, + { $set: { deletionLeaseExpiresAt: new Date(Date.now() - 1_000) } }, + ); + const fetchImpl = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ protocolVersion: 1, revoked: true }), + }); + await reconcileCodeEnvironmentLifecycle({ + mongoose, + readSecret: () => 'administrator-token', + fetchImpl, + }); + + expect(fetchImpl).toHaveBeenCalledWith( + 'https://code.example.com/v1/bridge/workers/interrupted-removal/revoke', + expect.objectContaining({ method: 'POST' }), + ); + await expect( + mongoose.models.CodeEnvironment.findOne({ environmentId: 'interrupted-removal' }), + ).resolves.toBeNull(); + }); + + test('fences removal while an agent write is reserving the environment', async () => { + const registry = createCodeEnvironmentRegistry(mongoose); + const methods = createMethods(mongoose); + const ownerId = new Types.ObjectId(); + await registry.register({ + actor: { userId: ownerId, role: 'USER', idOnTheSource: null }, + environment: { + id: 'agent-write-race', + name: 'Agent write race', + type: 'attached', + baseURL: 'https://code.example.com', + controlPlaneId: 'shared-code-api', + }, + }); + const Agent = mongoose.models.Agent; + const createAgent = Agent.create.bind(Agent); + let enteredCreate!: () => void; + let releaseCreate!: () => void; + const entered = new Promise((resolve) => (enteredCreate = resolve)); + const release = new Promise((resolve) => (releaseCreate = resolve)); + const createSpy = jest.spyOn(Agent, 'create').mockImplementationOnce(async (input) => { + enteredCreate(); + await release; + return await createAgent(input); + }); + + const pendingAgent = methods.createAgent({ + id: 'agent_write_race', + name: 'Agent write race', + author: ownerId, + model: 'test-model', + provider: 'test-provider', + code_environment_id: 'agent-write-race', + }); + await entered; + + await expect( + registry.remove({ + actor: { userId: ownerId, role: 'USER', idOnTheSource: null }, + environmentId: 'agent-write-race', + }), + ).rejects.toMatchObject({ name: 'CodeEnvironmentInUseError' }); + + releaseCreate(); + await expect(pendingAgent).resolves.toMatchObject({ + code_environment_id: 'agent-write-race', + }); + createSpy.mockRestore(); + }); + + test('revokes every user-bound worker before account deletion', async () => { + const ownerId = new Types.ObjectId(); + await createCodeEnvironmentRegistry(mongoose).register({ + actor: { userId: ownerId, role: 'USER', idOnTheSource: null }, + environment: { + id: 'account-worker', + name: 'Account worker', + type: 'attached', + baseURL: 'https://code.example.com/v1', + workerId: 'account-worker', + controlPlaneId: 'self-service', + revocationTokenEnv: 'CODE_ADMIN_TOKEN', + workerPrincipal: { type: 'user', id: ownerId.toString() }, + }, + }); + const fetchImpl = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ protocolVersion: 1, revoked: true }), + }); + + await expect( + revokeUserCodeEnvironmentWorkers({ + mongoose, + userId: ownerId.toString(), + appConfig: { + endpoints: { + agents: { + statefulCodeSessions: { + allowedEnvironments: ['user'], + environments: [], + }, + }, + }, + } as never, + readSecret: () => 'administrator-token', + fetchImpl, + }), + ).resolves.toBe(1); + expect(fetchImpl).toHaveBeenCalledWith( + 'https://code.example.com/v1/bridge/workers/account-worker/revoke', + expect.objectContaining({ method: 'POST' }), + ); + }); + + test('reports successful revocations without aborting after another worker fails', async () => { + const ownerId = new Types.ObjectId(); + const registry = createCodeEnvironmentRegistry(mongoose); + for (const id of ['worker-ok', 'worker-unreachable']) { + await registry.register({ + actor: { userId: ownerId, role: 'USER', idOnTheSource: null }, + environment: { + id, + name: id, + type: 'attached', + baseURL: `https://${id}.example.com/v1`, + controlPlaneId: 'shared-code-api', + workerId: id, + revocationTokenEnv: 'CODE_ADMIN_TOKEN', + workerPrincipal: { type: 'user', id: ownerId.toString() }, + }, + }); + } + const fetchImpl = jest.fn(async (input: string | URL | Request) => { + const url = String(input); + if (url.includes('unreachable')) { + throw new Error('control plane unavailable'); + } + return { + ok: true, + json: async () => ({ protocolVersion: 1, revoked: true }), + } as Response; + }); + + await expect( + revokeUserCodeEnvironmentWorkers({ + mongoose, + userId: ownerId.toString(), + appConfig: {} as never, + readSecret: () => 'administrator-token', + fetchImpl, + }), + ).resolves.toBe(1); + expect(fetchImpl).toHaveBeenCalledTimes(2); + await expect(createMethods(mongoose).deleteUserCodeEnvironments(ownerId)).resolves.toBe(0); + await expect( + mongoose.models.CodeEnvironment.findOne({ environmentId: 'worker-ok' }).lean(), + ).resolves.toMatchObject({ deletionCommittedAt: expect.any(Date) }); + await expect( + mongoose.models.CodeEnvironment.findOne({ environmentId: 'worker-unreachable' }).lean(), + ).resolves.toMatchObject({ + revocationPendingAt: expect.any(Date), + revocationAttempts: 1, + revocationLastError: 'Code bridge lifecycle request failed', + }); + + const reconcileFailure = jest.fn().mockRejectedValue(new Error('control plane still down')); + await reconcileCodeEnvironmentLifecycle({ + mongoose, + readSecret: () => 'administrator-token', + fetchImpl: reconcileFailure, + }); + await expect( + mongoose.models.CodeEnvironment.findOne({ environmentId: 'worker-unreachable' }).lean(), + ).resolves.toMatchObject({ revocationReconcileAfter: expect.any(Date) }); + + await registry.register({ + actor: { userId: ownerId, role: 'USER', idOnTheSource: null }, + environment: { + id: 'worker-later', + name: 'worker-later', + type: 'attached', + baseURL: 'https://worker-later.example.com/v1', + controlPlaneId: 'shared-code-api', + workerId: 'worker-later', + revocationTokenEnv: 'CODE_ADMIN_TOKEN', + workerPrincipal: { type: 'user', id: ownerId.toString() }, + }, + }); + await mongoose.models.CodeEnvironment.updateOne( + { environmentId: 'worker-later' }, + { $set: { revocationPendingAt: new Date() } }, + ); + + const retryFetch = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ protocolVersion: 1, revoked: true }), + }); + await reconcileCodeEnvironmentLifecycle({ + mongoose, + readSecret: () => 'administrator-token', + fetchImpl: retryFetch, + limit: 1, + }); + expect(retryFetch).toHaveBeenCalledWith( + 'https://worker-later.example.com/v1/bridge/workers/worker-later/revoke', + expect.objectContaining({ method: 'POST' }), + ); + await expect( + mongoose.models.CodeEnvironment.findOne({ environmentId: 'worker-later' }), + ).resolves.toBeNull(); + retryFetch.mockClear(); + await mongoose.models.CodeEnvironment.updateOne( + { environmentId: 'worker-unreachable' }, + { $set: { revocationReconcileAfter: new Date(Date.now() - 1_000) } }, + ); + await reconcileCodeEnvironmentLifecycle({ + mongoose, + readSecret: () => 'administrator-token', + fetchImpl: retryFetch, + }); + expect(retryFetch).toHaveBeenCalledWith( + 'https://worker-unreachable.example.com/v1/bridge/workers/worker-unreachable/revoke', + expect.objectContaining({ method: 'POST' }), + ); + await expect( + mongoose.models.CodeEnvironment.findOne({ environmentId: 'worker-unreachable' }), + ).resolves.toBeNull(); + }); + + test('reconciles an unmarked user worker after its owner document is deleted', async () => { + const ownerId = new Types.ObjectId(); + await mongoose.models.User.collection.insertOne({ + _id: ownerId, + email: 'departed-worker-owner@example.com', + }); + await createCodeEnvironmentRegistry(mongoose).register({ + actor: { userId: ownerId, role: 'USER', idOnTheSource: null }, + environment: { + id: 'unmarked-orphan-worker', + name: 'Unmarked orphan worker', + type: 'attached', + baseURL: 'https://code.example.com/v1', + controlPlaneId: 'shared-code-api', + workerId: 'unmarked-orphan-worker', + revocationTokenEnv: 'CODE_ADMIN_TOKEN', + workerPrincipal: { type: 'user', id: ownerId.toString() }, + }, + }); + await createCodeEnvironmentRegistry(mongoose).register({ + actor: { userId: ownerId, role: 'USER', idOnTheSource: null }, + environment: { + id: 'unmarked-local-environment', + name: 'Unmarked local environment', + type: 'attached', + baseURL: 'https://local.example.com/v1', + controlPlaneId: 'deployment-code-api', + }, + }); + await mongoose.models.User.deleteOne({ _id: ownerId }); + const fetchImpl = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ protocolVersion: 1, revoked: true }), + }); + + await reconcileCodeEnvironmentLifecycle({ + mongoose, + readSecret: () => 'administrator-token', + fetchImpl, + }); + + expect(fetchImpl).toHaveBeenCalledWith( + 'https://code.example.com/v1/bridge/workers/unmarked-orphan-worker/revoke', + expect.objectContaining({ method: 'POST' }), + ); + await expect( + mongoose.models.CodeEnvironment.findOne({ environmentId: 'unmarked-orphan-worker' }), + ).resolves.toBeNull(); + await expect( + mongoose.models.CodeEnvironment.findOne({ environmentId: 'unmarked-local-environment' }), + ).resolves.toBeNull(); + }); + test('removes creator-owned environment records and grants when the user is deleted', async () => { const registry = createCodeEnvironmentRegistry(mongoose); const methods = createMethods(mongoose); @@ -230,6 +655,37 @@ describe('code environment registry', () => { ]); }); + test('does not reuse a cached configuration after removal is fenced', async () => { + const cache = createSharedCache(); + const registry = createCodeEnvironmentRegistry(mongoose, { configurationCache: cache }); + const ownerId = new Types.ObjectId(); + const actor = { userId: ownerId, role: 'USER', idOnTheSource: null }; + const environment = await registry.register({ + actor, + environment: { + id: 'cached-removal-vm', + name: 'Cached removal VM', + type: 'attached', + baseURL: 'https://code.example.com', + controlPlaneId: 'shared-code-api', + }, + }); + await expect(registry.listAccessibleConfigurations(actor)).resolves.toHaveLength(1); + + await mongoose.models.CodeEnvironment.updateOne( + { _id: environment.resourceId }, + { + $set: { + deletionStartedAt: new Date(), + deletionLeaseId: 'in-flight-removal', + deletionLeaseExpiresAt: new Date(Date.now() + 60_000), + }, + }, + ); + + await expect(registry.listAccessibleConfigurations(actor)).resolves.toEqual([]); + }); + test('caches registered environment ids behind the shared tenant revision', async () => { const cache = createSharedCache(); const firstWorker = createCodeEnvironmentRegistry(mongoose, { configurationCache: cache }); @@ -402,4 +858,321 @@ describe('code environment registry', () => { mongoose.models.AclEntry.countDocuments({ resourceType: ResourceType.CODE_ENVIRONMENT }), ).resolves.toBe(0); }); + + test('reconciles a pending registration when ACL rollback fails', async () => { + const rollback = jest + .spyOn(AccessControlService.prototype, 'removeAllPermissions') + .mockRejectedValueOnce(new Error('acl store unavailable')); + 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: 'pending-rollback-vm', + name: 'Pending rollback VM', + type: 'attached', + baseURL: 'https://code.example.com', + controlPlaneId: 'shared-code-api', + workerId: 'shared-deployment-worker', + workerPrincipal: { type: 'deployment', id: 'shared-code-api' }, + }, + }), + ).rejects.toThrow('redis unavailable'); + await expect( + mongoose.models.CodeEnvironment.findOne({ environmentId: 'pending-rollback-vm' }).lean(), + ).resolves.toMatchObject({ registrationPendingAt: expect.any(Date) }); + + rollback.mockRestore(); + await mongoose.models.CodeEnvironment.updateOne( + { environmentId: 'pending-rollback-vm' }, + { $set: { registrationPendingAt: new Date(Date.now() - 10 * 60_000) } }, + ); + await reconcileCodeEnvironmentLifecycle({ mongoose }); + + await expect( + mongoose.models.CodeEnvironment.countDocuments({ environmentId: 'pending-rollback-vm' }), + ).resolves.toBe(0); + await expect( + mongoose.models.AclEntry.countDocuments({ resourceType: ResourceType.CODE_ENVIRONMENT }), + ).resolves.toBe(0); + }); + + test('claims a stale registration before compensating its worker', async () => { + const methods = createMethods(mongoose); + const ownerId = new Types.ObjectId(); + const environment = await methods.createCodeEnvironment({ + environmentId: 'stale-registration-race', + name: 'Stale registration race', + type: 'attached', + baseURL: 'https://code.example.com/v1', + controlPlaneId: 'shared-code-api', + createdBy: ownerId, + workerId: 'stale-registration-race', + revocationTokenEnv: 'CODE_ADMIN_TOKEN', + workerPrincipal: { type: 'user', id: ownerId.toString() }, + }); + await mongoose.models.CodeEnvironment.updateOne( + { _id: environment._id }, + { $set: { registrationPendingAt: new Date(Date.now() - 10 * 60_000) } }, + ); + let enteredRevoke!: () => void; + let releaseRevoke!: () => void; + const entered = new Promise((resolve) => (enteredRevoke = resolve)); + const release = new Promise((resolve) => (releaseRevoke = resolve)); + const fetchImpl = jest.fn(async () => { + enteredRevoke(); + await release; + return { + ok: true, + json: async () => ({ protocolVersion: 1, revoked: true }), + } as Response; + }); + + const reconciliation = reconcileCodeEnvironmentLifecycle({ + mongoose, + readSecret: () => 'administrator-token', + fetchImpl, + }); + await entered; + await expect(methods.completeCodeEnvironmentRegistration(environment._id)).rejects.toThrow( + 'registration could not be committed', + ); + releaseRevoke(); + await reconciliation; + + await expect(mongoose.models.CodeEnvironment.findById(environment._id)).resolves.toBeNull(); + }); + + test('preserves a creator-owned environment referenced by another surviving agent', async () => { + const registry = createCodeEnvironmentRegistry(mongoose); + const methods = createMethods(mongoose); + const ownerId = new Types.ObjectId(); + const teammateId = new Types.ObjectId(); + const environment = await registry.register({ + actor: { userId: ownerId, role: 'USER', idOnTheSource: null }, + environment: { + id: 'shared-deployment-worker', + name: 'Shared deployment worker', + type: 'attached', + baseURL: 'https://code.example.com', + controlPlaneId: 'shared-code-api', + workerPrincipal: { type: 'deployment', id: 'shared-control-plane' }, + }, + }); + await mongoose.models.Agent.create({ + id: 'agent_survives_owner', + name: 'Surviving agent', + author: teammateId, + model: 'test-model', + provider: 'test-provider', + code_environment_id: environment.id, + }); + + await expect(methods.deleteUserCodeEnvironments(ownerId)).resolves.toBe(0); + await expect( + mongoose.models.CodeEnvironment.findOne({ environmentId: environment.id }), + ).resolves.not.toBeNull(); + await expect( + mongoose.models.AclEntry.countDocuments({ resourceId: environment.resourceId }), + ).resolves.toBeGreaterThan(0); + }); + + test('recovers expired agent reservations and removal leases', async () => { + const methods = createMethods(mongoose); + const ownerId = new Types.ObjectId(); + const environment = await createCodeEnvironmentRegistry(mongoose).register({ + actor: { userId: ownerId, role: 'USER', idOnTheSource: null }, + environment: { + id: 'expired-lifecycle-leases', + name: 'Expired lifecycle leases', + type: 'attached', + baseURL: 'https://code.example.com', + controlPlaneId: 'shared-code-api', + }, + }); + const expiredAt = new Date(Date.now() - 1_000); + await mongoose.models.CodeEnvironment.updateOne( + { _id: environment.resourceId }, + { + $set: { + deletionStartedAt: expiredAt, + deletionLeaseId: 'abandoned-removal', + deletionLeaseExpiresAt: expiredAt, + pendingAgentReferences: [{ reservationId: 'abandoned-reference', expiresAt: expiredAt }], + }, + }, + ); + + const claimed = await methods.beginCodeEnvironmentRemoval(environment.resourceId); + + expect(claimed).toMatchObject({ + deletionLeaseId: expect.any(String), + pendingAgentReferences: [], + }); + expect(claimed?.deletionLeaseId).not.toBe('abandoned-removal'); + }); + + test('preserves persisted agent references during interrupted-removal recovery', async () => { + const ownerId = new Types.ObjectId(); + const registry = createCodeEnvironmentRegistry(mongoose); + const environment = await registry.register({ + actor: { userId: ownerId, role: 'USER', idOnTheSource: null }, + environment: { + id: 'referenced-interrupted-removal', + name: 'Referenced interrupted removal', + type: 'attached', + baseURL: 'https://code.example.com', + controlPlaneId: 'shared-code-api', + }, + }); + await mongoose.models.Agent.create({ + id: 'agent_references_interrupted_removal', + name: 'Referenced environment agent', + author: ownerId, + model: 'test-model', + provider: 'test-provider', + code_environment_id: environment.id, + }); + const expiredAt = new Date(Date.now() - 1_000); + await mongoose.models.CodeEnvironment.updateOne( + { _id: environment.resourceId }, + { + $set: { + deletionStartedAt: expiredAt, + deletionLeaseId: 'abandoned-removal', + deletionLeaseExpiresAt: expiredAt, + }, + }, + ); + + await reconcileCodeEnvironmentLifecycle({ mongoose }); + + await expect( + mongoose.models.CodeEnvironment.findById(environment.resourceId).lean(), + ).resolves.toMatchObject({ environmentId: environment.id }); + await expect( + mongoose.models.CodeEnvironment.findById(environment.resourceId).lean(), + ).resolves.not.toHaveProperty('deletionStartedAt'); + }); + + test('finishes an interrupted local-only removal', async () => { + const ownerId = new Types.ObjectId(); + const registry = createCodeEnvironmentRegistry(mongoose); + const environment = await registry.register({ + actor: { userId: ownerId, role: 'USER', idOnTheSource: null }, + environment: { + id: 'local-interrupted-removal', + name: 'Local interrupted removal', + type: 'attached', + baseURL: 'https://code.example.com', + controlPlaneId: 'shared-code-api', + workerPrincipal: { type: 'deployment', id: 'shared-code-api' }, + }, + }); + const expiredAt = new Date(Date.now() - 1_000); + await mongoose.models.CodeEnvironment.updateOne( + { _id: environment.resourceId }, + { + $set: { + deletionStartedAt: expiredAt, + deletionLeaseId: 'abandoned-local-removal', + deletionLeaseExpiresAt: expiredAt, + }, + }, + ); + + await reconcileCodeEnvironmentLifecycle({ mongoose }); + + await expect( + mongoose.models.CodeEnvironment.findById(environment.resourceId), + ).resolves.toBeNull(); + await expect( + mongoose.models.AclEntry.countDocuments({ resourceId: environment.resourceId }), + ).resolves.toBe(0); + }); + + test('scopes retired environment ids to their tenant', async () => { + const ownerId = new Types.ObjectId(); + const input = { + environmentId: 'tenant-reusable-id', + name: 'Tenant VM', + type: 'attached' as const, + baseURL: 'https://code.example.com', + controlPlaneId: 'shared-code-api', + createdBy: ownerId, + }; + + await tenantStorage.run({ tenantId: 'tenant-a' }, async () => { + const methods = createMethods(mongoose); + const created = await methods.createCodeEnvironment(input); + await methods.deleteCodeEnvironmentById(created._id); + await expect(methods.createCodeEnvironment(input)).rejects.toThrow( + 'Code environment id was previously retired', + ); + }); + + await tenantStorage.run({ tenantId: 'tenant-b' }, async () => { + await expect(createMethods(mongoose).createCodeEnvironment(input)).resolves.toMatchObject({ + environmentId: 'tenant-reusable-id', + tenantId: 'tenant-b', + }); + }); + }); + + test('scopes recovered agent references to the environment tenant', async () => { + const ownerId = new Types.ObjectId(); + let tenantAEnvironmentId!: Types.ObjectId; + await tenantStorage.run({ tenantId: 'tenant-a' }, async () => { + const environment = await createMethods(mongoose).createCodeEnvironment({ + environmentId: 'shared-tenant-environment-id', + name: 'Tenant A environment', + type: 'attached', + baseURL: 'https://tenant-a.example.com', + controlPlaneId: 'shared-code-api', + createdBy: ownerId, + }); + tenantAEnvironmentId = environment._id; + await mongoose.models.CodeEnvironment.updateOne( + { _id: environment._id }, + { + $unset: { registrationPendingAt: 1 }, + $set: { + deletionStartedAt: new Date(Date.now() - 10_000), + deletionLeaseId: 'expired-tenant-a-removal', + deletionLeaseExpiresAt: new Date(Date.now() - 1_000), + }, + }, + ); + }); + await tenantStorage.run({ tenantId: 'tenant-b' }, async () => { + await createMethods(mongoose).createAgent({ + id: 'agent_tenant_b_shared_environment_id', + name: 'Tenant B agent', + author: ownerId, + model: 'test-model', + provider: 'test-provider', + code_environment_id: 'shared-tenant-environment-id', + }); + }); + + await reconcileCodeEnvironmentLifecycle({ mongoose }); + + await tenantStorage.run({ tenantId: 'tenant-a' }, async () => { + await expect( + mongoose.models.CodeEnvironment.findById(tenantAEnvironmentId), + ).resolves.toBeNull(); + }); + await tenantStorage.run({ tenantId: 'tenant-b' }, async () => { + await expect( + mongoose.models.Agent.findOne({ id: 'agent_tenant_b_shared_environment_id' }), + ).resolves.not.toBeNull(); + }); + }); }); diff --git a/packages/api/src/code/environments.ts b/packages/api/src/code/environments.ts index 0625b93ce2..0720644d87 100644 --- a/packages/api/src/code/environments.ts +++ b/packages/api/src/code/environments.ts @@ -23,6 +23,7 @@ export type CodeEnvironmentSummary = { id: string; name: string; type: 'managed' | 'attached'; + canDelete: boolean; }; export type CodeEnvironmentRegistration = { @@ -32,6 +33,7 @@ export type CodeEnvironmentRegistration = { baseURL: string; controlPlaneId: string; workerId?: string; + revocationTokenEnv?: string; workerPrincipal?: { type: 'deployment' | 'tenant' | 'user' | 'role' | 'group'; id: string; @@ -52,6 +54,21 @@ type CachedAccessibleCodeEnvironmentConfiguration = AccessibleCodeEnvironmentCon resourceId: string; }; +export type CodeEnvironmentLifecycleTarget = CodeEnvironmentSummary & { + baseURL: string; + workerId?: string; + controlPlaneId?: string; + revocationTokenEnv?: string; + workerPrincipal?: CodeEnvironmentRegistration['workerPrincipal']; +}; + +function agentReferenceFilter(environmentId: string, tenantId?: string) { + return { + code_environment_id: environmentId, + ...(tenantId == null ? { tenantId: { $exists: false } } : { tenantId }), + }; +} + 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 WORKER_PRINCIPAL_ID_PATTERN = /^\S(?:.{0,254}\S)?$/; @@ -85,6 +102,13 @@ export class CodeEnvironmentValidationError extends Error { } } +export class CodeEnvironmentLimitError extends Error { + constructor() { + super('Personal code environment limit reached'); + this.name = 'CodeEnvironmentLimitError'; + } +} + function normalizeRegistration(input: CodeEnvironmentRegistration): CodeEnvironmentRegistration { const id = input.id.trim(); const name = normalizeCodeEnvironmentName(input.name); @@ -117,17 +141,21 @@ function normalizeRegistration(input: CodeEnvironmentRegistration): CodeEnvironm return { ...input, id, name, baseURL, controlPlaneId, workerId }; } -function toSummary(environment: { - _id: Types.ObjectId; - environmentId: string; - name: string; - type: 'managed' | 'attached'; -}): CodeEnvironmentSummary { +function toSummary( + environment: { + _id: Types.ObjectId; + environmentId: string; + name: string; + type: 'managed' | 'attached'; + }, + canDelete = false, +): CodeEnvironmentSummary { return { resourceId: environment._id.toString(), id: environment.environmentId, name: environment.name, type: environment.type, + canDelete, }; } @@ -138,6 +166,7 @@ export function createCodeEnvironmentRegistry( register: (params: { actor: CodeEnvironmentPrincipalContext; environment: CodeEnvironmentRegistration; + maxOwned?: number; }) => Promise; listAccessible: (actor: CodeEnvironmentPrincipalContext) => Promise; listAccessibleConfigurations: ( @@ -145,6 +174,12 @@ export function createCodeEnvironmentRegistry( ) => Promise; listRegisteredIds: () => Promise; invalidateAccessibleConfigurations: (tenantId?: string) => Promise; + markRevocationPending: (environmentId: string) => Promise; + remove: (params: { + actor: CodeEnvironmentPrincipalContext; + environmentId: string; + beforeDelete?: (target: CodeEnvironmentLifecycleTarget) => Promise; + }) => Promise; } { const methods = createMethods(mongoose); const access = new AccessControlService(mongoose); @@ -189,21 +224,31 @@ export function createCodeEnvironmentRegistry( async function register({ actor, environment: input, + maxOwned, }: { actor: CodeEnvironmentPrincipalContext; environment: CodeEnvironmentRegistration; + maxOwned?: number; }): Promise { const environment = normalizeRegistration(input); - const created = await methods.createCodeEnvironment({ + const createInput = { environmentId: environment.id, name: environment.name, type: environment.type, baseURL: environment.baseURL, controlPlaneId: environment.controlPlaneId, workerId: environment.workerId, + revocationTokenEnv: environment.revocationTokenEnv, workerPrincipal: environment.workerPrincipal, createdBy: new Types.ObjectId(actor.userId), - }); + }; + const created = + maxOwned == null + ? await methods.createCodeEnvironment(createInput) + : await methods.createCodeEnvironmentWithinOwnerLimit(createInput, maxOwned); + if (created == null) { + throw new CodeEnvironmentLimitError(); + } try { const permission = await access.grantPermission({ principalType: PrincipalType.USER, @@ -216,20 +261,26 @@ export function createCodeEnvironmentRegistry( if (permission == null) { throw new Error('Unable to grant code environment ownership'); } - const summary = toSummary(created); + const summary = toSummary(created, true); await invalidateAccessibleConfigurations(); + await methods.completeCodeEnvironmentRegistration(created._id); return summary; } catch (error) { - const cleanup = await Promise.allSettled([ - access.removeAllPermissions({ + let permissionsRemoved = false; + try { + await 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); + }); + permissionsRemoved = true; + } catch (cleanupError) { + logger.error('[codeEnvironments] registration ACL rollback failed:', cleanupError); + } + if (permissionsRemoved) { + try { + await methods.discardCodeEnvironmentById(created._id); + } catch (cleanupError) { + logger.error('[codeEnvironments] registration record rollback failed:', cleanupError); } } throw error; @@ -251,7 +302,10 @@ export function createCodeEnvironmentRegistry( const userId = actor.userId.toString(); return environments.filter( (environment) => - environment.workerPrincipal?.type !== 'user' || environment.workerPrincipal.id === userId, + environment.registrationPendingAt == null && + environment.deletionStartedAt == null && + environment.deletionCommittedAt == null && + (environment.workerPrincipal?.type !== 'user' || environment.workerPrincipal.id === userId), ); } @@ -259,7 +313,32 @@ export function createCodeEnvironmentRegistry( actor: CodeEnvironmentPrincipalContext, ): Promise { const environments = await findAccessible(actor); - return environments.map(toSummary); + const permissions = await access.getResourcePermissionsMap({ + userId: actor.userId, + role: actor.role ?? '', + resourceType: ResourceType.CODE_ENVIRONMENT, + resourceIds: environments.map((environment) => environment._id), + }); + return environments.map((environment) => { + const permission = permissions.get(environment._id.toString()) ?? 0; + return toSummary(environment, (permission & PermissionBits.DELETE) === PermissionBits.DELETE); + }); + } + + async function markRevocationPending(environmentId: string): Promise { + const CodeEnvironment = mongoose.models.CodeEnvironment; + const result = await CodeEnvironment.updateOne( + { environmentId, deletionCommittedAt: { $exists: false } }, + { + $set: { revocationPendingAt: new Date() }, + $inc: { revocationAttempts: 1 }, + $unset: { revocationLastError: 1, revocationReconcileAfter: 1 }, + }, + ); + if (result.matchedCount !== 1) { + throw new Error('Code environment cleanup target is unavailable'); + } + await invalidateAccessibleConfigurations(); } async function listAccessibleConfigurations( @@ -281,28 +360,41 @@ export function createCodeEnvironmentRegistry( ...configuration }: CachedAccessibleCodeEnvironmentConfiguration): AccessibleCodeEnvironmentConfiguration => configuration; - const load = async (): Promise => { + const load = async (): Promise<{ + configurations: CachedAccessibleCodeEnvironmentConfiguration[]; + hasPendingRegistration: boolean; + }> => { const ids = await findAccessibleResourceIds(principals); const environments = await methods.findCodeEnvironmentsByIds(ids); const userId = actor.userId.toString(); - return environments - .filter( - (environment) => - environment.workerPrincipal?.type !== 'user' || - environment.workerPrincipal.id === userId, - ) - .map((environment) => ({ - resourceId: environment._id.toString(), - id: environment.environmentId, - name: environment.name, - type: environment.type, - baseURL: environment.baseURL, - controlPlaneId: environment.controlPlaneId, - owner: 'principal', - workerId: environment.workerId, - })); + return { + hasPendingRegistration: environments.some( + (environment) => environment.registrationPendingAt != null, + ), + configurations: environments + .filter( + (environment) => + environment.registrationPendingAt == null && + environment.deletionStartedAt == null && + environment.deletionCommittedAt == null && + (environment.workerPrincipal?.type !== 'user' || + environment.workerPrincipal.id === userId), + ) + .map((environment) => ({ + resourceId: environment._id.toString(), + id: environment.environmentId, + name: environment.name, + type: environment.type, + baseURL: environment.baseURL, + controlPlaneId: environment.controlPlaneId, + owner: 'principal', + workerId: environment.workerId, + })), + }; }; - if (configurationCache == null) return (await load()).map(toPublicConfiguration); + if (configurationCache == null) { + return (await load()).configurations.map(toPublicConfiguration); + } const tenant = tenantCacheKey(); const revision = String((await configurationCache.get(revisionKey())) ?? '0'); @@ -326,25 +418,130 @@ export function createCodeEnvironmentRegistry( const accessibleIds = new Set( (await findAccessibleResourceIds(principals)).map((id) => id.toString()), ); - return (cached as CachedAccessibleCodeEnvironmentConfiguration[]) - .filter(({ resourceId }) => accessibleIds.has(resourceId)) + const cachedConfigurations = cached as CachedAccessibleCodeEnvironmentConfiguration[]; + const liveEnvironments = await methods.findCodeEnvironmentsByIds( + cachedConfigurations.map(({ resourceId }) => resourceId), + ); + const userId = actor.userId.toString(); + const liveIds = new Set( + liveEnvironments + .filter( + (environment) => + environment.registrationPendingAt == null && + environment.deletionStartedAt == null && + environment.deletionCommittedAt == null && + (environment.workerPrincipal?.type !== 'user' || + environment.workerPrincipal.id === userId), + ) + .map((environment) => environment._id.toString()), + ); + return cachedConfigurations + .filter(({ resourceId }) => accessibleIds.has(resourceId) && liveIds.has(resourceId)) .map(toPublicConfiguration); } - const configurations = await load(); + const { configurations, hasPendingRegistration } = await load(); const currentRevision = String((await configurationCache.get(revisionKey())) ?? '0'); if (currentRevision !== revision) { return await listAccessibleConfigurations(actor); } + // Registration commits after its first revision write so a failed rollback remains hidden and + // recoverable. Do not cache that transient empty view under the new revision. + if (hasPendingRegistration) { + return configurations.map(toPublicConfiguration); + } await configurationCache.set(key, configurations, CONFIGURATION_CACHE_TTL_MS); return configurations.map(toPublicConfiguration); } + async function remove({ + actor, + environmentId, + beforeDelete, + }: { + actor: CodeEnvironmentPrincipalContext; + environmentId: string; + beforeDelete?: (target: CodeEnvironmentLifecycleTarget) => Promise; + }): Promise { + const environment = await methods.findCodeEnvironmentByEnvironmentId(environmentId); + if (environment == null) return null; + if ( + environment.workerPrincipal?.type === 'user' && + environment.workerPrincipal.id !== actor.userId.toString() + ) { + return null; + } + const allowed = await access.checkPermission({ + userId: actor.userId.toString(), + role: actor.role, + resourceType: ResourceType.CODE_ENVIRONMENT, + resourceId: environment._id, + requiredPermission: PermissionBits.DELETE, + }); + if (!allowed) return null; + const removal = await methods.beginCodeEnvironmentRemoval(environment._id); + if (removal == null) { + throw new CodeEnvironmentInUseError(environmentId); + } + const removalLeaseId = removal.deletionLeaseId; + if (removalLeaseId == null) { + throw new Error('Code environment removal lease is unavailable'); + } + let deletionCommitted = false; + let externalLifecycleStarted = false; + try { + const Agent = mongoose.models.Agent; + if ( + Agent != null && + (await Agent.exists(agentReferenceFilter(environmentId, environment.tenantId))) != null + ) { + throw new CodeEnvironmentInUseError(environmentId); + } + if (beforeDelete != null) { + // From this point onward the remote outcome may be committed even if the request fails. + // Keep the local fence for idempotent reconciliation instead of reopening a dead worker. + externalLifecycleStarted = true; + await beforeDelete({ + ...toSummary(environment), + baseURL: environment.baseURL, + workerId: environment.workerId, + controlPlaneId: environment.controlPlaneId, + revocationTokenEnv: environment.revocationTokenEnv, + workerPrincipal: environment.workerPrincipal, + }); + } + await methods.commitCodeEnvironmentRemoval(environment._id, removalLeaseId); + deletionCommitted = true; + await access.removeAllPermissions({ + resourceType: ResourceType.CODE_ENVIRONMENT, + resourceId: environment._id, + }); + const deleted = await methods.deleteCodeEnvironmentById(environment._id); + if (deleted == null) return null; + await invalidateAccessibleConfigurations(); + return toSummary(deleted, true); + } catch (error) { + if (!deletionCommitted && !externalLifecycleStarted) { + await methods.cancelCodeEnvironmentRemoval(environment._id, removalLeaseId); + } + throw error; + } + } + return { register, + markRevocationPending, listAccessible, listAccessibleConfigurations, listRegisteredIds, invalidateAccessibleConfigurations, + remove, }; } + +export class CodeEnvironmentInUseError extends Error { + constructor(public readonly environmentId: string) { + super(`Code environment is still referenced by an agent: ${environmentId}`); + this.name = 'CodeEnvironmentInUseError'; + } +} diff --git a/packages/api/src/code/http.spec.ts b/packages/api/src/code/http.spec.ts index bd3d9b332c..4e4efd131e 100644 --- a/packages/api/src/code/http.spec.ts +++ b/packages/api/src/code/http.spec.ts @@ -1,6 +1,6 @@ import { EModelEndpoint } from 'librechat-data-provider'; import type { AppConfig } from '@librechat/data-schemas'; -import { CodeEnvironmentValidationError } from './environments'; +import { CodeEnvironmentLimitError, CodeEnvironmentValidationError } from './environments'; import { createCodeEnvironmentHttpHandlers } from './http'; function response() { @@ -20,11 +20,85 @@ function response() { } describe('code environment HTTP handlers', () => { + test('does not advertise principal pairing when Code API principal auth is disabled', async () => { + const handlers = createCodeEnvironmentHttpHandlers({ + getAppConfig: jest.fn().mockResolvedValue({ + endpoints: { + [EModelEndpoint.agents]: { + statefulCodeSessions: { + environments: [ + { + id: 'principal-workers', + name: 'Principal workers', + type: 'attached', + baseURL: 'https://code.example.com/v1', + owner: 'deployment', + pairing: { allowPrincipalWorkers: true }, + }, + ], + }, + }, + }, + } as AppConfig), + registry: { + register: jest.fn(), + listAccessible: jest.fn().mockResolvedValue([]), + remove: jest.fn(), + }, + principalAuthEnabled: () => false, + }); + const res = response(); + + await handlers.list( + { user: { id: '68b2f0c498f24c1e78fa0001', role: 'USER' } } as never, + res as never, + ); + + expect(res.statusCode).toBe(200); + expect(res.body).toEqual({ environments: [], controlPlanes: [] }); + }); + + test('lists only principal control planes present in the caller effective policy', async () => { + const getAppConfig = jest.fn().mockResolvedValue({ + endpoints: { [EModelEndpoint.agents]: { statefulCodeSessions: { environments: [] } } }, + } as unknown as AppConfig); + const handlers = createCodeEnvironmentHttpHandlers({ + getAppConfig, + registry: { + register: jest.fn(), + listAccessible: jest.fn().mockResolvedValue([]), + remove: jest.fn(), + }, + principalAuthEnabled: () => true, + }); + const res = response(); + + await handlers.list( + { + user: { + id: '68b2f0c498f24c1e78fa0001', + role: 'USER', + tenantId: 'tenant-1', + }, + } as never, + res as never, + ); + + expect(res.body).toEqual({ environments: [], controlPlanes: [] }); + expect(getAppConfig).toHaveBeenCalledWith({ + role: 'USER', + userId: '68b2f0c498f24c1e78fa0001', + idOnTheSource: undefined, + tenantId: 'tenant-1', + failClosed: true, + }); + }); + 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() }, + registry: { register, listAccessible: jest.fn(), remove: jest.fn() }, }); const res = response(); @@ -77,7 +151,7 @@ describe('code environment HTTP handlers', () => { } as AppConfig; const handlers = createCodeEnvironmentHttpHandlers({ getAppConfig: jest.fn().mockResolvedValue(appConfig), - registry: { register, listAccessible: jest.fn() }, + registry: { register, listAccessible: jest.fn(), remove: jest.fn() }, createEnvironmentId: () => 'code-generated', readSecret: jest.fn(() => 'administrator-token'), resolveTenantId: jest.fn(() => 'tenant-1'), @@ -119,27 +193,368 @@ describe('code environment HTTP handlers', () => { role: 'USER', idOnTheSource: null, }, + maxOwned: 5, environment: { id: 'code-generated', name: 'Personal VM', - type: 'attached', + type: 'attached' as const, baseURL: 'https://code.librechat.example/v1', workerId: 'code-generated', controlPlaneId: 'shared-code-api', + revocationTokenEnv: 'CODE_ADMIN_TOKEN', workerPrincipal: { type: 'user', id: '68b2f0c498f24c1e78fa0001' }, }, }); expect(res.body).toEqual({ environment: expect.objectContaining({ id: 'code-generated' }), - pairing: expect.objectContaining({ workerId: 'code-generated', code: 'a'.repeat(32) }), + pairing: expect.objectContaining({ + workerId: 'code-generated', + code: 'a'.repeat(32), + endpoint: 'https://code.librechat.example/v1', + }), }); }); + test('revokes an upstream pairing when the atomic owner quota is exhausted', async () => { + const fetchImpl = jest.fn( + async (input: string | URL | Request) => + ({ + ok: true, + json: async () => + String(input).endsWith('/bridge/pairings') + ? { + protocolVersion: 1, + workerId: 'code-generated', + code: 'a'.repeat(32), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + } + : { protocolVersion: 1, revoked: true }, + }) as Response, + ); + const handlers = createCodeEnvironmentHttpHandlers({ + getAppConfig: jest.fn().mockResolvedValue({ + endpoints: { + [EModelEndpoint.agents]: { + statefulCodeSessions: { + allowedEnvironments: ['user'], + environments: [ + { + id: 'shared-code-api', + name: 'Shared Code API', + type: 'attached', + baseURL: 'https://code.librechat.example/v1', + owner: 'deployment', + pairing: { + allowPrincipalWorkers: true, + tokenEnv: 'CODE_ADMIN_TOKEN', + }, + }, + ], + }, + }, + }, + } as AppConfig), + registry: { + register: jest.fn().mockRejectedValue(new CodeEnvironmentLimitError()), + listAccessible: jest.fn(), + remove: jest.fn(), + }, + createEnvironmentId: () => 'code-generated', + readSecret: () => 'administrator-token', + principalAuthEnabled: () => true, + principalAuthReady: jest.fn().mockResolvedValue(undefined), + fetchImpl, + }); + const res = response(); + + await handlers.pair( + { + user: { id: '68b2f0c498f24c1e78fa0001', role: 'USER' }, + body: { name: 'Personal VM', controlPlaneId: 'shared-code-api' }, + } as never, + res as never, + ); + + expect(res.statusCode).toBe(409); + expect(res.body).toEqual({ error: 'Personal code environment limit reached' }); + expect(fetchImpl).toHaveBeenCalledWith( + 'https://code.librechat.example/v1/bridge/workers/code-generated/revoke', + expect.objectContaining({ method: 'POST' }), + ); + }); + + test('returns 503 without issuing a pairing when the initial principal check is unavailable', async () => { + const fetchImpl = jest.fn(); + const handlers = createCodeEnvironmentHttpHandlers({ + getAppConfig: jest.fn().mockResolvedValue({ + endpoints: { + [EModelEndpoint.agents]: { + statefulCodeSessions: { + allowedEnvironments: ['user'], + environments: [ + { + id: 'shared-code-api', + name: 'Shared Code API', + type: 'attached', + baseURL: 'https://code.librechat.example/v1', + owner: 'deployment', + pairing: { allowPrincipalWorkers: true, tokenEnv: 'CODE_ADMIN_TOKEN' }, + }, + ], + }, + }, + }, + } as AppConfig), + registry: { register: jest.fn(), listAccessible: jest.fn(), remove: jest.fn() }, + createEnvironmentId: () => 'code-generated', + readSecret: () => 'administrator-token', + principalAuthEnabled: () => true, + principalAuthReady: jest.fn(), + principalIsActive: jest.fn().mockRejectedValue(new Error('user store unavailable')), + fetchImpl, + }); + const res = response(); + + await handlers.pair( + { + user: { id: '68b2f0c498f24c1e78fa0001', role: 'USER' }, + body: { name: 'Personal VM', controlPlaneId: 'shared-code-api' }, + } as never, + res as never, + ); + + expect(res.statusCode).toBe(503); + expect(res.body).toEqual({ error: 'Account status could not be confirmed' }); + expect(fetchImpl).not.toHaveBeenCalled(); + }); + + test('revokes through the registry fence when the principal becomes inactive after registration', async () => { + const remove = jest.fn( + async ({ beforeDelete }: { beforeDelete?: (target: never) => Promise }) => { + await beforeDelete?.({} as never); + return { id: 'code-generated' } as never; + }, + ); + const principalIsActive = jest + .fn() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + const fetchImpl = jest.fn( + async (input: string | URL | Request) => + ({ + ok: true, + json: async () => + String(input).endsWith('/bridge/pairings') + ? { + protocolVersion: 1, + workerId: 'code-generated', + code: 'a'.repeat(32), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + } + : { protocolVersion: 1, revoked: true }, + }) as Response, + ); + const handlers = createCodeEnvironmentHttpHandlers({ + getAppConfig: jest.fn().mockResolvedValue({ + endpoints: { + [EModelEndpoint.agents]: { + statefulCodeSessions: { + allowedEnvironments: ['user'], + environments: [ + { + id: 'shared-code-api', + name: 'Shared Code API', + type: 'attached', + baseURL: 'https://code.librechat.example/v1', + owner: 'deployment', + pairing: { allowPrincipalWorkers: true, tokenEnv: 'CODE_ADMIN_TOKEN' }, + }, + ], + }, + }, + }, + } as AppConfig), + registry: { + register: jest.fn().mockResolvedValue({ id: 'code-generated' }), + listAccessible: jest.fn(), + remove, + }, + createEnvironmentId: () => 'code-generated', + readSecret: () => 'administrator-token', + principalAuthEnabled: () => true, + principalAuthReady: jest.fn(), + principalIsActive, + fetchImpl, + }); + const res = response(); + + await handlers.pair( + { + user: { id: '68b2f0c498f24c1e78fa0001', role: 'USER' }, + body: { name: 'Personal VM', controlPlaneId: 'shared-code-api' }, + } as never, + res as never, + ); + + expect(res.statusCode).toBe(409); + expect(remove).toHaveBeenCalledWith( + expect.objectContaining({ + environmentId: 'code-generated', + beforeDelete: expect.any(Function), + }), + ); + expect(fetchImpl).toHaveBeenLastCalledWith( + 'https://code.librechat.example/v1/bridge/workers/code-generated/revoke', + expect.objectContaining({ method: 'POST' }), + ); + }); + + test('persists cleanup intent before compensating a registered pairing after removal fails', async () => { + const markRevocationPending = jest.fn().mockResolvedValue(undefined); + const remove = jest.fn().mockRejectedValue(new Error('registry removal failed')); + const principalIsActive = jest + .fn() + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(true) + .mockResolvedValueOnce(false); + const fetchImpl = jest.fn( + async (input: string | URL | Request) => + ({ + ok: true, + json: async () => + String(input).endsWith('/bridge/pairings') + ? { + protocolVersion: 1, + workerId: 'code-generated', + code: 'a'.repeat(32), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + } + : { protocolVersion: 1, revoked: true }, + }) as Response, + ); + const handlers = createCodeEnvironmentHttpHandlers({ + getAppConfig: jest.fn().mockResolvedValue({ + endpoints: { + [EModelEndpoint.agents]: { + statefulCodeSessions: { + allowedEnvironments: ['user'], + environments: [ + { + id: 'shared-code-api', + name: 'Shared Code API', + type: 'attached', + baseURL: 'https://code.librechat.example/v1', + owner: 'deployment', + pairing: { allowPrincipalWorkers: true, tokenEnv: 'CODE_ADMIN_TOKEN' }, + }, + ], + }, + }, + }, + } as AppConfig), + registry: { + register: jest.fn().mockResolvedValue({ id: 'code-generated' }), + listAccessible: jest.fn(), + remove, + markRevocationPending, + }, + createEnvironmentId: () => 'code-generated', + readSecret: () => 'administrator-token', + principalAuthEnabled: () => true, + principalAuthReady: jest.fn(), + principalIsActive, + fetchImpl, + }); + const res = response(); + + await handlers.pair( + { + user: { id: '68b2f0c498f24c1e78fa0001', role: 'USER' }, + body: { name: 'Personal VM', controlPlaneId: 'shared-code-api' }, + } as never, + res as never, + ); + + expect(res.statusCode).toBe(500); + expect(markRevocationPending).toHaveBeenCalledWith('code-generated'); + expect(markRevocationPending.mock.invocationCallOrder[0]).toBeLessThan( + fetchImpl.mock.invocationCallOrder[fetchImpl.mock.invocationCallOrder.length - 1] ?? 0, + ); + }); + + test('revokes an issued pairing when the post-issue principal check is unavailable', async () => { + const register = jest.fn(); + const principalIsActive = jest + .fn() + .mockResolvedValueOnce(true) + .mockRejectedValueOnce(new Error('user store unavailable')); + const fetchImpl = jest.fn( + async (input: string | URL | Request) => + ({ + ok: true, + json: async () => + String(input).endsWith('/bridge/pairings') + ? { + protocolVersion: 1, + workerId: 'code-generated', + code: 'a'.repeat(32), + expiresAt: new Date(Date.now() + 60_000).toISOString(), + } + : { protocolVersion: 1, revoked: true }, + }) as Response, + ); + const handlers = createCodeEnvironmentHttpHandlers({ + getAppConfig: jest.fn().mockResolvedValue({ + endpoints: { + [EModelEndpoint.agents]: { + statefulCodeSessions: { + allowedEnvironments: ['user'], + environments: [ + { + id: 'shared-code-api', + name: 'Shared Code API', + type: 'attached', + baseURL: 'https://code.librechat.example/v1', + owner: 'deployment', + pairing: { allowPrincipalWorkers: true, tokenEnv: 'CODE_ADMIN_TOKEN' }, + }, + ], + }, + }, + }, + } as AppConfig), + registry: { register, listAccessible: jest.fn(), remove: jest.fn() }, + createEnvironmentId: () => 'code-generated', + readSecret: () => 'administrator-token', + principalAuthEnabled: () => true, + principalAuthReady: jest.fn(), + principalIsActive, + fetchImpl, + }); + const res = response(); + + await handlers.pair( + { + user: { id: '68b2f0c498f24c1e78fa0001', role: 'USER' }, + body: { name: 'Personal VM', controlPlaneId: 'shared-code-api' }, + } as never, + res as never, + ); + + expect(res.statusCode).toBe(503); + expect(register).not.toHaveBeenCalled(); + expect(fetchImpl).toHaveBeenLastCalledWith( + 'https://code.librechat.example/v1/bridge/workers/code-generated/revoke', + expect.objectContaining({ method: 'POST' }), + ); + }); + test('rejects self-service pairing without principal-aware Code API auth', async () => { const fetchImpl = jest.fn(); const handlers = createCodeEnvironmentHttpHandlers({ getAppConfig: jest.fn(), - registry: { register: jest.fn(), listAccessible: jest.fn() }, + registry: { register: jest.fn(), listAccessible: jest.fn(), remove: jest.fn() }, principalAuthEnabled: jest.fn(() => false), fetchImpl, }); @@ -185,7 +600,7 @@ describe('code environment HTTP handlers', () => { ); const handlers = createCodeEnvironmentHttpHandlers({ getAppConfig, - registry: { register: jest.fn(), listAccessible: jest.fn() }, + registry: { register: jest.fn(), listAccessible: jest.fn(), remove: jest.fn() }, readSecret: jest.fn(() => 'administrator-token'), principalAuthEnabled: jest.fn(() => true), principalAuthReady: jest.fn(), @@ -219,7 +634,7 @@ describe('code environment HTTP handlers', () => { if (options.baseOnly === true) return {} as AppConfig; throw new Error('authorization unavailable'); }), - registry: { register: jest.fn(), listAccessible: jest.fn() }, + registry: { register: jest.fn(), listAccessible: jest.fn(), remove: jest.fn() }, principalAuthEnabled: jest.fn(() => true), principalAuthReady: jest.fn(), fetchImpl, @@ -294,6 +709,7 @@ describe('code environment HTTP handlers', () => { registry: { register: jest.fn().mockResolvedValue({ id: 'code-generated' }), listAccessible: jest.fn(), + remove: jest.fn(), }, createEnvironmentId: () => 'code-generated', readSecret, @@ -325,7 +741,7 @@ describe('code environment HTTP handlers', () => { const fetchImpl = jest.fn(); const handlers = createCodeEnvironmentHttpHandlers({ getAppConfig: jest.fn(), - registry: { register: jest.fn(), listAccessible: jest.fn() }, + registry: { register: jest.fn(), listAccessible: jest.fn(), remove: jest.fn() }, principalAuthEnabled: jest.fn(() => true), principalAuthReady: jest.fn(() => { throw new Error('invalid signing key'); @@ -374,7 +790,7 @@ describe('code environment HTTP handlers', () => { } as AppConfig; const handlers = createCodeEnvironmentHttpHandlers({ getAppConfig: jest.fn().mockResolvedValue(appConfig), - registry: { register, listAccessible: jest.fn() }, + registry: { register, listAccessible: jest.fn(), remove: jest.fn() }, createEnvironmentId: () => 'personal-vm', }); const req = { @@ -409,6 +825,96 @@ describe('code environment HTTP handlers', () => { }); }); + test('removes a fixed environment when its principal becomes inactive after registration', async () => { + const register = jest.fn().mockResolvedValue({ + resourceId: '68b2f0c498f24c1e78fa0111', + id: 'personal-vm', + name: 'Personal VM', + type: 'attached', + }); + const remove = jest.fn().mockResolvedValue({ id: 'personal-vm' }); + 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(), remove }, + createEnvironmentId: () => 'personal-vm', + principalIsActive: jest.fn().mockResolvedValueOnce(true).mockResolvedValueOnce(false), + }); + const res = response(); + + await handlers.register( + { + user: { id: '68b2f0c498f24c1e78fa0001', role: 'USER' }, + body: { name: 'Personal VM', controlPlaneId: 'shared-code-api' }, + } as never, + res as never, + ); + + expect(res.statusCode).toBe(409); + expect(remove).toHaveBeenCalledWith({ + actor: { + userId: '68b2f0c498f24c1e78fa0001', + role: 'USER', + idOnTheSource: null, + }, + environmentId: 'personal-vm', + }); + }); + + test('returns 503 without fixed registration when the principal check is unavailable', async () => { + const register = jest.fn(); + 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(), remove: jest.fn() }, + principalIsActive: jest.fn().mockRejectedValue(new Error('user store unavailable')), + }); + const res = response(); + + await handlers.register( + { + user: { id: '68b2f0c498f24c1e78fa0001', role: 'USER' }, + body: { name: 'Personal VM', controlPlaneId: 'shared-code-api' }, + } as never, + res as never, + ); + + expect(res.statusCode).toBe(503); + expect(register).not.toHaveBeenCalled(); + }); + test('does not register a fixed environment on a self-service-only control plane', async () => { const register = jest.fn(); const handlers = createCodeEnvironmentHttpHandlers({ @@ -434,7 +940,7 @@ describe('code environment HTTP handlers', () => { }, }, } as AppConfig), - registry: { register, listAccessible: jest.fn() }, + registry: { register, listAccessible: jest.fn(), remove: jest.fn() }, }); const res = response(); @@ -475,7 +981,7 @@ describe('code environment HTTP handlers', () => { }, }, } as AppConfig), - registry: { register: jest.fn(), listAccessible: jest.fn() }, + registry: { register: jest.fn(), listAccessible: jest.fn(), remove: jest.fn() }, readSecret: jest.fn(() => 'administrator-token'), principalAuthEnabled: jest.fn(() => true), principalAuthReady: jest.fn(), @@ -495,6 +1001,79 @@ describe('code environment HTTP handlers', () => { expect(fetchImpl).not.toHaveBeenCalled(); }); + test('revokes a user-bound worker before deleting its registry entry', async () => { + const remove = jest.fn(async ({ beforeDelete }) => { + await beforeDelete({ + resourceId: '68b2f0c498f24c1e78fa0111', + id: 'code-generated', + name: 'Personal VM', + type: 'attached', + baseURL: 'https://code.librechat.example/v1', + workerId: 'code-generated', + controlPlaneId: 'self-service', + revocationTokenEnv: 'CODE_ADMIN_TOKEN', + workerPrincipal: { type: 'user', id: '68b2f0c498f24c1e78fa0001' }, + }); + return { + resourceId: '68b2f0c498f24c1e78fa0111', + id: 'code-generated', + name: 'Personal VM', + type: 'attached' as const, + canDelete: true, + }; + }); + const fetchImpl = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ protocolVersion: 1, revoked: true }), + }); + const handlers = createCodeEnvironmentHttpHandlers({ + getAppConfig: jest.fn().mockResolvedValue({ + endpoints: { + [EModelEndpoint.agents]: { + statefulCodeSessions: { + allowedEnvironments: ['user'], + environments: [ + { + id: 'self-service', + name: 'Self-service', + type: 'attached', + baseURL: 'https://code.librechat.example/v1', + owner: 'deployment', + pairing: { + allowPrincipalWorkers: true, + tokenEnv: 'CODE_ADMIN_TOKEN', + }, + }, + ], + }, + }, + }, + } as AppConfig), + registry: { register: jest.fn(), listAccessible: jest.fn(), remove }, + readSecret: jest.fn(() => 'administrator-token'), + fetchImpl, + }); + const res = response(); + + await handlers.remove( + { + user: { id: '68b2f0c498f24c1e78fa0001', role: 'USER' }, + params: { environmentId: 'code-generated' }, + } as never, + res as never, + ); + + expect(res.statusCode).toBe(200); + expect(fetchImpl).toHaveBeenCalledWith( + 'https://code.librechat.example/v1/bridge/workers/code-generated/revoke', + expect.objectContaining({ + method: 'POST', + headers: { Authorization: 'Bearer administrator-token' }, + }), + ); + expect(remove).toHaveBeenCalled(); + }); + test.each([ { error: new Error('mongo connection details'), @@ -526,7 +1105,11 @@ describe('code environment HTTP handlers', () => { }, }, } as AppConfig), - registry: { register: jest.fn().mockRejectedValue(error), listAccessible: jest.fn() }, + registry: { + register: jest.fn().mockRejectedValue(error), + listAccessible: jest.fn(), + remove: jest.fn(), + }, }); const res = response(); diff --git a/packages/api/src/code/http.ts b/packages/api/src/code/http.ts index b6c485759d..60c0766a8c 100644 --- a/packages/api/src/code/http.ts +++ b/packages/api/src/code/http.ts @@ -3,6 +3,7 @@ import { EModelEndpoint } from 'librechat-data-provider'; import { logger, type AppConfig } from '@librechat/data-schemas'; import type { Response } from 'express'; import type { + CodeEnvironmentLifecycleTarget, CodeEnvironmentPrincipalContext, CodeEnvironmentRegistration, CodeEnvironmentSummary, @@ -10,21 +11,39 @@ import type { import type { GetAppConfigOptions } from '~/app/service'; import type { ServerRequest } from '~/types/http'; import type { CodeBridgeFetch } from './bridge'; +import { + CodeEnvironmentInUseError, + CodeEnvironmentLimitError, + CodeEnvironmentValidationError, + normalizeCodeEnvironmentName, +} from './environments'; +import { + CodeBridgeLifecycleError, + CodeBridgePairingError, + createCodeBridgePairing, + readCodeBridgeSecret, + revokeCodeBridgeWorker, +} from './bridge'; import { assertCodeApiJwtSigningReady, getCodeApiTenantId, isCodeApiJwtAuthEnabled, } from '~/auth/codeapi'; -import { CodeBridgePairingError, createCodeBridgePairing, readCodeBridgeSecret } from './bridge'; -import { CodeEnvironmentValidationError, normalizeCodeEnvironmentName } from './environments'; import { getAppConfigOptionsFromUser } from '~/app/service'; type Registry = { register: (params: { actor: CodeEnvironmentPrincipalContext; environment: CodeEnvironmentRegistration; + maxOwned?: number; }) => Promise; listAccessible: (actor: CodeEnvironmentPrincipalContext) => Promise; + remove: (params: { + actor: CodeEnvironmentPrincipalContext; + environmentId: string; + beforeDelete?: (target: CodeEnvironmentLifecycleTarget) => Promise; + }) => Promise; + markRevocationPending?: (environmentId: string) => Promise; }; type StatefulCodeConfig = NonNullable< @@ -42,6 +61,8 @@ export interface CodeEnvironmentHttpDeps { resolveTenantId?: (req: ServerRequest) => string; principalAuthEnabled?: () => boolean; principalAuthReady?: () => Promise | void; + principalIsActive?: (userId: string) => Promise; + maxPrincipalEnvironments?: number; fetchImpl?: CodeBridgeFetch; } @@ -80,6 +101,28 @@ function configuredPrincipalControlPlane( ); } +function principalControlPlanes(appConfig: AppConfig): Array<{ id: string; name: string }> { + return ( + appConfig.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.environments + ?.filter( + (environment) => + environment.type === 'attached' && + environment.owner === 'deployment' && + environment.pairing?.allowPrincipalWorkers === true, + ) + .map(({ id, name }) => ({ id, name })) ?? [] + ); +} + +class CodeEnvironmentLifecycleHttpError extends Error { + constructor( + public readonly status: number, + message: string, + ) { + super(message); + } +} + function pairingErrorResponse(error: unknown, res: Response): Response { if (!(error instanceof CodeBridgePairingError)) { return res.status(502).json({ error: 'Code API pairing request failed' }); @@ -105,20 +148,36 @@ export function createCodeEnvironmentHttpHandlers(deps: CodeEnvironmentHttpDeps) list: (req: ServerRequest, res: Response) => Promise; register: (req: ServerRequest, res: Response) => Promise; pair: (req: ServerRequest, res: Response) => Promise; + remove: (req: ServerRequest, res: Response) => Promise; } { const createEnvironmentId = deps.createEnvironmentId ?? (() => `code-${nanoid(20)}`); const readSecret = deps.readSecret ?? readCodeBridgeSecret; const resolveTenantId = deps.resolveTenantId ?? getCodeApiTenantId; const principalAuthEnabled = deps.principalAuthEnabled ?? isCodeApiJwtAuthEnabled; const principalAuthReady = deps.principalAuthReady ?? assertCodeApiJwtSigningReady; + const principalIsActive = deps.principalIsActive ?? (async () => true); + const maxPrincipalEnvironments = deps.maxPrincipalEnvironments ?? 5; 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 }); + let environments: CodeEnvironmentSummary[]; + let appConfig: AppConfig; + try { + [environments, appConfig] = await Promise.all([ + deps.registry.listAccessible(principal), + deps.getAppConfig({ ...getAppConfigOptionsFromUser(req.user), failClosed: true }), + ]); + } catch (error) { + logger.error('[codeEnvironments] discovery policy resolution failed:', error); + return res.status(503).json({ error: 'Code environment policy is unavailable' }); + } + return res.status(200).json({ + environments, + controlPlanes: principalAuthEnabled() ? principalControlPlanes(appConfig) : [], + }); } async function register(req: ServerRequest, res: Response): Promise { @@ -165,6 +224,17 @@ export function createCodeEnvironmentHttpHandlers(deps: CodeEnvironmentHttpDeps) return res.status(404).json({ error: 'Code control plane was not found' }); } + let activeBeforeRegistration: boolean; + try { + activeBeforeRegistration = await principalIsActive(principal.userId.toString()); + } catch (error) { + logger.error('[codeEnvironments] pre-registration principal check failed:', error); + return res.status(503).json({ error: 'Account status could not be confirmed' }); + } + if (!activeBeforeRegistration) { + return res.status(409).json({ error: 'Account deletion is already in progress' }); + } + try { const environment = await deps.registry.register({ actor: principal, @@ -173,11 +243,25 @@ export function createCodeEnvironmentHttpHandlers(deps: CodeEnvironmentHttpDeps) name, type: 'attached', baseURL: controlPlane.baseURL, - controlPlaneId, workerId: controlPlane.pairing?.workerId, + controlPlaneId: controlPlane.id, workerPrincipal: { type: 'deployment', id: controlPlane.id }, }, }); + let activeAfterRegistration = false; + let principalCheckUnavailable = false; + try { + activeAfterRegistration = await principalIsActive(principal.userId.toString()); + } catch (error) { + principalCheckUnavailable = true; + logger.error('[codeEnvironments] post-registration principal check failed:', error); + } + if (!activeAfterRegistration) { + await deps.registry.remove({ actor: principal, environmentId: environment.id }); + return principalCheckUnavailable + ? res.status(503).json({ error: 'Account status could not be confirmed' }) + : res.status(409).json({ error: 'Account deletion is already in progress' }); + } return res.status(201).json({ environment }); } catch (error) { const duplicate = @@ -253,6 +337,16 @@ export function createCodeEnvironmentHttpHandlers(deps: CodeEnvironmentHttpDeps) } const workerId = createEnvironmentId(); + let activeBeforePairing: boolean; + try { + activeBeforePairing = await principalIsActive(principal.userId.toString()); + } catch (error) { + logger.error('[codeEnvironments] pre-pairing principal check failed:', error); + return res.status(503).json({ error: 'Account status could not be confirmed' }); + } + if (!activeBeforePairing) { + return res.status(409).json({ error: 'Account deletion is already in progress' }); + } let pairing; try { pairing = await createCodeBridgePairing({ @@ -269,9 +363,36 @@ export function createCodeEnvironmentHttpHandlers(deps: CodeEnvironmentHttpDeps) return pairingErrorResponse(error, res); } + let activeAfterPairing = false; + let principalCheckUnavailable = false; + try { + activeAfterPairing = await principalIsActive(principal.userId.toString()); + } catch (error) { + principalCheckUnavailable = true; + logger.error('[codeEnvironments] post-pairing principal check failed:', error); + } + if (!activeAfterPairing) { + try { + await revokeCodeBridgeWorker({ + baseURL: controlPlane.baseURL, + token, + workerId, + fetchImpl: deps.fetchImpl, + }); + } catch (error) { + logger.error('[codeEnvironments] orphaned pairing compensation failed:', error); + return res.status(502).json({ error: 'Code worker pairing could not be compensated' }); + } + return principalCheckUnavailable + ? res.status(503).json({ error: 'Account status could not be confirmed' }) + : res.status(409).json({ error: 'Account deletion is already in progress' }); + } + + let registrationCommitted = false; try { const environment = await deps.registry.register({ actor: principal, + maxOwned: maxPrincipalEnvironments, environment: { id: workerId, name, @@ -279,18 +400,73 @@ export function createCodeEnvironmentHttpHandlers(deps: CodeEnvironmentHttpDeps) baseURL: controlPlane.baseURL, workerId, controlPlaneId: controlPlane.id, + revocationTokenEnv: tokenEnv, workerPrincipal: { type: 'user', id: principal.userId.toString() }, }, }); + registrationCommitted = true; + let activeAfterRegistration = false; + try { + activeAfterRegistration = await principalIsActive(principal.userId.toString()); + } catch (error) { + logger.error('[codeEnvironments] post-registration principal check failed:', error); + } + if (!activeAfterRegistration) { + const removed = await deps.registry.remove({ + actor: principal, + environmentId: workerId, + beforeDelete: async () => { + await revokeCodeBridgeWorker({ + baseURL: controlPlane.baseURL, + token, + workerId, + fetchImpl: deps.fetchImpl, + }); + }, + }); + if (removed == null) { + await revokeCodeBridgeWorker({ + baseURL: controlPlane.baseURL, + token, + workerId, + fetchImpl: deps.fetchImpl, + }); + } + return res.status(409).json({ error: 'Account is unavailable for code worker pairing' }); + } return res.status(201).json({ environment, pairing: { workerId: pairing.workerId, code: pairing.code, expiresAt: pairing.expiresAt, + endpoint: controlPlane.baseURL, }, }); } catch (error) { + if (registrationCommitted) { + try { + if (deps.registry.markRevocationPending == null) { + throw new Error('Code environment cleanup scheduling is unavailable'); + } + await deps.registry.markRevocationPending(workerId); + } catch (markerError) { + logger.error('[codeEnvironments] failed to persist pairing cleanup intent:', markerError); + return res.status(503).json({ error: 'Code environment cleanup could not be scheduled' }); + } + } + try { + await revokeCodeBridgeWorker({ + baseURL: controlPlane.baseURL, + token, + workerId, + fetchImpl: deps.fetchImpl, + }); + } catch { + return res.status(502).json({ + error: 'Code environment registration failed and its pairing could not be revoked', + }); + } const duplicate = typeof error === 'object' && error != null && @@ -299,6 +475,9 @@ export function createCodeEnvironmentHttpHandlers(deps: CodeEnvironmentHttpDeps) if (duplicate) { return res.status(409).json({ error: 'Code environment already exists' }); } + if (error instanceof CodeEnvironmentLimitError) { + return res.status(409).json({ error: error.message }); + } if (error instanceof CodeEnvironmentValidationError) { return res.status(400).json({ error: error.message }); } @@ -307,5 +486,58 @@ export function createCodeEnvironmentHttpHandlers(deps: CodeEnvironmentHttpDeps) } } - return { list, register, pair }; + async function remove(req: ServerRequest, res: Response): Promise { + const principal = actor(req); + if (principal == null) { + return res.status(401).json({ error: 'Authentication required' }); + } + const params = req.params as Record; + const environmentId = typeof params.environmentId === 'string' ? params.environmentId : ''; + if (!environmentId) { + return res.status(400).json({ error: 'Code environment id is required' }); + } + try { + const environment = await deps.registry.remove({ + actor: principal, + environmentId, + beforeDelete: async (target) => { + if (target.workerPrincipal?.type !== 'user' || target.workerId == null) return; + const tokenEnv = target.revocationTokenEnv; + const token = tokenEnv != null ? readSecret(tokenEnv)?.trim() : undefined; + if (!token) { + throw new CodeEnvironmentLifecycleHttpError( + 503, + 'Code environment revocation is not configured', + ); + } + await revokeCodeBridgeWorker({ + baseURL: target.baseURL, + token, + workerId: target.workerId, + fetchImpl: deps.fetchImpl, + }); + }, + }); + if (environment == null) { + return res.status(404).json({ error: 'Code environment was not found' }); + } + return res.status(200).json({ environment }); + } catch (error) { + if (error instanceof CodeEnvironmentLifecycleHttpError) { + return res.status(error.status).json({ error: error.message }); + } + if (error instanceof CodeEnvironmentInUseError) { + return res.status(409).json({ error: error.message }); + } + if (error instanceof CodeBridgeLifecycleError) { + return res.status(error.reason === 'timeout' ? 504 : 502).json({ + error: 'Code environment worker could not be revoked', + ...(error.upstreamStatus != null ? { upstreamStatus: error.upstreamStatus } : {}), + }); + } + throw error; + } + } + + return { list, register, pair, remove }; } diff --git a/packages/api/src/code/index.ts b/packages/api/src/code/index.ts index 0e405e9f19..787a58b1de 100644 --- a/packages/api/src/code/index.ts +++ b/packages/api/src/code/index.ts @@ -2,3 +2,4 @@ export * from './environments'; export * from './http'; export * from './config'; export * from './bridge'; +export * from './lifecycle'; diff --git a/packages/api/src/code/lifecycle.ts b/packages/api/src/code/lifecycle.ts new file mode 100644 index 0000000000..f82de80e0b --- /dev/null +++ b/packages/api/src/code/lifecycle.ts @@ -0,0 +1,440 @@ +import { EModelEndpoint, ResourceType } from 'librechat-data-provider'; +import { createMethods, logger, runAsSystem } from '@librechat/data-schemas'; +import type { AppConfig, CodeEnvironmentDocument } from '@librechat/data-schemas'; +import type { CodeBridgeFetch } from './bridge'; +import { readCodeBridgeSecret, revokeCodeBridgeWorker } from './bridge'; + +const RECONCILE_INTERVAL_MS = 60_000; +const RECONCILE_LEASE_MS = 2 * 60_000; +const REGISTRATION_STALE_MS = 5 * 60_000; +const REGISTRATION_RETRY_MS = 5 * 60_000; +const REVOCATION_RETRY_MS = 5 * 60_000; +let reconcileTimer: NodeJS.Timeout | undefined; +let reconcileInFlight: Promise | undefined; + +function agentReferenceFilter(environmentId: string, tenantId?: string) { + return { + code_environment_id: environmentId, + ...(tenantId == null ? { tenantId: { $exists: false } } : { tenantId }), + }; +} + +export async function reconcileCodeEnvironmentLifecycle({ + mongoose, + readSecret = readCodeBridgeSecret, + fetchImpl, + limit = 25, +}: { + mongoose: typeof import('mongoose'); + readSecret?: (name: string) => string | undefined; + fetchImpl?: CodeBridgeFetch; + limit?: number; +}): Promise { + await runAsSystem(async () => { + const CodeEnvironment = mongoose.models.CodeEnvironment; + const AclEntry = mongoose.models.AclEntry; + if (CodeEnvironment == null || AclEntry == null) return; + const methods = createMethods(mongoose); + const now = new Date(); + await CodeEnvironment.updateMany( + {}, + { $pull: { pendingAgentReferences: { expiresAt: { $lte: now } } } }, + ); + const expiredRemovals = await CodeEnvironment.find({ + deletionCommittedAt: { $exists: false }, + deletionLeaseExpiresAt: { $lte: now }, + }) + .sort({ _id: 1 }) + .limit(limit) + .select('_id') + .lean>>(); + for (const candidate of expiredRemovals) { + const environment = await methods.beginCodeEnvironmentRemoval(candidate._id); + if (environment == null || environment.deletionLeaseId == null) continue; + const leaseId = environment.deletionLeaseId; + const Agent = mongoose.models.Agent; + if ( + Agent != null && + (await Agent.exists( + agentReferenceFilter(environment.environmentId, environment.tenantId), + )) != null + ) { + await methods.cancelCodeEnvironmentRemoval(environment._id, leaseId); + continue; + } + try { + if (environment.workerPrincipal?.type === 'user') { + const tokenEnv = environment.revocationTokenEnv; + const token = tokenEnv != null ? readSecret(tokenEnv)?.trim() : undefined; + if (!token || environment.workerId == null) continue; + await revokeCodeBridgeWorker({ + baseURL: environment.baseURL, + token, + workerId: environment.workerId, + fetchImpl, + }); + } + await methods.commitCodeEnvironmentRemoval(environment._id, leaseId); + await AclEntry.deleteMany({ + resourceType: ResourceType.CODE_ENVIRONMENT, + resourceId: environment._id, + }); + await methods.deleteCodeEnvironmentById(environment._id); + } catch (error) { + logger.error('[code-environments] interrupted removal reconciliation failed:', error); + } + } + const staleRegistration = new Date(Date.now() - REGISTRATION_STALE_MS); + const registrationCandidates = await CodeEnvironment.find({ + registrationPendingAt: { $lte: staleRegistration }, + $and: [ + { + $or: [ + { registrationReconcileAfter: { $exists: false } }, + { registrationReconcileAfter: { $lte: now } }, + ], + }, + { + $or: [ + { registrationLeaseExpiresAt: { $exists: false } }, + { registrationLeaseExpiresAt: { $lte: now } }, + ], + }, + ], + }) + .sort({ registrationReconcileAfter: 1, _id: 1 }) + .limit(limit) + .select('_id') + .lean>>(); + for (const candidate of registrationCandidates) { + const leaseId = new mongoose.Types.ObjectId().toHexString(); + const leaseNow = new Date(); + const environment = await CodeEnvironment.findOneAndUpdate( + { + _id: candidate._id, + registrationPendingAt: { $lte: staleRegistration }, + $and: [ + { + $or: [ + { registrationReconcileAfter: { $exists: false } }, + { registrationReconcileAfter: { $lte: leaseNow } }, + ], + }, + { + $or: [ + { registrationLeaseExpiresAt: { $exists: false } }, + { registrationLeaseExpiresAt: { $lte: leaseNow } }, + ], + }, + ], + }, + { + $set: { + registrationLeaseId: leaseId, + registrationLeaseExpiresAt: new Date(leaseNow.getTime() + RECONCILE_LEASE_MS), + }, + }, + { new: true }, + ).lean(); + if (environment == null) continue; + const deferRegistration = async (): Promise => { + await CodeEnvironment.updateOne( + { _id: environment._id, registrationLeaseId: leaseId }, + { + $set: { registrationReconcileAfter: new Date(Date.now() + REGISTRATION_RETRY_MS) }, + $unset: { registrationLeaseId: 1, registrationLeaseExpiresAt: 1 }, + }, + ); + }; + const tokenEnv = environment.revocationTokenEnv; + const token = tokenEnv != null ? readSecret(tokenEnv)?.trim() : undefined; + if (environment.workerId != null && environment.workerPrincipal?.type !== 'deployment') { + if (!token) { + await deferRegistration(); + continue; + } + try { + await revokeCodeBridgeWorker({ + baseURL: environment.baseURL, + token, + workerId: environment.workerId, + fetchImpl, + }); + } catch { + await deferRegistration(); + continue; + } + } + await AclEntry.deleteMany({ + resourceType: ResourceType.CODE_ENVIRONMENT, + resourceId: environment._id, + }); + await CodeEnvironment.deleteOne({ + _id: environment._id, + registrationLeaseId: leaseId, + }); + } + + const committedDeletions = await CodeEnvironment.find({ + deletionCommittedAt: { $exists: true }, + }) + .limit(limit) + .lean(); + for (const environment of committedDeletions) { + await AclEntry.deleteMany({ + resourceType: ResourceType.CODE_ENVIRONMENT, + resourceId: environment._id, + }); + await methods.deleteCodeEnvironmentById(environment._id); + } + + /** A failed marker write after an account deletion must not strand live worker + * credentials. The missing owner is itself durable retry intent, so discover + * unmarked orphans independently of the account-deletion request path. */ + const orphanedEnvironments = await CodeEnvironment.aggregate<{ + _id: CodeEnvironmentDocument['_id']; + createdBy: CodeEnvironmentDocument['createdBy']; + workerId?: string; + workerPrincipal?: CodeEnvironmentDocument['workerPrincipal']; + }>([ + { + $match: { + revocationPendingAt: { $exists: false }, + deletionCommittedAt: { $exists: false }, + }, + }, + { + $lookup: { + from: mongoose.models.User.collection.name, + localField: 'createdBy', + foreignField: '_id', + as: 'owner', + }, + }, + { $match: { owner: { $size: 0 } } }, + { $sort: { _id: 1 } }, + { $limit: limit }, + { $project: { _id: 1, createdBy: 1, workerId: 1, workerPrincipal: 1 } }, + ]); + const revocationTargets = orphanedEnvironments.filter( + ({ workerId, workerPrincipal }) => workerId != null && workerPrincipal?.type === 'user', + ); + if (revocationTargets.length > 0) { + await CodeEnvironment.updateMany( + { _id: { $in: revocationTargets.map(({ _id }) => _id) } }, + { + $set: { revocationPendingAt: now }, + $inc: { revocationAttempts: 1 }, + $unset: { revocationLastError: 1, revocationReconcileAfter: 1 }, + }, + ); + } + for (const creatorId of new Set( + orphanedEnvironments.map(({ createdBy }) => createdBy.toHexString()), + )) { + await methods.deleteUserCodeEnvironments(creatorId); + } + + const candidates = await CodeEnvironment.find({ + revocationPendingAt: { $exists: true }, + $and: [ + { + $or: [ + { revocationReconcileAfter: { $exists: false } }, + { revocationReconcileAfter: { $lte: now } }, + ], + }, + { + $or: [ + { revocationLeaseExpiresAt: { $exists: false } }, + { revocationLeaseExpiresAt: { $lte: now } }, + ], + }, + ], + }) + .sort({ revocationReconcileAfter: 1, _id: 1 }) + .limit(limit) + .select('_id') + .lean>>(); + for (const candidate of candidates) { + const now = new Date(); + const leaseId = new mongoose.Types.ObjectId().toHexString(); + const environment = await CodeEnvironment.findOneAndUpdate( + { + _id: candidate._id, + revocationPendingAt: { $exists: true }, + $and: [ + { + $or: [ + { revocationReconcileAfter: { $exists: false } }, + { revocationReconcileAfter: { $lte: now } }, + ], + }, + { + $or: [ + { revocationLeaseExpiresAt: { $exists: false } }, + { revocationLeaseExpiresAt: { $lte: now } }, + ], + }, + ], + }, + { + $set: { + revocationLeaseId: leaseId, + revocationLeaseExpiresAt: new Date(now.getTime() + RECONCILE_LEASE_MS), + }, + $inc: { revocationAttempts: 1 }, + }, + { new: true }, + ).lean(); + if (environment == null || environment.workerId == null) continue; + const tokenEnv = environment.revocationTokenEnv; + const token = tokenEnv != null ? readSecret(tokenEnv)?.trim() : undefined; + try { + if (!token) throw new Error('Code environment revocation token is unavailable'); + await revokeCodeBridgeWorker({ + baseURL: environment.baseURL, + token, + workerId: environment.workerId, + fetchImpl, + }); + await AclEntry.deleteMany({ + resourceType: ResourceType.CODE_ENVIRONMENT, + resourceId: environment._id, + }); + await methods.deleteCodeEnvironmentById(environment._id); + } catch (error) { + const message = (error instanceof Error ? error.message : 'Worker revocation failed').slice( + 0, + 500, + ); + await CodeEnvironment.updateOne( + { _id: environment._id, revocationLeaseId: leaseId }, + { + $set: { + revocationLastError: message, + revocationReconcileAfter: new Date(Date.now() + REVOCATION_RETRY_MS), + }, + $unset: { revocationLeaseId: 1, revocationLeaseExpiresAt: 1 }, + }, + ); + } + } + }); +} + +export function startCodeEnvironmentLifecycleReconciler( + options: Parameters[0], +): void { + if (reconcileTimer != null) return; + const run = (): void => { + if (reconcileInFlight != null) return; + const current = reconcileCodeEnvironmentLifecycle(options) + .catch((error) => { + logger.error('[code-environments] lifecycle reconciliation failed:', error); + }) + .finally(() => { + if (reconcileInFlight === current) reconcileInFlight = undefined; + }); + reconcileInFlight = current; + }; + run(); + reconcileTimer = setInterval(run, RECONCILE_INTERVAL_MS); + reconcileTimer.unref(); +} + +export async function revokeUserCodeEnvironmentWorkers({ + mongoose, + userId, + appConfig, + readSecret = readCodeBridgeSecret, + fetchImpl, +}: { + mongoose: typeof import('mongoose'); + userId: string; + appConfig: AppConfig; + readSecret?: (name: string) => string | undefined; + fetchImpl?: CodeBridgeFetch; +}): Promise { + const environments = await createMethods(mongoose).findCodeEnvironmentsByCreator(userId); + const controlPlanes = + appConfig.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.environments ?? []; + const targets = environments.filter( + (environment) => environment.workerPrincipal?.type === 'user' && environment.workerId != null, + ); + const CodeEnvironment = mongoose.models.CodeEnvironment; + if (targets.length > 0 && CodeEnvironment != null) { + await CodeEnvironment.updateMany( + { _id: { $in: targets.map(({ _id }) => _id) } }, + { + $set: { revocationPendingAt: new Date() }, + $inc: { revocationAttempts: 1 }, + $unset: { revocationLastError: 1 }, + }, + ); + } + const results = await Promise.allSettled( + targets.map(async (environment) => { + const workerId = environment.workerId; + if (workerId == null) { + throw new Error( + `Code environment worker id is unavailable for ${environment.environmentId}`, + ); + } + const fallbackControlPlane = controlPlanes.find( + (candidate) => + candidate.id === environment.controlPlaneId && + candidate.owner === 'deployment' && + candidate.type === 'attached' && + candidate.pairing?.allowPrincipalWorkers === true && + candidate.baseURL === environment.baseURL, + ); + const tokenEnv = environment.revocationTokenEnv ?? fallbackControlPlane?.pairing?.tokenEnv; + const token = tokenEnv != null ? readSecret(tokenEnv)?.trim() : undefined; + if (!token) { + throw new Error( + `Code environment revocation is unavailable for ${environment.environmentId}`, + ); + } + await revokeCodeBridgeWorker({ + baseURL: environment.baseURL, + token, + workerId, + fetchImpl, + }); + return environment.environmentId; + }), + ); + let revoked = 0; + for (const [index, result] of results.entries()) { + const target = targets[index]; + if (result.status === 'fulfilled') { + revoked += 1; + await CodeEnvironment?.updateOne( + { _id: target._id }, + { + $set: { deletionCommittedAt: new Date() }, + $unset: { + revocationPendingAt: 1, + revocationAttempts: 1, + revocationLastError: 1, + revocationReconcileAfter: 1, + }, + }, + ); + continue; + } + const failureMessage = ( + result.reason instanceof Error ? result.reason.message : 'Worker revocation failed' + ).slice(0, 500); + await CodeEnvironment?.updateOne( + { _id: target._id }, + { $set: { revocationLastError: failureMessage } }, + ); + logger.error( + `[code-environments] worker revocation failed during committed user deletion: ${userId}`, + result.reason, + ); + } + return revoked; +} diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index c958c8d299..72edbed6d8 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -49,6 +49,10 @@ export const balance = () => `${BASE_URL}/api/balance`; export const userPlugins = () => `${BASE_URL}/api/user/plugins`; export const deleteUser = () => `${BASE_URL}/api/user/delete`; +export const codeEnvironments = () => `${BASE_URL}/api/code-environments`; +export const codeEnvironmentPairings = () => `${codeEnvironments()}/pairings`; +export const codeEnvironmentById = (id: string) => + `${codeEnvironments()}/${encodeURIComponent(id)}`; const messagesRoot = `${BASE_URL}/api/messages`; diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 7b0e8194e8..51a2e87c5b 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -68,6 +68,23 @@ export function deleteUser(payload?: t.TDeleteUserRequest): Promise { return request.deleteWithOptions(endpoints.deleteUser(), { data: payload }); } +export function getCodeEnvironments(): Promise { + return request.get(endpoints.codeEnvironments()); +} + +export function pairCodeEnvironment(payload: { + name: string; + controlPlaneId: string; +}): Promise { + return request.post(endpoints.codeEnvironmentPairings(), payload); +} + +export function deleteCodeEnvironment( + id: string, +): Promise<{ environment: t.TCodeEnvironmentSummary }> { + return request.delete(endpoints.codeEnvironmentById(id)); +} + export function getFavorites(): Promise { return request.get(`${endpoints.apiBaseUrl()}/api/user/settings/favorites`); } diff --git a/packages/data-provider/src/keys.ts b/packages/data-provider/src/keys.ts index d58856d066..36c7d61b62 100644 --- a/packages/data-provider/src/keys.ts +++ b/packages/data-provider/src/keys.ts @@ -95,6 +95,7 @@ export enum QueryKeys { schedule = 'schedule', parentSubagents = 'parentSubagents', subagentThread = 'subagentThread', + codeEnvironments = 'codeEnvironments', agentQueuedTurns = 'agentQueuedTurns', } @@ -151,4 +152,6 @@ export enum MutationKeys { updateSchedule = 'updateSchedule', deleteSchedule = 'deleteSchedule', runSchedule = 'runSchedule', + pairCodeEnvironment = 'pairCodeEnvironment', + deleteCodeEnvironment = 'deleteCodeEnvironment', } diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 8f28e0a987..31f92b1e68 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -534,6 +534,34 @@ export type TPublicCodeEnvironment = { pairingAvailable?: boolean; }; +export type TCodeEnvironmentSummary = { + resourceId: string; + id: string; + name: string; + type: 'managed' | 'attached'; + canDelete: boolean; +}; + +export type TCodeControlPlane = { + id: string; + name: string; +}; + +export type TCodeEnvironmentsResponse = { + environments: TCodeEnvironmentSummary[]; + controlPlanes: TCodeControlPlane[]; +}; + +export type TCodeEnvironmentPairingResponse = { + environment: TCodeEnvironmentSummary; + pairing: { + workerId: string; + code: string; + expiresAt: string; + endpoint: string; + }; +}; + export type TConfig = { order: number; type?: EModelEndpoint; diff --git a/packages/data-schemas/src/methods/agent.spec.ts b/packages/data-schemas/src/methods/agent.spec.ts index 139fdb71c2..017713cf24 100644 --- a/packages/data-schemas/src/methods/agent.spec.ts +++ b/packages/data-schemas/src/methods/agent.spec.ts @@ -19,7 +19,8 @@ import type { UpdateQuery, Model, } from 'mongoose'; -import type { IAgent, IAclEntry, IUser, IAccessRole } from '..'; +import type { IAgent, IAclEntry, IUser, IAccessRole, CodeEnvironmentDocument } from '..'; +import { withCodeEnvironmentReference } from './codeEnvironment'; import { createAgentMethods, type AgentMethods } from './agent'; import { tenantStorage } from '~/config/tenantContext'; import { createAclEntryMethods } from './aclEntry'; @@ -2595,6 +2596,152 @@ describe('Agent Methods', () => { expect(revertedAgent.skill_authoring_enabled).toBeUndefined(); }); + test('renews a code environment reference until the guarded write settles', async () => { + const environmentId = `environment_${uuidv4()}`; + await mongoose.models.CodeEnvironment.create({ + environmentId, + name: 'Slow writer environment', + type: 'attached', + baseURL: 'https://code.example.com', + controlPlaneId: 'shared-code-api', + createdBy: new mongoose.Types.ObjectId(), + }); + let settleWrite: (() => void) | undefined; + const guardedWrite = withCodeEnvironmentReference( + mongoose, + environmentId, + async () => + await new Promise((resolve) => { + settleWrite = resolve; + }), + 10, + ); + let initial: CodeEnvironmentDocument | null = null; + for (let attempt = 0; attempt < 20; attempt++) { + initial = await mongoose.models.CodeEnvironment.findOne({ + environmentId, + }).lean(); + if (initial?.pendingAgentReferences?.length) break; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + const initialExpiry = initial?.pendingAgentReferences?.[0]?.expiresAt; + + await new Promise((resolve) => setTimeout(resolve, 30)); + + const renewed = await mongoose.models.CodeEnvironment.findOne({ + environmentId, + }).lean(); + expect(initialExpiry).toBeInstanceOf(Date); + expect(renewed?.pendingAgentReferences?.[0]?.expiresAt.getTime()).toBeGreaterThan( + initialExpiry?.getTime() ?? 0, + ); + settleWrite?.(); + await guardedWrite; + await expect( + mongoose.models.CodeEnvironment.findOne({ environmentId }).lean(), + ).resolves.toMatchObject({ pendingAgentReferences: [] }); + }); + + test('rejects a guarded write when its code environment reference lease is lost', async () => { + const environmentId = `environment_${uuidv4()}`; + await mongoose.models.CodeEnvironment.create({ + environmentId, + name: 'Lost lease environment', + type: 'attached', + baseURL: 'https://code.example.com', + controlPlaneId: 'shared-code-api', + createdBy: new mongoose.Types.ObjectId(), + }); + let settleWrite: (() => void) | undefined; + const compensate = jest.fn().mockResolvedValue(undefined); + const guardedWrite = withCodeEnvironmentReference( + mongoose, + environmentId, + async () => + await new Promise((resolve) => { + settleWrite = resolve; + }), + 10, + compensate, + ); + for (let attempt = 0; attempt < 20; attempt++) { + const environment = await mongoose.models.CodeEnvironment.findOne({ + environmentId, + pendingAgentReferences: { $exists: true }, + }); + if (environment != null) break; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + await mongoose.models.CodeEnvironment.updateOne( + { environmentId }, + { $set: { deletionStartedAt: new Date() } }, + ); + await new Promise((resolve) => setTimeout(resolve, 25)); + settleWrite?.(); + + await expect(guardedWrite).rejects.toMatchObject({ + name: 'CodeEnvironmentReferenceError', + }); + expect(compensate).toHaveBeenCalledWith(undefined); + }); + + test('does not overwrite an intervening Agent update when acquiring an environment reference', async () => { + const agentId = `agent_${uuidv4()}`; + const environmentId = `environment_${uuidv4()}`; + const authorId = new mongoose.Types.ObjectId(); + await createAgent({ + id: agentId, + name: 'Original agent name', + author: authorId, + model: 'test-model', + provider: 'test-provider', + }); + await mongoose.models.CodeEnvironment.create({ + environmentId, + name: 'Concurrent update environment', + type: 'attached', + baseURL: 'https://code.example.com', + controlPlaneId: 'shared-code-api', + createdBy: authorId, + }); + const CodeEnvironment = mongoose.models.CodeEnvironment; + const reserve = CodeEnvironment.findOneAndUpdate.bind(CodeEnvironment); + let enteredReserve!: () => void; + let releaseReserve!: () => void; + const entered = new Promise((resolve) => (enteredReserve = resolve)); + const release = new Promise((resolve) => (releaseReserve = resolve)); + const reserveSpy = jest.spyOn(CodeEnvironment, 'findOneAndUpdate').mockImplementationOnce( + (...args: Parameters) => + ({ + lean: async () => { + enteredReserve(); + await release; + return await reserve(...args).lean(); + }, + }) as ReturnType, + ); + const guardedUpdate = updateAgent( + { id: agentId }, + { name: 'Guarded update name', code_environment_id: environmentId }, + ); + await entered; + await Agent.updateOne( + { id: agentId }, + { $set: { description: 'Intervening update survived' } }, + ); + releaseReserve(); + + await expect(guardedUpdate).resolves.toBeNull(); + await expect(Agent.findOne({ id: agentId }).lean()).resolves.toMatchObject({ + name: 'Original agent name', + description: 'Intervening update survived', + }); + await expect(Agent.findOne({ id: agentId }).lean()).resolves.not.toHaveProperty( + 'code_environment_id', + ); + reserveSpy.mockRestore(); + }); + test('should prune deleted skill ids when reverting to an older version', async () => { const agentId = `agent_${uuidv4()}`; const authorId = new mongoose.Types.ObjectId(); diff --git a/packages/data-schemas/src/methods/agent.ts b/packages/data-schemas/src/methods/agent.ts index 2dcff45400..8e5e138564 100644 --- a/packages/data-schemas/src/methods/agent.ts +++ b/packages/data-schemas/src/methods/agent.ts @@ -9,6 +9,7 @@ import { import type { FilterQuery, Model, PipelineStage, ProjectionType, Types } from 'mongoose'; import type { AgentToolResources } from 'librechat-data-provider'; import type { IAgent, IAclEntry } from '~/types'; +import { withCodeEnvironmentReference } from './codeEnvironment'; import { filterExistingSkillIds } from './skill'; import logger from '~/config/winston'; @@ -548,6 +549,34 @@ export function createAgentMethods( } { const { removeAllPermissions, getActions, getSoleOwnedResourceIds, isExternalSkillId } = deps; + async function restoreAgentAfterReferenceLoss( + Agent: Model, + agentAfterWrite: IAgent | null, + originalAgent: IAgent, + lostEnvironmentId: string, + ): Promise { + if (agentAfterWrite == null) return; + const { updatedAt } = agentAfterWrite as IAgent & { updatedAt: Date }; + const restored = await Agent.replaceOne( + { + _id: agentAfterWrite._id, + code_environment_id: lostEnvironmentId, + updatedAt, + }, + originalAgent, + { timestamps: false }, + ); + if (restored.matchedCount === 0) { + /** A concurrent writer may have changed the document after the guarded + * write. Never erase that writer, but still remove the lost reference if + * it remains active. */ + await Agent.updateOne( + { _id: agentAfterWrite._id, code_environment_id: lostEnvironmentId }, + { $unset: { code_environment_id: 1 } }, + ); + } + } + /** * Create an agent with the provided data. */ @@ -585,7 +614,15 @@ export function createAgentMethods( extractMCPServerNames(agentData.tools as string[] | undefined), }; - return (await Agent.create(initialAgentData)).toObject() as IAgent; + return await withCodeEnvironmentReference( + mongoose, + typeof agentData.code_environment_id === 'string' ? agentData.code_environment_id : undefined, + async () => (await Agent.create(initialAgentData)).toObject() as IAgent, + undefined, + async (createdAgent) => { + await Agent.deleteOne({ _id: createdAgent._id }); + }, + ); } /** @@ -685,6 +722,7 @@ export function createAgentMethods( let suppressedVersionEntry = false; const currentAgent = await Agent.findOne(searchParameter); + const currentRevision = (currentAgent as (IAgent & { updatedAt: Date }) | null)?.updatedAt; if (currentAgent) { const currentObject = currentAgent.toObject() as unknown as Record; const { __v, _id, id: __id, versions, author: _author, ...versionData } = currentObject; @@ -828,11 +866,40 @@ export function createAgentMethods( } } - const updatedAgent = (await Agent.findOneAndUpdate( - searchParameter, - updateData, - mongoOptions, - ).lean()) as IAgent | null; + const directEnvironmentId = updateData.code_environment_id; + const setEnvironmentId = + typeof updateData.$set === 'object' && updateData.$set != null + ? (updateData.$set as { code_environment_id?: unknown }).code_environment_id + : undefined; + let nextEnvironmentId: string | undefined; + if (typeof directEnvironmentId === 'string') { + nextEnvironmentId = directEnvironmentId; + } else if (typeof setEnvironmentId === 'string') { + nextEnvironmentId = setEnvironmentId; + } + const updatedAgent = await withCodeEnvironmentReference( + mongoose, + nextEnvironmentId, + async () => + (await Agent.findOneAndUpdate( + currentAgent == null || nextEnvironmentId == null + ? searchParameter + : { ...searchParameter, _id: currentAgent._id, updatedAt: currentRevision }, + updateData, + mongoOptions, + ).lean()) as IAgent | null, + undefined, + async (agentAfterUpdate) => { + if (agentAfterUpdate == null || nextEnvironmentId == null) return; + if (currentAgent == null) return; + await restoreAgentAfterReferenceLoss( + Agent, + agentAfterUpdate, + currentAgent.toObject() as IAgent, + nextEnvironmentId, + ); + }, + ); /** `version` is a response-only field holding the count of `versions`. It is reported * here so a suppressed entry keeps the shape callers saw before the write was fixed. @@ -1224,6 +1291,7 @@ export function createAgentMethods( } const revertToVersion = { ...(agent.versions[versionIndex] as Record) }; + const originalRevision = (agent as unknown as IAgent & { updatedAt: Date }).updatedAt; delete revertToVersion._id; delete revertToVersion.id; delete revertToVersion.versions; @@ -1256,9 +1324,30 @@ export function createAgentMethods( Object.keys(unsetOnRestore).length > 0 ? { $set: revertToVersion, $unset: unsetOnRestore } : { $set: revertToVersion }; - const revertedAgent = await Agent.findOneAndUpdate(searchParameter, revertUpdate, { - new: true, - }).lean(); + const revertedAgent = await withCodeEnvironmentReference( + mongoose, + typeof revertToVersion.code_environment_id === 'string' + ? revertToVersion.code_environment_id + : undefined, + async () => + await Agent.findOneAndUpdate( + { ...searchParameter, _id: agent._id, updatedAt: originalRevision }, + revertUpdate, + { new: true }, + ).lean(), + undefined, + async (agentAfterRevert) => { + if (agentAfterRevert == null || typeof revertToVersion.code_environment_id !== 'string') { + return; + } + await restoreAgentAfterReferenceLoss( + Agent, + agentAfterRevert, + agent.toObject() as IAgent, + revertToVersion.code_environment_id, + ); + }, + ); if (!revertedAgent) { throw new Error('Agent not found'); } diff --git a/packages/data-schemas/src/methods/codeEnvironment.ts b/packages/data-schemas/src/methods/codeEnvironment.ts index d9a4899303..cb6a563293 100644 --- a/packages/data-schemas/src/methods/codeEnvironment.ts +++ b/packages/data-schemas/src/methods/codeEnvironment.ts @@ -1,17 +1,202 @@ import { Types } from 'mongoose'; +import { createHash } from 'crypto'; import { ResourceType } from 'librechat-data-provider'; import type { Model } from 'mongoose'; import type { CodeEnvironmentDocument } from '~/types'; import type { IAclEntry } from '~/types'; +import { getTenantId, SYSTEM_TENANT_ID } from '~/config/tenantContext'; +import logger from '~/config/winston'; + +const CODE_ENVIRONMENT_TOMBSTONES = 'code_environment_tombstones'; +const REFERENCE_LEASE_MS = 2 * 60_000; +const REFERENCE_RENEW_INTERVAL_MS = REFERENCE_LEASE_MS / 3; +const REMOVAL_LEASE_MS = 2 * 60_000; + +type CodeEnvironmentTombstone = { + _id: string; + tenantId: string; + environmentId: string; + deletedAt: Date; +}; + +function tenantScope(tenantId?: string): string { + const resolved = tenantId ?? getTenantId(); + return resolved == null || resolved === SYSTEM_TENANT_ID ? '' : resolved; +} + +function tombstoneId(environmentId: string, tenantId?: string): string { + return createHash('sha256') + .update(`librechat-code-environment-tombstone\0${tenantScope(tenantId)}\0${environmentId}`) + .digest('hex'); +} + +function tombstones(mongoose: typeof import('mongoose')) { + const db = mongoose.connection.db; + if (db == null) { + throw new Error('MongoDB is not connected'); + } + return db.collection(CODE_ENVIRONMENT_TOMBSTONES); +} + +function ownerSlotId(ownerId: Types.ObjectId, slot: number): Types.ObjectId { + const hex = createHash('sha256') + .update(`librechat-code-environment-slot\0${ownerId.toHexString()}\0${slot}`) + .digest('hex') + .slice(0, 24); + return new Types.ObjectId(hex); +} + +function agentReferenceFilter(environmentId: string, tenantId?: string) { + return { + code_environment_id: environmentId, + ...(tenantId == null ? { tenantId: { $exists: false } } : { tenantId }), + }; +} + +export type CodeEnvironmentReferenceReservation = { + environmentId: string; + reservationId: string; +}; + +export class CodeEnvironmentReferenceError extends Error { + readonly statusCode = 409; + constructor(environmentId: string) { + super(`Code environment is being removed: ${environmentId}`); + this.name = 'CodeEnvironmentReferenceError'; + } +} + +export async function reserveCodeEnvironmentReference( + mongoose: typeof import('mongoose'), + environmentId: string | undefined, +): Promise { + if (environmentId == null || environmentId.length === 0) return undefined; + const CodeEnvironment = mongoose.models.CodeEnvironment as + | Model + | undefined; + if (CodeEnvironment == null) return undefined; + const reservationId = new Types.ObjectId().toHexString(); + const now = new Date(); + const expiresAt = new Date(now.getTime() + REFERENCE_LEASE_MS); + const reserved = await CodeEnvironment.findOneAndUpdate( + { + environmentId, + registrationPendingAt: { $exists: false }, + deletionCommittedAt: { $exists: false }, + deletionStartedAt: { $exists: false }, + }, + { $push: { pendingAgentReferences: { reservationId, expiresAt } } }, + { new: true }, + ).lean(); + if (reserved != null) return { environmentId, reservationId }; + + const [persisted, tombstone] = await Promise.all([ + CodeEnvironment.exists({ environmentId }), + tombstones(mongoose).findOne({ _id: tombstoneId(environmentId) }), + ]); + if (persisted != null || tombstone != null) { + throw new CodeEnvironmentReferenceError(environmentId); + } + // Deployment-owned configuration does not have a registry row. + return undefined; +} + +export async function releaseCodeEnvironmentReference( + mongoose: typeof import('mongoose'), + reservation: CodeEnvironmentReferenceReservation | undefined, +): Promise { + if (reservation == null) return; + const CodeEnvironment = mongoose.models.CodeEnvironment as + | Model + | undefined; + if (CodeEnvironment == null) return; + await CodeEnvironment.updateOne( + { environmentId: reservation.environmentId }, + { $pull: { pendingAgentReferences: { reservationId: reservation.reservationId } } }, + ); +} + +async function renewCodeEnvironmentReference( + mongoose: typeof import('mongoose'), + reservation: CodeEnvironmentReferenceReservation, +): Promise { + const CodeEnvironment = mongoose.models.CodeEnvironment as + | Model + | undefined; + if (CodeEnvironment == null) return; + const result = await CodeEnvironment.updateOne( + { + environmentId: reservation.environmentId, + deletionStartedAt: { $exists: false }, + 'pendingAgentReferences.reservationId': reservation.reservationId, + }, + { + $set: { + 'pendingAgentReferences.$.expiresAt': new Date(Date.now() + REFERENCE_LEASE_MS), + }, + }, + ); + if (result.matchedCount !== 1) { + throw new CodeEnvironmentReferenceError(reservation.environmentId); + } +} + +export async function withCodeEnvironmentReference( + mongoose: typeof import('mongoose'), + environmentId: string | undefined, + operation: () => Promise, + renewIntervalMs: number = REFERENCE_RENEW_INTERVAL_MS, + onReferenceLoss?: (result: T) => Promise, +): Promise { + const reservation = await reserveCodeEnvironmentReference(mongoose, environmentId); + if (reservation == null) return await operation(); + + let renewal = Promise.resolve(); + let renewalError: unknown; + const timer = setInterval(() => { + renewal = renewal + .then(() => renewCodeEnvironmentReference(mongoose, reservation)) + .catch((error) => { + renewalError ??= error; + logger.error('[code-environments] agent reference lease renewal failed:', error); + }); + }, renewIntervalMs); + timer.unref(); + try { + const result = await operation(); + /** Freeze the renewal chain before awaiting it. Otherwise the interval can + * append one last renewal after this await has captured an earlier promise. */ + clearInterval(timer); + await renewal; + if (renewalError != null) { + try { + await renewCodeEnvironmentReference(mongoose, reservation); + renewalError = undefined; + } catch (error) { + await onReferenceLoss?.(result); + throw error; + } + } + return result; + } finally { + clearInterval(timer); + await renewal; + await releaseCodeEnvironmentReference(mongoose, reservation); + } +} type CreateCodeEnvironmentInput = Pick< CodeEnvironmentDocument, 'environmentId' | 'name' | 'type' | 'baseURL' | 'controlPlaneId' | 'createdBy' > & - Pick, 'workerId' | 'workerPrincipal'>; + Pick, 'workerId' | 'revocationTokenEnv' | 'workerPrincipal'>; export function createCodeEnvironmentMethods(mongoose: typeof import('mongoose')): { createCodeEnvironment: (input: CreateCodeEnvironmentInput) => Promise; + createCodeEnvironmentWithinOwnerLimit: ( + input: CreateCodeEnvironmentInput, + maxOwned: number, + ) => Promise; findCodeEnvironmentsByIds: ( ids: Array, ) => Promise; @@ -19,9 +204,19 @@ export function createCodeEnvironmentMethods(mongoose: typeof import('mongoose') environmentId: string, ) => Promise; listCodeEnvironmentIds: () => Promise; + findCodeEnvironmentsByCreator: ( + userId: string | Types.ObjectId, + ) => Promise; deleteCodeEnvironmentById: ( id: string | Types.ObjectId, ) => Promise; + discardCodeEnvironmentById: (id: string | Types.ObjectId) => Promise; + completeCodeEnvironmentRegistration: (id: string | Types.ObjectId) => Promise; + beginCodeEnvironmentRemoval: ( + id: string | Types.ObjectId, + ) => Promise; + cancelCodeEnvironmentRemoval: (id: string | Types.ObjectId, leaseId: string) => Promise; + commitCodeEnvironmentRemoval: (id: string | Types.ObjectId, leaseId: string) => Promise; deleteUserCodeEnvironments: (userId: string | Types.ObjectId) => Promise; } { const model = () => mongoose.models.CodeEnvironment as Model; @@ -29,7 +224,64 @@ export function createCodeEnvironmentMethods(mongoose: typeof import('mongoose') async function createCodeEnvironment( input: CreateCodeEnvironmentInput, ): Promise { - return (await model().create(input)).toObject() as CodeEnvironmentDocument; + if ((await tombstones(mongoose).findOne({ _id: tombstoneId(input.environmentId) })) != null) { + throw new Error(`Code environment id was previously retired: ${input.environmentId}`); + } + return ( + await model().create({ ...input, registrationPendingAt: new Date() }) + ).toObject() as CodeEnvironmentDocument; + } + + async function createCodeEnvironmentWithinOwnerLimit( + input: CreateCodeEnvironmentInput, + maxOwned: number, + ): Promise { + if ((await tombstones(mongoose).findOne({ _id: tombstoneId(input.environmentId) })) != null) { + throw new Error(`Code environment id was previously retired: ${input.environmentId}`); + } + const existing = await model() + .find({ createdBy: input.createdBy }) + .select('ownerSlot') + .lean>>(); + if (existing.length >= maxOwned) return null; + const reserved = new Set( + existing + .map(({ ownerSlot }) => ownerSlot) + .filter( + (slot): slot is number => + typeof slot === 'number' && Number.isSafeInteger(slot) && slot >= 0 && slot < maxOwned, + ), + ); + for (let ownerSlot = 0; reserved.size < existing.length && ownerSlot < maxOwned; ownerSlot++) { + reserved.add(ownerSlot); + } + for (let ownerSlot = 0; ownerSlot < maxOwned; ownerSlot++) { + if (reserved.has(ownerSlot)) continue; + try { + return ( + await model().create({ + ...input, + _id: ownerSlotId(input.createdBy, ownerSlot), + ownerSlot, + registrationPendingAt: new Date(), + }) + ).toObject() as CodeEnvironmentDocument; + } catch (error) { + const duplicateOwnerSlot = + typeof error === 'object' && + error != null && + 'code' in error && + error.code === 11000 && + 'keyPattern' in error && + typeof error.keyPattern === 'object' && + error.keyPattern != null && + ('_id' in error.keyPattern || 'ownerSlot' in error.keyPattern); + if (!duplicateOwnerSlot) { + throw error; + } + } + } + return null; } async function findCodeEnvironmentsByIds( @@ -52,36 +304,193 @@ export function createCodeEnvironmentMethods(mongoose: typeof import('mongoose') return await model().distinct('environmentId'); } + async function findCodeEnvironmentsByCreator( + userId: string | Types.ObjectId, + ): Promise { + const creatorId = typeof userId === 'string' ? new Types.ObjectId(userId) : userId; + return await model() + .find({ createdBy: creatorId }) + .sort({ createdAt: 1, _id: 1 }) + .lean(); + } + async function deleteCodeEnvironmentById( id: string | Types.ObjectId, ): Promise { + const environment = await model().findById(id).lean(); + if (environment == null) return null; + await tombstones(mongoose).updateOne( + { _id: tombstoneId(environment.environmentId, environment.tenantId) }, + { + $setOnInsert: { + tenantId: tenantScope(environment.tenantId), + environmentId: environment.environmentId, + deletedAt: new Date(), + }, + }, + { upsert: true }, + ); 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 discardCodeEnvironmentById(id: string | Types.ObjectId): Promise { + await model().deleteOne({ _id: id }); + } + + async function completeCodeEnvironmentRegistration(id: string | Types.ObjectId): Promise { + const result = await model().updateOne( + { + _id: id, + registrationPendingAt: { $exists: true }, + registrationLeaseId: { $exists: false }, + }, + { $unset: { registrationPendingAt: 1, registrationReconcileAfter: 1 } }, + ); + if (result.matchedCount !== 1) { + throw new Error('Code environment registration could not be committed'); + } + } + + async function beginCodeEnvironmentRemoval( + id: string | Types.ObjectId, + ): Promise { + const now = new Date(); + const leaseId = new Types.ObjectId().toHexString(); + return await model() + .findOneAndUpdate( + { + _id: id, + deletionCommittedAt: { $exists: false }, + $and: [ + { + $or: [ + { deletionStartedAt: { $exists: false } }, + { deletionLeaseExpiresAt: { $lte: now } }, + ], + }, + { + $or: [ + { pendingAgentReferences: { $exists: false } }, + { pendingAgentReferences: { $size: 0 } }, + { + pendingAgentReferences: { + $not: { $elemMatch: { expiresAt: { $gt: now } } }, + }, + }, + ], + }, + ], + }, + { + $set: { + deletionStartedAt: now, + deletionLeaseId: leaseId, + deletionLeaseExpiresAt: new Date(now.getTime() + REMOVAL_LEASE_MS), + }, + $pull: { pendingAgentReferences: { expiresAt: { $lte: now } } }, + }, + { new: true }, + ) + .lean(); + } + + async function cancelCodeEnvironmentRemoval( + id: string | Types.ObjectId, + leaseId: string, + ): Promise { + await model().updateOne( + { _id: id, deletionLeaseId: leaseId, deletionCommittedAt: { $exists: false } }, + { + $unset: { + deletionStartedAt: 1, + deletionLeaseId: 1, + deletionLeaseExpiresAt: 1, + }, + }, + ); + } + + async function commitCodeEnvironmentRemoval( + id: string | Types.ObjectId, + leaseId: string, + ): Promise { + const result = await model().updateOne( + { _id: id, deletionLeaseId: leaseId }, + { + $set: { deletionCommittedAt: new Date() }, + $unset: { deletionLeaseExpiresAt: 1 }, + }, + ); + if (result.matchedCount !== 1) { + throw new Error('Code environment removal lease was lost'); + } + } + + /** Delete unreferenced creator-owned records and grants after user removal. + * Failed worker revocations and environments selected by surviving agents + * remain durable for reconciliation instead of becoming dangling refs. */ 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(); + const environments = await model() + .find({ + createdBy: creatorId, + revocationPendingAt: { $exists: false }, + deletionCommittedAt: { $exists: false }, + }) + .select('_id') + .lean>>(); if (environments.length === 0) return 0; - const resourceIds = environments.map((environment) => environment._id); + const Agent = mongoose.models.Agent; 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; + let deleted = 0; + for (const candidate of environments) { + const claimed = await beginCodeEnvironmentRemoval(candidate._id); + if (claimed == null || claimed.deletionLeaseId == null) continue; + const referenced = + Agent != null && + (await Agent.exists(agentReferenceFilter(claimed.environmentId, claimed.tenantId))) != null; + if (referenced) { + await cancelCodeEnvironmentRemoval(candidate._id, claimed.deletionLeaseId); + continue; + } + await tombstones(mongoose).updateOne( + { _id: tombstoneId(claimed.environmentId, claimed.tenantId) }, + { + $setOnInsert: { + tenantId: tenantScope(claimed.tenantId), + environmentId: claimed.environmentId, + deletedAt: new Date(), + }, + }, + { upsert: true }, + ); + await AclEntry.deleteMany({ + resourceType: ResourceType.CODE_ENVIRONMENT, + resourceId: candidate._id, + }); + const result = await model().deleteOne({ + _id: candidate._id, + deletionLeaseId: claimed.deletionLeaseId, + }); + deleted += result.deletedCount ?? 0; + } + return deleted; } return { createCodeEnvironment, + createCodeEnvironmentWithinOwnerLimit, findCodeEnvironmentsByIds, findCodeEnvironmentByEnvironmentId, listCodeEnvironmentIds, + findCodeEnvironmentsByCreator, deleteCodeEnvironmentById, + discardCodeEnvironmentById, + completeCodeEnvironmentRegistration, + beginCodeEnvironmentRemoval, + cancelCodeEnvironmentRemoval, + commitCodeEnvironmentRemoval, deleteUserCodeEnvironments, }; } diff --git a/packages/data-schemas/src/schema/codeEnvironment.ts b/packages/data-schemas/src/schema/codeEnvironment.ts index fb297e5d35..ea320b3b86 100644 --- a/packages/data-schemas/src/schema/codeEnvironment.ts +++ b/packages/data-schemas/src/schema/codeEnvironment.ts @@ -45,9 +45,51 @@ const codeEnvironmentSchema: Schema = new Schema; + deletionStartedAt?: Date; + deletionLeaseId?: string; + deletionLeaseExpiresAt?: Date; + deletionCommittedAt?: Date; + registrationPendingAt?: Date; + registrationLeaseId?: string; + registrationLeaseExpiresAt?: Date; + registrationReconcileAfter?: Date; + revocationPendingAt?: Date; + revocationAttempts?: number; + revocationLastError?: string; + revocationReconcileAfter?: Date; + revocationLeaseId?: string; + revocationLeaseExpiresAt?: Date; workerId?: string; + revocationTokenEnv?: string; workerPrincipal?: { type: 'deployment' | 'tenant' | 'user' | 'role' | 'group'; id: string;