mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
🛡️ 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:
parent
6eb2249620
commit
57ea1137f6
30 changed files with 805 additions and 81 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) =>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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] },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
24
packages/data-provider/specs/stateful-code.spec.ts
Normal file
24
packages/data-provider/specs/stateful-code.spec.ts
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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)
|
||||
*/
|
||||
|
|
|
|||
24
packages/data-provider/src/stateful-code.ts
Normal file
24
packages/data-provider/src/stateful-code.ts
Normal 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];
|
||||
}
|
||||
|
|
@ -544,6 +544,9 @@ export type TConfig = {
|
|||
disableBuilder?: boolean;
|
||||
retrievalModels?: string[];
|
||||
capabilities?: string[];
|
||||
statefulCodeSessions?: {
|
||||
allowedEnvironments: StatefulCodeEnvironment[];
|
||||
};
|
||||
customParams?: {
|
||||
defaultParamsEndpoint?: string;
|
||||
reasoningFormat?: ReasoningParameterFormat;
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue