Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
e0861d6
feat: add self-service code worker pairing
danny-avila Aug 30, 2026
4b5295a
fix: enforce code worker principal boundaries
danny-avila Aug 30, 2026
cfaf26d
fix: separate pairing control planes from execution
danny-avila Aug 30, 2026
25238d7
fix: preserve principal worker routing
danny-avila Aug 30, 2026
ab726fd
fix: pin artifact traffic to code workers
danny-avila Aug 30, 2026
0ea75c5
fix: authenticate code worker selection
danny-avila Aug 30, 2026
0712566
test: mock worker-aware artifact headers
danny-avila Aug 30, 2026
6ea3bab
fix: harden self-service code worker routing
danny-avila Aug 30, 2026
a9e255c
fix: fail closed for principal code pairing
danny-avila Aug 30, 2026
a3adb77
feat: manage personal code environments
danny-avila Aug 30, 2026
27582e8
fix: harden personal code environment lifecycle
danny-avila Aug 30, 2026
20f6721
fix: satisfy backend lifecycle types
danny-avila Aug 30, 2026
d3b2857
fix: harden principal environment lifecycle and quotas
danny-avila Aug 30, 2026
d14fa4b
fix: fence environment lifecycle races
danny-avila Aug 30, 2026
b3bce9f
fix: reconcile code environment stack
danny-avila Aug 31, 2026
1e1a798
merge: update self-service code pairing with dev
danny-avila Sep 1, 2026
d4be154
merge: update code environment UI with pairing
danny-avila Sep 1, 2026
b49c906
style: format code execution headers
danny-avila Sep 1, 2026
4a91e97
Merge remote-tracking branch 'origin/danny-avila/self-service-code-pa…
danny-avila Sep 1, 2026
1cc4c46
fix: reconcile code environment lifecycle
danny-avila Sep 1, 2026
b83b296
fix: close self-service pairing gaps
danny-avila Sep 1, 2026
7136c92
Merge remote-tracking branch 'origin/danny-avila/self-service-code-pa…
danny-avila Sep 1, 2026
8887182
fix: close code environment reconciliation gaps
danny-avila Sep 1, 2026
712154c
fix: close code environment UI review findings
danny-avila Sep 1, 2026
8c207b3
fix: satisfy code environment type checks
danny-avila Sep 1, 2026
311175c
Merge remote-tracking branch 'origin/danny-avila/self-service-code-pa…
danny-avila Sep 1, 2026
f5ed4a9
Merge remote-tracking branch 'origin/dev' into danny-avila/code-envir…
danny-avila Sep 1, 2026
3ccd22d
fix: route code priming to selected worker
danny-avila Sep 1, 2026
848c6e8
fix: harden code environment lifecycle fences
danny-avila Sep 1, 2026
04a5f01
fix: type lease renewal interval
danny-avila Sep 1, 2026
0cab1e5
fix: harden environment lifecycle recovery
danny-avila Sep 1, 2026
c7b2592
fix: close lifecycle recovery races
danny-avila Sep 1, 2026
868d662
fix: type compensated agent timestamps
danny-avila Sep 1, 2026
03ce617
fix: fence lifecycle cleanup by tenant and revision
danny-avila Sep 1, 2026
dce0d37
fix: scope environment revision fencing
danny-avila Sep 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions api/app/clients/tools/util/handleTools.js
Original file line number Diff line number Diff line change
Expand Up @@ -370,6 +370,7 @@ const loadTools = async ({
codeApiBaseUrl: codeExecutionContext.baseUrl,
executionProfile: codeExecutionContext.executionProfile,
executionRouteKey: codeExecutionContext.executionRouteKey,
bridgeWorkerId: codeExecutionContext.bridgeWorkerId,
});
if (toolContext) {
dynamicToolContextMap[tool] = toolContext;
Expand Down
33 changes: 33 additions & 0 deletions api/app/clients/tools/util/handleTools.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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', () => ({
Expand Down Expand Up @@ -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}`;
Expand Down
27 changes: 23 additions & 4 deletions api/server/controllers/UserController.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const {
getWebSearchUninstallFields,
deleteAgentCheckpoints,
deleteAllSharedLinksWithCleanup,
revokeUserCodeEnvironmentWorkers,
} = require('@librechat/api');
const {
Tools,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 });
Expand All @@ -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' });
Expand Down
9 changes: 9 additions & 0 deletions api/server/controllers/UserController.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -161,6 +163,7 @@ const {
deleteConvos,
acceptTerms,
deleteUserById,
deleteUserCodeEnvironments,
deleteMessages,
beginAgentTriggerUserDeletion,
cancelAgentTriggerUserDeletion,
Expand Down Expand Up @@ -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();
Expand Down
31 changes: 30 additions & 1 deletion api/server/controllers/__tests__/deleteUser.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() },
Expand All @@ -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),
Expand Down Expand Up @@ -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),
}));
Expand Down Expand Up @@ -186,6 +188,7 @@ function stubDeletionMocks() {
mockCancelAndDrainSubagentThreads.mockResolvedValue();
mockQuiesceUserSchedules.mockResolvedValue(true);
mockDeleteSchedulesByUser.mockResolvedValue();
mockRevokeUserCodeEnvironmentWorkers.mockResolvedValue(0);
}

beforeEach(() => {
Expand All @@ -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],
Expand All @@ -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();
});
Expand All @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
23 changes: 21 additions & 2 deletions api/server/controllers/agents/v1.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
}
};
Expand Down Expand Up @@ -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([
Expand Down Expand Up @@ -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 });
}
Comment thread
danny-avila marked this conversation as resolved.
res.status(500).json({ error: error.message });
}
};
Expand Down Expand Up @@ -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 });
}
};
Expand Down
11 changes: 9 additions & 2 deletions api/server/routes/admin/users.js
Original file line number Diff line number Diff line change
@@ -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');
Expand All @@ -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();

Expand All @@ -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,
Expand Down
Loading
Loading