🛡️ feat: Let Admins Restrict Stateful Workspace Scopes (#14910)

* feat: let admins restrict stateful workspace scopes

* fix: enforce stateful scope policy across agent paths

* fix: close stateful scope policy activation gaps
This commit is contained in:
Danny Avila 2026-08-17 01:29:19 -04:00 committed by GitHub
parent 6eb2249620
commit 57ea1137f6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 805 additions and 81 deletions

View file

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

View file

@ -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) => {

View file

@ -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) =>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<StatefulCodeEnvironment>(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() {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">{localize('com_ui_stateful_code_environment_user')}</SelectItem>
<SelectItem value="agent-user">
{localize('com_ui_stateful_code_environment_agent_user')}
</SelectItem>
<SelectItem value="conversation">
{localize('com_ui_stateful_code_environment_conversation')}
</SelectItem>
{STATEFUL_CODE_ENVIRONMENTS.filter(
(candidate) =>
allowedEnvironments.includes(candidate) || candidate === savedEnvironment,
).map((candidate) => (
<SelectItem
key={candidate}
value={candidate}
disabled={!allowedEnvironments.includes(candidate)}
>
{localize(ENVIRONMENT_LABELS[candidate])}
</SelectItem>
))}
</SelectContent>
</Select>
</div>

View file

@ -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 }) => (
<option value={value}>{children}</option>
SelectItem: ({
value,
children,
disabled,
}: {
value: string;
children: ReactNode;
disabled?: boolean;
}) => (
<option value={value} disabled={disabled}>
{children}
</option>
),
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(<StatefulWorkspaceDefault />);
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();
});
});

View file

@ -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<AgentForm>();
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() {
</label>
<Select
value={environment}
onValueChange={(value) =>
setValue('stateful_code_environment', value as StatefulCodeEnvironment, {
shouldDirty: true,
})
}
onValueChange={(value) => {
const nextEnvironment = value as StatefulCodeEnvironment;
if (allowedEnvironments.includes(nextEnvironment)) {
setValue('stateful_code_environment', nextEnvironment, { shouldDirty: true });
}
}}
>
<SelectTrigger id="stateful-code-environment" data-testid="stateful-code-environment">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user">
{localize('com_ui_stateful_code_environment_user')}
</SelectItem>
<SelectItem value="agent-user">
{localize('com_ui_stateful_code_environment_agent_user')}
</SelectItem>
<SelectItem value="conversation">
{localize('com_ui_stateful_code_environment_conversation')}
</SelectItem>
{STATEFUL_CODE_ENVIRONMENTS.filter(
(candidate) => allowedEnvironments.includes(candidate) || candidate === environment,
).map((candidate) => (
<SelectItem
key={candidate}
value={candidate}
disabled={!allowedEnvironments.includes(candidate)}
>
{localize(ENVIRONMENT_LABELS[candidate])}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-text-tertiary">

View file

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

View file

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

View file

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

View file

@ -110,6 +110,7 @@ jest.mock('../resources', () => ({
}));
import { initializeAgent } from '../initialize';
import { isFatalAgentInitializationError } from '../errors';
const realUtils = jest.requireActual<typeof import('~/utils')>('~/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<typeof req.config>;
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'];

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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({

View file

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

View file

@ -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<typeof request.config>;
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 });

View file

@ -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<unknown, unknown, UserPreferencesBody> & {
type UserPreferencesRequest = Omit<ServerRequest, 'body' | 'user'> & {
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) {

View file

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

View file

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

View file

@ -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)
*/

View file

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

View file

@ -544,6 +544,9 @@ export type TConfig = {
disableBuilder?: boolean;
retrievalModels?: string[];
capabilities?: string[];
statefulCodeSessions?: {
allowedEnvironments: StatefulCodeEnvironment[];
};
customParams?: {
defaultParamsEndpoint?: string;
reasoningFormat?: ReasoningParameterFormat;

View file

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