mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-10-21 09:32:24 +00:00
🛞 feat: Manage Personal Code Environment Lifecycles (#15369)
* feat: add self-service code worker pairing * fix: enforce code worker principal boundaries * fix: separate pairing control planes from execution * fix: preserve principal worker routing * fix: pin artifact traffic to code workers * fix: authenticate code worker selection * test: mock worker-aware artifact headers * fix: harden self-service code worker routing * fix: fail closed for principal code pairing * feat: manage personal code environments * fix: harden personal code environment lifecycle * fix: satisfy backend lifecycle types * fix: harden principal environment lifecycle and quotas * fix: fence environment lifecycle races * fix: reconcile code environment stack * style: format code execution headers * fix: reconcile code environment lifecycle * fix: close self-service pairing gaps * fix: close code environment reconciliation gaps * fix: close code environment UI review findings * fix: satisfy code environment type checks * fix: route code priming to selected worker * fix: harden code environment lifecycle fences * fix: type lease renewal interval * fix: harden environment lifecycle recovery * fix: close lifecycle recovery races * fix: type compensated agent timestamps * fix: fence lifecycle cleanup by tenant and revision * fix: scope environment revision fencing
This commit is contained in:
parent
38183fdc13
commit
164f146bdf
35 changed files with 3678 additions and 97 deletions
|
|
@ -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' });
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue