diff --git a/api/server/routes/agents/actions.js b/api/server/routes/agents/actions.js index 6133ee24e1..eab7c908aa 100644 --- a/api/server/routes/agents/actions.js +++ b/api/server/routes/agents/actions.js @@ -3,8 +3,12 @@ const { nanoid } = require('nanoid'); const { logger } = require('@librechat/data-schemas'); const { generateCheckAccess, + planAgentActionUpdate, isActionDomainAllowed, + legacyActionDomainEncode, validateActionOAuthMetadata, + ACTION_CREDENTIAL_REFRESH_MESSAGE, + buildActionOAuthTokenDeleteQueries, } = require('@librechat/api'); const { Permissions, @@ -16,17 +20,19 @@ const { validateActionDomain, validateAndParseOpenAPISpec, } = require('librechat-data-provider'); -const { - legacyDomainEncode, - encryptMetadata, - domainParser, -} = require('~/server/services/ActionService'); +const { encryptMetadata, domainParser } = require('~/server/services/ActionService'); const { findAccessibleResources } = require('~/server/services/PermissionService'); const db = require('~/models'); const { canAccessAgentResource } = require('~/server/middleware'); const router = express.Router(); +async function deleteActionOAuthTokens(action_id) { + await Promise.all( + buildActionOAuthTokenDeleteQueries(action_id).map((query) => db.deleteTokens(query)), + ); +} + const checkAgentCreate = generateCheckAccess({ permissionType: PermissionTypes.AGENTS, permissions: [Permissions.USE, Permissions.CREATE], @@ -92,7 +98,7 @@ router.post( return res.status(400).json({ message: 'No functions provided' }); } - let metadata = await encryptMetadata(removeNullishValues(_metadata, true)); + const metadata = await encryptMetadata(removeNullishValues(_metadata, true)); const appConfig = req.config; // SECURITY: Validate the OpenAPI spec and extract the server URL @@ -135,15 +141,16 @@ router.post( return res.status(400).json({ message: 'No domain provided' }); } - const legacyDomain = legacyDomainEncode(metadata.domain); + const legacyDomain = legacyActionDomainEncode(metadata.domain); - const action_id = _action_id ?? nanoid(); + const requestedActionId = _action_id; + const action_id = requestedActionId ?? nanoid(); const initialPromises = []; // Permissions already validated by middleware - load agent directly initialPromises.push(db.getAgent({ id: agent_id })); - if (_action_id) { - initialPromises.push(db.getActions({ action_id }, true)); + if (requestedActionId) { + initialPromises.push(db.getActions({ action_id: requestedActionId }, true)); } /** @type {[Agent, [Action|undefined]]} */ @@ -152,53 +159,51 @@ router.post( return res.status(404).json({ message: 'Agent not found for adding action' }); } - if (actions_result && actions_result.length) { - const action = actions_result[0]; - if (action.agent_id !== agent_id) { + const storedAction = actions_result?.[0]; + if (storedAction) { + if (storedAction.agent_id !== agent_id) { return res.status(403).json({ message: 'Action does not belong to this agent' }); } - metadata = { ...action.metadata, ...metadata }; + } + + const { actions: agentActions = [], tools: agentTools = [], author: agent_author } = agent; + const plannedUpdate = planAgentActionUpdate({ + agentActions, + agentTools, + incomingFunctions: functions, + incomingMetadata: metadata, + actionId: action_id, + requestedActionId, + encodedDomain, + legacyDomain, + previousLegacyDomain: legacyActionDomainEncode(storedAction?.metadata?.domain), + storedAction, + }); + + if (plannedUpdate.requiresCredentialRefresh) { + return res.status(400).json({ + message: ACTION_CREDENTIAL_REFRESH_MESSAGE, + }); } try { - await validateActionOAuthMetadata(metadata.auth, appConfig?.actions?.allowedAddresses); + await validateActionOAuthMetadata( + plannedUpdate.metadata.auth, + appConfig?.actions?.allowedAddresses, + ); } catch (error) { return res.status(400).json({ message: error.message }); } - const { actions: _actions = [], author: agent_author } = agent ?? {}; - const actions = []; - for (const action of _actions) { - const [_action_domain, current_action_id] = action.split(actionDelimiter); - if (current_action_id === action_id) { - continue; - } - - actions.push(action); + if (plannedUpdate.deleteOAuthTokens && requestedActionId) { + // Keep the callback URL stable while preventing old OAuth tokens from following a new target. + await deleteActionOAuthTokens(requestedActionId); } - actions.push(`${encodedDomain}${actionDelimiter}${action_id}`); - - /** @type {string[]}} */ - const { tools: _tools = [] } = agent; - - const shouldRemoveAgentTool = (tool) => { - if (!tool) { - return false; - } - return ( - tool.includes(encodedDomain) || tool.includes(legacyDomain) || tool.includes(action_id) - ); - }; - - const tools = _tools - .filter((tool) => !shouldRemoveAgentTool(tool)) - .concat(functions.map((tool) => `${tool.function.name}${actionDelimiter}${encodedDomain}`)); - // Force version update since actions are changing const updatedAgent = await db.updateAgent( { id: agent_id }, - { tools, actions }, + { tools: plannedUpdate.tools, actions: plannedUpdate.actions }, { updatingUserId: req.user.id, forceVersion: true, @@ -206,14 +211,21 @@ router.post( ); // Only update user field for new actions - const actionUpdateData = { metadata, agent_id }; + const actionUpdateData = { + action_id: plannedUpdate.actionId, + metadata: plannedUpdate.metadata, + agent_id, + }; if (!actions_result || !actions_result.length) { // For new actions, use the agent owner's user ID actionUpdateData.user = agent_author || req.user.id; } - /** @type {[Action]} */ - const updatedAction = await db.updateAction({ action_id, agent_id }, actionUpdateData); + /** @type {Action} */ + const updatedAction = await db.updateAction( + { action_id: requestedActionId ?? action_id, agent_id }, + actionUpdateData, + ); const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret']; for (let field of sensitiveFields) { diff --git a/packages/api/src/actions/credentials.spec.ts b/packages/api/src/actions/credentials.spec.ts new file mode 100644 index 0000000000..d10b0410ca --- /dev/null +++ b/packages/api/src/actions/credentials.spec.ts @@ -0,0 +1,183 @@ +jest.mock( + 'librechat-data-provider', + () => ({ + AuthTypeEnum: { + ServiceHttp: 'service_http', + OAuth: 'oauth', + None: 'none', + }, + AuthorizationTypeEnum: { + Bearer: 'bearer', + }, + TokenExchangeMethodEnum: { + DefaultPost: 'default_post', + }, + validateAndParseOpenAPISpec: (specString: string) => { + const spec = JSON.parse(specString) as { servers?: Array<{ url?: string }> }; + return { + status: true, + spec, + serverUrl: spec.servers?.[0]?.url, + }; + }, + }), + { virtual: true }, +); + +import { + AuthTypeEnum, + AuthorizationTypeEnum, + TokenExchangeMethodEnum, +} from 'librechat-data-provider'; +import type { ActionMetadata } from 'librechat-data-provider'; +import { mergeActionMetadataForUpdate } from './credentials'; + +const specFor = (serverUrl: string): string => + JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Action API', version: '1.0.0' }, + servers: [{ url: serverUrl }], + paths: { + '/echo': { + get: { + operationId: 'echo', + responses: { 200: { description: 'OK' } }, + }, + }, + }, + }); + +const serviceMetadata = (serverUrl = 'https://api.example.com/v1'): ActionMetadata => ({ + domain: 'https://api.example.com', + raw_spec: specFor(serverUrl), + api_key: 'encrypted-owner-key', + auth: { + type: AuthTypeEnum.ServiceHttp, + authorization_type: AuthorizationTypeEnum.Bearer, + }, +}); + +describe('mergeActionMetadataForUpdate', () => { + it('preserves existing service credentials when the action target is unchanged', () => { + const incoming: ActionMetadata = { + domain: 'https://api.example.com', + raw_spec: specFor('https://api.example.com/v1'), + }; + + const result = mergeActionMetadataForUpdate({ + storedMetadata: serviceMetadata(), + incomingMetadata: incoming, + }); + + expect(result.targetChanged).toBe(false); + expect(result.requiresCredentialRefresh).toBe(false); + expect(result.metadata.api_key).toBe('encrypted-owner-key'); + }); + + it('requires a fresh service credential when the domain changes', () => { + const incoming: ActionMetadata = { + domain: 'https://attacker.example', + raw_spec: specFor('https://attacker.example/v1'), + }; + + const result = mergeActionMetadataForUpdate({ + storedMetadata: serviceMetadata(), + incomingMetadata: incoming, + }); + + expect(result.targetChanged).toBe(true); + expect(result.requiresCredentialRefresh).toBe(true); + expect(result.metadata.api_key).toBeUndefined(); + }); + + it('treats OpenAPI server path changes as target changes', () => { + const incoming: ActionMetadata = { + domain: 'https://api.example.com', + raw_spec: specFor('https://api.example.com/debug'), + }; + + const result = mergeActionMetadataForUpdate({ + storedMetadata: serviceMetadata(), + incomingMetadata: incoming, + }); + + expect(result.targetChanged).toBe(true); + expect(result.requiresCredentialRefresh).toBe(true); + expect(result.metadata.api_key).toBeUndefined(); + }); + + it('allows target changes when the request supplies a fresh service credential', () => { + const incoming: ActionMetadata = { + domain: 'https://api.example.com', + raw_spec: specFor('https://api.example.com/v2'), + api_key: 'encrypted-new-key', + auth: { + type: AuthTypeEnum.ServiceHttp, + authorization_type: AuthorizationTypeEnum.Bearer, + }, + }; + + const result = mergeActionMetadataForUpdate({ + storedMetadata: serviceMetadata(), + incomingMetadata: incoming, + }); + + expect(result.targetChanged).toBe(true); + expect(result.requiresCredentialRefresh).toBe(false); + expect(result.metadata.api_key).toBe('encrypted-new-key'); + }); + + it('allows target changes when auth is explicitly removed', () => { + const incoming: ActionMetadata = { + domain: 'https://api.example.com', + raw_spec: specFor('https://api.example.com/v2'), + auth: { + type: AuthTypeEnum.None, + }, + }; + + const result = mergeActionMetadataForUpdate({ + storedMetadata: serviceMetadata(), + incomingMetadata: incoming, + }); + + expect(result.targetChanged).toBe(true); + expect(result.requiresCredentialRefresh).toBe(false); + expect(result.metadata.api_key).toBeUndefined(); + }); + + it('requires fresh OAuth client credentials when OAuth endpoints change', () => { + const storedMetadata: ActionMetadata = { + domain: 'https://api.example.com', + raw_spec: specFor('https://api.example.com/v1'), + oauth_client_id: 'encrypted-client-id', + oauth_client_secret: 'encrypted-client-secret', + auth: { + type: AuthTypeEnum.OAuth, + authorization_url: 'https://auth.example.com/authorize', + client_url: 'https://auth.example.com/token', + scope: 'read', + token_exchange_method: TokenExchangeMethodEnum.DefaultPost, + }, + }; + const incomingMetadata: ActionMetadata = { + auth: { + type: AuthTypeEnum.OAuth, + authorization_url: 'https://evil.example/authorize', + client_url: 'https://evil.example/token', + scope: 'read', + token_exchange_method: TokenExchangeMethodEnum.DefaultPost, + }, + }; + + const result = mergeActionMetadataForUpdate({ + storedMetadata, + incomingMetadata, + }); + + expect(result.targetChanged).toBe(true); + expect(result.requiresCredentialRefresh).toBe(true); + expect(result.metadata.oauth_client_id).toBeUndefined(); + expect(result.metadata.oauth_client_secret).toBeUndefined(); + }); +}); diff --git a/packages/api/src/actions/credentials.ts b/packages/api/src/actions/credentials.ts new file mode 100644 index 0000000000..ad53d21c8b --- /dev/null +++ b/packages/api/src/actions/credentials.ts @@ -0,0 +1,194 @@ +import { AuthTypeEnum, validateAndParseOpenAPISpec } from 'librechat-data-provider'; +import type { ActionAuth, ActionMetadata } from 'librechat-data-provider'; + +const authBoundaryFields: Array = [ + 'type', + 'authorization_type', + 'custom_auth_header', + 'authorization_url', + 'client_url', + 'scope', + 'token_exchange_method', +]; + +export type ActionMetadataUpdateResult = { + metadata: ActionMetadata; + targetChanged: boolean; + requiresCredentialRefresh: boolean; +}; + +export type ActionMetadataUpdateParams = { + storedMetadata: ActionMetadata; + incomingMetadata: ActionMetadata; +}; + +function hasOwn( + obj: T | null | undefined, + key: K, +): obj is T & Record { + return obj != null && Object.prototype.hasOwnProperty.call(obj, key); +} + +function hasValue(value: string | undefined): boolean { + return typeof value === 'string' && value.length > 0; +} + +function normalizeUrlTarget(value: string | undefined): string { + if (!value) { + return ''; + } + + try { + const url = new URL(value); + const pathname = url.pathname.replace(/\/+$/, ''); + return `${url.protocol}//${url.host.toLowerCase()}${pathname}${url.search}`; + } catch { + return value.trim().toLowerCase().replace(/\/+$/, ''); + } +} + +function normalizeDomainTarget(value: string | undefined): string { + if (!value) { + return ''; + } + + const urlValue = value.includes('://') ? value : `https://${value}`; + try { + const url = new URL(urlValue); + return `${url.protocol}//${url.host.toLowerCase()}`; + } catch { + return value.trim().toLowerCase().replace(/\/+$/, ''); + } +} + +function getSpecServerTarget(rawSpec: string | undefined): string { + if (!rawSpec) { + return ''; + } + + const result = validateAndParseOpenAPISpec(rawSpec); + return normalizeUrlTarget(result.serverUrl); +} + +function didDomainChange( + storedMetadata: ActionMetadata, + incomingMetadata: ActionMetadata, +): boolean { + if (!hasOwn(incomingMetadata, 'domain')) { + return false; + } + + return ( + normalizeDomainTarget(storedMetadata.domain) !== normalizeDomainTarget(incomingMetadata.domain) + ); +} + +function didSpecServerChange( + storedMetadata: ActionMetadata, + incomingMetadata: ActionMetadata, +): boolean { + if (!hasOwn(incomingMetadata, 'raw_spec')) { + return false; + } + + return ( + getSpecServerTarget(storedMetadata.raw_spec) !== getSpecServerTarget(incomingMetadata.raw_spec) + ); +} + +function didAuthBoundaryChange( + storedMetadata: ActionMetadata, + incomingMetadata: ActionMetadata, +): boolean { + if (!hasOwn(incomingMetadata, 'auth')) { + return false; + } + + return authBoundaryFields.some( + (field) => storedMetadata.auth?.[field] !== incomingMetadata.auth?.[field], + ); +} + +function getTargetChanged( + storedMetadata: ActionMetadata, + incomingMetadata: ActionMetadata, +): boolean { + return ( + didDomainChange(storedMetadata, incomingMetadata) || + didSpecServerChange(storedMetadata, incomingMetadata) || + didAuthBoundaryChange(storedMetadata, incomingMetadata) + ); +} + +function getNextAuthType( + storedMetadata: ActionMetadata, + incomingMetadata: ActionMetadata, +): AuthTypeEnum | undefined { + return incomingMetadata.auth?.type ?? storedMetadata.auth?.type; +} + +function requiresCredentialRefresh( + storedMetadata: ActionMetadata, + incomingMetadata: ActionMetadata, +): boolean { + const authType = getNextAuthType(storedMetadata, incomingMetadata); + + if (authType === AuthTypeEnum.ServiceHttp) { + return hasValue(storedMetadata.api_key) && !hasValue(incomingMetadata.api_key); + } + + if (authType === AuthTypeEnum.OAuth) { + return ( + (hasValue(storedMetadata.oauth_client_id) && !hasValue(incomingMetadata.oauth_client_id)) || + (hasValue(storedMetadata.oauth_client_secret) && + !hasValue(incomingMetadata.oauth_client_secret)) + ); + } + + return false; +} + +function removeUnsubmittedCredentials( + metadata: ActionMetadata, + incomingMetadata: ActionMetadata, +): ActionMetadata { + const sanitized = { ...metadata }; + + if (!hasValue(incomingMetadata.api_key)) { + delete sanitized.api_key; + } + + if (!hasValue(incomingMetadata.oauth_client_id)) { + delete sanitized.oauth_client_id; + } + + if (!hasValue(incomingMetadata.oauth_client_secret)) { + delete sanitized.oauth_client_secret; + } + + return sanitized; +} + +/** + * Preserves saved credentials only while an Action continues to point at the + * same request/auth boundary. A changed domain, OpenAPI server URL, or auth + * endpoint must come with fresh credentials or explicitly remove auth. + */ +export function mergeActionMetadataForUpdate({ + storedMetadata, + incomingMetadata, +}: ActionMetadataUpdateParams): ActionMetadataUpdateResult { + const targetChanged = getTargetChanged(storedMetadata, incomingMetadata); + const refreshRequired = targetChanged + ? requiresCredentialRefresh(storedMetadata, incomingMetadata) + : false; + const mergedMetadata = { ...storedMetadata, ...incomingMetadata }; + + return { + targetChanged, + requiresCredentialRefresh: refreshRequired, + metadata: targetChanged + ? removeUnsubmittedCredentials(mergedMetadata, incomingMetadata) + : mergedMetadata, + }; +} diff --git a/packages/api/src/actions/index.ts b/packages/api/src/actions/index.ts new file mode 100644 index 0000000000..bd6bb74bd5 --- /dev/null +++ b/packages/api/src/actions/index.ts @@ -0,0 +1,3 @@ +export * from './credentials'; +export * from './tools'; +export * from './update'; diff --git a/packages/api/src/actions/tools.spec.ts b/packages/api/src/actions/tools.spec.ts new file mode 100644 index 0000000000..2b4747de64 --- /dev/null +++ b/packages/api/src/actions/tools.spec.ts @@ -0,0 +1,92 @@ +jest.mock( + 'librechat-data-provider', + () => ({ + actionDelimiter: '_action_', + validateAndParseOpenAPISpec: (specString: string) => { + const spec = JSON.parse(specString) as { paths?: Record }; + return { + status: true, + message: 'OpenAPI spec is valid.', + spec, + serverUrl: 'https://api.example.com', + }; + }, + }), + { virtual: true }, +); + +import { mergeAgentActionTools } from './tools'; + +const specFor = (operationId: string): string => + JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Action API', version: '1.0.0' }, + servers: [{ url: 'https://api.example.com' }], + paths: { + '/items': { + get: { + operationId, + responses: { 200: { description: 'OK' } }, + }, + }, + }, + }); + +const toolFor = (name: string) => ({ + function: { + name, + }, +}); + +describe('mergeAgentActionTools', () => { + it('preserves other action tools that share the previous domain', () => { + const result = mergeAgentActionTools({ + existingTools: [ + 'listItems_action_shared_domain', + 'echoMessage_action_shared_domain', + 'echoMessage_action_legacy_domain', + ], + incomingFunctions: [toolFor('echoMessage')], + encodedDomain: 'new_domain', + actionId: 'action-a', + requestedActionId: 'action-a', + previousEncodedDomain: 'shared_domain', + previousLegacyDomain: 'legacy_domain', + previousRawSpec: specFor('echoMessage'), + }); + + expect(result).toEqual(['listItems_action_shared_domain', 'echoMessage_action_new_domain']); + }); + + it('removes previous operation names when an action function is renamed', () => { + const result = mergeAgentActionTools({ + existingTools: ['oldName_action_shared_domain', 'unrelated_action_shared_domain'], + incomingFunctions: [toolFor('newName')], + encodedDomain: 'shared_domain', + actionId: 'action-a', + requestedActionId: 'action-a', + previousEncodedDomain: 'shared_domain', + previousRawSpec: specFor('oldName'), + }); + + expect(result).toEqual(['unrelated_action_shared_domain', 'newName_action_shared_domain']); + }); + + it('removes legacy action-id keyed entries without dropping unrelated tools', () => { + const result = mergeAgentActionTools({ + existingTools: [ + 'legacy-action-a', + 'listItems_action_shared_domain', + 'echoMessage_action_shared_domain', + ], + incomingFunctions: [toolFor('echoMessage')], + encodedDomain: 'shared_domain', + actionId: 'action-a', + requestedActionId: 'action-a', + previousEncodedDomain: 'shared_domain', + previousRawSpec: specFor('echoMessage'), + }); + + expect(result).toEqual(['listItems_action_shared_domain', 'echoMessage_action_shared_domain']); + }); +}); diff --git a/packages/api/src/actions/tools.ts b/packages/api/src/actions/tools.ts new file mode 100644 index 0000000000..2647d5cde5 --- /dev/null +++ b/packages/api/src/actions/tools.ts @@ -0,0 +1,127 @@ +import { actionDelimiter, validateAndParseOpenAPISpec } from 'librechat-data-provider'; + +export type ActionToolLike = { + function?: { + name?: string; + }; +}; + +export type MergeAgentActionToolsParams = { + existingTools: string[]; + incomingFunctions: ActionToolLike[]; + encodedDomain: string; + actionId: string; + requestedActionId?: string; + legacyDomain?: string; + previousEncodedDomain?: string; + previousLegacyDomain?: string; + previousRawSpec?: string; +}; + +const httpMethods = new Set(['get', 'put', 'post', 'delete', 'options', 'head', 'patch', 'trace']); + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function sanitizeOperationId(input: string): string { + return input.replace(/[^a-zA-Z0-9_-]/g, ''); +} + +function getFunctionNames(functions: ActionToolLike[]): string[] { + const names = new Set(); + + for (const tool of functions) { + const name = tool.function?.name; + if (typeof name === 'string' && name.length > 0) { + names.add(name); + } + } + + return [...names]; +} + +function getSpecOperationNames(rawSpec?: string): string[] { + if (!rawSpec) { + return []; + } + + const result = validateAndParseOpenAPISpec(rawSpec); + if (!result.status || !isRecord(result.spec?.paths)) { + return []; + } + + const names = new Set(); + for (const [path, methods] of Object.entries(result.spec.paths)) { + if (!isRecord(methods)) { + continue; + } + + for (const [method, operation] of Object.entries(methods)) { + if (!httpMethods.has(method.toLowerCase()) || !isRecord(operation)) { + continue; + } + + const operationId = operation.operationId; + names.add( + typeof operationId === 'string' && operationId.length > 0 + ? operationId + : sanitizeOperationId(`${method}_${path}`), + ); + } + } + + return [...names]; +} + +function getUniqueValues(values: Array): string[] { + return [...new Set(values.filter((value): value is string => !!value))]; +} + +export function mergeAgentActionTools({ + existingTools, + incomingFunctions, + encodedDomain, + actionId, + requestedActionId, + legacyDomain, + previousEncodedDomain, + previousLegacyDomain, + previousRawSpec, +}: MergeAgentActionToolsParams): string[] { + const incomingNames = getFunctionNames(incomingFunctions); + const domainsToReplace = getUniqueValues([ + encodedDomain, + legacyDomain, + previousEncodedDomain, + previousLegacyDomain, + ]); + const namesToReplace = getUniqueValues([ + ...incomingNames, + ...getSpecOperationNames(previousRawSpec), + ]); + const toolsToReplace = new Set(); + + for (const name of namesToReplace) { + for (const domain of domainsToReplace) { + toolsToReplace.add(`${name}${actionDelimiter}${domain}`); + } + } + + const actionIdsToReplace = getUniqueValues([actionId, requestedActionId]); + const retainedTools = existingTools.filter((tool) => { + if (!tool) { + return false; + } + + if (toolsToReplace.has(tool)) { + return false; + } + + return !actionIdsToReplace.some((id) => tool.includes(id)); + }); + + return retainedTools.concat( + incomingNames.map((name) => `${name}${actionDelimiter}${encodedDomain}`), + ); +} diff --git a/packages/api/src/actions/update.spec.ts b/packages/api/src/actions/update.spec.ts new file mode 100644 index 0000000000..ed356100af --- /dev/null +++ b/packages/api/src/actions/update.spec.ts @@ -0,0 +1,152 @@ +jest.mock( + 'librechat-data-provider', + () => ({ + actionDelimiter: '_action_', + actionDomainSeparator: '---', + Constants: { + ENCODED_DOMAIN_LENGTH: 64, + }, + AuthTypeEnum: { + ServiceHttp: 'service_http', + OAuth: 'oauth', + None: 'none', + }, + AuthorizationTypeEnum: { + Bearer: 'bearer', + }, + validateAndParseOpenAPISpec: (specString: string) => { + const spec = JSON.parse(specString) as { servers?: Array<{ url?: string }> }; + return { + status: true, + message: 'OpenAPI spec is valid.', + spec, + serverUrl: spec.servers?.[0]?.url, + }; + }, + }), + { virtual: true }, +); + +import { AuthTypeEnum, AuthorizationTypeEnum } from 'librechat-data-provider'; +import type { ActionMetadata } from 'librechat-data-provider'; +import { + buildActionOAuthTokenDeleteQueries, + legacyActionDomainEncode, + planAgentActionUpdate, +} from './update'; + +const specFor = (serverUrl: string, operationId = 'echoMessage'): string => + JSON.stringify({ + openapi: '3.0.0', + info: { title: 'Action API', version: '1.0.0' }, + servers: [{ url: serverUrl }], + paths: { + '/echo': { + get: { + operationId, + responses: { 200: { description: 'OK' } }, + }, + }, + }, + }); + +const toolFor = (name: string) => ({ + function: { + name, + }, +}); + +const storedServiceMetadata: ActionMetadata = { + domain: 'https://api.example.com', + raw_spec: specFor('https://api.example.com/v1'), + api_key: 'encrypted-owner-key', + auth: { + type: AuthTypeEnum.ServiceHttp, + authorization_type: AuthorizationTypeEnum.Bearer, + }, +}; + +describe('planAgentActionUpdate', () => { + it('requires refreshed credentials when an existing target changes without new credentials', () => { + const result = planAgentActionUpdate({ + agentActions: ['api---example---com_action_action-1'], + agentTools: ['echoMessage_action_api---example---com'], + incomingFunctions: [toolFor('echoMessage')], + incomingMetadata: { + domain: 'https://other.example.com', + raw_spec: specFor('https://other.example.com/v1'), + }, + actionId: 'action-1', + requestedActionId: 'action-1', + encodedDomain: 'other---example---com', + legacyDomain: legacyActionDomainEncode('https://other.example.com'), + previousLegacyDomain: legacyActionDomainEncode('https://api.example.com'), + storedAction: { + metadata: storedServiceMetadata, + }, + }); + + expect(result.requiresCredentialRefresh).toBe(true); + expect(result.targetChanged).toBe(true); + expect(result.deleteOAuthTokens).toBe(true); + expect(result.metadata.api_key).toBeUndefined(); + }); + + it('plans a stable-id target update while preserving unrelated same-domain tools', () => { + const result = planAgentActionUpdate({ + agentActions: ['api---example---com_action_action-1', 'api---example---com_action_action-2'], + agentTools: [ + 'echoMessage_action_api---example---com', + 'listItems_action_api---example---com', + ], + incomingFunctions: [toolFor('echoMessage')], + incomingMetadata: { + domain: 'https://other.example.com', + raw_spec: specFor('https://other.example.com/v1'), + api_key: 'encrypted-new-key', + auth: { + type: AuthTypeEnum.ServiceHttp, + authorization_type: AuthorizationTypeEnum.Bearer, + }, + }, + actionId: 'action-1', + requestedActionId: 'action-1', + encodedDomain: 'other---example---com', + legacyDomain: legacyActionDomainEncode('https://other.example.com'), + previousLegacyDomain: legacyActionDomainEncode('https://api.example.com'), + storedAction: { + metadata: storedServiceMetadata, + }, + }); + + expect(result.requiresCredentialRefresh).toBe(false); + expect(result.actionId).toBe('action-1'); + expect(result.deleteOAuthTokens).toBe(true); + expect(result.actions).toEqual([ + 'api---example---com_action_action-2', + 'other---example---com_action_action-1', + ]); + expect(result.tools).toEqual([ + 'listItems_action_api---example---com', + 'echoMessage_action_other---example---com', + ]); + }); +}); + +describe('buildActionOAuthTokenDeleteQueries', () => { + it('builds escaped access and refresh token identifier queries', () => { + const [accessQuery, refreshQuery] = buildActionOAuthTokenDeleteQueries('action.1'); + + expect(accessQuery).toEqual({ + type: 'oauth', + identifier: expect.any(RegExp), + }); + expect(refreshQuery).toEqual({ + type: 'oauth_refresh', + identifier: expect.any(RegExp), + }); + expect(accessQuery.identifier.test('user-1:action.1')).toBe(true); + expect(accessQuery.identifier.test('user-1:actionx1')).toBe(false); + expect(refreshQuery.identifier.test('user-1:action.1:refresh')).toBe(true); + }); +}); diff --git a/packages/api/src/actions/update.ts b/packages/api/src/actions/update.ts new file mode 100644 index 0000000000..03bb3ba861 --- /dev/null +++ b/packages/api/src/actions/update.ts @@ -0,0 +1,163 @@ +import { Constants, actionDelimiter, actionDomainSeparator } from 'librechat-data-provider'; +import type { ActionMetadata } from 'librechat-data-provider'; +import type { ActionToolLike } from './tools'; +import { mergeActionMetadataForUpdate } from './credentials'; +import { mergeAgentActionTools } from './tools'; + +export const ACTION_CREDENTIAL_REFRESH_MESSAGE = + 'Action credentials must be re-entered when changing the domain, OpenAPI server URL, or authentication settings'; + +type StoredActionForUpdate = { + metadata?: ActionMetadata | null; +}; + +export type PlanAgentActionUpdateParams = { + agentActions: string[]; + agentTools: string[]; + incomingFunctions: ActionToolLike[]; + incomingMetadata: ActionMetadata; + actionId: string; + requestedActionId?: string; + encodedDomain: string; + legacyDomain?: string; + previousLegacyDomain?: string; + storedAction?: StoredActionForUpdate | null; +}; + +export type PlannedAgentActionUpdate = { + actionId: string; + metadata: ActionMetadata; + actions: string[]; + tools: string[]; + targetChanged: boolean; + requiresCredentialRefresh: boolean; + deleteOAuthTokens: boolean; +}; + +export type ActionOAuthTokenDeleteQuery = { + type: 'oauth' | 'oauth_refresh'; + identifier: RegExp; +}; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function getUpdatedAgentActionRefs({ + agentActions, + actionId, + requestedActionId, + encodedDomain, +}: Pick< + PlanAgentActionUpdateParams, + 'agentActions' | 'actionId' | 'requestedActionId' | 'encodedDomain' +>): { + actions: string[]; + previousEncodedDomain?: string; +} { + const actions: string[] = []; + let previousEncodedDomain: string | undefined; + + for (const action of agentActions) { + const [actionDomain, currentActionId] = action.split(actionDelimiter); + if ( + (requestedActionId && currentActionId === requestedActionId) || + currentActionId === actionId + ) { + previousEncodedDomain = actionDomain; + continue; + } + + actions.push(action); + } + + actions.push(`${encodedDomain}${actionDelimiter}${actionId}`); + + return { + actions, + previousEncodedDomain, + }; +} + +export function legacyActionDomainEncode(domain?: string): string { + if (!domain) { + return ''; + } + + if (domain.length <= Constants.ENCODED_DOMAIN_LENGTH) { + return domain.replace(/\./g, actionDomainSeparator); + } + + const modifiedDomain = Buffer.from(domain).toString('base64'); + return modifiedDomain.substring(0, Constants.ENCODED_DOMAIN_LENGTH); +} + +export function buildActionOAuthTokenDeleteQueries( + actionId: string, +): ActionOAuthTokenDeleteQuery[] { + const escapedActionId = escapeRegExp(actionId); + + return [ + { + type: 'oauth', + identifier: new RegExp(`^[^:]+:${escapedActionId}$`), + }, + { + type: 'oauth_refresh', + identifier: new RegExp(`^[^:]+:${escapedActionId}:refresh$`), + }, + ]; +} + +export function planAgentActionUpdate({ + agentActions, + agentTools, + incomingFunctions, + incomingMetadata, + actionId, + requestedActionId, + encodedDomain, + legacyDomain, + previousLegacyDomain, + storedAction, +}: PlanAgentActionUpdateParams): PlannedAgentActionUpdate { + const metadataUpdate = storedAction + ? mergeActionMetadataForUpdate({ + storedMetadata: storedAction.metadata ?? {}, + incomingMetadata, + }) + : { + metadata: incomingMetadata, + targetChanged: false, + requiresCredentialRefresh: false, + }; + + const { actions, previousEncodedDomain } = getUpdatedAgentActionRefs({ + agentActions, + actionId, + requestedActionId, + encodedDomain, + }); + + const tools = mergeAgentActionTools({ + existingTools: agentTools, + incomingFunctions, + encodedDomain, + actionId, + requestedActionId, + legacyDomain, + previousEncodedDomain, + previousLegacyDomain, + previousRawSpec: storedAction?.metadata?.raw_spec, + }); + + return { + actionId, + actions, + tools, + metadata: metadataUpdate.metadata, + targetChanged: metadataUpdate.targetChanged, + requiresCredentialRefresh: metadataUpdate.requiresCredentialRefresh, + deleteOAuthTokens: Boolean(metadataUpdate.targetChanged && requestedActionId), + }; +} diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 130b563839..49e4ff50d3 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -39,6 +39,8 @@ export * from './memory'; export * from './modelSpecs'; /* Agents */ export * from './agents'; +/* Actions */ +export * from './actions'; /* Prompts */ export * from './prompts'; /* Projects */ diff --git a/packages/data-schemas/src/methods/token.spec.ts b/packages/data-schemas/src/methods/token.spec.ts index 9508784fea..7e7c44c185 100644 --- a/packages/data-schemas/src/methods/token.spec.ts +++ b/packages/data-schemas/src/methods/token.spec.ts @@ -580,6 +580,38 @@ describe('Token Methods - Detailed Tests', () => { expect(remainingTokens.find((t) => t.identifier === 'oauth-identifier-456')).toBeUndefined(); }); + test('should delete tokens matching an identifier pattern', async () => { + await Token.create([ + { + token: 'action-access-token', + userId: oauthUserId, + type: 'oauth', + identifier: `${oauthUserId.toString()}:action-1`, + createdAt: new Date(), + expiresAt: new Date(Date.now() + 3600000), + }, + { + token: 'other-action-token', + userId: oauthUserId, + type: 'oauth', + identifier: `${oauthUserId.toString()}:action-2`, + createdAt: new Date(), + expiresAt: new Date(Date.now() + 3600000), + }, + ]); + + const result = await methods.deleteTokens({ + type: 'oauth', + identifier: /^[^:]+:action-1$/, + }); + + expect(result.deletedCount).toBe(1); + + const remaining = await Token.find({ userId: oauthUserId, type: 'oauth' }); + expect(remaining).toHaveLength(1); + expect(remaining[0].identifier).toBe(`${oauthUserId.toString()}:action-2`); + }); + test('should not delete tokens when undefined fields are passed', async () => { // This is the critical test case for the bug fix const result = await methods.deleteTokens({ diff --git a/packages/data-schemas/src/types/token.ts b/packages/data-schemas/src/types/token.ts index bc61f9f136..889f1768a3 100644 --- a/packages/data-schemas/src/types/token.ts +++ b/packages/data-schemas/src/types/token.ts @@ -27,7 +27,7 @@ export interface TokenQuery { token?: string; email?: string | null; type?: string | null; - identifier?: string | null; + identifier?: string | RegExp | null; } export interface TokenUpdateData {