🛡️ 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

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