Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
23 changes: 22 additions & 1 deletion api/server/controllers/agents/__tests__/openai.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1310,13 +1310,34 @@ describe('OpenAIChatCompletionController', () => {

const toolExecuteOptions = createToolExecuteHandler.mock.calls.at(-1)[0];
expect(toolExecuteOptions.ordinaryToolCancellation).toBe(true);
await toolExecuteOptions.loadTools(['file_search'], 'agent-123');
expect(toolExecuteOptions.runSignal).toBe(mockExecution.signal);
expect(toolExecuteOptions.foregroundRunId).toBe(initializeParams.requestBody.messageId);
const effectiveSignal = new AbortController().signal;
await toolExecuteOptions.loadTools(
['file_search'],
'agent-123',
undefined,
undefined,
effectiveSignal,
);
expect(loadToolsForExecution).toHaveBeenLastCalledWith(
expect.objectContaining({
agentResourceType: ResourceType.REMOTE_AGENT,
requestBody: initializeParams.requestBody,
signal: effectiveSignal,
}),
);
mockExecution.abort();
await toolExecuteOptions.loadTools(
['file_search'],
'agent-123',
undefined,
undefined,
undefined,
);
expect(loadToolsForExecution).toHaveBeenLastCalledWith(
expect.objectContaining({ signal: undefined }),
);
});

it('returns 503 when an agent expects MCP tools but resolves none', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ jest.mock('@librechat/api', () => ({
buildMessageFiles: jest.fn(() => []),
resolveTitleTiming: jest.fn(() => 'immediate'),
resolveConversationAnchor: jest.requireActual('@librechat/api').resolveConversationAnchor,
resolveRunCodeWorkspaces: jest.requireActual('@librechat/api').resolveRunCodeWorkspaces,
GenerationJobManager: mockGenerationJobManager,
getReferencedQuotes: jest.fn(() => null),
cleanupMCPRequestContext: jest.fn(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ jest.mock('@librechat/api', () => ({
buildMessageFiles: jest.fn(() => []),
resolveTitleTiming: jest.fn(() => 'immediate'),
resolveConversationAnchor: jest.requireActual('@librechat/api').resolveConversationAnchor,
resolveRunCodeWorkspaces: jest.requireActual('@librechat/api').resolveRunCodeWorkspaces,
getCodeWorkspaceSelectionErrorDetails:
jest.requireActual('@librechat/api').getCodeWorkspaceSelectionErrorDetails,
GenerationJobManager: mockGenerationJobManager,
Expand Down Expand Up @@ -4251,17 +4252,20 @@ describe('ResumableAgentController resume metadata', () => {
);
});

it.each(['submitted', 'persisted'])(
it.each(['submitted', 'persisted', 'overridden'])(
'preserves %s workspace selections in the runtime envelope',
async (source) => {
mockGenerationJobManager.claimGeneration.mockResolvedValue(wonGenerationClaim());
const initializeClient = jest.fn().mockRejectedValue(new Error('stop before tool loading'));
const codeWorkspaces = [{ environmentId: 'machine-a', workspaceId: 'project-b' }];
const req = {
user: { id: 'user-123' },
...(source === 'persisted' ? { resolvedConversation: { codeWorkspaces } } : {}),
...(source !== 'submitted'
? { resolvedConversation: { conversationId: 'conversation-123', codeWorkspaces } }
: {}),
body: {
...(source === 'submitted' ? { codeWorkspaces } : {}),
...(source === 'overridden' ? { overrideConvoId: 'target-conversation' } : {}),
text: 'Fresh submission.',
messageId: 'user-msg',
clientRequestId: 'req-abc',
Expand All @@ -4283,9 +4287,14 @@ describe('ResumableAgentController resume metadata', () => {

expect(initializeClient).toHaveBeenCalledWith(
expect.objectContaining({
requestBody: expect.objectContaining({ codeWorkspaces }),
requestBody: expect.objectContaining({
conversationId: source === 'overridden' ? 'target-conversation' : 'conversation-123',
}),
}),
);
expect(initializeClient.mock.calls[0][0].requestBody.codeWorkspaces).toEqual(
source === 'overridden' ? undefined : codeWorkspaces,
);
expect(mockCheckAndIncrementPendingRequest).toHaveBeenCalledWith('user-123');
expect(mockGenerationJobManager.createJob).toHaveBeenCalledWith(
'conversation-123',
Expand Down
107 changes: 66 additions & 41 deletions api/server/controllers/agents/__tests__/responses.unit.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -1867,48 +1867,73 @@ describe('createResponse controller', () => {
});

describe('remote-agent file authorization', () => {
it('threads the remote-agent permission boundary through initialization and tool loading', async () => {
const { initializeAgent, createToolExecuteHandler } = require('@librechat/api');
const { loadAgentTools, loadToolsForExecution } = require('~/server/services/ToolService');
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');

req.config.endpoints.agents.backgroundTasks = { ordinaryToolCancellation: true };
await createResponse(req, res);

const [initializeParams, dbMethods] = initializeAgent.mock.calls.at(-1);
const filterParams = {
files: [{ file_id: 'owner-file', user: 'agent-owner' }],
userId: 'user-123',
role: 'USER',
agentId: 'agent-123',
};
await dbMethods.filterFilesByAgentAccess(filterParams);
expect(filterFilesByAgentAccess).toHaveBeenLastCalledWith({
...filterParams,
resourceType: ResourceType.REMOTE_AGENT,
});

await initializeParams.loadTools({
agentId: 'agent-123',
tools: ['file_search'],
provider: 'anthropic',
model: 'claude-3',
tool_resources: { file_search: { file_ids: ['owner-file'] } },
});
expect(loadAgentTools).toHaveBeenLastCalledWith(
expect.objectContaining({ agentResourceType: ResourceType.REMOTE_AGENT }),
);
it.each([false, true])(
'threads the remote-agent permission boundary through initialization and tool loading (stream=%s)',
async (stream) => {
const { initializeAgent, createToolExecuteHandler } = require('@librechat/api');
const { loadAgentTools, loadToolsForExecution } = require('~/server/services/ToolService');
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');

req.config.endpoints.agents.backgroundTasks = { ordinaryToolCancellation: true };
req.body.stream = stream;
await createResponse(req, res);

const [initializeParams, dbMethods] = initializeAgent.mock.calls.at(-1);
const filterParams = {
files: [{ file_id: 'owner-file', user: 'agent-owner' }],
userId: 'user-123',
role: 'USER',
agentId: 'agent-123',
};
await dbMethods.filterFilesByAgentAccess(filterParams);
expect(filterFilesByAgentAccess).toHaveBeenLastCalledWith({
...filterParams,
resourceType: ResourceType.REMOTE_AGENT,
});

const toolExecuteOptions = createToolExecuteHandler.mock.calls.at(-1)[0];
expect(toolExecuteOptions.ordinaryToolCancellation).toBe(true);
await toolExecuteOptions.loadTools(['file_search'], 'agent-123');
expect(loadToolsForExecution).toHaveBeenLastCalledWith(
expect.objectContaining({
agentResourceType: ResourceType.REMOTE_AGENT,
requestBody: initializeParams.requestBody,
}),
);
});
await initializeParams.loadTools({
agentId: 'agent-123',
tools: ['file_search'],
provider: 'anthropic',
model: 'claude-3',
tool_resources: { file_search: { file_ids: ['owner-file'] } },
});
expect(loadAgentTools).toHaveBeenLastCalledWith(
expect.objectContaining({ agentResourceType: ResourceType.REMOTE_AGENT }),
);

const toolExecuteOptions = createToolExecuteHandler.mock.calls.at(-1)[0];
expect(toolExecuteOptions.ordinaryToolCancellation).toBe(true);
expect(toolExecuteOptions.runSignal).toBe(mockExecution.signal);
expect(toolExecuteOptions.foregroundRunId).toBe(initializeParams.requestBody.messageId);
const effectiveSignal = new AbortController().signal;
await toolExecuteOptions.loadTools(
['file_search'],
'agent-123',
undefined,
undefined,
effectiveSignal,
);
expect(loadToolsForExecution).toHaveBeenLastCalledWith(
expect.objectContaining({
agentResourceType: ResourceType.REMOTE_AGENT,
requestBody: initializeParams.requestBody,
signal: effectiveSignal,
}),
);
mockExecution.abort();
await toolExecuteOptions.loadTools(
['file_search'],
'agent-123',
undefined,
undefined,
undefined,
);
expect(loadToolsForExecution).toHaveBeenLastCalledWith(
expect.objectContaining({ signal: undefined }),
);
},
);
});

describe('token usage recording - non-streaming', () => {
Expand Down
12 changes: 10 additions & 2 deletions api/server/controllers/agents/openai.js
Original file line number Diff line number Diff line change
Expand Up @@ -803,13 +803,21 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
agent never gains sandbox access even if the admin enabled the
capability globally. */
const toolExecuteOptions = {
runSignal: execution.signal,
foregroundRunId: responseId,
ordinaryToolCancellation: ordinaryToolCancellationEnabled,
provisionFiles: createProvisionFilesCallback({
req,
agentToolContexts,
resolvePrimaryAgentId: () => primaryConfig.id,
}),
loadTools: async (toolNames, agentId, _configurable, callerCapabilityProjection) => {
loadTools: async (
toolNames,
agentId,
_configurable,
callerCapabilityProjection,
runSignal,
) => {
const ctx =
agentToolContexts.get(agentId) ?? agentToolContexts.get(primaryConfig.id) ?? {};
const result = await loadToolsForExecution({
Expand All @@ -820,7 +828,7 @@ const executeOpenAIChatCompletion = async (envelope, { req, res }) => {
requestBody: mcpRequestBody,
toolNames,
agent: ctx.agent ?? agent,
signal: execution.signal,
signal: runSignal,
toolRegistry: ctx.toolRegistry,
callerCapabilityProjection,
backgroundToolNames: ctx.backgroundToolNames,
Expand Down
7 changes: 6 additions & 1 deletion api/server/controllers/agents/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const {
deleteAgentCheckpoint,
getAttachmentTitleText,
createMCPRuntimeRequestBody,
resolveRunCodeWorkspaces,
isAgentEventRetentionActive,
createAgentEventActorTurn,
createAgentEventActorDetachedActionLifecycle,
Expand Down Expand Up @@ -1485,7 +1486,11 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
const mcpRequestBody = createMCPRuntimeRequestBody({
messageId: preallocatedResponseMessageId,
conversationId: effectiveConversationId,
codeWorkspaces: req.body.codeWorkspaces ?? req.resolvedConversation?.codeWorkspaces,
codeWorkspaces: resolveRunCodeWorkspaces({
conversationId: effectiveConversationId,
requestedSelections: req.body.codeWorkspaces,
conversation: req.resolvedConversation,
}),
parentMessageId:
editedContent != null ? preallocatedResponseMessageId : preallocatedUserMessageId,
});
Expand Down
24 changes: 20 additions & 4 deletions api/server/controllers/agents/responses.js
Original file line number Diff line number Diff line change
Expand Up @@ -1146,13 +1146,21 @@ const executeResponse = async (envelope, { req, res }) => {

// Create tool execute options for event-driven tool execution
const toolExecuteOptions = {
runSignal: execution.signal,
foregroundRunId: responseId,
ordinaryToolCancellation: ordinaryToolCancellationEnabled,
provisionFiles: createProvisionFilesCallback({
req,
agentToolContexts,
resolvePrimaryAgentId: () => primaryConfig.id,
}),
loadTools: async (toolNames, agentId, _configurable, callerCapabilityProjection) => {
loadTools: async (
toolNames,
agentId,
_configurable,
callerCapabilityProjection,
runSignal,
) => {
const ctx =
agentToolContexts.get(agentId) ?? agentToolContexts.get(primaryConfig.id) ?? {};
const result = await loadToolsForExecution({
Expand All @@ -1163,7 +1171,7 @@ const executeResponse = async (envelope, { req, res }) => {
requestBody: mcpRequestBody,
toolNames,
agent: ctx.agent ?? agent,
signal: execution.signal,
signal: runSignal,
toolRegistry: ctx.toolRegistry,
callerCapabilityProjection,
backgroundToolNames: ctx.backgroundToolNames,
Expand Down Expand Up @@ -1375,13 +1383,21 @@ const executeResponse = async (envelope, { req, res }) => {
});

const toolExecuteOptions = {
runSignal: execution.signal,
foregroundRunId: responseId,
ordinaryToolCancellation: ordinaryToolCancellationEnabled,
provisionFiles: createProvisionFilesCallback({
req,
agentToolContexts,
resolvePrimaryAgentId: () => primaryConfig.id,
}),
loadTools: async (toolNames, agentId, _configurable, callerCapabilityProjection) => {
loadTools: async (
toolNames,
agentId,
_configurable,
callerCapabilityProjection,
runSignal,
) => {
const ctx =
agentToolContexts.get(agentId) ?? agentToolContexts.get(primaryConfig.id) ?? {};
const result = await loadToolsForExecution({
Expand All @@ -1392,7 +1408,7 @@ const executeResponse = async (envelope, { req, res }) => {
requestBody: mcpRequestBody,
toolNames,
agent: ctx.agent ?? agent,
signal: execution.signal,
signal: runSignal,
toolRegistry: ctx.toolRegistry,
callerCapabilityProjection,
backgroundToolNames: ctx.backgroundToolNames,
Expand Down
8 changes: 8 additions & 0 deletions api/server/csp.spec.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
const fs = require('fs');
const path = require('path');
const { promisify } = require('util');
const express = require('express');
const request = require('supertest');
const { MongoMemoryServer } = require('mongodb-memory-server');
const mongoose = require('mongoose');
Expand Down Expand Up @@ -76,6 +78,7 @@ describe('Content Security Policy', () => {

let mongoServer;
let app;
let server;

const originalReadFileSync = fs.readFileSync;

Expand Down Expand Up @@ -105,8 +108,12 @@ describe('Content Security Policy', () => {
/* A cacheable override that CSP must refuse for the shell. */
process.env.INDEX_CACHE_CONTROL = 'public, max-age=3600';

/* index.js listens at module scope and exports only the app, so capture the server to close it. */
const listenSpy = jest.spyOn(express.application, 'listen');
app = require('~/server');
await healthCheckPoll(app);
server = listenSpy.mock.results[0].value;
listenSpy.mockRestore();
});

afterAll(async () => {
Expand All @@ -115,6 +122,7 @@ describe('Content Security Policy', () => {
delete process.env.CSP_REPORT_ONLY;
delete process.env.CSP_CONNECT_SRC_EXTRA;
delete process.env.INDEX_CACHE_CONTROL;
await promisify(server.close).call(server);
await mongoServer.stop();
await mongoose.disconnect();
});
Expand Down
8 changes: 8 additions & 0 deletions api/server/index.metrics.spec.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
const fs = require('fs');
const { promisify } = require('util');
const express = require('express');
const request = require('supertest');
const { MongoMemoryServer } = require('mongodb-memory-server');
const mongoose = require('mongoose');
Expand Down Expand Up @@ -47,6 +49,7 @@ describe('Server metrics route', () => {

let mongoServer;
let app;
let server;

const originalReadFileSync = fs.readFileSync;

Expand Down Expand Up @@ -83,9 +86,13 @@ describe('Server metrics route', () => {
process.env.MONGO_URI = mongoServer.getUri();
process.env.PORT = '0';
process.env.METRICS_SECRET = 'test-secret';
/* index.js listens at module scope and exports only the app, so capture the server to close it. */
const listenSpy = jest.spyOn(express.application, 'listen');
app = require('~/server');

await healthCheckPoll(app);
server = listenSpy.mock.results[0].value;
listenSpy.mockRestore();
});

afterEach(() => {
Expand All @@ -94,6 +101,7 @@ describe('Server metrics route', () => {

afterAll(async () => {
delete process.env.METRICS_SECRET;
await promisify(server.close).call(server);
await mongoServer.stop();
await mongoose.disconnect();
});
Expand Down
Loading
Loading