mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 06:52:47 +00:00
🔀 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:
parent
8c71dbcb32
commit
07af6ee288
11 changed files with 1007 additions and 47 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
183
packages/api/src/actions/credentials.spec.ts
Normal file
183
packages/api/src/actions/credentials.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
194
packages/api/src/actions/credentials.ts
Normal file
194
packages/api/src/actions/credentials.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
3
packages/api/src/actions/index.ts
Normal file
3
packages/api/src/actions/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './credentials';
|
||||
export * from './tools';
|
||||
export * from './update';
|
||||
92
packages/api/src/actions/tools.spec.ts
Normal file
92
packages/api/src/actions/tools.spec.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
127
packages/api/src/actions/tools.ts
Normal file
127
packages/api/src/actions/tools.ts
Normal 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}`),
|
||||
);
|
||||
}
|
||||
152
packages/api/src/actions/update.spec.ts
Normal file
152
packages/api/src/actions/update.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
163
packages/api/src/actions/update.ts
Normal file
163
packages/api/src/actions/update.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
|
|
@ -39,6 +39,8 @@ export * from './memory';
|
|||
export * from './modelSpecs';
|
||||
/* Agents */
|
||||
export * from './agents';
|
||||
/* Actions */
|
||||
export * from './actions';
|
||||
/* Prompts */
|
||||
export * from './prompts';
|
||||
/* Projects */
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue