diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index ec75adea76..a982d698e1 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -356,6 +356,9 @@ const loadTools = async ({ resolveCodeExecutionContext({ statefulSessions, environment: agent?.stateful_code_environment, + environmentId: agent?.code_environment_id, + environments: + options.req?.config?.endpoints?.agents?.statefulCodeSessions?.environments, userId: user, agentId: agent?.id, conversationId: options.req?.body?.conversationId, @@ -365,6 +368,7 @@ const loadTools = async ({ agentId: agent?.id, codeApiBaseUrl: codeExecutionContext.baseUrl, executionProfile: codeExecutionContext.executionProfile, + executionRouteKey: codeExecutionContext.executionRouteKey, }); if (toolContext) { dynamicToolContextMap[tool] = toolContext; diff --git a/api/server/controllers/agents/__tests__/callbacks.spec.js b/api/server/controllers/agents/__tests__/callbacks.spec.js index a4195dccd4..aec8c48a9a 100644 --- a/api/server/controllers/agents/__tests__/callbacks.spec.js +++ b/api/server/controllers/agents/__tests__/callbacks.spec.js @@ -855,6 +855,7 @@ describe('createToolEndCallback', () => { codeExecutionContext: { baseUrl: 'https://code-stateful.example.com', executionProfile: 'stateful', + executionRouteKey: `stateful:${'a'.repeat(32)}`, }, }); await toolEndCallback({ output: event.output }, event.metadata); @@ -870,6 +871,7 @@ describe('createToolEndCallback', () => { conversationId: 'thread789', codeApiBaseUrl: 'https://code-stateful.example.com', executionProfile: 'stateful', + executionRouteKey: `stateful:${'a'.repeat(32)}`, }), ); expect(res.write).toHaveBeenCalledTimes(2); diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js index 864273c0a8..5f3579fd34 100644 --- a/api/server/controllers/agents/callbacks.js +++ b/api/server/controllers/agents/callbacks.js @@ -1047,6 +1047,7 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null, jo session_id: sessionId, codeApiBaseUrl: metadata.codeExecutionContext?.baseUrl, executionProfile: metadata.codeExecutionContext?.executionProfile, + executionRouteKey: metadata.codeExecutionContext?.executionRouteKey, preparedBuffer, downloadFallback, }); @@ -1391,6 +1392,7 @@ function createResponsesToolEndCallback({ req, res, tracker, artifactPromises }) session_id: sessionId, codeApiBaseUrl: metadata.codeExecutionContext?.baseUrl, executionProfile: metadata.codeExecutionContext?.executionProfile, + executionRouteKey: metadata.codeExecutionContext?.executionRouteKey, preparedBuffer, downloadFallback, }); diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 7ce1bde2e1..042fbcd3b9 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -404,6 +404,9 @@ class AgentClient extends BaseClient { hide_sequential_outputs: agent.hide_sequential_outputs, stateful_code_sessions: agent.stateful_code_sessions, stateful_code_environment: agent.stateful_code_environment, + execution_route_key: + agent.codeExecutionContext?.executionRouteKey ?? + agent.codeExecutionContext?.executionProfile, artifacts: agent.artifacts, recursion_limit: agent.recursion_limit, subagents: agent.subagents, diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index a9a1158149..f412b152ff 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -443,7 +443,27 @@ const isCodeInterpreterCapabilityEnabled = (req) => { /** Reject a newly selected stateful workspace scope that the deployment owner * has excluded. Disabled sessions and unrelated edits remain saveable so an * allowlist tightening never silently rewrites or strands an existing agent. */ -const validateStatefulCodeEnvironment = (req, res, enabled, environment) => { +const validateStatefulCodeEnvironment = ( + req, + res, + enabled, + environment, + environmentId, + environmentIdSelected = false, +) => { + if (enabled !== true && !environmentIdSelected) { + return true; + } + if (environmentId != null) { + const configuredEnvironments = + req.config?.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.environments ?? []; + if (!configuredEnvironments.some((configured) => configured.id === environmentId)) { + res.status(400).json({ + error: `Stateful code environment is not configured: ${environmentId}`, + }); + return false; + } + } if (enabled !== true) { return true; } @@ -721,6 +741,8 @@ const createAgentHandler = async (req, res) => { res, agentData.stateful_code_sessions, agentData.stateful_code_environment, + agentData.code_environment_id, + agentData.code_environment_id != null, ) ) { return; @@ -997,13 +1019,22 @@ const updateAgentHandler = async (req, res) => { normalizeToolResourceFiles(req.body?.tool_resources); const validatedData = agentUpdateSchema.parse(req.body); // Preserve explicit null for avatar to allow resetting the avatar - const { avatar: avatarField, _id, ...rest } = validatedData; + const { + avatar: avatarField, + code_environment_id: codeEnvironmentIdField, + _id, + ...rest + } = validatedData; const updateData = removeNullishValues(rest); + if (codeEnvironmentIdField !== undefined) { + updateData.code_environment_id = codeEnvironmentIdField; + } let existingAgent; const includesStatefulConfiguration = updateData.stateful_code_sessions !== undefined || - updateData.stateful_code_environment !== undefined; + updateData.stateful_code_environment !== undefined || + updateData.code_environment_id !== undefined; const includesToolsConfiguration = Array.isArray(updateData.tools); const includesToolOptionsConfiguration = updateData.tool_options !== undefined; if ( @@ -1016,13 +1047,17 @@ const updateAgentHandler = async (req, res) => { return res.status(404).json({ error: 'Agent not found' }); } + const codeEnvironmentSelectionChanged = + updateData.code_environment_id !== undefined && + updateData.code_environment_id !== existingAgent.code_environment_id; const statefulConfigurationChanged = (updateData.stateful_code_sessions !== undefined && (updateData.stateful_code_sessions === true) !== (existingAgent.stateful_code_sessions === true)) || (updateData.stateful_code_environment !== undefined && (updateData.stateful_code_environment ?? 'user') !== - (existingAgent.stateful_code_environment ?? 'user')); + (existingAgent.stateful_code_environment ?? 'user')) || + codeEnvironmentSelectionChanged; const activatesCodeExecution = includesToolsConfiguration && updateData.tools.includes(Tools.execute_code) && @@ -1032,12 +1067,18 @@ const updateAgentHandler = async (req, res) => { updateData.stateful_code_sessions ?? existingAgent.stateful_code_sessions; const effectiveStatefulEnvironment = updateData.stateful_code_environment ?? existingAgent.stateful_code_environment; + const effectiveCodeEnvironmentId = + updateData.code_environment_id === null + ? undefined + : (updateData.code_environment_id ?? existingAgent.code_environment_id); if ( !validateStatefulCodeEnvironment( req, res, effectiveStatefulSessions, effectiveStatefulEnvironment, + effectiveCodeEnvironmentId, + codeEnvironmentSelectionChanged, ) ) { return; @@ -1227,6 +1268,11 @@ const updateAgentHandler = async (req, res) => { } } + if (updateData.code_environment_id === null) { + delete updateData.code_environment_id; + updateData.$unset = { code_environment_id: 1 }; + } + let updatedAgent = Object.keys(updateData).length > 0 ? await db.updateAgent({ id }, updateData, { @@ -1333,6 +1379,7 @@ const duplicateAgentHandler = async (req, res) => { res, newAgentData.stateful_code_sessions, newAgentData.stateful_code_environment, + newAgentData.code_environment_id, ) ) { return; @@ -1925,6 +1972,7 @@ const revertAgentVersionHandler = async (req, res) => { res, revertVersion.stateful_code_sessions, revertVersion.stateful_code_environment, + revertVersion.code_environment_id, ) ) { return; diff --git a/api/server/controllers/agents/v1.spec.js b/api/server/controllers/agents/v1.spec.js index 02b1f3e834..ef1345586f 100644 --- a/api/server/controllers/agents/v1.spec.js +++ b/api/server/controllers/agents/v1.spec.js @@ -237,6 +237,39 @@ describe('Agent Controllers - Mass Assignment Protection', () => { expect(await Agent.countDocuments()).toBe(0); }); + test('rejects an unconfigured code environment id', async () => { + mockReq.config = { + endpoints: { + agents: { + statefulCodeSessions: { + allowedEnvironments: ['user'], + environments: [ + { + id: 'configured-vm', + name: 'Configured VM', + type: 'attached', + baseURL: 'https://code.example.com/v1', + default: true, + }, + ], + }, + }, + }, + }; + mockReq.body = { + name: 'Invalid Environment Agent', + provider: 'openai', + model: 'gpt-4', + stateful_code_sessions: true, + code_environment_id: 'missing-vm', + }; + + await createAgentHandler(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(400); + expect(await Agent.countDocuments()).toBe(0); + }); + test('should block configured agent instruction content before persistence', async () => { mockReq.config = { filters: { @@ -1182,6 +1215,98 @@ describe('Agent Controllers - Mass Assignment Protection', () => { expect(agentInDb.stateful_code_sessions).not.toBe(true); }); + test('rejects updating an agent to an unconfigured code environment id', async () => { + mockReq.user.id = existingAgentAuthorId.toString(); + mockReq.params.id = existingAgentId; + mockReq.config = { + endpoints: { + agents: { + statefulCodeSessions: { + allowedEnvironments: ['user'], + environments: [], + }, + }, + }, + }; + mockReq.body = { code_environment_id: 'missing-vm' }; + + await updateAgentHandler(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(400); + const agentInDb = await Agent.findOne({ id: existingAgentId }); + expect(agentInDb.code_environment_id).toBeUndefined(); + }); + + test('allows disabling stateful sessions after the configured environment is removed', async () => { + await Agent.updateOne( + { id: existingAgentId }, + { + stateful_code_sessions: true, + code_environment_id: 'removed-vm', + }, + ); + mockReq.user.id = existingAgentAuthorId.toString(); + mockReq.params.id = existingAgentId; + mockReq.config = { + endpoints: { + agents: { + statefulCodeSessions: { + allowedEnvironments: ['user'], + environments: [], + }, + }, + }, + }; + mockReq.body = { + stateful_code_sessions: false, + code_environment_id: 'removed-vm', + }; + + await updateAgentHandler(mockReq, mockRes); + + expect(mockRes.status).not.toHaveBeenCalledWith(400); + const agentInDb = await Agent.findOne({ id: existingAgentId }); + expect(agentInDb.stateful_code_sessions).toBe(false); + expect(agentInDb.code_environment_id).toBe('removed-vm'); + }); + + test('restores the deployment-default code environment', async () => { + await Agent.updateOne( + { id: existingAgentId }, + { + stateful_code_sessions: true, + code_environment_id: 'attached-vm', + }, + ); + mockReq.user.id = existingAgentAuthorId.toString(); + mockReq.params.id = existingAgentId; + mockReq.config = { + endpoints: { + agents: { + statefulCodeSessions: { + allowedEnvironments: ['user'], + environments: [ + { + id: 'attached-vm', + name: 'Attached VM', + type: 'attached', + baseURL: 'https://bridge.example.com/v1', + default: true, + }, + ], + }, + }, + }, + }; + mockReq.body = { code_environment_id: null }; + + await updateAgentHandler(mockReq, mockRes); + + expect(mockRes.status).not.toHaveBeenCalledWith(400); + const agentInDb = await Agent.findOne({ id: existingAgentId }); + expect(agentInDb.code_environment_id).toBeUndefined(); + }); + test('allows unrelated edits to an existing scope after policy is tightened', async () => { await Agent.updateOne( { id: existingAgentId }, diff --git a/api/server/routes/files/files.js b/api/server/routes/files/files.js index fd1188a37b..abb2713e92 100644 --- a/api/server/routes/files/files.js +++ b/api/server/routes/files/files.js @@ -13,6 +13,7 @@ const { sendUploadPolicyError, resolveUploadErrorMessage, verifyAgentUploadPermission, + createCodeExecutionRouteKey, getCodeExecutionBaseUrl, assertUploadContentAllowed, hasActiveFilePolicy, @@ -350,7 +351,29 @@ router.get('/code/download/:session_id/:fileId', async (req, res) => { return res.status(400).send('Bad request'); } const executionProfile = requestedProfile ?? 'default'; - const baseUrl = getCodeExecutionBaseUrl(executionProfile); + const requestedRouteKey = req.query.execution_route_key; + if ( + requestedRouteKey != null && + (typeof requestedRouteKey !== 'string' || + executionProfile !== 'stateful' || + !/^stateful:[a-f0-9]{32}$/.test(requestedRouteKey)) + ) { + logger.debug(`${logPrefix} invalid execution_route_key`); + return res.status(400).send('Bad request'); + } + const environments = + req.config?.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.environments; + const configuredEnvironment = requestedRouteKey + ? environments?.find( + (environment) => + createCodeExecutionRouteKey('stateful', environment) === requestedRouteKey, + ) + : undefined; + if (requestedRouteKey && !configuredEnvironment) { + logger.debug(`${logPrefix} unknown execution_route_key`); + return res.status(404).send('Not found'); + } + const baseUrl = getCodeExecutionBaseUrl(executionProfile, configuredEnvironment); const { getDownloadStream } = getStrategyFunctions(FileSources.execute_code); if (!getDownloadStream) { diff --git a/api/server/routes/files/files.test.js b/api/server/routes/files/files.test.js index 34894e2a56..af154d0fee 100644 --- a/api/server/routes/files/files.test.js +++ b/api/server/routes/files/files.test.js @@ -45,11 +45,15 @@ jest.mock('sharp', () => jest.mock('@librechat/api', () => ({ ...jest.requireActual('@librechat/api'), refreshS3FileUrls: jest.fn(), - getCodeExecutionBaseUrl: jest.fn((profile) => - profile === 'stateful' - ? process.env.LIBRECHAT_CODE_BASEURL_STATEFUL - : 'https://code-default.example.com/v1', - ), + getCodeExecutionBaseUrl: jest.fn((profile, environment) => { + if (environment?.baseURL) { + return environment.baseURL; + } + if (profile === 'stateful') { + return process.env.LIBRECHAT_CODE_BASEURL_STATEFUL; + } + return 'https://code-default.example.com/v1'; + }), })); jest.mock('~/cache', () => ({ @@ -69,6 +73,7 @@ jest.mock('~/config', () => ({ const { processDeleteRequest } = require('~/server/services/Files/process'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); +const { createCodeExecutionRouteKey } = require('@librechat/api'); // Import the router after mocks const router = require('./files'); @@ -84,6 +89,7 @@ describe('File Routes - Delete with Agent Access', () => { let AclEntry; let User; let methods; + let requestConfig; let modelsToCleanup = []; beforeAll(async () => { @@ -121,6 +127,7 @@ describe('File Routes - Delete with Agent Access', () => { id: otherUserId?.toString() || 'default-user', role: SystemRoles.USER, }; + req.config = requestConfig; req.app.locals = {}; next(); }); @@ -148,6 +155,7 @@ describe('File Routes - Delete with Agent Access', () => { beforeEach(async () => { jest.clearAllMocks(); + requestConfig = {}; // Clear database - clean up all test data await File.deleteMany({}); @@ -1249,6 +1257,43 @@ describe('File Routes - Delete with Agent Access', () => { }); describe('GET /files/code/download/:session_id/:fileId', () => { + it('resolves a configured environment route for a persisted fallback', async () => { + const environment = { + id: 'managed-vm', + name: 'Managed VM', + type: 'managed', + baseURL: 'https://managed-code.example.com/v1', + default: true, + owner: 'deployment', + }; + requestConfig = { + endpoints: { + agents: { + statefulCodeSessions: { environments: [environment] }, + }, + }, + }; + const executionRouteKey = createCodeExecutionRouteKey('stateful', environment); + const getDownloadStream = jest.fn().mockResolvedValue({ + data: Readable.from(['configured output']), + }); + getStrategyFunctions.mockReturnValue({ getDownloadStream }); + const sessionId = 's'.repeat(21); + const codeFileId = 'f'.repeat(21); + + const response = await request(app).get( + `/files/code/download/${sessionId}/${codeFileId}?execution_profile=stateful&execution_route_key=${encodeURIComponent(executionRouteKey)}`, + ); + + expect(response.status).toBe(200); + expect(getDownloadStream).toHaveBeenCalledWith( + `${sessionId}/${codeFileId}`, + { kind: 'user', id: otherUserId.toString() }, + expect.any(Object), + { baseUrl: environment.baseURL, executionProfile: 'stateful' }, + ); + }); + it('routes a persisted stateful fallback through the stateful Code API', async () => { const getDownloadStream = jest.fn().mockResolvedValue({ headers: { @@ -1285,6 +1330,15 @@ describe('File Routes - Delete with Agent Access', () => { } }); + it('rejects an unmapped configured-environment route before contacting Code API', async () => { + const response = await request(app).get( + `/files/code/download/${'s'.repeat(21)}/${'f'.repeat(21)}?execution_profile=stateful&execution_route_key=stateful:${'a'.repeat(32)}`, + ); + + expect(response.status).toBe(404); + expect(getStrategyFunctions).not.toHaveBeenCalled(); + }); + it('rejects an unknown execution profile', async () => { const response = await request(app).get( `/files/code/download/${'s'.repeat(21)}/${'f'.repeat(21)}?execution_profile=attacker`, diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index fa4d6710f0..024246b80b 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -21,6 +21,7 @@ const { buildAgentContextAttachmentsByAgentId, collectCodeExecutionProfileRoutes, getLazySubagentConfigId, + resolveCodeExecutionContext, createStatefulCodeEnvironmentPolicyError, buildSubagentThreadTaskConfig, backgroundCompletionWakeupsEnabled, @@ -921,9 +922,11 @@ const initializeClient = async ({ }; const toLazySubagentMetadata = async (agent) => { + const lazyCodeEnvAvailable = + codeEnvAvailable === true && agent.tools?.includes(Tools.execute_code) === true; const statefulCodeSessions = statefulSessionsAvailable === true && - codeEnvAvailable === true && + lazyCodeEnvAvailable && agent.stateful_code_sessions === true && agent.tools?.includes(Tools.execute_code) === true; const statefulCodeEnvironment = agent.stateful_code_environment ?? 'user'; @@ -933,6 +936,23 @@ const initializeClient = async ({ ) { throw createStatefulCodeEnvironmentPolicyError(statefulCodeEnvironment); } + const configuredCodeEnvironments = + appConfig?.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.environments; + const hasConfiguredCodeEnvironment = + agent.code_environment_id != null || + configuredCodeEnvironments?.some((environment) => environment.default === true) === true; + const codeExecutionContext = + lazyCodeEnvAvailable && (!statefulCodeSessions || hasConfiguredCodeEnvironment) + ? resolveCodeExecutionContext({ + statefulSessions: statefulCodeSessions, + environment: statefulCodeEnvironment, + environmentId: agent.code_environment_id, + environments: configuredCodeEnvironments, + userId, + agentId: agent.id, + conversationId, + }) + : undefined; return { id: agent.id, name: agent.name, @@ -946,10 +966,11 @@ const initializeClient = async ({ memoryAvailable === true && agent.tools?.includes(Tools.memory) === true, subagents: agent.subagents, configId: getLazySubagentConfigId(agent), - codeEnvAvailable: - codeEnvAvailable === true && agent.tools?.includes(Tools.execute_code) === true, + codeEnvAvailable: lazyCodeEnvAvailable, statefulCodeSessions, statefulCodeEnvironment, + codeExecutionContext, + codeSessionKey: codeExecutionContext?.codeSessionKey, includeReasoningHistory: getIncludeReasoningHistory(agent), alwaysApplySkillPrimes: await resolveLazyAlwaysApplySkillPrimes(agent), }; @@ -1182,6 +1203,8 @@ const initializeClient = async ({ codeEnvAvailable: metadata.codeEnvAvailable, statefulCodeSessions: metadata.statefulCodeSessions, statefulCodeEnvironment: metadata.statefulCodeEnvironment, + codeExecutionContext: metadata.codeExecutionContext, + codeSessionKey: metadata.codeSessionKey, includeReasoningHistory: metadata.includeReasoningHistory, alwaysApplySkillPrimes: metadata.alwaysApplySkillPrimes, lazySubagentConfigs: lazyChildren, diff --git a/api/server/services/Endpoints/agents/initialize.spec.js b/api/server/services/Endpoints/agents/initialize.spec.js index 48fc331389..7251d7d980 100644 --- a/api/server/services/Endpoints/agents/initialize.spec.js +++ b/api/server/services/Endpoints/agents/initialize.spec.js @@ -1204,6 +1204,60 @@ describe('initializeClient — subagent loading', () => { expect(mockInitializeAgent).toHaveBeenCalledTimes(1); }); + it('retains a configured Code API route on lazy subagent descriptors', async () => { + const subAgent = await createAgent({ + id: SUBAGENT_ID, + name: 'Attached Stateful Subagent', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(), + tools: ['execute_code'], + stateful_code_sessions: true, + stateful_code_environment: 'agent-user', + code_environment_id: 'attached-vm', + }); + await grantView(subAgent); + mockInitializeAgent.mockResolvedValue( + makePrimaryConfig({ + subagents: { enabled: true, allowSelf: false, agent_ids: [SUBAGENT_ID] }, + }), + ); + const req = makeSubagentReq(); + req.config.endpoints.agents.capabilities.push('execute_code', 'stateful_code_sessions'); + req.config.endpoints.agents.statefulCodeSessions = { + allowedEnvironments: ['agent-user'], + environments: [ + { + id: 'attached-vm', + name: 'Attached VM', + type: 'attached', + baseURL: 'https://bridge.example.com/v1/', + default: true, + }, + ], + }; + + await initializeClient({ + req, + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + + expect(agentClientArgs.agent.lazySubagentConfigs[0]).toEqual( + expect.objectContaining({ + codeSessionKey: expect.stringMatching(/^execute_code:stateful:[a-f0-9]{32}:v3:/), + codeExecutionContext: expect.objectContaining({ + baseUrl: 'https://bridge.example.com/v1', + environmentId: 'attached-vm', + environmentType: 'attached', + executionProfile: 'stateful', + executionRouteKey: expect.stringMatching(/^stateful:[a-f0-9]{32}$/), + }), + }), + ); + }); + it('omits a descriptor when its metadata lookup fails without aborting the primary run', async () => { const primaryConfig = makePrimaryConfig({ subagents: { enabled: true, allowSelf: false, agent_ids: [SUBAGENT_ID] }, diff --git a/api/server/services/Files/Code/crud.js b/api/server/services/Files/Code/crud.js index f04e5ff682..27986ca215 100644 --- a/api/server/services/Files/Code/crud.js +++ b/api/server/services/Files/Code/crud.js @@ -1,7 +1,7 @@ const FormData = require('form-data'); const { logger } = require('@librechat/data-schemas'); const { getCodeBaseURL } = require('@librechat/agents'); -const { getCodeEnvRefs } = require('librechat-data-provider'); +const { EModelEndpoint, getCodeEnvRefs } = require('librechat-data-provider'); const { logAxiosError, appendCodeEnvFile, @@ -12,6 +12,7 @@ const { buildCodeEnvDownloadQuery, getCodeApiAuthHeaders, getCodeExecutionBaseUrl, + createCodeExecutionRouteKey, CODE_API_EXPECTED_PROFILE_HEADER, } = require('@librechat/api'); @@ -82,8 +83,40 @@ async function deleteCodeEnvFile(req, file) { const missingOrUnsupportedStatuses = new Set([404, 405]); const authHeaders = await getCodeApiAuthHeaders(req); - for (const [executionProfile, ref] of refs) { - const baseURL = getCodeExecutionBaseUrl(executionProfile); + for (const [executionRouteKey, ref] of refs) { + const executionProfile = ref.executionProfile ?? 'default'; + const environments = + req.config?.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.environments; + const configuredEnvironment = environments?.find( + (environment) => + createCodeExecutionRouteKey(executionProfile, environment) === executionRouteKey, + ); + if ( + executionProfile === 'stateful' && + executionRouteKey !== executionProfile && + !configuredEnvironment + ) { + logger.warn( + `[deleteCodeEnvFile] Skipping remote cleanup for unmapped historical route ${executionRouteKey}`, + ); + continue; + } + let baseURL; + try { + baseURL = getCodeExecutionBaseUrl(executionProfile, configuredEnvironment); + } catch (error) { + if ( + executionProfile === 'stateful' && + executionRouteKey === executionProfile && + !configuredEnvironment + ) { + logger.warn( + '[deleteCodeEnvFile] Skipping remote cleanup for retired legacy stateful route', + ); + continue; + } + throw error; + } const query = buildCodeEnvDownloadQuery({ kind: ref.kind, id: ref.id, diff --git a/api/server/services/Files/Code/crud.spec.js b/api/server/services/Files/Code/crud.spec.js index 7cc9e743fd..c4a5dec457 100644 --- a/api/server/services/Files/Code/crud.spec.js +++ b/api/server/services/Files/Code/crud.spec.js @@ -64,6 +64,7 @@ const { codeServerHttpAgent, codeServerHttpsAgent, getCodeApiAuthHeaders, + getCodeExecutionBaseUrl, } = require('@librechat/api'); const { deleteCodeEnvFile, getCodeOutputDownloadStream, uploadCodeEnvFile } = require('./crud'); @@ -269,6 +270,43 @@ describe('Code CRUD', () => { ); }); + it('skips remote cleanup instead of falling back when a historical route is unmapped', async () => { + const historicalRoute = 'stateful:0123456789abcdef0123456789abcdef'; + const historicalFile = { + metadata: { + codeEnvRefs: { + [historicalRoute]: { + ...file.metadata.codeEnvRef, + executionProfile: 'stateful', + executionRouteKey: historicalRoute, + }, + }, + }, + }; + + await expect(deleteCodeEnvFile(req, historicalFile)).resolves.toBeUndefined(); + + expect(mockAxios).not.toHaveBeenCalled(); + }); + + it('skips legacy stateful cleanup after its endpoint is retired', async () => { + getCodeExecutionBaseUrl.mockImplementationOnce(() => { + throw new Error('LIBRECHAT_CODE_BASEURL_STATEFUL is not configured'); + }); + const legacyStatefulFile = { + metadata: { + codeEnvRef: { + ...file.metadata.codeEnvRef, + executionProfile: 'stateful', + }, + }, + }; + + await expect(deleteCodeEnvFile(req, legacyStatefulFile)).resolves.toBeUndefined(); + + expect(mockAxios).not.toHaveBeenCalled(); + }); + it.each([404, 405])( 'falls back to the legacy code environment delete route after a %s', async (status) => { diff --git a/api/server/services/Files/Code/process.js b/api/server/services/Files/Code/process.js index 9c782b0ff3..6bb352e011 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -229,6 +229,7 @@ const prepareCodeOutputForInspection = async ({ * @param {string} params.messageId - The current message ID. * @param {number} params.expiresAt - Expiration timestamp (24 hours from creation). * @param {'default'|'stateful'} [params.executionProfile] - Code API route for later fallback download. + * @param {string} [params.executionRouteKey] - Deployment-local route identity. * @returns {Object} Fallback response with download URL. */ const createDownloadFallback = ({ @@ -241,12 +242,20 @@ const createDownloadFallback = ({ toolCallId, conversationId, executionProfile, + executionRouteKey, }) => { const basePath = getBasePath(); - const profileQuery = executionProfile === 'stateful' ? '?execution_profile=stateful' : ''; + const query = new URLSearchParams(); + if (executionProfile === 'stateful') { + query.set('execution_profile', 'stateful'); + } + if (executionRouteKey && executionRouteKey !== executionProfile) { + query.set('execution_route_key', executionRouteKey); + } + const routeQuery = query.size > 0 ? `?${query.toString()}` : ''; return { filename: name, - filepath: `${basePath}/api/files/code/download/${session_id}/${id}${profileQuery}`, + filepath: `${basePath}/api/files/code/download/${session_id}/${id}${routeQuery}`, expiresAt, conversationId, toolCallId, @@ -494,6 +503,7 @@ const runPreviewFinalize = ({ finalize, fileId, previewRevision, onResolved }) = * @param {string} params.messageId - The current message ID. * @param {string} [params.codeApiBaseUrl] - Trusted per-agent Code API endpoint. * @param {'default'|'stateful'} [params.executionProfile] - Trusted execution profile. + * @param {string} [params.executionRouteKey] - Trusted deployment-local route identity. * @param {Buffer} [params.preparedBuffer] - Bytes downloaded during a * no-write content inspection preflight. * @param {boolean} [params.downloadFallback] - Return the bounded download @@ -512,6 +522,7 @@ const processCodeOutput = async ({ freshClaimAfter, codeApiBaseUrl, executionProfile = 'default', + executionRouteKey = executionProfile, preparedBuffer, downloadFallback, }) => { @@ -535,6 +546,7 @@ const processCodeOutput = async ({ session_id, conversationId, executionProfile, + executionRouteKey, expiresAt: currentDate.getTime() + 86400000, }), }; @@ -565,6 +577,7 @@ const processCodeOutput = async ({ session_id, conversationId, executionProfile, + executionRouteKey, expiresAt: currentDate.getTime() + 86400000, }), }; @@ -579,6 +592,7 @@ const processCodeOutput = async ({ storage_session_id: session_id, file_id: id, executionProfile, + ...(executionRouteKey !== executionProfile ? { executionRouteKey } : {}), }; /* `safeName` keeps the directory structure (`a/b/file.txt` -> `a/b/file.txt`) @@ -752,6 +766,7 @@ const processCodeOutput = async ({ session_id, conversationId, executionProfile, + executionRouteKey, expiresAt: currentDate.getTime() + 86400000, }), }; @@ -935,6 +950,7 @@ const processCodeOutput = async ({ session_id, conversationId, executionProfile, + executionRouteKey, expiresAt: currentDate.getTime() + 86400000, }), }; @@ -1102,6 +1118,7 @@ const primeFiles = async (options) => { agentResourceType, codeApiBaseUrl, executionProfile = 'default', + executionRouteKey = executionProfile, } = options; const codeApiRoute = { baseUrl: codeApiBaseUrl, executionProfile }; const file_ids = tool_resources?.[EToolResources.execute_code]?.file_ids ?? []; @@ -1160,7 +1177,7 @@ const primeFiles = async (options) => { continue; } - const ref = getCodeEnvRefForProfile(file.metadata, executionProfile); + const ref = getCodeEnvRefForProfile(file.metadata, executionRouteKey); const sourceRef = ref ?? getCodeEnvRefs(file.metadata)[0]?.[1]; if (!sourceRef) { skippedNoRef += 1; @@ -1250,6 +1267,7 @@ const primeFiles = async (options) => { storage_session_id: uploaded.storage_session_id, file_id: uploaded.file_id, executionProfile, + ...(executionRouteKey !== executionProfile ? { executionRouteKey } : {}), ...(sourceRef.kind === 'skill' ? { version: sourceRef.version } : {}), }; @@ -1258,7 +1276,7 @@ const primeFiles = async (options) => { await updateFile({ file_id: file.file_id, 'metadata.codeEnvRef': updatedRefs.codeEnvRef, - [`metadata.codeEnvRefs.${executionProfile}`]: newRef, + [`metadata.codeEnvRefs.${executionRouteKey}`]: newRef, }); sessions.set(newRef.storage_session_id, true); pushFile(newRef.storage_session_id, newRef.file_id); diff --git a/api/server/services/Files/Code/process.spec.js b/api/server/services/Files/Code/process.spec.js index 52e99ced88..8e2b3b258a 100644 --- a/api/server/services/Files/Code/process.spec.js +++ b/api/server/services/Files/Code/process.spec.js @@ -978,11 +978,13 @@ describe('Code Process', () => { describe('fallback behavior', () => { it('preserves the stateful route in generated downloads and fallbacks', async () => { mockAxios.mockRejectedValue(new Error('Network error')); + const executionRouteKey = `stateful:${'a'.repeat(32)}`; const { file: result } = await processCodeOutput({ ...baseParams, codeApiBaseUrl: 'https://code-stateful.example.com', executionProfile: 'stateful', + executionRouteKey, }); expect(mockAxios).toHaveBeenCalledWith( @@ -992,6 +994,9 @@ describe('Code Process', () => { }), ); expect(result.filepath).toContain('execution_profile=stateful'); + expect(result.filepath).toContain( + `execution_route_key=${encodeURIComponent(executionRouteKey)}`, + ); }); it('should fallback to download URL when saveBuffer is not available', async () => { @@ -1091,14 +1096,19 @@ describe('Code Process', () => { it('persists the originating profile on a stateful artifact ref', async () => { mockAxios.mockResolvedValue({ data: Buffer.alloc(100) }); + const executionRouteKey = `stateful:${'a'.repeat(32)}`; const { file: result } = await processCodeOutput({ ...baseParams, codeApiBaseUrl: 'https://code-stateful.example.com', executionProfile: 'stateful', + executionRouteKey, }); - expect(result.metadata.codeEnvRef.executionProfile).toBe('stateful'); + expect(result.metadata.codeEnvRef).toEqual( + expect.objectContaining({ executionProfile: 'stateful', executionRouteKey }), + ); + expect(result.metadata.codeEnvRefs[executionRouteKey]).toEqual(result.metadata.codeEnvRef); }); /* Phase C lock-in: outputs are ALWAYS user-scoped, never skill-scoped. diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 200f81fbd0..f5655088a8 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -785,6 +785,8 @@ async function loadToolDefinitionsWrapper({ enabledCapabilities.has(AgentCapabilities.stateful_code_sessions) && agent.stateful_code_sessions === true, environment: agent.stateful_code_environment, + environmentId: agent.code_environment_id, + environments: req.config?.endpoints?.agents?.statefulCodeSessions?.environments, userId: req.user.id, agentId: agent.id, conversationId: runtimeRequestBody?.conversationId, @@ -1392,6 +1394,7 @@ async function loadToolDefinitionsWrapper({ agentResourceType, codeApiBaseUrl: resolvedCodeExecutionContext.baseUrl, executionProfile: resolvedCodeExecutionContext.executionProfile, + executionRouteKey: resolvedCodeExecutionContext.executionRouteKey, }); if (toolContext) { dynamicToolContextMap[Tools.execute_code] = toolContext; @@ -1642,6 +1645,8 @@ async function loadAgentTools({ resolveCodeExecutionContext({ statefulSessions: statefulCodeSessions, environment: agent.stateful_code_environment, + environmentId: agent.code_environment_id, + environments: req.config?.endpoints?.agents?.statefulCodeSessions?.environments, userId: req.user.id, agentId: agent.id, conversationId: requestBody?.conversationId ?? req.body?.conversationId, @@ -2022,6 +2027,8 @@ async function loadToolsForExecution({ const codeExecutionContext = resolveCodeExecutionContext({ statefulSessions: statefulCodeSessions, environment: agent?.stateful_code_environment, + environmentId: agent?.code_environment_id, + environments: req.config?.endpoints?.agents?.statefulCodeSessions?.environments, userId: req.user.id, agentId: agent?.id, conversationId: conversationId ?? runtimeRequestBody?.conversationId, diff --git a/client/src/common/agents-types.ts b/client/src/common/agents-types.ts index c5ac569ac2..1500d9e503 100644 --- a/client/src/common/agents-types.ts +++ b/client/src/common/agents-types.ts @@ -47,6 +47,8 @@ export type AgentForm = { memory_scope?: MemoryScope; /** Sharing scope for stateful Code API workspaces. */ stateful_code_environment?: StatefulCodeEnvironment; + /** Operator-configured managed or attached execution environment. */ + code_environment_id?: string | null; provider?: AgentProvider | OptionWithIcon; /** @deprecated Use edges instead */ agent_ids?: string[]; diff --git a/client/src/components/SidePanel/Agents/Advanced/StatefulSessions.tsx b/client/src/components/SidePanel/Agents/Advanced/StatefulSessions.tsx index 77d6445e59..d0958c0a63 100644 --- a/client/src/components/SidePanel/Agents/Advanced/StatefulSessions.tsx +++ b/client/src/components/SidePanel/Agents/Advanced/StatefulSessions.tsx @@ -29,6 +29,8 @@ const ENVIRONMENT_LABELS = { conversation: 'com_ui_stateful_code_environment_conversation', } as const; +const DEPLOYMENT_DEFAULT_ENVIRONMENT = '__deployment_default__'; + export default function StatefulSessions() { const localize = useLocalize(); const { user } = useAuthContext(); @@ -39,7 +41,9 @@ export default function StatefulSessions() { const enabled = watch(AgentCapabilities.stateful_code_sessions) ?? false; const codeEnabled = watch(AgentCapabilities.execute_code); const environment = watch('stateful_code_environment') ?? 'user'; + const codeEnvironmentId = watch('code_environment_id'); const configuredEnvironments = agentsConfig?.statefulCodeSessions?.allowedEnvironments; + const executionEnvironments = agentsConfig?.statefulCodeSessions?.environments ?? []; const allowedEnvironments = resolveAllowedStatefulCodeEnvironments(configuredEnvironments); const handleChange = (value: boolean) => { @@ -91,6 +95,46 @@ export default function StatefulSessions() { {enabled && codeEnabled === true && (
+ {executionEnvironments.length > 0 && ( + <> + + +

+ {localize('com_nav_info_code_environment')} +

+ + )}