diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index 8b3dca83f2..21fba1dc39 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -568,6 +568,7 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to const definitions = []; const allowedDomains = appConfig?.actions?.allowedDomains; + const domainSeparatorRegex = new RegExp(actionDomainSeparator, 'g'); for (const action of actionSets) { const domain = await domainParser(action.metadata.domain, true); @@ -590,7 +591,6 @@ async function loadToolDefinitionsWrapper({ req, res, agent, streamId = null, to const { functionSignatures } = openapiToFunction(validationResult.spec, true); - const domainSeparatorRegex = new RegExp(actionDomainSeparator, 'g'); for (const sig of functionSignatures) { const toolName = `${sig.name}${actionDelimiter}${normalizedDomain}`; if (!actionToolNames.some((name) => name.replace(domainSeparatorRegex, '_') === toolName)) { diff --git a/packages/api/src/app/permissions.spec.ts b/packages/api/src/app/permissions.spec.ts index a787c0c3a3..86d0c83095 100644 --- a/packages/api/src/app/permissions.spec.ts +++ b/packages/api/src/app/permissions.spec.ts @@ -962,9 +962,7 @@ describe('updateInterfacePermissions - permissions', () => { const expectedPermissionsForUser = { [PermissionTypes.PROMPTS]: { [Permissions.USE]: true, - [Permissions.CREATE]: true, - [Permissions.SHARE]: false, - [Permissions.SHARE_PUBLIC]: false, + // CREATE/SHARE/SHARE_PUBLIC not included since prompts: true is boolean and PROMPTS already exists }, // Explicitly configured // All other permissions that don't exist in the database [PermissionTypes.MEMORIES]: { @@ -1003,9 +1001,7 @@ describe('updateInterfacePermissions - permissions', () => { const expectedPermissionsForAdmin = { [PermissionTypes.PROMPTS]: { [Permissions.USE]: true, - [Permissions.CREATE]: true, - [Permissions.SHARE]: true, - [Permissions.SHARE_PUBLIC]: true, + // CREATE/SHARE/SHARE_PUBLIC not included since prompts: true is boolean and PROMPTS already exists }, // Explicitly configured // All other permissions that don't exist in the database [PermissionTypes.MEMORIES]: { @@ -1460,11 +1456,9 @@ describe('updateInterfacePermissions - permissions', () => { ); // Explicitly configured permissions should be updated + // CREATE/SHARE/SHARE_PUBLIC not included since prompts: true is boolean and PROMPTS already exists expect(userCall[1][PermissionTypes.PROMPTS]).toEqual({ [Permissions.USE]: true, - [Permissions.CREATE]: true, - [Permissions.SHARE]: false, - [Permissions.SHARE_PUBLIC]: false, }); expect(userCall[1][PermissionTypes.BOOKMARKS]).toEqual({ [Permissions.USE]: true }); expect(userCall[1][PermissionTypes.MARKETPLACE]).toEqual({ [Permissions.USE]: true }); @@ -1785,12 +1779,9 @@ describe('updateInterfacePermissions - permissions', () => { ); // Memory permissions should be updated even though they already exist expect(userCall[1][PermissionTypes.MEMORIES]).toEqual(expectedMemoryPermissions); - // Prompts should be updated (explicitly configured) + // Prompts should be updated (explicitly configured) - CREATE/SHARE/SHARE_PUBLIC not included since prompts: true is boolean and PROMPTS already exists expect(userCall[1][PermissionTypes.PROMPTS]).toEqual({ [Permissions.USE]: true, - [Permissions.CREATE]: true, - [Permissions.SHARE]: false, - [Permissions.SHARE_PUBLIC]: false, }); // Bookmarks should be updated (explicitly configured) expect(userCall[1][PermissionTypes.BOOKMARKS]).toEqual({ [Permissions.USE]: true }); @@ -1801,11 +1792,9 @@ describe('updateInterfacePermissions - permissions', () => { ); // Memory permissions should be updated even though they already exist expect(adminCall[1][PermissionTypes.MEMORIES]).toEqual(expectedMemoryPermissions); + // CREATE/SHARE/SHARE_PUBLIC not included since prompts: true is boolean and PROMPTS already exists expect(adminCall[1][PermissionTypes.PROMPTS]).toEqual({ [Permissions.USE]: true, - [Permissions.CREATE]: true, - [Permissions.SHARE]: true, - [Permissions.SHARE_PUBLIC]: true, }); expect(adminCall[1][PermissionTypes.BOOKMARKS]).toEqual({ [Permissions.USE]: true }); @@ -1821,4 +1810,199 @@ describe('updateInterfacePermissions - permissions', () => { }), }); }); + + it('should preserve existing SHARE/SHARE_PUBLIC values when using boolean config (regression test)', async () => { + // This test ensures that when `agents: true` (boolean) is configured, + // existing SHARE and SHARE_PUBLIC permissions are NOT reset to defaults. + + // Mock existing permissions where SHARE and SHARE_PUBLIC were enabled by user + mockGetRoleByName.mockResolvedValue({ + permissions: { + [PermissionTypes.AGENTS]: { + [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: true, // User enabled this via admin panel + [Permissions.SHARE_PUBLIC]: true, // User enabled this via admin panel + }, + [PermissionTypes.PROMPTS]: { + [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: true, + [Permissions.SHARE_PUBLIC]: true, + }, + }, + }); + + // Config uses boolean (not object), simulating `agents: true` in librechat.yaml + const config = { + interface: { + agents: true, // Boolean config - should only update USE, not reset SHARE/SHARE_PUBLIC + prompts: true, // Boolean config - should only update USE, not reset SHARE/SHARE_PUBLIC + }, + }; + const configDefaults = { + interface: { + agents: true, + prompts: true, + }, + } as TConfigDefaults; + const interfaceConfig = await loadDefaultInterface({ config, configDefaults }); + const appConfig = { config, interfaceConfig } as unknown as AppConfig; + + await updateInterfacePermissions({ + appConfig, + getRoleByName: mockGetRoleByName, + updateAccessPermissions: mockUpdateAccessPermissions, + }); + + const userCall = mockUpdateAccessPermissions.mock.calls.find( + (call) => call[0] === SystemRoles.USER, + ); + + // CRITICAL: When using boolean config and permissions already exist, + // only USE should be updated. CREATE, SHARE, and SHARE_PUBLIC should NOT be in the update payload. + // This means they will be preserved in the database (not reset to defaults). + expect(userCall[1][PermissionTypes.AGENTS]).toEqual({ + [Permissions.USE]: true, + // CREATE, SHARE, and SHARE_PUBLIC intentionally omitted - preserves existing DB values + }); + expect(userCall[1][PermissionTypes.AGENTS]).not.toHaveProperty(Permissions.CREATE); + expect(userCall[1][PermissionTypes.AGENTS]).not.toHaveProperty(Permissions.SHARE); + expect(userCall[1][PermissionTypes.AGENTS]).not.toHaveProperty(Permissions.SHARE_PUBLIC); + + expect(userCall[1][PermissionTypes.PROMPTS]).toEqual({ + [Permissions.USE]: true, + // CREATE, SHARE, and SHARE_PUBLIC intentionally omitted - preserves existing DB values + }); + expect(userCall[1][PermissionTypes.PROMPTS]).not.toHaveProperty(Permissions.CREATE); + expect(userCall[1][PermissionTypes.PROMPTS]).not.toHaveProperty(Permissions.SHARE); + expect(userCall[1][PermissionTypes.PROMPTS]).not.toHaveProperty(Permissions.SHARE_PUBLIC); + }); + + it('should include SHARE/SHARE_PUBLIC when using object config (explicit configuration)', async () => { + // When using object config like `agents: { share: true }`, SHARE/SHARE_PUBLIC SHOULD be updated + mockGetRoleByName.mockResolvedValue({ + permissions: { + [PermissionTypes.AGENTS]: { + [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: false, + [Permissions.SHARE_PUBLIC]: false, + }, + }, + }); + + const config = { + interface: { + agents: { + use: true, + share: true, // Explicitly setting SHARE + public: true, // Explicitly setting SHARE_PUBLIC + }, + }, + }; + const configDefaults = { + interface: { + agents: { + use: true, + share: false, + public: false, + }, + }, + } as TConfigDefaults; + const interfaceConfig = await loadDefaultInterface({ config, configDefaults }); + const appConfig = { config, interfaceConfig } as unknown as AppConfig; + + await updateInterfacePermissions({ + appConfig, + getRoleByName: mockGetRoleByName, + updateAccessPermissions: mockUpdateAccessPermissions, + }); + + const userCall = mockUpdateAccessPermissions.mock.calls.find( + (call) => call[0] === SystemRoles.USER, + ); + + // When object config is used with explicit share/public, they SHOULD be included + expect(userCall[1][PermissionTypes.AGENTS]).toHaveProperty(Permissions.SHARE, true); + expect(userCall[1][PermissionTypes.AGENTS]).toHaveProperty(Permissions.SHARE_PUBLIC, true); + }); + + it('should preserve SHARE/SHARE_PUBLIC when using object config without share/public keys', async () => { + // When using object config like `agents: { use: true, create: false }` WITHOUT share/public, + // existing SHARE and SHARE_PUBLIC should be preserved (not reset to defaults) + mockGetRoleByName.mockResolvedValue({ + permissions: { + [PermissionTypes.AGENTS]: { + [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: true, // User enabled this via admin panel + [Permissions.SHARE_PUBLIC]: true, // User enabled this via admin panel + }, + [PermissionTypes.PROMPTS]: { + [Permissions.USE]: true, + [Permissions.CREATE]: true, + [Permissions.SHARE]: true, + [Permissions.SHARE_PUBLIC]: true, + }, + }, + }); + + const config = { + interface: { + agents: { + use: true, + create: false, // Only setting use and create, NOT share/public + }, + prompts: { + use: true, + create: false, // Only setting use and create, NOT share/public + }, + }, + }; + const configDefaults = { + interface: { + agents: { + use: true, + create: true, + share: false, + public: false, + }, + prompts: { + use: true, + create: true, + share: false, + public: false, + }, + }, + } as TConfigDefaults; + const interfaceConfig = await loadDefaultInterface({ config, configDefaults }); + const appConfig = { config, interfaceConfig } as unknown as AppConfig; + + await updateInterfacePermissions({ + appConfig, + getRoleByName: mockGetRoleByName, + updateAccessPermissions: mockUpdateAccessPermissions, + }); + + const userCall = mockUpdateAccessPermissions.mock.calls.find( + (call) => call[0] === SystemRoles.USER, + ); + + // AGENTS: use and create should be updated, but SHARE/SHARE_PUBLIC should NOT be in payload + expect(userCall[1][PermissionTypes.AGENTS]).toEqual({ + [Permissions.USE]: true, + [Permissions.CREATE]: false, + }); + expect(userCall[1][PermissionTypes.AGENTS]).not.toHaveProperty(Permissions.SHARE); + expect(userCall[1][PermissionTypes.AGENTS]).not.toHaveProperty(Permissions.SHARE_PUBLIC); + + // PROMPTS: same behavior + expect(userCall[1][PermissionTypes.PROMPTS]).toEqual({ + [Permissions.USE]: true, + [Permissions.CREATE]: false, + }); + expect(userCall[1][PermissionTypes.PROMPTS]).not.toHaveProperty(Permissions.SHARE); + expect(userCall[1][PermissionTypes.PROMPTS]).not.toHaveProperty(Permissions.SHARE_PUBLIC); + }); }); diff --git a/packages/api/src/app/permissions.ts b/packages/api/src/app/permissions.ts index 8f97236823..28d475e14e 100644 --- a/packages/api/src/app/permissions.ts +++ b/packages/api/src/app/permissions.ts @@ -146,21 +146,28 @@ export async function updateInterfacePermissions({ }; // Helper to extract value from boolean or object config - const getConfigUse = ( - config: boolean | { use?: boolean; share?: boolean; public?: boolean } | undefined, - ) => (typeof config === 'boolean' ? config : config?.use); - const getConfigShare = ( - config: boolean | { use?: boolean; share?: boolean; public?: boolean } | undefined, - ) => (typeof config === 'boolean' ? undefined : config?.share); - const getConfigPublic = ( - config: boolean | { use?: boolean; share?: boolean; public?: boolean } | undefined, - ) => (typeof config === 'boolean' ? undefined : config?.public); + type PermissionConfig = + | boolean + | { use?: boolean; create?: boolean; share?: boolean; public?: boolean } + | undefined; + const getConfigUse = (config: PermissionConfig) => + typeof config === 'boolean' ? config : config?.use; + const getConfigCreate = (config: PermissionConfig) => + typeof config === 'boolean' ? undefined : config?.create; + const getConfigShare = (config: PermissionConfig) => + typeof config === 'boolean' ? undefined : config?.share; + const getConfigPublic = (config: PermissionConfig) => + typeof config === 'boolean' ? undefined : config?.public; - // Get default use values (for backward compat when config is boolean) + // Get default values (for backward compat when config is boolean) const promptsDefaultUse = typeof defaults.prompts === 'boolean' ? defaults.prompts : defaults.prompts?.use; const agentsDefaultUse = typeof defaults.agents === 'boolean' ? defaults.agents : defaults.agents?.use; + const promptsDefaultCreate = + typeof defaults.prompts === 'object' ? defaults.prompts?.create : undefined; + const agentsDefaultCreate = + typeof defaults.agents === 'object' ? defaults.agents?.create : undefined; const promptsDefaultShare = typeof defaults.prompts === 'object' ? defaults.prompts?.share : undefined; const agentsDefaultShare = @@ -177,21 +184,32 @@ export async function updateInterfacePermissions({ defaultPerms[PermissionTypes.PROMPTS]?.[Permissions.USE], promptsDefaultUse, ), - [Permissions.CREATE]: getPermissionValue( - undefined, - defaultPerms[PermissionTypes.PROMPTS]?.[Permissions.CREATE], - true, - ), - [Permissions.SHARE]: getPermissionValue( - getConfigShare(loadedInterface.prompts), - defaultPerms[PermissionTypes.PROMPTS]?.[Permissions.SHARE], - promptsDefaultShare, - ), - [Permissions.SHARE_PUBLIC]: getPermissionValue( - getConfigPublic(loadedInterface.prompts), - defaultPerms[PermissionTypes.PROMPTS]?.[Permissions.SHARE_PUBLIC], - promptsDefaultPublic, - ), + ...((typeof interfaceConfig?.prompts === 'object' && 'create' in interfaceConfig.prompts) || + !existingPermissions?.[PermissionTypes.PROMPTS] + ? { + [Permissions.CREATE]: getPermissionValue( + getConfigCreate(loadedInterface.prompts), + defaultPerms[PermissionTypes.PROMPTS]?.[Permissions.CREATE], + promptsDefaultCreate ?? true, + ), + } + : {}), + ...((typeof interfaceConfig?.prompts === 'object' && + ('share' in interfaceConfig.prompts || 'public' in interfaceConfig.prompts)) || + !existingPermissions?.[PermissionTypes.PROMPTS] + ? { + [Permissions.SHARE]: getPermissionValue( + getConfigShare(loadedInterface.prompts), + defaultPerms[PermissionTypes.PROMPTS]?.[Permissions.SHARE], + promptsDefaultShare, + ), + [Permissions.SHARE_PUBLIC]: getPermissionValue( + getConfigPublic(loadedInterface.prompts), + defaultPerms[PermissionTypes.PROMPTS]?.[Permissions.SHARE_PUBLIC], + promptsDefaultPublic, + ), + } + : {}), }, [PermissionTypes.BOOKMARKS]: { [Permissions.USE]: getPermissionValue( @@ -242,21 +260,32 @@ export async function updateInterfacePermissions({ defaultPerms[PermissionTypes.AGENTS]?.[Permissions.USE], agentsDefaultUse, ), - [Permissions.CREATE]: getPermissionValue( - undefined, - defaultPerms[PermissionTypes.AGENTS]?.[Permissions.CREATE], - true, - ), - [Permissions.SHARE]: getPermissionValue( - getConfigShare(loadedInterface.agents), - defaultPerms[PermissionTypes.AGENTS]?.[Permissions.SHARE], - agentsDefaultShare, - ), - [Permissions.SHARE_PUBLIC]: getPermissionValue( - getConfigPublic(loadedInterface.agents), - defaultPerms[PermissionTypes.AGENTS]?.[Permissions.SHARE_PUBLIC], - agentsDefaultPublic, - ), + ...((typeof interfaceConfig?.agents === 'object' && 'create' in interfaceConfig.agents) || + !existingPermissions?.[PermissionTypes.AGENTS] + ? { + [Permissions.CREATE]: getPermissionValue( + getConfigCreate(loadedInterface.agents), + defaultPerms[PermissionTypes.AGENTS]?.[Permissions.CREATE], + agentsDefaultCreate ?? true, + ), + } + : {}), + ...((typeof interfaceConfig?.agents === 'object' && + ('share' in interfaceConfig.agents || 'public' in interfaceConfig.agents)) || + !existingPermissions?.[PermissionTypes.AGENTS] + ? { + [Permissions.SHARE]: getPermissionValue( + getConfigShare(loadedInterface.agents), + defaultPerms[PermissionTypes.AGENTS]?.[Permissions.SHARE], + agentsDefaultShare, + ), + [Permissions.SHARE_PUBLIC]: getPermissionValue( + getConfigPublic(loadedInterface.agents), + defaultPerms[PermissionTypes.AGENTS]?.[Permissions.SHARE_PUBLIC], + agentsDefaultPublic, + ), + } + : {}), }, [PermissionTypes.TEMPORARY_CHAT]: { [Permissions.USE]: getPermissionValue( @@ -328,16 +357,22 @@ export async function updateInterfacePermissions({ defaultPerms[PermissionTypes.MCP_SERVERS]?.[Permissions.CREATE], defaults.mcpServers?.create, ), - [Permissions.SHARE]: getPermissionValue( - loadedInterface.mcpServers?.share, - defaultPerms[PermissionTypes.MCP_SERVERS]?.[Permissions.SHARE], - defaults.mcpServers?.share, - ), - [Permissions.SHARE_PUBLIC]: getPermissionValue( - loadedInterface.mcpServers?.public, - defaultPerms[PermissionTypes.MCP_SERVERS]?.[Permissions.SHARE_PUBLIC], - defaults.mcpServers?.public, - ), + ...((typeof interfaceConfig?.mcpServers === 'object' && + ('share' in interfaceConfig.mcpServers || 'public' in interfaceConfig.mcpServers)) || + !existingPermissions?.[PermissionTypes.MCP_SERVERS] + ? { + [Permissions.SHARE]: getPermissionValue( + loadedInterface.mcpServers?.share, + defaultPerms[PermissionTypes.MCP_SERVERS]?.[Permissions.SHARE], + defaults.mcpServers?.share, + ), + [Permissions.SHARE_PUBLIC]: getPermissionValue( + loadedInterface.mcpServers?.public, + defaultPerms[PermissionTypes.MCP_SERVERS]?.[Permissions.SHARE_PUBLIC], + defaults.mcpServers?.public, + ), + } + : {}), }, [PermissionTypes.REMOTE_AGENTS]: { [Permissions.USE]: getPermissionValue( @@ -350,16 +385,22 @@ export async function updateInterfacePermissions({ defaultPerms[PermissionTypes.REMOTE_AGENTS]?.[Permissions.CREATE], defaults.remoteAgents?.create, ), - [Permissions.SHARE]: getPermissionValue( - loadedInterface.remoteAgents?.share, - defaultPerms[PermissionTypes.REMOTE_AGENTS]?.[Permissions.SHARE], - defaults.remoteAgents?.share, - ), - [Permissions.SHARE_PUBLIC]: getPermissionValue( - loadedInterface.remoteAgents?.public, - defaultPerms[PermissionTypes.REMOTE_AGENTS]?.[Permissions.SHARE_PUBLIC], - defaults.remoteAgents?.public, - ), + ...((typeof interfaceConfig?.remoteAgents === 'object' && + ('share' in interfaceConfig.remoteAgents || 'public' in interfaceConfig.remoteAgents)) || + !existingPermissions?.[PermissionTypes.REMOTE_AGENTS] + ? { + [Permissions.SHARE]: getPermissionValue( + loadedInterface.remoteAgents?.share, + defaultPerms[PermissionTypes.REMOTE_AGENTS]?.[Permissions.SHARE], + defaults.remoteAgents?.share, + ), + [Permissions.SHARE_PUBLIC]: getPermissionValue( + loadedInterface.remoteAgents?.public, + defaultPerms[PermissionTypes.REMOTE_AGENTS]?.[Permissions.SHARE_PUBLIC], + defaults.remoteAgents?.public, + ), + } + : {}), }, }; diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 6402f30670..5ea851de19 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -628,6 +628,7 @@ export const interfaceSchema = z z.boolean(), z.object({ use: z.boolean().optional(), + create: z.boolean().optional(), share: z.boolean().optional(), public: z.boolean().optional(), }), @@ -638,6 +639,7 @@ export const interfaceSchema = z z.boolean(), z.object({ use: z.boolean().optional(), + create: z.boolean().optional(), share: z.boolean().optional(), public: z.boolean().optional(), }), @@ -681,11 +683,13 @@ export const interfaceSchema = z memories: true, prompts: { use: true, + create: true, share: false, public: false, }, agents: { use: true, + create: true, share: false, public: false, },