Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e9e435b
🀝 fix: load handoff sub-agents on OpenAI-compat endpoints (#12726)
danny-avila Apr 18, 2026
5cc74e3
πŸ§ͺ fix: use ServerRequest in discovery spec helpers
danny-avila Apr 18, 2026
7418dd8
πŸͺ² fix: drop orphan edges on both endpoints, not just `to`
danny-avila Apr 18, 2026
e491a23
πŸ”’ fix: enforce REMOTE_AGENT ACL on handoff sub-agents for API routes
danny-avila Apr 18, 2026
7ec1ba2
🧯 fix: enforce allowedProviders for discovered sub-agents
danny-avila Apr 18, 2026
4cbfffc
βœ‚οΈ fix: prune unreachable sub-agents after orphan-edge filtering
danny-avila Apr 18, 2026
e54440b
πŸ” fix: don't seed initialize.js agentConfigs from the pre-pruning cal…
danny-avila Apr 18, 2026
e000354
πŸ”¬ fix: address audit findings on discovery helper
danny-avila Apr 18, 2026
6450aa8
🧹 chore: address audit NITs on discovery helper
danny-avila Apr 20, 2026
4982f1c
πŸ•Έ fix: require all sources reachable when traversing fan-in edges
danny-avila Apr 20, 2026
6879f45
πŸ”€ fix: match SDK OR semantics for multi-source edge reachability
danny-avila Apr 20, 2026
222716d
βœ‚οΈ fix: strip skipped co-members from multi-source/multi-dest edges
danny-avila Apr 20, 2026
0de3684
πŸ”“ fix: respect SHARE-on-AGENT fallback for handoff ACL on API routes
danny-avila Apr 20, 2026
7cc7aed
🌱 fix: preserve user-defined parallel-start branches
danny-avila Apr 20, 2026
54de02c
🎯 fix: tighten parallel-start seed criterion to 'no pre-filter incomi…
danny-avila Apr 20, 2026
6ce3462
πŸ“ fix: don't enforce AGENT-only file ACL on REMOTE_AGENT API callers
danny-avila Apr 20, 2026
718b000
πŸͺ“ fix: strip unreachable co-sources from surviving multi-source edges
danny-avila Apr 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions api/server/controllers/agents/__tests__/openai.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,11 @@ jest.mock('@librechat/api', () => ({
.fn()
.mockReturnValue({ request: { model: 'agent-123', messages: [], stream: false } }),
initializeAgent: jest.fn().mockResolvedValue({
id: 'agent-123',
model: 'gpt-4',
model_parameters: {},
toolRegistry: {},
edges: [],
}),
getBalanceConfig: mockGetBalanceConfig,
createErrorResponse: jest.fn(),
Expand All @@ -72,6 +74,24 @@ jest.mock('@librechat/api', () => ({
resolveRecursionLimit: jest.fn().mockReturnValue(50),
createToolExecuteHandler: jest.fn().mockReturnValue({ handle: jest.fn() }),
isChatCompletionValidationFailure: jest.fn().mockReturnValue(false),
discoverConnectedAgents: jest.fn().mockResolvedValue({
agentConfigs: new Map(),
edges: [],
skippedAgentIds: new Set(),
userMCPAuthMap: undefined,
}),
}));

jest.mock('~/server/controllers/ModelController', () => ({
getModelsConfig: jest.fn().mockResolvedValue({}),
}));

jest.mock('~/server/services/Files/permissions', () => ({
filterFilesByAgentAccess: jest.fn(),
}));

jest.mock('~/cache', () => ({
logViolation: jest.fn(),
}));

jest.mock('~/server/services/ToolService', () => ({
Expand All @@ -91,6 +111,7 @@ jest.mock('~/server/controllers/agents/callbacks', () => ({

jest.mock('~/server/services/PermissionService', () => ({
findAccessibleResources: jest.fn().mockResolvedValue([]),
checkPermission: jest.fn().mockResolvedValue(true),
}));

const mockUpdateBalance = jest.fn().mockResolvedValue({});
Expand Down
21 changes: 21 additions & 0 deletions api/server/controllers/agents/__tests__/responses.unit.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,17 @@ jest.mock('@librechat/api', () => ({
buildToolSet: jest.fn().mockReturnValue(new Set()),
createSafeUser: jest.fn().mockReturnValue({ id: 'user-123' }),
initializeAgent: jest.fn().mockResolvedValue({
id: 'agent-123',
model: 'claude-3',
model_parameters: {},
toolRegistry: {},
edges: [],
}),
discoverConnectedAgents: jest.fn().mockResolvedValue({
agentConfigs: new Map(),
edges: [],
skippedAgentIds: new Set(),
userMCPAuthMap: undefined,
}),
getBalanceConfig: mockGetBalanceConfig,
getTransactionsConfig: mockGetTransactionsConfig,
Expand Down Expand Up @@ -121,6 +129,19 @@ jest.mock('~/server/controllers/agents/callbacks', () => {

jest.mock('~/server/services/PermissionService', () => ({
findAccessibleResources: jest.fn().mockResolvedValue([]),
checkPermission: jest.fn().mockResolvedValue(true),
}));

jest.mock('~/server/controllers/ModelController', () => ({
getModelsConfig: jest.fn().mockResolvedValue({}),
}));

jest.mock('~/server/services/Files/permissions', () => ({
filterFilesByAgentAccess: jest.fn(),
}));

jest.mock('~/cache', () => ({
logViolation: jest.fn(),
}));

const mockUpdateBalance = jest.fn().mockResolvedValue({});
Expand Down
111 changes: 92 additions & 19 deletions api/server/controllers/agents/openai.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const {
createOpenAIStreamTracker,
createOpenAIContentAggregator,
isChatCompletionValidationFailure,
discoverConnectedAgents,
} = require('@librechat/api');
const {
buildSummarizationHandlers,
Expand All @@ -29,7 +30,10 @@ const {
agentLogHandlerObj,
} = require('~/server/controllers/agents/callbacks');
const { loadAgentTools, loadToolsForExecution } = require('~/server/services/ToolService');
const { findAccessibleResources } = require('~/server/services/PermissionService');
const { findAccessibleResources, checkPermission } = require('~/server/services/PermissionService');
const { getModelsConfig } = require('~/server/controllers/ModelController');
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
const { logViolation } = require('~/cache');
const db = require('~/models');

/**
Expand Down Expand Up @@ -207,6 +211,19 @@ const OpenAIChatCompletionController = async (req, res) => {
model_parameters: agent.model_parameters ?? {},
};

const dbMethods = {
getConvoFiles: db.getConvoFiles,
getFiles: db.getFiles,
getUserKey: db.getUserKey,
getMessages: db.getMessages,
updateFilesUsage: db.updateFilesUsage,
getUserKeyValues: db.getUserKeyValues,
getUserCodeFiles: db.getUserCodeFiles,
getToolFilesByIds: db.getToolFilesByIds,
getCodeGeneratedFiles: db.getCodeGeneratedFiles,
filterFilesByAgentAccess,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove AGENT-only file filter from remote agent initialization

Passing filterFilesByAgentAccess into initializeAgent here makes remote API calls enforce ResourceType.AGENT on attached context files (api/server/services/Files/permissions.js uses resourceType: ResourceType.AGENT at the access check), even though this route authorizes callers with REMOTE_AGENT permissions via getRemoteAgentPermissions. A user who has REMOTE_AGENT_VIEWER but no AGENT_VIEW can invoke the shared agent, but all owner-attached context files get filtered out, so tools like file_search/context-backed retrieval silently stop working for remote consumers; the same regression is also introduced in responses.js with the identical dbMethods wiring.

Useful? React with πŸ‘Β / πŸ‘Ž.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right β€” this was a regression I introduced when I refactored the DB-method bundle into a shared dbMethods object. Fixed in 6ce3462: both API controllers now omit filterFilesByAgentAccess from dbMethods (and drop the now-unused import). The in-app chat initialize.js keeps it because that path legitimately authorizes at the AGENT level.

This restores the pre-refactor API behavior β€” primeResources sees filterFiles: undefined and skips the per-file ACL check, so REMOTE_AGENT_VIEWER-only callers get the owner-attached context files the route's getRemoteAgentPermissions has already decided they can see.

};

const primaryConfig = await initializeAgent(
{
req,
Expand All @@ -220,19 +237,71 @@ const OpenAIChatCompletionController = async (req, res) => {
allowedProviders,
isInitialAgent: true,
},
dbMethods,
);

/**
* Per-agent tool-execution context map, keyed by agentId.
* Needed so the ON_TOOL_EXECUTE callback routes each sub-agent's tool calls
* to the correct toolRegistry / userMCPAuthMap / tool_resources.
* @type {Map<string, {
* agent: object,
* toolRegistry?: import('@librechat/agents').LCToolRegistry,
* userMCPAuthMap?: Record<string, Record<string, string>>,
* tool_resources?: object,
* actionsEnabled?: boolean,
* }>}
*/
const agentToolContexts = new Map();
agentToolContexts.set(primaryConfig.id, {
agent,
toolRegistry: primaryConfig.toolRegistry,
userMCPAuthMap: primaryConfig.userMCPAuthMap,
tool_resources: primaryConfig.tool_resources,
actionsEnabled: primaryConfig.actionsEnabled,
});

const modelsConfig = await getModelsConfig(req);
const {
agentConfigs: handoffAgentConfigs,
edges: discoveredEdges,
userMCPAuthMap: discoveredMCPAuthMap,
} = await discoverConnectedAgents(
{
req,
res,
primaryConfig,
endpointOption,
allowedProviders,
modelsConfig,
loadTools,
requestFiles: [],
conversationId,
parentMessageId,
},
{
getConvoFiles: db.getConvoFiles,
getFiles: db.getFiles,
getUserKey: db.getUserKey,
getMessages: db.getMessages,
updateFilesUsage: db.updateFilesUsage,
getUserKeyValues: db.getUserKeyValues,
getUserCodeFiles: db.getUserCodeFiles,
getToolFilesByIds: db.getToolFilesByIds,
getCodeGeneratedFiles: db.getCodeGeneratedFiles,
getAgent: db.getAgent,
checkPermission,
logViolation,
db: dbMethods,
onAgentInitialized: (agentId, handoffAgent, config) => {
agentToolContexts.set(agentId, {
agent: handoffAgent,
toolRegistry: config.toolRegistry,
userMCPAuthMap: config.userMCPAuthMap,
tool_resources: config.tool_resources,
actionsEnabled: config.actionsEnabled,
});
},
initializeAgent,
},
);

// Ensure edges is an array when multi-agent mode is active
// (MultiAgentGraph.categorizeEdges requires edges to be iterable)
const edges = handoffAgentConfigs.size > 0 ? (discoveredEdges ?? []) : discoveredEdges;
primaryConfig.edges = edges;

// Determine if streaming is enabled (check both request and agent config)
const streamingDisabled = !!primaryConfig.model_parameters?.disableStreaming;
const isStreaming = request.stream === true && !streamingDisabled;
Expand Down Expand Up @@ -270,17 +339,18 @@ const OpenAIChatCompletionController = async (req, res) => {
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises, streamId: null });

const toolExecuteOptions = {
loadTools: async (toolNames) => {
loadTools: async (toolNames, agentId) => {
const ctx = agentToolContexts.get(agentId) ?? agentToolContexts.get(primaryConfig.id) ?? {};
return loadToolsForExecution({
req,
res,
agent,
toolNames,
agent: ctx.agent ?? agent,
signal: abortController.signal,
toolRegistry: primaryConfig.toolRegistry,
userMCPAuthMap: primaryConfig.userMCPAuthMap,
tool_resources: primaryConfig.tool_resources,
actionsEnabled: primaryConfig.actionsEnabled,
toolRegistry: ctx.toolRegistry,
userMCPAuthMap: ctx.userMCPAuthMap,
tool_resources: ctx.tool_resources,
actionsEnabled: ctx.actionsEnabled,
});
},
toolEndCallback,
Expand Down Expand Up @@ -467,11 +537,14 @@ const OpenAIChatCompletionController = async (req, res) => {
// Create and run the agent
const userId = req.user?.id ?? 'api-user';

// Extract userMCPAuthMap from primaryConfig (needed for MCP tool connections)
const userMCPAuthMap = primaryConfig.userMCPAuthMap;
// Extract merged userMCPAuthMap (needed for MCP tool connections across
// the primary and any discovered handoff sub-agents)
const userMCPAuthMap = discoveredMCPAuthMap ?? primaryConfig.userMCPAuthMap;

const runAgents = [primaryConfig, ...handoffAgentConfigs.values()];

const run = await createRun({
agents: [primaryConfig],
agents: runAgents,
messages: formattedMessages,
indexTokenCountMap,
initialSummary,
Expand Down
Loading
Loading