🛞 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:
Danny Avila 2026-09-01 21:10:05 -04:00 • committed by GitHub
parent 38183fdc13
commit 164f146bdf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 3678 additions and 97 deletions

View file

@ -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' });

View file

@ -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();

View file

@ -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();

View file

@ -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';

View file

@ -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 });
}
};

View file

@ -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,

View file

@ -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;

View file

@ -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);
});
});