mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-30 14:53:13 +00:00
🚦 fix: Restrict Programmatic Tool Execution Maps (#15105)
* fix: restrict programmatic tool execution maps * chore: bump `@librechat/agents` to v3.6.10 * fix: honor live programmatic caller projections * style: sort caller capability imports * test: expect caller projection loader argument * chore: bump agents sdk to v3.6.11 * refactor: use SDK caller projection type * style: sort agent handler imports
This commit is contained in:
parent
d3e70159ca
commit
89494d45fd
15 changed files with 456 additions and 37 deletions
|
|
@ -701,7 +701,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
|
|||
agent never gains sandbox access even if the admin enabled the
|
||||
capability globally. */
|
||||
const toolExecuteOptions = {
|
||||
loadTools: async (toolNames, agentId) => {
|
||||
loadTools: async (toolNames, agentId, _configurable, callerCapabilityProjection) => {
|
||||
const ctx = agentToolContexts.get(agentId) ?? agentToolContexts.get(primaryConfig.id) ?? {};
|
||||
const result = await loadToolsForExecution({
|
||||
req,
|
||||
|
|
@ -713,6 +713,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
|
|||
agent: ctx.agent ?? agent,
|
||||
signal: abortController.signal,
|
||||
toolRegistry: ctx.toolRegistry,
|
||||
callerCapabilityProjection,
|
||||
backgroundToolNames: ctx.backgroundToolNames,
|
||||
intentToolNames: ctx.intentToolNames,
|
||||
mcpAvailableTools: ctx.mcpAvailableTools,
|
||||
|
|
|
|||
|
|
@ -1016,7 +1016,7 @@ const executeResponse = async (envelope, { req, res }) => {
|
|||
|
||||
// Create tool execute options for event-driven tool execution
|
||||
const toolExecuteOptions = {
|
||||
loadTools: async (toolNames, agentId) => {
|
||||
loadTools: async (toolNames, agentId, _configurable, callerCapabilityProjection) => {
|
||||
const ctx =
|
||||
agentToolContexts.get(agentId) ?? agentToolContexts.get(primaryConfig.id) ?? {};
|
||||
const result = await loadToolsForExecution({
|
||||
|
|
@ -1029,6 +1029,7 @@ const executeResponse = async (envelope, { req, res }) => {
|
|||
agent: ctx.agent ?? agent,
|
||||
signal: abortController.signal,
|
||||
toolRegistry: ctx.toolRegistry,
|
||||
callerCapabilityProjection,
|
||||
backgroundToolNames: ctx.backgroundToolNames,
|
||||
intentToolNames: ctx.intentToolNames,
|
||||
mcpAvailableTools: ctx.mcpAvailableTools,
|
||||
|
|
@ -1206,7 +1207,7 @@ const executeResponse = async (envelope, { req, res }) => {
|
|||
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises, streamId: null });
|
||||
|
||||
const toolExecuteOptions = {
|
||||
loadTools: async (toolNames, agentId) => {
|
||||
loadTools: async (toolNames, agentId, _configurable, callerCapabilityProjection) => {
|
||||
const ctx =
|
||||
agentToolContexts.get(agentId) ?? agentToolContexts.get(primaryConfig.id) ?? {};
|
||||
const result = await loadToolsForExecution({
|
||||
|
|
@ -1219,6 +1220,7 @@ const executeResponse = async (envelope, { req, res }) => {
|
|||
agent: ctx.agent ?? agent,
|
||||
signal: abortController.signal,
|
||||
toolRegistry: ctx.toolRegistry,
|
||||
callerCapabilityProjection,
|
||||
backgroundToolNames: ctx.backgroundToolNames,
|
||||
intentToolNames: ctx.intentToolNames,
|
||||
mcpAvailableTools: ctx.mcpAvailableTools,
|
||||
|
|
|
|||
|
|
@ -329,7 +329,7 @@ const initializeClient = async ({
|
|||
const endpointTokenConfigByAgentId = new Map();
|
||||
|
||||
const toolExecuteOptions = {
|
||||
loadTools: async (toolNames, agentId) => {
|
||||
loadTools: async (toolNames, agentId, _configurable, callerCapabilityProjection) => {
|
||||
const ctx = agentToolContexts.get(agentId) ?? {};
|
||||
logger.debug(`[ON_TOOL_EXECUTE] ctx found: ${!!ctx.userMCPAuthMap}, agent: ${ctx.agent?.id}`);
|
||||
logger.debug(`[ON_TOOL_EXECUTE] toolRegistry size: ${ctx.toolRegistry?.size ?? 'undefined'}`);
|
||||
|
|
@ -344,6 +344,7 @@ const initializeClient = async ({
|
|||
toolNames,
|
||||
agent: ctx.agent,
|
||||
toolRegistry: ctx.toolRegistry,
|
||||
callerCapabilityProjection,
|
||||
backgroundToolNames: ctx.backgroundToolNames,
|
||||
intentToolNames: ctx.intentToolNames,
|
||||
mcpAvailableTools: ctx.mcpAvailableTools,
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ const {
|
|||
AGENT_EXPECTED_MCP_TOOLS_UNAVAILABLE,
|
||||
isFatalAgentInitializationError,
|
||||
resolveCodeExecutionContext,
|
||||
resolveCallerCapabilityProjectionSnapshot,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
Time,
|
||||
|
|
@ -1902,6 +1903,7 @@ async function loadAgentTools({
|
|||
* @param {string} [params.agentResourceType] - Permission resource type for the authorized agent route
|
||||
* @param {string[]} params.toolNames - Names of tools to load
|
||||
* @param {Map} [params.toolRegistry] - Tool registry
|
||||
* @param {unknown} [params.callerCapabilityProjection] - SDK-owned live caller projection
|
||||
* @param {Record<string, import('@librechat/api').LCAvailableTools>} [params.mcpAvailableTools] - Run-scoped MCP tool definitions
|
||||
* @param {import('@librechat/api').RequestScopedMCPConnectionStore} [params.requestScopedConnections] - Run-scoped MCP connections
|
||||
* @param {Record<string, Record<string, string>>} [params.userMCPAuthMap] - User MCP auth map
|
||||
|
|
@ -1922,6 +1924,7 @@ async function loadToolsForExecution({
|
|||
agentResourceType,
|
||||
toolNames,
|
||||
toolRegistry,
|
||||
callerCapabilityProjection,
|
||||
backgroundToolNames,
|
||||
intentToolNames,
|
||||
mcpAvailableTools,
|
||||
|
|
@ -1943,6 +1946,12 @@ async function loadToolsForExecution({
|
|||
requestBody: runtimeRequestBody,
|
||||
requestScopedConnections: mcpRequestScopedConnections,
|
||||
};
|
||||
const activeCallerCapabilities = resolveCallerCapabilityProjectionSnapshot(
|
||||
callerCapabilityProjection,
|
||||
);
|
||||
const activeCodeExecutionToolNames = activeCallerCapabilities
|
||||
? new Set(activeCallerCapabilities.codeExecutionToolNames)
|
||||
: undefined;
|
||||
/** Per-agent set of tools that received the injected `run_in_background`
|
||||
* param; the event-driven executor gates background dispatch and the
|
||||
* `check_background_task` poll tool on this reliable per-agent channel. */
|
||||
|
|
@ -2104,9 +2113,14 @@ async function loadToolsForExecution({
|
|||
|
||||
let ptcOrchestratedToolNames = [];
|
||||
if (isPTC && toolRegistry) {
|
||||
ptcOrchestratedToolNames = Array.from(toolRegistry.keys()).filter(
|
||||
(name) => !specialToolNames.has(name),
|
||||
);
|
||||
ptcOrchestratedToolNames = Array.from(toolRegistry.values())
|
||||
.filter(
|
||||
(toolDef) =>
|
||||
!specialToolNames.has(toolDef.name) &&
|
||||
(toolDef.allowed_callers ?? ['direct']).includes('code_execution') &&
|
||||
(activeCodeExecutionToolNames == null || activeCodeExecutionToolNames.has(toolDef.name)),
|
||||
)
|
||||
.map((toolDef) => toolDef.name);
|
||||
}
|
||||
|
||||
const requestedNonSpecialToolNames = toolNames.filter((name) => !specialToolNames.has(name));
|
||||
|
|
@ -2199,7 +2213,9 @@ async function loadToolsForExecution({
|
|||
if (
|
||||
tool.name &&
|
||||
tool.name !== AgentConstants.PROGRAMMATIC_TOOL_CALLING &&
|
||||
tool.name !== AgentConstants.BASH_PROGRAMMATIC_TOOL_CALLING
|
||||
tool.name !== AgentConstants.BASH_PROGRAMMATIC_TOOL_CALLING &&
|
||||
(toolRegistry.get(tool.name)?.allowed_callers ?? ['direct']).includes('code_execution') &&
|
||||
(activeCodeExecutionToolNames == null || activeCodeExecutionToolNames.has(tool.name))
|
||||
) {
|
||||
ptcToolMap.set(tool.name, tool);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2586,7 +2586,9 @@ describe('ToolService - Action Capability Gating', () => {
|
|||
},
|
||||
},
|
||||
};
|
||||
const toolRegistry = new Map([[mcpTool, { name: mcpTool }]]);
|
||||
const toolRegistry = new Map([
|
||||
[mcpTool, { name: mcpTool, allowed_callers: ['code_execution'] }],
|
||||
]);
|
||||
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
||||
|
||||
await loadToolsForExecution({
|
||||
|
|
@ -2609,6 +2611,169 @@ describe('ToolService - Action Capability Gating', () => {
|
|||
);
|
||||
});
|
||||
|
||||
it('loads only code_execution tools into the PTC execution map', async () => {
|
||||
const capabilities = [
|
||||
AgentCapabilities.tools,
|
||||
AgentCapabilities.programmatic_tools,
|
||||
AgentCapabilities.execute_code,
|
||||
];
|
||||
const req = createMockReq(capabilities);
|
||||
const programmaticTool = {
|
||||
name: 'programmatic_tool',
|
||||
invoke: jest.fn(),
|
||||
};
|
||||
const toolRegistry = new Map([
|
||||
[
|
||||
programmaticTool.name,
|
||||
{ name: programmaticTool.name, allowed_callers: ['code_execution'] },
|
||||
],
|
||||
['direct_tool', { name: 'direct_tool', allowed_callers: ['direct'] }],
|
||||
]);
|
||||
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
||||
mockLoadToolsUtil.mockResolvedValue({
|
||||
loadedTools: [programmaticTool],
|
||||
toolContextMap: {},
|
||||
});
|
||||
|
||||
const result = await loadToolsForExecution({
|
||||
req,
|
||||
res: {},
|
||||
agent: { id: 'agent_ptc', tools: [Tools.execute_code] },
|
||||
toolNames: [Constants.BASH_PROGRAMMATIC_TOOL_CALLING],
|
||||
toolRegistry,
|
||||
actionsEnabled: false,
|
||||
});
|
||||
|
||||
expect(mockLoadToolsUtil).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tools: ['programmatic_tool'] }),
|
||||
);
|
||||
expect([...result.configurable.ptcToolMap.keys()]).toEqual(['programmatic_tool']);
|
||||
});
|
||||
|
||||
it('intersects PTC loading with the SDK live caller projection', async () => {
|
||||
const capabilities = [
|
||||
AgentCapabilities.tools,
|
||||
AgentCapabilities.programmatic_tools,
|
||||
AgentCapabilities.execute_code,
|
||||
];
|
||||
const req = createMockReq(capabilities);
|
||||
const activeTool = { name: 'active_programmatic_tool', invoke: jest.fn() };
|
||||
const deferredTool = { name: 'deferred_programmatic_tool', invoke: jest.fn() };
|
||||
const toolRegistry = new Map([
|
||||
[activeTool.name, { name: activeTool.name, allowed_callers: ['code_execution'] }],
|
||||
[
|
||||
deferredTool.name,
|
||||
{
|
||||
name: deferredTool.name,
|
||||
allowed_callers: ['code_execution'],
|
||||
defer_loading: true,
|
||||
},
|
||||
],
|
||||
]);
|
||||
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
||||
mockLoadToolsUtil.mockResolvedValue({
|
||||
loadedTools: [activeTool],
|
||||
toolContextMap: {},
|
||||
});
|
||||
|
||||
const result = await loadToolsForExecution({
|
||||
req,
|
||||
res: {},
|
||||
agent: { id: 'agent_ptc', tools: [Tools.execute_code] },
|
||||
toolNames: [Constants.BASH_PROGRAMMATIC_TOOL_CALLING],
|
||||
toolRegistry,
|
||||
callerCapabilityProjection: {
|
||||
version: 1,
|
||||
directToolNames: [],
|
||||
codeExecutionToolNames: [activeTool.name],
|
||||
directOnlyToolNames: [],
|
||||
codeExecutionOnlyToolNames: [activeTool.name],
|
||||
},
|
||||
actionsEnabled: false,
|
||||
});
|
||||
|
||||
expect(mockLoadToolsUtil).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tools: [activeTool.name] }),
|
||||
);
|
||||
expect([...result.configurable.ptcToolMap.keys()]).toEqual([activeTool.name]);
|
||||
});
|
||||
|
||||
it('treats an empty versioned caller projection as authoritative', async () => {
|
||||
const capabilities = [
|
||||
AgentCapabilities.tools,
|
||||
AgentCapabilities.programmatic_tools,
|
||||
AgentCapabilities.execute_code,
|
||||
];
|
||||
const req = createMockReq(capabilities);
|
||||
const toolRegistry = new Map([
|
||||
[
|
||||
'deferred_programmatic_tool',
|
||||
{ name: 'deferred_programmatic_tool', allowed_callers: ['code_execution'] },
|
||||
],
|
||||
]);
|
||||
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
||||
|
||||
const result = await loadToolsForExecution({
|
||||
req,
|
||||
res: {},
|
||||
agent: { id: 'agent_ptc', tools: [Tools.execute_code] },
|
||||
toolNames: [Constants.BASH_PROGRAMMATIC_TOOL_CALLING],
|
||||
toolRegistry,
|
||||
callerCapabilityProjection: {
|
||||
version: 1,
|
||||
directToolNames: [],
|
||||
codeExecutionToolNames: [],
|
||||
directOnlyToolNames: [],
|
||||
codeExecutionOnlyToolNames: [],
|
||||
},
|
||||
actionsEnabled: false,
|
||||
});
|
||||
|
||||
expect(mockLoadToolsUtil).not.toHaveBeenCalled();
|
||||
expect(result.configurable.ptcToolMap).toEqual(new Map());
|
||||
});
|
||||
|
||||
it('falls back to registry projection for unknown snapshot versions', async () => {
|
||||
const capabilities = [
|
||||
AgentCapabilities.tools,
|
||||
AgentCapabilities.programmatic_tools,
|
||||
AgentCapabilities.execute_code,
|
||||
];
|
||||
const req = createMockReq(capabilities);
|
||||
const programmaticTool = { name: 'programmatic_tool', invoke: jest.fn() };
|
||||
const toolRegistry = new Map([
|
||||
[
|
||||
programmaticTool.name,
|
||||
{ name: programmaticTool.name, allowed_callers: ['code_execution'] },
|
||||
],
|
||||
]);
|
||||
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
||||
mockLoadToolsUtil.mockResolvedValue({
|
||||
loadedTools: [programmaticTool],
|
||||
toolContextMap: {},
|
||||
});
|
||||
|
||||
await loadToolsForExecution({
|
||||
req,
|
||||
res: {},
|
||||
agent: { id: 'agent_ptc', tools: [Tools.execute_code] },
|
||||
toolNames: [Constants.BASH_PROGRAMMATIC_TOOL_CALLING],
|
||||
toolRegistry,
|
||||
callerCapabilityProjection: {
|
||||
version: 2,
|
||||
directToolNames: [],
|
||||
codeExecutionToolNames: [],
|
||||
directOnlyToolNames: [],
|
||||
codeExecutionOnlyToolNames: [],
|
||||
},
|
||||
actionsEnabled: false,
|
||||
});
|
||||
|
||||
expect(mockLoadToolsUtil).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ tools: [programmaticTool.name] }),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not load PTC when programmatic tools capability is disabled', async () => {
|
||||
const capabilities = [AgentCapabilities.tools, AgentCapabilities.execute_code];
|
||||
const req = createMockReq(capabilities);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue