diff --git a/.env.example b/.env.example index 7779276bccf1..d06959947661 100644 --- a/.env.example +++ b/.env.example @@ -140,7 +140,7 @@ PROXY= #============# ANTHROPIC_API_KEY=user_provided -# ANTHROPIC_MODELS=claude-opus-4-7,claude-sonnet-4-6,claude-opus-4-6,claude-opus-4-20250514,claude-sonnet-4-20250514,claude-3-7-sonnet-20250219,claude-3-5-sonnet-20241022,claude-3-5-haiku-20241022,claude-3-opus-20240229,claude-3-sonnet-20240229,claude-3-haiku-20240307 +# ANTHROPIC_MODELS=claude-opus-4-7,claude-sonnet-4-6,claude-opus-4-6,claude-opus-4-20250514,claude-3-7-sonnet-20250219,claude-3-5-sonnet-20241022,claude-3-5-haiku-20241022,claude-3-opus-20240229,claude-3-sonnet-20240229,claude-3-haiku-20240307 # ANTHROPIC_REVERSE_PROXY= # Set to true to use Anthropic models through Google Vertex AI instead of direct API @@ -810,13 +810,6 @@ HELP_AND_FAQ_URL=https://librechat.ai #=====================================================# OPENWEATHER_API_KEY= -#====================================# -# LibreChat Code Interpreter API # -#====================================# - -# https://code.librechat.ai -# LIBRECHAT_CODE_API_KEY=your-key - #======================# # Web Search # #======================# diff --git a/.github/workflows/backend-review.yml b/.github/workflows/backend-review.yml index 9dd3905c0e20..03b7c135d2ab 100644 --- a/.github/workflows/backend-review.yml +++ b/.github/workflows/backend-review.yml @@ -1,11 +1,6 @@ name: Backend Unit Tests on: pull_request: - branches: - - main - - dev - - dev-staging - - release/* paths: - 'api/**' - 'packages/**' diff --git a/.github/workflows/frontend-review.yml b/.github/workflows/frontend-review.yml index 9c2d4a37b14f..05b3f4154f49 100644 --- a/.github/workflows/frontend-review.yml +++ b/.github/workflows/frontend-review.yml @@ -2,11 +2,6 @@ name: Frontend Unit Tests on: pull_request: - branches: - - main - - dev - - dev-staging - - release/* paths: - 'client/**' - 'packages/data-provider/**' diff --git a/api/app/clients/BaseClient.js b/api/app/clients/BaseClient.js index 905cadfd235d..626b4c77f102 100644 --- a/api/app/clients/BaseClient.js +++ b/api/app/clients/BaseClient.js @@ -492,6 +492,39 @@ class BaseClient { } delete userMessage.image_urls; } + /** + * Persist the user's manual skill picks onto the user message so the + * frontend `SkillPills` component can render them in history + * after reload. UI-only metadata — the runtime skill resolution + * pipeline reads the top-level `req.body.manualSkills` separately. + * Filter is defense-in-depth on top of Mongoose schema validation: + * keeps the DB row free of empty/non-string entries even if a + * crafted payload slips past schema checks upstream. + */ + const rawManualSkills = this.options.req?.body?.manualSkills; + if (Array.isArray(rawManualSkills) && rawManualSkills.length > 0) { + const skills = rawManualSkills.filter((s) => typeof s === 'string' && s.length > 0); + if (skills.length > 0) { + userMessage.manualSkills = skills; + } + } + /** + * Persist the names of skills auto-primed this turn via `always-apply` + * frontmatter so `SkillPills` can render pinned-variant badges + * on the user bubble that survive reload and history render. Frozen + * at turn time (not reconstructed from `Skill.alwaysApply` at render + * time) because the flag is mutable — historical turns must keep + * their audit trail even if an admin flips `alwaysApply` off later. + */ + const alwaysApplySkillPrimes = this.options.agent?.alwaysApplySkillPrimes; + if (Array.isArray(alwaysApplySkillPrimes) && alwaysApplySkillPrimes.length > 0) { + const names = alwaysApplySkillPrimes + .map((p) => p?.name) + .filter((n) => typeof n === 'string' && n.length > 0); + if (names.length > 0) { + userMessage.alwaysAppliedSkills = names; + } + } userMessagePromise = this.saveMessageToDatabase(userMessage, saveOptions, user).catch( (err) => { logger.error('[BaseClient] Failed to save user message:', err); @@ -780,11 +813,28 @@ class BaseClient { endpointType: options.endpointType, ...endpointOptions, }; + const conversationCreatedAt = options?.req?.conversationCreatedAt; + const createdAtOnInsert = + conversationCreatedAt != null ? new Date(conversationCreatedAt) : undefined; + const validCreatedAtOnInsert = + createdAtOnInsert && !Number.isNaN(createdAtOnInsert.getTime()) + ? createdAtOnInsert + : undefined; - const existingConvo = - this.fetchedConvo === true - ? null - : await db.getConvo(options?.req?.user?.id, message.conversationId); + const req = options?.req; + const skippedExistingConvoLookup = this.fetchedConvo === true; + const hasResolvedConversation = + req != null && Object.prototype.hasOwnProperty.call(req, 'resolvedConversation'); + let existingConvo = null; + if (!skippedExistingConvoLookup && hasResolvedConversation) { + existingConvo = req.resolvedConversation; + } else if (!skippedExistingConvoLookup) { + existingConvo = await db.getConvo(req?.user?.id, message.conversationId); + } + if (hasResolvedConversation) { + delete req.resolvedConversation; + } + const shouldSetCreatedAtOnInsert = !skippedExistingConvoLookup && existingConvo == null; const unsetFields = {}; const exceptions = new Set(['spec', 'iconURL']); @@ -814,6 +864,7 @@ class BaseClient { const conversation = await db.saveConvo(reqCtx, fieldsToKeep, { context: 'api/app/clients/BaseClient.js - saveMessageToDatabase #saveConvo', unsetFields, + createdAtOnInsert: shouldSetCreatedAtOnInsert ? validCreatedAtOnInsert : undefined, }); return { message: savedMessage, conversation }; diff --git a/api/app/clients/specs/BaseClient.test.js b/api/app/clients/specs/BaseClient.test.js index 3ce910948cbd..eb6ae656e996 100644 --- a/api/app/clients/specs/BaseClient.test.js +++ b/api/app/clients/specs/BaseClient.test.js @@ -952,6 +952,45 @@ describe('BaseClient', () => { saveConvo.mockReset(); }); + test('saveMessageToDatabase reuses conversation resolved on the request', async () => { + const existingConvo = { + conversationId: 'cached-convo-id', + endpoint: 'openai', + endpointType: 'openai', + temperature: 0.7, + }; + const user = { id: 'user-id' }; + const req = { user, resolvedConversation: existingConvo }; + + getConvo.mockClear(); + saveMessage.mockResolvedValue({ messageId: 'msg-1' }); + saveConvo.mockResolvedValue(existingConvo); + + TestClient = initializeFakeClient(apiKey, { ...options, endpoint: 'openai', req }, []); + + await TestClient.saveMessageToDatabase( + { + messageId: 'msg-1', + conversationId: existingConvo.conversationId, + isCreatedByUser: true, + text: 'hi', + }, + { endpoint: 'openai' }, + user, + ); + + expect(getConvo).not.toHaveBeenCalled(); + expect(req).not.toHaveProperty('resolvedConversation'); + expect(TestClient.fetchedConvo).toBe(true); + expect(saveConvo).toHaveBeenCalledWith( + expect.any(Object), + expect.objectContaining({ conversationId: existingConvo.conversationId }), + expect.objectContaining({ + unsetFields: expect.objectContaining({ temperature: 1 }), + }), + ); + }); + test('userMessagePromise is awaited before saving response message', async () => { // Mock the saveMessageToDatabase method TestClient.saveMessageToDatabase = jest.fn().mockImplementation(() => { diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index 8adb43f9459e..b1a3a371b6bb 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -1,10 +1,5 @@ const { logger } = require('@librechat/data-schemas'); -const { - EnvVar, - Calculator, - createSearchTool, - createCodeExecutionTool, -} = require('@librechat/agents'); +const { Calculator, createSearchTool, createCodeExecutionTool } = require('@librechat/agents'); const { checkAccess, toolkitParent, @@ -13,6 +8,7 @@ const { loadWebSearchAuth, buildImageToolContext, buildWebSearchContext, + buildWebSearchDynamicContext, } = require('@librechat/api'); const { Tools, @@ -155,7 +151,7 @@ const getAuthFields = (toolKey) => { * @param {AppConfig['webSearch']} [params.webSearch] * @param {AppConfig['fileStrategy']} [params.fileStrategy] * @param {AppConfig['imageOutputType']} [params.imageOutputType] - * @returns {Promise<{ loadedTools: Tool[], toolContextMap: Object } | Record>} + * @returns {Promise<{ loadedTools: Tool[], toolContextMap: Object, dynamicToolContextMap?: Object } | Record>} */ const loadTools = async ({ user, @@ -185,7 +181,7 @@ const loadTools = async ({ }; const customConstructors = { - image_gen_oai: async (toolContextMap) => { + image_gen_oai: async (_toolContextMap, dynamicToolContextMap) => { const authFields = getAuthFields('image_gen_oai'); const authValues = await loadAuthValues({ userId: user, authFields }); const imageFiles = options.tool_resources?.[EToolResources.image_edit]?.files ?? []; @@ -195,7 +191,7 @@ const loadTools = async ({ contextDescription: 'image editing', }); if (toolContext) { - toolContextMap.image_edit_oai = toolContext; + dynamicToolContextMap.image_edit_oai = toolContext; } return createOpenAIImageTools({ ...authValues, @@ -206,7 +202,7 @@ const loadTools = async ({ imageFiles, }); }, - gemini_image_gen: async (toolContextMap) => { + gemini_image_gen: async (_toolContextMap, dynamicToolContextMap) => { const authFields = getAuthFields('gemini_image_gen'); const authValues = await loadAuthValues({ userId: user, authFields, throwError: false }); const imageFiles = options.tool_resources?.[EToolResources.image_edit]?.files ?? []; @@ -216,7 +212,7 @@ const loadTools = async ({ contextDescription: 'image context', }); if (toolContext) { - toolContextMap.gemini_image_gen = toolContext; + dynamicToolContextMap.gemini_image_gen = toolContext; } return createGeminiImageTool({ ...authValues, @@ -254,6 +250,17 @@ const loadTools = async ({ /** @type {Record} */ const toolContextMap = {}; + /** @type {Record} */ + const dynamicToolContextMap = {}; + /** + * @type {import('@librechat/agents').CodeEnvFile[] | undefined} + * Captured by the `execute_code` factory when files are primed. Surfaced + * out of `loadTools` so client.js can seed `Graph.sessions[EXECUTE_CODE]` + * before run start — without that seed, the first `execute_code` / + * `bash_tool` call lands with empty `_injected_files` and the sandbox + * can't see the prior turn's generated artifacts. + */ + let primedCodeFiles; const requestedMCPTools = {}; /** Resolve config-source servers for the current user/tenant context */ @@ -265,28 +272,17 @@ const loadTools = async ({ for (const tool of tools) { if (tool === Tools.execute_code) { requestedTools[tool] = async () => { - const authValues = await loadAuthValues({ - userId: user, - authFields: [EnvVar.CODE_API_KEY], + const { files, toolContext } = await primeCodeFiles({ + ...options, + agentId: agent?.id, }); - const codeApiKey = authValues[EnvVar.CODE_API_KEY]; - const { files, toolContext } = await primeCodeFiles( - { - ...options, - agentId: agent?.id, - }, - codeApiKey, - ); if (toolContext) { - toolContextMap[tool] = toolContext; + dynamicToolContextMap[tool] = toolContext; } - const CodeExecutionTool = createCodeExecutionTool({ - user_id: user, - files, - ...authValues, - }); - CodeExecutionTool.apiKey = codeApiKey; - return CodeExecutionTool; + if (files?.length) { + primedCodeFiles = files; + } + return createCodeExecutionTool({ user_id: user, files }); }; continue; } else if (tool === Tools.file_search) { @@ -296,7 +292,7 @@ const loadTools = async ({ agentId: agent?.id, }); if (toolContext) { - toolContextMap[tool] = toolContext; + dynamicToolContextMap[tool] = toolContext; } /** @type {boolean | undefined} Check if user has FILE_CITATIONS permission */ @@ -332,6 +328,9 @@ const loadTools = async ({ const { onSearchResults, onGetHighlights } = options?.[Tools.web_search] ?? {}; requestedTools[tool] = async () => { toolContextMap[tool] = buildWebSearchContext(); + dynamicToolContextMap[tool] = buildWebSearchDynamicContext( + options.req?.conversationCreatedAt, + ); return createSearchTool({ ...result.authResult, onSearchResults, @@ -381,7 +380,7 @@ const loadTools = async ({ if (!requestedTools[toolKey]) { let cached; requestedTools[toolKey] = async () => { - cached ??= customConstructors[toolKey](toolContextMap); + cached ??= customConstructors[toolKey](toolContextMap, dynamicToolContextMap); return cached; }; } @@ -493,7 +492,7 @@ const loadTools = async ({ } } loadedTools.push(...(await Promise.all(mcpToolPromises)).flatMap((plugin) => plugin || [])); - return { loadedTools, toolContextMap }; + return { loadedTools, toolContextMap, dynamicToolContextMap, primedCodeFiles }; }; module.exports = { diff --git a/api/package.json b/api/package.json index 239b859084df..5e286f5b4a93 100644 --- a/api/package.json +++ b/api/package.json @@ -44,7 +44,7 @@ "@google/genai": "^1.19.0", "@keyv/redis": "^4.3.3", "@langchain/core": "^0.3.80", - "@librechat/agents": "^3.1.68", + "@librechat/agents": "^3.1.75", "@librechat/api": "*", "@librechat/data-schemas": "*", "@microsoft/microsoft-graph-client": "^3.0.7", diff --git a/api/server/controllers/SkillStatesController.js b/api/server/controllers/SkillStatesController.js new file mode 100644 index 000000000000..35679d2ac6d5 --- /dev/null +++ b/api/server/controllers/SkillStatesController.js @@ -0,0 +1,93 @@ +const mongoose = require('mongoose'); +const { logger } = require('@librechat/data-schemas'); +const { + MAX_SKILL_STATES, + toSkillStatesRecord, + validateSkillStatesPayload, + pruneOrphanSkillStates, +} = require('@librechat/api'); +const { ResourceType, PermissionBits } = require('librechat-data-provider'); +const { findAccessibleResources } = require('~/server/services/PermissionService'); +const { updateUser, getUserById } = require('~/models'); + +/** Builds the injected deps for `pruneOrphanSkillStates` from live models. */ +function buildPruneDeps(user) { + return { + findExistingSkillIds: async (validIds) => { + const Skill = mongoose.models.Skill; + if (!Skill) { + return validIds; + } + const existing = await Skill.find({ _id: { $in: validIds } }) + .select('_id') + .lean(); + return existing.map((doc) => doc._id.toString()); + }, + findAccessibleSkillIds: () => + findAccessibleResources({ + userId: user.id, + role: user.role, + resourceType: ResourceType.SKILL, + requiredPermissions: PermissionBits.VIEW, + }), + }; +} + +const getSkillStatesController = async (req, res) => { + try { + const userId = req.user.id; + const user = await getUserById(userId, 'skillStates'); + + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + const states = toSkillStatesRecord(user.skillStates); + const pruned = await pruneOrphanSkillStates(states, buildPruneDeps(req.user)); + return res.status(200).json(pruned); + } catch (error) { + logger.error('[SkillStatesController] Error fetching skill states:', error); + return res.status(500).json({ message: 'Internal server error' }); + } +}; + +const updateSkillStatesController = async (req, res) => { + try { + const { skillStates } = req.body; + + const validationError = validateSkillStatesPayload(skillStates); + if (validationError) { + const { message, code, limit } = validationError; + const payload = { message }; + if (code) payload.code = code; + if (limit != null) payload.limit = limit; + return res.status(400).json(payload); + } + + const pruned = await pruneOrphanSkillStates(skillStates, buildPruneDeps(req.user)); + + if (Object.keys(pruned).length > MAX_SKILL_STATES) { + return res.status(400).json({ + code: 'MAX_SKILL_STATES_EXCEEDED', + message: `Maximum ${MAX_SKILL_STATES} skill state overrides allowed`, + limit: MAX_SKILL_STATES, + }); + } + + const user = await updateUser(req.user.id, { skillStates: pruned }); + + if (!user) { + return res.status(404).json({ message: 'User not found' }); + } + + return res.status(200).json(toSkillStatesRecord(user.skillStates)); + } catch (error) { + logger.error('[SkillStatesController] Error updating skill states:', error); + return res.status(500).json({ message: 'Internal server error' }); + } +}; + +module.exports = { + getSkillStatesController, + updateSkillStatesController, +}; diff --git a/api/server/controllers/UserController.js b/api/server/controllers/UserController.js index 16b68968d9d2..710b2f3f6a5d 100644 --- a/api/server/controllers/UserController.js +++ b/api/server/controllers/UserController.js @@ -333,6 +333,7 @@ const deleteUserController = async (req, res) => { await db.deleteConversationTags({ user: user.id }); await db.deleteAllUserMemories(user.id); await db.deleteUserPrompts(user.id); + await db.deleteUserSkills(user.id); await deleteUserMcpServers(user.id); await db.deleteActions({ user: user.id }); await db.deleteTokens({ userId: user.id }); @@ -374,9 +375,48 @@ const resendVerificationController = async (req, res) => { } }; -/** - * OAuth MCP specific uninstall logic - */ +/** Best-effort cleanup of stored MCP OAuth tokens and flow state. */ +const clearStoredMCPOAuthState = async (userId, serverName) => { + try { + await MCPTokenStorage.deleteUserTokens({ + userId, + serverName, + deleteToken: async (filter) => { + await db.deleteTokens(filter); + }, + }); + } catch (error) { + logger.warn( + `[clearStoredMCPOAuthState] Failed to delete MCP OAuth tokens for ${serverName}:`, + error, + ); + } + + try { + const flowsCache = getLogStores(CacheKeys.FLOWS); + const flowManager = getFlowStateManager(flowsCache); + const flowId = MCPOAuthHandler.generateFlowId(userId, serverName); + const results = await Promise.allSettled([ + flowManager.deleteFlow(flowId, 'mcp_get_tokens'), + flowManager.deleteFlow(flowId, 'mcp_oauth'), + ]); + for (const result of results) { + if (result.status === 'rejected') { + logger.warn( + `[clearStoredMCPOAuthState] Failed to clear MCP OAuth flow state for ${serverName}:`, + result.reason, + ); + } + } + } catch (error) { + logger.warn( + `[clearStoredMCPOAuthState] Failed to clear MCP OAuth flow state for ${serverName}:`, + error, + ); + } +}; + +/** Revokes MCP OAuth tokens at the provider when possible, then clears local state. */ const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => { if (!pluginKey.startsWith(Constants.mcp_prefix)) { // this is not an MCP server, so nothing to do here @@ -388,28 +428,50 @@ const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => { (await getMCPServersRegistry().getServerConfig(serverName, userId)) ?? appConfig?.mcpServers?.[serverName]; const oauthServers = await getMCPServersRegistry().getOAuthServers(userId); - if (!oauthServers.has(serverName)) { - // this server does not use OAuth, so nothing to do here as well + if (!oauthServers.has(serverName) || !serverConfig) { + await clearStoredMCPOAuthState(userId, serverName); return; } // 1. get client info used for revocation (client id, secret) - const clientTokenData = await MCPTokenStorage.getClientInfoAndMetadata({ - userId, - serverName, - findToken: db.findToken, - }); + let clientTokenData = null; + try { + clientTokenData = await MCPTokenStorage.getClientInfoAndMetadata({ + userId, + serverName, + findToken: db.findToken, + }); + } catch (error) { + logger.warn( + `[maybeUninstallOAuthMCP] Unable to load OAuth client metadata for ${serverName}; clearing local MCP OAuth state only.`, + error, + ); + await clearStoredMCPOAuthState(userId, serverName); + return; + } if (clientTokenData == null) { + logger.info( + `[maybeUninstallOAuthMCP] Missing OAuth client metadata for ${serverName}; clearing local MCP OAuth state only.`, + ); + await clearStoredMCPOAuthState(userId, serverName); return; } const { clientInfo, clientMetadata } = clientTokenData; // 2. get decrypted tokens before deletion - const tokens = await MCPTokenStorage.getTokens({ - userId, - serverName, - findToken: db.findToken, - }); + let tokens = null; + try { + tokens = await MCPTokenStorage.getTokens({ + userId, + serverName, + findToken: db.findToken, + }); + } catch (error) { + logger.warn( + `[maybeUninstallOAuthMCP] Unable to load OAuth tokens for ${serverName}; clearing local token state.`, + error, + ); + } // 3. revoke OAuth tokens at the provider const revocationEndpoint = @@ -437,7 +499,10 @@ const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => { allowedDomains, ); } catch (error) { - logger.error(`Error revoking OAuth access token for ${serverName}:`, error); + logger.error( + `[maybeUninstallOAuthMCP] Error revoking OAuth access token for ${serverName}:`, + error, + ); } } @@ -458,25 +523,15 @@ const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => { allowedDomains, ); } catch (error) { - logger.error(`Error revoking OAuth refresh token for ${serverName}:`, error); + logger.error( + `[maybeUninstallOAuthMCP] Error revoking OAuth refresh token for ${serverName}:`, + error, + ); } } - // 4. delete tokens from the DB after revocation attempts - await MCPTokenStorage.deleteUserTokens({ - userId, - serverName, - deleteToken: async (filter) => { - await db.deleteTokens(filter); - }, - }); - - // 5. clear the flow state for the OAuth tokens - const flowsCache = getLogStores(CacheKeys.FLOWS); - const flowManager = getFlowStateManager(flowsCache); - const flowId = MCPOAuthHandler.generateFlowId(userId, serverName); - await flowManager.deleteFlow(flowId, 'mcp_get_tokens'); - await flowManager.deleteFlow(flowId, 'mcp_oauth'); + // 4. delete tokens from the DB and clear the flow state after revocation attempts + await clearStoredMCPOAuthState(userId, serverName); }; module.exports = { @@ -488,4 +543,5 @@ module.exports = { updateUserPluginsController, resendVerificationController, deleteUserMcpServers, + maybeUninstallOAuthMCP, }; diff --git a/api/server/controllers/UserController.spec.js b/api/server/controllers/UserController.spec.js index 4a96072062be..30e6190e286a 100644 --- a/api/server/controllers/UserController.spec.js +++ b/api/server/controllers/UserController.spec.js @@ -28,6 +28,7 @@ jest.mock('~/models', () => { deleteAssistants: jest.fn().mockResolvedValue(undefined), deleteUserById: jest.fn().mockResolvedValue(undefined), deleteUserPrompts: jest.fn().mockResolvedValue(undefined), + deleteUserSkills: jest.fn().mockResolvedValue(undefined), deleteMessages: jest.fn().mockResolvedValue(undefined), deleteBalances: jest.fn().mockResolvedValue(undefined), deleteActions: jest.fn().mockResolvedValue(undefined), diff --git a/api/server/controllers/__tests__/UserController.mcpOAuth.spec.js b/api/server/controllers/__tests__/UserController.mcpOAuth.spec.js new file mode 100644 index 000000000000..986ab712a1f7 --- /dev/null +++ b/api/server/controllers/__tests__/UserController.mcpOAuth.spec.js @@ -0,0 +1,419 @@ +const mockUpdateUserPlugins = jest.fn(); +const mockFindToken = jest.fn(); +const mockDeleteUserPluginAuth = jest.fn(); +const mockGetAppConfig = jest.fn(); +const mockInvalidateCachedTools = jest.fn(); +const mockGetLogStores = jest.fn(); +const mockGetMCPManager = jest.fn(); +const mockGetFlowStateManager = jest.fn(); +const mockGetMCPServersRegistry = jest.fn(); + +jest.mock('@librechat/data-schemas', () => ({ + logger: { error: jest.fn(), info: jest.fn(), warn: jest.fn() }, + webSearchKeys: [], +})); + +jest.mock('librechat-data-provider', () => ({ + Tools: {}, + CacheKeys: { FLOWS: 'flows' }, + Constants: { mcp_delimiter: '_mcp_', mcp_prefix: 'mcp_' }, + FileSources: {}, +})); + +jest.mock('@librechat/api', () => ({ + MCPOAuthHandler: { + generateFlowId: jest.fn(() => 'user-1:test-server'), + revokeOAuthToken: jest.fn(), + }, + MCPTokenStorage: { + getClientInfoAndMetadata: jest.fn(), + getTokens: jest.fn(), + deleteUserTokens: jest.fn().mockResolvedValue(undefined), + }, + normalizeHttpError: jest.fn((error) => error), + extractWebSearchEnvVars: jest.fn((params) => params.keys), + needsRefresh: jest.fn(), + getNewS3URL: jest.fn(), +})); + +jest.mock('~/models', () => ({ + updateUserPlugins: (...args) => mockUpdateUserPlugins(...args), + findToken: mockFindToken, + deleteTokens: jest.fn(), +})); + +jest.mock('~/server/services/PluginService', () => ({ + updateUserPluginAuth: jest.fn(), + deleteUserPluginAuth: (...args) => mockDeleteUserPluginAuth(...args), +})); + +jest.mock('~/server/services/twoFactorService', () => ({ + verifyOTPOrBackupCode: jest.fn(), +})); + +jest.mock('~/server/services/AuthService', () => ({ + verifyEmail: jest.fn(), + resendVerificationEmail: jest.fn(), +})); + +jest.mock('~/config', () => ({ + getMCPManager: (...args) => mockGetMCPManager(...args), + getFlowStateManager: (...args) => mockGetFlowStateManager(...args), + getMCPServersRegistry: (...args) => mockGetMCPServersRegistry(...args), +})); + +jest.mock('~/server/services/Config/getCachedTools', () => ({ + invalidateCachedTools: (...args) => mockInvalidateCachedTools(...args), +})); + +jest.mock('~/server/services/Files/process', () => ({ + processDeleteRequest: jest.fn(), +})); + +jest.mock('~/server/services/Config', () => ({ + getAppConfig: (...args) => mockGetAppConfig(...args), +})); + +jest.mock('~/cache', () => ({ + getLogStores: (...args) => mockGetLogStores(...args), +})); + +const { logger } = require('@librechat/data-schemas'); +const { MCPTokenStorage, MCPOAuthHandler } = require('@librechat/api'); +const { updateUserPluginsController } = require('~/server/controllers/UserController'); + +function createResponse() { + const res = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + res.send = jest.fn().mockReturnValue(res); + return res; +} + +function createRequest() { + return { + user: { + id: 'user-1', + _id: 'user-1', + plugins: [], + role: 'USER', + }, + body: { + pluginKey: 'mcp_test-server', + action: 'uninstall', + auth: {}, + }, + }; +} + +function setupMCPMocks() { + const flowManager = { + deleteFlow: jest.fn().mockResolvedValue(true), + }; + const mcpManager = { + disconnectUserConnection: jest.fn().mockResolvedValue(), + }; + const registry = { + getServerConfig: jest.fn().mockResolvedValue({ + url: 'https://example.com/mcp', + oauth: {}, + oauth_headers: {}, + }), + getOAuthServers: jest.fn().mockResolvedValue(new Set(['test-server'])), + getAllowedDomains: jest.fn().mockReturnValue([]), + }; + + mockGetAppConfig.mockResolvedValue({}); + mockUpdateUserPlugins.mockResolvedValue(); + mockDeleteUserPluginAuth.mockResolvedValue(); + mockInvalidateCachedTools.mockResolvedValue(); + mockGetLogStores.mockReturnValue({}); + mockGetFlowStateManager.mockReturnValue(flowManager); + mockGetMCPManager.mockReturnValue(mcpManager); + mockGetMCPServersRegistry.mockReturnValue(registry); + + return { flowManager, mcpManager, registry }; +} + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('updateUserPluginsController MCP OAuth cleanup', () => { + it('clears stored OAuth token state when client metadata is missing', async () => { + const { flowManager, mcpManager } = setupMCPMocks(); + MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue(null); + + const res = createResponse(); + await updateUserPluginsController(createRequest(), res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(MCPTokenStorage.getClientInfoAndMetadata).toHaveBeenCalledWith({ + userId: 'user-1', + serverName: 'test-server', + findToken: mockFindToken, + }); + expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({ + userId: 'user-1', + serverName: 'test-server', + deleteToken: expect.any(Function), + }); + expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens'); + expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_oauth'); + expect(MCPOAuthHandler.revokeOAuthToken).not.toHaveBeenCalled(); + expect(mcpManager.disconnectUserConnection).toHaveBeenCalledWith('user-1', 'test-server'); + }); + + it('still clears OAuth flow state when stored token deletion fails', async () => { + const { flowManager } = setupMCPMocks(); + const cleanupError = new Error('DB down'); + MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue(null); + MCPTokenStorage.deleteUserTokens.mockRejectedValueOnce(cleanupError); + + const res = createResponse(); + await updateUserPluginsController(createRequest(), res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens'); + expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_oauth'); + expect(logger.warn).toHaveBeenCalledWith( + '[clearStoredMCPOAuthState] Failed to delete MCP OAuth tokens for test-server:', + cleanupError, + ); + }); + + it('logs all flow cleanup failures without failing MCP OAuth cleanup', async () => { + const { flowManager } = setupMCPMocks(); + const getTokensFlowError = new Error('get tokens flow cache down'); + const oauthFlowError = new Error('oauth flow cache down'); + MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue(null); + flowManager.deleteFlow + .mockRejectedValueOnce(getTokensFlowError) + .mockRejectedValueOnce(oauthFlowError); + + const res = createResponse(); + await updateUserPluginsController(createRequest(), res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens'); + expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_oauth'); + expect(logger.warn).toHaveBeenCalledWith( + '[clearStoredMCPOAuthState] Failed to clear MCP OAuth flow state for test-server:', + getTokensFlowError, + ); + expect(logger.warn).toHaveBeenCalledWith( + '[clearStoredMCPOAuthState] Failed to clear MCP OAuth flow state for test-server:', + oauthFlowError, + ); + }); + + it('clears stored OAuth token state when client metadata cannot be loaded', async () => { + const { flowManager } = setupMCPMocks(); + MCPTokenStorage.getClientInfoAndMetadata.mockRejectedValue(new Error('invalid client info')); + + const res = createResponse(); + await updateUserPluginsController(createRequest(), res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(logger.warn).toHaveBeenCalledWith( + '[maybeUninstallOAuthMCP] Unable to load OAuth client metadata for test-server; clearing local MCP OAuth state only.', + expect.any(Error), + ); + expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({ + userId: 'user-1', + serverName: 'test-server', + deleteToken: expect.any(Function), + }); + expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens'); + expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_oauth'); + expect(MCPTokenStorage.getTokens).not.toHaveBeenCalled(); + expect(MCPOAuthHandler.revokeOAuthToken).not.toHaveBeenCalled(); + }); + + it('clears stored OAuth token state when server config is missing', async () => { + const { flowManager, registry } = setupMCPMocks(); + registry.getServerConfig.mockResolvedValue(undefined); + + const res = createResponse(); + await updateUserPluginsController(createRequest(), res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({ + userId: 'user-1', + serverName: 'test-server', + deleteToken: expect.any(Function), + }); + expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens'); + expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_oauth'); + expect(MCPTokenStorage.getClientInfoAndMetadata).not.toHaveBeenCalled(); + expect(MCPOAuthHandler.revokeOAuthToken).not.toHaveBeenCalled(); + }); + + it('clears stored OAuth token state when server no longer requires OAuth', async () => { + const { flowManager, registry } = setupMCPMocks(); + registry.getOAuthServers.mockResolvedValue(new Set()); + + const res = createResponse(); + await updateUserPluginsController(createRequest(), res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({ + userId: 'user-1', + serverName: 'test-server', + deleteToken: expect.any(Function), + }); + expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens'); + expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_oauth'); + expect(MCPTokenStorage.getClientInfoAndMetadata).not.toHaveBeenCalled(); + expect(MCPOAuthHandler.revokeOAuthToken).not.toHaveBeenCalled(); + }); + + it('clears stored OAuth token state when token loading fails before provider revocation', async () => { + const { flowManager } = setupMCPMocks(); + MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue({ + clientInfo: { client_id: 'client-1' }, + clientMetadata: {}, + }); + MCPTokenStorage.getTokens.mockRejectedValue(new Error('token lookup failed')); + + const res = createResponse(); + await updateUserPluginsController(createRequest(), res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(MCPTokenStorage.getTokens).toHaveBeenCalledWith({ + userId: 'user-1', + serverName: 'test-server', + findToken: mockFindToken, + }); + expect(logger.warn).toHaveBeenCalledWith( + '[maybeUninstallOAuthMCP] Unable to load OAuth tokens for test-server; clearing local token state.', + expect.any(Error), + ); + expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({ + userId: 'user-1', + serverName: 'test-server', + deleteToken: expect.any(Function), + }); + expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens'); + expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_oauth'); + expect(MCPOAuthHandler.revokeOAuthToken).not.toHaveBeenCalled(); + }); + + it('revokes provider tokens before clearing local token state when token data is available', async () => { + setupMCPMocks(); + MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue({ + clientInfo: { client_id: 'client-1', client_secret: 'secret-1' }, + clientMetadata: { revocation_endpoint: 'https://example.com/revoke' }, + }); + MCPTokenStorage.getTokens.mockResolvedValue({ + access_token: 'access-token', + refresh_token: 'refresh-token', + }); + MCPOAuthHandler.revokeOAuthToken.mockResolvedValue(); + + const res = createResponse(); + await updateUserPluginsController(createRequest(), res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(MCPTokenStorage.getTokens).toHaveBeenCalledWith({ + userId: 'user-1', + serverName: 'test-server', + findToken: mockFindToken, + }); + expect(MCPOAuthHandler.revokeOAuthToken).toHaveBeenCalledWith( + 'test-server', + 'access-token', + 'access', + { + serverUrl: 'https://example.com/mcp', + clientId: 'client-1', + clientSecret: 'secret-1', + revocationEndpoint: 'https://example.com/revoke', + revocationEndpointAuthMethodsSupported: undefined, + }, + {}, + [], + ); + expect(MCPOAuthHandler.revokeOAuthToken).toHaveBeenCalledWith( + 'test-server', + 'refresh-token', + 'refresh', + { + serverUrl: 'https://example.com/mcp', + clientId: 'client-1', + clientSecret: 'secret-1', + revocationEndpoint: 'https://example.com/revoke', + revocationEndpointAuthMethodsSupported: undefined, + }, + {}, + [], + ); + expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({ + userId: 'user-1', + serverName: 'test-server', + deleteToken: expect.any(Function), + }); + }); + + it('revokes only the access token when refresh token data is absent', async () => { + setupMCPMocks(); + MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue({ + clientInfo: { client_id: 'client-1', client_secret: 'secret-1' }, + clientMetadata: {}, + }); + MCPTokenStorage.getTokens.mockResolvedValue({ + access_token: 'access-token', + }); + MCPOAuthHandler.revokeOAuthToken.mockResolvedValue(); + + const res = createResponse(); + await updateUserPluginsController(createRequest(), res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(MCPOAuthHandler.revokeOAuthToken).toHaveBeenCalledTimes(1); + expect(MCPOAuthHandler.revokeOAuthToken).toHaveBeenCalledWith( + 'test-server', + 'access-token', + 'access', + expect.objectContaining({ clientId: 'client-1' }), + {}, + [], + ); + expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({ + userId: 'user-1', + serverName: 'test-server', + deleteToken: expect.any(Function), + }); + }); + + it('revokes only the refresh token when access token data is absent', async () => { + setupMCPMocks(); + MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue({ + clientInfo: { client_id: 'client-1', client_secret: 'secret-1' }, + clientMetadata: {}, + }); + MCPTokenStorage.getTokens.mockResolvedValue({ + refresh_token: 'refresh-token', + }); + MCPOAuthHandler.revokeOAuthToken.mockResolvedValue(); + + const res = createResponse(); + await updateUserPluginsController(createRequest(), res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(MCPOAuthHandler.revokeOAuthToken).toHaveBeenCalledTimes(1); + expect(MCPOAuthHandler.revokeOAuthToken).toHaveBeenCalledWith( + 'test-server', + 'refresh-token', + 'refresh', + expect.objectContaining({ clientId: 'client-1' }), + {}, + [], + ); + expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({ + userId: 'user-1', + serverName: 'test-server', + deleteToken: expect.any(Function), + }); + }); +}); diff --git a/api/server/controllers/__tests__/deleteUser.spec.js b/api/server/controllers/__tests__/deleteUser.spec.js index a382a6cdc77f..bc6acde53d76 100644 --- a/api/server/controllers/__tests__/deleteUser.spec.js +++ b/api/server/controllers/__tests__/deleteUser.spec.js @@ -17,6 +17,7 @@ const mockProcessDeleteRequest = jest.fn(); const mockDeleteToolCalls = jest.fn(); const mockDeleteUserAgents = jest.fn(); const mockDeleteUserPrompts = jest.fn(); +const mockDeleteUserSkills = jest.fn(); jest.mock('@librechat/data-schemas', () => ({ logger: { error: jest.fn(), info: jest.fn() }, @@ -56,6 +57,7 @@ jest.mock('~/models', () => ({ deleteToolCalls: (...args) => mockDeleteToolCalls(...args), deleteUserAgents: (...args) => mockDeleteUserAgents(...args), deleteUserPrompts: (...args) => mockDeleteUserPrompts(...args), + deleteUserSkills: (...args) => mockDeleteUserSkills(...args), deleteTransactions: jest.fn(), deleteBalances: jest.fn(), deleteAllAgentApiKeys: jest.fn(), @@ -130,6 +132,7 @@ function stubDeletionMocks() { mockDeleteToolCalls.mockResolvedValue(); mockDeleteUserAgents.mockResolvedValue(); mockDeleteUserPrompts.mockResolvedValue(); + mockDeleteUserSkills.mockResolvedValue(0); } beforeEach(() => { @@ -148,6 +151,9 @@ describe('deleteUserController - 2FA enforcement', () => { expect(res.status).toHaveBeenCalledWith(200); expect(res.send).toHaveBeenCalledWith({ message: 'User deleted' }); expect(mockDeleteMessages).toHaveBeenCalled(); + expect(mockDeleteUserAgents).toHaveBeenCalledWith('user1'); + expect(mockDeleteUserPrompts).toHaveBeenCalledWith('user1'); + expect(mockDeleteUserSkills).toHaveBeenCalledWith('user1'); expect(mockVerifyOTPOrBackupCode).not.toHaveBeenCalled(); }); diff --git a/api/server/controllers/__tests__/deleteUserResourceCoverage.spec.js b/api/server/controllers/__tests__/deleteUserResourceCoverage.spec.js index b08e502800e1..78fcfa16b0bb 100644 --- a/api/server/controllers/__tests__/deleteUserResourceCoverage.spec.js +++ b/api/server/controllers/__tests__/deleteUserResourceCoverage.spec.js @@ -15,6 +15,7 @@ const HANDLED_RESOURCE_TYPES = { [ResourceType.REMOTE_AGENT]: 'deleteUserAgents', [ResourceType.PROMPTGROUP]: 'deleteUserPrompts', [ResourceType.MCPSERVER]: 'deleteUserMcpServers', + [ResourceType.SKILL]: 'deleteUserSkills', }; /** diff --git a/api/server/controllers/__tests__/maybeUninstallOAuthMCP.spec.js b/api/server/controllers/__tests__/maybeUninstallOAuthMCP.spec.js new file mode 100644 index 000000000000..65e12cb5b98c --- /dev/null +++ b/api/server/controllers/__tests__/maybeUninstallOAuthMCP.spec.js @@ -0,0 +1,297 @@ +const mockGetTokens = jest.fn(); +const mockDeleteUserTokens = jest.fn(); +const mockGetClientInfoAndMetadata = jest.fn(); +const mockRevokeOAuthToken = jest.fn(); +const mockGetServerConfig = jest.fn(); +const mockGetOAuthServers = jest.fn(); +const mockGetAllowedDomains = jest.fn(); +const mockDeleteFlow = jest.fn(); +const mockGetLogStores = jest.fn(); +const mockFindToken = jest.fn(); +const mockDeleteTokens = jest.fn(); +const mockLoggerInfo = jest.fn(); +const mockLoggerWarn = jest.fn(); +const mockLoggerError = jest.fn(); + +jest.mock('@librechat/data-schemas', () => ({ + logger: { info: mockLoggerInfo, warn: mockLoggerWarn, error: mockLoggerError }, + webSearchKeys: [], +})); + +jest.mock('@librechat/api', () => { + return { + MCPOAuthHandler: { + revokeOAuthToken: (...args) => mockRevokeOAuthToken(...args), + generateFlowId: (userId, serverName) => `${userId}:${serverName}`, + }, + MCPTokenStorage: { + getTokens: (...args) => mockGetTokens(...args), + getClientInfoAndMetadata: (...args) => mockGetClientInfoAndMetadata(...args), + deleteUserTokens: (...args) => mockDeleteUserTokens(...args), + }, + normalizeHttpError: jest.fn(), + extractWebSearchEnvVars: jest.fn(), + needsRefresh: jest.fn(), + getNewS3URL: jest.fn(), + }; +}); + +jest.mock('librechat-data-provider', () => ({ + Tools: {}, + CacheKeys: { FLOWS: 'flows' }, + Constants: { mcp_delimiter: '::', mcp_prefix: 'mcp_' }, + FileSources: {}, + ResourceType: {}, +})); + +jest.mock('~/config', () => ({ + getMCPManager: jest.fn(), + getFlowStateManager: jest.fn(() => ({ + deleteFlow: (...args) => mockDeleteFlow(...args), + })), + getMCPServersRegistry: jest.fn(() => ({ + getServerConfig: (...args) => mockGetServerConfig(...args), + getOAuthServers: (...args) => mockGetOAuthServers(...args), + getAllowedDomains: (...args) => mockGetAllowedDomains(...args), + })), +})); + +jest.mock('~/cache', () => ({ + getLogStores: (...args) => mockGetLogStores(...args), +})); + +jest.mock('~/server/services/PluginService', () => ({ + updateUserPluginAuth: jest.fn(), + deleteUserPluginAuth: jest.fn(), +})); + +jest.mock('~/server/services/twoFactorService', () => ({ + verifyOTPOrBackupCode: jest.fn(), +})); + +jest.mock('~/server/services/AuthService', () => ({ + verifyEmail: jest.fn(), + resendVerificationEmail: jest.fn(), +})); + +jest.mock('~/server/services/Config/getCachedTools', () => ({ + invalidateCachedTools: jest.fn(), +})); + +jest.mock('~/server/services/Files/process', () => ({ + processDeleteRequest: jest.fn(), +})); + +jest.mock('~/server/services/Config', () => ({ + getAppConfig: jest.fn(), +})); + +jest.mock('~/models', () => ({ + findToken: (...args) => mockFindToken(...args), + deleteTokens: (...args) => mockDeleteTokens(...args), + updateUser: jest.fn(), + deleteAllUserSessions: jest.fn(), + deleteAllSharedLinks: jest.fn(), + updateUserPlugins: jest.fn(), + deleteUserById: jest.fn(), + deleteMessages: jest.fn(), + deletePresets: jest.fn(), + deleteUserKey: jest.fn(), + getUserById: jest.fn(), + deleteConvos: jest.fn(), + deleteFiles: jest.fn(), + getFiles: jest.fn(), + deleteToolCalls: jest.fn(), + deleteUserAgents: jest.fn(), + deleteUserPrompts: jest.fn(), + deleteTransactions: jest.fn(), + deleteBalances: jest.fn(), + deleteAllAgentApiKeys: jest.fn(), + deleteAssistants: jest.fn(), + deleteConversationTags: jest.fn(), + deleteAllUserMemories: jest.fn(), + deleteActions: jest.fn(), + removeUserFromAllGroups: jest.fn(), + deleteAclEntries: jest.fn(), + getSoleOwnedResourceIds: jest.fn().mockResolvedValue([]), +})); + +const { maybeUninstallOAuthMCP } = require('~/server/controllers/UserController'); + +const userId = 'user-123'; +const pluginKey = 'mcp_acme'; +const serverName = 'acme'; + +const serverConfig = { + url: 'https://acme.example.com', + oauth: { + revocation_endpoint: 'https://acme.example.com/revoke', + revocation_endpoint_auth_methods_supported: ['client_secret_basic'], + }, + oauth_headers: { 'X-Tenant': 'acme' }, +}; + +const appConfig = { + mcpServers: { acme: serverConfig }, +}; + +const clientInfo = { client_id: 'cid', client_secret: 'csec' }; +const clientMetadata = {}; + +function setupOAuthServerFound() { + mockGetServerConfig.mockResolvedValue(serverConfig); + mockGetOAuthServers.mockResolvedValue(new Set([serverName])); + mockGetAllowedDomains.mockReturnValue(['https://acme.example.com']); + mockGetClientInfoAndMetadata.mockResolvedValue({ clientInfo, clientMetadata }); +} + +describe('maybeUninstallOAuthMCP', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + test('is a no-op when pluginKey is not an MCP key', async () => { + await maybeUninstallOAuthMCP(userId, 'plugin_google_calendar', appConfig); + + expect(mockGetServerConfig).not.toHaveBeenCalled(); + expect(mockGetTokens).not.toHaveBeenCalled(); + expect(mockDeleteUserTokens).not.toHaveBeenCalled(); + expect(mockDeleteFlow).not.toHaveBeenCalled(); + }); + + test('clears stored state when the MCP server is not an OAuth server', async () => { + mockGetServerConfig.mockResolvedValue(serverConfig); + mockGetOAuthServers.mockResolvedValue(new Set(['other'])); + + await maybeUninstallOAuthMCP(userId, pluginKey, appConfig); + + expect(mockGetClientInfoAndMetadata).not.toHaveBeenCalled(); + expect(mockGetTokens).not.toHaveBeenCalled(); + expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1); + expect(mockDeleteUserTokens.mock.calls[0][0]).toMatchObject({ userId, serverName }); + expect(mockDeleteFlow).toHaveBeenCalledTimes(2); + }); + + test('clears stored state when client info is missing', async () => { + setupOAuthServerFound(); + mockGetClientInfoAndMetadata.mockResolvedValue(null); + + await maybeUninstallOAuthMCP(userId, pluginKey, appConfig); + + expect(mockGetTokens).not.toHaveBeenCalled(); + expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1); + expect(mockDeleteUserTokens.mock.calls[0][0]).toMatchObject({ userId, serverName }); + expect(mockDeleteFlow).toHaveBeenCalledTimes(2); + }); + + test('clears stored state when client info cannot be loaded', async () => { + setupOAuthServerFound(); + mockGetClientInfoAndMetadata.mockRejectedValue(new Error('bad client data')); + mockDeleteUserTokens.mockResolvedValue(undefined); + mockDeleteFlow.mockResolvedValue(undefined); + + await maybeUninstallOAuthMCP(userId, pluginKey, appConfig); + + expect(mockGetTokens).not.toHaveBeenCalled(); + expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1); + expect(mockDeleteUserTokens.mock.calls[0][0]).toMatchObject({ userId, serverName }); + expect(mockDeleteFlow).toHaveBeenCalledTimes(2); + expect(mockLoggerWarn).toHaveBeenCalledWith( + `[maybeUninstallOAuthMCP] Unable to load OAuth client metadata for ${serverName}; clearing local MCP OAuth state only.`, + expect.any(Error), + ); + }); + + test('revokes both tokens and runs cleanup on happy path', async () => { + setupOAuthServerFound(); + mockGetTokens.mockResolvedValue({ + access_token: 'access-abc', + refresh_token: 'refresh-xyz', + }); + mockRevokeOAuthToken.mockResolvedValue(undefined); + mockDeleteUserTokens.mockResolvedValue(undefined); + mockDeleteFlow.mockResolvedValue(undefined); + + await maybeUninstallOAuthMCP(userId, pluginKey, appConfig); + + expect(mockRevokeOAuthToken).toHaveBeenCalledTimes(2); + expect(mockRevokeOAuthToken.mock.calls[0][1]).toBe('access-abc'); + expect(mockRevokeOAuthToken.mock.calls[0][2]).toBe('access'); + expect(mockRevokeOAuthToken.mock.calls[1][1]).toBe('refresh-xyz'); + expect(mockRevokeOAuthToken.mock.calls[1][2]).toBe('refresh'); + + expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1); + expect(mockDeleteUserTokens.mock.calls[0][0]).toMatchObject({ userId, serverName }); + + expect(mockDeleteFlow).toHaveBeenCalledTimes(2); + expect(mockDeleteFlow.mock.calls[0][1]).toBe('mcp_get_tokens'); + expect(mockDeleteFlow.mock.calls[1][1]).toBe('mcp_oauth'); + }); + + test('skips revocation but still runs cleanup when token retrieval fails', async () => { + setupOAuthServerFound(); + mockGetTokens.mockRejectedValue(new Error('missing')); + mockDeleteUserTokens.mockResolvedValue(undefined); + mockDeleteFlow.mockResolvedValue(undefined); + + await expect(maybeUninstallOAuthMCP(userId, pluginKey, appConfig)).resolves.toBeUndefined(); + + expect(mockRevokeOAuthToken).not.toHaveBeenCalled(); + expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1); + expect(mockDeleteFlow).toHaveBeenCalledTimes(2); + expect(mockLoggerWarn).toHaveBeenCalledWith( + `[maybeUninstallOAuthMCP] Unable to load OAuth tokens for ${serverName}; clearing local token state.`, + expect.any(Error), + ); + }); + + test('skips revocation, logs warn, and still runs cleanup on unexpected token-retrieval error', async () => { + setupOAuthServerFound(); + mockGetTokens.mockRejectedValue(new Error('boom: unreachable')); + mockDeleteUserTokens.mockResolvedValue(undefined); + mockDeleteFlow.mockResolvedValue(undefined); + + await expect(maybeUninstallOAuthMCP(userId, pluginKey, appConfig)).resolves.toBeUndefined(); + + expect(mockRevokeOAuthToken).not.toHaveBeenCalled(); + expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1); + expect(mockDeleteFlow).toHaveBeenCalledTimes(2); + expect(mockLoggerWarn).toHaveBeenCalledWith( + `[maybeUninstallOAuthMCP] Unable to load OAuth tokens for ${serverName}; clearing local token state.`, + expect.any(Error), + ); + }); + + test('continues cleanup when only one token type is present', async () => { + setupOAuthServerFound(); + mockGetTokens.mockResolvedValue({ access_token: 'only-access' }); + mockRevokeOAuthToken.mockResolvedValue(undefined); + mockDeleteUserTokens.mockResolvedValue(undefined); + mockDeleteFlow.mockResolvedValue(undefined); + + await maybeUninstallOAuthMCP(userId, pluginKey, appConfig); + + expect(mockRevokeOAuthToken).toHaveBeenCalledTimes(1); + expect(mockRevokeOAuthToken.mock.calls[0][2]).toBe('access'); + expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1); + expect(mockDeleteFlow).toHaveBeenCalledTimes(2); + }); + + test('still runs cleanup even when both revocation calls fail', async () => { + setupOAuthServerFound(); + mockGetTokens.mockResolvedValue({ + access_token: 'a', + refresh_token: 'r', + }); + mockRevokeOAuthToken.mockRejectedValue(new Error('network down')); + mockDeleteUserTokens.mockResolvedValue(undefined); + mockDeleteFlow.mockResolvedValue(undefined); + + await expect(maybeUninstallOAuthMCP(userId, pluginKey, appConfig)).resolves.toBeUndefined(); + + expect(mockRevokeOAuthToken).toHaveBeenCalledTimes(2); + expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1); + expect(mockDeleteFlow).toHaveBeenCalledTimes(2); + expect(mockLoggerError).toHaveBeenCalled(); + }); +}); diff --git a/api/server/controllers/__tests__/tools.verifyToolAuth.spec.js b/api/server/controllers/__tests__/tools.verifyToolAuth.spec.js new file mode 100644 index 000000000000..03965021c471 --- /dev/null +++ b/api/server/controllers/__tests__/tools.verifyToolAuth.spec.js @@ -0,0 +1,102 @@ +jest.mock('@librechat/data-schemas', () => ({ + logger: { debug: jest.fn(), error: jest.fn(), warn: jest.fn() }, +})); + +jest.mock('@librechat/api', () => ({ + checkAccess: jest.fn(), + loadWebSearchAuth: jest.fn(), +})); + +jest.mock('~/models', () => ({ + getRoleByName: jest.fn(), + createToolCall: jest.fn(), + getToolCallsByConvo: jest.fn(), + getMessage: jest.fn(), +})); + +jest.mock('~/server/services/Files/process', () => ({ + processFileURL: jest.fn(), + uploadImageBuffer: jest.fn(), +})); + +jest.mock('~/server/services/Files/Code/process', () => ({ + processCodeOutput: jest.fn(), +})); + +jest.mock('~/server/services/Tools/credentials', () => ({ + loadAuthValues: jest.fn(), +})); + +jest.mock('~/app/clients/tools/util', () => ({ + loadTools: jest.fn(), +})); + +const { Tools, AuthType } = require('librechat-data-provider'); +const { verifyToolAuth } = require('../tools'); + +/** + * Phase 8 behavioral pin: `verifyToolAuth(execute_code)` unconditionally + * returns system-authenticated. Sandbox auth moved server-side into the + * agents library, so the per-user `CODE_API_KEY` check that previously + * gated this endpoint is gone. The deployment contract is: if the + * admin enabled the `execute_code` capability, the sandbox is + * reachable. This endpoint does not probe reachability (would be too + * expensive per UI-gate query); failures surface at execution time. + * + * A regression where someone re-adds an auth check here would + * resurrect the per-user key-entry dialog on the client, which Phase 8 + * explicitly removed. Pin the contract. + */ +describe('verifyToolAuth — execute_code system-auth contract', () => { + const makeReq = (toolId) => ({ + params: { toolId }, + user: { id: 'user-1' }, + config: {}, + }); + + const makeRes = () => { + const res = {}; + res.status = jest.fn().mockReturnValue(res); + res.json = jest.fn().mockReturnValue(res); + return res; + }; + + it('returns authenticated: true with SYSTEM_DEFINED for execute_code', async () => { + const res = makeRes(); + await verifyToolAuth(makeReq(Tools.execute_code), res); + + expect(res.status).toHaveBeenCalledWith(200); + expect(res.json).toHaveBeenCalledWith({ + authenticated: true, + message: AuthType.SYSTEM_DEFINED, + }); + }); + + it('returns 404 for unknown tool ids (not in directCallableTools)', async () => { + const res = makeRes(); + await verifyToolAuth(makeReq('not_a_real_tool'), res); + + expect(res.status).toHaveBeenCalledWith(404); + expect(res.json).toHaveBeenCalledWith({ message: 'Tool not found' }); + }); + + it('does NOT invoke loadAuthValues for execute_code (no per-user credential check)', async () => { + /* Regression guard: a future refactor that threads per-user auth back + in would resurface the key-entry dialog on the client. Pin that + the auth path is never consulted. */ + const { loadAuthValues } = require('~/server/services/Tools/credentials'); + loadAuthValues.mockClear(); + + await verifyToolAuth(makeReq(Tools.execute_code), makeRes()); + + expect(loadAuthValues).not.toHaveBeenCalled(); + }); + + it('does NOT reference AuthType.USER_PROVIDED in the response (Phase 8 removed the path)', async () => { + const res = makeRes(); + await verifyToolAuth(makeReq(Tools.execute_code), res); + + const payload = res.json.mock.calls[0][0]; + expect(payload.message).not.toBe(AuthType.USER_PROVIDED); + }); +}); diff --git a/api/server/controllers/agents/__tests__/client.memory.spec.js b/api/server/controllers/agents/__tests__/client.memory.spec.js new file mode 100644 index 000000000000..de8eeb21536e --- /dev/null +++ b/api/server/controllers/agents/__tests__/client.memory.spec.js @@ -0,0 +1,74 @@ +const { EModelEndpoint, AgentCapabilities } = require('librechat-data-provider'); + +/** + * Pins the capability-flag derivation that `AgentClient::useMemory` uses when + * it calls `initializeAgent` for the memory-extraction agent. The expression + * is trivial but lives in a controller path that's otherwise hard to unit- + * test, so a focused regression guard at the pure-logic layer ensures any + * drift in config-key names (`agents`, `capabilities`) or capability enum + * values (`execute_code`) surfaces here instead of silently stripping + * `bash_tool` + `read_file` from memory agents in production. + * + * The expression mirrored below is the one in + * `api/server/controllers/agents/client.js::useMemory`: + * + * new Set(appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities) + * .has(AgentCapabilities.execute_code) + */ +function deriveMemoryCodeEnvAvailable(appConfig) { + return new Set(appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities).has( + AgentCapabilities.execute_code, + ); +} + +describe('AgentClient::useMemory — codeEnvAvailable derivation', () => { + it('returns true when appConfig lists execute_code under the agents endpoint capabilities', () => { + expect( + deriveMemoryCodeEnvAvailable({ + endpoints: { + [EModelEndpoint.agents]: { + capabilities: [AgentCapabilities.execute_code, AgentCapabilities.file_search], + }, + }, + }), + ).toBe(true); + }); + + it('returns false when the agents endpoint omits execute_code', () => { + expect( + deriveMemoryCodeEnvAvailable({ + endpoints: { + [EModelEndpoint.agents]: { + capabilities: [AgentCapabilities.file_search, AgentCapabilities.web_search], + }, + }, + }), + ).toBe(false); + }); + + it('returns false when the capabilities array is absent', () => { + expect(deriveMemoryCodeEnvAvailable({ endpoints: { [EModelEndpoint.agents]: {} } })).toBe( + false, + ); + }); + + it('returns false when the agents endpoint config is absent', () => { + expect(deriveMemoryCodeEnvAvailable({ endpoints: {} })).toBe(false); + }); + + it('returns false when appConfig is null / undefined', () => { + /* Defensive — `req.config` can be unset in edge-case test harnesses and + ephemeral-agent flows; the memory path must not throw on access. */ + expect(deriveMemoryCodeEnvAvailable(null)).toBe(false); + expect(deriveMemoryCodeEnvAvailable(undefined)).toBe(false); + }); + + it('matches the literal string "execute_code" — catches enum rename drift', () => { + /* Pins the capability enum value so a rename of `AgentCapabilities.execute_code` + that doesn't propagate to the controllers surfaces here. If this test breaks, + update the underlying expression in `useMemory` and the helpers in + `initialize.js` / `openai.js` / `responses.js` to match. */ + expect(AgentCapabilities.execute_code).toBe('execute_code'); + expect(EModelEndpoint.agents).toBe('agents'); + }); +}); diff --git a/api/server/controllers/agents/__tests__/openai.spec.js b/api/server/controllers/agents/__tests__/openai.spec.js index f55b798f14a2..7638fc2e35d7 100644 --- a/api/server/controllers/agents/__tests__/openai.spec.js +++ b/api/server/controllers/agents/__tests__/openai.spec.js @@ -40,6 +40,11 @@ jest.mock('@librechat/api', () => ({ }), createChunk: jest.fn().mockReturnValue({}), buildToolSet: jest.fn().mockReturnValue(new Set()), + scopeSkillIds: jest.fn().mockImplementation((ids) => ids), + resolveAgentScopedSkillIds: jest + .fn() + .mockImplementation(({ accessibleSkillIds }) => accessibleSkillIds), + loadSkillStates: jest.fn().mockResolvedValue({ skillStates: {}, defaultActiveOnShare: false }), sendFinalChunk: jest.fn(), createSafeUser: jest.fn().mockReturnValue({ id: 'user-123' }), validateRequest: jest @@ -56,6 +61,15 @@ jest.mock('@librechat/api', () => ({ createErrorResponse: jest.fn(), getTransactionsConfig: mockGetTransactionsConfig, recordCollectedUsage: mockRecordCollectedUsage, + extractManualSkills: jest.fn().mockReturnValue(undefined), + injectSkillPrimes: jest.fn().mockReturnValue({ + initialMessages: [], + indexTokenCountMap: {}, + inserted: 0, + insertIdx: -1, + alwaysApplyDropped: 0, + alwaysApplyDedupedFromManual: 0, + }), buildNonStreamingResponse: jest.fn().mockReturnValue({ id: 'resp-123' }), createOpenAIStreamTracker: jest.fn().mockReturnValue({ addText: jest.fn(), @@ -114,6 +128,19 @@ jest.mock('~/server/services/PermissionService', () => ({ checkPermission: jest.fn().mockResolvedValue(true), })); +jest.mock('~/server/services/Files/strategies', () => ({ + getStrategyFunctions: jest.fn().mockReturnValue({}), +})); + +jest.mock('~/server/services/Files/Code/crud', () => ({ + batchUploadCodeEnvFiles: jest.fn().mockResolvedValue({ session_id: '', files: [] }), +})); + +jest.mock('~/server/services/Files/Code/process', () => ({ + getSessionInfo: jest.fn().mockResolvedValue(null), + checkIfActive: jest.fn().mockReturnValue(false), +})); + const mockUpdateBalance = jest.fn().mockResolvedValue({}); const mockBulkInsertTransactions = jest.fn().mockResolvedValue(undefined); diff --git a/api/server/controllers/agents/__tests__/responses.unit.spec.js b/api/server/controllers/agents/__tests__/responses.unit.spec.js index 1a6fa6053f14..e5569fbbf578 100644 --- a/api/server/controllers/agents/__tests__/responses.unit.spec.js +++ b/api/server/controllers/agents/__tests__/responses.unit.spec.js @@ -41,6 +41,11 @@ jest.mock('@librechat/api', () => ({ processStream: jest.fn().mockResolvedValue(undefined), }), buildToolSet: jest.fn().mockReturnValue(new Set()), + scopeSkillIds: jest.fn().mockImplementation((ids) => ids), + resolveAgentScopedSkillIds: jest + .fn() + .mockImplementation(({ accessibleSkillIds }) => accessibleSkillIds), + loadSkillStates: jest.fn().mockResolvedValue({ skillStates: {}, defaultActiveOnShare: false }), createSafeUser: jest.fn().mockReturnValue({ id: 'user-123' }), initializeAgent: jest.fn().mockResolvedValue({ id: 'agent-123', @@ -58,6 +63,15 @@ jest.mock('@librechat/api', () => ({ getBalanceConfig: mockGetBalanceConfig, getTransactionsConfig: mockGetTransactionsConfig, recordCollectedUsage: mockRecordCollectedUsage, + extractManualSkills: jest.fn().mockReturnValue(undefined), + injectSkillPrimes: jest.fn().mockReturnValue({ + initialMessages: [], + indexTokenCountMap: {}, + inserted: 0, + insertIdx: -1, + alwaysApplyDropped: 0, + alwaysApplyDedupedFromManual: 0, + }), createToolExecuteHandler: jest.fn().mockReturnValue({ handle: jest.fn() }), // Responses API writeDone: jest.fn(), @@ -144,6 +158,19 @@ jest.mock('~/cache', () => ({ logViolation: jest.fn(), })); +jest.mock('~/server/services/Files/strategies', () => ({ + getStrategyFunctions: jest.fn().mockReturnValue({}), +})); + +jest.mock('~/server/services/Files/Code/crud', () => ({ + batchUploadCodeEnvFiles: jest.fn().mockResolvedValue({ session_id: '', files: [] }), +})); + +jest.mock('~/server/services/Files/Code/process', () => ({ + getSessionInfo: jest.fn().mockResolvedValue(null), + checkIfActive: jest.fn().mockReturnValue(false), +})); + const mockUpdateBalance = jest.fn().mockResolvedValue({}); const mockBulkInsertTransactions = jest.fn().mockResolvedValue(undefined); diff --git a/api/server/controllers/agents/callbacks.js b/api/server/controllers/agents/callbacks.js index 40fdf7421294..2af4d5b45198 100644 --- a/api/server/controllers/agents/callbacks.js +++ b/api/server/controllers/agents/callbacks.js @@ -2,11 +2,11 @@ const { nanoid } = require('nanoid'); const { logger } = require('@librechat/data-schemas'); const { Tools, StepTypes, FileContext, ErrorTypes } = require('librechat-data-provider'); const { - EnvVar, - Constants, GraphEvents, GraphNodeKeys, ToolEndHandler, + CODE_EXECUTION_TOOLS, + createContentAggregator, } = require('@librechat/agents'); const { sendEvent, @@ -16,7 +16,6 @@ const { } = require('@librechat/api'); const { processFileCitations } = require('~/server/services/Files/Citations'); const { processCodeOutput } = require('~/server/services/Files/Code/process'); -const { loadAuthValues } = require('~/server/services/Tools/credentials'); const { saveBase64Image } = require('~/server/services/Files/process'); class ModelEndHandler { @@ -76,6 +75,9 @@ class ModelEndHandler { if (modelName) { usage.model = modelName; } + if (agentContext.provider) { + usage.provider = agentContext.provider; + } const taggedUsage = markSummarizationUsage(usage, metadata); @@ -116,6 +118,48 @@ async function emitEvent(res, streamId, eventData) { } } +/** + * Maps a {@link SubagentUpdateEvent} phase to the corresponding + * {@link GraphEvents} name that the SDK's `createContentAggregator` + * knows how to consume. Phases that don't carry content (`start`, `stop`, + * `error`) or whose payload doesn't match a handled event (`run_step` + * with an `ON_TOOL_EXECUTE`-shaped batch request rather than a RunStep) + * return `null` so the caller skips them. + * @param {SubagentUpdateEvent} event + * @returns {string | null} + */ +function subagentPhaseToGraphEvent(event) { + switch (event?.phase) { + case 'run_step': + /** `ON_RUN_STEP` and `ON_TOOL_EXECUTE` both forward with phase + * `run_step`; only the former matches the aggregator's RunStep + * schema. Detect by presence of `stepDetails`. */ + return event.data?.stepDetails ? GraphEvents.ON_RUN_STEP : null; + case 'run_step_delta': + return GraphEvents.ON_RUN_STEP_DELTA; + case 'run_step_completed': + return GraphEvents.ON_RUN_STEP_COMPLETED; + case 'message_delta': + return GraphEvents.ON_MESSAGE_DELTA; + case 'reasoning_delta': + return GraphEvents.ON_REASONING_DELTA; + default: + return null; + } +} + +/** + * Folds a single {@link SubagentUpdateEvent} into the given content + * aggregator. Silent no-op for phases outside the aggregator's domain. + * @param {{ aggregateContent: Function }} aggregator + * @param {SubagentUpdateEvent} event + */ +function feedSubagentAggregator(aggregator, event) { + const graphEvent = subagentPhaseToGraphEvent(event); + if (!graphEvent) return; + aggregator.aggregateContent({ event: graphEvent, data: event.data }); +} + /** * @typedef {Object} ToolExecuteOptions * @property {(toolNames: string[]) => Promise<{loadedTools: StructuredTool[]}>} loadTools - Function to load tools by name @@ -142,6 +186,7 @@ function getDefaultHandlers({ streamId = null, toolExecuteOptions = null, summarizationOptions = null, + subagentAggregatorsByToolCallId = null, }) { if (!res || !aggregateContent) { throw new Error( @@ -254,6 +299,55 @@ function getDefaultHandlers({ handlers[GraphEvents.ON_TOOL_EXECUTE] = createToolExecuteHandler(toolExecuteOptions); } + handlers[GraphEvents.ON_SUBAGENT_UPDATE] = { + /** + * Forwards subagent progress envelopes to the client stream, and + * (when a caller-owned aggregator map is provided) also folds each + * event into a per-tool-call `createContentAggregator`. The + * resulting `contentParts` are attached to the parent's `subagent` + * tool_call at message-save time so the child's reasoning / tool + * calls / final text survive a page refresh — in-memory Recoil + * atoms alone wouldn't persist that. + * + * Aggregation runs regardless of stream visibility (persistence + + * dialog depend on it), but the SSE forward respects + * `hide_sequential_outputs` the same way `ON_RUN_STEP`, + * `ON_MESSAGE_DELTA`, etc. do — so intermediate agents in a + * sequential chain don't leak their subagent activity when the + * chain is configured to suppress intermediates. + */ + handle: async (event, data, metadata) => { + const isLastAgent = checkIfLastAgent(metadata?.last_agent_id, metadata?.langgraph_node); + const visible = isLastAgent || !metadata?.hide_sequential_outputs; + /** + * Gate BOTH aggregation (persistence) AND streaming on the same + * visibility rule. If we aggregated for a hidden intermediate + * agent, `finalizeSubagentContent` would still attach its + * child's reasoning / tool output to the saved message — so a + * page refresh would reveal activity that was intentionally + * suppressed live. Treat hide_sequential_outputs as a + * consistent "don't record" rule for subagent traces. + */ + if (!visible) return; + if (subagentAggregatorsByToolCallId && data?.parentToolCallId) { + const key = data.parentToolCallId; + let aggregator = subagentAggregatorsByToolCallId.get(key); + if (!aggregator) { + aggregator = createContentAggregator(); + subagentAggregatorsByToolCallId.set(key, aggregator); + } + try { + feedSubagentAggregator(aggregator, data); + } catch (err) { + logger.warn( + `[ON_SUBAGENT_UPDATE] Failed to aggregate phase "${data?.phase}" for tool_call ${key}: ${err?.message ?? err}`, + ); + } + } + await emitEvent(res, streamId, { event, data }); + }, + }; + if (summarizationOptions?.enabled !== false) { handlers[GraphEvents.ON_SUMMARIZE_START] = { handle: async (_event, data) => { @@ -443,9 +537,7 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null }) return; } - const isCodeTool = - output.name === Tools.execute_code || output.name === Constants.PROGRAMMATIC_TOOL_CALLING; - if (!isCodeTool) { + if (!CODE_EXECUTION_TOOLS.has(output.name)) { return; } @@ -454,22 +546,42 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null }) } for (const file of output.artifact.files) { + /* `inherited` files are unchanged passthroughs of inputs the caller + * already owns (skill files, prior session inputs, inherited + * .dirkeep markers). Skip post-processing: re-downloading with the + * user's session key 403s when the file is entity-scoped, and the + * input is already persisted at its origin. They remain available + * to subsequent calls via primeInvokedSkills / session inheritance. */ + if (file.inherited) { + continue; + } const { id, name } = file; artifactPromises.push( (async () => { - const result = await loadAuthValues({ - userId: req.user.id, - authFields: [EnvVar.CODE_API_KEY], - }); const fileMetadata = await processCodeOutput({ req, id, name, - apiKey: result[EnvVar.CODE_API_KEY], messageId: metadata.run_id, toolCallId: output.tool_call_id, conversationId: metadata.thread_id, - session_id: output.artifact.session_id, + /** + * Use the FILE's session_id (storage session), not the + * top-level artifact session_id (exec session). The codeapi + * worker reports two distinct ids on a tool result: + * - `artifact.session_id` is the EXEC session — the + * sandbox VM that ran the bash command. Files don't + * live there; it's torn down post-execution. + * - `file.session_id` is the STORAGE session — the + * file-server bucket prefix where artifacts actually + * live and are served from. + * `processCodeOutput` builds `/download/{session_id}/{id}`, + * so passing the exec id resolves to a path the file-server + * doesn't know about and 404s. Fall back to artifact-level + * for older worker payloads that may not populate per-file + * ids. + */ + session_id: file.session_id ?? output.artifact.session_id, }); if (!streamId && !res.headersSent) { return fileMetadata; @@ -651,9 +763,7 @@ function createResponsesToolEndCallback({ req, res, tracker, artifactPromises }) return; } - const isCodeTool = - output.name === Tools.execute_code || output.name === Constants.PROGRAMMATIC_TOOL_CALLING; - if (!isCodeTool) { + if (!CODE_EXECUTION_TOOLS.has(output.name)) { return; } @@ -662,22 +772,42 @@ function createResponsesToolEndCallback({ req, res, tracker, artifactPromises }) } for (const file of output.artifact.files) { + /* `inherited` files are unchanged passthroughs of inputs the caller + * already owns (skill files, prior session inputs, inherited + * .dirkeep markers). Skip post-processing: re-downloading with the + * user's session key 403s when the file is entity-scoped, and the + * input is already persisted at its origin. They remain available + * to subsequent calls via primeInvokedSkills / session inheritance. */ + if (file.inherited) { + continue; + } const { id, name } = file; artifactPromises.push( (async () => { - const result = await loadAuthValues({ - userId: req.user.id, - authFields: [EnvVar.CODE_API_KEY], - }); const fileMetadata = await processCodeOutput({ req, id, name, - apiKey: result[EnvVar.CODE_API_KEY], messageId: metadata.run_id, toolCallId: output.tool_call_id, conversationId: metadata.thread_id, - session_id: output.artifact.session_id, + /** + * Use the FILE's session_id (storage session), not the + * top-level artifact session_id (exec session). The codeapi + * worker reports two distinct ids on a tool result: + * - `artifact.session_id` is the EXEC session — the + * sandbox VM that ran the bash command. Files don't + * live there; it's torn down post-execution. + * - `file.session_id` is the STORAGE session — the + * file-server bucket prefix where artifacts actually + * live and are served from. + * `processCodeOutput` builds `/download/{session_id}/{id}`, + * so passing the exec id resolves to a path the file-server + * doesn't know about and 404s. Fall back to artifact-level + * for older worker payloads that may not populate per-file + * ids. + */ + session_id: file.session_id ?? output.artifact.session_id, }); if (!fileMetadata) { diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 71fd843b0f16..dec8da160ce3 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -18,6 +18,7 @@ const { memoryInstructions, createTokenCounter, applyContextToAgent, + isMemoryAgentEnabled, recordCollectedUsage, GenerationJobManager, getTransactionsConfig, @@ -28,6 +29,10 @@ const { filterMalformedContentParts, countFormattedMessageTokens, hydrateMissingIndexTokenCounts, + injectSkillPrimes, + isSkillPrimeMessage, + buildSkillPrimeContentParts, + buildInitialToolSessions, } = require('@librechat/api'); const { Callback, @@ -44,6 +49,7 @@ const { ContentTypes, EModelEndpoint, PermissionTypes, + AgentCapabilities, isAgentsEndpoint, isEphemeralAgentId, removeNullishValues, @@ -78,6 +84,7 @@ class AgentClient extends BaseClient { collectedUsage, artifactPromises, maxContextTokens, + subagentAggregatorsByToolCallId, ...clientOptions } = options; @@ -89,6 +96,12 @@ class AgentClient extends BaseClient { this.collectedUsage = collectedUsage; /** @type {ArtifactPromises} */ this.artifactPromises = artifactPromises; + /** Per-request map of `createContentAggregator` instances keyed by + * the parent's `tool_call_id`. `ON_SUBAGENT_UPDATE` events stream + * into each aggregator as they arrive; `finalizeSubagentContent` + * harvests `contentParts` onto the matching `subagent` tool_call + * so the child's full activity survives a page refresh. */ + this.subagentAggregatorsByToolCallId = subagentAggregatorsByToolCallId ?? new Map(); /** @type {AgentClientOptions} */ this.options = Object.assign({ endpoint: options.endpoint }, clientOptions); /** @type {string} */ @@ -114,6 +127,45 @@ class AgentClient extends BaseClient { return this.contentParts; } + /** + * Harvest the `contentParts` from each per-subagent `createContentAggregator` + * instance and attach them onto the matching parent `subagent` tool_call + * as `subagent_content`. Runs once per message save (from + * `sendCompletion`'s `finally`) so the child's full reasoning / tool + * calls / final text survive a page refresh — the client-side Recoil + * atom is session-only. Aggregators keyed by a tool_call_id that never + * appeared in `contentParts` are discarded (no home to attach to). + */ + finalizeSubagentContent() { + const buffer = this.subagentAggregatorsByToolCallId; + if (!buffer || buffer.size === 0 || !Array.isArray(this.contentParts)) { + return; + } + for (const part of this.contentParts) { + if (part?.type !== ContentTypes.TOOL_CALL) continue; + const toolCall = part[ContentTypes.TOOL_CALL]; + if (!toolCall || toolCall.name !== Constants.SUBAGENT || !toolCall.id) continue; + const aggregator = buffer.get(toolCall.id); + if (!aggregator) continue; + try { + /** `createContentAggregator` returns a sparse array (undefined + * slots for indices that never received content). Strip those + * so the persisted shape is a clean `TMessageContentParts[]`. */ + const parts = Array.isArray(aggregator.contentParts) + ? aggregator.contentParts.filter((p) => p != null) + : []; + if (parts.length > 0) { + toolCall.subagent_content = parts; + } + } catch (err) { + logger.warn( + `[AgentClient] Failed to attach subagent content for tool_call ${toolCall.id}: ${err?.message ?? err}`, + ); + } + } + buffer.clear(); + } + setOptions(_options) {} /** @@ -194,25 +246,19 @@ class AgentClient extends BaseClient { /** @type {number | undefined} */ let promptTokens; - /** - * Extract base instructions for all agents (combines instructions + additional_instructions). - * This must be done before applying context to preserve the original agent configuration. - */ - const extractBaseInstructions = (agent) => { - const baseInstructions = [agent.instructions ?? '', agent.additional_instructions ?? ''] - .filter(Boolean) - .join('\n') - .trim(); - agent.instructions = baseInstructions; + /** Normalize instruction fields before applying per-run context. */ + const normalizeInstructions = (agent) => { + agent.instructions = agent.instructions?.trim() || undefined; + agent.additional_instructions = agent.additional_instructions?.trim() || undefined; return agent; }; - /** Collect all agents for unified processing, extracting base instructions during collection */ + /** Collect all agents for unified processing while preserving stable/dynamic instruction fields. */ const allAgents = [ - { agent: extractBaseInstructions(this.options.agent), agentId: this.options.agent.id }, + { agent: normalizeInstructions(this.options.agent), agentId: this.options.agent.id }, ...(this.agentConfigs?.size > 0 ? Array.from(this.agentConfigs.entries()).map(([agentId, agent]) => ({ - agent: extractBaseInstructions(agent), + agent: normalizeInstructions(agent), agentId, })) : []), @@ -321,7 +367,8 @@ class AgentClient extends BaseClient { /** * Build shared run context - applies to ALL agents in the run. - * This includes: file context (latest message), augmented prompt (RAG), memory context. + * This includes file context from the latest message and augmented prompt (RAG). + * Memory context is handled separately and applied per-agent based on config. */ const sharedRunContextParts = []; @@ -341,12 +388,12 @@ class AgentClient extends BaseClient { /** Memory context (user preferences/memories) */ const withoutKeys = await this.useMemory(); - if (withoutKeys) { - const memoryContext = `${memoryInstructions}\n\n# Existing memory about the user:\n${withoutKeys}`; - sharedRunContextParts.push(memoryContext); - } + const memoryContext = withoutKeys + ? `${memoryInstructions}\n\n# Existing memory about the user:\n${withoutKeys}` + : undefined; const sharedRunContext = sharedRunContextParts.join('\n\n'); + const memoryAgentEnabled = isMemoryAgentEnabled(this.options.req.config?.memory); /** Preserve canonical pre-format token counts for all history entering graph formatting */ this.indexTokenCountMap = canonicalTokenCountMap; @@ -372,7 +419,8 @@ class AgentClient extends BaseClient { /** * Apply context to all agents. - * Each agent gets: shared run context + their own base instructions + their own MCP instructions. + * Stable agent/MCP instructions stay on `instructions`; shared runtime context + * is appended to `additional_instructions` as the dynamic system tail. * * NOTE: This intentionally mutates agent objects in place. The agentConfigs Map * holds references to config objects that will be passed to the graph runtime. @@ -383,17 +431,22 @@ class AgentClient extends BaseClient { const configServers = await resolveConfigServers(this.options.req); await Promise.all( - allAgents.map(({ agent, agentId }) => - applyContextToAgent({ + allAgents.map(({ agent, agentId }) => { + const agentRunContext = + memoryContext && (agentId === this.options.agent.id || memoryAgentEnabled) + ? [sharedRunContext, memoryContext].filter(Boolean).join('\n\n') + : sharedRunContext; + + return applyContextToAgent({ agent, agentId, logger, mcpManager, configServers, - sharedRunContext, + sharedRunContext: agentRunContext, ephemeralAgent: agentId === this.options.agent.id ? ephemeralAgent : undefined, - }), - ), + }); + }), ); return result; @@ -454,6 +507,22 @@ class AgentClient extends BaseClient { return; } + const userId = this.options.req.user.id + ''; + this.processMemory = undefined; + + if (!isMemoryAgentEnabled(memoryConfig)) { + try { + const { withoutKeys } = await db.getFormattedMemories({ userId }); + return withoutKeys; + } catch (error) { + logger.error( + '[api/server/controllers/agents/client.js #useMemory] Error loading memories', + error, + ); + return; + } + } + /** @type {Agent} */ let prelimAgent; const allowedProviders = new Set( @@ -486,6 +555,13 @@ class AgentClient extends BaseClient { return; } + /** Forward the same `execute_code` capability gate the chat flow uses — + * memory agents are unlikely to list `execute_code`, but if one does, + * Phase 8 relies on this flag to expand the string into + * `bash_tool` + `read_file` (pre-Phase 8 the legacy `execute_code` + * tool registered unconditionally; without this passthrough the + * memory path would silently lose code-execution tooling). */ + const memoryCapabilities = new Set(appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities); const agent = await initializeAgent( { req: this.options.req, @@ -497,6 +573,7 @@ class AgentClient extends BaseClient { ? EModelEndpoint.agents : memoryConfig.agent?.provider, }, + codeEnvAvailable: memoryCapabilities.has(AgentCapabilities.execute_code), }, { getFiles: db.getFiles, @@ -534,7 +611,6 @@ class AgentClient extends BaseClient { tokenLimit: memoryConfig.tokenLimit, }; - const userId = this.options.req.user.id + ''; const messageId = this.responseMessageId + ''; const conversationId = this.conversationId + ''; const streamId = this.options.req?._resumableStreamId || null; @@ -603,18 +679,27 @@ class AgentClient extends BaseClient { const memoryConfig = appConfig.memory; const messageWindowSize = memoryConfig?.messageWindowSize ?? 5; - let messagesToProcess = [...messages]; - if (messages.length > messageWindowSize) { - for (let i = messages.length - messageWindowSize; i >= 0; i--) { - const potentialWindow = messages.slice(i, i + messageWindowSize); + /** + * Strip skill-primed meta messages before memory extraction. The primes + * sit next to the latest user message and carry large SKILL.md bodies, + * so letting them into the window would crowd out real chat turns and + * pollute extracted memories with synthetic instruction content the + * user never typed. + */ + const chatMessages = messages.filter((m) => !isSkillPrimeMessage(m)); + + let messagesToProcess = [...chatMessages]; + if (chatMessages.length > messageWindowSize) { + for (let i = chatMessages.length - messageWindowSize; i >= 0; i--) { + const potentialWindow = chatMessages.slice(i, i + messageWindowSize); if (potentialWindow[0]?.role === 'user') { messagesToProcess = [...potentialWindow]; break; } } - if (messagesToProcess.length === messages.length) { - messagesToProcess = [...messages.slice(-messageWindowSize)]; + if (messagesToProcess.length === chatMessages.length) { + messagesToProcess = [...chatMessages.slice(-messageWindowSize)]; } } @@ -742,17 +827,76 @@ class AgentClient extends BaseClient { const toolSet = buildToolSet(this.options.agent); const tokenCounter = createTokenCounter(this.getEncoding()); + + /** Pre-resolve invoked skill bodies + re-prime files before formatting messages */ + const skillPrimeResult = this.options.primeInvokedSkills + ? await this.options.primeInvokedSkills(payload) + : undefined; + + /** + * Seed `Graph.sessions` with code-env files primed across every + * reachable agent (primary, handoff/addedConvo, and nested + * subagents) plus skill-priming output. The merge logic and its + * run-wide semantics live in `buildInitialToolSessions`; see that + * helper's doc for why this is intentionally NOT per-agent. + */ + const initialSessions = buildInitialToolSessions({ + skillSessions: skillPrimeResult?.initialSessions, + agents: [this.options.agent, ...(this.agentConfigs ? this.agentConfigs.values() : [])], + }); + let { messages: initialMessages, indexTokenCountMap, summary: initialSummary, boundaryTokenAdjustment, - } = formatAgentMessages(payload, this.indexTokenCountMap, toolSet); + } = formatAgentMessages(payload, this.indexTokenCountMap, toolSet, skillPrimeResult?.skills); if (boundaryTokenAdjustment) { logger.debug( `[AgentClient] Boundary token adjustment: ${boundaryTokenAdjustment.original} → ${boundaryTokenAdjustment.adjusted} (${boundaryTokenAdjustment.remainingChars}/${boundaryTokenAdjustment.totalChars} chars)`, ); } + + /** + * Skill priming — both manual ($ popover) and always-apply (frontmatter). + * + * Splice + index-shift logic lives in `injectSkillPrimes` + * (packages/api/src/agents/skills.ts) so the delicate position math + * can be unit-tested in TS without standing up AgentClient. The + * resolver enforces a combined ceiling (manual-first, always-apply + * truncated first when over cap) before reaching here; the splice + * re-applies the cap as defense-in-depth. Runs for both single- + * agent and multi-agent runs; how primes interact with handoff / + * added-convo agents' per-agent state is an agents-SDK concern, + * not this layer's to gate. + */ + const manualSkillPrimes = this.options.agent?.manualSkillPrimes; + const alwaysApplySkillPrimes = this.options.agent?.alwaysApplySkillPrimes; + if ( + (manualSkillPrimes && manualSkillPrimes.length > 0) || + (alwaysApplySkillPrimes && alwaysApplySkillPrimes.length > 0) + ) { + const primeResult = injectSkillPrimes({ + initialMessages, + indexTokenCountMap, + manualSkillPrimes, + alwaysApplySkillPrimes, + }); + indexTokenCountMap = primeResult.indexTokenCountMap; + if (primeResult.inserted > 0) { + const manualNames = (manualSkillPrimes ?? []).map((p) => p.name); + const alwaysApplyNames = (alwaysApplySkillPrimes ?? []).map((p) => p.name); + logger.debug( + `[AgentClient] Primed ${primeResult.inserted} skill(s) at message index ${primeResult.insertIdx} — manual: [${manualNames.join(', ')}], always-apply: [${alwaysApplyNames.join(', ')}]`, + ); + } + if (primeResult.alwaysApplyDropped > 0) { + logger.warn( + `[AgentClient] Dropped ${primeResult.alwaysApplyDropped} always-apply prime(s) to stay within MAX_PRIMED_SKILLS_PER_TURN.`, + ); + } + } + if (indexTokenCountMap && isEnabled(process.env.AGENT_DEBUG_LOGGING)) { const entries = Object.entries(indexTokenCountMap); const perMsg = entries.map(([idx, count]) => { @@ -809,7 +953,9 @@ class AgentClient extends BaseClient { // messages = addCacheControl(messages); // } - memoryPromise = this.runMemory(messages); + if (this.processMemory) { + memoryPromise = this.runMemory(messages); + } /** Seed calibration state from previous run if encoding matches */ const currentEncoding = this.getEncoding(); @@ -829,6 +975,7 @@ class AgentClient extends BaseClient { messages, indexTokenCountMap, initialSummary, + initialSessions, calibrationRatio, runId: this.responseMessageId, signal: abortController.signal, @@ -869,6 +1016,43 @@ class AgentClient extends BaseClient { const hideSequentialOutputs = config.configurable.hide_sequential_outputs; await runAgents(initialMessages); + /** + * Surface a completed `skill` tool_call content part per *manually*- + * primed skill so the existing `SkillCall` frontend renderer shows + * a "Skill X loaded" card on the assistant response. Applied after + * the graph finishes to avoid clashing with the aggregator's own + * per-step content indexing. Prepended (not appended) so cards sit + * above the model's output — priming ran before the turn, the + * reply follows. + * + * Always-apply primes intentionally do NOT emit assistant-side + * cards. `extractInvokedSkillsFromPayload` scans history for + * `skill` tool_calls and feeds `primeInvokedSkills`, which is + * Phase 3's sticky-re-prime path — that's the right behavior for + * manual (user picked `$skill` once; re-prime on every subsequent + * turn from history). For always-apply, `resolveAlwaysApplySkills` + * already re-primes every turn from fresh DB state, so persisting + * the card would cause the skill body to get primed twice per + * turn starting on turn 2. The user-facing acknowledgement for + * always-apply lives on the user bubble as the pinned + * `SkillPills` row (`message.alwaysAppliedSkills`), which + * is the durable signal the user wants: "this skill auto-primes". + * + * Live streaming display of manual user-bubble pills is handled + * by `SkillPills` reading `message.manualSkills`. No + * separate SSE emit is needed here; trying to stream a mid-run + * tool_call at index 0 collided with the LLM's first text + * content, while emitting at a sparse offset pushed the card + * below the reply on finalize. Post-run unshift keeps the final + * responseMessage.content in the right order. + */ + const manualPrimed = this.options.agent?.manualSkillPrimes ?? []; + if (manualPrimed.length > 0) { + const runId = this.responseMessageId ?? 'skill-prime'; + const manualParts = buildSkillPrimeContentParts(manualPrimed, { runId }); + this.contentParts.unshift(...manualParts); + } + /** @deprecated Agent Chain */ if (hideSequentialOutputs) { this.contentParts = this.contentParts.filter((part, index) => { @@ -911,6 +1095,8 @@ class AgentClient extends BaseClient { this.contextMeta = undefined; } + this.finalizeSubagentContent(); + try { const attachments = await this.awaitMemoryWithTimeout(memoryPromise); if (attachments && attachments.length > 0) { diff --git a/api/server/controllers/agents/client.test.js b/api/server/controllers/agents/client.test.js index 1595f652f7e6..bf1ddffe0e84 100644 --- a/api/server/controllers/agents/client.test.js +++ b/api/server/controllers/agents/client.test.js @@ -15,6 +15,12 @@ jest.mock('@librechat/api', () => ({ checkAccess: jest.fn(), initializeAgent: jest.fn(), createMemoryProcessor: jest.fn(), + isMemoryAgentEnabled: jest.fn((config) => { + if (!config || config.disabled === true) return false; + const agent = config.agent; + if (agent?.enabled !== true) return false; + return Boolean(agent.id || (agent.provider && agent.model)); + }), loadAgent: jest.fn(), })); @@ -29,6 +35,7 @@ jest.mock('~/server/services/MCP', () => ({ jest.mock('~/models', () => ({ getAgent: jest.fn(), getRoleByName: jest.fn(), + getFormattedMemories: jest.fn(), })); // Mock getMCPManager @@ -1923,7 +1930,7 @@ describe('AgentClient - titleConvo', () => { client.maxContextTokens = 4096; }); - it('should pass memory context to parallel agents (addedConvo)', async () => { + it('should only pass memory context to the primary agent by default', async () => { const memoryContent = 'User prefers dark mode. User is a software developer.'; client.useMemory = jest.fn().mockResolvedValue(memoryContent); @@ -1963,15 +1970,58 @@ describe('AgentClient - titleConvo', () => { expect(client.useMemory).toHaveBeenCalled(); - // Verify primary agent has its configured instructions (not from buildOptions) and memory context expect(client.options.agent.instructions).toContain('Primary agent instructions'); - expect(client.options.agent.instructions).toContain(memoryContent); + expect(client.options.agent.instructions).not.toContain(memoryContent); + expect(client.options.agent.additional_instructions).toContain(memoryContent); expect(parallelAgent1.instructions).toContain('Parallel agent 1 instructions'); - expect(parallelAgent1.instructions).toContain(memoryContent); + expect(parallelAgent1.instructions).not.toContain(memoryContent); + expect(parallelAgent1.additional_instructions ?? '').not.toContain(memoryContent); expect(parallelAgent2.instructions).toContain('Parallel agent 2 instructions'); - expect(parallelAgent2.instructions).toContain(memoryContent); + expect(parallelAgent2.instructions).not.toContain(memoryContent); + expect(parallelAgent2.additional_instructions ?? '').not.toContain(memoryContent); + }); + + it('should pass memory context to parallel agents when automatic memory updates are enabled', async () => { + const memoryContent = 'User prefers dark mode. User is a software developer.'; + client.useMemory = jest.fn().mockResolvedValue(memoryContent); + mockReq.config.memory.agent = { + enabled: true, + id: 'memory-agent', + }; + + const parallelAgent = { + id: 'parallel-agent-1', + name: 'Parallel Agent 1', + instructions: 'Parallel agent instructions', + provider: EModelEndpoint.openAI, + }; + + client.agentConfigs = new Map([['parallel-agent-1', parallelAgent]]); + + const messages = [ + { + messageId: 'msg-1', + parentMessageId: null, + sender: 'User', + text: 'Hello', + isCreatedByUser: true, + }, + ]; + + await client.buildMessages(messages, null, { + instructions: 'Base instructions', + additional_instructions: null, + }); + + expect(client.options.agent.instructions).toContain('Primary agent instructions'); + expect(client.options.agent.instructions).not.toContain(memoryContent); + expect(client.options.agent.additional_instructions).toContain(memoryContent); + + expect(parallelAgent.instructions).toContain('Parallel agent instructions'); + expect(parallelAgent.instructions).not.toContain(memoryContent); + expect(parallelAgent.additional_instructions).toContain(memoryContent); }); it('should not modify parallel agents when no memory context is available', async () => { @@ -2004,7 +2054,7 @@ describe('AgentClient - titleConvo', () => { expect(parallelAgent.instructions).toBe('Original parallel instructions'); }); - it('should handle parallel agents without existing instructions', async () => { + it('should handle parallel agents without existing instructions when memory stays primary-only', async () => { const memoryContent = 'User is a data scientist.'; client.useMemory = jest.fn().mockResolvedValue(memoryContent); @@ -2033,7 +2083,11 @@ describe('AgentClient - titleConvo', () => { additional_instructions: null, }); - expect(parallelAgentNoInstructions.instructions).toContain(memoryContent); + expect(client.options.agent.additional_instructions).toContain(memoryContent); + expect(parallelAgentNoInstructions.instructions).toBeUndefined(); + expect(parallelAgentNoInstructions.additional_instructions ?? '').not.toContain( + memoryContent, + ); }); it('should not modify agentConfigs when none exist', async () => { @@ -2059,7 +2113,7 @@ describe('AgentClient - titleConvo', () => { }), ).resolves.not.toThrow(); - expect(client.options.agent.instructions).toContain(memoryContent); + expect(client.options.agent.additional_instructions).toContain(memoryContent); }); it('should handle empty agentConfigs map', async () => { @@ -2085,7 +2139,7 @@ describe('AgentClient - titleConvo', () => { }), ).resolves.not.toThrow(); - expect(client.options.agent.instructions).toContain(memoryContent); + expect(client.options.agent.additional_instructions).toContain(memoryContent); }); }); @@ -2099,6 +2153,7 @@ describe('AgentClient - titleConvo', () => { let mockLoadAgent; let mockInitializeAgent; let mockCreateMemoryProcessor; + let mockGetFormattedMemories; beforeEach(() => { jest.clearAllMocks(); @@ -2124,6 +2179,7 @@ describe('AgentClient - titleConvo', () => { config: { memory: { agent: { + enabled: true, id: 'agent-123', }, }, @@ -2147,6 +2203,12 @@ describe('AgentClient - titleConvo', () => { mockLoadAgent = require('@librechat/api').loadAgent; mockInitializeAgent = require('@librechat/api').initializeAgent; mockCreateMemoryProcessor = require('@librechat/api').createMemoryProcessor; + mockGetFormattedMemories = require('~/models').getFormattedMemories; + mockGetFormattedMemories.mockResolvedValue({ + withKeys: '', + withoutKeys: '', + totalTokens: 0, + }); }); it('should use current agent when memory config agent.id matches current agent id', async () => { @@ -2211,12 +2273,89 @@ describe('AgentClient - titleConvo', () => { ); }); - it('should return early when prelimAgent is undefined (no valid memory agent config)', async () => { + it('should return existing memories without auto-processing when memory agent is not enabled', async () => { + mockReq.config.memory = { + personalize: true, + }; + + mockCheckAccess.mockResolvedValue(true); + mockGetFormattedMemories.mockResolvedValue({ + withKeys: 'food: likes pasta', + withoutKeys: 'likes pasta', + totalTokens: 3, + }); + + client = new AgentClient(mockOptions); + client.conversationId = 'convo-123'; + client.responseMessageId = 'response-123'; + + const result = await client.useMemory(); + + expect(result).toBe('likes pasta'); + expect(mockGetFormattedMemories).toHaveBeenCalledWith({ userId: 'user-123' }); + expect(mockInitializeAgent).not.toHaveBeenCalled(); + expect(mockCreateMemoryProcessor).not.toHaveBeenCalled(); + expect(client.processMemory).toBeUndefined(); + }); + + it('should not initialize auto-processing when no memories exist', async () => { + mockReq.config.memory = { + personalize: true, + }; + + mockCheckAccess.mockResolvedValue(true); + mockGetFormattedMemories.mockResolvedValue({ + withKeys: '', + withoutKeys: '', + totalTokens: 0, + }); + + client = new AgentClient(mockOptions); + client.conversationId = 'convo-123'; + client.responseMessageId = 'response-123'; + + const result = await client.useMemory(); + + expect(result).toBe(''); + expect(mockGetFormattedMemories).toHaveBeenCalledWith({ userId: 'user-123' }); + expect(mockInitializeAgent).not.toHaveBeenCalled(); + expect(mockCreateMemoryProcessor).not.toHaveBeenCalled(); + expect(client.processMemory).toBeUndefined(); + }); + + it('should return existing memories without auto-processing when memory agent config lacks explicit enablement', async () => { + mockReq.config.memory.agent = { + id: 'agent-123', + }; + + mockCheckAccess.mockResolvedValue(true); + mockGetFormattedMemories.mockResolvedValue({ + withKeys: 'tone: concise', + withoutKeys: 'prefers concise answers', + totalTokens: 4, + }); + + client = new AgentClient(mockOptions); + client.conversationId = 'convo-123'; + client.responseMessageId = 'response-123'; + + const result = await client.useMemory(); + + expect(result).toBe('prefers concise answers'); + expect(mockLoadAgent).not.toHaveBeenCalled(); + expect(mockInitializeAgent).not.toHaveBeenCalled(); + expect(mockCreateMemoryProcessor).not.toHaveBeenCalled(); + }); + + it('should return undefined when loading memories fails without auto-processing', async () => { + const { logger } = require('@librechat/data-schemas'); + const errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => logger); mockReq.config.memory = { - agent: {}, + personalize: true, }; mockCheckAccess.mockResolvedValue(true); + mockGetFormattedMemories.mockRejectedValue(new Error('DB connection failed')); client = new AgentClient(mockOptions); client.conversationId = 'convo-123'; @@ -2225,13 +2364,20 @@ describe('AgentClient - titleConvo', () => { const result = await client.useMemory(); expect(result).toBeUndefined(); + expect(mockGetFormattedMemories).toHaveBeenCalledWith({ userId: 'user-123' }); expect(mockInitializeAgent).not.toHaveBeenCalled(); expect(mockCreateMemoryProcessor).not.toHaveBeenCalled(); + expect(client.processMemory).toBeUndefined(); + expect(errorSpy).toHaveBeenCalledWith( + '[api/server/controllers/agents/client.js #useMemory] Error loading memories', + expect.any(Error), + ); }); it('should create ephemeral agent when no id but model and provider are specified', async () => { mockReq.config.memory = { agent: { + enabled: true, model: 'gpt-4', provider: EModelEndpoint.openAI, }, @@ -2265,3 +2411,247 @@ describe('AgentClient - titleConvo', () => { }); }); }); + +describe('AgentClient - finalizeSubagentContent', () => { + /** Verifies the backend persistence path: per-subagent + * `createContentAggregator` instances (populated by the callbacks + * ON_SUBAGENT_UPDATE handler) have their `contentParts` harvested + * onto the matching parent `subagent` tool_call at message-save time + * so a page refresh shows the same activity the user saw live. */ + const { GraphEvents } = jest.requireActual('@librechat/agents'); + const { getDefaultHandlers } = require('./callbacks'); + + const makeClient = (subagentAggregatorsByToolCallId) => { + const client = new AgentClient({ + req: { user: { id: 'u' }, body: {}, config: { endpoints: {} } }, + res: {}, + agent: { + id: 'agent', + endpoint: EModelEndpoint.openAI, + provider: EModelEndpoint.openAI, + model_parameters: { model: 'gpt-4' }, + }, + contentParts: [], + subagentAggregatorsByToolCallId, + }); + return client; + }; + + const event = (phase, data, parentToolCallId = 'call_sub') => ({ + runId: 'parent-run', + subagentRunId: 'child-run', + subagentType: 'self', + subagentAgentId: 'child', + parentToolCallId, + phase, + data, + timestamp: '2026-04-17T00:00:00Z', + }); + + /** Feeds a SubagentUpdateEvent sequence through the real + * `ON_SUBAGENT_UPDATE` handler so we exercise the same get-or-create + * aggregator logic the live request uses, rather than constructing + * aggregators directly in the test. */ + const runSubagentEvents = async (events) => { + const map = new Map(); + const handlers = getDefaultHandlers({ + res: { write: jest.fn(), writableEnded: false }, + aggregateContent: jest.fn(), + toolEndCallback: jest.fn(), + collectedUsage: [], + subagentAggregatorsByToolCallId: map, + }); + const handler = handlers[GraphEvents.ON_SUBAGENT_UPDATE]; + for (const e of events) { + await handler.handle(GraphEvents.ON_SUBAGENT_UPDATE, e); + } + return map; + }; + + it('attaches aggregated subagent_content to the matching subagent tool_call part', async () => { + const buffer = await runSubagentEvents([ + event('run_step', { + id: 'step_msg', + index: 0, + stepDetails: { type: 'message_creation' }, + }), + event('message_delta', { + id: 'step_msg', + delta: { content: [{ type: 'text', text: 'Hello ' }] }, + }), + event('message_delta', { + id: 'step_msg', + delta: { content: [{ type: 'text', text: 'world!' }] }, + }), + event('run_step', { + id: 'step_tool', + index: 1, + stepDetails: { + type: 'tool_calls', + tool_calls: [{ id: 'inner_1', name: 'calculator', args: '{}' }], + }, + }), + event('run_step_completed', { + id: 'step_tool', + index: 1, + result: { + id: 'step_tool', + type: 'tool_call', + tool_call: { + id: 'inner_1', + name: 'calculator', + output: '4', + progress: 1, + }, + }, + }), + ]); + + const client = makeClient(buffer); + client.contentParts = [ + { + type: 'tool_call', + tool_call: { + id: 'call_sub', + name: Constants.SUBAGENT, + args: '{}', + output: 'final text', + progress: 1, + }, + }, + ]; + + client.finalizeSubagentContent(); + + const attached = client.contentParts[0].tool_call.subagent_content; + expect(Array.isArray(attached)).toBe(true); + expect(attached).toHaveLength(2); + expect(attached[0].type).toBe('text'); + expect(attached[0].text).toBe('Hello world!'); + expect(attached[1].type).toBe('tool_call'); + expect(attached[1].tool_call.name).toBe('calculator'); + expect(attached[1].tool_call.output).toBe('4'); + /** Buffer drained so a second call (e.g. resumable retry) doesn't + * double-append. */ + expect(buffer.size).toBe(0); + }); + + it('ignores tool_call parts whose name is not SUBAGENT', async () => { + const buffer = await runSubagentEvents([ + event( + 'run_step', + { + id: 'step_msg', + index: 0, + stepDetails: { type: 'message_creation' }, + }, + 'call_regular', + ), + event( + 'message_delta', + { + id: 'step_msg', + delta: { content: [{ type: 'text', text: 'x' }] }, + }, + 'call_regular', + ), + ]); + const client = makeClient(buffer); + client.contentParts = [ + { + type: 'tool_call', + tool_call: { id: 'call_regular', name: 'calculator', args: '{}' }, + }, + ]; + client.finalizeSubagentContent(); + expect(client.contentParts[0].tool_call.subagent_content).toBeUndefined(); + }); + + it('is a safe no-op when the aggregator map is empty or missing', () => { + const client = makeClient(undefined); + client.contentParts = [ + { + type: 'tool_call', + tool_call: { id: 'call_sub', name: Constants.SUBAGENT, args: '{}' }, + }, + ]; + expect(() => client.finalizeSubagentContent()).not.toThrow(); + expect(client.contentParts[0].tool_call.subagent_content).toBeUndefined(); + }); + + it('discards aggregators keyed by a tool_call_id not present in contentParts', async () => { + const buffer = await runSubagentEvents([ + event( + 'run_step', + { + id: 'step_msg', + index: 0, + stepDetails: { type: 'message_creation' }, + }, + 'call_missing', + ), + event( + 'message_delta', + { + id: 'step_msg', + delta: { content: [{ type: 'text', text: 'x' }] }, + }, + 'call_missing', + ), + ]); + const client = makeClient(buffer); + client.contentParts = [ + { + type: 'tool_call', + tool_call: { id: 'call_other', name: Constants.SUBAGENT, args: '{}' }, + }, + ]; + client.finalizeSubagentContent(); + expect(client.contentParts[0].tool_call.subagent_content).toBeUndefined(); + }); + + it('keeps per-parent tool_call aggregators isolated for parallel subagents', async () => { + const buffer = await runSubagentEvents([ + event( + 'run_step', + { + id: 'step_a', + index: 0, + stepDetails: { type: 'message_creation' }, + }, + 'call_a', + ), + event( + 'message_delta', + { id: 'step_a', delta: { content: [{ type: 'text', text: 'A' }] } }, + 'call_a', + ), + event( + 'run_step', + { + id: 'step_b', + index: 0, + stepDetails: { type: 'message_creation' }, + }, + 'call_b', + ), + event( + 'message_delta', + { id: 'step_b', delta: { content: [{ type: 'text', text: 'B' }] } }, + 'call_b', + ), + ]); + const client = makeClient(buffer); + client.contentParts = [ + { type: 'tool_call', tool_call: { id: 'call_a', name: Constants.SUBAGENT, args: '{}' } }, + { type: 'tool_call', tool_call: { id: 'call_b', name: Constants.SUBAGENT, args: '{}' } }, + ]; + client.finalizeSubagentContent(); + expect(client.contentParts[0].tool_call.subagent_content).toEqual([ + expect.objectContaining({ type: 'text', text: 'A' }), + ]); + expect(client.contentParts[1].tool_call.subagent_content).toEqual([ + expect.objectContaining({ type: 'text', text: 'B' }), + ]); + }); +}); diff --git a/api/server/controllers/agents/openai.js b/api/server/controllers/agents/openai.js index e7cd7e1c2906..7d2395b09f1b 100644 --- a/api/server/controllers/agents/openai.js +++ b/api/server/controllers/agents/openai.js @@ -6,28 +6,33 @@ const { ResourceType, PermissionBits, hasPermissions, + AgentCapabilities, } = require('librechat-data-provider'); const { writeSSE, createRun, createChunk, buildToolSet, + loadSkillStates, sendFinalChunk, createSafeUser, validateRequest, initializeAgent, getBalanceConfig, + injectSkillPrimes, + extractManualSkills, createErrorResponse, recordCollectedUsage, getTransactionsConfig, resolveRecursionLimit, + discoverConnectedAgents, + getRemoteAgentPermissions, createToolExecuteHandler, buildNonStreamingResponse, createOpenAIStreamTracker, + resolveAgentScopedSkillIds, createOpenAIContentAggregator, isChatCompletionValidationFailure, - discoverConnectedAgents, - getRemoteAgentPermissions, } = require('@librechat/api'); const { buildSummarizationHandlers, @@ -40,6 +45,11 @@ const { findAccessibleResources, getEffectivePermissions, } = require('~/server/services/PermissionService'); +const { + getSkillToolDeps, + enrichWithSkillConfigurable, + buildSkillPrimedIdsByName, +} = require('~/server/services/Endpoints/agents/skillDeps'); const { getModelsConfig } = require('~/server/controllers/ModelController'); const { logViolation } = require('~/cache'); const db = require('~/models'); @@ -235,8 +245,32 @@ const OpenAIChatCompletionController = async (req, res) => { getUserCodeFiles: db.getUserCodeFiles, getToolFilesByIds: db.getToolFilesByIds, getCodeGeneratedFiles: db.getCodeGeneratedFiles, + listSkillsByAccess: db.listSkillsByAccess, + listAlwaysApplySkills: db.listAlwaysApplySkills, + getSkillByName: db.getSkillByName, }; + const enabledCapabilities = new Set(agentsEConfig?.capabilities); + const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills); + const ephemeralSkillsToggle = req.body?.ephemeralAgent?.skills === true; + const accessibleSkillIds = skillsCapabilityEnabled + ? await findAccessibleResources({ + userId: req.user.id, + role: req.user.role, + resourceType: ResourceType.SKILL, + requiredPermissions: PermissionBits.VIEW, + }) + : []; + + const { skillStates, defaultActiveOnShare } = await loadSkillStates({ + userId: req.user.id, + appConfig, + getUserById: db.getUserById, + accessibleSkillIds, + }); + + const manualSkills = extractManualSkills(req.body); + const primaryConfig = await initializeAgent( { req, @@ -249,6 +283,16 @@ const OpenAIChatCompletionController = async (req, res) => { endpointOption, allowedProviders, isInitialAgent: true, + accessibleSkillIds: resolveAgentScopedSkillIds({ + agent, + accessibleSkillIds, + skillsCapabilityEnabled, + ephemeralSkillsToggle, + }), + codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code), + skillStates, + defaultActiveOnShare, + manualSkills, }, dbMethods, ); @@ -272,6 +316,7 @@ const OpenAIChatCompletionController = async (req, res) => { userMCPAuthMap: primaryConfig.userMCPAuthMap, tool_resources: primaryConfig.tool_resources, actionsEnabled: primaryConfig.actionsEnabled, + codeEnvAvailable: primaryConfig.codeEnvAvailable, }); // Only run BFS discovery (and pay `getModelsConfig` upfront) when the @@ -301,6 +346,8 @@ const OpenAIChatCompletionController = async (req, res) => { // sub-agent must clear the same sharing boundary, not the looser // in-app AGENT one. resourceType: ResourceType.REMOTE_AGENT, + /** @see DiscoverConnectedAgentsParams.codeEnvAvailable */ + codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code), }, { getAgent: db.getAgent, @@ -327,6 +374,7 @@ const OpenAIChatCompletionController = async (req, res) => { userMCPAuthMap: config.userMCPAuthMap, tool_resources: config.tool_resources, actionsEnabled: config.actionsEnabled, + codeEnvAvailable: config.codeEnvAvailable, }); }, initializeAgent, @@ -372,10 +420,22 @@ const OpenAIChatCompletionController = async (req, res) => { const toolEndCallback = createToolEndCallback({ req, res, artifactPromises, streamId: null }); + /* Stable for the turn: the prime lists are fixed once + `initializeAgent` resolves. Hoisted out of `loadTools` so tool + execution doesn't recompute them. `codeEnvAvailable` is read + per-agent from the stored tool context (admin cap AND that + agent's `tools` list includes `execute_code`) — a skills-only + agent never gains sandbox access even if the admin enabled the + capability globally. */ + const skillPrimedIdsByName = buildSkillPrimedIdsByName( + primaryConfig.manualSkillPrimes, + primaryConfig.alwaysApplySkillPrimes, + ); + const toolExecuteOptions = { loadTools: async (toolNames, agentId) => { const ctx = agentToolContexts.get(agentId) ?? agentToolContexts.get(primaryConfig.id) ?? {}; - return loadToolsForExecution({ + const result = await loadToolsForExecution({ req, res, toolNames, @@ -386,8 +446,16 @@ const OpenAIChatCompletionController = async (req, res) => { tool_resources: ctx.tool_resources, actionsEnabled: ctx.actionsEnabled, }); + return enrichWithSkillConfigurable( + result, + req, + primaryConfig.accessibleSkillIds, + ctx.codeEnvAvailable === true, + skillPrimedIdsByName, + ); }, toolEndCallback, + ...getSkillToolDeps(), }; const summarizationConfig = appConfig?.summarization; @@ -395,11 +463,42 @@ const OpenAIChatCompletionController = async (req, res) => { const openaiMessages = convertMessages(request.messages); const toolSet = buildToolSet(primaryConfig); - const { - messages: formattedMessages, - indexTokenCountMap, - summary: initialSummary, - } = formatAgentMessages(openaiMessages, {}, toolSet); + const formatted = formatAgentMessages(openaiMessages, {}, toolSet); + const formattedMessages = formatted.messages; + const initialSummary = formatted.summary; + let indexTokenCountMap = formatted.indexTokenCountMap; + + /** + * Inject manual + always-apply skill primes so the model sees SKILL.md + * bodies for this turn — parity with AgentClient's chat path. OpenAI- + * compatible streaming uses its own tracker/aggregator shape, so the + * LibreChat-style card SSE events don't apply here; only the + * message-context part carries over. + */ + const manualSkillPrimes = primaryConfig.manualSkillPrimes; + const alwaysApplySkillPrimes = primaryConfig.alwaysApplySkillPrimes; + if ( + (manualSkillPrimes && manualSkillPrimes.length > 0) || + (alwaysApplySkillPrimes && alwaysApplySkillPrimes.length > 0) + ) { + const primeResult = injectSkillPrimes({ + initialMessages: formattedMessages, + indexTokenCountMap, + manualSkillPrimes, + alwaysApplySkillPrimes, + }); + indexTokenCountMap = primeResult.indexTokenCountMap; + /* Surface the cap-driven always-apply truncation at the controller + layer too — `injectSkillPrimes` already logs internally, but the + controller-level warn includes endpoint context so operators can + tell at a glance which path hit the cap. Mirrors AgentClient's + warn in `client.js`. */ + if (primeResult.alwaysApplyDropped > 0) { + logger.warn( + `[OpenAI API] Dropped ${primeResult.alwaysApplyDropped} always-apply prime(s) to stay within MAX_PRIMED_SKILLS_PER_TURN.`, + ); + } + } /** * Create a simple handler that processes data diff --git a/api/server/controllers/agents/request.js b/api/server/controllers/agents/request.js index 6f7e1b88c13a..51ac9a4885c4 100644 --- a/api/server/controllers/agents/request.js +++ b/api/server/controllers/agents/request.js @@ -12,7 +12,7 @@ const { const { disposeClient, clientRegistry, requestDataMap } = require('~/server/cleanup'); const { handleAbortError } = require('~/server/middleware'); const { logViolation } = require('~/cache'); -const { saveMessage } = require('~/models'); +const { saveMessage, getConvo } = require('~/models'); function createCloseHandler(abortController) { return function (manual) { @@ -32,6 +32,48 @@ function createCloseHandler(abortController) { }; } +function toValidISOString(value) { + if (value == null) { + return null; + } + + const date = value instanceof Date ? value : new Date(value); + return Number.isNaN(date.getTime()) ? null : date.toISOString(); +} + +async function resolveConversationCreatedAt({ userId, conversationId, isNewConvo }) { + if (isNewConvo) { + return { createdAt: new Date().toISOString(), conversation: undefined }; + } + + try { + const conversation = await getConvo(userId, conversationId); + return { + conversation, + createdAt: toValidISOString(conversation?.createdAt) ?? new Date().toISOString(), + }; + } catch (error) { + logger.warn('[AgentController] Failed to resolve conversation timestamp anchor', { + conversationId, + error: error?.message ?? error, + }); + return { createdAt: new Date().toISOString(), conversation: undefined }; + } +} + +async function attachConversationCreatedAt(req, { userId, conversationId, isNewConvo }) { + req.body.conversationId = conversationId; + const resolved = await resolveConversationCreatedAt({ + userId, + conversationId, + isNewConvo, + }); + req.conversationCreatedAt = resolved.createdAt; + if (!isNewConvo && resolved.conversation !== undefined) { + req.resolvedConversation = resolved.conversation ?? null; + } +} + /** * Resumable Agent Controller - Generation runs independently of HTTP connection. * Returns streamId immediately, client subscribes separately via SSE. @@ -60,9 +102,10 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit // Generate conversationId upfront if not provided - streamId === conversationId always // Treat "new" as a placeholder that needs a real UUID (frontend may send "new" for new convos) - const conversationId = - !reqConversationId || reqConversationId === 'new' ? crypto.randomUUID() : reqConversationId; + const isNewConvo = !reqConversationId || reqConversationId === 'new'; + const conversationId = isNewConvo ? crypto.randomUUID() : reqConversationId; const streamId = conversationId; + req.body.conversationId = conversationId; let client = null; @@ -82,6 +125,8 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit // This is critical: tool loading (MCP OAuth) may emit events that the client needs to receive res.json({ streamId, conversationId, status: 'started' }); + await attachConversationCreatedAt(req, { userId, conversationId, isNewConvo }); + // Note: We no longer use res.on('close') to abort since we send JSON immediately. // The response closes normally after res.json(), which is not an abort condition. // Abort handling is done through GenerationJobManager via the SSE stream connection. @@ -268,7 +313,6 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit // Check abort state BEFORE calling completeJob (which triggers abort signal for cleanup) const wasAbortedBeforeComplete = job.abortController.signal.aborted; - const isNewConvo = !reqConversationId || reqConversationId === 'new'; const shouldGenerateTitle = addTitle && parentMessageId === Constants.NO_PARENT && @@ -453,8 +497,8 @@ const _LegacyAgentController = async (req, res, next, initializeClient, addTitle // Generate conversationId upfront if not provided - streamId === conversationId always // Treat "new" as a placeholder that needs a real UUID (frontend may send "new" for new convos) - const conversationId = - !reqConversationId || reqConversationId === 'new' ? crypto.randomUUID() : reqConversationId; + const isNewConvo = !reqConversationId || reqConversationId === 'new'; + const conversationId = isNewConvo ? crypto.randomUUID() : reqConversationId; const streamId = conversationId; let userMessage; @@ -464,9 +508,10 @@ const _LegacyAgentController = async (req, res, next, initializeClient, addTitle let cleanupHandlers = []; // Match the same logic used for conversationId generation above - const isNewConvo = !reqConversationId || reqConversationId === 'new'; const userId = req.user.id; + await attachConversationCreatedAt(req, { userId, conversationId, isNewConvo }); + // Create handler to avoid capturing the entire parent scope let getReqData = (data = {}) => { for (let key in data) { diff --git a/api/server/controllers/agents/responses.js b/api/server/controllers/agents/responses.js index 3cb24b208311..b2805fc19fca 100644 --- a/api/server/controllers/agents/responses.js +++ b/api/server/controllers/agents/responses.js @@ -7,15 +7,20 @@ const { ResourceType, PermissionBits, hasPermissions, + AgentCapabilities, } = require('librechat-data-provider'); const { createRun, buildToolSet, + loadSkillStates, + resolveAgentScopedSkillIds, createSafeUser, initializeAgent, getBalanceConfig, recordCollectedUsage, getTransactionsConfig, + extractManualSkills, + injectSkillPrimes, createToolExecuteHandler, discoverConnectedAgents, getRemoteAgentPermissions, @@ -49,6 +54,11 @@ const { findAccessibleResources, getEffectivePermissions, } = require('~/server/services/PermissionService'); +const { + getSkillToolDeps, + enrichWithSkillConfigurable, + buildSkillPrimedIdsByName, +} = require('~/server/services/Endpoints/agents/skillDeps'); const { getModelsConfig } = require('~/server/controllers/ModelController'); const { logViolation } = require('~/cache'); const db = require('~/models'); @@ -270,6 +280,7 @@ function convertMessagesToOutputItems(messages) { * @param {import('express').Response} res */ const createResponse = async (req, res) => { + const appConfig = req.config; const requestStartTime = Date.now(); // Validate request @@ -281,7 +292,7 @@ const createResponse = async (req, res) => { const request = validation.request; const agentId = request.model; const isStreaming = request.stream === true; - const summarizationConfig = req.config?.summarization; + const summarizationConfig = appConfig?.summarization; // Look up the agent const agent = await db.getAgent({ id: agentId }); @@ -334,7 +345,7 @@ const createResponse = async (req, res) => { // Build allowed providers set const allowedProviders = new Set( - req.config?.endpoints?.[EModelEndpoint.agents]?.allowedProviders, + appConfig?.endpoints?.[EModelEndpoint.agents]?.allowedProviders, ); // Create tool loader @@ -362,8 +373,34 @@ const createResponse = async (req, res) => { getUserCodeFiles: db.getUserCodeFiles, getToolFilesByIds: db.getToolFilesByIds, getCodeGeneratedFiles: db.getCodeGeneratedFiles, + listSkillsByAccess: db.listSkillsByAccess, + listAlwaysApplySkills: db.listAlwaysApplySkills, + getSkillByName: db.getSkillByName, }; + const enabledCapabilities = new Set( + appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities, + ); + const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills); + const ephemeralSkillsToggle = req.body?.ephemeralAgent?.skills === true; + const accessibleSkillIds = skillsCapabilityEnabled + ? await findAccessibleResources({ + userId: req.user.id, + role: req.user.role, + resourceType: ResourceType.SKILL, + requiredPermissions: PermissionBits.VIEW, + }) + : []; + + const { skillStates, defaultActiveOnShare } = await loadSkillStates({ + userId: req.user.id, + appConfig, + getUserById: db.getUserById, + accessibleSkillIds, + }); + + const manualSkills = extractManualSkills(req.body); + const primaryConfig = await initializeAgent( { req, @@ -376,6 +413,16 @@ const createResponse = async (req, res) => { endpointOption, allowedProviders, isInitialAgent: true, + accessibleSkillIds: resolveAgentScopedSkillIds({ + agent, + accessibleSkillIds, + skillsCapabilityEnabled, + ephemeralSkillsToggle, + }), + codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code), + skillStates, + defaultActiveOnShare, + manualSkills, }, dbMethods, ); @@ -399,6 +446,7 @@ const createResponse = async (req, res) => { userMCPAuthMap: primaryConfig.userMCPAuthMap, tool_resources: primaryConfig.tool_resources, actionsEnabled: primaryConfig.actionsEnabled, + codeEnvAvailable: primaryConfig.codeEnvAvailable, }); // Only run BFS discovery (and pay `getModelsConfig` upfront) when the @@ -428,6 +476,8 @@ const createResponse = async (req, res) => { // sub-agent must clear the same sharing boundary, not the looser // in-app AGENT one. resourceType: ResourceType.REMOTE_AGENT, + /** @see DiscoverConnectedAgentsParams.codeEnvAvailable */ + codeEnvAvailable: enabledCapabilities.has(AgentCapabilities.execute_code), }, { getAgent: db.getAgent, @@ -454,6 +504,7 @@ const createResponse = async (req, res) => { userMCPAuthMap: config.userMCPAuthMap, tool_resources: config.tool_resources, actionsEnabled: config.actionsEnabled, + codeEnvAvailable: config.codeEnvAvailable, }); }, initializeAgent, @@ -485,11 +536,55 @@ const createResponse = async (req, res) => { const allMessages = [...previousMessages, ...inputMessages]; const toolSet = buildToolSet(primaryConfig); - const { - messages: formattedMessages, - indexTokenCountMap, - summary: initialSummary, - } = formatAgentMessages(allMessages, {}, toolSet); + const formatted = formatAgentMessages(allMessages, {}, toolSet); + const formattedMessages = formatted.messages; + const initialSummary = formatted.summary; + let indexTokenCountMap = formatted.indexTokenCountMap; + + /** + * Inject manual + always-apply skill primes so the model sees SKILL.md + * bodies for this turn — parity with AgentClient's chat path. The + * Responses API uses its own response-builder shape, so LibreChat- + * style card SSE events don't apply; only the message-context part + * carries over. + */ + const manualSkillPrimes = primaryConfig.manualSkillPrimes; + const alwaysApplySkillPrimes = primaryConfig.alwaysApplySkillPrimes; + if ( + (manualSkillPrimes && manualSkillPrimes.length > 0) || + (alwaysApplySkillPrimes && alwaysApplySkillPrimes.length > 0) + ) { + const primeResult = injectSkillPrimes({ + initialMessages: formattedMessages, + indexTokenCountMap, + manualSkillPrimes, + alwaysApplySkillPrimes, + }); + indexTokenCountMap = primeResult.indexTokenCountMap; + /* Surface the cap-driven always-apply truncation at the controller + layer too — `injectSkillPrimes` already logs internally, but the + controller-level warn includes endpoint context so operators can + tell at a glance which path hit the cap. Mirrors AgentClient's + warn in `client.js`. */ + if (primeResult.alwaysApplyDropped > 0) { + logger.warn( + `[Responses API] Dropped ${primeResult.alwaysApplyDropped} always-apply prime(s) to stay within MAX_PRIMED_SKILLS_PER_TURN.`, + ); + } + } + + /* Stable for the turn: the prime lists are fixed once + `initializeAgent` resolves. Hoisted here so both the streaming + and non-streaming `loadTools` closures below reuse it without + recomputing per tool execution. `codeEnvAvailable` is read + per-agent from the stored tool context (admin cap AND that + agent's `tools` list includes `execute_code`) — a skills-only + agent never gains sandbox access even if the admin enabled the + capability globally. */ + const skillPrimedIdsByName = buildSkillPrimedIdsByName( + manualSkillPrimes, + alwaysApplySkillPrimes, + ); // Create tracker for streaming or aggregator for non-streaming const tracker = actuallyStreaming ? createResponseTracker() : null; @@ -533,7 +628,7 @@ const createResponse = async (req, res) => { loadTools: async (toolNames, agentId) => { const ctx = agentToolContexts.get(agentId) ?? agentToolContexts.get(primaryConfig.id) ?? {}; - return loadToolsForExecution({ + const result = await loadToolsForExecution({ req, res, toolNames, @@ -544,8 +639,16 @@ const createResponse = async (req, res) => { tool_resources: ctx.tool_resources, actionsEnabled: ctx.actionsEnabled, }); + return enrichWithSkillConfigurable( + result, + req, + primaryConfig.accessibleSkillIds, + ctx.codeEnvAvailable === true, + skillPrimedIdsByName, + ); }, toolEndCallback, + ...getSkillToolDeps(), }; // Combine handlers @@ -588,7 +691,7 @@ const createResponse = async (req, res) => { initialSummary, runId: responseId, summarizationConfig, - appConfig: req.config, + appConfig, signal: abortController.signal, customHandlers: handlers, requestBody: { @@ -629,8 +732,8 @@ const createResponse = async (req, res) => { }); // Record token usage against balance - const balanceConfig = getBalanceConfig(req.config); - const transactionsConfig = getTransactionsConfig(req.config); + const balanceConfig = getBalanceConfig(appConfig); + const transactionsConfig = getTransactionsConfig(appConfig); recordCollectedUsage( { spendTokens: db.spendTokens, @@ -701,7 +804,7 @@ const createResponse = async (req, res) => { loadTools: async (toolNames, agentId) => { const ctx = agentToolContexts.get(agentId) ?? agentToolContexts.get(primaryConfig.id) ?? {}; - return loadToolsForExecution({ + const result = await loadToolsForExecution({ req, res, toolNames, @@ -712,8 +815,16 @@ const createResponse = async (req, res) => { tool_resources: ctx.tool_resources, actionsEnabled: ctx.actionsEnabled, }); + return enrichWithSkillConfigurable( + result, + req, + primaryConfig.accessibleSkillIds, + ctx.codeEnvAvailable === true, + skillPrimedIdsByName, + ); }, toolEndCallback, + ...getSkillToolDeps(), }; const handlers = { @@ -754,7 +865,7 @@ const createResponse = async (req, res) => { initialSummary, runId: responseId, summarizationConfig, - appConfig: req.config, + appConfig, signal: abortController.signal, customHandlers: handlers, requestBody: { @@ -794,8 +905,8 @@ const createResponse = async (req, res) => { }); // Record token usage against balance - const balanceConfig = getBalanceConfig(req.config); - const transactionsConfig = getTransactionsConfig(req.config); + const balanceConfig = getBalanceConfig(appConfig); + const transactionsConfig = getTransactionsConfig(appConfig); recordCollectedUsage( { spendTokens: db.spendTokens, diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index 5bddb9aac360..23c834183995 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -26,6 +26,8 @@ const { EToolResources, PermissionBits, actionDelimiter, + AgentCapabilities, + EModelEndpoint, removeNullishValues, } = require('librechat-data-provider'); const { @@ -55,25 +57,28 @@ const MAX_SEARCH_LEN = 100; const escapeRegex = (str = '') => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); /** - * Validates that the requesting user has VIEW access to every agent referenced in edges. - * Agents that do not exist in the database are skipped — at create time, the `from` field - * often references the agent being built, which has no DB record yet. - * @param {import('librechat-data-provider').GraphEdge[]} edges + * Looks up each referenced agent id in Mongo, splits them into three + * buckets the caller needs for validation: ids that don't exist at all, + * ids the user lacks VIEW permission on, and ids that are fully + * accessible. Missing ids are intentionally NOT treated as unauthorized + * — for `edges`, a self-referential `from` can legitimately name the + * agent being created (no DB record yet); callers that should reject + * missing ids (like the subagent path) read the `missing` bucket + * instead. + * @param {Iterable} agentIds * @param {string} userId - * @param {string} userRole - Used for group/role principal resolution - * @returns {Promise} Agent IDs the user cannot VIEW (empty if all accessible) + * @param {string} userRole + * @returns {Promise<{ missing: string[], unauthorized: string[] }>} */ -const validateEdgeAgentAccess = async (edges, userId, userRole) => { - const edgeAgentIds = collectEdgeAgentIds(edges); - if (edgeAgentIds.size === 0) { - return []; - } +const classifyAgentReferences = async (agentIds, userId, userRole) => { + const ids = [...new Set(agentIds)]; + if (ids.length === 0) return { missing: [], unauthorized: [] }; - const agents = await db.getAgents({ id: { $in: [...edgeAgentIds] } }); + const agents = await db.getAgents({ id: { $in: ids } }); + const foundIds = new Set(agents.map((a) => a.id)); + const missing = ids.filter((id) => !foundIds.has(id)); - if (agents.length === 0) { - return []; - } + if (agents.length === 0) return { missing, unauthorized: [] }; const permissionsMap = await getResourcePermissionsMap({ userId, @@ -82,12 +87,57 @@ const validateEdgeAgentAccess = async (edges, userId, userRole) => { resourceIds: agents.map((a) => a._id), }); - return agents + const unauthorized = agents .filter((a) => { const bits = permissionsMap.get(a._id.toString()) ?? 0; return (bits & PermissionBits.VIEW) === 0; }) .map((a) => a.id); + + return { missing, unauthorized }; +}; + +/** + * Validates VIEW access for every agent referenced in `edges`. + * Missing ids are NOT errors here — at create time a self-referential + * `from` often names the agent being built, which has no DB record + * yet. Only unauthorized (existing but unviewable) ids are returned. + */ +const validateEdgeAgentAccess = async (edges, userId, userRole) => { + const { unauthorized } = await classifyAgentReferences( + collectEdgeAgentIds(edges), + userId, + userRole, + ); + return unauthorized; +}; + +/** + * Validates `subagents.agent_ids` more strictly than edges: both + * missing AND unauthorized ids are errors. `subagents.agent_ids` + * can't self-reference (subagents spawn *other* agents), so a + * missing id is always a typo or a reference to a deleted agent — + * `initializeClient` would silently drop it at runtime, leaving the + * persisted config out of sync with actual spawn targets (Codex P2). + * Returning the split lets the caller report each bucket with the + * appropriate status. + */ +const validateSubagentReferences = (subagents, userId, userRole) => + classifyAgentReferences(subagents?.agent_ids ?? [], userId, userRole); + +/** + * Returns true when the agents-endpoint `subagents` capability is + * enabled in this request's resolved app config. When disabled, + * `initializeClient` already strips the `subagents` block at runtime + * so persisted `agent_ids` are inert — gating the ACL check on this + * keeps stale references in legacy records from blocking unrelated + * edits after a capability-off rollback (Codex P2). + * @param {Express.Request} req + */ +const isSubagentsCapabilityEnabled = (req) => { + const capabilities = req.config?.endpoints?.[EModelEndpoint.agents]?.capabilities; + if (!Array.isArray(capabilities)) return false; + return capabilities.includes(AgentCapabilities.subagents); }; /** @@ -199,6 +249,44 @@ const createAgentHandler = async (req, res) => { } } + /** + * Only validate subagent ACL when the feature is actually enabled + * on BOTH the endpoint (capability flag in appConfig) AND the + * agent payload. Runtime (`initializeClient` + `run.ts`) checks + * `subagents?.enabled` as a truthy predicate — so `undefined` / + * `null` / missing `enabled` all disable the feature. The ACL + * check must match exactly: only enforce when `enabled === true`. + * Otherwise a payload that omits `enabled` (e.g. API clients, or + * legacy records that never set the field) could 403 here while + * runtime would happily no-op on the subagent tool. Disable-path + * is also untouched: toggling `enabled: false` always passes the + * gate, so a user who lost VIEW on a child can still save the + * disable edit. + */ + if ( + isSubagentsCapabilityEnabled(req) && + agentData.subagents?.enabled === true && + agentData.subagents?.agent_ids?.length + ) { + const { missing, unauthorized } = await validateSubagentReferences( + agentData.subagents, + userId, + userRole, + ); + if (missing.length > 0) { + return res.status(400).json({ + error: 'One or more agents referenced in subagents do not exist', + agent_ids: missing, + }); + } + if (unauthorized.length > 0) { + return res.status(403).json({ + error: 'You do not have access to one or more agents referenced in subagents', + agent_ids: unauthorized, + }); + } + } + agentData.id = `agent_${nanoid()}`; agentData.author = userId; agentData.tools = []; @@ -371,6 +459,38 @@ const updateAgentHandler = async (req, res) => { } } + /** Same guard as the create path: capability on the endpoint, + * AND `subagents.enabled === true` on the payload (runtime's + * truthy check treats `undefined` / `null` / `false` as + * disabled, so the ACL check must too). Missing or explicitly- + * disabled payloads always pass the gate — that preserves the + * "can always save a disable edit" behavior a user might need + * after losing VIEW on a referenced child. */ + if ( + isSubagentsCapabilityEnabled(req) && + updateData.subagents?.enabled === true && + updateData.subagents?.agent_ids?.length + ) { + const { id: userId, role: userRole } = req.user; + const { missing, unauthorized } = await validateSubagentReferences( + updateData.subagents, + userId, + userRole, + ); + if (missing.length > 0) { + return res.status(400).json({ + error: 'One or more agents referenced in subagents do not exist', + agent_ids: missing, + }); + } + if (unauthorized.length > 0) { + return res.status(403).json({ + error: 'You do not have access to one or more agents referenced in subagents', + agent_ids: unauthorized, + }); + } + } + // Convert OCR to context in incoming updateData convertOcrToContextInPlace(updateData); diff --git a/api/server/controllers/mcp.js b/api/server/controllers/mcp.js index e31bb93bc619..8d20cbc82caf 100644 --- a/api/server/controllers/mcp.js +++ b/api/server/controllers/mcp.js @@ -78,12 +78,20 @@ const getMCPTools = async (req, res) => { const mcpManager = getMCPManager(); const mcpServers = {}; - const cachePromises = configuredServers.map((serverName) => - getMCPServerTools(userId, serverName).then((tools) => ({ serverName, tools })), - ); - const cacheResults = await Promise.all(cachePromises); - const serverToolsMap = new Map(); + const cacheResults = await Promise.all( + configuredServers.map(async (serverName) => { + try { + return { + serverName, + tools: await getMCPServerTools(userId, serverName), + }; + } catch (error) { + logger.error(`[getMCPTools] Error fetching cached tools for ${serverName}:`, error); + return { serverName, tools: null }; + } + }), + ); for (const { serverName, tools } of cacheResults) { if (tools) { serverToolsMap.set(serverName, tools); diff --git a/api/server/controllers/tools.js b/api/server/controllers/tools.js index 1df11b105973..8124894584cc 100644 --- a/api/server/controllers/tools.js +++ b/api/server/controllers/tools.js @@ -1,5 +1,4 @@ const { nanoid } = require('nanoid'); -const { EnvVar } = require('@librechat/agents'); const { logger } = require('@librechat/data-schemas'); const { checkAccess, loadWebSearchAuth } = require('@librechat/api'); const { @@ -15,9 +14,12 @@ const { processCodeOutput } = require('~/server/services/Files/Code/process'); const { loadAuthValues } = require('~/server/services/Tools/credentials'); const { loadTools } = require('~/app/clients/tools/util'); -const fieldsMap = { - [Tools.execute_code]: [EnvVar.CODE_API_KEY], -}; +/** + * Tools that are callable directly via `POST /tools/:toolId/call`. + * `execute_code` is the only entry today; the tool runs server-side via + * the agents library / sandbox service without any per-user credential. + */ +const directCallableTools = new Set([Tools.execute_code]); const toolAccessPermType = { [Tools.execute_code]: PermissionTypes.RUN_CODE, @@ -65,37 +67,23 @@ const verifyToolAuth = async (req, res) => { if (toolId === Tools.web_search) { return await verifyWebSearchAuth(req, res); } - const authFields = fieldsMap[toolId]; - if (!authFields) { + if (!directCallableTools.has(toolId)) { res.status(404).json({ message: 'Tool not found' }); return; } - let result; - try { - result = await loadAuthValues({ - userId: req.user.id, - authFields, - throwError: false, - }); - } catch (error) { - logger.error('Error loading auth values', error); - res.status(200).json({ authenticated: false, message: AuthType.USER_PROVIDED }); - return; - } - let isUserProvided = false; - for (const field of authFields) { - if (!result[field]) { - res.status(200).json({ authenticated: false, message: AuthType.USER_PROVIDED }); - return; - } - if (!isUserProvided && process.env[field] !== result[field]) { - isUserProvided = true; - } - } - res.status(200).json({ - authenticated: true, - message: isUserProvided ? AuthType.USER_PROVIDED : AuthType.SYSTEM_DEFINED, - }); + /** + * `execute_code` no longer requires a per-user credential — sandbox + * auth is handled server-side by the agents library. Always report + * system-authenticated so the client proceeds straight to the call + * without a key-entry dialog. + * + * Deployment contract: reachability of the sandbox service is the + * admin's responsibility. This endpoint does not probe the service + * (a per-auth-check network hop would be too expensive for what is + * a UI-gate query). If the sandbox is unreachable, the call path + * surfaces the error at execution time instead of here. + */ + res.status(200).json({ authenticated: true, message: AuthType.SYSTEM_DEFINED }); } catch (error) { res.status(500).json({ message: error.message }); } @@ -111,7 +99,7 @@ const callTool = async (req, res) => { try { const appConfig = req.config; const { toolId = '' } = req.params; - if (!fieldsMap[toolId]) { + if (!directCallableTools.has(toolId)) { logger.warn(`[${toolId}/call] User ${req.user.id} attempted call to invalid tool`); res.status(404).json({ message: 'Tool not found' }); return; @@ -192,6 +180,15 @@ const callTool = async (req, res) => { const artifactPromises = []; for (const file of artifact.files) { + /* Files flagged `inherited` by codeapi are unchanged passthroughs of + * inputs the caller already owns (skill files, prior downloaded inputs, + * inherited .dirkeep markers). Re-downloading them is wasted work and + * 403s when the file is scoped to a different entity (e.g. skill + * entity_id) than the user's session key. They remain available for + * subsequent tool calls via primeInvokedSkills / session inheritance. */ + if (file.inherited) { + continue; + } const { id, name } = file; artifactPromises.push( (async () => { @@ -199,7 +196,6 @@ const callTool = async (req, res) => { req, id, name, - apiKey: tool.apiKey, messageId, toolCallId, conversationId, diff --git a/api/server/experimental.js b/api/server/experimental.js index 3dfd5aee3f59..cd594c169676 100644 --- a/api/server/experimental.js +++ b/api/server/experimental.js @@ -310,6 +310,7 @@ if (cluster.isMaster) { app.use('/api/convos', routes.convos); app.use('/api/presets', routes.presets); app.use('/api/prompts', routes.prompts); + app.use('/api/skills', routes.skills); app.use('/api/categories', routes.categories); app.use('/api/endpoints', routes.endpoints); app.use('/api/balance', routes.balance); diff --git a/api/server/index.js b/api/server/index.js index adeaacdfcc24..d798f1a1661a 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -168,6 +168,7 @@ const startServer = async () => { app.use('/api/convos', routes.convos); app.use('/api/presets', routes.presets); app.use('/api/prompts', routes.prompts); + app.use('/api/skills', routes.skills); app.use('/api/categories', routes.categories); app.use('/api/endpoints', routes.endpoints); app.use('/api/balance', routes.balance); diff --git a/api/server/middleware/accessResources/canAccessSkillResource.js b/api/server/middleware/accessResources/canAccessSkillResource.js new file mode 100644 index 000000000000..1010ca899808 --- /dev/null +++ b/api/server/middleware/accessResources/canAccessSkillResource.js @@ -0,0 +1,32 @@ +const { ResourceType } = require('librechat-data-provider'); +const { canAccessResource } = require('./canAccessResource'); +const { getSkillById } = require('~/models'); + +/** + * Skill-specific middleware factory that checks skill access permissions. + * Wraps the generic `canAccessResource` with the SKILL resource type and + * `getSkillById` as the ID resolver. + * + * @param {Object} options + * @param {number} options.requiredPermission - Permission bit required (1=view, 2=edit, 4=delete, 8=share) + * @param {string} [options.resourceIdParam='id'] - Route parameter name holding the skill id + * @returns {Function} Express middleware + */ +const canAccessSkillResource = (options) => { + const { requiredPermission, resourceIdParam = 'id' } = options || {}; + + if (!requiredPermission || typeof requiredPermission !== 'number') { + throw new Error('canAccessSkillResource: requiredPermission is required and must be a number'); + } + + return canAccessResource({ + resourceType: ResourceType.SKILL, + requiredPermission, + resourceIdParam, + idResolver: getSkillById, + }); +}; + +module.exports = { + canAccessSkillResource, +}; diff --git a/api/server/middleware/accessResources/index.js b/api/server/middleware/accessResources/index.js index 838834919a4b..31548411bacb 100644 --- a/api/server/middleware/accessResources/index.js +++ b/api/server/middleware/accessResources/index.js @@ -4,6 +4,7 @@ const { canAccessAgentFromBody } = require('./canAccessAgentFromBody'); const { canAccessPromptViaGroup } = require('./canAccessPromptViaGroup'); const { canAccessPromptGroupResource } = require('./canAccessPromptGroupResource'); const { canAccessMCPServerResource } = require('./canAccessMCPServerResource'); +const { canAccessSkillResource } = require('./canAccessSkillResource'); module.exports = { canAccessResource, @@ -12,4 +13,5 @@ module.exports = { canAccessPromptViaGroup, canAccessPromptGroupResource, canAccessMCPServerResource, + canAccessSkillResource, }; diff --git a/api/server/middleware/checkSharePublicAccess.js b/api/server/middleware/checkSharePublicAccess.js index c7b65a077ecc..945d5521b711 100644 --- a/api/server/middleware/checkSharePublicAccess.js +++ b/api/server/middleware/checkSharePublicAccess.js @@ -10,6 +10,7 @@ const resourceToPermissionType = { [ResourceType.PROMPTGROUP]: PermissionTypes.PROMPTS, [ResourceType.MCPSERVER]: PermissionTypes.MCP_SERVERS, [ResourceType.REMOTE_AGENT]: PermissionTypes.REMOTE_AGENTS, + [ResourceType.SKILL]: PermissionTypes.SKILLS, }; /** diff --git a/api/server/routes/__tests__/mcp.spec.js b/api/server/routes/__tests__/mcp.spec.js index 7fabcc910a92..89f4237b1968 100644 --- a/api/server/routes/__tests__/mcp.spec.js +++ b/api/server/routes/__tests__/mcp.spec.js @@ -1947,6 +1947,106 @@ describe('MCP Routes', () => { }); }); + describe('GET /tools', () => { + it('should continue returning MCP tools when one server cache lookup fails', async () => { + const { Constants } = require('librechat-data-provider'); + const { logger } = require('@librechat/data-schemas'); + const { getMCPServerTools } = require('~/server/services/Config'); + + mockResolveAllMcpConfigs.mockResolvedValueOnce({ + 'bad-server': { + type: 'sse', + url: 'https://bad.example.com/sse', + }, + 'good-server': { + type: 'sse', + url: 'https://good.example.com/sse', + iconPath: '/icons/good.svg', + }, + }); + + // Mock order matches Object.keys() order from the config above. + getMCPServerTools + .mockRejectedValueOnce(new Error('cache unavailable')) + .mockResolvedValueOnce({ + [`search${Constants.mcp_delimiter}good-server`]: { + type: 'function', + function: { + name: `search${Constants.mcp_delimiter}good-server`, + description: 'Search good server', + parameters: { type: 'object' }, + }, + }, + }); + + const mockGetServerToolFunctions = jest.fn().mockResolvedValue(null); + require('~/config').getMCPManager.mockReturnValue({ + getServerToolFunctions: mockGetServerToolFunctions, + }); + + const response = await request(app).get('/api/mcp/tools'); + + expect(response.status).toBe(200); + expect(logger.error).toHaveBeenCalledWith( + '[getMCPTools] Error fetching cached tools for bad-server:', + expect.any(Error), + ); + expect(mockGetServerToolFunctions).toHaveBeenCalledWith('test-user-id', 'bad-server'); + expect(response.body.servers['good-server']).toMatchObject({ + name: 'good-server', + icon: '/icons/good.svg', + tools: [ + { + name: 'search', + pluginKey: `search${Constants.mcp_delimiter}good-server`, + description: 'Search good server', + }, + ], + }); + expect(response.body.servers['bad-server']).toMatchObject({ + name: 'bad-server', + tools: [], + }); + }); + + it('should return configured servers when all cache lookups fail', async () => { + const { logger } = require('@librechat/data-schemas'); + const { getMCPServerTools } = require('~/server/services/Config'); + + mockResolveAllMcpConfigs.mockResolvedValueOnce({ + 'first-server': { + type: 'sse', + url: 'https://first.example.com/sse', + }, + 'second-server': { + type: 'sse', + url: 'https://second.example.com/sse', + }, + }); + + getMCPServerTools.mockRejectedValue(new Error('cache unavailable')); + + const mockGetServerToolFunctions = jest.fn().mockResolvedValue(null); + require('~/config').getMCPManager.mockReturnValue({ + getServerToolFunctions: mockGetServerToolFunctions, + }); + + const response = await request(app).get('/api/mcp/tools'); + + expect(response.status).toBe(200); + expect(response.body.servers['first-server']).toMatchObject({ + name: 'first-server', + tools: [], + }); + expect(response.body.servers['second-server']).toMatchObject({ + name: 'second-server', + tools: [], + }); + expect(logger.error).toHaveBeenCalledTimes(2); + expect(mockGetServerToolFunctions).toHaveBeenCalledTimes(2); + }); + }); + describe('GET /servers', () => { // mockRegistryInstance is defined at the top of the file diff --git a/api/server/routes/accessPermissions.js b/api/server/routes/accessPermissions.js index 45afec133b77..01e3196b8f19 100644 --- a/api/server/routes/accessPermissions.js +++ b/api/server/routes/accessPermissions.js @@ -11,7 +11,7 @@ const { const { requireJwtAuth, checkBan, uaParser, canAccessResource } = require('~/server/middleware'); const { checkPeoplePickerAccess } = require('~/server/middleware/checkPeoplePickerAccess'); const { checkSharePublicAccess } = require('~/server/middleware/checkSharePublicAccess'); -const { findMCPServerByObjectId } = require('~/models'); +const { findMCPServerByObjectId, getSkillById } = require('~/models'); const router = express.Router(); @@ -72,6 +72,13 @@ const checkResourcePermissionAccess = (requiredPermission) => (req, res, next) = resourceIdParam: 'resourceId', idResolver: findMCPServerByObjectId, }); + } else if (resourceType === ResourceType.SKILL) { + middleware = canAccessResource({ + resourceType: ResourceType.SKILL, + requiredPermission, + resourceIdParam: 'resourceId', + idResolver: getSkillById, + }); } else { return res.status(400).json({ error: 'Bad Request', diff --git a/api/server/routes/agents/index.js b/api/server/routes/agents/index.js index eb42046bed41..bbb39f5d2c91 100644 --- a/api/server/routes/agents/index.js +++ b/api/server/routes/agents/index.js @@ -143,7 +143,8 @@ router.get('/chat/stream/:streamId', async (req, res) => { } if (!result) { - return res.status(404).json({ error: 'Failed to subscribe to stream' }); + onError('Failed to subscribe to stream'); + return; } req.on('close', () => { diff --git a/api/server/routes/files/files.js b/api/server/routes/files/files.js index eb13ecdc3114..5c26f65b81c9 100644 --- a/api/server/routes/files/files.js +++ b/api/server/routes/files/files.js @@ -1,6 +1,5 @@ const fs = require('fs').promises; const express = require('express'); -const { EnvVar } = require('@librechat/agents'); const { logger, SystemCapabilities } = require('@librechat/data-schemas'); const { refreshS3FileUrls, @@ -29,7 +28,6 @@ const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { getOpenAIClient } = require('~/server/controllers/assistants/helpers'); const { hasCapability } = require('~/server/middleware/roles/capabilities'); const { checkPermission } = require('~/server/services/PermissionService'); -const { loadAuthValues } = require('~/server/services/Tools/credentials'); const { hasAccessToFilesViaAgent } = require('~/server/services/Files'); const { cleanFileName } = require('~/server/utils/files'); const { getLogStores } = require('~/cache'); @@ -287,13 +285,8 @@ router.get('/code/download/:session_id/:fileId', async (req, res) => { return res.status(501).send('Not Implemented'); } - const result = await loadAuthValues({ userId: req.user.id, authFields: [EnvVar.CODE_API_KEY] }); - /** @type {AxiosResponse | undefined} */ - const response = await getDownloadStream( - `${session_id}/${fileId}`, - result[EnvVar.CODE_API_KEY], - ); + const response = await getDownloadStream(`${session_id}/${fileId}`); res.set(response.headers); response.data.pipe(res); } catch (error) { diff --git a/api/server/routes/index.js b/api/server/routes/index.js index 1feaf63fdb77..cab2f92ed1db 100644 --- a/api/server/routes/index.js +++ b/api/server/routes/index.js @@ -13,6 +13,7 @@ const messages = require('./messages'); const memories = require('./memories'); const presets = require('./presets'); const prompts = require('./prompts'); +const skills = require('./skills'); const balance = require('./balance'); const actions = require('./actions'); const apiKeys = require('./apiKeys'); @@ -56,6 +57,7 @@ module.exports = { config, models, prompts, + skills, actions, presets, balance, diff --git a/api/server/routes/roles.js b/api/server/routes/roles.js index 456d144af13d..feed23f8dd9b 100644 --- a/api/server/routes/roles.js +++ b/api/server/routes/roles.js @@ -11,6 +11,7 @@ const { marketplacePermissionsSchema, peoplePickerPermissionsSchema, remoteAgentsPermissionsSchema, + skillPermissionsSchema, } = require('librechat-data-provider'); const { hasCapability, requireCapability } = require('~/server/middleware/roles/capabilities'); const { updateRoleByName, getRoleByName } = require('~/models'); @@ -60,6 +61,11 @@ const permissionConfigs = { permissionType: PermissionTypes.REMOTE_AGENTS, errorMessage: 'Invalid remote agents permissions.', }, + skills: { + schema: skillPermissionsSchema, + permissionType: PermissionTypes.SKILLS, + errorMessage: 'Invalid skill permissions.', + }, }; /** @@ -177,4 +183,10 @@ router.put('/:roleName/marketplace', manageRoles, createPermissionUpdateHandler( */ router.put('/:roleName/remote-agents', manageRoles, createPermissionUpdateHandler('remote-agents')); +/** + * PUT /api/roles/:roleName/skills + * Update skill permissions for a specific role + */ +router.put('/:roleName/skills', manageRoles, createPermissionUpdateHandler('skills')); + module.exports = router; diff --git a/api/server/routes/settings.js b/api/server/routes/settings.js index 22162fed4e00..c6b7c84b2c21 100644 --- a/api/server/routes/settings.js +++ b/api/server/routes/settings.js @@ -3,11 +3,17 @@ const { updateFavoritesController, getFavoritesController, } = require('~/server/controllers/FavoritesController'); +const { + getSkillStatesController, + updateSkillStatesController, +} = require('~/server/controllers/SkillStatesController'); const { requireJwtAuth } = require('~/server/middleware'); const router = express.Router(); router.get('/favorites', requireJwtAuth, getFavoritesController); router.post('/favorites', requireJwtAuth, updateFavoritesController); +router.get('/skills/active', requireJwtAuth, getSkillStatesController); +router.post('/skills/active', requireJwtAuth, updateSkillStatesController); module.exports = router; diff --git a/api/server/routes/skills.js b/api/server/routes/skills.js new file mode 100644 index 000000000000..9e9676fb4535 --- /dev/null +++ b/api/server/routes/skills.js @@ -0,0 +1,321 @@ +const path = require('path'); +const crypto = require('crypto'); +const multer = require('multer'); +const express = require('express'); +const { + createSkillsHandlers, + createImportHandler, + generateCheckAccess, +} = require('@librechat/api'); +const { isValidObjectIdString, logger } = require('@librechat/data-schemas'); +const { + PermissionBits, + PermissionTypes, + Permissions, + FileContext, +} = require('librechat-data-provider'); +const { + createSkill, + getSkillById, + listSkillsByAccess, + updateSkill, + deleteSkill, + listSkillFiles, + upsertSkillFile, + deleteSkillFile, + getSkillFileByPath, + updateSkillFileContent, + getRoleByName, +} = require('~/models'); +const { requireJwtAuth, canAccessSkillResource } = require('~/server/middleware'); +const { + findAccessibleResources, + findPubliclyAccessibleResources, + hasPublicPermission, + grantPermission, +} = require('~/server/services/PermissionService'); +const { getStrategyFunctions } = require('~/server/services/Files/strategies'); +const { createFileLimiters } = require('~/server/middleware/limiters/uploadLimiters'); +const configMiddleware = require('~/server/middleware/config/app'); +const { getFileStrategy } = require('~/server/utils/getFileStrategy'); + +const router = express.Router(); + +// --------------------------------------------------------------------------- +// Multer: memory storage for skill imports (zip processed in-memory) +// --------------------------------------------------------------------------- +const ALLOWED_EXTENSIONS = new Set(['.md', '.zip', '.skill']); +const MAX_IMPORT_SIZE = 50 * 1024 * 1024; // 50 MB + +const memoryStorage = multer.memoryStorage(); + +const skillImportFilter = (_req, file, cb) => { + const ext = path.extname(file.originalname).toLowerCase(); + if (ALLOWED_EXTENSIONS.has(ext)) { + cb(null, true); + } else { + // N.B. The error handler at the bottom of this file matches this "Only " prefix. + cb(new Error('Only .md, .zip, and .skill files are allowed'), false); + } +}; + +const skillUpload = multer({ + storage: memoryStorage, + fileFilter: skillImportFilter, + limits: { fileSize: MAX_IMPORT_SIZE }, +}); + +// Per-file upload (for adding individual files to an existing skill) +const MAX_SINGLE_FILE_SIZE = 10 * 1024 * 1024; // 10 MB +const singleFileUpload = multer({ + storage: memoryStorage, + limits: { fileSize: MAX_SINGLE_FILE_SIZE }, +}); + +// --------------------------------------------------------------------------- +// Role-based capability gates +// --------------------------------------------------------------------------- +const checkSkillAccess = generateCheckAccess({ + permissionType: PermissionTypes.SKILLS, + permissions: [Permissions.USE], + getRoleByName, +}); +const checkSkillCreate = generateCheckAccess({ + permissionType: PermissionTypes.SKILLS, + permissions: [Permissions.USE, Permissions.CREATE], + getRoleByName, +}); + +// --------------------------------------------------------------------------- +// Rate limiters (reuse existing file upload limiters) +// --------------------------------------------------------------------------- +const { fileUploadIpLimiter, fileUploadUserLimiter } = createFileLimiters(); + +router.use(requireJwtAuth); +router.use(configMiddleware); +router.use(checkSkillAccess); + +// --------------------------------------------------------------------------- +// CRUD handlers +// --------------------------------------------------------------------------- +const handlers = createSkillsHandlers({ + createSkill, + getSkillById, + listSkillsByAccess, + updateSkill, + deleteSkill, + listSkillFiles, + deleteSkillFile, + getSkillFileByPath, + updateSkillFileContent, + getStrategyFunctions, + findAccessibleResources, + findPubliclyAccessibleResources, + hasPublicPermission, + grantPermission, + isValidObjectIdString, +}); + +// --------------------------------------------------------------------------- +// File storage helper: resolve the active strategy's saveBuffer +// --------------------------------------------------------------------------- +function resolveSkillStorage(req, { isImage = false } = {}) { + const source = getFileStrategy(req.config, { context: FileContext.skill_file, isImage }); + const strategy = getStrategyFunctions(source); + if (!strategy.saveBuffer) { + throw new Error(`Storage backend "${source}" does not support file writes`); + } + return { saveBuffer: strategy.saveBuffer, source }; +} + +// --------------------------------------------------------------------------- +// Import handler (zip/md/skill → create skill + files) +// --------------------------------------------------------------------------- +const importHandler = createImportHandler({ + createSkill, + getSkillById, + deleteSkill, + upsertSkillFile, + saveBuffer: (req, { userId, buffer, fileName, basePath, isImage }) => { + const storage = resolveSkillStorage(req, { isImage }); + return storage.saveBuffer({ userId, buffer, fileName, basePath }).then((filepath) => ({ + filepath, + source: storage.source, + })); + }, + deleteFile: (req, file) => { + const { deleteFile } = getStrategyFunctions(file.source); + if (deleteFile) { + return deleteFile(req, file); + } + return Promise.resolve(); + }, + grantPermission, +}); + +// --------------------------------------------------------------------------- +// Per-file upload handler (add a single file to an existing skill) +// --------------------------------------------------------------------------- +async function uploadFileHandler(req, res) { + try { + const { file } = req; + if (!file) { + return res.status(400).json({ error: 'No file provided' }); + } + + const skillId = req.params.id; + const relativePath = req.body.relativePath; + if (!relativePath) { + return res.status(400).json({ error: 'relativePath is required in form body' }); + } + if (relativePath.toUpperCase() === 'SKILL.MD') { + return res.status(400).json({ error: 'SKILL.md is reserved; update the skill body instead' }); + } + // Reject traversal, absolute paths, empty/dot segments — matches model-layer validator + // so storage writes don't happen before DB rejects the path. + if ( + !/^[a-zA-Z0-9._\-/]+$/.test(relativePath) || + /^\//.test(relativePath) || + relativePath.split('/').some((s) => s === '' || s === '.' || s === '..') + ) { + return res.status(400).json({ error: 'Invalid file path' }); + } + + // Look up existing file before saving — needed to clean up old blob on replace + const existingFile = await getSkillFileByPath(skillId, relativePath); + + const fileId = crypto.randomUUID(); + const filename = file.originalname; + const storageFileName = `${fileId}__${filename}`; + + const isImage = (file.mimetype || '').startsWith('image/'); + const storage = resolveSkillStorage(req, { isImage }); + const filepath = await storage.saveBuffer({ + userId: req.user.id, + buffer: file.buffer, + fileName: storageFileName, + basePath: 'uploads', + }); + + let result; + try { + result = await upsertSkillFile({ + skillId, + relativePath, + file_id: fileId, + filename, + filepath, + source: storage.source, + mimeType: file.mimetype || 'application/octet-stream', + bytes: file.size, + isExecutable: false, + author: req.user._id, + }); + } catch (dbError) { + // Clean up the stored blob so it doesn't leak on DB failure + try { + const { deleteFile } = getStrategyFunctions(storage.source); + if (deleteFile) { + await deleteFile(req, { filepath }); + } + } catch (cleanupErr) { + logger.error('[uploadFile] Failed to clean up orphaned blob:', cleanupErr); + } + throw dbError; + } + + // Clean up old blob if this was a replace (different filepath means new storage object) + if (existingFile && existingFile.filepath !== filepath) { + const { deleteFile: delOld } = getStrategyFunctions(existingFile.source); + if (delOld) { + delOld(req, { filepath: existingFile.filepath }).catch((e) => + logger.error('[uploadFile] Old blob cleanup failed:', e), + ); + } + } + + return res.status(200).json(result); + } catch (error) { + if (error.code === 'SKILL_FILE_VALIDATION_FAILED') { + return res.status(400).json({ error: error.message }); + } + logger.error('[uploadFile] Error:', error); + return res.status(500).json({ error: 'Failed to upload file' }); + } +} + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + +// Import: accepts .md / .zip / .skill via multipart +router.post( + '/import', + checkSkillCreate, + fileUploadIpLimiter, + fileUploadUserLimiter, + skillUpload.single('file'), + importHandler, +); + +router.get('/', handlers.list); +router.post('/', checkSkillCreate, handlers.create); + +router.get( + '/:id', + canAccessSkillResource({ requiredPermission: PermissionBits.VIEW }), + handlers.get, +); + +router.patch( + '/:id', + checkSkillCreate, + canAccessSkillResource({ requiredPermission: PermissionBits.EDIT }), + handlers.patch, +); + +router.delete( + '/:id', + checkSkillCreate, + canAccessSkillResource({ requiredPermission: PermissionBits.DELETE }), + handlers.delete, +); + +router.get( + '/:id/files', + canAccessSkillResource({ requiredPermission: PermissionBits.VIEW }), + handlers.listFiles, +); + +// Per-file upload (live — replaces 501 stub) +router.post( + '/:id/files', + canAccessSkillResource({ requiredPermission: PermissionBits.EDIT }), + fileUploadIpLimiter, + fileUploadUserLimiter, + singleFileUpload.single('file'), + uploadFileHandler, +); + +router.get( + '/:id/files/:relativePath', + canAccessSkillResource({ requiredPermission: PermissionBits.VIEW }), + handlers.downloadFile, +); + +router.delete( + '/:id/files/:relativePath', + canAccessSkillResource({ requiredPermission: PermissionBits.EDIT }), + handlers.deleteFile, +); + +// Multer + file-filter error handler — surface as 400, forward everything else + +router.use((err, _req, res, next) => { + if (err && (err.name === 'MulterError' || err.message?.startsWith('Only '))) { + return res.status(400).json({ error: err.message }); + } + return next(err); +}); + +module.exports = router; diff --git a/api/server/routes/skills.test.js b/api/server/routes/skills.test.js new file mode 100644 index 000000000000..1a0a23c89622 --- /dev/null +++ b/api/server/routes/skills.test.js @@ -0,0 +1,519 @@ +const express = require('express'); +const request = require('supertest'); +const mongoose = require('mongoose'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const { + SystemRoles, + ResourceType, + AccessRoleIds, + PrincipalType, + PermissionBits, +} = require('librechat-data-provider'); + +jest.mock('~/server/services/Config', () => ({ + getCachedTools: jest.fn().mockResolvedValue({}), + getAppConfig: jest.fn().mockResolvedValue({ + fileStrategy: 'local', + paths: { uploads: '/tmp/uploads', images: '/tmp/images' }, + }), +})); + +jest.mock('~/server/middleware/config/app', () => (req, _res, next) => { + req.config = { + fileStrategy: 'local', + paths: { uploads: '/tmp/uploads', images: '/tmp/images' }, + }; + next(); +}); + +jest.mock('~/server/services/Files/strategies', () => ({ + getStrategyFunctions: jest.fn().mockReturnValue({ + saveBuffer: jest.fn().mockResolvedValue('/uploads/test/file.txt'), + getDownloadStream: jest.fn().mockResolvedValue({ + pipe: jest.fn(), + on: jest.fn(), + [Symbol.asyncIterator]: async function* () { + yield Buffer.from('test content'); + }, + }), + }), +})); + +jest.mock('~/server/utils/getFileStrategy', () => ({ + getFileStrategy: jest.fn().mockReturnValue('local'), +})); + +jest.mock('~/models', () => { + const mongoose = require('mongoose'); + const { createMethods } = require('@librechat/data-schemas'); + const methods = createMethods(mongoose, { + removeAllPermissions: async ({ resourceType, resourceId }) => { + const AclEntry = mongoose.models.AclEntry; + if (AclEntry) { + await AclEntry.deleteMany({ resourceType, resourceId }); + } + }, + }); + // Override getRoleByName to return a permissive SKILLS capability block for all + // test users. The real role seeding relies on `initializeRoles` which this + // suite intentionally skips to keep setup minimal. + return { + ...methods, + getRoleByName: jest.fn(), + }; +}); + +jest.mock('~/server/middleware', () => ({ + requireJwtAuth: (req, res, next) => next(), + canAccessSkillResource: jest.requireActual('~/server/middleware').canAccessSkillResource, +})); + +let app; +let mongoServer; +let skillRoutes; +let Skill; +let SkillFile; +let AclEntry; +let AccessRole; +let User; +let testUsers; +let testRoles; +let grantPermission; +let currentTestUser; + +function setTestUser(user) { + currentTestUser = user; +} + +beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + + const dbModels = require('~/db/models'); + Skill = dbModels.Skill; + SkillFile = dbModels.SkillFile; + AclEntry = dbModels.AclEntry; + AccessRole = dbModels.AccessRole; + User = dbModels.User; + + const permissionService = require('~/server/services/PermissionService'); + grantPermission = permissionService.grantPermission; + + await setupTestData(); + + app = express(); + app.use(express.json()); + app.use((req, res, next) => { + if (currentTestUser) { + req.user = { + ...(currentTestUser.toObject ? currentTestUser.toObject() : currentTestUser), + id: currentTestUser._id.toString(), + _id: currentTestUser._id, + name: currentTestUser.name, + role: currentTestUser.role, + }; + } + next(); + }); + + currentTestUser = testUsers.owner; + skillRoutes = require('./skills'); + app.use('/api/skills', skillRoutes); +}); + +afterEach(async () => { + await Skill.deleteMany({}); + await SkillFile.deleteMany({}); + await AclEntry.deleteMany({}); + currentTestUser = testUsers.owner; +}); + +afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); + jest.clearAllMocks(); +}); + +async function setupTestData() { + testRoles = { + viewer: await AccessRole.create({ + accessRoleId: AccessRoleIds.SKILL_VIEWER, + name: 'Viewer', + resourceType: ResourceType.SKILL, + permBits: PermissionBits.VIEW, + }), + editor: await AccessRole.create({ + accessRoleId: AccessRoleIds.SKILL_EDITOR, + name: 'Editor', + resourceType: ResourceType.SKILL, + permBits: PermissionBits.VIEW | PermissionBits.EDIT, + }), + owner: await AccessRole.create({ + accessRoleId: AccessRoleIds.SKILL_OWNER, + name: 'Owner', + resourceType: ResourceType.SKILL, + permBits: + PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE, + }), + }; + + testUsers = { + owner: await User.create({ + name: 'Skill Owner', + email: 'skill-owner@test.com', + role: SystemRoles.USER, + }), + editor: await User.create({ + name: 'Skill Editor', + email: 'skill-editor@test.com', + role: SystemRoles.USER, + }), + noAccess: await User.create({ + name: 'No Access', + email: 'no-access@test.com', + role: SystemRoles.USER, + }), + }; + + const { getRoleByName } = require('~/models'); + getRoleByName.mockImplementation((roleName) => { + if (roleName === SystemRoles.USER || roleName === SystemRoles.ADMIN) { + return { + permissions: { + SKILLS: { + USE: true, + CREATE: true, + SHARE: true, + SHARE_PUBLIC: true, + }, + }, + }; + } + return null; + }); +} + +async function createSkillAsOwner(overrides = {}) { + // Description is deliberately kept above the 20-char short-description + // warning threshold so existing tests don't trip the coaching warning. + const res = await request(app) + .post('/api/skills') + .send({ + name: 'demo-skill', + description: 'A small demo skill used in routing integration tests.', + body: '# Demo', + ...overrides, + }); + return res; +} + +describe('Skill routes', () => { + let errSpy; + beforeEach(() => { + errSpy = jest.spyOn(console, 'error').mockImplementation(); + }); + afterEach(() => errSpy.mockRestore()); + + describe('POST /api/skills', () => { + it('creates a skill and grants SKILL_OWNER ACL', async () => { + const res = await createSkillAsOwner(); + expect(res.status).toBe(201); + expect(res.body._id).toBeDefined(); + expect(res.body.version).toBe(1); + expect(res.body.name).toBe('demo-skill'); + // No warnings on a description that comfortably clears the threshold. + expect(res.body.warnings).toBeUndefined(); + + const acl = await AclEntry.findOne({ + resourceType: ResourceType.SKILL, + resourceId: res.body._id, + principalType: PrincipalType.USER, + principalId: testUsers.owner._id, + }); + expect(acl).toBeTruthy(); + expect(acl.roleId.toString()).toBe(testRoles.owner._id.toString()); + }); + + it('attaches a TOO_SHORT warning on create when description is under 20 chars', async () => { + const res = await createSkillAsOwner({ + name: 'short-desc-skill', + description: 'Too short.', + }); + expect(res.status).toBe(201); + expect(res.body._id).toBeDefined(); + expect(Array.isArray(res.body.warnings)).toBe(true); + expect(res.body.warnings).toEqual([ + expect.objectContaining({ + field: 'description', + code: 'TOO_SHORT', + severity: 'warning', + }), + ]); + }); + + it('rejects names starting with reserved brand prefixes', async () => { + const anthropic = await createSkillAsOwner({ name: 'anthropic-helper' }); + expect(anthropic.status).toBe(400); + const claude = await createSkillAsOwner({ name: 'claude-helper' }); + expect(claude.status).toBe(400); + }); + + it('allows names that merely contain reserved brand words as substrings', async () => { + const res = await createSkillAsOwner({ name: 'research-anthropic-helper' }); + expect(res.status).toBe(201); + }); + + it('rejects reserved CLI command names', async () => { + const res = await createSkillAsOwner({ name: 'settings' }); + expect(res.status).toBe(400); + }); + + it('rejects frontmatter with unknown keys', async () => { + const res = await createSkillAsOwner({ + name: 'bad-frontmatter-skill', + frontmatter: { 'not-a-real-key': 'value' }, + }); + expect(res.status).toBe(400); + expect(res.body.issues).toEqual( + expect.arrayContaining([expect.objectContaining({ code: 'UNKNOWN_KEY' })]), + ); + }); + + it('rejects missing description with 400', async () => { + const res = await request(app).post('/api/skills').send({ name: 'x-skill', body: '' }); + expect(res.status).toBe(400); + }); + + it('rejects invalid name with 400 validation failure', async () => { + const res = await createSkillAsOwner({ name: 'BAD NAME' }); + expect(res.status).toBe(400); + expect(res.body.issues).toBeDefined(); + }); + + it('rejects duplicate names with 409', async () => { + const a = await createSkillAsOwner(); + expect(a.status).toBe(201); + const b = await createSkillAsOwner(); + expect(b.status).toBe(409); + }); + }); + + describe('GET /api/skills', () => { + it('returns only skills the caller can access', async () => { + const mine = await createSkillAsOwner({ name: 'mine-skill' }); + expect(mine.status).toBe(201); + + setTestUser(testUsers.noAccess); + const other = await createSkillAsOwner({ name: 'other-skill' }); + expect(other.status).toBe(201); + // Note: the user middleware grants owner perms to whichever user created, so both + // users see their own skill only. + + setTestUser(testUsers.owner); + const res = await request(app).get('/api/skills'); + expect(res.status).toBe(200); + expect(res.body.skills.length).toBe(1); + expect(res.body.skills[0].name).toBe('mine-skill'); + }); + }); + + describe('GET /api/skills/:id', () => { + it('returns 403 when the user has no access', async () => { + const created = await createSkillAsOwner(); + expect(created.status).toBe(201); + setTestUser(testUsers.noAccess); + const res = await request(app).get(`/api/skills/${created.body._id}`); + expect(res.status).toBe(403); + }); + + it('returns the skill to the owner with isPublic flag', async () => { + const created = await createSkillAsOwner(); + const res = await request(app).get(`/api/skills/${created.body._id}`); + expect(res.status).toBe(200); + expect(res.body.name).toBe('demo-skill'); + expect(res.body.isPublic).toBe(false); + }); + }); + + describe('PATCH /api/skills/:id (optimistic concurrency)', () => { + it('updates with correct expectedVersion and bumps version', async () => { + const created = await createSkillAsOwner(); + const res = await request(app) + .patch(`/api/skills/${created.body._id}`) + .send({ expectedVersion: 1, description: 'Updated description' }); + expect(res.status).toBe(200); + expect(res.body.version).toBe(2); + expect(res.body.description).toBe('Updated description'); + }); + + it('returns 409 on stale expectedVersion', async () => { + const created = await createSkillAsOwner(); + const first = await request(app) + .patch(`/api/skills/${created.body._id}`) + .send({ expectedVersion: 1, description: 'First' }); + expect(first.status).toBe(200); + + const stale = await request(app) + .patch(`/api/skills/${created.body._id}`) + .send({ expectedVersion: 1, description: 'Stale' }); + expect(stale.status).toBe(409); + expect(stale.body.error).toBe('skill_version_conflict'); + expect(stale.body.current.version).toBe(2); + }); + + it('rejects updates without expectedVersion', async () => { + const created = await createSkillAsOwner(); + const res = await request(app) + .patch(`/api/skills/${created.body._id}`) + .send({ description: 'no version' }); + expect(res.status).toBe(400); + }); + + it('returns 403 for a user without EDIT permission', async () => { + const created = await createSkillAsOwner(); + setTestUser(testUsers.noAccess); + const res = await request(app) + .patch(`/api/skills/${created.body._id}`) + .send({ expectedVersion: 1, description: 'nope' }); + expect(res.status).toBe(403); + }); + }); + + describe('DELETE /api/skills/:id', () => { + it('deletes and cascades ACL entries', async () => { + const created = await createSkillAsOwner(); + const res = await request(app).delete(`/api/skills/${created.body._id}`); + expect(res.status).toBe(200); + expect(res.body.deleted).toBe(true); + + const remainingAcl = await AclEntry.countDocuments({ + resourceType: ResourceType.SKILL, + resourceId: created.body._id, + }); + expect(remainingAcl).toBe(0); + }); + + it('returns 403 for a non-owner', async () => { + const created = await createSkillAsOwner(); + setTestUser(testUsers.noAccess); + const res = await request(app).delete(`/api/skills/${created.body._id}`); + expect(res.status).toBe(403); + }); + }); + + describe('GET /api/skills/:id/files', () => { + it('returns an empty list for a skill with no files', async () => { + const created = await createSkillAsOwner(); + const res = await request(app).get(`/api/skills/${created.body._id}/files`); + expect(res.status).toBe(200); + expect(res.body.files).toEqual([]); + }); + }); + + describe('POST /api/skills/:id/files (live)', () => { + it('returns 400 when no file is provided', async () => { + const created = await createSkillAsOwner(); + const res = await request(app).post(`/api/skills/${created.body._id}/files`); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/no file/i); + }); + }); + + describe('GET /api/skills/:id/files/:relativePath', () => { + it('returns SKILL.md content from skill body', async () => { + const created = await createSkillAsOwner(); + const res = await request(app).get(`/api/skills/${created.body._id}/files/SKILL.md`); + expect(res.status).toBe(200); + expect(res.body.mimeType).toBe('text/markdown'); + expect(res.body.isBinary).toBe(false); + expect(res.body.filename).toBe('SKILL.md'); + expect(res.body.content).toBeDefined(); + }); + + it('returns 404 for a nonexistent file', async () => { + const created = await createSkillAsOwner(); + const res = await request(app).get( + `/api/skills/${created.body._id}/files/scripts%2Fmissing.sh`, + ); + expect(res.status).toBe(404); + }); + }); + + describe('DELETE /api/skills/:id/files/:relativePath', () => { + const { upsertSkillFile } = require('~/models'); + + it('deletes an existing skill file, bumps skill version, and returns 200', async () => { + const created = await createSkillAsOwner(); + await upsertSkillFile({ + skillId: created.body._id, + relativePath: 'scripts/parse.sh', + file_id: 'file-1', + filename: 'parse.sh', + filepath: '/tmp/parse.sh', + source: 'local', + mimeType: 'text/x-shellscript', + bytes: 42, + author: testUsers.owner._id, + }); + + const beforeSkill = await request(app).get(`/api/skills/${created.body._id}`); + expect(beforeSkill.body.fileCount).toBe(1); + expect(beforeSkill.body.version).toBe(2); + + const res = await request(app).delete( + `/api/skills/${created.body._id}/files/scripts%2Fparse.sh`, + ); + expect(res.status).toBe(200); + expect(res.body).toEqual({ + skillId: created.body._id, + relativePath: 'scripts/parse.sh', + deleted: true, + }); + + const afterSkill = await request(app).get(`/api/skills/${created.body._id}`); + expect(afterSkill.body.fileCount).toBe(0); + expect(afterSkill.body.version).toBe(3); + }); + + it('returns 404 when the file does not exist', async () => { + const created = await createSkillAsOwner(); + const res = await request(app).delete( + `/api/skills/${created.body._id}/files/scripts%2Fmissing.sh`, + ); + expect(res.status).toBe(404); + }); + + it('returns 403 for a non-owner', async () => { + const created = await createSkillAsOwner(); + setTestUser(testUsers.noAccess); + const res = await request(app).delete( + `/api/skills/${created.body._id}/files/scripts%2Fparse.sh`, + ); + expect(res.status).toBe(403); + }); + }); + + describe('Sharing via ACL (editor grant)', () => { + it('allows an editor to patch a shared skill', async () => { + const created = await createSkillAsOwner(); + await grantPermission({ + principalType: PrincipalType.USER, + principalId: testUsers.editor._id, + resourceType: ResourceType.SKILL, + resourceId: created.body._id, + accessRoleId: AccessRoleIds.SKILL_EDITOR, + grantedBy: testUsers.owner._id, + }); + + setTestUser(testUsers.editor); + const res = await request(app) + .patch(`/api/skills/${created.body._id}`) + .send({ expectedVersion: 1, description: 'Edited by editor' }); + expect(res.status).toBe(200); + + // Editor should NOT be able to delete + const del = await request(app).delete(`/api/skills/${created.body._id}`); + expect(del.status).toBe(403); + }); + }); +}); diff --git a/api/server/services/Config/__tests__/getCachedTools.spec.js b/api/server/services/Config/__tests__/getCachedTools.spec.js index 38d488ed38df..71ae8b5a5731 100644 --- a/api/server/services/Config/__tests__/getCachedTools.spec.js +++ b/api/server/services/Config/__tests__/getCachedTools.spec.js @@ -1,6 +1,12 @@ const { CacheKeys } = require('librechat-data-provider'); +jest.mock('@librechat/data-schemas', () => ({ + logger: { + error: jest.fn(), + }, +})); jest.mock('~/cache/getLogStores'); +const { logger } = require('@librechat/data-schemas'); const getLogStores = require('~/cache/getLogStores'); const mockCache = { get: jest.fn(), set: jest.fn(), delete: jest.fn() }; @@ -75,6 +81,30 @@ describe('getCachedTools', () => { expect(mockCache.get).toHaveBeenCalledWith(ToolCacheKeys.MCP_SERVER('user1', 'github')); }); + it('getMCPServerTools should return null when the cache lookup fails', async () => { + const error = new Error('cache unavailable'); + mockCache.get.mockRejectedValue(error); + + await expect(getMCPServerTools('user1', 'github')).resolves.toBeNull(); + expect(logger.error).toHaveBeenCalledWith( + '[getMCPServerTools] Error fetching cached tools for github:', + error, + ); + }); + + it('getMCPServerTools should return null when the cache store is unavailable', async () => { + const error = new Error('cache store unavailable'); + getLogStores.mockImplementationOnce(() => { + throw error; + }); + + await expect(getMCPServerTools('user1', 'github')).resolves.toBeNull(); + expect(logger.error).toHaveBeenCalledWith( + '[getMCPServerTools] Error fetching cached tools for github:', + error, + ); + }); + it('should NOT use CONFIG_STORE namespace', async () => { mockCache.get.mockResolvedValue(null); await getCachedTools(); diff --git a/api/server/services/Config/getCachedTools.js b/api/server/services/Config/getCachedTools.js index eb7a08305a3e..083cfae6bad7 100644 --- a/api/server/services/Config/getCachedTools.js +++ b/api/server/services/Config/getCachedTools.js @@ -1,4 +1,5 @@ const { CacheKeys, Time } = require('librechat-data-provider'); +const { logger } = require('@librechat/data-schemas'); const getLogStores = require('~/cache/getLogStores'); /** @@ -89,14 +90,13 @@ async function invalidateCachedTools(options = {}) { * @returns {Promise} The available tools for the server */ async function getMCPServerTools(userId, serverName) { - const cache = getLogStores(CacheKeys.TOOL_CACHE); - const serverTools = await cache.get(ToolCacheKeys.MCP_SERVER(userId, serverName)); - - if (serverTools) { - return serverTools; + try { + const cache = getLogStores(CacheKeys.TOOL_CACHE); + return (await cache.get(ToolCacheKeys.MCP_SERVER(userId, serverName))) || null; + } catch (error) { + logger.error(`[getMCPServerTools] Error fetching cached tools for ${serverName}:`, error); + return null; } - - return null; } module.exports = { diff --git a/api/server/services/Endpoints/agents/addedConvo.js b/api/server/services/Endpoints/agents/addedConvo.js index 7561053f8f78..2a2cd9ca30b7 100644 --- a/api/server/services/Endpoints/agents/addedConvo.js +++ b/api/server/services/Endpoints/agents/addedConvo.js @@ -40,6 +40,9 @@ const loadAddedAgent = (params) => * @param {Map} params.agentConfigs - Map of agent configs to add to * @param {string} params.primaryAgentId - The primary agent ID * @param {Object|undefined} params.userMCPAuthMap - User MCP auth map to merge into + * @param {boolean} [params.codeEnvAvailable] - `execute_code` capability flag; + * forwarded verbatim to the added agent's `initializeAgent`. @see + * InitializeAgentParams.codeEnvAvailable for full semantics. * @returns {Promise<{userMCPAuthMap: Object|undefined}>} The updated userMCPAuthMap */ const processAddedConvo = async ({ @@ -57,6 +60,7 @@ const processAddedConvo = async ({ primaryAgentId, primaryAgent, userMCPAuthMap, + codeEnvAvailable, }) => { const addedConvo = endpointOption.addedConvo; if (addedConvo == null) { @@ -101,6 +105,7 @@ const processAddedConvo = async ({ agent: addedAgent, endpointOption, allowedProviders, + codeEnvAvailable, }, { getFiles: db.getFiles, diff --git a/api/server/services/Endpoints/agents/addedConvo.spec.js b/api/server/services/Endpoints/agents/addedConvo.spec.js new file mode 100644 index 000000000000..b5c9427690ea --- /dev/null +++ b/api/server/services/Endpoints/agents/addedConvo.spec.js @@ -0,0 +1,108 @@ +const mockInitializeAgent = jest.fn(); +const mockValidateAgentModel = jest.fn(); +const mockLoadAddedAgent = jest.fn(); +const mockGetAgent = jest.fn(); +const mockGetMCPServerTools = jest.fn(); + +jest.mock('@librechat/data-schemas', () => ({ + logger: { + debug: jest.fn(), + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + }, +})); + +jest.mock('@librechat/api', () => ({ + ADDED_AGENT_ID: '__added_agent__', + initializeAgent: (...args) => mockInitializeAgent(...args), + validateAgentModel: (...args) => mockValidateAgentModel(...args), + loadAddedAgent: (params) => mockLoadAddedAgent(params), +})); + +jest.mock('~/server/services/Files/permissions', () => ({ + filterFilesByAgentAccess: jest.fn(), +})); + +jest.mock('~/server/services/Config', () => ({ + getMCPServerTools: (...args) => mockGetMCPServerTools(...args), +})); + +jest.mock('~/models', () => ({ + getAgent: (...args) => mockGetAgent(...args), +})); + +const { processAddedConvo } = require('./addedConvo'); + +const makeReq = () => ({ user: { id: 'u1', role: 'USER' } }); + +/** + * Phase 8 pins `processAddedConvo` forwarding the run's `codeEnvAvailable` to + * the added-convo `initializeAgent` call. Without this, parallel multi-convo + * agents with `tools: ['execute_code']` silently drop `bash_tool` + `read_file` + * even though the primary had them — pre-Phase-8 the legacy + * `CodeExecutionToolDefinition` landed in their `toolDefinitions` via the + * registry regardless of any explicit flag. + */ +describe('processAddedConvo — codeEnvAvailable passthrough', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockValidateAgentModel.mockResolvedValue({ isValid: true }); + mockInitializeAgent.mockResolvedValue({ + id: 'added-agent', + userMCPAuthMap: undefined, + }); + mockLoadAddedAgent.mockResolvedValue({ id: 'added-agent', provider: 'openai' }); + }); + + const baseParams = (overrides = {}) => ({ + req: makeReq(), + res: {}, + endpointOption: { addedConvo: { model: 'gpt-4o', agent_id: 'added-agent' } }, + modelsConfig: { openai: ['gpt-4o'] }, + logViolation: jest.fn(), + loadTools: jest.fn(), + requestFiles: [], + conversationId: 'conv-1', + parentMessageId: null, + allowedProviders: new Set(['openai']), + agentConfigs: new Map(), + primaryAgentId: 'primary-id', + primaryAgent: { id: 'primary-id' }, + userMCPAuthMap: undefined, + ...overrides, + }); + + it('forwards codeEnvAvailable=true to the added-convo initializeAgent call', async () => { + await processAddedConvo(baseParams({ codeEnvAvailable: true })); + + expect(mockInitializeAgent).toHaveBeenCalledWith( + expect.objectContaining({ codeEnvAvailable: true }), + expect.anything(), + ); + }); + + it('forwards codeEnvAvailable=false verbatim (not coerced to undefined)', async () => { + /* Symmetric coverage: if the runtime gate is off for the primary, the + parallel agent must not accidentally re-enable code execution via a + defaulting bug in the destructuring. */ + await processAddedConvo(baseParams({ codeEnvAvailable: false })); + + expect(mockInitializeAgent).toHaveBeenCalledWith( + expect.objectContaining({ codeEnvAvailable: false }), + expect.anything(), + ); + }); + + it('forwards codeEnvAvailable=undefined when caller omits it (no silent default)', async () => { + /* Backstop for the "caller didn't update after Phase 8" case — the + added-convo path must not invent a truthy value out of thin air. + Matches `initializeAgent`'s own "explicit opt-in" semantics. */ + await processAddedConvo(baseParams()); + + expect(mockInitializeAgent).toHaveBeenCalledWith( + expect.objectContaining({ codeEnvAvailable: undefined }), + expect.anything(), + ); + }); +}); diff --git a/api/server/services/Endpoints/agents/initialize.js b/api/server/services/Endpoints/agents/initialize.js index 549e9047b570..a2f07c783f65 100644 --- a/api/server/services/Endpoints/agents/initialize.js +++ b/api/server/services/Endpoints/agents/initialize.js @@ -1,16 +1,23 @@ const { logger } = require('@librechat/data-schemas'); const { createContentAggregator } = require('@librechat/agents'); const { + loadSkillStates, initializeAgent, + primeInvokedSkills, validateAgentModel, + extractManualSkills, GenerationJobManager, getCustomEndpointConfig, discoverConnectedAgents, + resolveAgentScopedSkillIds, } = require('@librechat/api'); const { + ResourceType, EModelEndpoint, + PermissionBits, isAgentsEndpoint, getResponseSender, + AgentCapabilities, isEphemeralAgentId, } = require('librechat-data-provider'); const { @@ -19,8 +26,13 @@ const { } = require('~/server/controllers/agents/callbacks'); const { loadAgentTools, loadToolsForExecution } = require('~/server/services/ToolService'); const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions'); +const { + getSkillToolDeps, + enrichWithSkillConfigurable, + buildSkillPrimedIdsByName, +} = require('./skillDeps'); const { getModelsConfig } = require('~/server/controllers/ModelController'); -const { checkPermission } = require('~/server/services/PermissionService'); +const { checkPermission, findAccessibleResources } = require('~/server/services/PermissionService'); const AgentClient = require('~/server/controllers/agents/client'); const { processAddedConvo } = require('./addedConvo'); const { logViolation } = require('~/cache'); @@ -102,6 +114,34 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { const { contentParts, aggregateContent } = createContentAggregator(); const toolEndCallback = createToolEndCallback({ req, res, artifactPromises, streamId }); + /** Query accessible skill IDs once per run (shared across all agents). + * Skills activate under strict opt-in semantics — see + * `resolveAgentScopedSkillIds` for the per-agent activation predicate: + * - Ephemeral agent → per-conversation skills badge toggle (full catalog). + * - Persisted agent → `agent.skills_enabled === true`. Optional + * `agent.skills` allowlist narrows the catalog; empty/undefined + * allowlist with the toggle on = full accessible catalog. */ + const enabledCapabilities = new Set(appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities); + const skillsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.skills); + const codeEnvAvailable = enabledCapabilities.has(AgentCapabilities.execute_code); + const ephemeralSkillsToggle = req.body?.ephemeralAgent?.skills === true; + + const accessibleSkillIds = skillsCapabilityEnabled + ? await findAccessibleResources({ + userId: req.user.id, + role: req.user.role, + resourceType: ResourceType.SKILL, + requiredPermissions: PermissionBits.VIEW, + }) + : []; + + const { skillStates, defaultActiveOnShare } = await loadSkillStates({ + userId: req.user.id, + appConfig, + getUserById: db.getUserById, + accessibleSkillIds, + }); + /** * Agent context store - populated after initialization, accessed by callback via closure. * Maps agentId -> { userMCPAuthMap, agent, tool_resources, toolRegistry, openAIApiKey } @@ -135,14 +175,39 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { }); logger.debug(`[ON_TOOL_EXECUTE] loaded ${result.loadedTools?.length ?? 0} tools`); - return result; + /** Per-agent narrowed flag (admin capability AND agent.tools + * includes execute_code), captured in `agentToolContexts` when + * the agent initialized. Falls back to `false` on any stray + * ctx miss so a skills-only agent never gains sandbox access + * even if capability lookup somehow skips. */ + return enrichWithSkillConfigurable( + result, + req, + ctx.accessibleSkillIds, + ctx.codeEnvAvailable === true, + ctx.skillPrimedIdsByName, + ctx.activeSkillNames, + ); }, toolEndCallback, + ...getSkillToolDeps(), }; const summarizationOptions = appConfig?.summarization?.enabled === false ? { enabled: false } : { enabled: true }; + /** + * Per-request map of per-subagent `createContentAggregator` instances + * keyed by the parent's `tool_call_id`. The handler in `callbacks.js` + * lazily creates an aggregator for each distinct `parentToolCallId` + * and folds every `ON_SUBAGENT_UPDATE` event into it as they stream + * in. `AgentClient` pulls each aggregator's `contentParts` at message + * save time and attaches them to the matching `subagent` tool_call so + * the child's reasoning / tool calls / final text survive a page + * refresh — the client-side Recoil atom is best-effort live-only. + */ + const subagentAggregatorsByToolCallId = new Map(); + const eventHandlers = getDefaultHandlers({ res, toolExecuteOptions, @@ -151,6 +216,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { toolEndCallback, collectedUsage, streamId, + subagentAggregatorsByToolCallId, }); if (!endpointOption.agent) { @@ -187,6 +253,22 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { const conversationId = req.body.conversationId; /** @type {string | undefined} */ const parentMessageId = req.body.parentMessageId; + /** + * Skill names the user invoked via the `$` popover for this turn. Only flows + * to the primary agent — handoff agents are follow-up turns that don't see + * the user's per-submission `$` selections. `extractManualSkills` also + * drops non-string / empty elements so a crafted payload can't reach the + * `getSkillByName` DB query with nonsense values. + * @type {string[] | undefined} + */ + const manualSkills = extractManualSkills(req.body); + + const primaryScopedSkillIds = resolveAgentScopedSkillIds({ + agent: primaryAgent, + accessibleSkillIds, + skillsCapabilityEnabled, + ephemeralSkillsToggle, + }); const primaryConfig = await initializeAgent( { @@ -200,6 +282,11 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { endpointOption, allowedProviders, isInitialAgent: true, + accessibleSkillIds: primaryScopedSkillIds, + codeEnvAvailable, + skillStates, + defaultActiveOnShare, + manualSkills, }, { getFiles: db.getFiles, @@ -212,24 +299,42 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { getToolFilesByIds: db.getToolFilesByIds, getCodeGeneratedFiles: db.getCodeGeneratedFiles, filterFilesByAgentAccess, + listSkillsByAccess: db.listSkillsByAccess, + listAlwaysApplySkills: db.listAlwaysApplySkills, + getSkillByName: db.getSkillByName, }, ); logger.debug( `[initializeClient] Storing tool context for ${primaryConfig.id}: ${primaryConfig.toolDefinitions?.length ?? 0} tools, registry size: ${primaryConfig.toolRegistry?.size ?? '0'}`, ); + /** Maps each primed skill name (manual `$` or always-apply) to the + * `_id` of the exact doc that was primed. Plumbed to + * `enrichWithSkillConfigurable` so the read_file handler can pin + * same-name collision lookups to the resolver's chosen doc AND relax + * the disable-model-invocation gate for skills whose body is already + * in this turn's context. */ + const skillPrimedIdsByName = buildSkillPrimedIdsByName( + primaryConfig.manualSkillPrimes, + primaryConfig.alwaysApplySkillPrimes, + ); agentToolContexts.set(primaryConfig.id, { agent: primaryAgent, toolRegistry: primaryConfig.toolRegistry, userMCPAuthMap: primaryConfig.userMCPAuthMap, tool_resources: primaryConfig.tool_resources, actionsEnabled: primaryConfig.actionsEnabled, + accessibleSkillIds: primaryConfig.accessibleSkillIds, + activeSkillNames: primaryConfig.activeSkillNames, + codeEnvAvailable: primaryConfig.codeEnvAvailable, + skillPrimedIdsByName, }); const { agentConfigs: discoveredConfigs, edges: discoveredEdges, userMCPAuthMap: discoveredMCPAuthMap, + skippedAgentIds: discoveredSkippedIds, } = await discoverConnectedAgents( { req, @@ -243,6 +348,16 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { requestFiles, conversationId, parentMessageId, + computeAccessibleSkillIds: (agent) => + resolveAgentScopedSkillIds({ + agent, + accessibleSkillIds, + skillsCapabilityEnabled, + ephemeralSkillsToggle, + }), + skillStates, + defaultActiveOnShare, + codeEnvAvailable, }, { getAgent: db.getAgent, @@ -259,6 +374,9 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { getToolFilesByIds: db.getToolFilesByIds, getCodeGeneratedFiles: db.getCodeGeneratedFiles, filterFilesByAgentAccess, + listSkillsByAccess: db.listSkillsByAccess, + listAlwaysApplySkills: db.listAlwaysApplySkills, + getSkillByName: db.getSkillByName, }, // The callback fires during BFS, before the helper prunes agents // whose edges end up filtered. Don't populate `agentConfigs` here — @@ -266,6 +384,13 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { // set. The per-agent tool context map is OK to keep populated even // for pruned ids: it's only read by closure in ON_TOOL_EXECUTE, // stale entries are unreachable at runtime. + // + // Handoff agents get the same `skillPrimedIdsByName` plumbing as the + // primary so `read_file` can pin same-name collisions to the exact + // primed doc AND relax the `disable-model-invocation: true` gate for + // skills whose body is already in this turn's context — matters for + // handoff agents that have their own always-apply skills bound or + // that the user `$`-invokes within the handoff flow. onAgentInitialized: (agentId, agent, config) => { agentToolContexts.set(agentId, { agent, @@ -273,6 +398,13 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { userMCPAuthMap: config.userMCPAuthMap, tool_resources: config.tool_resources, actionsEnabled: config.actionsEnabled, + accessibleSkillIds: config.accessibleSkillIds, + activeSkillNames: config.activeSkillNames, + codeEnvAvailable: config.codeEnvAvailable, + skillPrimedIdsByName: buildSkillPrimedIdsByName( + config.manualSkillPrimes, + config.alwaysApplySkillPrimes, + ), }); }, // Pass through the `@librechat/api` exports so that tests which @@ -309,6 +441,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { parentMessageId, allowedProviders, primaryAgentId: primaryConfig.id, + codeEnvAvailable, }); if (updatedMCPAuthMap) { @@ -325,6 +458,9 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { userMCPAuthMap: config.userMCPAuthMap, tool_resources: config.tool_resources, actionsEnabled: config.actionsEnabled, + accessibleSkillIds: config.accessibleSkillIds, + activeSkillNames: config.activeSkillNames, + codeEnvAvailable: config.codeEnvAvailable, }); } @@ -332,6 +468,275 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { // further normalization is needed before handing this to `createRun`. primaryConfig.edges = edges; + // Subagents: load any explicit subagent configs. Subagents run in isolated + // context windows and are invoked via a dedicated spawn tool (not handoff + // edges). An agent that is ONLY referenced as a subagent is dropped from + // `agentConfigs` so the LangGraph pipeline doesn't treat it as a + // parallel/handoff node, but it is KEPT in `agentToolContexts` — the child's + // `ON_TOOL_EXECUTE` dispatches resolve tool execution context (agent, + // tool_resources, skill ACLs, ...) from that map, so removing it would leave + // action tools skipped and resource-scoped tools running without their + // configured resources. + const subagentsCapabilityEnabled = enabledCapabilities.has(AgentCapabilities.subagents); + /** Track skipped ids locally so repeated failures short-circuit within + * the subagent loading loop. Seeded from the discovery helper's skip + * list so agents that already failed handoff loading don't get retried. */ + const skippedAgentIds = new Set(discoveredSkippedIds ?? []); + + /** All agent ids referenced on any edge (source OR target). Used by + * `loadSubagentsFor` to decide whether an agent that's only a subagent + * can be safely dropped from `agentConfigs` — LangGraph doesn't treat + * pure subagents as parallel/handoff nodes. */ + const edgeAgentIds = new Set([primaryConfig.id]); + for (const edge of edges ?? []) { + const sources = Array.isArray(edge.from) ? edge.from : [edge.from]; + const targets = Array.isArray(edge.to) ? edge.to : [edge.to]; + for (const id of sources) { + if (typeof id === 'string') edgeAgentIds.add(id); + } + for (const id of targets) { + if (typeof id === 'string') edgeAgentIds.add(id); + } + } + + /** Lazy per-id agent loader used for subagents that weren't reachable + * via the handoff edge graph (so `discoverConnectedAgents` didn't + * initialize them). Mirrors the helper's internal `processAgent`: + * DB lookup + VIEW check + `initializeAgent`, then inserts into + * `agentConfigs` and `agentToolContexts`. Returns `null` on any + * failure so the caller can skip gracefully. */ + const loadAgentById = async (agentId) => { + if (skippedAgentIds.has(agentId)) return null; + const existing = agentConfigs.get(agentId); + if (existing) return existing; + + try { + const agent = await db.getAgent({ id: agentId }); + if (!agent) { + skippedAgentIds.add(agentId); + return null; + } + const userId = req.user?.id; + if (!userId) { + skippedAgentIds.add(agentId); + return null; + } + const hasAccess = await checkPermission({ + userId, + role: req.user?.role, + resourceType: ResourceType.AGENT, + resourceId: agent._id, + requiredPermission: PermissionBits.VIEW, + }); + if (!hasAccess) { + logger.warn( + `[processAgent] User ${userId} lacks VIEW access to subagent ${agentId}, skipping`, + ); + skippedAgentIds.add(agentId); + return null; + } + const validation = await validateAgentModel({ + req, + res, + agent, + modelsConfig, + logViolation, + }); + if (!validation.isValid) { + logger.warn( + `[processAgent] Subagent ${agentId} failed model validation: ${validation.error?.message}`, + ); + skippedAgentIds.add(agentId); + return null; + } + const config = await initializeAgent( + { + req, + res, + agent, + loadTools, + requestFiles, + conversationId, + parentMessageId, + endpointOption: { ...endpointOption, endpoint: EModelEndpoint.agents }, + allowedProviders, + accessibleSkillIds: resolveAgentScopedSkillIds({ + agent, + accessibleSkillIds, + skillsCapabilityEnabled, + ephemeralSkillsToggle, + }), + /** Match the primary / handoff / addedConvo paths: forward the + * endpoint-level admin flag so `initializeAgent` can compute the + * per-agent narrowing (admin AND agent.tools includes + * execute_code) into `InitializedAgent.codeEnvAvailable`. Without + * this, a code-enabled subagent loaded only through + * `subagentAgentConfigs` initializes with `codeEnvAvailable: + * false`, so `bash_tool` / `read_file` sandbox fallback are + * silently gated off even though the seed walk found it. */ + codeEnvAvailable, + skillStates, + defaultActiveOnShare, + }, + { + getAgent: db.getAgent, + checkPermission, + logViolation, + db: { + getFiles: db.getFiles, + getUserKey: db.getUserKey, + getMessages: db.getMessages, + getConvoFiles: db.getConvoFiles, + updateFilesUsage: db.updateFilesUsage, + getUserKeyValues: db.getUserKeyValues, + getUserCodeFiles: db.getUserCodeFiles, + getToolFilesByIds: db.getToolFilesByIds, + getCodeGeneratedFiles: db.getCodeGeneratedFiles, + filterFilesByAgentAccess, + listSkillsByAccess: db.listSkillsByAccess, + listAlwaysApplySkills: db.listAlwaysApplySkills, + getSkillByName: db.getSkillByName, + }, + }, + ); + agentConfigs.set(agentId, config); + agentToolContexts.set(agentId, { + agent, + toolRegistry: config.toolRegistry, + userMCPAuthMap: config.userMCPAuthMap, + tool_resources: config.tool_resources, + actionsEnabled: config.actionsEnabled, + accessibleSkillIds: config.accessibleSkillIds, + activeSkillNames: config.activeSkillNames, + codeEnvAvailable: config.codeEnvAvailable, + skillPrimedIdsByName: buildSkillPrimedIdsByName( + config.manualSkillPrimes, + config.alwaysApplySkillPrimes, + ), + }); + return config; + } catch (err) { + logger.error(`[processAgent] Error processing subagent ${agentId}:`, err); + skippedAgentIds.add(agentId); + return null; + } + }; + + /** Collected during resolution; applied to `agentConfigs` only after + * every config has had its subagents resolved. Eager pruning would + * hide pure-subagent ids from the subsequent `loadSubagentsFor` + * loop, which would leave *their* `subagentAgentConfigs` empty and + * silently break nested delegation like A → B → C where B is only + * a subagent of A. */ + const pureSubagentIds = new Set(); + + /** + * Loads `subagentAgentConfigs` for a single agent config. Shared + * between the primary agent and handoff-target agents (and pure + * subagents, transitively) so an agent used via handoff or + * nested-subagent that has its own explicit `subagents.agent_ids` + * gets them honored at runtime. Self-spawn works regardless (no DB + * lookup needed). Pruning decisions are deferred to `pureSubagentIds`. + */ + const loadSubagentsFor = async (config) => { + const sub = config.subagents; + if (!subagentsCapabilityEnabled || !sub?.enabled) { + config.subagentAgentConfigs = []; + return; + } + + /** Dedupe and filter in one pass — a crafted payload could + * legitimately include the same ID twice; the backend shouldn't + * create duplicate SubagentConfig entries for the LLM to see as + * separate spawn targets. */ + const explicitSubagentIds = Array.from( + new Set( + Array.isArray(sub.agent_ids) + ? sub.agent_ids.filter((id) => typeof id === 'string' && id && id !== config.id) + : [], + ), + ); + + /** @type {Array} */ + const resolved = []; + for (const subagentId of explicitSubagentIds) { + if (skippedAgentIds.has(subagentId)) continue; + + /** Cycle guard: a configuration like A ↔ B (B lists A as its + * subagent) would otherwise trigger `loadAgentById` on the + * primary — inserting a second config for the same primary id, + * which downstream duplicates in the agent array. Reuse the + * existing primary config when a subagent ref points back at it. */ + if (subagentId === primaryConfig.id) { + resolved.push(primaryConfig); + continue; + } + + const subagentConfig = await loadAgentById(subagentId); + if (!subagentConfig) continue; + + resolved.push(subagentConfig); + + if (!edgeAgentIds.has(subagentId)) { + pureSubagentIds.add(subagentId); + } + } + + config.subagentAgentConfigs = resolved; + }; + + /** BFS across the primary's subagent tree so nested chains like + * A → B → C get resolved before any pruning. Each config is + * visited once. */ + const visitedConfigIds = new Set(); + const pending = [primaryConfig]; + while (pending.length > 0) { + const cfg = pending.shift(); + if (!cfg || visitedConfigIds.has(cfg.id)) continue; + visitedConfigIds.add(cfg.id); + await loadSubagentsFor(cfg); + for (const child of cfg.subagentAgentConfigs ?? []) { + if (child?.id && !visitedConfigIds.has(child.id)) { + pending.push(child); + } + } + } + /** Handoff targets still in the map that weren't visited via the + * primary's subagent tree also need their subagents resolved. */ + for (const [id, cfg] of agentConfigs.entries()) { + if (id === primaryConfig.id || visitedConfigIds.has(id)) continue; + visitedConfigIds.add(id); + await loadSubagentsFor(cfg); + for (const child of cfg.subagentAgentConfigs ?? []) { + if (child?.id && !visitedConfigIds.has(child.id)) { + visitedConfigIds.add(child.id); + await loadSubagentsFor(child); + } + } + } + + /** Drop pure-subagent entries now that every reachable config has + * had its subagents resolved. They stay in `agentToolContexts` so + * their tools still execute with the right scoping. */ + for (const id of pureSubagentIds) { + agentConfigs.delete(id); + } + + primaryConfig.subagents = subagentsCapabilityEnabled ? primaryConfig.subagents : undefined; + + /** If the capability is off at the endpoint level, strip `subagents` on + * every loaded config — not just the primary. `run.ts` calls + * `buildSubagentConfigs` for every agent in the array, so a handoff + * agent with `subagents.enabled: true` persisted on its document would + * otherwise still expose self-spawn at runtime even though the admin + * has disabled the capability globally. */ + if (!subagentsCapabilityEnabled) { + for (const config of agentConfigs.values()) { + config.subagents = undefined; + config.subagentAgentConfigs = undefined; + } + } + let endpointConfig = appConfig.endpoints?.[primaryConfig.endpoint]; if (!isAgentsEndpoint(primaryConfig.endpoint) && !endpointConfig) { try { @@ -356,6 +761,22 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { modelLabel: endpointOption.model_parameters.modelLabel, }); + /** History priming uses the user's full ACL-accessible skill set (not + * per-agent scoped) because prior turns may reference skills no longer + * in any active agent's scope; the ACL check is the security gate. + * `codeEnvAvailable` comes from `primaryConfig` — @see + * `InitializedAgent.codeEnvAvailable` for the per-agent narrowing. */ + const handlePrimeInvokedSkills = skillsCapabilityEnabled + ? (payload) => + primeInvokedSkills({ + req, + payload, + accessibleSkillIds, + codeEnvAvailable: primaryConfig.codeEnvAvailable === true, + ...getSkillToolDeps(), + }) + : undefined; + const client = new AgentClient({ req, res, @@ -366,6 +787,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { collectedUsage, aggregateContent, artifactPromises, + primeInvokedSkills: handlePrimeInvokedSkills, agent: primaryConfig, spec: endpointOption.spec, iconURL: endpointOption.iconURL, @@ -374,6 +796,7 @@ const initializeClient = async ({ req, res, signal, endpointOption }) => { resendFiles: primaryConfig.resendFiles ?? true, maxContextTokens: primaryConfig.maxContextTokens, endpoint: isEphemeralAgentId(primaryConfig.id) ? primaryConfig.endpoint : EModelEndpoint.agents, + subagentAggregatorsByToolCallId, }); if (streamId) { diff --git a/api/server/services/Endpoints/agents/initialize.spec.js b/api/server/services/Endpoints/agents/initialize.spec.js index 802774496588..ca44be812287 100644 --- a/api/server/services/Endpoints/agents/initialize.spec.js +++ b/api/server/services/Endpoints/agents/initialize.spec.js @@ -27,14 +27,22 @@ jest.mock('@librechat/api', () => ({ createSequentialChainEdges: jest.fn(), })); +/** Captured by the `getDefaultHandlers` mock so tests can drive the + * `ON_TOOL_EXECUTE` pipeline with a real subagent id and observe whether + * the tool context (agent, tool_resources, skill ACLs) was preserved. */ +let capturedToolExecuteOptions; jest.mock('~/server/controllers/agents/callbacks', () => ({ createToolEndCallback: jest.fn(() => jest.fn()), - getDefaultHandlers: jest.fn(() => ({})), + getDefaultHandlers: jest.fn((opts) => { + capturedToolExecuteOptions = opts?.toolExecuteOptions; + return {}; + }), })); +const mockLoadToolsForExecution = jest.fn(); jest.mock('~/server/services/ToolService', () => ({ loadAgentTools: jest.fn(), - loadToolsForExecution: jest.fn(), + loadToolsForExecution: (...args) => mockLoadToolsForExecution(...args), })); jest.mock('~/server/controllers/ModelController', () => ({ @@ -199,3 +207,384 @@ describe('initializeClient — processAgent ACL gate', () => { expect(agentClientArgs.agent.edges[0].to).toBe(AUTHORIZED_ID); }); }); + +describe('initializeClient — subagent loading', () => { + const SUBAGENT_ID = 'agent_subagent_1'; + const DUPLICATE_SUBAGENT_ID = 'agent_subagent_dup'; + const HANDOFF_AND_SUB_ID = 'agent_handoff_and_sub'; + + let mongoServer; + let testUser; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + }); + + afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + beforeEach(async () => { + await mongoose.connection.dropDatabase(); + jest.clearAllMocks(); + agentClientArgs = undefined; + capturedToolExecuteOptions = undefined; + mockLoadToolsForExecution.mockReset(); + mockLoadToolsForExecution.mockResolvedValue({ loadedTools: [] }); + + testUser = await User.create({ + email: 'subagent@example.com', + name: 'Subagent User', + username: 'subuser', + role: 'USER', + }); + + mockValidateAgentModel.mockResolvedValue({ isValid: true }); + }); + + /** Grant the test user VIEW on an agent so processAgent loads it. */ + const grantView = async (agentDoc) => { + await AclEntry.create({ + principalType: PrincipalType.USER, + principalId: testUser._id, + principalModel: PrincipalModel.USER, + resourceType: ResourceType.AGENT, + resourceId: agentDoc._id, + permBits: PermissionBits.VIEW, + grantedBy: testUser._id, + }); + }; + + /** Build a request with the `subagents` capability enabled. */ + const makeSubagentReq = () => ({ + user: { id: testUser._id.toString(), role: 'USER' }, + body: { conversationId: 'conv_sub', files: [] }, + config: { + endpoints: { + agents: { + capabilities: ['subagents'], + }, + }, + }, + _resumableStreamId: null, + }); + + const makeEndpointOption = () => ({ + agent: Promise.resolve({ + id: PRIMARY_ID, + name: 'Primary', + provider: 'openai', + model: 'gpt-4', + tools: [], + }), + model_parameters: { model: 'gpt-4' }, + endpoint: 'agents', + }); + + const makePrimaryConfig = ({ edges = [], subagents, agent_ids }) => ({ + id: PRIMARY_ID, + endpoint: 'agents', + edges, + toolDefinitions: [], + toolRegistry: new Map(), + userMCPAuthMap: null, + tool_resources: {}, + resendFiles: true, + maxContextTokens: 4096, + subagents, + agent_ids, + }); + + const makeSubagentConfig = (id) => ({ + id, + endpoint: 'agents', + edges: [], + toolDefinitions: [{ name: 'web', description: 'web', parameters: {} }], + toolRegistry: new Map([['web', { name: 'web' }]]), + userMCPAuthMap: null, + tool_resources: { file_search: { file_ids: ['file_1'] } }, + accessibleSkillIds: ['skill_1'], + actionsEnabled: true, + resendFiles: false, + maxContextTokens: 4096, + }); + + it('loads a configured subagent, populates `subagentAgentConfigs`, and keeps it out of `agentConfigs`', async () => { + const subAgent = await createAgent({ + id: SUBAGENT_ID, + name: 'Explicit Subagent', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(), + tools: ['web'], + }); + await grantView(subAgent); + + const primaryConfig = makePrimaryConfig({ + subagents: { enabled: true, allowSelf: true, agent_ids: [SUBAGENT_ID] }, + }); + const subagentConfig = makeSubagentConfig(SUBAGENT_ID); + + let call = 0; + mockInitializeAgent.mockImplementation(() => + Promise.resolve(++call === 1 ? primaryConfig : subagentConfig), + ); + + await initializeClient({ + req: makeSubagentReq(), + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + + expect(mockInitializeAgent).toHaveBeenCalledTimes(2); + + /** The subagent's AgentConfig is attached to the primary for run.ts to + * turn into `SubagentConfig[]` on the parent's `AgentInputs`. */ + expect(agentClientArgs.agent.subagentAgentConfigs).toHaveLength(1); + expect(agentClientArgs.agent.subagentAgentConfigs[0].id).toBe(SUBAGENT_ID); + + /** Subagent-only agents must NOT appear in `agentConfigs` — otherwise the + * graph would treat them as a parallel/handoff node. */ + expect(agentClientArgs.agentConfigs).toBeDefined(); + expect(agentClientArgs.agentConfigs.has(SUBAGENT_ID)).toBe(false); + }); + + it('preserves subagent tool context for ON_TOOL_EXECUTE (Codex P1 regression guard)', async () => { + /** Verifies the Codex P1 fix: `agentToolContexts.delete(subagentId)` is + * NOT called for subagent-only agents, so when the child dispatches + * `ON_TOOL_EXECUTE` the parent can still resolve its tool context + * (agent, tool_resources, skill ACLs, actionsEnabled) to run tools + * with the right scope. We drive the real `loadTools` closure that + * `initializeClient` wires into `toolExecuteOptions`. */ + const subAgent = await createAgent({ + id: SUBAGENT_ID, + name: 'Explicit Subagent', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(), + tools: ['web'], + }); + await grantView(subAgent); + + const primaryConfig = makePrimaryConfig({ + subagents: { enabled: true, allowSelf: false, agent_ids: [SUBAGENT_ID] }, + }); + const subagentConfig = makeSubagentConfig(SUBAGENT_ID); + + let call = 0; + mockInitializeAgent.mockImplementation(() => + Promise.resolve(++call === 1 ? primaryConfig : subagentConfig), + ); + + await initializeClient({ + req: makeSubagentReq(), + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + + expect(capturedToolExecuteOptions?.loadTools).toBeInstanceOf(Function); + + /** Invoke the real closure with the subagent's id. If `agentToolContexts` + * had been deleted (as in the pre-fix code), this would call + * `loadToolsForExecution` with `agent: undefined` — actions/resource- + * scoped tools would silently drop. */ + await capturedToolExecuteOptions.loadTools(['web'], SUBAGENT_ID); + + expect(mockLoadToolsForExecution).toHaveBeenCalledTimes(1); + const arg = mockLoadToolsForExecution.mock.calls[0][0]; + expect(arg.agent).toBeDefined(); + expect(arg.agent.id).toBe(SUBAGENT_ID); + expect(arg.toolRegistry).toBeInstanceOf(Map); + expect(arg.tool_resources).toEqual({ file_search: { file_ids: ['file_1'] } }); + expect(arg.actionsEnabled).toBe(true); + }); + + it('deduplicates repeated ids in subagents.agent_ids', async () => { + const subAgent = await createAgent({ + id: DUPLICATE_SUBAGENT_ID, + name: 'Dup Subagent', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(), + tools: [], + }); + await grantView(subAgent); + + const primaryConfig = makePrimaryConfig({ + subagents: { + enabled: true, + allowSelf: false, + /** Same id three times — the backend must not load the agent + * repeatedly and must not emit three SubagentConfig entries. */ + agent_ids: [DUPLICATE_SUBAGENT_ID, DUPLICATE_SUBAGENT_ID, DUPLICATE_SUBAGENT_ID], + }, + }); + const subagentConfig = makeSubagentConfig(DUPLICATE_SUBAGENT_ID); + + let initCalls = 0; + mockInitializeAgent.mockImplementation(() => { + initCalls += 1; + return Promise.resolve(initCalls === 1 ? primaryConfig : subagentConfig); + }); + + await initializeClient({ + req: makeSubagentReq(), + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + + /** One call for primary, one for the subagent — not four. */ + expect(mockInitializeAgent).toHaveBeenCalledTimes(2); + expect(agentClientArgs.agent.subagentAgentConfigs).toHaveLength(1); + }); + + it('keeps an agent in `agentConfigs` when it is BOTH a handoff target and a subagent', async () => { + /** Overlap case: the same child is used both via handoff edges (needs to + * be in agentConfigs) and as a subagent (needs to be in + * subagentAgentConfigs, and its tool context preserved). The pipeline + * shouldn't silently drop it from the handoff map. */ + const shared = await createAgent({ + id: HANDOFF_AND_SUB_ID, + name: 'Shared Agent', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(), + tools: [], + }); + await grantView(shared); + + const edges = [{ from: PRIMARY_ID, to: HANDOFF_AND_SUB_ID, edgeType: 'handoff' }]; + const primaryConfig = makePrimaryConfig({ + edges, + subagents: { + enabled: true, + allowSelf: false, + agent_ids: [HANDOFF_AND_SUB_ID], + }, + }); + const sharedConfig = makeSubagentConfig(HANDOFF_AND_SUB_ID); + + let call = 0; + mockInitializeAgent.mockImplementation(() => + Promise.resolve(++call === 1 ? primaryConfig : sharedConfig), + ); + + await initializeClient({ + req: makeSubagentReq(), + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + + expect(agentClientArgs.agent.subagentAgentConfigs).toHaveLength(1); + /** Shared agent must stay in agentConfigs — it's still the handoff target. */ + expect(agentClientArgs.agentConfigs.has(HANDOFF_AND_SUB_ID)).toBe(true); + }); + + it('clears subagents config on primary when the capability is disabled', async () => { + /** Admin can turn subagents off at the endpoint level even if an agent was + * configured for them. The primary's `subagents` field should be + * suppressed so run.ts never builds a SubagentConfig. */ + const primaryConfig = makePrimaryConfig({ + subagents: { enabled: true, allowSelf: true, agent_ids: [] }, + }); + mockInitializeAgent.mockResolvedValue(primaryConfig); + + const req = makeSubagentReq(); + /** Remove the capability from the admin config. */ + req.config.endpoints.agents.capabilities = []; + + await initializeClient({ + req, + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + + expect(agentClientArgs.agent.subagents).toBeUndefined(); + expect(agentClientArgs.agent.subagentAgentConfigs).toEqual([]); + }); + + it('clears subagents on handoff agents too when capability is disabled (Codex P2 regression)', async () => { + /** Codex P2: the capability gate must suppress `subagents` on EVERY + * loaded config, not just the primary. `run.ts` iterates all agents + * and calls `buildSubagentConfigs` per agent, so a handoff target + * with `subagents.enabled: true` persisted on its document would + * otherwise still expose self-spawn even when the admin has disabled + * the capability globally. */ + const authorized = await createAgent({ + id: AUTHORIZED_ID, + name: 'Handoff Agent', + provider: 'openai', + model: 'gpt-4', + author: new mongoose.Types.ObjectId(), + tools: [], + }); + await grantView(authorized); + + const edges = [{ from: PRIMARY_ID, to: AUTHORIZED_ID, edgeType: 'handoff' }]; + const primaryConfig = makePrimaryConfig({ + edges, + subagents: { enabled: true, allowSelf: true, agent_ids: [] }, + }); + const handoffConfig = { + id: AUTHORIZED_ID, + endpoint: 'agents', + edges: [], + toolDefinitions: [], + toolRegistry: new Map(), + userMCPAuthMap: null, + tool_resources: {}, + /** Handoff agent document has subagents enabled of its own accord. */ + subagents: { enabled: true, allowSelf: true, agent_ids: [] }, + }; + + let callCount = 0; + mockInitializeAgent.mockImplementation(() => + Promise.resolve(++callCount === 1 ? primaryConfig : handoffConfig), + ); + + const req = makeSubagentReq(); + /** Capability OFF at endpoint level. */ + req.config.endpoints.agents.capabilities = []; + + await initializeClient({ + req, + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + + /** Primary cleared. */ + expect(agentClientArgs.agent.subagents).toBeUndefined(); + /** Handoff target must ALSO be cleared — otherwise its self-spawn + * would still fire when run.ts iterates agentConfigs. */ + const handoffLoaded = agentClientArgs.agentConfigs.get(AUTHORIZED_ID); + expect(handoffLoaded).toBeDefined(); + expect(handoffLoaded.subagents).toBeUndefined(); + expect(handoffLoaded.subagentAgentConfigs).toBeUndefined(); + }); + + it('skips subagent loading entirely when the feature is disabled on the agent', async () => { + const primaryConfig = makePrimaryConfig({ + subagents: { enabled: false, allowSelf: true, agent_ids: [SUBAGENT_ID] }, + }); + mockInitializeAgent.mockResolvedValue(primaryConfig); + + await initializeClient({ + req: makeSubagentReq(), + res: {}, + signal: new AbortController().signal, + endpointOption: makeEndpointOption(), + }); + + /** Only one initializeAgent call — for the primary. No subagent loaded. */ + expect(mockInitializeAgent).toHaveBeenCalledTimes(1); + expect(agentClientArgs.agent.subagentAgentConfigs).toEqual([]); + }); +}); diff --git a/api/server/services/Endpoints/agents/skillDeps.js b/api/server/services/Endpoints/agents/skillDeps.js new file mode 100644 index 000000000000..ee2c6841da17 --- /dev/null +++ b/api/server/services/Endpoints/agents/skillDeps.js @@ -0,0 +1,92 @@ +const { getStrategyFunctions } = require('~/server/services/Files/strategies'); +const { batchUploadCodeEnvFiles } = require('~/server/services/Files/Code/crud'); +const { + getSessionInfo, + checkIfActive, + readSandboxFile, +} = require('~/server/services/Files/Code/process'); +const { enrichWithSkillConfigurable } = require('@librechat/api'); +const db = require('~/models'); + +/** + * Builds the `skillPrimedIdsByName` map passed through to + * `enrichWithSkillConfigurable`. Centralized here so the four CJS call + * sites (`initialize.js`, `responses.js` x2, `openai.js`) share one + * source of truth — if `ResolvedManualSkill` ever renames `_id` or + * gains new identifying fields, only this helper changes. + * + * Combines both manual (`$`-popover) primes AND always-apply primes so + * `read_file` can: + * - Relax the `disable-model-invocation: true` gate for either source + * (the body is already in context; blocking its own files would be + * nonsensical). + * - Pin same-name collision lookups to the exact `_id` the resolver + * primed (otherwise a newer same-name duplicate could shadow the + * body/file pair within a single turn). + * + * On the rare overlap (a name appears in both arrays because upstream + * dedup was skipped), manual wins — manual invocation is explicit user + * intent and carries the authoritative `_id` for this turn. + * + * Returns `undefined` (not `{}`) when both arrays are empty, so the + * downstream `enrichWithSkillConfigurable` cleanly omits the field from + * `mergedConfigurable` rather than threading an empty object. + * + * @param {Array<{ name: string, _id: { toString(): string } }> | undefined} manualSkillPrimes + * @param {Array<{ name: string, _id: { toString(): string } }> | undefined} alwaysApplySkillPrimes + * @returns {Record | undefined} + */ +function buildSkillPrimedIdsByName(manualSkillPrimes, alwaysApplySkillPrimes) { + const manualCount = manualSkillPrimes?.length ?? 0; + const alwaysApplyCount = alwaysApplySkillPrimes?.length ?? 0; + if (manualCount === 0 && alwaysApplyCount === 0) { + return undefined; + } + const out = {}; + /* Order matters on the edge case where the same name appears in both + lists: always-apply goes in first, then manual overwrites — manual + wins because it's explicit user intent for this turn. */ + if (alwaysApplyCount > 0) { + for (const p of alwaysApplySkillPrimes) { + out[p.name] = p._id.toString(); + } + } + if (manualCount > 0) { + for (const p of manualSkillPrimes) { + out[p.name] = p._id.toString(); + } + } + return out; +} + +/** Skill-related properties for ToolExecuteOptions (stable references, allocated once). */ +const skillToolDeps = { + getSkillByName: db.getSkillByName, + listSkillFiles: db.listSkillFiles, + getStrategyFunctions, + batchUploadCodeEnvFiles, + getSessionInfo, + checkIfActive, + updateSkillFileCodeEnvIds: db.updateSkillFileCodeEnvIds, + getSkillFileByPath: db.getSkillFileByPath, + updateSkillFileContent: db.updateSkillFileContent, + /** + * `read_file` falls back to a sandbox `cat` for `/mnt/data/...` paths + * and for `{firstSegment}/...` paths whose first segment isn't a known + * skill name. The handler routes through this when the agent has code + * execution enabled; the codeapi base URL comes from + * `LIBRECHAT_CODE_BASEURL` and the sandbox session id is forwarded by + * the agents-side `ToolNode` via `tc.codeSessionContext`. + */ + readSandboxFile, +}; + +function getSkillToolDeps() { + return skillToolDeps; +} + +module.exports = { + getSkillToolDeps, + enrichWithSkillConfigurable, + buildSkillPrimedIdsByName, +}; diff --git a/api/server/services/Files/Code/__tests__/process-traversal.spec.js b/api/server/services/Files/Code/__tests__/process-traversal.spec.js index 0b8548445d86..016f3a71c636 100644 --- a/api/server/services/Files/Code/__tests__/process-traversal.spec.js +++ b/api/server/services/Files/Code/__tests__/process-traversal.spec.js @@ -8,7 +8,8 @@ jest.mock('@librechat/agents', () => ({ getCodeBaseURL: jest.fn(() => 'http://localhost:8000'), })); -const mockSanitizeFilename = jest.fn(); +const mockSanitizeArtifactPath = jest.fn(); +const mockFlattenArtifactPath = jest.fn((name) => name.replace(/\//g, '__')); const mockAxios = jest.fn().mockResolvedValue({ data: Buffer.from('file-content'), @@ -21,8 +22,11 @@ jest.mock('@librechat/api', () => { return { logAxiosError: jest.fn(), getBasePath: jest.fn(() => ''), - sanitizeFilename: mockSanitizeFilename, + sanitizeArtifactPath: mockSanitizeArtifactPath, + flattenArtifactPath: mockFlattenArtifactPath, createAxiosInstance: jest.fn(() => mockAxios), + classifyCodeArtifact: jest.fn(() => 'other'), + extractCodeArtifactText: jest.fn(async () => null), codeServerHttpAgent: new http.Agent({ keepAlive: false }), codeServerHttpsAgent: new https.Agent({ keepAlive: false }), }; @@ -90,23 +94,25 @@ describe('processCodeOutput path traversal protection', () => { jest.clearAllMocks(); }); - test('sanitizeFilename is called with the raw artifact name', async () => { - mockSanitizeFilename.mockReturnValueOnce('output.csv'); + test('sanitizeArtifactPath is called with the raw artifact name', async () => { + mockSanitizeArtifactPath.mockReturnValueOnce('output.csv'); await processCodeOutput({ ...baseParams, name: 'output.csv' }); - expect(mockSanitizeFilename).toHaveBeenCalledWith('output.csv'); + expect(mockSanitizeArtifactPath).toHaveBeenCalledWith('output.csv'); }); - test('sanitized name is used in saveBuffer fileName', async () => { - mockSanitizeFilename.mockReturnValueOnce('sanitized-name.txt'); + test('sanitized name is used in saveBuffer fileName (and flattened to a single component)', async () => { + mockSanitizeArtifactPath.mockReturnValueOnce('sanitized-name.txt'); await processCodeOutput({ ...baseParams, name: '../../../tmp/poc.txt' }); - expect(mockSanitizeFilename).toHaveBeenCalledWith('../../../tmp/poc.txt'); + expect(mockSanitizeArtifactPath).toHaveBeenCalledWith('../../../tmp/poc.txt'); const call = mockSaveBuffer.mock.calls[0][0]; + /* `flattenArtifactPath` is identity for already-flat names; the assert + * is against the storage-key composition (`__`). */ expect(call.fileName).toBe('mock-uuid__sanitized-name.txt'); }); test('sanitized name is stored as filename in the file record', async () => { - mockSanitizeFilename.mockReturnValueOnce('safe-output.csv'); + mockSanitizeArtifactPath.mockReturnValueOnce('safe-output.csv'); await processCodeOutput({ ...baseParams, name: 'unsafe/../../output.csv' }); const fileArg = createFile.mock.calls[0][0]; @@ -120,10 +126,10 @@ describe('processCodeOutput path traversal protection', () => { bytes: 100, }); - mockSanitizeFilename.mockReturnValueOnce('safe-chart.png'); + mockSanitizeArtifactPath.mockReturnValueOnce('safe-chart.png'); await processCodeOutput({ ...baseParams, name: '../../../chart.png' }); - expect(mockSanitizeFilename).toHaveBeenCalledWith('../../../chart.png'); + expect(mockSanitizeArtifactPath).toHaveBeenCalledWith('../../../chart.png'); const fileArg = createFile.mock.calls[0][0]; expect(fileArg.filename).toBe('safe-chart.png'); }); diff --git a/api/server/services/Files/Code/crud.js b/api/server/services/Files/Code/crud.js index 945aec787be0..6b4e58d89b94 100644 --- a/api/server/services/Files/Code/crud.js +++ b/api/server/services/Files/Code/crud.js @@ -1,7 +1,9 @@ const FormData = require('form-data'); +const { logger } = require('@librechat/data-schemas'); const { getCodeBaseURL } = require('@librechat/agents'); const { logAxiosError, + appendCodeEnvFile, createAxiosInstance, codeServerHttpAgent, codeServerHttpsAgent, @@ -14,11 +16,10 @@ const MAX_FILE_SIZE = 150 * 1024 * 1024; /** * Retrieves a download stream for a specified file. * @param {string} fileIdentifier - The identifier for the file (e.g., "session_id/fileId"). - * @param {string} apiKey - The API key for authentication. * @returns {Promise} A promise that resolves to a readable stream of the file content. * @throws {Error} If there's an error during the download process. */ -async function getCodeOutputDownloadStream(fileIdentifier, apiKey) { +async function getCodeOutputDownloadStream(fileIdentifier) { try { const baseURL = getCodeBaseURL(); /** @type {import('axios').AxiosRequestConfig} */ @@ -28,7 +29,6 @@ async function getCodeOutputDownloadStream(fileIdentifier, apiKey) { responseType: 'stream', headers: { 'User-Agent': 'LibreChat/1.0', - 'X-API-Key': apiKey, }, httpAgent: codeServerHttpAgent, httpsAgent: codeServerHttpsAgent, @@ -53,18 +53,17 @@ async function getCodeOutputDownloadStream(fileIdentifier, apiKey) { * @param {ServerRequest} params.req - The request object from Express. It should have a `user` property with an `id` representing the user * @param {import('fs').ReadStream | import('stream').Readable} params.stream - The read stream for the file. * @param {string} params.filename - The name of the file. - * @param {string} params.apiKey - The API key for authentication. * @param {string} [params.entity_id] - Optional entity ID for the file. * @returns {Promise} * @throws {Error} If there's an error during the upload process. */ -async function uploadCodeEnvFile({ req, stream, filename, apiKey, entity_id = '' }) { +async function uploadCodeEnvFile({ req, stream, filename, entity_id = '' }) { try { const form = new FormData(); if (entity_id.length > 0) { form.append('entity_id', entity_id); } - form.append('file', stream, filename); + appendCodeEnvFile(form, stream, filename); const baseURL = getCodeBaseURL(); /** @type {import('axios').AxiosRequestConfig} */ @@ -74,7 +73,6 @@ async function uploadCodeEnvFile({ req, stream, filename, apiKey, entity_id = '' 'Content-Type': 'multipart/form-data', 'User-Agent': 'LibreChat/1.0', 'User-Id': req.user.id, - 'X-API-Key': apiKey, }, httpAgent: codeServerHttpAgent, httpsAgent: codeServerHttpsAgent, @@ -107,4 +105,89 @@ async function uploadCodeEnvFile({ req, stream, filename, apiKey, entity_id = '' } } -module.exports = { getCodeOutputDownloadStream, uploadCodeEnvFile }; +/** + * Uploads multiple files to the code execution environment in a single request. + * Uses the /upload/batch endpoint which shares one session_id across all files. + * + * @param {object} params + * @param {import('express').Request & { user: { id: string } }} params.req - The request object. + * @param {Array<{ stream: NodeJS.ReadableStream; filename: string }>} params.files - Files to upload. + * @param {string} [params.entity_id] - Optional entity ID. + * @param {boolean} [params.read_only] - When true, codeapi tags every file in + * the batch as infrastructure (e.g. skill files). The flag is persisted as + * MinIO object metadata (`X-Amz-Meta-Read-Only`) and travels with the file + * through subsequent download/walk passes — sandboxed-code modifications + * are dropped on the floor and the original ref is echoed back as + * `inherited: true`, never as a generated artifact. + * @returns {Promise<{ session_id: string; files: Array<{ fileId: string; filename: string }> }>} + * @throws {Error} If the batch upload fails entirely. + */ +async function batchUploadCodeEnvFiles({ req, files, entity_id = '', read_only = false }) { + try { + const form = new FormData(); + if (entity_id.length > 0) { + form.append('entity_id', entity_id); + } + if (read_only) { + form.append('read_only', 'true'); + } + for (const file of files) { + appendCodeEnvFile(form, file.stream, file.filename); + } + + const baseURL = getCodeBaseURL(); + /** @type {import('axios').AxiosRequestConfig} */ + const options = { + headers: { + ...form.getHeaders(), + 'Content-Type': 'multipart/form-data', + 'User-Agent': 'LibreChat/1.0', + 'User-Id': req.user.id, + }, + httpAgent: codeServerHttpAgent, + httpsAgent: codeServerHttpsAgent, + timeout: 120000, + maxContentLength: MAX_FILE_SIZE, + maxBodyLength: MAX_FILE_SIZE, + }; + + const response = await axios.post(`${baseURL}/upload/batch`, form, options); + + /** @type {{ message: string; session_id: string; files: Array<{ status: string; fileId?: string; filename: string; error?: string }>; succeeded: number; failed: number }} */ + const result = response.data; + if ( + !result || + typeof result !== 'object' || + !result.session_id || + !Array.isArray(result.files) + ) { + throw new Error(`Unexpected batch upload response: ${JSON.stringify(result).slice(0, 200)}`); + } + if (result.message === 'error') { + throw new Error('All files in batch upload failed'); + } + + if (result.failed > 0) { + const failedNames = result.files + .filter((f) => f.status === 'error') + .map((f) => `${f.filename}: ${f.error || 'unknown'}`) + .join(', '); + logger.warn(`[batchUploadCodeEnvFiles] ${result.failed} file(s) failed: ${failedNames}`); + } + + const successFiles = result.files + .filter((f) => f.status === 'success' && f.fileId) + .map((f) => ({ fileId: f.fileId, filename: f.filename })); + + return { session_id: result.session_id, files: successFiles }; + } catch (error) { + throw new Error( + logAxiosError({ + message: `Error in batch upload to code environment: ${error instanceof Error ? error.message : String(error)}`, + error, + }), + ); + } +} + +module.exports = { getCodeOutputDownloadStream, uploadCodeEnvFile, batchUploadCodeEnvFiles }; diff --git a/api/server/services/Files/Code/crud.spec.js b/api/server/services/Files/Code/crud.spec.js index 261f0f052b93..487ae567864c 100644 --- a/api/server/services/Files/Code/crud.spec.js +++ b/api/server/services/Files/Code/crud.spec.js @@ -13,6 +13,9 @@ jest.mock('@librechat/api', () => { const http = require('http'); const https = require('https'); return { + appendCodeEnvFile: jest.fn((form, stream, filename) => { + form.append('file', stream, { filename }); + }), logAxiosError: jest.fn(({ message }) => message), createAxiosInstance: jest.fn(() => mockAxios), codeServerHttpAgent: new http.Agent({ keepAlive: false }), @@ -33,7 +36,7 @@ describe('Code CRUD', () => { const mockResponse = { data: Readable.from(['chunk']) }; mockAxios.mockResolvedValue(mockResponse); - await getCodeOutputDownloadStream('session-1/file-1', 'test-key'); + await getCodeOutputDownloadStream('session-1/file-1'); const callConfig = mockAxios.mock.calls[0][0]; expect(callConfig.httpAgent).toBe(codeServerHttpAgent); @@ -47,19 +50,18 @@ describe('Code CRUD', () => { it('should request stream response from the correct URL', async () => { mockAxios.mockResolvedValue({ data: Readable.from(['chunk']) }); - await getCodeOutputDownloadStream('session-1/file-1', 'test-key'); + await getCodeOutputDownloadStream('session-1/file-1'); const callConfig = mockAxios.mock.calls[0][0]; expect(callConfig.url).toBe('https://code-api.example.com/download/session-1/file-1'); expect(callConfig.responseType).toBe('stream'); expect(callConfig.timeout).toBe(15000); - expect(callConfig.headers['X-API-Key']).toBe('test-key'); }); it('should throw on network error', async () => { mockAxios.mockRejectedValue(new Error('ECONNREFUSED')); - await expect(getCodeOutputDownloadStream('s/f', 'key')).rejects.toThrow(); + await expect(getCodeOutputDownloadStream('s/f')).rejects.toThrow(); }); }); @@ -68,7 +70,6 @@ describe('Code CRUD', () => { req: { user: { id: 'user-123' } }, stream: Readable.from(['file-content']), filename: 'data.csv', - apiKey: 'test-key', }; it('should pass dedicated keepAlive:false agents to axios', async () => { diff --git a/api/server/services/Files/Code/process.js b/api/server/services/Files/Code/process.js index 7cdebeb20285..5ae78a7e9a9d 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -5,10 +5,13 @@ const { getCodeBaseURL } = require('@librechat/agents'); const { getBasePath, logAxiosError, - sanitizeFilename, + sanitizeArtifactPath, + flattenArtifactPath, createAxiosInstance, + classifyCodeArtifact, codeServerHttpAgent, codeServerHttpsAgent, + extractCodeArtifactText, } = require('@librechat/api'); const { Tools, @@ -70,7 +73,6 @@ const createDownloadFallback = ({ * @param {ServerRequest} params.req - The Express request object. * @param {string} params.id - The file ID from the code environment. * @param {string} params.name - The filename. - * @param {string} params.apiKey - The code execution API key. * @param {string} params.toolCallId - The tool call ID that generated the file. * @param {string} params.session_id - The code execution session ID. * @param {string} params.conversationId - The current conversation ID. @@ -81,7 +83,6 @@ const processCodeOutput = async ({ req, id, name, - apiKey, toolCallId, conversationId, messageId, @@ -108,7 +109,6 @@ const processCodeOutput = async ({ responseType: 'arraybuffer', headers: { 'User-Agent': 'LibreChat/1.0', - 'X-API-Key': apiKey, }, httpAgent: codeServerHttpAgent, httpsAgent: codeServerHttpsAgent, @@ -135,14 +135,32 @@ const processCodeOutput = async ({ const fileIdentifier = `${session_id}/${id}`; + /* `safeName` keeps the directory structure (`a/b/file.txt` -> `a/b/file.txt`) + * so the next prime() can place the file at the same nested path in the + * sandbox; flattening would re-create the bug where every nested artifact + * collapsed into the root and read_file calls 404'd. The flat-form + * storage key is composed below once `file_id` is known so we can cap + * the total length at filesystem NAME_MAX. */ + const safeName = sanitizeArtifactPath(name); + if (safeName !== name) { + logger.warn( + `[processCodeOutput] Filename sanitized: "${name}" -> "${safeName}" | conv=${conversationId}`, + ); + } + /** * Atomically claim a file_id for this (filename, conversationId, context) tuple. * Uses $setOnInsert so concurrent calls for the same filename converge on * a single record instead of creating duplicates (TOCTOU race fix). + * + * Claim by `safeName` (not raw `name`) so the claim and the eventual + * `createFile` agree on the filename column — otherwise weird inputs + * (e.g. `"proj name/file@v1.txt"`) would claim under the raw name and + * then write under the sanitized one, leaving the claim row orphaned. */ const newFileId = v4(); const claimed = await claimCodeFile({ - filename: name, + filename: safeName, conversationId, file_id: newFileId, user: req.user.id, @@ -152,16 +170,19 @@ const processCodeOutput = async ({ if (isUpdate) { logger.debug( - `[processCodeOutput] Updating existing file "${name}" (${file_id}) instead of creating duplicate`, + `[processCodeOutput] Updating existing file "${safeName}" (${file_id}) instead of creating duplicate`, ); } - const safeName = sanitizeFilename(name); - if (safeName !== name) { - logger.warn( - `[processCodeOutput] Filename sanitized: "${name}" -> "${safeName}" | conv=${conversationId}`, - ); - } + /** + * Preserve the original `messageId` on update. Each `processCodeOutput` + * call would otherwise overwrite it with the current run's run id, which + * decouples the file from the assistant message that originally created + * it. `getCodeGeneratedFiles` filters by `messageId IN `, so a + * stale id (e.g. from a later regeneration / failed re-read attempt) + * silently excludes the file from priming on subsequent turns. + */ + const persistedMessageId = isUpdate ? (claimed.messageId ?? messageId) : messageId; if (isImage) { const usage = isUpdate ? (claimed.usage ?? 0) + 1 : 1; @@ -171,7 +192,7 @@ const processCodeOutput = async ({ ..._file, filepath, file_id, - messageId, + messageId: persistedMessageId, usage, filename: safeName, conversationId, @@ -217,7 +238,19 @@ const processCodeOutput = async ({ ); } - const fileName = `${file_id}__${safeName}`; + /* Compose the storage key here, after `file_id` is known, so the + * `flattenArtifactPath` cap budget can be calculated against the + * actual prefix length. The full key has to fit in one filesystem + * path component (NAME_MAX = 255 on most filesystems); without this + * cap, deeply-nested artifact paths whose individual segments were + * within bounds can still produce a flat form that overflows once + * `${file_id}__` is prepended, causing `ENAMETOOLONG` inside + * saveBuffer and falling back to a download URL. The 255 figure is + * the conservative cross-platform NAME_MAX (Linux ext4, NTFS, APFS). + */ + const NAME_MAX = 255; + const flatName = flattenArtifactPath(safeName, NAME_MAX - file_id.length - 2); + const fileName = `${file_id}__${flatName}`; const filepath = await saveBuffer({ userId: req.user.id, buffer, @@ -225,10 +258,24 @@ const processCodeOutput = async ({ basePath: 'uploads', }); + /* `classifyCodeArtifact` and `extractCodeArtifactText` make + * extension/bare-name decisions on the input string. With the + * path-preserving sanitizer they can now receive a nested path like + * `reports.v1/Makefile`, which the classifier's `extensionOf` reads + * as `v1/Makefile` (the slice after the dot in the directory name) + * and the bare-name branch rejects because it sees a `.` anywhere in + * the string. Result: extensionless artifacts under dotted folders + * (Makefile, Dockerfile, etc.) get misclassified as `other` and + * skip text extraction. Pass the basename so classification matches + * what it would have gotten with the old flat-name flow. */ + const leafName = path.basename(safeName); + const category = classifyCodeArtifact(leafName, mimeType); + const text = await extractCodeArtifactText(buffer, leafName, mimeType, category); + const file = { file_id, filepath, - messageId, + messageId: persistedMessageId, object: 'file', filename: safeName, type: mimeType, @@ -241,6 +288,11 @@ const processCodeOutput = async ({ context: FileContext.execute_code, usage: isUpdate ? (claimed.usage ?? 0) + 1 : 1, createdAt: isUpdate ? claimed.createdAt : formattedDate, + // Always set `text` explicitly (string or null) so that an update which + // produces a binary or oversized artifact clears any previously cached + // text — `createFile` uses findOneAndUpdate with $set semantics, which + // would otherwise leave a stale value behind. + text: text ?? null, }; await createFile(file, true); @@ -280,20 +332,17 @@ function checkIfActive(dateString) { /** * Retrieves the `lastModified` time string for a specified file from Code Execution Server. * - * @param {Object} params - The parameters object. - * @param {string} params.fileIdentifier - The identifier for the file (e.g., "session_id/fileId"). - * @param {string} params.apiKey - The API key for authentication. + * @param {string} fileIdentifier - The identifier for the file (e.g., "session_id/fileId"). * * @returns {Promise} * A promise that resolves to the `lastModified` time string of the file if successful, or null if there is an * error in initialization or fetching the info. */ -async function getSessionInfo(fileIdentifier, apiKey) { +async function getSessionInfo(fileIdentifier) { try { const baseURL = getCodeBaseURL(); const [path, queryString] = fileIdentifier.split('?'); - const session_id = path.split('/')[0]; - + const [session_id, fileId] = path.split('/'); let queryParams = {}; if (queryString) { queryParams = Object.fromEntries(new URLSearchParams(queryString).entries()); @@ -301,21 +350,17 @@ async function getSessionInfo(fileIdentifier, apiKey) { const response = await axios({ method: 'get', - url: `${baseURL}/files/${session_id}`, - params: { - detail: 'summary', - ...queryParams, - }, + url: `${baseURL}/sessions/${session_id}/objects/${fileId}`, + params: queryParams, headers: { 'User-Agent': 'LibreChat/1.0', - 'X-API-Key': apiKey, }, httpAgent: codeServerHttpAgent, httpsAgent: codeServerHttpsAgent, timeout: 5000, }); - return response.data.find((file) => file.name.startsWith(path))?.lastModified; + return response.data?.lastModified; } catch (error) { logAxiosError({ message: `Error fetching session info: ${error.message}`, @@ -331,13 +376,12 @@ async function getSessionInfo(fileIdentifier, apiKey) { * @param {ServerRequest} options.req * @param {Agent['tool_resources']} options.tool_resources * @param {string} [options.agentId] - The agent ID for file access control - * @param {string} apiKey * @returns {Promise<{ * files: Array<{ id: string; session_id: string; name: string }>, * toolContext: string, * }>} */ -const primeFiles = async (options, apiKey) => { +const primeFiles = async (options) => { const { tool_resources, req, agentId } = options; const file_ids = tool_resources?.[EToolResources.execute_code]?.file_ids ?? []; const agentResourceIds = new Set(file_ids); @@ -375,7 +419,19 @@ const primeFiles = async (options, apiKey) => { const [path, queryString] = file.metadata.fileIdentifier.split('?'); const [session_id, id] = path.split('/'); - const pushFile = () => { + /** + * `pushFile` accepts optional overrides so the reupload path can + * push the FRESH `(session_id, id)` parsed off the new + * `fileIdentifier`. Without these overrides, the closure would + * capture the stale pre-reupload refs from the outer loop and + * the in-memory `files` array (now consumed by + * `buildInitialToolSessions` to seed `Graph.sessions`) would + * point at a sandbox object that no longer exists. The DB record + * gets the new identifier via `updateFile`, but the seed would + * still inject the old one — bash_tool / read_file would 404 + * trying to mount the file until the next turn re-reads metadata. + */ + const pushFile = (overrideSessionId, overrideId) => { if (!toolContext) { toolContext = `- Note: The following files are available in the "${Tools.execute_code}" tool environment:`; } @@ -390,8 +446,8 @@ const primeFiles = async (options, apiKey) => { toolContext += `\n\t- /mnt/data/${file.filename}${fileSuffix}`; files.push({ - id, - session_id, + id: overrideId ?? id, + session_id: overrideSessionId ?? session_id, name: file.filename, }); }; @@ -418,7 +474,6 @@ const primeFiles = async (options, apiKey) => { stream, filename: file.filename, entity_id: queryParams.entity_id, - apiKey, }); // Preserve existing metadata when adding fileIdentifier @@ -431,8 +486,18 @@ const primeFiles = async (options, apiKey) => { file_id: file.file_id, metadata: updatedMetadata, }); - sessions.set(session_id, true); - pushFile(); + /** + * Parse the FRESH fileIdentifier returned by the reupload and + * route it through both the dedupe Map and the in-memory + * `files` list. The original `(session_id, id)` parsed at the + * top of this iteration refer to the old, expired/missing + * sandbox object — using them here would silently re-introduce + * the bug `Graph.sessions` seeding is supposed to fix. + */ + const [newPath] = fileIdentifier.split('?'); + const [newSessionId, newId] = newPath.split('/'); + sessions.set(newSessionId, true); + pushFile(newSessionId, newId); } catch (error) { logger.error( `Error re-uploading file ${id} in session ${session_id}: ${error.message}`, @@ -440,7 +505,7 @@ const primeFiles = async (options, apiKey) => { ); } }; - const uploadTime = await getSessionInfo(file.metadata.fileIdentifier, apiKey); + const uploadTime = await getSessionInfo(file.metadata.fileIdentifier); if (!uploadTime) { logger.warn(`Failed to get upload time for file ${id} in session ${session_id}`); await reuploadFile(); @@ -458,8 +523,81 @@ const primeFiles = async (options, apiKey) => { return { files, toolContext }; }; +/** + * Reads a single file from the code-execution sandbox by shelling `cat` + * through the sandbox `/exec` endpoint. Used by the `read_file` host + * handler when the requested path is a code-env path (`/mnt/data/...`) + * or otherwise not resolvable as a skill file. Resolves to + * `{ content }` from stdout on success, or `null` when the codeapi base + * URL isn't configured / the read returns no content (caller turns that + * into a model-visible error). Throws axios-style errors on transport + * failure so the caller can surface a meaningful error message. + * + * `session_id` and `files` come from the seeded `tc.codeSessionContext` + * (emitted by the agents-side `ToolNode` for `read_file` calls in + * v3.1.72+) so the read lands in the same sandbox session that holds + * the agent's prior-turn artifacts. + * + * @param {Object} params + * @param {string} params.file_path - Absolute path inside the sandbox (e.g. `/mnt/data/foo.txt`). + * @param {string} [params.session_id] - Sandbox session id from the seeded context. + * @param {Array<{id: string, name: string, session_id?: string}>} [params.files] - File refs to mount. + * @returns {Promise<{content: string} | null>} + */ +async function readSandboxFile({ file_path, session_id, files }) { + const baseURL = getCodeBaseURL(); + if (!baseURL) { + return null; + } + + /** Single-quote `file_path` with embedded-quote escaping so a malicious + * filename can't break out of the `cat` command. The handler upstream + * has already established this is a code-env path the model + * legitimately asked to read; this just keeps the shell quoting safe. */ + const safePath = `'${file_path.replace(/'/g, `'\\''`)}'`; + /** @type {Record} */ + const postData = { lang: 'bash', code: `cat ${safePath}` }; + if (session_id) { + postData.session_id = session_id; + } + if (files && files.length > 0) { + postData.files = files; + } + + try { + const response = await axios({ + method: 'post', + url: `${baseURL}/exec`, + data: postData, + headers: { + 'Content-Type': 'application/json', + 'User-Agent': 'LibreChat/1.0', + }, + httpAgent: codeServerHttpAgent, + httpsAgent: codeServerHttpsAgent, + timeout: 15000, + }); + const result = response?.data ?? {}; + if (result.stderr && (result.stdout == null || result.stdout === '')) { + throw new Error(String(result.stderr).trim()); + } + if (result.stdout == null) { + return null; + } + return { content: String(result.stdout) }; + } catch (error) { + logAxiosError({ + message: `Error reading sandbox file "${file_path}"`, + error, + }); + throw error; + } +} + module.exports = { primeFiles, + checkIfActive, getSessionInfo, processCodeOutput, + readSandboxFile, }; diff --git a/api/server/services/Files/Code/process.spec.js b/api/server/services/Files/Code/process.spec.js index a805ee2bcc26..e0d7b08fd344 100644 --- a/api/server/services/Files/Code/process.spec.js +++ b/api/server/services/Files/Code/process.spec.js @@ -41,14 +41,28 @@ const mockAxios = jest.fn(); mockAxios.post = jest.fn(); mockAxios.isAxiosError = jest.fn(() => false); +const mockClassifyCodeArtifact = jest.fn(() => 'other'); +const mockExtractCodeArtifactText = jest.fn(async () => null); jest.mock('@librechat/api', () => { const http = require('http'); const https = require('https'); return { logAxiosError: jest.fn(), getBasePath: jest.fn(() => ''), - sanitizeFilename: jest.fn((name) => name), + sanitizeArtifactPath: jest.fn((name) => name), + flattenArtifactPath: jest.fn((name) => name.replace(/\//g, '__')), createAxiosInstance: jest.fn(() => mockAxios), + /** + * Arrow-function indirection (vs. a direct `jest.fn()` reference) so + * tests can per-case `mockReturnValueOnce` / `mockImplementationOnce` + * on `mockClassifyCodeArtifact` / `mockExtractCodeArtifactText`. + * `jest.mock(...)` is hoisted above the outer `const` declarations + * at parse time, so a direct reference here would capture + * `undefined`; the arrow defers the binding to call time. The + * direct-`jest.fn()` mocks below stay constant per file. + */ + classifyCodeArtifact: (...args) => mockClassifyCodeArtifact(...args), + extractCodeArtifactText: (...args) => mockExtractCodeArtifactText(...args), codeServerHttpAgent: new http.Agent({ keepAlive: false }), codeServerHttpsAgent: new https.Agent({ keepAlive: false }), }; @@ -104,7 +118,7 @@ const { determineFileType } = require('~/server/utils'); const { logger } = require('@librechat/data-schemas'); const { codeServerHttpAgent, codeServerHttpsAgent } = require('@librechat/api'); -const { processCodeOutput, getSessionInfo } = require('./process'); +const { processCodeOutput, getSessionInfo, readSandboxFile, primeFiles } = require('./process'); describe('Code Process', () => { const mockReq = { @@ -261,6 +275,94 @@ describe('Code Process', () => { expect(result.bytes).toBe(100); }); + it('preserves nested directory paths in the DB record while flattening the storage key', async () => { + /* Regression test for the silent-data-loss path: when codeapi reports a + * file with a nested name like "test_folder/test_file.txt", LibreChat + * used to feed it through `sanitizeFilename` (basename-only), which + * persisted "test_file.txt" to the DB and made the file un-locatable on + * the next prime() (cat /mnt/data/test_folder/test_file.txt would + * 404). The fix: keep the path on the DB record (so primeFiles can + * place it back at the same nested location), but flatten it for the + * storage key (saveBuffer strategies key by single component). */ + const smallBuffer = Buffer.alloc(100); + mockAxios.mockResolvedValue({ data: smallBuffer }); + const mockSaveBuffer = jest.fn().mockResolvedValue('/uploads/saved.txt'); + getStrategyFunctions.mockReturnValue({ saveBuffer: mockSaveBuffer }); + + const result = await processCodeOutput({ + ...baseParams, + name: 'test_folder/test_file.txt', + }); + + // Storage key flattens `/` to `__` so on-disk strategies don't + // accidentally create real subdirectories under uploads/. + expect(mockSaveBuffer).toHaveBeenCalledWith( + expect.objectContaining({ + fileName: 'mock-uuid-1234__test_folder__test_file.txt', + }), + ); + // DB row keeps the nested path verbatim — that's what primeFiles + // ships back to the sandbox on the next turn. + expect(result.filename).toBe('test_folder/test_file.txt'); + // Claim is also keyed by the path-preserving name so the + // (filename, conversationId) compound key stays consistent. + expect(mockClaimCodeFile).toHaveBeenCalledWith( + expect.objectContaining({ filename: 'test_folder/test_file.txt' }), + ); + }); + + it('passes a NAME_MAX-aware budget to flattenArtifactPath when composing the storage key', async () => { + /* Codex review P1: per-segment caps on the path-preserving form + * aren't enough — once the segments are joined with `__` for the + * storage key, deeply-nested or moderately long paths can still + * exceed filesystem NAME_MAX (255) and cause `ENAMETOOLONG` in + * saveBuffer. processCodeOutput must pass a file_id-aware budget + * to flattenArtifactPath so the cap holds end-to-end. The unit + * tests in `packages/api/src/utils/files.spec.ts` cover the + * truncation logic itself; this test covers the integration. */ + const smallBuffer = Buffer.alloc(100); + mockAxios.mockResolvedValue({ data: smallBuffer }); + const mockSaveBuffer = jest.fn().mockResolvedValue('/uploads/saved.bin'); + getStrategyFunctions.mockReturnValue({ saveBuffer: mockSaveBuffer }); + + const flattenSpy = require('@librechat/api').flattenArtifactPath; + flattenSpy.mockClear(); + + await processCodeOutput({ ...baseParams, name: 'a/b/c.csv' }); + + // The handler should call flattenArtifactPath with both the + // safeName AND a budget = NAME_MAX (255) minus the prefix + // (`${file_id}__`). file_id mock is `mock-uuid-1234` (14 chars), + // so the budget should be 255 - 14 - 2 = 239. + expect(flattenSpy).toHaveBeenCalledWith(expect.any(String), 239); + }); + + it('passes the basename (not the full nested path) to classifyCodeArtifact and extractCodeArtifactText', async () => { + /* Codex review P2: with the path-preserving sanitizer, `safeName` + * can be a nested string like `reports.v1/Makefile`. The + * classifier reads `extensionOf` against the full string, which + * sees `.v1/Makefile` after the dotted-dir's first dot and + * misclassifies the file as `other` (so text extraction is + * skipped). Pass `path.basename(safeName)` instead. */ + const smallBuffer = Buffer.alloc(100); + mockAxios.mockResolvedValue({ data: smallBuffer }); + const mockSaveBuffer = jest.fn().mockResolvedValue('/uploads/saved.txt'); + getStrategyFunctions.mockReturnValue({ saveBuffer: mockSaveBuffer }); + + await processCodeOutput({ + ...baseParams, + name: 'reports.v1/Makefile', + }); + + expect(mockClassifyCodeArtifact).toHaveBeenCalledWith('Makefile', expect.any(String)); + expect(mockExtractCodeArtifactText).toHaveBeenCalledWith( + expect.any(Buffer), + 'Makefile', + expect.any(String), + expect.any(String), + ); + }); + it('should detect MIME type from buffer', async () => { const smallBuffer = Buffer.alloc(100); mockAxios.mockResolvedValue({ data: smallBuffer }); @@ -283,6 +385,78 @@ describe('Code Process', () => { }); }); + describe('inline text extraction', () => { + it('should populate text on the file when extractor returns content', async () => { + const buffer = Buffer.from('hello world\n', 'utf-8'); + mockAxios.mockResolvedValue({ data: buffer }); + determineFileType.mockResolvedValue({ mime: 'text/plain' }); + mockClassifyCodeArtifact.mockReturnValueOnce('utf8-text'); + mockExtractCodeArtifactText.mockResolvedValueOnce('hello world\n'); + + const result = await processCodeOutput({ ...baseParams, name: 'note.txt' }); + + expect(mockClassifyCodeArtifact).toHaveBeenCalledWith('note.txt', 'text/plain'); + expect(mockExtractCodeArtifactText).toHaveBeenCalledWith( + buffer, + 'note.txt', + 'text/plain', + 'utf8-text', + ); + expect(result.text).toBe('hello world\n'); + expect(createFile).toHaveBeenCalledWith( + expect.objectContaining({ text: 'hello world\n' }), + true, + ); + }); + + it('should set text to null when extractor returns null so updates clear stale values', async () => { + const buffer = Buffer.alloc(100); + mockAxios.mockResolvedValue({ data: buffer }); + determineFileType.mockResolvedValue({ mime: 'application/octet-stream' }); + mockClassifyCodeArtifact.mockReturnValueOnce('other'); + mockExtractCodeArtifactText.mockResolvedValueOnce(null); + + const result = await processCodeOutput({ ...baseParams, name: 'archive.zip' }); + + expect(result.text).toBeNull(); + const createCall = createFile.mock.calls[0][0]; + expect(createCall.text).toBeNull(); + }); + + it('should overwrite a previously-stored text value when re-emitting a now-binary file', async () => { + // Same filename + conversationId already has a stored text value; + // claimCodeFile returns the existing record (isUpdate path). + mockClaimCodeFile.mockResolvedValueOnce({ + file_id: 'existing-id', + filename: 'output.bin', + usage: 1, + createdAt: '2024-01-01T00:00:00.000Z', + }); + const binaryBuffer = Buffer.from([0x00, 0xff, 0x00, 0xff]); + mockAxios.mockResolvedValue({ data: binaryBuffer }); + determineFileType.mockResolvedValue({ mime: 'application/octet-stream' }); + mockClassifyCodeArtifact.mockReturnValueOnce('other'); + mockExtractCodeArtifactText.mockResolvedValueOnce(null); + + await processCodeOutput({ ...baseParams, name: 'output.bin' }); + + // null (not omitted) so $set clears any prior `text` value. + const createCall = createFile.mock.calls[0][0]; + expect(createCall).toHaveProperty('text', null); + }); + + it('should not invoke text extraction for image files', async () => { + const imageBuffer = Buffer.alloc(500); + mockAxios.mockResolvedValue({ data: imageBuffer }); + convertImage.mockResolvedValue({ filepath: '/uploads/x.webp', bytes: 400 }); + + await processCodeOutput({ ...baseParams, name: 'chart.png' }); + + expect(mockClassifyCodeArtifact).not.toHaveBeenCalled(); + expect(mockExtractCodeArtifactText).not.toHaveBeenCalled(); + }); + }); + describe('file size limit enforcement', () => { it('should fallback to download URL when file exceeds size limit', async () => { // Set a small file size limit for this test @@ -416,6 +590,165 @@ describe('Code Process', () => { }); }); + describe('persistedMessageId (regression for cross-turn priming)', () => { + /** + * `getCodeGeneratedFiles` filters by `messageId IN ` + * to scope files to the current branch. If `processCodeOutput` overwrote + * the file's `messageId` with the current run's id on every update, a + * file re-touched by a later turn (e.g. a failed read attempt that + * re-shells the same filename) would lose its link to the assistant + * message that originally produced it. Subsequent turns then can't find + * it via `getCodeGeneratedFiles`, the priming chain has nothing to seed, + * and the model thinks its own prior-turn artifact disappeared. + * + * Contract: + * - On UPDATE (claimCodeFile returned an existing record): the persisted + * `messageId` is `claimed.messageId` (preserved). Falls back to the + * current run's `messageId` when the existing record predates the + * `messageId` field (legacy data). + * - On CREATE (new file): the persisted `messageId` is the current run's. + * - The runtime return value ALWAYS uses the current run's `messageId` + * via `Object.assign(file, { messageId, toolCallId })` so the artifact + * attaches to the correct tool_call in the live response. + */ + + /** + * `processCodeOutput` mutates the file object after `createFile` returns + * (`Object.assign(file, { messageId, toolCallId })`) so the runtime + * caller sees the live messageId on the response. Reading + * `createFile.mock.calls[0][0]` directly would therefore reflect the + * post-mutation state because JS captures by reference. To assert + * what was actually PERSISTED, snapshot the args at call time. + */ + function snapshotCreateFileArgs() { + const snapshots = []; + createFile.mockImplementation(async (file) => { + snapshots.push({ ...file }); + return {}; + }); + return snapshots; + } + + it('preserves the original messageId in the persisted record on UPDATE', async () => { + mockClaimCodeFile.mockResolvedValue({ + file_id: 'existing-id', + filename: 'sentinel.txt', + usage: 1, + createdAt: '2024-01-01T00:00:00.000Z', + messageId: 'turn-1-original-msg', + }); + const persisted = snapshotCreateFileArgs(); + + const smallBuffer = Buffer.alloc(100); + mockAxios.mockResolvedValue({ data: smallBuffer }); + + await processCodeOutput({ + ...baseParams, + name: 'sentinel.txt', + messageId: 'turn-2-current-run-msg', + }); + + expect(persisted[0].messageId).toBe('turn-1-original-msg'); + }); + + it('falls back to current run messageId on UPDATE when claimed.messageId is undefined (legacy record)', async () => { + // Legacy record predates the persistedMessageId tracking. + mockClaimCodeFile.mockResolvedValue({ + file_id: 'legacy-id', + filename: 'legacy.txt', + usage: 1, + createdAt: '2024-01-01T00:00:00.000Z', + // messageId intentionally absent + }); + const persisted = snapshotCreateFileArgs(); + + const smallBuffer = Buffer.alloc(100); + mockAxios.mockResolvedValue({ data: smallBuffer }); + + await processCodeOutput({ + ...baseParams, + name: 'legacy.txt', + messageId: 'turn-N-current-run-msg', + }); + + expect(persisted[0].messageId).toBe('turn-N-current-run-msg'); + }); + + it('uses the current run messageId on CREATE (no claimed record)', async () => { + mockClaimCodeFile.mockResolvedValue({ + file_id: 'mock-uuid-1234', + user: 'user-123', + }); + const persisted = snapshotCreateFileArgs(); + + const smallBuffer = Buffer.alloc(100); + mockAxios.mockResolvedValue({ data: smallBuffer }); + + await processCodeOutput({ + ...baseParams, + messageId: 'turn-1-create-msg', + }); + + expect(persisted[0].messageId).toBe('turn-1-create-msg'); + }); + + it('returns the CURRENT run messageId in the runtime response even on UPDATE (artifact attribution)', async () => { + // The persisted DB record keeps the original messageId, but the + // returned object surfaces the live messageId so the artifact lands + // on the correct tool_call in this run's response. + mockClaimCodeFile.mockResolvedValue({ + file_id: 'existing-id', + filename: 'sentinel.txt', + usage: 1, + createdAt: '2024-01-01T00:00:00.000Z', + messageId: 'turn-1-original-msg', + }); + const persisted = snapshotCreateFileArgs(); + + const smallBuffer = Buffer.alloc(100); + mockAxios.mockResolvedValue({ data: smallBuffer }); + + const result = await processCodeOutput({ + ...baseParams, + name: 'sentinel.txt', + messageId: 'turn-2-current-run-msg', + }); + + // DB preserves original + expect(persisted[0].messageId).toBe('turn-1-original-msg'); + // Runtime return surfaces the live (current) messageId + expect(result.messageId).toBe('turn-2-current-run-msg'); + }); + + it('preserves the original messageId on UPDATE for image files too', async () => { + // Same contract as text files; the image branch builds its own file + // record and would silently regress if the ternary diverged there. + mockClaimCodeFile.mockResolvedValue({ + file_id: 'existing-img', + filename: 'chart.png', + usage: 1, + createdAt: '2024-01-01T00:00:00.000Z', + messageId: 'turn-1-image-msg', + }); + const persisted = snapshotCreateFileArgs(); + + const imageBuffer = Buffer.alloc(500); + mockAxios.mockResolvedValue({ data: imageBuffer }); + convertImage.mockResolvedValue({ + filepath: '/uploads/chart.webp', + bytes: 400, + }); + + await processCodeOutput({ + ...baseParams, + name: 'chart.png', + messageId: 'turn-2-current-img-msg', + }); + + expect(persisted[0].messageId).toBe('turn-1-image-msg'); + }); + }); + describe('socket pool isolation', () => { it('should pass dedicated keepAlive:false agents to axios for processCodeOutput', async () => { const smallBuffer = Buffer.alloc(100); @@ -447,4 +780,323 @@ describe('Code Process', () => { }); }); }); + + describe('readSandboxFile', () => { + /** + * `readSandboxFile` shells `cat ` through the sandbox + * `/exec` endpoint. The `file_path` argument is model-controlled, so + * the single-quote escaping is a security boundary — a regression + * here would let a malicious filename break out of the `cat` + * argument and inject arbitrary shell. Lock the contract in tests. + */ + + /** Pull the bash code that the helper would send to /exec, given + * the file_path that the model supplied. */ + function execCodeFor(file_path) { + mockAxios.mockResolvedValueOnce({ data: { stdout: '', stderr: '' } }); + return readSandboxFile({ file_path }).then(() => { + const postData = mockAxios.mock.calls[0][0].data; + return postData.code; + }); + } + + describe('shell quoting (security boundary)', () => { + it('wraps a plain filename in single quotes', async () => { + const code = await execCodeFor('/mnt/data/sentinel.txt'); + expect(code).toBe(`cat '/mnt/data/sentinel.txt'`); + }); + + it("escapes a literal single-quote in the filename via the standard '\\'' sequence", async () => { + // Adversarial filename: `quote'breakout.txt`. Naive + // single-quoting would terminate the quoted string and + // inject the trailing `breakout.txt'` as shell tokens. + const code = await execCodeFor(`/mnt/data/quote'breakout.txt`); + // Expected escape: end the string, escape a literal quote, + // start a new string. POSIX-portable. + expect(code).toBe(`cat '/mnt/data/quote'\\''breakout.txt'`); + }); + + it('does not interpret command substitution syntax inside the quoted argument', async () => { + // `$(rm -rf /)` would expand if the path were unquoted or + // double-quoted. Inside POSIX single-quotes it stays literal. + const code = await execCodeFor('/mnt/data/$(rm -rf /).txt'); + expect(code).toBe(`cat '/mnt/data/$(rm -rf /).txt'`); + }); + + it('does not expand backtick command substitution inside the quoted argument', async () => { + const code = await execCodeFor('/mnt/data/`whoami`.txt'); + expect(code).toBe(`cat '/mnt/data/\`whoami\`.txt'`); + }); + + it('keeps newlines literal inside the quoted argument', async () => { + const code = await execCodeFor('/mnt/data/line1\nline2.txt'); + expect(code).toBe(`cat '/mnt/data/line1\nline2.txt'`); + }); + + it('keeps spaces and other shell metacharacters literal', async () => { + const code = await execCodeFor('/mnt/data/file ; ls -la /etc/passwd'); + expect(code).toBe(`cat '/mnt/data/file ; ls -la /etc/passwd'`); + }); + + it('handles multiple consecutive single-quotes', async () => { + const code = await execCodeFor(`a''b`); + // Each `'` becomes the 4-char escape sequence. + expect(code).toBe(`cat 'a'\\'''\\''b'`); + }); + }); + + describe('payload shape', () => { + it('POSTs to /exec on the configured codeapi base URL with bash language', async () => { + mockAxios.mockResolvedValueOnce({ data: { stdout: 'ok', stderr: '' } }); + + await readSandboxFile({ file_path: '/mnt/data/x.txt' }); + + const call = mockAxios.mock.calls[0][0]; + expect(call.method).toBe('post'); + expect(call.url).toBe('https://code-api.example.com/exec'); + expect(call.data.lang).toBe('bash'); + }); + + it('omits session_id and files when not provided', async () => { + mockAxios.mockResolvedValueOnce({ data: { stdout: '', stderr: '' } }); + + await readSandboxFile({ file_path: '/mnt/data/x.txt' }); + + const data = mockAxios.mock.calls[0][0].data; + expect(data).not.toHaveProperty('session_id'); + expect(data).not.toHaveProperty('files'); + }); + + it('forwards session_id when provided so the read lands in the seeded sandbox', async () => { + mockAxios.mockResolvedValueOnce({ data: { stdout: '', stderr: '' } }); + + await readSandboxFile({ + file_path: '/mnt/data/x.txt', + session_id: 'sess-XYZ', + }); + + expect(mockAxios.mock.calls[0][0].data.session_id).toBe('sess-XYZ'); + }); + + it('forwards files (non-empty array) so prior-turn artifacts are mounted', async () => { + mockAxios.mockResolvedValueOnce({ data: { stdout: '', stderr: '' } }); + + const files = [{ id: 'f1', name: 'sentinel.txt', session_id: 'sess-XYZ' }]; + await readSandboxFile({ + file_path: '/mnt/data/sentinel.txt', + session_id: 'sess-XYZ', + files, + }); + + expect(mockAxios.mock.calls[0][0].data.files).toEqual(files); + }); + + it('omits files when an empty array is provided (cleaner payload)', async () => { + mockAxios.mockResolvedValueOnce({ data: { stdout: '', stderr: '' } }); + + await readSandboxFile({ + file_path: '/mnt/data/x.txt', + session_id: 'sess-XYZ', + files: [], + }); + + expect(mockAxios.mock.calls[0][0].data).not.toHaveProperty('files'); + }); + + it('uses dedicated keepAlive:false agents (matches processCodeOutput pool isolation)', async () => { + mockAxios.mockResolvedValueOnce({ data: { stdout: '', stderr: '' } }); + + await readSandboxFile({ file_path: '/mnt/data/x.txt' }); + + const call = mockAxios.mock.calls[0][0]; + expect(call.httpAgent).toBe(codeServerHttpAgent); + expect(call.httpsAgent).toBe(codeServerHttpsAgent); + }); + }); + + describe('response handling', () => { + it('returns { content: stdout } on success', async () => { + mockAxios.mockResolvedValueOnce({ + data: { stdout: 'sentinel-XYZ-1234\n', stderr: '' }, + }); + + const result = await readSandboxFile({ file_path: '/mnt/data/sentinel.txt' }); + + expect(result).toEqual({ content: 'sentinel-XYZ-1234\n' }); + }); + + it('returns null when getCodeBaseURL is not configured', async () => { + const { getCodeBaseURL } = require('@librechat/agents'); + getCodeBaseURL.mockReturnValueOnce(''); + + const result = await readSandboxFile({ file_path: '/mnt/data/x.txt' }); + + expect(result).toBeNull(); + expect(mockAxios).not.toHaveBeenCalled(); + }); + + it('returns null when stdout is missing entirely (no content to surface)', async () => { + // stdout absent + no stderr = nothing to report; caller turns this + // into a model-visible "Failed to read" message. + mockAxios.mockResolvedValueOnce({ data: { stderr: '' } }); + + const result = await readSandboxFile({ file_path: '/mnt/data/x.txt' }); + + expect(result).toBeNull(); + }); + + it('throws when the command writes to stderr with no stdout (exposes the error to the caller)', async () => { + mockAxios.mockResolvedValueOnce({ + data: { stdout: '', stderr: 'cat: /mnt/data/missing.txt: No such file or directory\n' }, + }); + + await expect(readSandboxFile({ file_path: '/mnt/data/missing.txt' })).rejects.toThrow( + 'cat: /mnt/data/missing.txt: No such file or directory', + ); + }); + + it('returns stdout even when stderr is also present (stdout wins on partial-success)', async () => { + // Some `cat` builds emit warnings on stderr while still producing + // stdout (e.g. unusual line endings). Surface the content. + mockAxios.mockResolvedValueOnce({ + data: { stdout: 'partial', stderr: 'warning: ...' }, + }); + + const result = await readSandboxFile({ file_path: '/mnt/data/x.txt' }); + + expect(result).toEqual({ content: 'partial' }); + }); + + it('rethrows axios transport errors after logging via logAxiosError', async () => { + const { logAxiosError } = require('@librechat/api'); + const transportError = Object.assign(new Error('connect ECONNREFUSED'), { + code: 'ECONNREFUSED', + }); + mockAxios.mockRejectedValueOnce(transportError); + + await expect(readSandboxFile({ file_path: '/mnt/data/x.txt' })).rejects.toBe( + transportError, + ); + expect(logAxiosError).toHaveBeenCalledWith( + expect.objectContaining({ + message: expect.stringContaining('/mnt/data/x.txt'), + error: transportError, + }), + ); + }); + }); + + describe('timeout', () => { + it('uses the same 15s timeout as processCodeOutput (consistent code-server SLA)', async () => { + mockAxios.mockResolvedValueOnce({ data: { stdout: '', stderr: '' } }); + + await readSandboxFile({ file_path: '/mnt/data/x.txt' }); + + expect(mockAxios.mock.calls[0][0].timeout).toBe(15000); + }); + }); + }); + + describe('primeFiles reupload pushes FRESH sandbox ids (Pass-N review P2)', () => { + /** + * Regression: when a primed code file is missing/expired in the + * sandbox (`getSessionInfo` returns null), `primeFiles` re-uploads + * the file via `handleFileUpload` and persists the new + * `fileIdentifier`. Before the fix, the in-memory `files[]` array + * (now consumed by `buildInitialToolSessions` to seed + * `Graph.sessions`) still received the STALE `(session_id, id)` + * parsed from the original `fileIdentifier` at the top of the + * loop. The DB record was correct but the seed referenced a + * sandbox object that no longer existed — the first tool call + * 404'd trying to mount it until the next turn re-read metadata. + * + * Fix: parse the FRESH `fileIdentifier` returned by upload and + * push those ids into both the dedupe Map and the seed list. + */ + + const { getStrategyFunctions } = require('~/server/services/Files/strategies'); + const { updateFile, getFiles } = require('~/models'); + const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions'); + + /** + * Mock the full strategy pair. `primeFiles` calls + * `getStrategyFunctions(file.source)` for the download stream and + * `getStrategyFunctions(FileSources.execute_code)` for the code-env + * upload — both go through the same factory in production. + */ + function setupReuploadMocks(newFileIdentifier) { + const handleFileUpload = jest.fn().mockResolvedValue(newFileIdentifier); + const getDownloadStream = jest.fn().mockResolvedValue('mock-stream'); + getStrategyFunctions.mockImplementation((source) => { + if (source === 'execute_code') return { handleFileUpload }; + return { getDownloadStream }; + }); + updateFile.mockResolvedValue({}); + filterFilesByAgentAccess.mockImplementation(({ files }) => Promise.resolve(files)); + // getSessionInfo is mocked at module level via mockAxios; return null + // to force the reupload path. Each `getSessionInfo` call hits axios. + mockAxios.mockResolvedValue({ data: null }); + return { handleFileUpload, getDownloadStream }; + } + + it('seed receives FRESH session_id + id parsed off the new fileIdentifier on reupload', async () => { + const dbFile = { + file_id: 'librechat-file-id', + filename: 'sentinel.txt', + filepath: '/uploads/sentinel.txt', + source: 'local', + context: 'execute_code', + metadata: { + /* Stale sandbox ref — this is what `getSessionInfo` will 404 on. */ + fileIdentifier: 'OLD_SESSION/OLD_ID', + }, + }; + getFiles.mockResolvedValue([dbFile]); + + setupReuploadMocks('NEW_SESSION/NEW_ID'); + + const result = await primeFiles({ + req: { user: { id: 'user-123', role: 'USER' } }, + tool_resources: { + execute_code: { file_ids: ['librechat-file-id'], files: [] }, + }, + agentId: 'agent-id', + }); + + // The seed list (consumed by buildInitialToolSessions) MUST carry + // the post-reupload ids — not the stale pre-reupload ones. + expect(result.files).toEqual([ + { id: 'NEW_ID', session_id: 'NEW_SESSION', name: 'sentinel.txt' }, + ]); + }); + + it('persists the new fileIdentifier on the DB record (existing behavior, regression-locked)', async () => { + const dbFile = { + file_id: 'librechat-file-id', + filename: 'sentinel.txt', + filepath: '/uploads/sentinel.txt', + source: 'local', + context: 'execute_code', + metadata: { fileIdentifier: 'OLD_SESSION/OLD_ID' }, + }; + getFiles.mockResolvedValue([dbFile]); + + setupReuploadMocks('NEW_SESSION/NEW_ID'); + + await primeFiles({ + req: { user: { id: 'user-123', role: 'USER' } }, + tool_resources: { + execute_code: { file_ids: ['librechat-file-id'], files: [] }, + }, + agentId: 'agent-id', + }); + + expect(updateFile).toHaveBeenCalledWith( + expect.objectContaining({ + file_id: 'librechat-file-id', + metadata: expect.objectContaining({ fileIdentifier: 'NEW_SESSION/NEW_ID' }), + }), + ); + }); + }); }); diff --git a/api/server/services/Files/process.js b/api/server/services/Files/process.js index f9891483d41f..07c101fc3300 100644 --- a/api/server/services/Files/process.js +++ b/api/server/services/Files/process.js @@ -18,7 +18,6 @@ const { getEndpointFileConfig, documentParserMimeTypes, } = require('librechat-data-provider'); -const { EnvVar } = require('@librechat/agents'); const { logger } = require('@librechat/data-schemas'); const { sanitizeFilename, parseText, processAudioFile } = require('@librechat/api'); const { @@ -503,13 +502,11 @@ const processAgentFileUpload = async ({ req, res, metadata }) => { throw new Error('Code execution is not enabled for Agents'); } const { handleFileUpload: uploadCodeEnvFile } = getStrategyFunctions(FileSources.execute_code); - const result = await loadAuthValues({ userId: req.user.id, authFields: [EnvVar.CODE_API_KEY] }); const stream = fs.createReadStream(file.path); const fileIdentifier = await uploadCodeEnvFile({ req, stream, filename: file.originalname, - apiKey: result[EnvVar.CODE_API_KEY], entity_id, }); fileInfoMetadata = { fileIdentifier }; diff --git a/api/server/services/Files/process.spec.js b/api/server/services/Files/process.spec.js index 39300161a845..88f2bb7b6b7f 100644 --- a/api/server/services/Files/process.spec.js +++ b/api/server/services/Files/process.spec.js @@ -4,9 +4,7 @@ jest.mock('@librechat/data-schemas', () => ({ logger: { warn: jest.fn(), debug: jest.fn(), error: jest.fn() }, })); -jest.mock('@librechat/agents', () => ({ - EnvVar: { CODE_API_KEY: 'CODE_API_KEY' }, -})); +jest.mock('@librechat/agents', () => ({})); jest.mock('@librechat/api', () => ({ sanitizeFilename: jest.fn((n) => n), diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 747856429220..a41eaeb87d0e 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -2,11 +2,11 @@ const { logger } = require('@librechat/data-schemas'); const { tool: toolFn, DynamicStructuredTool } = require('@langchain/core/tools'); const { sleep, - EnvVar, StepTypes, GraphEvents, createToolSearch, Constants: AgentConstants, + createBashExecutionTool, createProgrammaticToolCallingTool, } = require('@librechat/agents'); const { @@ -17,6 +17,7 @@ const { GenerationJobManager, isActionDomainAllowed, buildWebSearchContext, + buildWebSearchDynamicContext, buildImageToolContext, buildToolClassification, buildOAuthToolCallName, @@ -59,7 +60,6 @@ const { primeFiles: primeSearchFiles } = require('~/app/clients/tools/util/fileS const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process'); const { manifestToolMap, toolkits } = require('~/app/clients/tools/manifest'); const { createOnSearchResults } = require('~/server/services/Tools/search'); -const { loadAuthValues } = require('~/server/services/Tools/credentials'); const { reinitMCPServer } = require('~/server/services/Tools/mcp'); const { resolveConfigServers } = require('~/server/services/MCP'); const { recordUsage } = require('~/server/services/Threads'); @@ -490,6 +490,7 @@ async function processRequiredActions(client, requiredActions) { * @returns {Promise<{ * tools?: StructuredTool[]; * toolContextMap?: Record; + * dynamicToolContextMap?: Record; * userMCPAuthMap?: Record>; * toolRegistry?: Map; * hasDeferredTools?: boolean; @@ -714,7 +715,6 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to }, { isBuiltInTool, - loadAuthValues, getOrFetchMCPServerTools, getActionToolDefinitions, }, @@ -769,7 +769,6 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to }, { isBuiltInTool, - loadAuthValues, getOrFetchMCPServerTools, getActionToolDefinitions, }, @@ -782,30 +781,39 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to /** @type {Record} */ const toolContextMap = {}; + /** @type {Record} */ + const dynamicToolContextMap = {}; const hasWebSearch = filteredTools.includes(Tools.web_search); const hasFileSearch = filteredTools.includes(Tools.file_search); const hasExecuteCode = filteredTools.includes(Tools.execute_code); if (hasWebSearch) { toolContextMap[Tools.web_search] = buildWebSearchContext(); + dynamicToolContextMap[Tools.web_search] = buildWebSearchDynamicContext( + req.conversationCreatedAt, + ); } + /** + * `files` carry the upload session_ids; we surface them so client.js can + * seed `Graph.sessions[EXECUTE_CODE]` before run start. Without that seed, + * the agents-side `ToolNode.getCodeSessionContext` returns undefined on + * call #1, `_injected_files` is never set on the tool call, and the + * sandbox can't see the prior turn's generated artifacts on first read. + */ + let primedCodeFiles; if (hasExecuteCode && tool_resources) { try { - const authValues = await loadAuthValues({ - userId: req.user.id, - authFields: [EnvVar.CODE_API_KEY], + const { toolContext, files } = await primeCodeFiles({ + req, + tool_resources, + agentId: agent.id, }); - const codeApiKey = authValues[EnvVar.CODE_API_KEY]; - - if (codeApiKey) { - const { toolContext } = await primeCodeFiles( - { req, tool_resources, agentId: agent.id }, - codeApiKey, - ); - if (toolContext) { - toolContextMap[Tools.execute_code] = toolContext; - } + if (toolContext) { + dynamicToolContextMap[Tools.execute_code] = toolContext; + } + if (files?.length) { + primedCodeFiles = files; } } catch (error) { logger.error('[loadToolDefinitionsWrapper] Error priming code files:', error); @@ -820,7 +828,7 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to agentId: agent.id, }); if (toolContext) { - toolContextMap[Tools.file_search] = toolContext; + dynamicToolContextMap[Tools.file_search] = toolContext; } } catch (error) { logger.error('[loadToolDefinitionsWrapper] Error priming search files:', error); @@ -839,7 +847,7 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to contextDescription: 'image editing', }); if (toolContext) { - toolContextMap.image_edit_oai = toolContext; + dynamicToolContextMap.image_edit_oai = toolContext; } } @@ -850,7 +858,7 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to contextDescription: 'image context', }); if (toolContext) { - toolContextMap.gemini_image_gen = toolContext; + dynamicToolContextMap.gemini_image_gen = toolContext; } } } @@ -859,9 +867,11 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to toolRegistry, userMCPAuthMap, toolContextMap, + dynamicToolContextMap, toolDefinitions, hasDeferredTools, actionsEnabled, + primedCodeFiles, }; } @@ -960,7 +970,7 @@ async function loadAgentTools({ }); } - const { loadedTools, toolContextMap } = await loadTools({ + const { loadedTools, toolContextMap, dynamicToolContextMap, primedCodeFiles } = await loadTools({ agent, signal, userMCPAuthMap, @@ -991,7 +1001,6 @@ async function loadAgentTools({ agentId: agent.id, agentToolOptions: agent.tool_options, deferredToolsEnabled, - loadAuthValues, }); const agentTools = []; @@ -1046,10 +1055,12 @@ async function loadAgentTools({ toolRegistry, userMCPAuthMap, toolContextMap, + dynamicToolContextMap, toolDefinitions, hasDeferredTools, actionsEnabled, tools: agentTools, + primedCodeFiles, }; } @@ -1062,10 +1073,12 @@ async function loadAgentTools({ toolRegistry, userMCPAuthMap, toolContextMap, + dynamicToolContextMap, toolDefinitions, hasDeferredTools, actionsEnabled, tools: agentTools, + primedCodeFiles, }; } @@ -1184,11 +1197,13 @@ async function loadAgentTools({ return { toolRegistry, toolContextMap, + dynamicToolContextMap, userMCPAuthMap, toolDefinitions, hasDeferredTools, actionsEnabled, tools: agentTools, + primedCodeFiles, }; } @@ -1252,26 +1267,33 @@ async function loadToolsForExecution({ if (isPTC && toolRegistry) { configurable.toolRegistry = toolRegistry; try { - const authValues = await loadAuthValues({ - userId: req.user.id, - authFields: [EnvVar.CODE_API_KEY], - }); - const codeApiKey = authValues[EnvVar.CODE_API_KEY]; - - if (codeApiKey) { - const ptcTool = createProgrammaticToolCallingTool({ apiKey: codeApiKey }); - allLoadedTools.push(ptcTool); - } else { - logger.warn('[loadToolsForExecution] PTC requested but CODE_API_KEY not available'); - } + /** + * PTC auth is handled by the agents library / sandbox service + * directly; LibreChat no longer threads a per-run credential. + */ + const ptcTool = createProgrammaticToolCallingTool({}); + allLoadedTools.push(ptcTool); } catch (error) { logger.error('[loadToolsForExecution] Error creating PTC tool:', error); } } + const isBashTool = toolNames.includes(AgentConstants.BASH_TOOL); + if (isBashTool) { + try { + const bashTool = createBashExecutionTool({}); + allLoadedTools.push(bashTool); + } catch (error) { + logger.error('[loadToolsForExecution] Failed to create bash_tool', error); + } + } + const specialToolNames = new Set([ AgentConstants.TOOL_SEARCH, AgentConstants.PROGRAMMATIC_TOOL_CALLING, + AgentConstants.BASH_TOOL, + AgentConstants.SKILL_TOOL, + AgentConstants.READ_FILE, ]); let ptcOrchestratedToolNames = []; diff --git a/api/server/services/Tools/credentials.js b/api/server/services/Tools/credentials.js index b50a2460d418..a289134a5a5b 100644 --- a/api/server/services/Tools/credentials.js +++ b/api/server/services/Tools/credentials.js @@ -1,3 +1,4 @@ +const { AuthType } = require('librechat-data-provider'); const { getUserPluginAuthValue } = require('~/server/services/PluginService'); /** @@ -19,17 +20,18 @@ const loadAuthValues = async ({ userId, authFields, optional, throwError = true */ const findAuthValue = async (fields) => { for (const field of fields) { - let value = process.env[field]; - if (value) { - return { authField: field, authValue: value }; + const envValue = process.env[field]; + if (envValue && envValue.trim() !== '' && envValue !== AuthType.USER_PROVIDED) { + return { authField: field, authValue: envValue }; } + let value; try { value = await getUserPluginAuthValue(userId, field, throwError); } catch (err) { if (optional && optional.has(field)) { return { authField: field, authValue: undefined }; } - if (field === fields[fields.length - 1] && !value) { + if (field === fields[fields.length - 1]) { throw err; } } diff --git a/api/server/services/Tools/credentials.spec.js b/api/server/services/Tools/credentials.spec.js new file mode 100644 index 000000000000..727edbd9f3c4 --- /dev/null +++ b/api/server/services/Tools/credentials.spec.js @@ -0,0 +1,134 @@ +const { AuthType } = require('librechat-data-provider'); + +jest.mock('~/server/services/PluginService', () => ({ + getUserPluginAuthValue: jest.fn(), +})); + +const { getUserPluginAuthValue } = require('~/server/services/PluginService'); +const { loadAuthValues } = require('./credentials'); + +describe('loadAuthValues', () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.resetAllMocks(); + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should return env value when set to a real key', async () => { + process.env.MY_API_KEY = 'real-key-123'; + + const result = await loadAuthValues({ + userId: 'user1', + authFields: ['MY_API_KEY'], + }); + + expect(result).toEqual({ MY_API_KEY: 'real-key-123' }); + }); + + it('should skip user_provided sentinel and try user DB value', async () => { + process.env.GOOGLE_KEY = AuthType.USER_PROVIDED; + getUserPluginAuthValue.mockResolvedValue('user-stored-key'); + + const result = await loadAuthValues({ + userId: 'user1', + authFields: ['GOOGLE_KEY'], + }); + + expect(getUserPluginAuthValue).toHaveBeenCalledWith('user1', 'GOOGLE_KEY', true); + expect(result).toEqual({ GOOGLE_KEY: 'user-stored-key' }); + }); + + it('should skip user_provided and continue to next field in fallback chain', async () => { + process.env.GOOGLE_KEY = AuthType.USER_PROVIDED; + process.env.GOOGLE_SERVICE_KEY_FILE = '/path/to/service-account.json'; + getUserPluginAuthValue.mockRejectedValue(new Error('No auth found')); + + const result = await loadAuthValues({ + userId: 'user1', + authFields: ['GEMINI_API_KEY||GOOGLE_KEY||GOOGLE_SERVICE_KEY_FILE'], + }); + + expect(result).toEqual({ GOOGLE_SERVICE_KEY_FILE: '/path/to/service-account.json' }); + }); + + it('should skip empty and whitespace-only env values', async () => { + process.env.EMPTY_KEY = ''; + process.env.WHITESPACE_KEY = ' '; + process.env.REAL_KEY = 'valid'; + + const result = await loadAuthValues({ + userId: 'user1', + authFields: ['EMPTY_KEY||WHITESPACE_KEY||REAL_KEY'], + }); + + expect(result).toEqual({ REAL_KEY: 'valid' }); + }); + + it('should not return user_provided as an auth value', async () => { + process.env.GOOGLE_KEY = AuthType.USER_PROVIDED; + getUserPluginAuthValue.mockResolvedValue(null); + + const result = await loadAuthValues({ + userId: 'user1', + authFields: ['GOOGLE_KEY'], + throwError: false, + }); + + expect(result).toEqual({}); + }); + + it('should return env value without calling DB when env is valid', async () => { + process.env.MY_KEY = 'valid-key'; + + const result = await loadAuthValues({ + userId: 'user1', + authFields: ['MY_KEY'], + }); + + expect(result).toEqual({ MY_KEY: 'valid-key' }); + expect(getUserPluginAuthValue).not.toHaveBeenCalled(); + }); + + it('should return real env value from first matching field in fallback chain', async () => { + process.env.GEMINI_API_KEY = 'gemini-key'; + process.env.GOOGLE_KEY = 'google-key'; + + const result = await loadAuthValues({ + userId: 'user1', + authFields: ['GEMINI_API_KEY||GOOGLE_KEY'], + }); + + expect(result).toEqual({ GEMINI_API_KEY: 'gemini-key' }); + }); + + it('should return undefined for optional field when sentinel is filtered and DB throws', async () => { + process.env.GOOGLE_KEY = AuthType.USER_PROVIDED; + getUserPluginAuthValue.mockRejectedValue(new Error('No auth found')); + + const optional = new Set(['GOOGLE_KEY']); + const result = await loadAuthValues({ + userId: 'user1', + authFields: ['GOOGLE_KEY'], + optional, + }); + + expect(result).toEqual({ GOOGLE_KEY: undefined }); + }); + + it('should not leak sentinel through catch path when DB lookup throws', async () => { + process.env.GOOGLE_KEY = AuthType.USER_PROVIDED; + getUserPluginAuthValue.mockRejectedValue(new Error('No auth found')); + + await expect( + loadAuthValues({ + userId: 'user1', + authFields: ['GOOGLE_KEY'], + }), + ).rejects.toThrow('No auth found'); + }); +}); diff --git a/api/server/utils/getFileStrategy.js b/api/server/utils/getFileStrategy.js index 2e3dfdd79eb4..1bab7f2468d3 100644 --- a/api/server/utils/getFileStrategy.js +++ b/api/server/utils/getFileStrategy.js @@ -43,6 +43,10 @@ function getFileStrategy(appConfig, { isAvatar = false, isImage = false, context if (isAvatar || context === FileContext.avatar) { selectedStrategy = strategies.avatar || defaultStrategy; + } else if (context === FileContext.skill_file) { + // Skill files: explicit skills strategy → fall back by type → default + selectedStrategy = + strategies.skills || (isImage ? strategies.image : strategies.document) || defaultStrategy; } else if (isImage || context === FileContext.image_generation) { selectedStrategy = strategies.image || defaultStrategy; } else { diff --git a/api/server/utils/import/defaults.js b/api/server/utils/import/defaults.js new file mode 100644 index 000000000000..2212c093591c --- /dev/null +++ b/api/server/utils/import/defaults.js @@ -0,0 +1,67 @@ +const { logger, getTenantId } = require('@librechat/data-schemas'); +const { EModelEndpoint, openAISettings, anthropicSettings } = require('librechat-data-provider'); +const { getModelsConfig } = require('~/server/controllers/ModelController'); + +/** + * Last-resort hardcoded defaults used only when the runtime models config is + * unavailable or returns no models for the endpoint. + */ +const FALLBACK_MODEL_BY_ENDPOINT = { + [EModelEndpoint.openAI]: openAISettings.model.default, + [EModelEndpoint.anthropic]: anthropicSettings.model.default, +}; + +/** + * Picks the first available model for an endpoint from a runtime models config. + * + * @param {string} endpoint - The endpoint key (e.g. EModelEndpoint.anthropic). + * @param {TModelsConfig} [modelsConfig] - Map of endpoint -> available model list. + * @returns {string | undefined} The first model for the endpoint, or undefined. + */ +function pickFirstConfiguredModel(endpoint, modelsConfig) { + const models = modelsConfig?.[endpoint]; + if (!Array.isArray(models)) { + return undefined; + } + for (const model of models) { + if (typeof model === 'string' && model.length > 0) { + return model; + } + } + return undefined; +} + +/** + * Resolves the default model that imported conversations should be saved with + * for a given endpoint. Prefers the first model exposed by the runtime models + * config (admin-configured / provider-discovered), and only falls back to the + * hardcoded per-endpoint default if the runtime config is empty or fails. + * + * @param {object} args + * @param {string} args.endpoint - The endpoint key the import is targeting. + * @param {string} args.requestUserId - The id of the importing user. + * @param {string} [args.userRole] - The role of the importing user. + * @returns {Promise} The default model name to persist on the conversation. + */ +async function resolveImportDefaultModel({ endpoint, requestUserId, userRole }) { + try { + const modelsConfig = await getModelsConfig({ + user: { id: requestUserId, role: userRole, tenantId: getTenantId() }, + }); + const configured = pickFirstConfiguredModel(endpoint, modelsConfig); + if (configured) { + return configured; + } + } catch (error) { + logger.warn( + `[import] Failed to resolve default model from modelsConfig for ${endpoint}: ${error.message}`, + ); + } + return FALLBACK_MODEL_BY_ENDPOINT[endpoint] ?? openAISettings.model.default; +} + +module.exports = { + FALLBACK_MODEL_BY_ENDPOINT, + pickFirstConfiguredModel, + resolveImportDefaultModel, +}; diff --git a/api/server/utils/import/defaults.spec.js b/api/server/utils/import/defaults.spec.js new file mode 100644 index 000000000000..46f233afb5cb --- /dev/null +++ b/api/server/utils/import/defaults.spec.js @@ -0,0 +1,122 @@ +const { EModelEndpoint, openAISettings, anthropicSettings } = require('librechat-data-provider'); + +const mockGetModelsConfig = jest.fn(); + +jest.mock('~/server/controllers/ModelController', () => ({ + getModelsConfig: (...args) => mockGetModelsConfig(...args), +})); + +jest.mock('@librechat/data-schemas', () => { + const actual = jest.requireActual('@librechat/data-schemas'); + return { + ...actual, + getTenantId: () => 'test-tenant', + logger: { warn: jest.fn(), error: jest.fn(), info: jest.fn(), debug: jest.fn() }, + }; +}); + +const { + pickFirstConfiguredModel, + resolveImportDefaultModel, + FALLBACK_MODEL_BY_ENDPOINT, +} = require('./defaults'); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('pickFirstConfiguredModel', () => { + it('returns the first non-empty string for the endpoint', () => { + const modelsConfig = { + [EModelEndpoint.anthropic]: ['claude-opus-4-7', 'claude-3-5-sonnet-latest'], + }; + expect(pickFirstConfiguredModel(EModelEndpoint.anthropic, modelsConfig)).toBe( + 'claude-opus-4-7', + ); + }); + + it('skips empty strings', () => { + const modelsConfig = { + [EModelEndpoint.openAI]: ['', 'gpt-4o'], + }; + expect(pickFirstConfiguredModel(EModelEndpoint.openAI, modelsConfig)).toBe('gpt-4o'); + }); + + it('returns undefined when modelsConfig is missing', () => { + expect(pickFirstConfiguredModel(EModelEndpoint.anthropic, undefined)).toBeUndefined(); + }); + + it('returns undefined when the endpoint has no models', () => { + expect(pickFirstConfiguredModel(EModelEndpoint.anthropic, {})).toBeUndefined(); + expect( + pickFirstConfiguredModel(EModelEndpoint.anthropic, { [EModelEndpoint.anthropic]: [] }), + ).toBeUndefined(); + }); + + it('returns undefined when the endpoint value is not an array', () => { + expect( + pickFirstConfiguredModel(EModelEndpoint.anthropic, { + [EModelEndpoint.anthropic]: 'claude-opus-4-7', + }), + ).toBeUndefined(); + }); +}); + +describe('resolveImportDefaultModel', () => { + it('returns the first model from modelsConfig when present', async () => { + mockGetModelsConfig.mockResolvedValueOnce({ + [EModelEndpoint.anthropic]: ['claude-opus-4-7'], + }); + + const result = await resolveImportDefaultModel({ + endpoint: EModelEndpoint.anthropic, + requestUserId: 'user-1', + userRole: 'USER', + }); + + expect(result).toBe('claude-opus-4-7'); + expect(mockGetModelsConfig).toHaveBeenCalledWith({ + user: { id: 'user-1', role: 'USER', tenantId: 'test-tenant' }, + }); + }); + + it('falls back to the per-endpoint default when modelsConfig has no models for the endpoint', async () => { + mockGetModelsConfig.mockResolvedValueOnce({}); + + const result = await resolveImportDefaultModel({ + endpoint: EModelEndpoint.anthropic, + requestUserId: 'user-1', + }); + + expect(result).toBe(anthropicSettings.model.default); + }); + + it('falls back to the openAI default for unknown endpoints with no modelsConfig entry', async () => { + mockGetModelsConfig.mockResolvedValueOnce({}); + + const result = await resolveImportDefaultModel({ + endpoint: 'some-custom-endpoint', + requestUserId: 'user-1', + }); + + expect(result).toBe(openAISettings.model.default); + }); + + it('falls back to the per-endpoint default when getModelsConfig rejects', async () => { + mockGetModelsConfig.mockRejectedValueOnce(new Error('boom')); + + const result = await resolveImportDefaultModel({ + endpoint: EModelEndpoint.anthropic, + requestUserId: 'user-1', + }); + + expect(result).toBe(anthropicSettings.model.default); + }); + + it('exposes hardcoded fallbacks for openAI and anthropic', () => { + expect(FALLBACK_MODEL_BY_ENDPOINT[EModelEndpoint.openAI]).toBe(openAISettings.model.default); + expect(FALLBACK_MODEL_BY_ENDPOINT[EModelEndpoint.anthropic]).toBe( + anthropicSettings.model.default, + ); + }); +}); diff --git a/api/server/utils/import/importBatchBuilder.js b/api/server/utils/import/importBatchBuilder.js index 29fbfa85a283..be47cd3692b4 100644 --- a/api/server/utils/import/importBatchBuilder.js +++ b/api/server/utils/import/importBatchBuilder.js @@ -2,6 +2,7 @@ const { v4: uuidv4 } = require('uuid'); const { logger } = require('@librechat/data-schemas'); const { EModelEndpoint, Constants, openAISettings } = require('librechat-data-provider'); const { bulkIncrementTagCounts, bulkSaveConvos, bulkSaveMessages } = require('~/models'); +const { FALLBACK_MODEL_BY_ENDPOINT } = require('./defaults'); /** * Factory function for creating an instance of ImportBatchBuilder. @@ -70,9 +71,14 @@ class ImportBatchBuilder { * @param {string} [title='Imported Chat'] - The title of the conversation. Defaults to 'Imported Chat'. * @param {Date} [createdAt] - The creation date of the conversation. * @param {TConversation} [originalConvo] - The original conversation. + * @param {string} [defaultModel] - Resolved default model for this endpoint + * (typically derived from the runtime models config). Used only when + * originalConvo.model is unset. * @returns {{ conversation: TConversation, messages: TMessage[] }} The resulting conversation and messages. */ - finishConversation(title, createdAt, originalConvo = {}) { + finishConversation(title, createdAt, originalConvo = {}, defaultModel) { + const fallbackModel = + defaultModel ?? FALLBACK_MODEL_BY_ENDPOINT[this.endpoint] ?? openAISettings.model.default; const convo = { ...originalConvo, user: this.requestUserId, @@ -82,7 +88,7 @@ class ImportBatchBuilder { updatedAt: createdAt, overrideTimestamp: true, endpoint: this.endpoint, - model: originalConvo.model ?? openAISettings.model.default, + model: originalConvo.model ?? fallbackModel, }; convo._id && delete convo._id; this.conversations.push(convo); diff --git a/api/server/utils/import/importers.js b/api/server/utils/import/importers.js index 7bcca41e0428..b86be3798e02 100644 --- a/api/server/utils/import/importers.js +++ b/api/server/utils/import/importers.js @@ -3,6 +3,7 @@ const { logger, getTenantId } = require('@librechat/data-schemas'); const { EModelEndpoint, Constants, openAISettings } = require('librechat-data-provider'); const { getEndpointsConfig } = require('~/server/services/Config'); const { createImportBatchBuilder } = require('./importBatchBuilder'); +const { resolveImportDefaultModel } = require('./defaults'); const { cloneMessagesWithTimestamps } = require('./fork'); /** @@ -53,11 +54,17 @@ async function importChatBotUiConvo( jsonData, requestUserId, builderFactory = createImportBatchBuilder, + userRole, ) { // this have been tested with chatbot-ui V1 export https://github.com/mckaywrigley/chatbot-ui/tree/b865b0555f53957e96727bc0bbb369c9eaecd83b#legacy-code try { /** @type {ImportBatchBuilder} */ const importBatchBuilder = builderFactory(requestUserId); + const defaultModel = await resolveImportDefaultModel({ + endpoint: EModelEndpoint.openAI, + requestUserId, + userRole, + }); for (const historyItem of jsonData.history) { importBatchBuilder.startConversation(EModelEndpoint.openAI); @@ -68,7 +75,7 @@ async function importChatBotUiConvo( importBatchBuilder.addUserMessage(message.content); } } - importBatchBuilder.finishConversation(historyItem.name, new Date()); + importBatchBuilder.finishConversation(historyItem.name, new Date(), {}, defaultModel); } await importBatchBuilder.saveBatch(); logger.info(`user: ${requestUserId} | ChatbotUI conversation imported`); @@ -115,9 +122,15 @@ async function importClaudeConvo( jsonData, requestUserId, builderFactory = createImportBatchBuilder, + userRole, ) { try { const importBatchBuilder = builderFactory(requestUserId); + const defaultModel = await resolveImportDefaultModel({ + endpoint: EModelEndpoint.anthropic, + requestUserId, + userRole, + }); for (const conv of jsonData) { importBatchBuilder.startConversation(EModelEndpoint.anthropic); @@ -172,7 +185,12 @@ async function importClaudeConvo( } const createdAt = conv.created_at ? new Date(conv.created_at) : new Date(); - importBatchBuilder.finishConversation(conv.name || 'Imported Claude Chat', createdAt); + importBatchBuilder.finishConversation( + conv.name || 'Imported Claude Chat', + createdAt, + {}, + defaultModel, + ); } await importBatchBuilder.saveBatch(); @@ -215,6 +233,12 @@ async function importLibreChatConvo( importBatchBuilder.startConversation(endpoint); + const defaultModel = await resolveImportDefaultModel({ + endpoint, + requestUserId, + userRole, + }); + let firstMessageDate = null; const messagesToImport = jsonData.messagesTree || jsonData.messages; @@ -271,7 +295,12 @@ async function importLibreChatConvo( firstMessageDate = null; } - importBatchBuilder.finishConversation(jsonData.title, firstMessageDate ?? new Date(), options); + importBatchBuilder.finishConversation( + jsonData.title, + firstMessageDate ?? new Date(), + options, + defaultModel, + ); await importBatchBuilder.saveBatch(); logger.debug(`user: ${requestUserId} | Conversation "${jsonData.title}" imported`); } catch (error) { @@ -292,11 +321,17 @@ async function importChatGptConvo( jsonData, requestUserId, builderFactory = createImportBatchBuilder, + userRole, ) { try { const importBatchBuilder = builderFactory(requestUserId); + const defaultModel = await resolveImportDefaultModel({ + endpoint: EModelEndpoint.openAI, + requestUserId, + userRole, + }); for (const conv of jsonData) { - processConversation(conv, importBatchBuilder, requestUserId); + processConversation(conv, importBatchBuilder, requestUserId, defaultModel); } await importBatchBuilder.saveBatch(); } catch (error) { @@ -311,9 +346,10 @@ async function importChatGptConvo( * @param {ChatGPTConvo} conv - A single conversation object that contains multiple messages and other details. * @param {ImportBatchBuilder} importBatchBuilder - The batch builder instance used to manage and batch conversation data. * @param {string} requestUserId - The ID of the user who initiated the import process. + * @param {string} [defaultModel] - Resolved default model for the openAI endpoint. * @returns {void} */ -function processConversation(conv, importBatchBuilder, requestUserId) { +function processConversation(conv, importBatchBuilder, requestUserId, defaultModel) { importBatchBuilder.startConversation(EModelEndpoint.openAI); // Map all message IDs to new UUIDs @@ -437,7 +473,8 @@ function processConversation(conv, importBatchBuilder, requestUserId) { const isCreatedByUser = role === 'user'; let sender = isCreatedByUser ? 'user' : 'assistant'; - const model = mapping.message.metadata.model_slug || openAISettings.model.default; + const model = + mapping.message.metadata.model_slug || defaultModel || openAISettings.model.default; if (!isCreatedByUser) { /** Extracted model name from model slug */ @@ -487,7 +524,12 @@ function processConversation(conv, importBatchBuilder, requestUserId) { importBatchBuilder.saveMessage(message); } - importBatchBuilder.finishConversation(conv.title, new Date(conv.create_time * 1000)); + importBatchBuilder.finishConversation( + conv.title, + new Date(conv.create_time * 1000), + {}, + defaultModel, + ); } /** diff --git a/api/server/utils/import/importers.spec.js b/api/server/utils/import/importers.spec.js index 6e712881fc34..cbd39afb341c 100644 --- a/api/server/utils/import/importers.spec.js +++ b/api/server/utils/import/importers.spec.js @@ -1,6 +1,11 @@ const fs = require('fs'); const path = require('path'); -const { EModelEndpoint, Constants, openAISettings } = require('librechat-data-provider'); +const { + EModelEndpoint, + Constants, + openAISettings, + anthropicSettings, +} = require('librechat-data-provider'); const { getImporter, processAssistantMessage } = require('./importers'); const { ImportBatchBuilder } = require('./importBatchBuilder'); const { bulkSaveMessages, bulkSaveConvos: _bulkSaveConvos } = require('~/models'); @@ -9,10 +14,16 @@ const mockGetEndpointsConfig = jest.fn().mockResolvedValue({ [EModelEndpoint.openAI]: { userProvide: false }, }); +const mockGetModelsConfig = jest.fn().mockResolvedValue({}); + jest.mock('~/server/services/Config', () => ({ getEndpointsConfig: (...args) => mockGetEndpointsConfig(...args), })); +jest.mock('~/server/controllers/ModelController', () => ({ + getModelsConfig: (...args) => mockGetModelsConfig(...args), +})); + // Mock the database methods jest.mock('~/models', () => ({ bulkSaveConvos: jest.fn(), @@ -1013,6 +1024,28 @@ describe('importLibreChatConvo', () => { expect(result.conversation.title).toBe('Imported Chat'); expect(result.conversation.model).toBe(openAISettings.model.default); }); + + it('should default to the anthropic model for anthropic-endpoint conversations', () => { + const requestUserId = 'user-123'; + const builder = new ImportBatchBuilder(requestUserId); + builder.conversationId = 'conv-id-123'; + builder.messages = [{ text: 'Hello, world!' }]; + builder.endpoint = EModelEndpoint.anthropic; + const result = builder.finishConversation(); + expect(result.conversation.endpoint).toBe(EModelEndpoint.anthropic); + expect(result.conversation.model).toBe(anthropicSettings.model.default); + }); + + it('should default to the openAI model for openAI-endpoint conversations', () => { + const requestUserId = 'user-123'; + const builder = new ImportBatchBuilder(requestUserId); + builder.conversationId = 'conv-id-123'; + builder.messages = [{ text: 'Hello, world!' }]; + builder.endpoint = EModelEndpoint.openAI; + const result = builder.finishConversation(); + expect(result.conversation.endpoint).toBe(EModelEndpoint.openAI); + expect(result.conversation.model).toBe(openAISettings.model.default); + }); }); }); @@ -1063,11 +1096,15 @@ describe('importChatBotUiConvo', () => { 1, 'Hello what are you able to do?', expect.any(Date), + {}, + expect.any(String), ); expect(importBatchBuilder.finishConversation).toHaveBeenNthCalledWith( 2, 'Give me the code that inverts ...', expect.any(Date), + {}, + expect.any(String), ); expect(importBatchBuilder.saveBatch).toHaveBeenCalled(); @@ -1347,6 +1384,8 @@ describe('importClaudeConvo', () => { expect(importBatchBuilder.finishConversation).toHaveBeenCalledWith( 'Test Conversation', expect.any(Date), + {}, + expect.any(String), ); const savedMessages = importBatchBuilder.saveMessage.mock.calls.map((call) => call[0]); @@ -1437,6 +1476,136 @@ describe('importClaudeConvo', () => { expect(savedMessages[0]).not.toHaveProperty('model'); }); + it('should set the conversation endpoint and a Claude model so the chat UI loads correctly without a refresh', async () => { + const jsonData = [ + { + uuid: 'conv-123', + name: 'Claude Conversation', + created_at: '2025-01-15T10:00:00.000Z', + chat_messages: [ + { + uuid: 'msg-1', + sender: 'human', + created_at: '2025-01-15T10:00:01.000Z', + content: [{ type: 'text', text: 'Hello' }], + }, + { + uuid: 'msg-2', + sender: 'assistant', + created_at: '2025-01-15T10:00:02.000Z', + content: [{ type: 'text', text: 'Hi there!' }], + }, + ], + }, + ]; + + const requestUserId = 'user-123'; + const importBatchBuilder = new ImportBatchBuilder(requestUserId); + + const importer = getImporter(jsonData); + await importer(jsonData, requestUserId, () => importBatchBuilder); + + expect(importBatchBuilder.conversations).toHaveLength(1); + const convo = importBatchBuilder.conversations[0]; + expect(convo.endpoint).toBe(EModelEndpoint.anthropic); + expect(convo.model).toBe(anthropicSettings.model.default); + expect(convo.model).not.toBe(openAISettings.model.default); + }); + + it('should prefer the first runtime-configured anthropic model over the hardcoded default', async () => { + mockGetModelsConfig.mockResolvedValueOnce({ + [EModelEndpoint.anthropic]: ['claude-opus-4-7', 'claude-3-5-sonnet-latest'], + }); + + const jsonData = [ + { + uuid: 'conv-456', + name: 'Configured Claude Conversation', + created_at: '2025-01-15T10:00:00.000Z', + chat_messages: [ + { + uuid: 'msg-1', + sender: 'human', + created_at: '2025-01-15T10:00:01.000Z', + content: [{ type: 'text', text: 'Hello' }], + }, + ], + }, + ]; + + const requestUserId = 'user-123'; + const importBatchBuilder = new ImportBatchBuilder(requestUserId); + + const importer = getImporter(jsonData); + await importer(jsonData, requestUserId, () => importBatchBuilder); + + const convo = importBatchBuilder.conversations[0]; + expect(convo.endpoint).toBe(EModelEndpoint.anthropic); + expect(convo.model).toBe('claude-opus-4-7'); + }); + + it('should fall back to the anthropic hardcoded default when modelsConfig has no anthropic models', async () => { + mockGetModelsConfig.mockResolvedValueOnce({ + [EModelEndpoint.anthropic]: [], + }); + + const jsonData = [ + { + uuid: 'conv-789', + name: 'Empty modelsConfig Conversation', + created_at: '2025-01-15T10:00:00.000Z', + chat_messages: [ + { + uuid: 'msg-1', + sender: 'human', + created_at: '2025-01-15T10:00:01.000Z', + content: [{ type: 'text', text: 'Hello' }], + }, + ], + }, + ]; + + const requestUserId = 'user-123'; + const importBatchBuilder = new ImportBatchBuilder(requestUserId); + + const importer = getImporter(jsonData); + await importer(jsonData, requestUserId, () => importBatchBuilder); + + const convo = importBatchBuilder.conversations[0]; + expect(convo.endpoint).toBe(EModelEndpoint.anthropic); + expect(convo.model).toBe(anthropicSettings.model.default); + }); + + it('should fall back to the anthropic hardcoded default when getModelsConfig throws', async () => { + mockGetModelsConfig.mockRejectedValueOnce(new Error('boom')); + + const jsonData = [ + { + uuid: 'conv-fail', + name: 'modelsConfig failure', + created_at: '2025-01-15T10:00:00.000Z', + chat_messages: [ + { + uuid: 'msg-1', + sender: 'human', + created_at: '2025-01-15T10:00:01.000Z', + content: [{ type: 'text', text: 'Hello' }], + }, + ], + }, + ]; + + const requestUserId = 'user-123'; + const importBatchBuilder = new ImportBatchBuilder(requestUserId); + + const importer = getImporter(jsonData); + await importer(jsonData, requestUserId, () => importBatchBuilder); + + const convo = importBatchBuilder.conversations[0]; + expect(convo.endpoint).toBe(EModelEndpoint.anthropic); + expect(convo.model).toBe(anthropicSettings.model.default); + }); + it('should correct timestamp inversions (child before parent)', async () => { const jsonData = [ { @@ -1603,6 +1772,8 @@ describe('importClaudeConvo', () => { expect(importBatchBuilder.finishConversation).toHaveBeenCalledWith( 'Imported Claude Chat', expect.any(Date), + {}, + expect.any(String), ); }); }); diff --git a/api/test/jestSetup.js b/api/test/jestSetup.js index d62b8467eda4..f358027caea2 100644 --- a/api/test/jestSetup.js +++ b/api/test/jestSetup.js @@ -1,3 +1,24 @@ +/** + * `undici` (transitive dep of `@librechat/agents` and others) references + * `globalThis.File` from `node:buffer`. Node 20+ exposes it as a global; + * Node 18 / certain WSL toolchains do not, which surfaces as a + * `ReferenceError: File is not defined` at module-load time the first + * time a test imports `@librechat/agents`. Mirror the polyfill in + * `packages/api/jest.setup.cjs` so this Jest suite boots on the same + * Node versions; production code never relies on this — only Jest does. + */ +if (typeof globalThis.File === 'undefined') { + try { + const { File } = require('node:buffer'); + if (File != null) { + globalThis.File = File; + } + } catch { + // Older Node versions without `node:buffer.File`. LibreChat doesn't + // support those anyway; let the test fail loudly. + } +} + // See .env.test.example for an example of the '.env.test' file. require('dotenv').config({ path: './test/.env.test' }); diff --git a/api/utils/tokens.spec.js b/api/utils/tokens.spec.js index 79073ba83c61..b8906118864c 100644 --- a/api/utils/tokens.spec.js +++ b/api/utils/tokens.spec.js @@ -1185,13 +1185,22 @@ describe('Grok Model Tests - Tokens', () => { describe('Claude Model Tests', () => { it('should return correct context length for Claude 4 models', () => { expect(getModelMaxTokens('claude-sonnet-4')).toBe( - maxTokensMap[EModelEndpoint.anthropic]['claude-sonnet-4'], + maxTokensMap[EModelEndpoint.anthropic]['claude-4'], ); expect(getModelMaxTokens('claude-opus-4')).toBe( maxTokensMap[EModelEndpoint.anthropic]['claude-opus-4'], ); }); + it('should return 200K for Claude Sonnet 4.5', () => { + expect(getModelMaxTokens('claude-sonnet-4-5', EModelEndpoint.anthropic)).toBe( + maxTokensMap[EModelEndpoint.anthropic]['claude-sonnet-4-5'], + ); + expect(getModelMaxTokens('claude-sonnet-4-5-20250929')).toBe( + maxTokensMap[EModelEndpoint.anthropic]['claude-sonnet-4-5'], + ); + }); + it('should return correct context length for Claude Haiku 4.5', () => { expect(getModelMaxTokens('claude-haiku-4-5', EModelEndpoint.anthropic)).toBe( maxTokensMap[EModelEndpoint.anthropic]['claude-haiku-4-5'], @@ -1415,6 +1424,9 @@ describe('Claude Model Tests', () => { expect(getModelMaxTokens('claude-sonnet-4-6')).toBe( maxTokensMap[EModelEndpoint.anthropic]['claude-sonnet-4-6'], ); + expect(getModelMaxTokens('claude-sonnet-4-6')).toBeGreaterThan( + getModelMaxTokens('claude-sonnet-4-5'), + ); }); it('should return correct max output tokens for Claude Sonnet 4.6 (64K)', () => { diff --git a/client/package.json b/client/package.json index 9765917a8702..feb07b193767 100644 --- a/client/package.json +++ b/client/package.json @@ -7,7 +7,7 @@ "typecheck": "tsc --noEmit", "data-provider": "cd .. && npm run build:data-provider", "build:file": "cross-env NODE_ENV=production vite build --debug > vite-output.log 2>&1", - "build": "cross-env NODE_ENV=production vite build && node ./scripts/post-build.cjs", + "build": "cross-env NODE_ENV=production NODE_OPTIONS=--max-old-space-size=8192 vite build && node ./scripts/post-build.cjs", "build:ci": "cross-env NODE_ENV=development vite build --mode ci", "dev": "cross-env NODE_ENV=development vite", "preview-prod": "cross-env NODE_ENV=development vite preview", @@ -101,6 +101,7 @@ "react-textarea-autosize": "^8.4.0", "react-transition-group": "^4.4.5", "react-virtualized": "^9.22.6", + "react-vtree": "^3.0.0", "recoil": "^0.7.7", "regenerator-runtime": "^0.14.1", "rehype-highlight": "^6.0.0", diff --git a/client/src/Providers/BadgeRowContext.tsx b/client/src/Providers/BadgeRowContext.tsx index 1bedcec66f6f..025532f0c6ac 100644 --- a/client/src/Providers/BadgeRowContext.tsx +++ b/client/src/Providers/BadgeRowContext.tsx @@ -6,7 +6,6 @@ import { useMCPServerManager, useSearchApiKeyForm, useGetAgentsConfig, - useCodeApiKeyForm, useToolToggle, } from '~/hooks'; import { getTimestampedValue } from '~/utils/timestamps'; @@ -17,11 +16,11 @@ interface BadgeRowContextType { conversationId?: string | null; storageContextKey?: string; agentsConfig?: TAgentsEndpoint | null; + skills: ReturnType; webSearch: ReturnType; artifacts: ReturnType; fileSearch: ReturnType; codeInterpreter: ReturnType; - codeApiKeyForm: ReturnType; searchApiKeyForm: ReturnType; mcpServerManager: ReturnType; } @@ -100,13 +99,15 @@ export default function BadgeRowProvider({ const webSearchToggleKey = `${LocalStorageKeys.LAST_WEB_SEARCH_TOGGLE_}${storageSuffix}`; const fileSearchToggleKey = `${LocalStorageKeys.LAST_FILE_SEARCH_TOGGLE_}${storageSuffix}`; const artifactsToggleKey = `${LocalStorageKeys.LAST_ARTIFACTS_TOGGLE_}${storageSuffix}`; + const skillsToggleKey = `${LocalStorageKeys.LAST_SKILLS_TOGGLE_}${storageSuffix}`; const codeToggleValue = getTimestampedValue(codeToggleKey); const webSearchToggleValue = getTimestampedValue(webSearchToggleKey); const fileSearchToggleValue = getTimestampedValue(fileSearchToggleKey); const artifactsToggleValue = getTimestampedValue(artifactsToggleKey); + const skillsToggleValue = getTimestampedValue(skillsToggleKey); - const initialValues: Record = {}; + const initialValues: Record = {}; if (codeToggleValue !== null) { try { @@ -140,6 +141,14 @@ export default function BadgeRowProvider({ } } + if (skillsToggleValue !== null) { + try { + initialValues[AgentCapabilities.skills] = JSON.parse(skillsToggleValue); + } catch (e) { + console.error('Failed to parse skills toggle value:', e); + } + } + const hasOverrides = Object.keys(initialValues).length > 0; /** Read persisted MCP values from localStorage */ @@ -188,20 +197,14 @@ export default function BadgeRowProvider({ } }, [storageSuffix, specName, isSubmitting, setEphemeralAgent]); - /** CodeInterpreter hooks */ - const codeApiKeyForm = useCodeApiKeyForm({}); - const { setIsDialogOpen: setCodeDialogOpen } = codeApiKeyForm; - + /** CodeInterpreter hook — sandbox auth is handled server-side by the + * agents library, so the toggle no longer has an auth dialog gate. */ const codeInterpreter = useToolToggle({ conversationId, storageContextKey, - setIsDialogOpen: setCodeDialogOpen, toolKey: Tools.execute_code, localStorageKey: LocalStorageKeys.LAST_CODE_TOGGLE_, - authConfig: { - toolId: Tools.execute_code, - queryOptions: { retry: 1 }, - }, + isAuthenticated: true, }); /** WebSearch hooks */ @@ -238,16 +241,25 @@ export default function BadgeRowProvider({ isAuthenticated: true, }); + /** Skills hook - using a custom key since it's not a Tool but a capability */ + const skills = useToolToggle({ + conversationId, + storageContextKey, + toolKey: AgentCapabilities.skills, + localStorageKey: LocalStorageKeys.LAST_SKILLS_TOGGLE_, + isAuthenticated: true, + }); + const mcpServerManager = useMCPServerManager({ conversationId, storageContextKey }); const value: BadgeRowContextType = { + skills, webSearch, artifacts, fileSearch, agentsConfig, conversationId, storageContextKey, - codeApiKeyForm, codeInterpreter, searchApiKeyForm, mcpServerManager, diff --git a/client/src/common/agents-types.ts b/client/src/common/agents-types.ts index c3ea06f89055..7313812ec500 100644 --- a/client/src/common/agents-types.ts +++ b/client/src/common/agents-types.ts @@ -1,6 +1,7 @@ import { AgentCapabilities, ArtifactModes } from 'librechat-data-provider'; import type { AgentModelParameters, + AgentSubagentsConfig, AgentToolOptions, SupportContact, AgentProvider, @@ -38,10 +39,13 @@ export type AgentForm = { tools?: string[]; /** Per-tool configuration options (deferred loading, allowed callers, etc.) */ tool_options?: AgentToolOptions; + skills?: string[]; + skills_enabled?: boolean; provider?: AgentProvider | OptionWithIcon; /** @deprecated Use edges instead */ agent_ids?: string[]; edges?: GraphEdge[]; + subagents?: AgentSubagentsConfig; [AgentCapabilities.artifacts]?: ArtifactModes | string; recursion_limit?: number; support_contact?: SupportContact; diff --git a/client/src/common/types.ts b/client/src/common/types.ts index 6ca408685f77..1db8ba3b3e75 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -349,6 +349,14 @@ export type TOptions = { isResubmission?: boolean; /** Currently only utilized when `isResubmission === true`, uses that message's currently attached files */ overrideFiles?: t.TMessage['files']; + /** + * Carry forward a user message's manually-invoked skills when the caller + * is resubmitting / regenerating that same message — the compose-time + * atom has already been drained on the original submit, so without this + * the second turn would run without any manual priming even though the + * pills are still visible on the user bubble. + */ + overrideManualSkills?: string[]; /** Added conversation for multi-convo feature - sent to server as part of submission payload */ addedConvo?: t.TConversation; }; diff --git a/client/src/components/Chat/Input/BadgeRow.tsx b/client/src/components/Chat/Input/BadgeRow.tsx index 6fea6b0d58bc..7b7140c9fb96 100644 --- a/client/src/components/Chat/Input/BadgeRow.tsx +++ b/client/src/components/Chat/Input/BadgeRow.tsx @@ -21,6 +21,7 @@ import FileSearch from './FileSearch'; import Artifacts from './Artifacts'; import MCPSelect from './MCPSelect'; import WebSearch from './WebSearch'; +import Skills from './Skills'; import store from '~/store'; interface BadgeRowProps { @@ -373,6 +374,7 @@ function BadgeRow({ + diff --git a/client/src/components/Chat/Input/ChatForm.tsx b/client/src/components/Chat/Input/ChatForm.tsx index 87ceccdc7154..a4f4f062f551 100644 --- a/client/src/components/Chat/Input/ChatForm.tsx +++ b/client/src/components/Chat/Input/ChatForm.tsx @@ -26,6 +26,8 @@ import AttachFileChat from './Files/AttachFileChat'; import FileFormChat from './Files/FileFormChat'; import { cn, removeFocusRings } from '~/utils'; import TextareaHeader from './TextareaHeader'; +import PendingManualSkillsChips from './PendingManualSkillsChips'; +import SkillsCommand from './SkillsCommand'; import PromptsCommand from './PromptsCommand'; import AudioRecorder from './AudioRecorder'; import CollapseChat from './CollapseChat'; @@ -254,6 +256,12 @@ const ChatForm = memo(function ChatForm({ textAreaRef={textAreaRef} /> +
+ {/* WIP */} ; overrideType?: string; + /** + * Optional pre-computed label for the chip. Callers in code-execution + * artifact contexts pass the de-suffixed name; upload chips and + * persisted user files leave this undefined and render the raw filename. + */ + displayName?: string; buttonClassName?: string; containerClassName?: string; onDelete?: () => void; onClick?: React.MouseEventHandler; }) => { const fileType = getFileType(overrideType ?? file.type); + const visibleName = displayName ?? file.filename ?? ''; return (
-
- {file.filename} +
+ {visibleName}
{fileType.title} diff --git a/client/src/components/Chat/Input/Files/__tests__/FileContainer.spec.tsx b/client/src/components/Chat/Input/Files/__tests__/FileContainer.spec.tsx new file mode 100644 index 000000000000..3291eb5b8c7f --- /dev/null +++ b/client/src/components/Chat/Input/Files/__tests__/FileContainer.spec.tsx @@ -0,0 +1,56 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import type { TFile } from 'librechat-data-provider'; +import FileContainer from '../FileContainer'; + +jest.mock('~/utils', () => ({ + cn: (...classes: Array) => classes.filter(Boolean).join(' '), + getFileType: () => ({ paths: [], color: '', title: 'Plain' }), +})); + +jest.mock('../FilePreview', () => ({ + __esModule: true, + default: () =>
, +})); + +jest.mock('../RemoveFile', () => ({ + __esModule: true, + default: () => + + ))} +
+ ); +} + +export default memo(PendingManualSkillsChips); diff --git a/client/src/components/Chat/Input/Skills.tsx b/client/src/components/Chat/Input/Skills.tsx new file mode 100644 index 000000000000..ed61263167b6 --- /dev/null +++ b/client/src/components/Chat/Input/Skills.tsx @@ -0,0 +1,40 @@ +import React, { memo } from 'react'; +import { ScrollText } from 'lucide-react'; +import { CheckboxButton } from '@librechat/client'; +import { Permissions, PermissionTypes, defaultAgentCapabilities } from 'librechat-data-provider'; +import { useLocalize, useHasAccess, useAgentCapabilities } from '~/hooks'; +import { useBadgeRowContext } from '~/Providers'; + +function Skills() { + const localize = useLocalize(); + const context = useBadgeRowContext(); + const { toggleState: skillsActive, debouncedChange, isPinned } = context?.skills ?? {}; + + const canUseSkills = useHasAccess({ + permissionType: PermissionTypes.SKILLS, + permission: Permissions.USE, + }); + + const { skillsEnabled } = useAgentCapabilities( + context?.agentsConfig?.capabilities ?? defaultAgentCapabilities, + ); + + if (!canUseSkills || !skillsEnabled) { + return null; + } + + return ( + (skillsActive || isPinned) && ( +
- {showCodeSettings && ( - - )} + )} +
+
+ ); +}); + const ImageAttachment = memo(({ attachment }: { attachment: TAttachment }) => { const [isLoaded, setIsLoaded] = useState(false); const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata; @@ -84,6 +189,40 @@ const ImageAttachment = memo(({ attachment }: { attachment: TAttachment }) => { ); }); +interface PanelArtifactProps { + attachment: TAttachment; + /** Pre-classified type from the routing decision tree, threaded down so + * `fileToArtifact` doesn't re-run `detectArtifactTypeFromFile`. */ + type: ToolArtifactType; +} + +const PanelArtifact = memo(({ attachment, type }: PanelArtifactProps) => { + const localize = useLocalize(); + const placeholder = localize('com_ui_artifact_preview_pending'); + const artifact = useMemo( + () => + fileToArtifact(attachment as TFile & TAttachmentMetadata, { + placeholder, + preClassifiedType: type, + }), + [attachment, type, placeholder], + ); + if (!artifact) { + return null; + } + return ; +}); +PanelArtifact.displayName = 'PanelArtifact'; + +const MermaidArtifact = memo(({ attachment }: { attachment: TAttachment }) => { + const file = attachment as TFile & TAttachmentMetadata; + if (!file.text) { + return null; + } + return ; +}); +MermaidArtifact.displayName = 'MermaidArtifact'; + export default function Attachment({ attachment }: { attachment?: TAttachment }) { if (!attachment) { return null; @@ -91,15 +230,30 @@ export default function Attachment({ attachment }: { attachment?: TAttachment }) if (attachment.type === Tools.web_search) { return null; } + // Sandbox-internal placeholders (`.dirkeep` etc.) are an implementation + // detail of the bash executor's empty-folder preservation; users have + // no reason to see them as their own file chips. + if (isInternalSandboxArtifact(attachment)) { + return null; + } - const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata; - const isImage = attachment.filename - ? imageExtRegex.test(attachment.filename) && width != null && height != null && filepath != null - : false; - - if (isImage) { + if (isImageAttachment(attachment)) { return ; - } else if (!attachment.filepath) { + } + // Single classification call. The result is threaded into + // `PanelArtifact` -> `fileToArtifact` so the panel path doesn't + // re-run `detectArtifactTypeFromFile` a second time. + const artType = artifactTypeForAttachment(attachment); + if (artType === TOOL_ARTIFACT_TYPES.MERMAID) { + return ; + } + if (artType != null) { + return ; + } + if (isTextAttachment(attachment)) { + return ; + } + if (!attachment.filepath) { return null; } return ; @@ -112,38 +266,98 @@ export function AttachmentGroup({ attachments }: { attachments?: TAttachment[] } const fileAttachments: TAttachment[] = []; const imageAttachments: TAttachment[] = []; + const textAttachments: TAttachment[] = []; + const panelArtifacts: Array<{ attachment: TAttachment; type: ToolArtifactType }> = []; + const mermaidArtifacts: TAttachment[] = []; attachments.forEach((attachment) => { - const { width, height, filepath = null } = attachment as TFile & TAttachmentMetadata; - const isImage = attachment.filename - ? imageExtRegex.test(attachment.filename) && - width != null && - height != null && - filepath != null - : false; - - if (isImage) { + if (attachment.type === Tools.web_search) { + return; + } + if (isInternalSandboxArtifact(attachment)) { + return; + } + if (isImageAttachment(attachment)) { imageAttachments.push(attachment); - } else if (attachment.type !== Tools.web_search) { - fileAttachments.push(attachment); + return; } + const artType = artifactTypeForAttachment(attachment); + if (artType === TOOL_ARTIFACT_TYPES.MERMAID) { + mermaidArtifacts.push(attachment); + return; + } + if (artType != null) { + panelArtifacts.push({ attachment, type: artType }); + return; + } + if (isTextAttachment(attachment)) { + textAttachments.push(attachment); + return; + } + fileAttachments.push(attachment); }); + // Sink empty / placeholder-shaped files in each bucket so the user's + // eye lands on the real artifact first. `sort` is stable in modern + // engines (V8 ≥ 7.0) so equal-weight entries keep their input order. + fileAttachments.sort(bySalience); + textAttachments.sort(bySalience); + panelArtifacts.sort(byEntrySalience); + mermaidArtifacts.sort(bySalience); + imageAttachments.sort(bySalience); + return ( <> {fileAttachments.length > 0 && (
{fileAttachments.map((attachment, index) => attachment.filepath ? ( - + ) : null, )}
)} + {panelArtifacts.length > 0 && ( +
+ {panelArtifacts.map(({ attachment, type }, index) => ( + + ))} +
+ )} + {mermaidArtifacts.length > 0 && ( +
+ {mermaidArtifacts.map((attachment, index) => ( + + ))} +
+ )} + {textAttachments.length > 0 && ( +
+ {textAttachments.map((attachment, index) => ( + + ))} +
+ )} {imageAttachments.length > 0 && (
{imageAttachments.map((attachment, index) => ( - + ))}
)} diff --git a/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx b/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx new file mode 100644 index 000000000000..9e4c4fb71a07 --- /dev/null +++ b/client/src/components/Chat/Messages/Content/Parts/BashCall.tsx @@ -0,0 +1,111 @@ +import { useMemo, useRef, useState, useCallback, useEffect } from 'react'; +import copy from 'copy-to-clipboard'; +import type { TAttachment } from 'librechat-data-provider'; +import ProgressText from '~/components/Chat/Messages/Content/ProgressText'; +import CopyButton from '~/components/Messages/Content/CopyButton'; +import LangIcon from '~/components/Messages/Content/LangIcon'; +import useToolCallState from './useToolCallState'; +import useLazyHighlight from './useLazyHighlight'; +import { ERROR_PATTERNS } from './ExecuteCode'; +import { AttachmentGroup } from './Attachment'; +import parseJsonField from './parseJsonField'; +import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; + +export default function BashCall({ + isSubmitting, + initialProgress = 0.1, + args, + output = '', + attachments, +}: { + initialProgress: number; + isSubmitting: boolean; + args?: string | Record; + output?: string; + attachments?: TAttachment[]; +}) { + const localize = useLocalize(); + const command = useMemo(() => parseJsonField(args, 'command'), [args]); + + const { showCode, toggleCode, expandStyle, expandRef, progress, cancelled, hasError, hasOutput } = + useToolCallState(initialProgress, isSubmitting, output, !!command); + + const highlighted = useLazyHighlight(command || undefined, 'bash'); + const outputHasError = useMemo(() => ERROR_PATTERNS.test(output), [output]); + + const [isCopied, setIsCopied] = useState(false); + const timerRef = useRef>(); + useEffect(() => () => clearTimeout(timerRef.current), []); + + const handleCopy = useCallback(() => { + setIsCopied(true); + copy(command, { format: 'text/plain' }); + clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => setIsCopied(false), 3000); + }, [command]); + + return ( + <> +
+ + } + hasInput={!!command || hasOutput} + isExpanded={showCode} + error={cancelled} + /> +
+
+
+
+ {command && ( +
+ +
+                  
+                  {highlighted ?? command}
+                
+
+ )} + {hasOutput && ( +
+
+                  {output}
+                
+
+ )} +
+
+
+ {attachments && attachments.length > 0 && } + + ); +} diff --git a/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx b/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx index 20298f5c0bc5..c3972c131bd3 100644 --- a/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx +++ b/client/src/components/Chat/Messages/Content/Parts/ExecuteCode.tsx @@ -1,124 +1,20 @@ -import React, { useMemo, useState, useEffect, useCallback, useRef } from 'react'; -import { useRecoilValue } from 'recoil'; +import { useMemo } from 'react'; import { SquareTerminal } from 'lucide-react'; import type { TAttachment } from 'librechat-data-provider'; import ProgressText from '~/components/Chat/Messages/Content/ProgressText'; -import { useProgress, useLocalize, useExpandCollapse } from '~/hooks'; +import useLazyHighlight from './useLazyHighlight'; +import useToolCallState from './useToolCallState'; import CodeWindowHeader from './CodeWindowHeader'; import { AttachmentGroup } from './Attachment'; +import { useLocalize } from '~/hooks'; import Stdout from './Stdout'; import { cn } from '~/utils'; -import store from '~/store'; interface ParsedArgs { lang?: string; code?: string; } -interface HastText { - type: 'text'; - value: string; -} - -interface HastElement { - type: 'element'; - tagName: string; - properties?: { className?: string[] }; - children?: HastNode[]; -} - -type HastNode = HastText | HastElement; - -function hastToReact(nodes: HastNode[]): React.ReactNode[] { - return nodes.map((node, i) => { - if (node.type === 'text') { - return node.value; - } - return React.createElement( - node.tagName, - { key: i, className: node.properties?.className?.join(' ') }, - node.children ? hastToReact(node.children) : undefined, - ); - }); -} - -type LowlightModule = typeof import('lowlight'); - -/** Lazy-loaded lowlight singleton — only fetched when syntax highlighting is first needed. */ -let lowlightPromise: Promise | null = null; -let lowlightModule: LowlightModule | null = null; - -function loadLowlight(): Promise { - if (lowlightModule) { - return Promise.resolve(lowlightModule); - } - if (!lowlightPromise) { - lowlightPromise = import('lowlight').then((mod) => { - lowlightModule = mod; - return mod; - }); - } - return lowlightPromise; -} - -function highlightCode(mod: LowlightModule, code: string, lang: string): React.ReactNode[] { - try { - const tree = mod.lowlight.registered(lang) - ? mod.lowlight.highlight(lang, code) - : mod.lowlight.highlightAuto(code); - return hastToReact(tree.children as HastNode[]); - } catch { - return [code]; - } -} - -/** Hook that lazily loads lowlight and returns highlighted nodes once ready. */ -function useLazyHighlight(code: string | undefined, lang: string): React.ReactNode[] | null { - const [highlighted, setHighlighted] = useState(() => { - if (!code || !lowlightModule) { - return null; - } - return highlightCode(lowlightModule, code, lang); - }); - const prevKey = useRef(''); - - useEffect(() => { - const key = `${lang}\0${code ?? ''}`; - if (key === prevKey.current) { - return; - } - prevKey.current = key; - - if (!code) { - setHighlighted(null); - return; - } - - if (lowlightModule) { - setHighlighted(highlightCode(lowlightModule, code, lang)); - return; - } - - let cancelled = false; - loadLowlight() - .then((mod) => { - if (!cancelled) { - setHighlighted(highlightCode(mod, code, lang)); - } - }) - .catch(() => { - if (!cancelled) { - setHighlighted([code]); - } - }); - return () => { - cancelled = true; - }; - }, [code, lang]); - - return highlighted; -} - export function useParseArgs(args?: string | Record): ParsedArgs | null { return useMemo(() => { if (typeof args === 'object' && args !== null) { @@ -152,7 +48,7 @@ export function useParseArgs(args?: string | Record): ParsedArg }, [args]); } -const ERROR_PATTERNS = /^(Traceback|Error:|Exception:|.*Error:)/m; +export const ERROR_PATTERNS = /^(Traceback|Error:|Exception:|.*Error:)/m; export default function ExecuteCode({ isSubmitting, @@ -168,29 +64,14 @@ export default function ExecuteCode({ attachments?: TAttachment[]; }) { const localize = useLocalize(); - const hasOutput = output.length > 0; - const autoExpand = useRecoilValue(store.autoExpandTools); - const { lang = 'py', code } = useParseArgs(args) ?? ({} as ParsedArgs); - const hasContent = !!code || hasOutput; - const [showCode, setShowCode] = useState(() => autoExpand && hasContent); - const { style: expandStyle, ref: expandRef } = useExpandCollapse(showCode); - useEffect(() => { - if (autoExpand && hasContent) { - setShowCode(true); - } - }, [autoExpand, hasContent]); - const progress = useProgress(initialProgress); + const { showCode, toggleCode, expandStyle, expandRef, progress, cancelled, hasError, hasOutput } = + useToolCallState(initialProgress, isSubmitting, output, !!code); const highlighted = useLazyHighlight(code, lang); - const outputHasError = useMemo(() => ERROR_PATTERNS.test(output), [output]); - const toggleCode = useCallback(() => setShowCode((prev) => !prev), [setShowCode]); - - const cancelled = !isSubmitting && progress < 1; - return ( <>
@@ -201,11 +82,12 @@ export default function ExecuteCode({ finishedText={ cancelled ? localize('com_ui_cancelled') : localize('com_ui_analyzing_finished') } + errorSuffix={hasError && !cancelled ? localize('com_ui_tool_failed') : undefined} icon={