Skip to content

Commit c1bd63a

Browse files
authored
Merge branch 'dev' into fix/modelspec-modellabel-context
2 parents d4d12b7 + 1d45f5b commit c1bd63a

37 files changed

Lines changed: 1326 additions & 31 deletions

api/server/controllers/agents/v1.js

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,9 @@ const {
3838
isContentTraversalLimitError,
3939
resolveCanonicalFileReferences,
4040
reportLocatorTraversalFailure,
41+
reconcileAgentWorkspaceDefault,
42+
shouldValidateAgentWorkspaceDefaultBinding,
43+
validateAgentWorkspaceDefaultBinding,
4144
} = require('@librechat/api');
4245
const {
4346
Time,
@@ -480,13 +483,27 @@ const validateStatefulCodeEnvironment = (
480483
environment,
481484
environmentId,
482485
environmentIdSelected = false,
486+
workspaceId,
487+
currentWorkspaceId,
488+
currentEnvironmentId,
483489
) => {
490+
const configuredEnvironments =
491+
req.config?.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.environments ?? [];
492+
const workspaceValidation = validateAgentWorkspaceDefaultBinding({
493+
workspaceId,
494+
environmentId,
495+
currentWorkspaceId,
496+
currentEnvironmentId,
497+
environments: configuredEnvironments,
498+
});
499+
if (!workspaceValidation.valid) {
500+
res.status(400).json({ error: workspaceValidation.error });
501+
return false;
502+
}
484503
if (enabled !== true && !environmentIdSelected) {
485504
return true;
486505
}
487506
if (environmentId != null) {
488-
const configuredEnvironments =
489-
req.config?.endpoints?.[EModelEndpoint.agents]?.statefulCodeSessions?.environments ?? [];
490507
const configuredEnvironment = configuredEnvironments.find(
491508
(configured) => configured.id === environmentId,
492509
);
@@ -780,6 +797,7 @@ const createAgentHandler = async (req, res) => {
780797
agentData.stateful_code_environment,
781798
agentData.code_environment_id,
782799
agentData.code_environment_id != null,
800+
agentData.code_workspace_id,
783801
)
784802
) {
785803
return;
@@ -1066,7 +1084,7 @@ const updateAgentHandler = async (req, res) => {
10661084
_id,
10671085
...rest
10681086
} = validatedData;
1069-
const updateData = removeNullishValues(rest);
1087+
let updateData = removeNullishValues(rest);
10701088
if (codeEnvironmentIdField !== undefined) {
10711089
updateData.code_environment_id = codeEnvironmentIdField;
10721090
}
@@ -1079,10 +1097,12 @@ const updateAgentHandler = async (req, res) => {
10791097
updateData.stateful_code_sessions !== undefined ||
10801098
updateData.stateful_code_environment !== undefined ||
10811099
updateData.code_environment_id !== undefined;
1100+
const includesWorkspaceConfiguration = updateData.code_workspace_id !== undefined;
10821101
const includesToolsConfiguration = Array.isArray(updateData.tools);
10831102
const includesToolOptionsConfiguration = updateData.tool_options !== undefined;
10841103
if (
10851104
includesStatefulConfiguration ||
1105+
includesWorkspaceConfiguration ||
10861106
includesToolsConfiguration ||
10871107
includesToolOptionsConfiguration
10881108
) {
@@ -1094,6 +1114,11 @@ const updateAgentHandler = async (req, res) => {
10941114
const codeEnvironmentSelectionChanged =
10951115
updateData.code_environment_id !== undefined &&
10961116
updateData.code_environment_id !== existingAgent.code_environment_id;
1117+
updateData = reconcileAgentWorkspaceDefault({
1118+
update: updateData,
1119+
request: validatedData,
1120+
currentEnvironmentId: existingAgent.code_environment_id,
1121+
});
10971122
const statefulConfigurationChanged =
10981123
(updateData.stateful_code_sessions !== undefined &&
10991124
(updateData.stateful_code_sessions === true) !==
@@ -1106,7 +1131,20 @@ const updateAgentHandler = async (req, res) => {
11061131
includesToolsConfiguration &&
11071132
updateData.tools.includes(Tools.execute_code) &&
11081133
existingAgent.tools?.includes(Tools.execute_code) !== true;
1109-
if (statefulConfigurationChanged || activatesCodeExecution) {
1134+
const effectiveCodeWorkspaceId =
1135+
updateData.code_workspace_id ?? existingAgent.code_workspace_id;
1136+
const selectsWorkspaceDefault =
1137+
includesWorkspaceConfiguration &&
1138+
shouldValidateAgentWorkspaceDefaultBinding({
1139+
workspaceId: effectiveCodeWorkspaceId,
1140+
environmentId:
1141+
updateData.code_environment_id === null
1142+
? undefined
1143+
: (updateData.code_environment_id ?? existingAgent.code_environment_id),
1144+
currentWorkspaceId: existingAgent.code_workspace_id,
1145+
currentEnvironmentId: existingAgent.code_environment_id,
1146+
});
1147+
if (statefulConfigurationChanged || selectsWorkspaceDefault || activatesCodeExecution) {
11101148
const effectiveStatefulSessions =
11111149
updateData.stateful_code_sessions ?? existingAgent.stateful_code_sessions;
11121150
const effectiveStatefulEnvironment =
@@ -1123,6 +1161,9 @@ const updateAgentHandler = async (req, res) => {
11231161
effectiveStatefulEnvironment,
11241162
effectiveCodeEnvironmentId,
11251163
codeEnvironmentSelectionChanged,
1164+
effectiveCodeWorkspaceId,
1165+
existingAgent.code_workspace_id,
1166+
existingAgent.code_environment_id,
11261167
)
11271168
) {
11281169
return;
@@ -1428,6 +1469,8 @@ const duplicateAgentHandler = async (req, res) => {
14281469
newAgentData.stateful_code_sessions,
14291470
newAgentData.stateful_code_environment,
14301471
newAgentData.code_environment_id,
1472+
false,
1473+
newAgentData.code_workspace_id,
14311474
)
14321475
) {
14331476
return;
@@ -1829,6 +1872,7 @@ const getListAgentsHandler = async (req, res) => {
18291872
limit,
18301873
after: cursor,
18311874
includeSkillConfig: true,
1875+
includeExecutionConfig: true,
18321876
});
18331877

18341878
const agents = data?.data ?? [];
@@ -2034,6 +2078,8 @@ const revertAgentVersionHandler = async (req, res) => {
20342078
revertVersion.stateful_code_sessions,
20352079
revertVersion.stateful_code_environment,
20362080
revertVersion.code_environment_id,
2081+
false,
2082+
revertVersion.code_workspace_id,
20372083
)
20382084
) {
20392085
return;

api/server/controllers/agents/v1.spec.js

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,76 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
316316
expect(await Agent.countDocuments()).toBe(0);
317317
});
318318

319+
test('rejects a workspace default without an explicit attached environment', async () => {
320+
mockReq.config = {
321+
endpoints: {
322+
agents: {
323+
statefulCodeSessions: {
324+
allowedEnvironments: ['user'],
325+
environments: [
326+
{
327+
id: 'default-vm',
328+
name: 'Default VM',
329+
type: 'attached',
330+
baseURL: 'https://code.example.com/v1',
331+
default: true,
332+
},
333+
],
334+
},
335+
},
336+
},
337+
};
338+
mockReq.body = {
339+
name: 'Unbound Workspace Agent',
340+
provider: 'openai',
341+
model: 'gpt-4',
342+
code_workspace_id: 'project-a',
343+
};
344+
345+
await createAgentHandler(mockReq, mockRes);
346+
347+
expect(mockRes.status).toHaveBeenCalledWith(400);
348+
expect(mockRes.json).toHaveBeenCalledWith({
349+
error: 'Code workspace defaults require an explicit attached code environment',
350+
});
351+
expect(await Agent.countDocuments()).toBe(0);
352+
});
353+
354+
test('rejects a workspace default for a managed environment', async () => {
355+
mockReq.config = {
356+
endpoints: {
357+
agents: {
358+
statefulCodeSessions: {
359+
allowedEnvironments: ['user'],
360+
environments: [
361+
{
362+
id: 'managed-runtime',
363+
name: 'Managed Runtime',
364+
type: 'managed',
365+
baseURL: 'https://code.example.com/v1',
366+
},
367+
],
368+
},
369+
},
370+
},
371+
};
372+
mockReq.body = {
373+
name: 'Managed Workspace Agent',
374+
provider: 'openai',
375+
model: 'gpt-4',
376+
code_environment_id: 'managed-runtime',
377+
code_workspace_id: 'project-a',
378+
};
379+
380+
await createAgentHandler(mockReq, mockRes);
381+
382+
expect(mockRes.status).toHaveBeenCalledWith(400);
383+
expect(mockRes.json).toHaveBeenCalledWith({
384+
error: 'Code workspace defaults require an explicit attached code environment',
385+
});
386+
expect(await Agent.countDocuments()).toBe(0);
387+
});
388+
319389
test('should block configured agent instruction content before persistence', async () => {
320390
mockReq.config = {
321391
filters: {
@@ -1605,12 +1675,74 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
16051675
expect(agentInDb.code_environment_id).toBeUndefined();
16061676
});
16071677

1678+
test('allows a workspace-only update for an existing attached environment', async () => {
1679+
await Agent.updateOne({ id: existingAgentId }, { code_environment_id: 'attached-vm' });
1680+
mockReq.user.id = existingAgentAuthorId.toString();
1681+
mockReq.params.id = existingAgentId;
1682+
mockReq.config = {
1683+
endpoints: {
1684+
agents: {
1685+
statefulCodeSessions: {
1686+
allowedEnvironments: ['user'],
1687+
environments: [
1688+
{
1689+
id: 'attached-vm',
1690+
name: 'Attached VM',
1691+
type: 'attached',
1692+
baseURL: 'https://bridge.example.com/v1',
1693+
},
1694+
],
1695+
},
1696+
},
1697+
},
1698+
};
1699+
mockReq.body = { code_workspace_id: 'project-a' };
1700+
1701+
await updateAgentHandler(mockReq, mockRes);
1702+
1703+
expect(mockRes.status).not.toHaveBeenCalledWith(400);
1704+
const agentInDb = await Agent.findOne({ id: existingAgentId });
1705+
expect(agentInDb.code_environment_id).toBe('attached-vm');
1706+
expect(agentInDb.code_workspace_id).toBe('project-a');
1707+
});
1708+
1709+
test('rejects a workspace-only update without an attached environment', async () => {
1710+
mockReq.user.id = existingAgentAuthorId.toString();
1711+
mockReq.params.id = existingAgentId;
1712+
mockReq.config = {
1713+
endpoints: {
1714+
agents: {
1715+
statefulCodeSessions: {
1716+
allowedEnvironments: ['user'],
1717+
environments: [
1718+
{
1719+
id: 'default-vm',
1720+
name: 'Default VM',
1721+
type: 'attached',
1722+
baseURL: 'https://bridge.example.com/v1',
1723+
default: true,
1724+
},
1725+
],
1726+
},
1727+
},
1728+
},
1729+
};
1730+
mockReq.body = { code_workspace_id: 'project-a' };
1731+
1732+
await updateAgentHandler(mockReq, mockRes);
1733+
1734+
expect(mockRes.status).toHaveBeenCalledWith(400);
1735+
const agentInDb = await Agent.findOne({ id: existingAgentId });
1736+
expect(agentInDb.code_workspace_id).toBeUndefined();
1737+
});
1738+
16081739
test('allows disabling stateful sessions after the configured environment is removed', async () => {
16091740
await Agent.updateOne(
16101741
{ id: existingAgentId },
16111742
{
16121743
stateful_code_sessions: true,
16131744
code_environment_id: 'removed-vm',
1745+
code_workspace_id: 'project-a',
16141746
},
16151747
);
16161748
mockReq.user.id = existingAgentAuthorId.toString();
@@ -1628,6 +1760,7 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
16281760
mockReq.body = {
16291761
stateful_code_sessions: false,
16301762
code_environment_id: 'removed-vm',
1763+
code_workspace_id: 'project-a',
16311764
};
16321765

16331766
await updateAgentHandler(mockReq, mockRes);
@@ -1636,6 +1769,7 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
16361769
const agentInDb = await Agent.findOne({ id: existingAgentId });
16371770
expect(agentInDb.stateful_code_sessions).toBe(false);
16381771
expect(agentInDb.code_environment_id).toBe('removed-vm');
1772+
expect(agentInDb.code_workspace_id).toBe('project-a');
16391773
});
16401774

16411775
test('restores the deployment-default code environment', async () => {
@@ -1644,6 +1778,7 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
16441778
{
16451779
stateful_code_sessions: true,
16461780
code_environment_id: 'attached-vm',
1781+
code_workspace_id: 'project-a',
16471782
},
16481783
);
16491784
mockReq.user.id = existingAgentAuthorId.toString();
@@ -1673,6 +1808,7 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
16731808
expect(mockRes.status).not.toHaveBeenCalledWith(400);
16741809
const agentInDb = await Agent.findOne({ id: existingAgentId });
16751810
expect(agentInDb.code_environment_id).toBeUndefined();
1811+
expect(agentInDb.code_workspace_id).toBe('');
16761812
});
16771813

16781814
test('clears a configured Git identity', async () => {
@@ -3332,16 +3468,20 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
33323468
expect(Object.keys(agent).sort()).toEqual(
33333469
[
33343470
'_id',
3471+
'agent_ids',
33353472
'author',
33363473
'avatar',
33373474
'category',
33383475
'conversation_starters',
33393476
'description',
3477+
'edges',
33403478
'id',
33413479
'isEditable',
33423480
'is_promoted',
33433481
'name',
3482+
'subagents',
33443483
'support_contact',
3484+
'tools',
33453485
'updatedAt',
33463486
].sort(),
33473487
);
@@ -3353,6 +3493,9 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
33533493
author: userA.toString(),
33543494
category: 'general',
33553495
is_promoted: true,
3496+
tools: ['execute_code'],
3497+
edges: [{ from: agentA1.id, to: agentA2.id }],
3498+
subagents: { enabled: true, agent_ids: [agentA2.id] },
33563499
}),
33573500
);
33583501
});

client/src/common/agents-types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ export type AgentForm = {
5252
stateful_code_environment?: StatefulCodeEnvironment;
5353
/** Operator-configured managed or attached execution environment. */
5454
code_environment_id?: string | null;
55+
code_workspace_id?: string;
5556
/** Git authorship applied to sandboxed commands for this agent. */
5657
git_identity?: Agent['git_identity'];
5758
provider?: AgentProvider | OptionWithIcon;

client/src/components/Chat/Input/CodeWorkspaceMenu.tsx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,22 +31,30 @@ export default function CodeWorkspaceMenu({
3131
const menuStore = Ariakit.useMenuStore({ focusLoop: true, placement: 'top-start' });
3232
const isOpen = menuStore.useState('open');
3333

34-
/** A single advertised root per reachable environment is an unambiguous
35-
* initial choice. Once a conversation owns any binding, even a partial or
36-
* stale set, only explicit user actions may replace or complete it. */
34+
/** Pin resolved defaults to this chat; later preference changes cannot move it. */
3735
useEffect(() => {
3836
if (conversation?.codeWorkspaces != null || workspace.selections == null) return;
3937
setConversation((current) =>
40-
current == null || current.codeWorkspaces != null
38+
current == null ||
39+
current.codeWorkspaces != null ||
40+
current.conversationId !== conversation?.conversationId ||
41+
current.agent_id !== conversation?.agent_id
4142
? current
4243
: { ...current, codeWorkspaces: workspace.selections },
4344
);
44-
}, [conversation?.codeWorkspaces, setConversation, workspace.selections]);
45+
}, [
46+
conversation?.codeWorkspaces,
47+
conversation?.conversationId,
48+
conversation?.agent_id,
49+
setConversation,
50+
workspace.selections,
51+
]);
4552

4653
if (!workspace.required) return null;
4754

4855
const environmentIds = new Set(workspace.environments.map(({ environment }) => environment.id));
4956
const selectWorkspace = (selection: CodeWorkspaceSelection) => {
57+
workspace.rememberSelection(selection);
5058
setConversation((current) => {
5159
if (current == null) return current;
5260
const retained = (current.codeWorkspaces ?? []).filter(

0 commit comments

Comments
 (0)