diff --git a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js index eda065cdf4..ceb1abb3ac 100644 --- a/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js +++ b/api/server/controllers/agents/__tests__/request.resumeMetadata.spec.js @@ -1817,6 +1817,43 @@ describe('ResumableAgentController resume metadata', () => { ); }); + it('preserves a stateful scope policy denial in the durable initialization error', async () => { + const policyError = Object.assign( + new Error('Stateful code environment is not allowed by this deployment: conversation'), + { + code: ErrorTypes.STATEFUL_CODE_ENVIRONMENT_NOT_ALLOWED, + status: 403, + statusCode: 403, + }, + ); + const initializeClient = jest.fn().mockRejectedValue(policyError); + const req = { + user: { id: 'user-123' }, + body: { + text: 'Run code.', + messageId: 'user-msg', + clientRequestId: 'req-abc', + conversationId: 'conversation-123', + endpointOption: { endpoint: 'agents', modelOptions: { model: 'gpt-4.1' } }, + }, + config: {}, + }; + const res = createResumableResponse(); + + await AgentController(req, res, jest.fn(), initializeClient, null); + + expect(res.status).toHaveBeenCalledWith(200); + expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith( + 'conversation-123', + JSON.stringify({ + status: 403, + code: ErrorTypes.STATEFUL_CODE_ENVIRONMENT_NOT_ALLOWED, + error: 'Stateful code environment is not allowed by this deployment: conversation', + }), + 1000, + ); + }); + it('returns a recovery conflict when the atomic store rejects changed source content', async () => { const mismatch = new Error('recovery mismatch'); mismatch.code = 'RECOVERY_PAYLOAD_MISMATCH'; diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js index 2dbe305609..ce1bdf5db9 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -50,15 +50,23 @@ function sendGenerationJson(res, status, body, generationProtocolVersion) { return res.status(status).json({ ...body, generationProtocolVersion }); } -function getResourceRecoveryFailure(error) { - if (error?.code !== ErrorTypes.RESOURCE_RECOVERY_REQUIRED) { - return null; +function getInitializationFailure(error) { + if (error?.code === ErrorTypes.RESOURCE_RECOVERY_REQUIRED) { + return { + status: 409, + code: ErrorTypes.RESOURCE_RECOVERY_REQUIRED, + error: error.message || 'Attached resources must be restored before retrying.', + }; } + const candidateStatus = error?.status ?? error?.statusCode; + if (!Number.isInteger(candidateStatus) || candidateStatus < 400 || candidateStatus >= 600) { + return null; + } return { - status: 409, - code: ErrorTypes.RESOURCE_RECOVERY_REQUIRED, - error: error.message || 'Attached resources must be restored before retrying.', + status: candidateStatus, + ...(typeof error?.code === 'string' ? { code: error.code } : {}), + error: error?.message || 'Failed to start generation', }; } @@ -1831,7 +1839,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit }); } catch (error) { logger.error('[ResumableAgentController] Initialization error:', error); - const resourceRecoveryFailure = getResourceRecoveryFailure(error); + const initializationFailure = getInitializationFailure(error); try { if (!res.headersSent) { if (error?.code === 'GENERATION_PREDECESSOR_MISMATCH') { @@ -1872,11 +1880,11 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit }, generationProtocolVersion, ); - } else if (resourceRecoveryFailure) { + } else if (initializationFailure) { sendGenerationJson( res, - resourceRecoveryFailure.status, - resourceRecoveryFailure, + initializationFailure.status, + initializationFailure, generationProtocolVersion, ); } else { @@ -1907,8 +1915,8 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit // and the concurrency slot leaks — so swallow its error. (A failed completeJob did not // finalize anything, so releasing afterward can't let it abort a later replacement.) if (jobCreatedAt != null) { - const initializationError = resourceRecoveryFailure - ? JSON.stringify(resourceRecoveryFailure) + const initializationError = initializationFailure + ? JSON.stringify(initializationFailure) : error.message || 'Failed to start generation'; await GenerationJobManager.completeJob(streamId, initializationError, jobCreatedAt).catch( (completeErr) => { diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index 3f6b4c5f53..6fc7450d61 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -35,6 +35,7 @@ const { actionDelimiter, AgentCapabilities, EModelEndpoint, + resolveAllowedStatefulCodeEnvironments, removeNullishValues, } = require('librechat-data-provider'); const { @@ -201,6 +202,28 @@ const isSubagentsCapabilityEnabled = (req) => { return capabilities.includes(AgentCapabilities.subagents); }; +/** 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) => { + if (enabled !== true) { + return true; + } + + const allowedEnvironments = resolveAllowedStatefulCodeEnvironments( + req.config?.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.allowedEnvironments, + ); + const resolvedEnvironment = environment ?? 'user'; + if (allowedEnvironments.includes(resolvedEnvironment)) { + return true; + } + + res.status(403).json({ + error: `Stateful code environment is not allowed by this deployment: ${resolvedEnvironment}`, + }); + return false; +}; + /** * Filters tools to only include those the user is authorized to use. * MCP tools must match the exact format `{toolName}_mcp_{serverName}` (exactly 2 segments). @@ -400,6 +423,17 @@ const createAgentHandler = async (req, res) => { const validatedData = agentCreateSchema.parse(req.body); const { tools = [], ...agentData } = removeNullishValues(validatedData); + if ( + !validateStatefulCodeEnvironment( + req, + res, + agentData.stateful_code_sessions, + agentData.stateful_code_environment, + ) + ) { + return; + } + if (agentData.model_parameters && typeof agentData.model_parameters === 'object') { agentData.model_parameters = removeNullishValues( sanitizeModelParameters(agentData.model_parameters), @@ -678,6 +712,46 @@ const updateAgentHandler = async (req, res) => { // Preserve explicit null for avatar to allow resetting the avatar const { avatar: avatarField, _id, ...rest } = validatedData; const updateData = removeNullishValues(rest); + let existingAgent; + + const includesStatefulConfiguration = + updateData.stateful_code_sessions !== undefined || + updateData.stateful_code_environment !== undefined; + const includesToolsConfiguration = Array.isArray(updateData.tools); + if (includesStatefulConfiguration || includesToolsConfiguration) { + existingAgent = await db.getAgent({ id }); + if (!existingAgent) { + return res.status(404).json({ error: 'Agent not found' }); + } + + 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')); + const activatesCodeExecution = + includesToolsConfiguration && + updateData.tools.includes(Tools.execute_code) && + existingAgent.tools?.includes(Tools.execute_code) !== true; + if (statefulConfigurationChanged || activatesCodeExecution) { + const effectiveStatefulSessions = + updateData.stateful_code_sessions ?? existingAgent.stateful_code_sessions; + const effectiveStatefulEnvironment = + updateData.stateful_code_environment ?? existingAgent.stateful_code_environment; + if ( + !validateStatefulCodeEnvironment( + req, + res, + effectiveStatefulSessions, + effectiveStatefulEnvironment, + ) + ) { + return; + } + } + } if (updateData.model_parameters && typeof updateData.model_parameters === 'object') { updateData.model_parameters = removeNullishValues( @@ -750,7 +824,7 @@ const updateAgentHandler = async (req, res) => { // Convert OCR to context in incoming updateData convertOcrToContextInPlace(updateData); - const existingAgent = await db.getAgent({ id }); + existingAgent ??= await db.getAgent({ id }); if (!existingAgent) { return res.status(404).json({ error: 'Agent not found' }); @@ -964,6 +1038,16 @@ const duplicateAgentHandler = async (req, res) => { id: newAgentId, author: userId, }); + if ( + !validateStatefulCodeEnvironment( + req, + res, + newAgentData.stateful_code_sessions, + newAgentData.stateful_code_environment, + ) + ) { + return; + } newAgentData.edges = replaceEdgeSourceId(newAgentData.edges, id, newAgentId); newAgentData.edges = replaceEdgeSourceId(newAgentData.edges, '', newAgentId); @@ -1498,6 +1582,17 @@ const revertAgentVersionHandler = async (req, res) => { } const revertVersion = existingAgent.versions?.[version_index]; + if ( + revertVersion && + !validateStatefulCodeEnvironment( + req, + res, + revertVersion.stateful_code_sessions, + revertVersion.stateful_code_environment, + ) + ) { + return; + } const storedRevertEdges = Array.isArray(revertVersion?.edges) ? revertVersion.edges : []; const revertEdges = replaceEdgeSourceId(storedRevertEdges, '', id); const hasLegacyEdgeSource = storedRevertEdges.some((edge) => diff --git a/api/server/controllers/agents/v1.spec.js b/api/server/controllers/agents/v1.spec.js index 67000ea4b3..cec5412f69 100644 --- a/api/server/controllers/agents/v1.spec.js +++ b/api/server/controllers/agents/v1.spec.js @@ -3,6 +3,7 @@ const { nanoid } = require('nanoid'); const { v4: uuidv4 } = require('uuid'); const { agentSchema, aclEntrySchema, fileSchema, userSchema } = require('@librechat/data-schemas'); const { + Tools, FileSources, PermissionBits, PrincipalModel, @@ -181,6 +182,28 @@ describe('Agent Controllers - Mass Assignment Protection', () => { }); describe('createAgentHandler', () => { + test('rejects a stateful environment excluded by deployment policy', async () => { + mockReq.config = { + endpoints: { + agents: { + statefulCodeSessions: { allowedEnvironments: ['user'] }, + }, + }, + }; + mockReq.body = { + name: 'Disallowed Stateful Agent', + provider: 'openai', + model: 'gpt-4', + stateful_code_sessions: true, + stateful_code_environment: 'conversation', + }; + + await createAgentHandler(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(403); + expect(await Agent.countDocuments()).toBe(0); + }); + test('should create agent with allowed fields only', async () => { const validData = { name: 'Test Agent', @@ -801,6 +824,83 @@ describe('Agent Controllers - Mass Assignment Protection', () => { expect(agentInDb.name).toBe('Updated Agent'); }); + test('rejects selecting a stateful environment excluded by deployment policy', async () => { + mockReq.user.id = existingAgentAuthorId.toString(); + mockReq.params.id = existingAgentId; + mockReq.config = { + endpoints: { + agents: { + statefulCodeSessions: { allowedEnvironments: ['user'] }, + }, + }, + }; + mockReq.body = { + stateful_code_sessions: true, + stateful_code_environment: 'conversation', + }; + + await updateAgentHandler(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(403); + const agentInDb = await Agent.findOne({ id: existingAgentId }); + expect(agentInDb.stateful_code_sessions).not.toBe(true); + }); + + test('allows unrelated edits to an existing scope after policy is tightened', async () => { + await Agent.updateOne( + { id: existingAgentId }, + { stateful_code_sessions: true, stateful_code_environment: 'conversation' }, + ); + mockReq.user.id = existingAgentAuthorId.toString(); + mockReq.params.id = existingAgentId; + mockReq.config = { + endpoints: { + agents: { + statefulCodeSessions: { allowedEnvironments: ['user'] }, + }, + }, + }; + mockReq.body = { + name: 'Still editable', + stateful_code_sessions: true, + stateful_code_environment: 'conversation', + }; + + await updateAgentHandler(mockReq, mockRes); + + expect(mockRes.status).not.toHaveBeenCalledWith(403); + const agentInDb = await Agent.findOne({ id: existingAgentId }); + expect(agentInDb.name).toBe('Still editable'); + expect(agentInDb.stateful_code_environment).toBe('conversation'); + }); + + test('rejects reactivating code execution with a retained disallowed scope', async () => { + await Agent.updateOne( + { id: existingAgentId }, + { + tools: [], + stateful_code_sessions: true, + stateful_code_environment: 'conversation', + }, + ); + mockReq.user.id = existingAgentAuthorId.toString(); + mockReq.params.id = existingAgentId; + mockReq.config = { + endpoints: { + agents: { + statefulCodeSessions: { allowedEnvironments: ['user'] }, + }, + }, + }; + mockReq.body = { tools: [Tools.execute_code] }; + + await updateAgentHandler(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(403); + const agentInDb = await Agent.findOne({ id: existingAgentId }); + expect(agentInDb.tools).not.toContain(Tools.execute_code); + }); + test('should sanitize corrupt numeric model_parameters on update', async () => { mockReq.user.id = existingAgentAuthorId.toString(); mockReq.params.id = existingAgentId; diff --git a/api/server/routes/agents/v1.js b/api/server/routes/agents/v1.js index 233839dcf4..6812dde46c 100644 --- a/api/server/routes/agents/v1.js +++ b/api/server/routes/agents/v1.js @@ -46,7 +46,7 @@ router.get('/categories', v1.getAgentCategories); * @param {AgentCreateParams} req.body - The agent creation parameters. * @returns {Agent} 201 - Success response - application/json */ -router.post('/', checkAgentCreate, v1.createAgent); +router.post('/', checkAgentCreate, configMiddleware, v1.createAgent); /** * Retrieves basic agent information (VIEW permission required). @@ -108,6 +108,7 @@ router.get( router.patch( '/:id', checkAgentCreate, + configMiddleware, canAccessAgentResource({ requiredPermission: PermissionBits.EDIT, resourceIdParam: 'id', @@ -124,6 +125,7 @@ router.patch( router.post( '/:id/duplicate', checkAgentCreate, + configMiddleware, canAccessAgentResource({ requiredPermission: PermissionBits.EDIT, resourceIdParam: 'id', @@ -157,6 +159,7 @@ router.delete( router.post( '/:id/revert', checkAgentCreate, + configMiddleware, canAccessAgentResource({ requiredPermission: PermissionBits.EDIT, resourceIdParam: 'id', diff --git a/api/server/routes/user.js b/api/server/routes/user.js index 90f247cd36..4107e410c7 100644 --- a/api/server/routes/user.js +++ b/api/server/routes/user.js @@ -28,7 +28,7 @@ const updateUserPreferences = createUserPreferencesHandler({ router.use('/settings', settings); router.get('/', requireJwtAuth, getUserController); -router.patch('/preferences', requireJwtAuth, updateUserPreferences); +router.patch('/preferences', requireJwtAuth, configMiddleware, updateUserPreferences); router.get('/terms', requireJwtAuth, getTermsStatusController); router.post('/terms/accept', requireJwtAuth, acceptTermsController); router.post('/plugins', requireJwtAuth, updateUserPluginsController); diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index a75683a4b8..5110a8b7bd 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -20,6 +20,7 @@ const { buildAgentContextAttachmentsByAgentId, collectCodeExecutionProfileRoutes, getLazySubagentConfigId, + createStatefulCodeEnvironmentPolicyError, } = require('@librechat/api'); const { Permissions, @@ -34,6 +35,7 @@ const { MAX_SUBAGENT_GRAPH_NODES, MAX_SUBAGENT_RUN_CONFIGS, isEphemeralAgentId, + resolveAllowedStatefulCodeEnvironments, } = require('librechat-data-provider'); const { createToolEndCallback, @@ -194,6 +196,9 @@ const initializeClient = async ({ const statefulSessionsAvailable = enabledCapabilities.has( AgentCapabilities.stateful_code_sessions, ); + const allowedStatefulCodeEnvironments = resolveAllowedStatefulCodeEnvironments( + appConfig?.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.allowedEnvironments, + ); const ephemeralSkillsToggle = req.body?.ephemeralAgent?.skills === true; const skillDbMethods = getSkillDbMethods(); @@ -807,26 +812,36 @@ const initializeClient = async ({ ), ); - const toLazySubagentMetadata = (agent) => ({ - id: agent.id, - name: agent.name, - description: agent.description, - provider: agent.provider, - model: agent.model, - model_parameters: { model: agent.model_parameters?.model }, - recursion_limit: agent.recursion_limit, - subagents: agent.subagents, - configId: getLazySubagentConfigId(agent), - codeEnvAvailable: - codeEnvAvailable === true && agent.tools?.includes(Tools.execute_code) === true, - statefulCodeSessions: + const toLazySubagentMetadata = (agent) => { + const statefulCodeSessions = statefulSessionsAvailable === true && codeEnvAvailable === true && agent.stateful_code_sessions === true && - agent.tools?.includes(Tools.execute_code) === true, - statefulCodeEnvironment: agent.stateful_code_environment, - includeReasoningHistory: getIncludeReasoningHistory(agent), - }); + agent.tools?.includes(Tools.execute_code) === true; + const statefulCodeEnvironment = agent.stateful_code_environment ?? 'user'; + if ( + statefulCodeSessions && + !allowedStatefulCodeEnvironments.includes(statefulCodeEnvironment) + ) { + throw createStatefulCodeEnvironmentPolicyError(statefulCodeEnvironment); + } + return { + id: agent.id, + name: agent.name, + description: agent.description, + provider: agent.provider, + model: agent.model, + model_parameters: { model: agent.model_parameters?.model }, + recursion_limit: agent.recursion_limit, + subagents: agent.subagents, + configId: getLazySubagentConfigId(agent), + codeEnvAvailable: + codeEnvAvailable === true && agent.tools?.includes(Tools.execute_code) === true, + statefulCodeSessions, + statefulCodeEnvironment, + includeReasoningHistory: getIncludeReasoningHistory(agent), + }; + }; const loadSubagentMetadata = async (agentId) => { if (skippedAgentIds.has(agentId)) return null; diff --git a/api/server/services/Endpoints/agents/initialize.spec.js b/api/server/services/Endpoints/agents/initialize.spec.js index a8ff3840f1..2152892fe8 100644 --- a/api/server/services/Endpoints/agents/initialize.spec.js +++ b/api/server/services/Endpoints/agents/initialize.spec.js @@ -62,7 +62,11 @@ jest.mock('~/server/services/ToolService', () => ({ loadAgentTools: jest.fn(), loadToolsForExecution: (...args) => mockLoadToolsForExecution(...args), isFatalAgentInitializationError: (error) => - ['AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE', 'resource_recovery_required'].includes(error?.code), + [ + 'AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE', + 'resource_recovery_required', + 'stateful_code_environment_not_allowed', + ].includes(error?.code), })); jest.mock('~/server/controllers/ModelController', () => ({ @@ -859,6 +863,42 @@ describe('initializeClient — subagent loading', () => { expect(agentClientArgs.agentConfigs.has(SUBAGENT_ID)).toBe(false); }); + it('rejects a disallowed lazy subagent scope before exposing it for prewarm', async () => { + const subAgent = await createAgent({ + id: SUBAGENT_ID, + name: 'Disallowed Stateful Subagent', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(), + tools: ['execute_code'], + stateful_code_sessions: true, + stateful_code_environment: 'conversation', + }); + 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: ['user'] }; + + await expect( + initializeClient({ + req, + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }), + ).rejects.toMatchObject({ + code: ErrorTypes.STATEFUL_CODE_ENVIRONMENT_NOT_ALLOWED, + }); + + expect(agentClientArgs).toBeUndefined(); + expect(mockInitializeAgent).toHaveBeenCalledTimes(1); + }); + 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/client/src/components/Nav/Settings/StatefulWorkspaceDefault.tsx b/client/src/components/Nav/Settings/StatefulWorkspaceDefault.tsx index 2d2f2c5857..4057336614 100644 --- a/client/src/components/Nav/Settings/StatefulWorkspaceDefault.tsx +++ b/client/src/components/Nav/Settings/StatefulWorkspaceDefault.tsx @@ -7,18 +7,34 @@ import { SelectTrigger, useToastContext, } from '@librechat/client'; - +import { + STATEFUL_CODE_ENVIRONMENTS, + resolveAllowedStatefulCodeEnvironments, + resolveStatefulCodeEnvironment, +} from 'librechat-data-provider'; import type { StatefulCodeEnvironment } from 'librechat-data-provider'; import { useGetUserQuery, useUpdateUserPreferencesMutation } from '~/data-provider'; -import { useLocalize } from '~/hooks'; +import { useGetAgentsConfig, useLocalize } from '~/hooks'; const FALLBACK_ENVIRONMENT: StatefulCodeEnvironment = 'user'; +const ENVIRONMENT_LABELS = { + user: 'com_ui_stateful_code_environment_user', + 'agent-user': 'com_ui_stateful_code_environment_agent_user', + conversation: 'com_ui_stateful_code_environment_conversation', +} as const; export default function StatefulWorkspaceDefault() { const localize = useLocalize(); const { showToast } = useToastContext(); const { data: user } = useGetUserQuery(); - const savedEnvironment = user?.personalization?.statefulCodeEnvironment ?? FALLBACK_ENVIRONMENT; + const { agentsConfig } = useGetAgentsConfig(); + const configuredEnvironments = agentsConfig?.statefulCodeSessions?.allowedEnvironments; + const allowedEnvironments = resolveAllowedStatefulCodeEnvironments(configuredEnvironments); + const savedPreference = user?.personalization?.statefulCodeEnvironment; + const savedEnvironment = + savedPreference ?? + resolveStatefulCodeEnvironment(FALLBACK_ENVIRONMENT, configuredEnvironments) ?? + FALLBACK_ENVIRONMENT; const [environment, setEnvironment] = useState(savedEnvironment); useEffect(() => { @@ -36,6 +52,9 @@ export default function StatefulWorkspaceDefault() { const handleChange = (value: string) => { const statefulCodeEnvironment = value as StatefulCodeEnvironment; + if (!allowedEnvironments.includes(statefulCodeEnvironment)) { + return; + } setEnvironment(statefulCodeEnvironment); mutation.mutate({ statefulCodeEnvironment }); }; @@ -61,13 +80,18 @@ export default function StatefulWorkspaceDefault() { - {localize('com_ui_stateful_code_environment_user')} - - {localize('com_ui_stateful_code_environment_agent_user')} - - - {localize('com_ui_stateful_code_environment_conversation')} - + {STATEFUL_CODE_ENVIRONMENTS.filter( + (candidate) => + allowedEnvironments.includes(candidate) || candidate === savedEnvironment, + ).map((candidate) => ( + + {localize(ENVIRONMENT_LABELS[candidate])} + + ))} diff --git a/client/src/components/Nav/Settings/__tests__/StatefulWorkspaceDefault.spec.tsx b/client/src/components/Nav/Settings/__tests__/StatefulWorkspaceDefault.spec.tsx index adfe650432..9e3773a5d3 100644 --- a/client/src/components/Nav/Settings/__tests__/StatefulWorkspaceDefault.spec.tsx +++ b/client/src/components/Nav/Settings/__tests__/StatefulWorkspaceDefault.spec.tsx @@ -5,6 +5,7 @@ import StatefulWorkspaceDefault from '../StatefulWorkspaceDefault'; const mockMutate = jest.fn(); const mockShowToast = jest.fn(); +let mockAllowedEnvironments: Array<'user' | 'agent-user' | 'conversation'> | undefined; jest.mock('@librechat/client', () => ({ Select: ({ @@ -30,14 +31,33 @@ jest.mock('@librechat/client', () => ({ SelectTrigger: ({ children }: { children: ReactNode }) => children, SelectValue: () => null, SelectContent: ({ children }: { children: ReactNode }) => children, - SelectItem: ({ value, children }: { value: string; children: ReactNode }) => ( - + SelectItem: ({ + value, + children, + disabled, + }: { + value: string; + children: ReactNode; + disabled?: boolean; + }) => ( + ), useToastContext: () => ({ showToast: mockShowToast }), })); jest.mock('~/hooks', () => ({ useLocalize: () => (key: string) => key, + useGetAgentsConfig: () => ({ + agentsConfig: { + capabilities: ['stateful_code_sessions'], + statefulCodeSessions: + mockAllowedEnvironments == null + ? undefined + : { allowedEnvironments: mockAllowedEnvironments }, + }, + }), })); jest.mock('~/data-provider', () => ({ @@ -53,6 +73,7 @@ jest.mock('~/data-provider', () => ({ describe('StatefulWorkspaceDefault', () => { beforeEach(() => { mockMutate.mockClear(); + mockAllowedEnvironments = undefined; }); it('shows the saved user preference', () => { @@ -71,4 +92,20 @@ describe('StatefulWorkspaceDefault', () => { expect(mockMutate).toHaveBeenCalledWith({ statefulCodeEnvironment: 'conversation' }); }); + + it('only enables deployment-allowed scopes while preserving the saved value', () => { + mockAllowedEnvironments = ['user']; + + render(); + + expect( + screen.getByRole('option', { name: 'com_ui_stateful_code_environment_user' }), + ).toBeEnabled(); + expect( + screen.getByRole('option', { name: 'com_ui_stateful_code_environment_agent_user' }), + ).toBeDisabled(); + expect( + screen.queryByRole('option', { name: 'com_ui_stateful_code_environment_conversation' }), + ).not.toBeInTheDocument(); + }); }); diff --git a/client/src/components/SidePanel/Agents/Advanced/StatefulSessions.tsx b/client/src/components/SidePanel/Agents/Advanced/StatefulSessions.tsx index 4fce6a8633..77d6445e59 100644 --- a/client/src/components/SidePanel/Agents/Advanced/StatefulSessions.tsx +++ b/client/src/components/SidePanel/Agents/Advanced/StatefulSessions.tsx @@ -1,5 +1,10 @@ import { useFormContext } from 'react-hook-form'; -import { AgentCapabilities } from 'librechat-data-provider'; +import { + AgentCapabilities, + STATEFUL_CODE_ENVIRONMENTS, + resolveStatefulCodeEnvironment, + resolveAllowedStatefulCodeEnvironments, +} from 'librechat-data-provider'; import { Switch, Select, @@ -15,25 +20,38 @@ import { } from '@librechat/client'; import type { StatefulCodeEnvironment } from 'librechat-data-provider'; import type { AgentForm } from '~/common'; -import { useAuthContext, useLocalize } from '~/hooks'; +import { useAuthContext, useGetAgentsConfig, useLocalize } from '~/hooks'; import { ESide } from '~/common'; +const ENVIRONMENT_LABELS = { + user: 'com_ui_stateful_code_environment_user', + 'agent-user': 'com_ui_stateful_code_environment_agent_user', + conversation: 'com_ui_stateful_code_environment_conversation', +} as const; + export default function StatefulSessions() { const localize = useLocalize(); const { user } = useAuthContext(); + const { agentsConfig } = useGetAgentsConfig(); const methods = useFormContext(); const { setValue, watch } = methods; const enabled = watch(AgentCapabilities.stateful_code_sessions) ?? false; const codeEnabled = watch(AgentCapabilities.execute_code); const environment = watch('stateful_code_environment') ?? 'user'; + const configuredEnvironments = agentsConfig?.statefulCodeSessions?.allowedEnvironments; + const allowedEnvironments = resolveAllowedStatefulCodeEnvironments(configuredEnvironments); const handleChange = (value: boolean) => { setValue(AgentCapabilities.stateful_code_sessions, value, { shouldDirty: true }); - if (value && !watch('stateful_code_environment')) { + const currentEnvironment = watch('stateful_code_environment'); + if (value && (!currentEnvironment || !allowedEnvironments.includes(currentEnvironment))) { setValue( 'stateful_code_environment', - user?.personalization?.statefulCodeEnvironment ?? 'user', + resolveStatefulCodeEnvironment( + user?.personalization?.statefulCodeEnvironment ?? 'user', + configuredEnvironments, + ) ?? 'user', { shouldDirty: true }, ); } @@ -81,25 +99,28 @@ export default function StatefulSessions() {

diff --git a/client/src/components/SidePanel/Agents/AgentPanel.tsx b/client/src/components/SidePanel/Agents/AgentPanel.tsx index 99e8938374..4dc9226015 100644 --- a/client/src/components/SidePanel/Agents/AgentPanel.tsx +++ b/client/src/components/SidePanel/Agents/AgentPanel.tsx @@ -11,6 +11,7 @@ import { ResourceType, EModelEndpoint, PermissionBits, + resolveStatefulCodeEnvironment, isAssistantsEndpoint, } from 'librechat-data-provider'; import type { Agent, AgentUpdateParams } from 'librechat-data-provider'; @@ -286,7 +287,11 @@ export default function AgentPanel() { setCurrentAgentId, agent_id: current_agent_id, } = useAgentPanelContext(); - const defaultStatefulCodeEnvironment = user?.personalization?.statefulCodeEnvironment ?? 'user'; + const defaultStatefulCodeEnvironment = + resolveStatefulCodeEnvironment( + user?.personalization?.statefulCodeEnvironment ?? 'user', + agentsConfig?.statefulCodeSessions?.allowedEnvironments, + ) ?? 'user'; const { onSelect: onSelectAgent } = useSelectAgent(); diff --git a/client/src/components/SidePanel/Agents/DeleteButton.tsx b/client/src/components/SidePanel/Agents/DeleteButton.tsx index fba6059975..93da129175 100644 --- a/client/src/components/SidePanel/Agents/DeleteButton.tsx +++ b/client/src/components/SidePanel/Agents/DeleteButton.tsx @@ -1,6 +1,7 @@ import { memo } from 'react'; import { useFormContext } from 'react-hook-form'; import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { resolveStatefulCodeEnvironment } from 'librechat-data-provider'; import { Label, Button, @@ -12,9 +13,9 @@ import { } from '@librechat/client'; import type { Agent, AgentCreateParams } from 'librechat-data-provider'; import type { UseMutationResult } from '@tanstack/react-query'; +import { useAuthContext, useGetAgentsConfig, useLocalize } from '~/hooks'; import { logger, getDefaultAgentFormValues } from '~/utils'; import { useDeleteAgentMutation } from '~/data-provider'; -import { useAuthContext, useLocalize } from '~/hooks'; import { isEphemeralAgent } from '~/common'; import store from '~/store'; @@ -29,6 +30,7 @@ function DeleteButton({ }) { const localize = useLocalize(); const { user } = useAuthContext(); + const { agentsConfig } = useGetAgentsConfig(); const { reset } = useFormContext(); const { showToast } = useToastContext(); const setConversation = useSetRecoilState(store.conversationByIndex(0)); @@ -54,7 +56,14 @@ function DeleteButton({ const firstAgent = updatedList[0] as Agent | undefined; if (!firstAgent) { setCurrentAgentId(undefined); - reset(getDefaultAgentFormValues(user?.personalization?.statefulCodeEnvironment ?? 'user')); + reset( + getDefaultAgentFormValues( + resolveStatefulCodeEnvironment( + user?.personalization?.statefulCodeEnvironment ?? 'user', + agentsConfig?.statefulCodeSessions?.allowedEnvironments, + ) ?? 'user', + ), + ); setConversation((prev) => (prev ? { ...prev, agent_id: '' } : prev)); return; } diff --git a/librechat.example.yaml b/librechat.example.yaml index 7be6cdc698..493ba92060 100644 --- a/librechat.example.yaml +++ b/librechat.example.yaml @@ -503,6 +503,9 @@ endpoints: # # The following capabilities are opt-in and must be added explicitly: # # "programmatic_tools", "stateful_code_sessions", "run_in_background", "tool_intents" # # "stateful_code_sessions" is highly experimental and may change substantially. + # # (optional) Limit the workspace scopes users may select. Omit to allow all three. + # statefulCodeSessions: + # allowedEnvironments: ["user", "agent-user", "conversation"] # # "run_in_background" makes Code Interpreter tools eligible by default and enables per-tool MCP opt-in. # # "tool_intents" enables live model-written labels for native tools and opted-in MCP tools. # # (optional) Require user approval before matching tool calls. Disabled by default. diff --git a/packages/api/src/agents/__tests__/initialize.test.ts b/packages/api/src/agents/__tests__/initialize.test.ts index fb0ff6de28..8138eb2e04 100644 --- a/packages/api/src/agents/__tests__/initialize.test.ts +++ b/packages/api/src/agents/__tests__/initialize.test.ts @@ -110,6 +110,7 @@ jest.mock('../resources', () => ({ })); import { initializeAgent } from '../initialize'; +import { isFatalAgentInitializationError } from '../errors'; const realUtils = jest.requireActual('~/utils'); @@ -1782,6 +1783,74 @@ describe('initializeAgent — execute_code capability expansion', () => { } }); + it('rejects a stateful environment excluded by deployment policy', async () => { + const { agent, req, res, loadTools, db } = createMocks(); + agent.tools = ['execute_code']; + agent.stateful_code_sessions = true; + agent.stateful_code_environment = 'conversation'; + req.config = { + endpoints: { + [EModelEndpoint.agents]: { + statefulCodeSessions: { allowedEnvironments: ['user'] }, + }, + }, + } as NonNullable; + + let error; + try { + await initializeAgent( + { + req, + res, + agent, + loadTools, + endpointOption: { endpoint: EModelEndpoint.agents }, + allowedProviders: new Set([Providers.OPENAI]), + isInitialAgent: true, + codeEnvAvailable: true, + statefulSessionsAvailable: true, + }, + db, + ); + } catch (caught) { + error = caught; + } + + expect(error).toMatchObject({ + code: ErrorTypes.STATEFUL_CODE_ENVIRONMENT_NOT_ALLOWED, + message: 'Stateful code environment is not allowed by this deployment: conversation', + }); + expect(isFatalAgentInitializationError(error)).toBe(true); + expect(loadTools).not.toHaveBeenCalled(); + }); + + it('uses an explicit stateful environment allowlist when req.config is unavailable', async () => { + const { agent, req, res, loadTools, db } = createMocks(); + agent.tools = ['execute_code']; + agent.stateful_code_sessions = true; + agent.stateful_code_environment = 'conversation'; + req.config = undefined; + + await expect( + initializeAgent( + { + req, + res, + agent, + loadTools, + endpointOption: { endpoint: EModelEndpoint.agents }, + allowedProviders: new Set([Providers.OPENAI]), + isInitialAgent: true, + codeEnvAvailable: true, + statefulSessionsAvailable: true, + allowedStatefulCodeEnvironments: ['user'], + }, + db, + ), + ).rejects.toMatchObject({ code: ErrorTypes.STATEFUL_CODE_ENVIRONMENT_NOT_ALLOWED }); + expect(loadTools).not.toHaveBeenCalled(); + }); + it('upgrades read_file to the skill-aware description when active skills are in scope', async () => { const { agent, req, res, loadTools, db } = createMocks(); agent.tools = ['execute_code']; diff --git a/packages/api/src/agents/errors.spec.ts b/packages/api/src/agents/errors.spec.ts index b2c88a25c7..e75982b3af 100644 --- a/packages/api/src/agents/errors.spec.ts +++ b/packages/api/src/agents/errors.spec.ts @@ -2,12 +2,13 @@ import { ErrorTypes } from 'librechat-data-provider'; import { AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE, isFatalAgentInitializationError } from './errors'; describe('isFatalAgentInitializationError', () => { - it.each([ErrorTypes.RESOURCE_RECOVERY_REQUIRED, AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE])( - 'classifies %s as fatal', - (code) => { - expect(isFatalAgentInitializationError({ code })).toBe(true); - }, - ); + it.each([ + ErrorTypes.RESOURCE_RECOVERY_REQUIRED, + ErrorTypes.STATEFUL_CODE_ENVIRONMENT_NOT_ALLOWED, + AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE, + ])('classifies %s as fatal', (code) => { + expect(isFatalAgentInitializationError({ code })).toBe(true); + }); it('allows skill-added MCP tools to fall back while keeping resource recovery fatal', () => { const options = { allowExpectedMCPFallback: true }; diff --git a/packages/api/src/agents/errors.ts b/packages/api/src/agents/errors.ts index b1809a094d..bb3e6aef49 100644 --- a/packages/api/src/agents/errors.ts +++ b/packages/api/src/agents/errors.ts @@ -2,6 +2,17 @@ import { ErrorTypes } from 'librechat-data-provider'; export const AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE = 'AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE'; +export function createStatefulCodeEnvironmentPolicyError(environment: string): Error { + return Object.assign( + new Error(`Stateful code environment is not allowed by this deployment: ${environment}`), + { + code: ErrorTypes.STATEFUL_CODE_ENVIRONMENT_NOT_ALLOWED, + status: 403, + statusCode: 403, + }, + ); +} + export interface FatalAgentInitializationOptions { /** * Skill `allowed-tools` may add an MCP tool beyond the agent's configured @@ -31,6 +42,7 @@ export function isFatalAgentInitializationError( const code = getErrorCode(error); return ( code === ErrorTypes.RESOURCE_RECOVERY_REQUIRED || + code === ErrorTypes.STATEFUL_CODE_ENVIRONMENT_NOT_ALLOWED || (code === AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE && options.allowExpectedMCPFallback !== true) ); } diff --git a/packages/api/src/agents/initialize.ts b/packages/api/src/agents/initialize.ts index c38d96260d..7e49a30a91 100644 --- a/packages/api/src/agents/initialize.ts +++ b/packages/api/src/agents/initialize.ts @@ -9,6 +9,7 @@ import { paramEndpoints, isAgentsEndpoint, AgentCapabilities, + resolveAllowedStatefulCodeEnvironments, replaceSpecialVars, providerEndpointMap, } from 'librechat-data-provider'; @@ -20,6 +21,7 @@ import type { TFile, Agent, TUser, + StatefulCodeEnvironment, } from 'librechat-data-provider'; import type { GenericTool, LCToolRegistry, ToolMap, LCTool } from '@librechat/agents'; import type { IMongoFile, FileOwnerScope } from '@librechat/data-schemas'; @@ -67,9 +69,12 @@ import { registerFileAuthoringTools, isFileAuthoringToolDefinition, } from './tools'; +import { + createStatefulCodeEnvironmentPolicyError, + isFatalAgentInitializationError, +} from './errors'; import { registerMemoryTools, memoryToolUsageGuard } from './memory'; import { applyIntentLabels, sanitizeIntentLabels } from './intent'; -import { isFatalAgentInitializationError } from './errors'; import { applyBackgroundToolCalls } from './background'; import { filterFilesByEndpointConfig } from '~/files'; import { generateArtifactsPrompt } from '~/prompts'; @@ -478,6 +483,8 @@ export interface InitializeAgentParams { toolIntentsAvailable?: boolean; /** Whether stateful code sessions are available (stateful_code_sessions capability enabled) */ statefulSessionsAvailable?: boolean; + /** Explicit deployment allowlist for request types that do not carry LibreChat config on req. */ + allowedStatefulCodeEnvironments?: readonly StatefulCodeEnvironment[]; /** Whether inline memory tools are available (memory capability enabled, memory * configured, and the user permitted). When true and the agent lists the `memory` * capability, `set_memory` + `delete_memory` are registered for the LLM. */ @@ -723,6 +730,15 @@ export async function initializeAgent( params.statefulSessionsAvailable === true && agent.stateful_code_sessions === true; const statefulCodeEnvironment = normalizeStatefulCodeEnvironment(agent.stateful_code_environment); + if (effectiveStatefulSessions) { + const allowedStatefulCodeEnvironments = resolveAllowedStatefulCodeEnvironments( + params.allowedStatefulCodeEnvironments ?? + req.config?.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.allowedEnvironments, + ); + if (!allowedStatefulCodeEnvironments.includes(statefulCodeEnvironment)) { + throw createStatefulCodeEnvironmentPolicyError(statefulCodeEnvironment); + } + } const codeExecutionContext = resolveCodeExecutionContext({ statefulSessions: effectiveStatefulSessions, environment: statefulCodeEnvironment, diff --git a/packages/api/src/agents/openai/service.spec.ts b/packages/api/src/agents/openai/service.spec.ts index 48892e627f..75bcf09172 100644 --- a/packages/api/src/agents/openai/service.spec.ts +++ b/packages/api/src/agents/openai/service.spec.ts @@ -1,3 +1,4 @@ +import { ErrorTypes } from 'librechat-data-provider'; import type { ChatCompletionDependencies } from './service'; import { createAgentChatCompletion } from './service'; @@ -140,4 +141,50 @@ describe('createAgentChatCompletion - MCP permission user propagation', () => { }); expect(runArgs.appConfig).not.toHaveProperty('interfaceConfig'); }); + + it('forwards the stateful environment allowlist from appConfig to agent initialization', async () => { + deps.appConfig = { + endpoints: { + agents: { + capabilities: ['execute_code', 'stateful_code_sessions'], + statefulCodeSessions: { allowedEnvironments: ['user', 'agent-user'] }, + }, + }, + } as never; + + await createAgentChatCompletion(createMockReq({ id: 'user-123' }), createMockRes(), deps); + + expect(deps.initializeAgent).toHaveBeenCalledWith( + expect.objectContaining({ + codeEnvAvailable: true, + statefulSessionsAvailable: true, + allowedStatefulCodeEnvironments: ['user', 'agent-user'], + }), + ); + }); + + it('preserves stateful scope policy status and code in an initialization error response', async () => { + const policyError = Object.assign( + new Error('Stateful code environment is not allowed by this deployment: conversation'), + { + code: ErrorTypes.STATEFUL_CODE_ENVIRONMENT_NOT_ALLOWED, + status: 403, + statusCode: 403, + }, + ); + (deps.initializeAgent as jest.Mock).mockRejectedValueOnce(policyError); + const res = createMockRes(); + + await createAgentChatCompletion(createMockReq({ id: 'user-123' }), res, deps); + + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith({ + error: { + message: policyError.message, + type: 'invalid_request_error', + param: null, + code: ErrorTypes.STATEFUL_CODE_ENVIRONMENT_NOT_ALLOWED, + }, + }); + }); }); diff --git a/packages/api/src/agents/openai/service.ts b/packages/api/src/agents/openai/service.ts index ebc8122916..193e8fa92f 100644 --- a/packages/api/src/agents/openai/service.ts +++ b/packages/api/src/agents/openai/service.ts @@ -20,6 +20,7 @@ */ import { nanoid } from 'nanoid'; import { AgentCapabilities } from 'librechat-data-provider'; +import type { StatefulCodeEnvironment } from 'librechat-data-provider'; import type { Response as ServerResponse, Request } from 'express'; import type { ChatCompletionResponse, @@ -155,6 +156,8 @@ interface InitializeAgentParams { * in-repo controllers; absent / `undefined` disables the feature. */ statefulSessionsAvailable?: boolean; + /** Deployment allowlist carried explicitly because this route's Request has no req.config. */ + allowedStatefulCodeEnvironments?: readonly StatefulCodeEnvironment[]; /** * Whether the admin-level `run_in_background` capability is enabled. * Gates `applyBackgroundToolCalls` in `initializeAgent` (the injected @@ -475,6 +478,16 @@ export async function createAgentChatCompletion( * also carries each agent's trusted stateful endpoint/profile selection * into tool loading and prewarming. */ const statefulSessionsAvailable = capabilityEnabled(AgentCapabilities.stateful_code_sessions); + const allowedStatefulCodeEnvironments = + agentsConfig != null && typeof agentsConfig === 'object' + ? ( + agentsConfig as { + statefulCodeSessions?: { + allowedEnvironments?: readonly StatefulCodeEnvironment[]; + }; + } + ).statefulCodeSessions?.allowedEnvironments + : undefined; /** Same gate as the in-repo controllers: without it, agents that opted * tools in via tool_options.run_in_background silently lose the * background param + poll tool on this route. */ @@ -498,6 +511,7 @@ export async function createAgentChatCompletion( isInitialAgent: true, codeEnvAvailable, statefulSessionsAvailable, + allowedStatefulCodeEnvironments, backgroundToolsAvailable, toolIntentsAvailable, }); @@ -637,7 +651,27 @@ export async function createAgentChatCompletion( writeSSE(res, '[DONE]'); res.end(); } else { - sendErrorResponse(res, 500, errorMessage, 'server_error'); + const candidateStatus = + error != null && typeof error === 'object' + ? ((error as { status?: unknown; statusCode?: unknown }).status ?? + (error as { statusCode?: unknown }).statusCode) + : undefined; + const statusCode = + typeof candidateStatus === 'number' && + Number.isInteger(candidateStatus) && + candidateStatus >= 400 && + candidateStatus < 600 + ? candidateStatus + : 500; + const errorType = + statusCode >= 400 && statusCode < 500 ? 'invalid_request_error' : 'server_error'; + const errorCode = + error != null && + typeof error === 'object' && + typeof (error as { code?: unknown }).code === 'string' + ? (error as { code: string }).code + : null; + sendErrorResponse(res, statusCode, errorMessage, errorType, errorCode); } } } diff --git a/packages/api/src/endpoints/config/endpoints.spec.ts b/packages/api/src/endpoints/config/endpoints.spec.ts index e0155fe323..fdeb341f17 100644 --- a/packages/api/src/endpoints/config/endpoints.spec.ts +++ b/packages/api/src/endpoints/config/endpoints.spec.ts @@ -175,6 +175,30 @@ describe('createEndpointsConfigService', () => { expect(result?.[EModelEndpoint.agents]?.allowedProviders).toEqual(['openAI', 'anthropic']); }); + it('exposes the deployment stateful environment allowlist', async () => { + const deps = createMockDeps({ + loadDefaultEndpointsConfig: jest.fn().mockResolvedValue({ + [EModelEndpoint.agents]: { userProvide: false, order: 0 }, + }), + getAppConfig: jest.fn().mockResolvedValue( + appConfig({ + endpoints: { + [EModelEndpoint.agents]: { + statefulCodeSessions: { allowedEnvironments: ['user', 'agent-user'] }, + }, + }, + }), + ), + }); + const { getEndpointsConfig } = createEndpointsConfigService(deps); + + const result = await getEndpointsConfig(fakeReq()); + + expect(result?.[EModelEndpoint.agents]?.statefulCodeSessions).toEqual({ + allowedEnvironments: ['user', 'agent-user'], + }); + }); + it('merges bedrock availableRegions', async () => { const deps = createMockDeps({ loadDefaultEndpointsConfig: jest.fn().mockResolvedValue({ diff --git a/packages/api/src/endpoints/config/endpoints.ts b/packages/api/src/endpoints/config/endpoints.ts index fd9e8c3018..b7f58985fe 100644 --- a/packages/api/src/endpoints/config/endpoints.ts +++ b/packages/api/src/endpoints/config/endpoints.ts @@ -70,13 +70,14 @@ export function createEndpointsConfigService(deps: EndpointsConfigDeps): { } if (mergedConfig[EModelEndpoint.agents] && appConfig?.endpoints?.[EModelEndpoint.agents]) { - const { disableBuilder, capabilities, allowedProviders } = + const { disableBuilder, capabilities, allowedProviders, statefulCodeSessions } = appConfig.endpoints[EModelEndpoint.agents]; mergedConfig[EModelEndpoint.agents] = { ...mergedConfig[EModelEndpoint.agents], allowedProviders, disableBuilder, capabilities, + statefulCodeSessions, }; } diff --git a/packages/api/src/user/preferences.spec.ts b/packages/api/src/user/preferences.spec.ts index d33ff364fc..004cdd0d3e 100644 --- a/packages/api/src/user/preferences.spec.ts +++ b/packages/api/src/user/preferences.spec.ts @@ -69,6 +69,27 @@ describe('createUserPreferencesHandler', () => { expect(response.statusCode).toBe(400); }); + it('rejects a valid scope excluded by deployment policy', async () => { + const updateStatefulCodeEnvironment = jest.fn(); + const handler = createUserPreferencesHandler({ updateStatefulCodeEnvironment }); + const response = createResponse(); + const request = createRequest({ statefulCodeEnvironment: 'conversation' }) as Parameters< + typeof handler + >[0]; + request.config = { + endpoints: { + agents: { + statefulCodeSessions: { allowedEnvironments: ['user', 'agent-user'] }, + }, + }, + } as NonNullable; + + await handler(request, response as Response); + + expect(updateStatefulCodeEnvironment).not.toHaveBeenCalled(); + expect(response.statusCode).toBe(403); + }); + it('requires an authenticated user', async () => { const updateStatefulCodeEnvironment = jest.fn(); const handler = createUserPreferencesHandler({ updateStatefulCodeEnvironment }); diff --git a/packages/api/src/user/preferences.ts b/packages/api/src/user/preferences.ts index 780db1f9d0..bc0c81b521 100644 --- a/packages/api/src/user/preferences.ts +++ b/packages/api/src/user/preferences.ts @@ -1,8 +1,12 @@ import { logger } from '@librechat/data-schemas'; -import { STATEFUL_CODE_ENVIRONMENTS } from 'librechat-data-provider'; +import { + STATEFUL_CODE_ENVIRONMENTS, + resolveAllowedStatefulCodeEnvironments, +} from 'librechat-data-provider'; import type { StatefulCodeEnvironment } from 'librechat-data-provider'; import type { IUser } from '@librechat/data-schemas'; -import type { Request, Response } from 'express'; +import type { Response } from 'express'; +import type { ServerRequest } from '~/types'; interface UserPreferencesBody { statefulCodeEnvironment?: string; @@ -12,7 +16,8 @@ function isStatefulCodeEnvironment(value: string): value is StatefulCodeEnvironm return STATEFUL_CODE_ENVIRONMENTS.some((environment) => environment === value); } -type UserPreferencesRequest = Request & { +type UserPreferencesRequest = Omit & { + body: UserPreferencesBody; user?: IUser; }; @@ -39,6 +44,15 @@ export function createUserPreferencesHandler( }); } + const allowedEnvironments = resolveAllowedStatefulCodeEnvironments( + req.config?.endpoints?.agents?.statefulCodeSessions?.allowedEnvironments, + ); + if (!allowedEnvironments.includes(environment)) { + return res.status(403).json({ + message: `statefulCodeEnvironment is not allowed by this deployment: ${environment}`, + }); + } + try { const updatedUser = await deps.updateStatefulCodeEnvironment(userId, environment); if (!updatedUser) { diff --git a/packages/data-provider/specs/config-schemas.spec.ts b/packages/data-provider/specs/config-schemas.spec.ts index 58b32f7da2..d81b2ea491 100644 --- a/packages/data-provider/specs/config-schemas.spec.ts +++ b/packages/data-provider/specs/config-schemas.spec.ts @@ -405,6 +405,27 @@ describe('endpointSchema addParams validation', () => { }); describe('agentsEndpointSchema', () => { + it('accepts a non-empty stateful code environment allowlist', () => { + const result = agentsEndpointSchema.safeParse({ + statefulCodeSessions: { allowedEnvironments: ['user', 'agent-user'] }, + }); + + expect(result.success).toBe(true); + }); + + it('rejects empty or unknown stateful code environment allowlists', () => { + expect( + agentsEndpointSchema.safeParse({ + statefulCodeSessions: { allowedEnvironments: [] }, + }).success, + ).toBe(false); + expect( + agentsEndpointSchema.safeParse({ + statefulCodeSessions: { allowedEnvironments: ['agent'] }, + }).success, + ).toBe(false); + }); + it('does not accept baseURL', () => { const result = agentsEndpointSchema.safeParse({ baseURL: 'https://example.com', diff --git a/packages/data-provider/specs/stateful-code.spec.ts b/packages/data-provider/specs/stateful-code.spec.ts new file mode 100644 index 0000000000..866a853ea7 --- /dev/null +++ b/packages/data-provider/specs/stateful-code.spec.ts @@ -0,0 +1,24 @@ +import { + STATEFUL_CODE_ENVIRONMENTS, + resolveStatefulCodeEnvironment, + resolveAllowedStatefulCodeEnvironments, +} from '../src/types/assistants'; + +describe('stateful code environment policy', () => { + it('allows every environment when deployment configuration is omitted', () => { + expect(resolveAllowedStatefulCodeEnvironments()).toEqual(STATEFUL_CODE_ENVIRONMENTS); + }); + + it('returns configured environments in stable UI order', () => { + expect(resolveAllowedStatefulCodeEnvironments(['conversation', 'user'])).toEqual([ + 'user', + 'conversation', + ]); + }); + + it('falls back to the first allowed environment when a preference is unavailable', () => { + expect(resolveStatefulCodeEnvironment('user', ['agent-user', 'conversation'])).toBe( + 'agent-user', + ); + }); +}); diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 66a589e415..96a23396a3 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -9,6 +9,7 @@ import { eReasoningResponseKeySchema, } from './schemas'; import { ComponentTypes, SettingTypes, OptionTypes } from './generate'; +import { STATEFUL_CODE_ENVIRONMENTS } from './stateful-code'; import { specsConfigSchema, TSpecsConfig } from './models'; import { REFILL_INTERVAL_UNITS } from './balance'; import { fileConfigSchema } from './file-config'; @@ -1004,6 +1005,13 @@ export const agentsEndpointSchema = baseEndpointSchema .array(z.nativeEnum(AgentCapabilities)) .optional() .default(defaultAgentCapabilities), + /** Controls which workspace-sharing scopes users may select for stateful code sessions. + * Omit this block to preserve the legacy behavior of allowing every scope. */ + statefulCodeSessions: z + .object({ + allowedEnvironments: z.array(z.enum(STATEFUL_CODE_ENVIRONMENTS)).min(1), + }) + .optional(), skills: z .object({ maxCatalogSkills: z.number().int().min(1).max(100).optional(), @@ -2722,6 +2730,10 @@ export enum ErrorTypes { * Required CodeAPI resources could not be restored before model invocation. */ RESOURCE_RECOVERY_REQUIRED = 'resource_recovery_required', + /** + * Agent selected a stateful Code API workspace scope disabled by the deployment. + */ + STATEFUL_CODE_ENVIRONMENT_NOT_ALLOWED = 'stateful_code_environment_not_allowed', /** * Invalid Agent Provider (excluded by Admin) */ diff --git a/packages/data-provider/src/stateful-code.ts b/packages/data-provider/src/stateful-code.ts new file mode 100644 index 0000000000..1288ba0171 --- /dev/null +++ b/packages/data-provider/src/stateful-code.ts @@ -0,0 +1,24 @@ +export const STATEFUL_CODE_ENVIRONMENTS = ['user', 'agent-user', 'conversation'] as const; +export type StatefulCodeEnvironment = (typeof STATEFUL_CODE_ENVIRONMENTS)[number]; + +/** Resolve a deployment allowlist in stable UI order. An omitted value preserves + * the backward-compatible behavior where every environment is available. */ +export function resolveAllowedStatefulCodeEnvironments( + configured?: readonly StatefulCodeEnvironment[] | null, +): StatefulCodeEnvironment[] { + if (configured == null) { + return [...STATEFUL_CODE_ENVIRONMENTS]; + } + + const configuredSet = new Set(configured); + return STATEFUL_CODE_ENVIRONMENTS.filter((environment) => configuredSet.has(environment)); +} + +/** Keep an allowed preference, otherwise select the first deployment-allowed scope. */ +export function resolveStatefulCodeEnvironment( + preferred: StatefulCodeEnvironment | null | undefined, + configured?: readonly StatefulCodeEnvironment[] | null, +): StatefulCodeEnvironment | undefined { + const allowed = resolveAllowedStatefulCodeEnvironments(configured); + return preferred != null && allowed.includes(preferred) ? preferred : allowed[0]; +} diff --git a/packages/data-provider/src/types.ts b/packages/data-provider/src/types.ts index 5f3d81ffd9..6e67a03517 100644 --- a/packages/data-provider/src/types.ts +++ b/packages/data-provider/src/types.ts @@ -544,6 +544,9 @@ export type TConfig = { disableBuilder?: boolean; retrievalModels?: string[]; capabilities?: string[]; + statefulCodeSessions?: { + allowedEnvironments: StatefulCodeEnvironment[]; + }; customParams?: { defaultParamsEndpoint?: string; reasoningFormat?: ReasoningParameterFormat; diff --git a/packages/data-provider/src/types/assistants.ts b/packages/data-provider/src/types/assistants.ts index fadda72394..c6da929e30 100644 --- a/packages/data-provider/src/types/assistants.ts +++ b/packages/data-provider/src/types/assistants.ts @@ -1,9 +1,16 @@ import type { OpenAPIV3 } from 'openapi-types'; import type { AssistantsEndpoint, AgentProvider, MemoryScope } from 'src/schemas'; +import type { StatefulCodeEnvironment } from '../stateful-code'; import type { Agents, GraphEdge } from './agents'; import type { ContentTypes } from './runs'; import type { TFile } from './files'; import { ArtifactModes } from 'src/artifacts'; +export { + STATEFUL_CODE_ENVIRONMENTS, + resolveStatefulCodeEnvironment, + resolveAllowedStatefulCodeEnvironments, +} from '../stateful-code'; +export type { StatefulCodeEnvironment } from '../stateful-code'; export type Schema = OpenAPIV3.SchemaObject & { description?: string }; export type Reference = OpenAPIV3.ReferenceObject & { description?: string }; @@ -538,9 +545,6 @@ export enum AnnotationTypes { FILE_PATH = 'file_path', } -export const STATEFUL_CODE_ENVIRONMENTS = ['user', 'agent-user', 'conversation'] as const; -export type StatefulCodeEnvironment = (typeof STATEFUL_CODE_ENVIRONMENTS)[number]; - export enum StepStatus { IN_PROGRESS = 'in_progress', CANCELLED = 'cancelled',