🔀 fix: Reconcile Agent Action Credential Merges (#13559)

* fix: Refine Agent Action Updates

* fix: Format Action Update Helper

* fix: Refine Agent Action Update Handling

* fix: Move Agent Action Update Planning

* fix: Sort Action Update Imports

* chore: Reorder imports in actions.js for clarity
This commit is contained in:
Danny Avila 2026-06-06 15:09:58 -04:00 committed by GitHub
parent 8c71dbcb32
commit 07af6ee288
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 1007 additions and 47 deletions

View file

@ -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) {

View file

@ -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();
});
});

View file

@ -0,0 +1,194 @@
import { AuthTypeEnum, validateAndParseOpenAPISpec } from 'librechat-data-provider';
import type { ActionAuth, ActionMetadata } from 'librechat-data-provider';
const authBoundaryFields: Array<keyof ActionAuth> = [
'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<T extends object, K extends PropertyKey>(
obj: T | null | undefined,
key: K,
): obj is T & Record<K, unknown> {
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,
};
}

View file

@ -0,0 +1,3 @@
export * from './credentials';
export * from './tools';
export * from './update';

View file

@ -0,0 +1,92 @@
jest.mock(
'librechat-data-provider',
() => ({
actionDelimiter: '_action_',
validateAndParseOpenAPISpec: (specString: string) => {
const spec = JSON.parse(specString) as { paths?: Record<string, unknown> };
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']);
});
});

View file

@ -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<string, unknown> {
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<string>();
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<string>();
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 | undefined>): 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<string>();
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}`),
);
}

View file

@ -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);
});
});

View file

@ -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),
};
}

View file

@ -39,6 +39,8 @@ export * from './memory';
export * from './modelSpecs';
/* Agents */
export * from './agents';
/* Actions */
export * from './actions';
/* Prompts */
export * from './prompts';
/* Projects */

View file

@ -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({

View file

@ -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 {