chore: import upstream snapshot with attribution
Publish `@librechat/data-schemas` to NPM / pack (push) Failing after 8s
Publish `@librechat/client` to NPM / pack (push) Failing after 0s
GitNexus Index / index (push) Failing after 1s
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been skipped
Sync Helm Chart Tags / Sync chart tags (push) Failing after 2s
Publish `librechat-data-provider` to NPM / pack (push) Failing after 1s
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Failing after 1s
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Failing after 0s
Sync Helm Chart Tags / Ignore non-main push (push) Has been skipped
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Failing after 1s
Publish `@librechat/client` to NPM / publish-npm (push) Has been cancelled
Publish `librechat-data-provider` to NPM / publish-npm (push) Has been cancelled
GitNexus Index / post-index (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / publish-npm (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / pack (push) Failing after 8s
Publish `@librechat/client` to NPM / pack (push) Failing after 0s
GitNexus Index / index (push) Failing after 1s
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been skipped
Sync Helm Chart Tags / Sync chart tags (push) Failing after 2s
Publish `librechat-data-provider` to NPM / pack (push) Failing after 1s
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Failing after 1s
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Failing after 0s
Sync Helm Chart Tags / Ignore non-main push (push) Has been skipped
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Failing after 1s
Publish `@librechat/client` to NPM / publish-npm (push) Has been cancelled
Publish `librechat-data-provider` to NPM / publish-npm (push) Has been cancelled
GitNexus Index / post-index (push) Has been cancelled
Publish `@librechat/data-schemas` to NPM / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
const cookies = require('cookie');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const openIdClient = require('openid-client');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const {
|
||||
math,
|
||||
isEnabled,
|
||||
findOpenIDUser,
|
||||
getOpenIdIssuer,
|
||||
buildOpenIDRefreshParams,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
requestPasswordReset,
|
||||
setOpenIDAuthTokens,
|
||||
setCloudFrontAuthCookies,
|
||||
resetPassword,
|
||||
setAuthTokens,
|
||||
registerUser,
|
||||
} = require('~/server/services/AuthService');
|
||||
const {
|
||||
deleteAllUserSessions,
|
||||
getUserById,
|
||||
findSession,
|
||||
updateUser,
|
||||
findUser,
|
||||
} = require('~/models');
|
||||
const { getGraphApiToken } = require('~/server/services/GraphTokenService');
|
||||
const { getOpenIdConfig, getOpenIdEmail } = require('~/strategies');
|
||||
|
||||
const AUTH_REFRESH_USER_PROJECTION = '-password -__v -totpSecret -backupCodes -federatedTokens';
|
||||
const OPENID_REUSE_EXPIRY_BUFFER_SECONDS = 30;
|
||||
/**
|
||||
* Max age (ms) LibreChat reuses a cached OpenID session token before forcing an IdP refresh.
|
||||
* Env-overridable (accepts an arithmetic expression, e.g. `60 * 60 * 24 * 1000`, like
|
||||
* `SESSION_EXPIRY`): deployments whose IdP revokes the previous access token on refresh can
|
||||
* widen this to the access-token lifetime so a still-valid token is not rotated/revoked out
|
||||
* from under downstream consumers (e.g. MCP servers that introspect the bearer). Defaults to
|
||||
* 15 minutes.
|
||||
*/
|
||||
const OPENID_REUSE_MAX_SESSION_AGE_MS = math(
|
||||
process.env.OPENID_REUSE_MAX_SESSION_AGE_MS,
|
||||
15 * 60 * 1000,
|
||||
);
|
||||
|
||||
const registrationController = async (req, res) => {
|
||||
try {
|
||||
const response = await registerUser(req.body);
|
||||
const { status, message } = response;
|
||||
res.status(status).send({ message });
|
||||
} catch (err) {
|
||||
logger.error('[registrationController]', err);
|
||||
return res.status(500).json({ message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
const sanitizeUserForAuthResponse = (user) => {
|
||||
const source = (typeof user?.toObject === 'function' ? user.toObject() : user) || {};
|
||||
const {
|
||||
password: _pw,
|
||||
__v: _v,
|
||||
totpSecret: _ts,
|
||||
backupCodes: _bc,
|
||||
federatedTokens: _ft,
|
||||
...safeUser
|
||||
} = source;
|
||||
return safeUser;
|
||||
};
|
||||
|
||||
const getValidOpenIDReuseUserId = (parsedCookies) => {
|
||||
const openidUserId = parsedCookies.openid_user_id;
|
||||
if (!openidUserId || !process.env.JWT_REFRESH_SECRET) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = jwt.verify(openidUserId, process.env.JWT_REFRESH_SECRET);
|
||||
return typeof payload === 'object' && payload != null && typeof payload.id === 'string'
|
||||
? payload.id
|
||||
: null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const isRecentOpenIDSessionRefresh = (openidTokens) => {
|
||||
const lastRefreshedAt = Number(openidTokens?.lastRefreshedAt);
|
||||
const elapsed = Date.now() - lastRefreshedAt;
|
||||
return (
|
||||
Number.isFinite(lastRefreshedAt) && elapsed >= 0 && elapsed <= OPENID_REUSE_MAX_SESSION_AGE_MS
|
||||
);
|
||||
};
|
||||
|
||||
const getReusableOpenIDSessionToken = (openidTokens) => {
|
||||
if (!isRecentOpenIDSessionRefresh(openidTokens)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = [
|
||||
{ token: openidTokens?.idToken, type: 'id_token' },
|
||||
{ token: openidTokens?.accessToken, type: 'access_token' },
|
||||
];
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate.token) {
|
||||
continue;
|
||||
}
|
||||
/** Decode only: tokens are from the trusted server-side session; expiry gates reuse. */
|
||||
const decoded = jwt.decode(candidate.token);
|
||||
if (
|
||||
decoded &&
|
||||
typeof decoded === 'object' &&
|
||||
decoded.exp > now + OPENID_REUSE_EXPIRY_BUFFER_SECONDS
|
||||
) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const resetPasswordRequestController = async (req, res) => {
|
||||
try {
|
||||
const resetService = await requestPasswordReset(req);
|
||||
if (resetService instanceof Error) {
|
||||
return res.status(400).json(resetService);
|
||||
} else {
|
||||
return res.status(200).json(resetService);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('[resetPasswordRequestController]', e);
|
||||
return res.status(400).json({ message: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
const resetPasswordController = async (req, res) => {
|
||||
try {
|
||||
const resetPasswordService = await resetPassword(
|
||||
req.body.userId,
|
||||
req.body.token,
|
||||
req.body.password,
|
||||
);
|
||||
if (resetPasswordService instanceof Error) {
|
||||
return res.status(400).json(resetPasswordService);
|
||||
} else {
|
||||
await deleteAllUserSessions({ userId: req.body.userId });
|
||||
return res.status(200).json(resetPasswordService);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('[resetPasswordController]', e);
|
||||
return res.status(400).json({ message: e.message });
|
||||
}
|
||||
};
|
||||
|
||||
const refreshController = async (req, res) => {
|
||||
const parsedCookies = req.headers.cookie ? cookies.parse(req.headers.cookie) : {};
|
||||
const token_provider = parsedCookies.token_provider;
|
||||
|
||||
if (token_provider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS)) {
|
||||
/** For OpenID users, read refresh token from session to avoid large cookie issues */
|
||||
const refreshToken = req.session?.openidTokens?.refreshToken || parsedCookies.refreshToken;
|
||||
|
||||
if (!refreshToken) {
|
||||
return res.status(200).send('Refresh token not provided');
|
||||
}
|
||||
|
||||
try {
|
||||
/**
|
||||
* Reuse skips an IdP refresh only for recently-refreshed server-side tokens.
|
||||
* Stale, missing, or near-expiry tokens fall through to refreshTokenGrant so
|
||||
* upstream revocations and cookie/session extension are checked regularly.
|
||||
*/
|
||||
const reusableSessionToken = getReusableOpenIDSessionToken(req.session?.openidTokens);
|
||||
const reuseUserId = reusableSessionToken ? getValidOpenIDReuseUserId(parsedCookies) : null;
|
||||
if (reuseUserId) {
|
||||
const user = await getUserById(reuseUserId, AUTH_REFRESH_USER_PROJECTION);
|
||||
if (user) {
|
||||
const cloudFrontCookiesSet = setCloudFrontAuthCookies(req, res, user);
|
||||
logger.debug('[refreshController] OpenID session token reused', {
|
||||
token_type: reusableSessionToken.type,
|
||||
has_id_token: Boolean(req.session?.openidTokens?.idToken),
|
||||
has_access_token: Boolean(req.session?.openidTokens?.accessToken),
|
||||
cloudfront_cookies_set: cloudFrontCookiesSet,
|
||||
});
|
||||
return res.status(200).send({
|
||||
token: reusableSessionToken.token,
|
||||
user: sanitizeUserForAuthResponse(user),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const openIdConfig = getOpenIdConfig();
|
||||
const refreshParams = buildOpenIDRefreshParams();
|
||||
logger.debug('[refreshController] OpenID refresh params', {
|
||||
has_scope: Boolean(process.env.OPENID_SCOPE),
|
||||
has_refresh_audience: Boolean(process.env.OPENID_REFRESH_AUDIENCE),
|
||||
});
|
||||
const tokenset = await openIdClient.refreshTokenGrant(
|
||||
openIdConfig,
|
||||
refreshToken,
|
||||
refreshParams,
|
||||
);
|
||||
logger.debug('[refreshController] OpenID refresh succeeded', {
|
||||
has_access_token: Boolean(tokenset.access_token),
|
||||
has_id_token: Boolean(tokenset.id_token),
|
||||
has_refresh_token: Boolean(tokenset.refresh_token),
|
||||
expires_in: tokenset.expires_in,
|
||||
});
|
||||
const claims = tokenset.claims();
|
||||
const openidIssuer = getOpenIdIssuer(claims, openIdConfig);
|
||||
const { user, error, migration } = await findOpenIDUser({
|
||||
findUser,
|
||||
email: getOpenIdEmail(claims),
|
||||
openidId: claims.sub,
|
||||
openidIssuer,
|
||||
idOnTheSource: claims.oid,
|
||||
strategyName: 'refreshController',
|
||||
});
|
||||
|
||||
logger.debug(
|
||||
`[refreshController] findOpenIDUser result: user=${user?.email ?? 'null'}, error=${error ?? 'null'}, migration=${migration}, userOpenidId=${user?.openidId ?? 'null'}, claimsSub=${claims.sub}`,
|
||||
);
|
||||
|
||||
if (error || !user) {
|
||||
logger.warn(
|
||||
`[refreshController] Redirecting to /login: error=${error ?? 'null'}, user=${user ? 'exists' : 'null'}`,
|
||||
);
|
||||
return res.status(401).redirect('/login');
|
||||
}
|
||||
|
||||
// Handle migration: update user with openidId if found by email without openidId
|
||||
// Also handle case where user has mismatched openidId (e.g., after database switch)
|
||||
if (migration || user.openidId !== claims.sub) {
|
||||
const reason = migration ? 'migration' : 'openidId mismatch';
|
||||
await updateUser(user._id.toString(), {
|
||||
provider: 'openid',
|
||||
openidId: claims.sub,
|
||||
...(openidIssuer ? { openidIssuer } : {}),
|
||||
});
|
||||
logger.info(
|
||||
`[refreshController] Updated user ${user.email} openidId (${reason}): ${user.openidId ?? 'null'} -> ${claims.sub}`,
|
||||
);
|
||||
}
|
||||
|
||||
const token = setOpenIDAuthTokens(tokenset, req, res, {
|
||||
userId: user._id.toString(),
|
||||
existingRefreshToken: refreshToken,
|
||||
tenantId: user.tenantId,
|
||||
});
|
||||
|
||||
return res.status(200).send({ token, user: sanitizeUserForAuthResponse(user) });
|
||||
} catch (error) {
|
||||
logger.error('[refreshController] OpenID token refresh error', error);
|
||||
return res.status(403).send('Invalid OpenID refresh token');
|
||||
}
|
||||
}
|
||||
|
||||
/** For non-OpenID users, read refresh token from cookies */
|
||||
const refreshToken = parsedCookies.refreshToken;
|
||||
if (!refreshToken) {
|
||||
return res.status(200).send('Refresh token not provided');
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
|
||||
const user = await getUserById(payload.id, AUTH_REFRESH_USER_PROJECTION);
|
||||
if (!user) {
|
||||
return res.status(401).redirect('/login');
|
||||
}
|
||||
|
||||
const userId = payload.id;
|
||||
|
||||
if (process.env.NODE_ENV === 'CI') {
|
||||
const token = await setAuthTokens(userId, res, null, req);
|
||||
return res.status(200).send({ token, user: sanitizeUserForAuthResponse(user) });
|
||||
}
|
||||
|
||||
/** Session with the hashed refresh token */
|
||||
const session = await findSession(
|
||||
{
|
||||
userId: userId,
|
||||
refreshToken: refreshToken,
|
||||
},
|
||||
{ lean: false },
|
||||
);
|
||||
|
||||
if (session && session.expiration > new Date()) {
|
||||
const token = await setAuthTokens(userId, res, session, req);
|
||||
|
||||
res.status(200).send({ token, user: sanitizeUserForAuthResponse(user) });
|
||||
} else if (req?.query?.retry) {
|
||||
// Retrying from a refresh token request that failed (401)
|
||||
res.status(403).send('No session found');
|
||||
} else if (payload.exp < Date.now() / 1000) {
|
||||
res.status(403).redirect('/login');
|
||||
} else {
|
||||
res.status(401).send('Refresh token expired or not found for this user');
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`[refreshController] Invalid refresh token:`, err);
|
||||
res.status(403).send('Invalid refresh token');
|
||||
}
|
||||
};
|
||||
|
||||
const graphTokenController = async (req, res) => {
|
||||
try {
|
||||
// Validate user is authenticated via Entra ID
|
||||
if (!req.user.openidId || req.user.provider !== 'openid') {
|
||||
return res.status(403).json({
|
||||
message: 'Microsoft Graph access requires Entra ID authentication',
|
||||
});
|
||||
}
|
||||
|
||||
// Check if OpenID token reuse is active (required for on-behalf-of flow)
|
||||
if (!isEnabled(process.env.OPENID_REUSE_TOKENS)) {
|
||||
return res.status(403).json({
|
||||
message: 'SharePoint integration requires OpenID token reuse to be enabled',
|
||||
});
|
||||
}
|
||||
|
||||
const scopes = req.query.scopes;
|
||||
if (!scopes) {
|
||||
return res.status(400).json({
|
||||
message: 'Graph API scopes are required as query parameter',
|
||||
});
|
||||
}
|
||||
|
||||
const accessToken = req.user.federatedTokens?.access_token;
|
||||
if (!accessToken) {
|
||||
return res.status(401).json({
|
||||
message: 'No federated access token available for token exchange',
|
||||
});
|
||||
}
|
||||
|
||||
const tokenResponse = await getGraphApiToken(req.user, accessToken, scopes);
|
||||
|
||||
res.json(tokenResponse);
|
||||
} catch (error) {
|
||||
logger.error('[graphTokenController] Failed to obtain Graph API token:', error);
|
||||
res.status(500).json({
|
||||
message: 'Failed to obtain Microsoft Graph token',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
refreshController,
|
||||
registrationController,
|
||||
resetPasswordController,
|
||||
resetPasswordRequestController,
|
||||
graphTokenController,
|
||||
};
|
||||
@@ -0,0 +1,842 @@
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { error: jest.fn(), debug: jest.fn(), warn: jest.fn(), info: jest.fn() },
|
||||
}));
|
||||
jest.mock('~/server/services/GraphTokenService', () => ({
|
||||
getGraphApiToken: jest.fn(),
|
||||
}));
|
||||
jest.mock('~/server/services/AuthService', () => ({
|
||||
requestPasswordReset: jest.fn(),
|
||||
setOpenIDAuthTokens: jest.fn(),
|
||||
setCloudFrontAuthCookies: jest.fn(),
|
||||
resetPassword: jest.fn(),
|
||||
setAuthTokens: jest.fn(),
|
||||
registerUser: jest.fn(),
|
||||
}));
|
||||
jest.mock('~/strategies', () => ({ getOpenIdConfig: jest.fn(), getOpenIdEmail: jest.fn() }));
|
||||
jest.mock('openid-client', () => ({ refreshTokenGrant: jest.fn() }));
|
||||
jest.mock('~/models', () => ({
|
||||
deleteAllUserSessions: jest.fn(),
|
||||
getUserById: jest.fn(),
|
||||
findSession: jest.fn(),
|
||||
updateUser: jest.fn(),
|
||||
findUser: jest.fn(),
|
||||
}));
|
||||
jest.mock('@librechat/api', () => ({
|
||||
math: jest.fn((value, fallback) => fallback),
|
||||
isEnabled: jest.fn(),
|
||||
findOpenIDUser: jest.fn(),
|
||||
getOpenIdIssuer: jest.fn(() => 'https://issuer.example.com'),
|
||||
buildOpenIDRefreshParams: jest.fn(() => {
|
||||
const params = {};
|
||||
if (process.env.OPENID_SCOPE) {
|
||||
params.scope = process.env.OPENID_SCOPE;
|
||||
}
|
||||
if (process.env.OPENID_REFRESH_AUDIENCE) {
|
||||
params.audience = process.env.OPENID_REFRESH_AUDIENCE;
|
||||
}
|
||||
return params;
|
||||
}),
|
||||
}));
|
||||
|
||||
const openIdClient = require('openid-client');
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { isEnabled, findOpenIDUser, buildOpenIDRefreshParams } = require('@librechat/api');
|
||||
const { graphTokenController, refreshController } = require('./AuthController');
|
||||
const { getGraphApiToken } = require('~/server/services/GraphTokenService');
|
||||
const {
|
||||
setOpenIDAuthTokens,
|
||||
setCloudFrontAuthCookies,
|
||||
setAuthTokens,
|
||||
} = require('~/server/services/AuthService');
|
||||
const { getOpenIdConfig, getOpenIdEmail } = require('~/strategies');
|
||||
const { getUserById, findSession, updateUser } = require('~/models');
|
||||
|
||||
const ORIGINAL_OPENID_SCOPE = process.env.OPENID_SCOPE;
|
||||
const ORIGINAL_OPENID_REFRESH_AUDIENCE = process.env.OPENID_REFRESH_AUDIENCE;
|
||||
const ORIGINAL_JWT_REFRESH_SECRET = process.env.JWT_REFRESH_SECRET;
|
||||
const ORIGINAL_NODE_ENV = process.env.NODE_ENV;
|
||||
|
||||
describe('graphTokenController', () => {
|
||||
let req, res;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
isEnabled.mockReturnValue(true);
|
||||
|
||||
req = {
|
||||
user: {
|
||||
openidId: 'oid-123',
|
||||
provider: 'openid',
|
||||
federatedTokens: {
|
||||
access_token: 'federated-access-token',
|
||||
id_token: 'federated-id-token',
|
||||
},
|
||||
},
|
||||
headers: { authorization: 'Bearer app-jwt-which-is-id-token' },
|
||||
query: { scopes: 'https://graph.microsoft.com/.default' },
|
||||
};
|
||||
|
||||
res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn(),
|
||||
};
|
||||
|
||||
getGraphApiToken.mockResolvedValue({
|
||||
access_token: 'graph-access-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass federatedTokens.access_token as OBO assertion, not the auth header bearer token', async () => {
|
||||
await graphTokenController(req, res);
|
||||
|
||||
expect(getGraphApiToken).toHaveBeenCalledWith(
|
||||
req.user,
|
||||
'federated-access-token',
|
||||
'https://graph.microsoft.com/.default',
|
||||
);
|
||||
expect(getGraphApiToken).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'app-jwt-which-is-id-token',
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return the graph token response on success', async () => {
|
||||
await graphTokenController(req, res);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
access_token: 'graph-access-token',
|
||||
token_type: 'Bearer',
|
||||
expires_in: 3600,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return 403 when user is not authenticated via Entra ID', async () => {
|
||||
req.user.provider = 'google';
|
||||
req.user.openidId = undefined;
|
||||
|
||||
await graphTokenController(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(getGraphApiToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 403 when OPENID_REUSE_TOKENS is not enabled', async () => {
|
||||
isEnabled.mockReturnValue(false);
|
||||
|
||||
await graphTokenController(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(getGraphApiToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 400 when scopes query param is missing', async () => {
|
||||
req.query.scopes = undefined;
|
||||
|
||||
await graphTokenController(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(getGraphApiToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 401 when federatedTokens.access_token is missing', async () => {
|
||||
req.user.federatedTokens = {};
|
||||
|
||||
await graphTokenController(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(getGraphApiToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 401 when federatedTokens is absent entirely', async () => {
|
||||
req.user.federatedTokens = undefined;
|
||||
|
||||
await graphTokenController(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(getGraphApiToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 500 when getGraphApiToken throws', async () => {
|
||||
getGraphApiToken.mockRejectedValue(new Error('OBO exchange failed'));
|
||||
|
||||
await graphTokenController(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
message: 'Failed to obtain Microsoft Graph token',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshController – OpenID path', () => {
|
||||
const mockTokenset = {
|
||||
claims: jest.fn(),
|
||||
access_token: 'new-access',
|
||||
id_token: 'new-id',
|
||||
refresh_token: 'new-refresh',
|
||||
expires_in: 3600,
|
||||
};
|
||||
|
||||
const baseClaims = {
|
||||
iss: 'https://issuer.example.com',
|
||||
sub: 'oidc-sub-123',
|
||||
oid: 'oid-456',
|
||||
email: 'user@example.com',
|
||||
exp: 9999999999,
|
||||
};
|
||||
|
||||
const defaultUser = {
|
||||
_id: 'user-db-id',
|
||||
email: baseClaims.email,
|
||||
openidId: baseClaims.sub,
|
||||
password: '$2b$10$hashedpassword',
|
||||
__v: 0,
|
||||
totpSecret: 'encrypted-totp-secret',
|
||||
backupCodes: ['hashed-code-1', 'hashed-code-2'],
|
||||
};
|
||||
|
||||
let req, res;
|
||||
const idpSigningSecret = 'idp-signing-secret';
|
||||
|
||||
const makeSessionToken = (claims = {}) =>
|
||||
jwt.sign(
|
||||
{
|
||||
sub: baseClaims.sub,
|
||||
exp: Math.floor(Date.now() / 1000) + 3600,
|
||||
...claims,
|
||||
},
|
||||
idpSigningSecret,
|
||||
);
|
||||
|
||||
const makeSignedUserId = (id = 'user-db-id', options = { expiresIn: '1h' }) =>
|
||||
jwt.sign({ id }, process.env.JWT_REFRESH_SECRET, options);
|
||||
|
||||
const setOpenIDReuseCookies = (signedUserId = makeSignedUserId()) => {
|
||||
req.headers.cookie = [
|
||||
'token_provider=openid',
|
||||
'refreshToken=stored-refresh',
|
||||
`openid_user_id=${signedUserId}`,
|
||||
].join('; ');
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
delete process.env.OPENID_SCOPE;
|
||||
delete process.env.OPENID_REFRESH_AUDIENCE;
|
||||
process.env.JWT_REFRESH_SECRET = 'test-refresh-secret';
|
||||
|
||||
isEnabled.mockReturnValue(true);
|
||||
getOpenIdConfig.mockReturnValue({ some: 'config' });
|
||||
openIdClient.refreshTokenGrant.mockResolvedValue(mockTokenset);
|
||||
mockTokenset.claims.mockReturnValue(baseClaims);
|
||||
getOpenIdEmail.mockReturnValue(baseClaims.email);
|
||||
setOpenIDAuthTokens.mockReturnValue('new-app-token');
|
||||
setCloudFrontAuthCookies.mockReturnValue(true);
|
||||
findOpenIDUser.mockResolvedValue({ user: { ...defaultUser }, error: null, migration: false });
|
||||
getUserById.mockResolvedValue({
|
||||
_id: 'user-db-id',
|
||||
email: baseClaims.email,
|
||||
openidId: baseClaims.sub,
|
||||
});
|
||||
updateUser.mockResolvedValue({});
|
||||
|
||||
req = {
|
||||
headers: { cookie: 'token_provider=openid; refreshToken=stored-refresh' },
|
||||
session: {},
|
||||
};
|
||||
|
||||
res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
redirect: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (ORIGINAL_OPENID_SCOPE === undefined) {
|
||||
delete process.env.OPENID_SCOPE;
|
||||
} else {
|
||||
process.env.OPENID_SCOPE = ORIGINAL_OPENID_SCOPE;
|
||||
}
|
||||
|
||||
if (ORIGINAL_OPENID_REFRESH_AUDIENCE === undefined) {
|
||||
delete process.env.OPENID_REFRESH_AUDIENCE;
|
||||
} else {
|
||||
process.env.OPENID_REFRESH_AUDIENCE = ORIGINAL_OPENID_REFRESH_AUDIENCE;
|
||||
}
|
||||
|
||||
if (ORIGINAL_JWT_REFRESH_SECRET === undefined) {
|
||||
delete process.env.JWT_REFRESH_SECRET;
|
||||
} else {
|
||||
process.env.JWT_REFRESH_SECRET = ORIGINAL_JWT_REFRESH_SECRET;
|
||||
}
|
||||
});
|
||||
|
||||
/** Asserts the full OpenID refresh grant was triggered using default mock state. */
|
||||
const expectOpenIDRefreshGrant = () => {
|
||||
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith(
|
||||
{ some: 'config' },
|
||||
'stored-refresh',
|
||||
{},
|
||||
);
|
||||
expect(setOpenIDAuthTokens).toHaveBeenCalledWith(mockTokenset, req, res, {
|
||||
userId: 'user-db-id',
|
||||
existingRefreshToken: 'stored-refresh',
|
||||
tenantId: undefined,
|
||||
});
|
||||
};
|
||||
|
||||
it('should call getOpenIdEmail with token claims and use result for findOpenIDUser', async () => {
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(buildOpenIDRefreshParams).toHaveBeenCalledTimes(1);
|
||||
expect(getOpenIdEmail).toHaveBeenCalledWith(baseClaims);
|
||||
expect(findOpenIDUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
email: baseClaims.email,
|
||||
openidIssuer: baseClaims.iss,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
|
||||
it('reuses valid OpenID session tokens and refreshes CloudFront cookies', async () => {
|
||||
const reusableIdToken = makeSessionToken();
|
||||
const signedUserId = makeSignedUserId();
|
||||
setOpenIDReuseCookies(signedUserId);
|
||||
req.session = {
|
||||
openidTokens: {
|
||||
accessToken: 'session-access-token',
|
||||
idToken: reusableIdToken,
|
||||
refreshToken: 'stored-refresh',
|
||||
lastRefreshedAt: Date.now(),
|
||||
},
|
||||
};
|
||||
const user = {
|
||||
...defaultUser,
|
||||
federatedTokens: { access_token: 'do-not-return' },
|
||||
};
|
||||
getUserById.mockResolvedValue(user);
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
|
||||
expect(setOpenIDAuthTokens).not.toHaveBeenCalled();
|
||||
expect(getUserById).toHaveBeenCalledWith(
|
||||
'user-db-id',
|
||||
'-password -__v -totpSecret -backupCodes -federatedTokens',
|
||||
);
|
||||
expect(setCloudFrontAuthCookies).toHaveBeenCalledWith(req, res, user);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.send).toHaveBeenCalledWith({
|
||||
token: reusableIdToken,
|
||||
user: expect.objectContaining({
|
||||
_id: 'user-db-id',
|
||||
email: baseClaims.email,
|
||||
openidId: baseClaims.sub,
|
||||
}),
|
||||
});
|
||||
|
||||
const sentPayload = res.send.mock.calls[0][0];
|
||||
expect(sentPayload.user).not.toHaveProperty('password');
|
||||
expect(sentPayload.user).not.toHaveProperty('totpSecret');
|
||||
expect(sentPayload.user).not.toHaveProperty('backupCodes');
|
||||
expect(sentPayload.user).not.toHaveProperty('federatedTokens');
|
||||
expect(logger.debug).toHaveBeenCalledWith(
|
||||
'[refreshController] OpenID session token reused',
|
||||
expect.objectContaining({
|
||||
token_type: 'id_token',
|
||||
cloudfront_cookies_set: true,
|
||||
}),
|
||||
);
|
||||
const debugOutput = JSON.stringify(logger.debug.mock.calls);
|
||||
expect(debugOutput).not.toContain(reusableIdToken);
|
||||
expect(debugOutput).not.toContain(signedUserId);
|
||||
expect(debugOutput).not.toContain('session-access-token');
|
||||
});
|
||||
|
||||
it('falls through to full OpenID refresh when session tokens are expired', async () => {
|
||||
const expiredToken = makeSessionToken({ exp: Math.floor(Date.now() / 1000) - 60 });
|
||||
setOpenIDReuseCookies();
|
||||
req.session = {
|
||||
openidTokens: {
|
||||
accessToken: expiredToken,
|
||||
idToken: expiredToken,
|
||||
refreshToken: 'stored-refresh',
|
||||
lastRefreshedAt: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(getUserById).not.toHaveBeenCalled();
|
||||
expect(setCloudFrontAuthCookies).not.toHaveBeenCalled();
|
||||
expectOpenIDRefreshGrant();
|
||||
});
|
||||
|
||||
it('falls through to full OpenID refresh when session tokens are near expiry', async () => {
|
||||
const nearExpiryToken = makeSessionToken({ exp: Math.floor(Date.now() / 1000) + 5 });
|
||||
setOpenIDReuseCookies();
|
||||
req.session = {
|
||||
openidTokens: {
|
||||
accessToken: nearExpiryToken,
|
||||
idToken: nearExpiryToken,
|
||||
refreshToken: 'stored-refresh',
|
||||
lastRefreshedAt: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(getUserById).not.toHaveBeenCalled();
|
||||
expectOpenIDRefreshGrant();
|
||||
});
|
||||
|
||||
it('falls through to full OpenID refresh when session tokens have no exp claim', async () => {
|
||||
const tokenWithoutExp = jwt.sign({ sub: baseClaims.sub }, idpSigningSecret);
|
||||
setOpenIDReuseCookies();
|
||||
req.session = {
|
||||
openidTokens: {
|
||||
accessToken: tokenWithoutExp,
|
||||
idToken: tokenWithoutExp,
|
||||
refreshToken: 'stored-refresh',
|
||||
lastRefreshedAt: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(getUserById).not.toHaveBeenCalled();
|
||||
expectOpenIDRefreshGrant();
|
||||
});
|
||||
|
||||
it('falls through to full OpenID refresh when the signed reuse user cookie is invalid', async () => {
|
||||
setOpenIDReuseCookies('tampered-cookie');
|
||||
req.session = {
|
||||
openidTokens: {
|
||||
accessToken: 'session-access-token',
|
||||
idToken: makeSessionToken(),
|
||||
refreshToken: 'stored-refresh',
|
||||
lastRefreshedAt: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(getUserById).not.toHaveBeenCalled();
|
||||
expectOpenIDRefreshGrant();
|
||||
});
|
||||
|
||||
it('falls through to full OpenID refresh when the reuse user no longer exists', async () => {
|
||||
setOpenIDReuseCookies();
|
||||
req.session = {
|
||||
openidTokens: {
|
||||
accessToken: 'session-access-token',
|
||||
idToken: makeSessionToken(),
|
||||
refreshToken: 'stored-refresh',
|
||||
lastRefreshedAt: Date.now(),
|
||||
},
|
||||
};
|
||||
getUserById.mockResolvedValueOnce(null);
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(getUserById).toHaveBeenCalledWith(
|
||||
'user-db-id',
|
||||
'-password -__v -totpSecret -backupCodes -federatedTokens',
|
||||
);
|
||||
expect(setCloudFrontAuthCookies).not.toHaveBeenCalled();
|
||||
expectOpenIDRefreshGrant();
|
||||
});
|
||||
|
||||
it('falls through to full OpenID refresh when session tokens are stale', async () => {
|
||||
setOpenIDReuseCookies();
|
||||
req.session = {
|
||||
openidTokens: {
|
||||
accessToken: 'session-access-token',
|
||||
idToken: makeSessionToken(),
|
||||
refreshToken: 'stored-refresh',
|
||||
lastRefreshedAt: Date.now() - 16 * 60 * 1000,
|
||||
},
|
||||
};
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(getUserById).not.toHaveBeenCalled();
|
||||
expectOpenIDRefreshGrant();
|
||||
});
|
||||
|
||||
it('falls through to full OpenID refresh when session refresh timestamp is in the future', async () => {
|
||||
setOpenIDReuseCookies();
|
||||
req.session = {
|
||||
openidTokens: {
|
||||
accessToken: 'session-access-token',
|
||||
idToken: makeSessionToken(),
|
||||
refreshToken: 'stored-refresh',
|
||||
lastRefreshedAt: Date.now() + 60 * 1000,
|
||||
},
|
||||
};
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(getUserById).not.toHaveBeenCalled();
|
||||
expectOpenIDRefreshGrant();
|
||||
});
|
||||
|
||||
it('falls through to full OpenID refresh for pre-upgrade sessions without lastRefreshedAt', async () => {
|
||||
setOpenIDReuseCookies();
|
||||
req.session = {
|
||||
openidTokens: {
|
||||
accessToken: 'session-access-token',
|
||||
idToken: makeSessionToken(),
|
||||
refreshToken: 'stored-refresh',
|
||||
},
|
||||
};
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(getUserById).not.toHaveBeenCalled();
|
||||
expectOpenIDRefreshGrant();
|
||||
});
|
||||
|
||||
it('sanitizes Mongoose-style user documents on the OpenID reuse path', async () => {
|
||||
const reusableIdToken = makeSessionToken();
|
||||
setOpenIDReuseCookies();
|
||||
req.session = {
|
||||
openidTokens: {
|
||||
accessToken: 'session-access-token',
|
||||
idToken: reusableIdToken,
|
||||
refreshToken: 'stored-refresh',
|
||||
lastRefreshedAt: Date.now(),
|
||||
},
|
||||
};
|
||||
const userDocument = {
|
||||
toObject: () => ({
|
||||
...defaultUser,
|
||||
federatedTokens: { access_token: 'do-not-return' },
|
||||
}),
|
||||
};
|
||||
getUserById.mockResolvedValue(userDocument);
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
const sentPayload = res.send.mock.calls[0][0];
|
||||
expect(setCloudFrontAuthCookies).toHaveBeenCalledWith(req, res, userDocument);
|
||||
expect(sentPayload).toEqual({
|
||||
token: reusableIdToken,
|
||||
user: expect.objectContaining({
|
||||
_id: 'user-db-id',
|
||||
email: baseClaims.email,
|
||||
}),
|
||||
});
|
||||
expect(sentPayload.user).not.toHaveProperty('password');
|
||||
expect(sentPayload.user).not.toHaveProperty('federatedTokens');
|
||||
});
|
||||
|
||||
it('should pass scope-only OpenID refresh params when OPENID_SCOPE is set', async () => {
|
||||
process.env.OPENID_SCOPE = 'openid profile email';
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith(
|
||||
{ some: 'config' },
|
||||
'stored-refresh',
|
||||
{ scope: 'openid profile email' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass scope and audience OpenID refresh params when both are set', async () => {
|
||||
process.env.OPENID_SCOPE = 'openid profile email';
|
||||
process.env.OPENID_REFRESH_AUDIENCE = 'https://api.example.com';
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith(
|
||||
{ some: 'config' },
|
||||
'stored-refresh',
|
||||
{
|
||||
scope: 'openid profile email',
|
||||
audience: 'https://api.example.com',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass audience-only OpenID refresh params when scope is unset', async () => {
|
||||
process.env.OPENID_REFRESH_AUDIENCE = 'https://api.example.com';
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith(
|
||||
{ some: 'config' },
|
||||
'stored-refresh',
|
||||
{ audience: 'https://api.example.com' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should omit empty OpenID refresh audience', async () => {
|
||||
process.env.OPENID_SCOPE = 'openid profile email';
|
||||
process.env.OPENID_REFRESH_AUDIENCE = '';
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(openIdClient.refreshTokenGrant).toHaveBeenCalledWith(
|
||||
{ some: 'config' },
|
||||
'stored-refresh',
|
||||
{ scope: 'openid profile email' },
|
||||
);
|
||||
});
|
||||
|
||||
it('should keep OpenID refresh diagnostics free of token and audience values', async () => {
|
||||
process.env.OPENID_SCOPE = 'openid profile email';
|
||||
process.env.OPENID_REFRESH_AUDIENCE = 'https://api.example.com';
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(logger.debug).toHaveBeenCalledWith('[refreshController] OpenID refresh params', {
|
||||
has_scope: true,
|
||||
has_refresh_audience: true,
|
||||
});
|
||||
expect(logger.debug).toHaveBeenCalledWith('[refreshController] OpenID refresh succeeded', {
|
||||
has_access_token: true,
|
||||
has_id_token: true,
|
||||
has_refresh_token: true,
|
||||
expires_in: 3600,
|
||||
});
|
||||
const debugOutput = JSON.stringify(logger.debug.mock.calls);
|
||||
expect(debugOutput).not.toContain('stored-refresh');
|
||||
expect(debugOutput).not.toContain('new-access');
|
||||
expect(debugOutput).not.toContain('new-id');
|
||||
expect(debugOutput).not.toContain('new-refresh');
|
||||
expect(debugOutput).not.toContain('https://api.example.com');
|
||||
});
|
||||
|
||||
it('should use OPENID_EMAIL_CLAIM-resolved value when claim is present in token', async () => {
|
||||
const claimsWithUpn = { ...baseClaims, upn: 'user@corp.example.com' };
|
||||
mockTokenset.claims.mockReturnValue(claimsWithUpn);
|
||||
getOpenIdEmail.mockReturnValue('user@corp.example.com');
|
||||
|
||||
const user = {
|
||||
_id: 'user-db-id',
|
||||
email: 'user@corp.example.com',
|
||||
openidId: baseClaims.sub,
|
||||
};
|
||||
findOpenIDUser.mockResolvedValue({ user, error: null, migration: false });
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(getOpenIdEmail).toHaveBeenCalledWith(claimsWithUpn);
|
||||
expect(findOpenIDUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
email: 'user@corp.example.com',
|
||||
openidIssuer: baseClaims.iss,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
|
||||
it('should fall back to claims.email when configured claim is absent from token claims', async () => {
|
||||
getOpenIdEmail.mockReturnValue(baseClaims.email);
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(findOpenIDUser).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
email: baseClaims.email,
|
||||
openidIssuer: baseClaims.iss,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not expose sensitive fields or federatedTokens in refresh response', async () => {
|
||||
await refreshController(req, res);
|
||||
|
||||
const sentPayload = res.send.mock.calls[0][0];
|
||||
expect(sentPayload).toEqual({
|
||||
token: 'new-app-token',
|
||||
user: expect.objectContaining({
|
||||
_id: 'user-db-id',
|
||||
email: baseClaims.email,
|
||||
openidId: baseClaims.sub,
|
||||
}),
|
||||
});
|
||||
expect(sentPayload.user).not.toHaveProperty('federatedTokens');
|
||||
expect(sentPayload.user).not.toHaveProperty('password');
|
||||
expect(sentPayload.user).not.toHaveProperty('totpSecret');
|
||||
expect(sentPayload.user).not.toHaveProperty('backupCodes');
|
||||
expect(sentPayload.user).not.toHaveProperty('__v');
|
||||
});
|
||||
|
||||
it('should update openidId when migration is triggered on refresh', async () => {
|
||||
const user = { _id: 'user-db-id', email: baseClaims.email, openidId: null };
|
||||
findOpenIDUser.mockResolvedValue({ user, error: null, migration: true });
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(updateUser).toHaveBeenCalledWith(
|
||||
'user-db-id',
|
||||
expect.objectContaining({
|
||||
provider: 'openid',
|
||||
openidId: baseClaims.sub,
|
||||
openidIssuer: baseClaims.iss,
|
||||
}),
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
|
||||
it('should return 401 and redirect to /login when findOpenIDUser returns no user', async () => {
|
||||
findOpenIDUser.mockResolvedValue({ user: null, error: null, migration: false });
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.redirect).toHaveBeenCalledWith('/login');
|
||||
});
|
||||
|
||||
it('should return 401 and redirect when findOpenIDUser returns an error', async () => {
|
||||
findOpenIDUser.mockResolvedValue({ user: null, error: 'AUTH_FAILED', migration: false });
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.redirect).toHaveBeenCalledWith('/login');
|
||||
});
|
||||
|
||||
it('should preserve invalid OpenID refresh token behavior', async () => {
|
||||
openIdClient.refreshTokenGrant.mockRejectedValue(new Error('invalid_grant'));
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.send).toHaveBeenCalledWith('Invalid OpenID refresh token');
|
||||
});
|
||||
|
||||
it('should skip OpenID path when token_provider is not openid', async () => {
|
||||
req.headers.cookie = 'token_provider=local; refreshToken=some-token';
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should skip OpenID path when OPENID_REUSE_TOKENS is disabled', async () => {
|
||||
isEnabled.mockReturnValue(false);
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(openIdClient.refreshTokenGrant).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 200 with token not provided when refresh token is absent', async () => {
|
||||
req.headers.cookie = 'token_provider=openid';
|
||||
req.session = {};
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.send).toHaveBeenCalledWith('Refresh token not provided');
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshController – LibreChat path', () => {
|
||||
let req, res;
|
||||
const refreshSecret = 'test-refresh-secret';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env.JWT_REFRESH_SECRET = refreshSecret;
|
||||
process.env.NODE_ENV = 'test';
|
||||
setAuthTokens.mockResolvedValue('local-app-token');
|
||||
findSession.mockResolvedValue({ expiration: new Date(Date.now() + 60_000) });
|
||||
|
||||
const refreshToken = jwt.sign({ id: 'local-user-id' }, refreshSecret, {
|
||||
expiresIn: '1h',
|
||||
});
|
||||
req = {
|
||||
headers: { cookie: `refreshToken=${refreshToken}` },
|
||||
query: {},
|
||||
session: {},
|
||||
};
|
||||
res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
redirect: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (ORIGINAL_JWT_REFRESH_SECRET === undefined) {
|
||||
delete process.env.JWT_REFRESH_SECRET;
|
||||
} else {
|
||||
process.env.JWT_REFRESH_SECRET = ORIGINAL_JWT_REFRESH_SECRET;
|
||||
}
|
||||
|
||||
if (ORIGINAL_NODE_ENV === undefined) {
|
||||
delete process.env.NODE_ENV;
|
||||
} else {
|
||||
process.env.NODE_ENV = ORIGINAL_NODE_ENV;
|
||||
}
|
||||
});
|
||||
|
||||
it('sanitizes user documents before returning local refresh responses', async () => {
|
||||
getUserById.mockResolvedValue({
|
||||
toObject: () => ({
|
||||
_id: 'local-user-id',
|
||||
email: 'local@example.com',
|
||||
password: 'hashed-password',
|
||||
__v: 1,
|
||||
totpSecret: 'totp-secret',
|
||||
backupCodes: ['backup-code'],
|
||||
federatedTokens: { access_token: 'do-not-return' },
|
||||
}),
|
||||
});
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
const sentPayload = res.send.mock.calls[0][0];
|
||||
expect(setAuthTokens).toHaveBeenCalledWith(
|
||||
'local-user-id',
|
||||
res,
|
||||
{ expiration: expect.any(Date) },
|
||||
req,
|
||||
);
|
||||
expect(sentPayload).toEqual({
|
||||
token: 'local-app-token',
|
||||
user: {
|
||||
_id: 'local-user-id',
|
||||
email: 'local@example.com',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('sanitizes user documents before returning CI refresh responses', async () => {
|
||||
process.env.NODE_ENV = 'CI';
|
||||
getUserById.mockResolvedValue({
|
||||
toObject: () => ({
|
||||
_id: 'local-user-id',
|
||||
email: 'local@example.com',
|
||||
password: 'hashed-password',
|
||||
__v: 1,
|
||||
totpSecret: 'totp-secret',
|
||||
backupCodes: ['backup-code'],
|
||||
federatedTokens: { access_token: 'do-not-return' },
|
||||
}),
|
||||
});
|
||||
|
||||
await refreshController(req, res);
|
||||
|
||||
const sentPayload = res.send.mock.calls[0][0];
|
||||
expect(findSession).not.toHaveBeenCalled();
|
||||
expect(setAuthTokens).toHaveBeenCalledWith('local-user-id', res, null, req);
|
||||
expect(sentPayload).toEqual({
|
||||
token: 'local-app-token',
|
||||
user: {
|
||||
_id: 'local-user-id',
|
||||
email: 'local@example.com',
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
const { findBalanceByUser } = require('~/models');
|
||||
|
||||
async function balanceController(req, res) {
|
||||
const balanceLocals = res.locals || {};
|
||||
|
||||
if (balanceLocals.balanceConfigEnabled === false) {
|
||||
return res.sendStatus(204);
|
||||
}
|
||||
|
||||
const balanceData = balanceLocals.balanceData ?? (await findBalanceByUser(req.user.id));
|
||||
|
||||
if (!balanceData) {
|
||||
return res.status(404).json({ error: 'Balance not found' });
|
||||
}
|
||||
|
||||
const { _id: _, ...result } = balanceData;
|
||||
|
||||
if (!result.autoRefillEnabled) {
|
||||
delete result.refillIntervalValue;
|
||||
delete result.refillIntervalUnit;
|
||||
delete result.lastRefill;
|
||||
delete result.refillAmount;
|
||||
}
|
||||
|
||||
res.status(200).json(result);
|
||||
}
|
||||
|
||||
module.exports = balanceController;
|
||||
@@ -0,0 +1,72 @@
|
||||
jest.mock('~/models', () => ({
|
||||
findBalanceByUser: jest.fn(),
|
||||
}));
|
||||
|
||||
const { findBalanceByUser } = require('~/models');
|
||||
const balanceController = require('./Balance');
|
||||
|
||||
describe('balanceController', () => {
|
||||
const createResponse = () => ({
|
||||
locals: {},
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
sendStatus: jest.fn().mockReturnThis(),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns no content without reading balance when balance config is disabled', async () => {
|
||||
const req = {
|
||||
user: { id: 'user-1' },
|
||||
};
|
||||
const res = createResponse();
|
||||
res.locals.balanceConfigEnabled = false;
|
||||
|
||||
await balanceController(req, res);
|
||||
|
||||
expect(findBalanceByUser).not.toHaveBeenCalled();
|
||||
expect(res.sendStatus).toHaveBeenCalledWith(204);
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses balance data attached by middleware without a second read', async () => {
|
||||
const req = {
|
||||
user: { id: 'user-1' },
|
||||
};
|
||||
const res = createResponse();
|
||||
res.locals.balanceConfigEnabled = true;
|
||||
res.locals.balanceData = {
|
||||
_id: 'balance-1',
|
||||
user: 'user-1',
|
||||
tokenCredits: 100,
|
||||
autoRefillEnabled: false,
|
||||
};
|
||||
|
||||
await balanceController(req, res);
|
||||
|
||||
expect(findBalanceByUser).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
user: 'user-1',
|
||||
tokenCredits: 100,
|
||||
autoRefillEnabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns not found when balance is enabled and no record exists', async () => {
|
||||
findBalanceByUser.mockResolvedValue(null);
|
||||
const req = {
|
||||
user: { id: 'user-1' },
|
||||
};
|
||||
const res = createResponse();
|
||||
res.locals.balanceConfigEnabled = true;
|
||||
|
||||
await balanceController(req, res);
|
||||
|
||||
expect(findBalanceByUser).toHaveBeenCalledWith('user-1');
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Balance not found' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
const { getEndpointsConfig } = require('~/server/services/Config');
|
||||
|
||||
async function endpointController(req, res) {
|
||||
const endpointsConfig = await getEndpointsConfig(req);
|
||||
res.send(JSON.stringify(endpointsConfig));
|
||||
}
|
||||
|
||||
module.exports = endpointController;
|
||||
@@ -0,0 +1,128 @@
|
||||
const { updateUser, getUserById } = require('~/models');
|
||||
|
||||
const MAX_FAVORITES = 50;
|
||||
const MAX_STRING_LENGTH = 256;
|
||||
|
||||
const updateFavoritesController = async (req, res) => {
|
||||
try {
|
||||
const { favorites } = req.body;
|
||||
const userId = req.user.id;
|
||||
|
||||
if (!favorites) {
|
||||
return res.status(400).json({ message: 'Favorites data is required' });
|
||||
}
|
||||
|
||||
if (!Array.isArray(favorites)) {
|
||||
return res.status(400).json({ message: 'Favorites must be an array' });
|
||||
}
|
||||
|
||||
if (favorites.length > MAX_FAVORITES) {
|
||||
return res.status(400).json({
|
||||
code: 'MAX_FAVORITES_EXCEEDED',
|
||||
message: `Maximum ${MAX_FAVORITES} favorites allowed`,
|
||||
limit: MAX_FAVORITES,
|
||||
});
|
||||
}
|
||||
|
||||
for (const fav of favorites) {
|
||||
const hasAgent = !!fav.agentId;
|
||||
const hasModel = !!(fav.model && fav.endpoint);
|
||||
const hasSpec = !!fav.spec;
|
||||
|
||||
if (fav.agentId && fav.agentId.length > MAX_STRING_LENGTH) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: `agentId exceeds maximum length of ${MAX_STRING_LENGTH}` });
|
||||
}
|
||||
if (fav.model && fav.model.length > MAX_STRING_LENGTH) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: `model exceeds maximum length of ${MAX_STRING_LENGTH}` });
|
||||
}
|
||||
if (fav.endpoint && fav.endpoint.length > MAX_STRING_LENGTH) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: `endpoint exceeds maximum length of ${MAX_STRING_LENGTH}` });
|
||||
}
|
||||
if (fav.spec !== undefined && fav.spec !== null) {
|
||||
if (typeof fav.spec !== 'string' || fav.spec.length === 0) {
|
||||
return res.status(400).json({ message: 'spec must be a non-empty string' });
|
||||
}
|
||||
if (fav.spec.length > MAX_STRING_LENGTH) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: `spec exceeds maximum length of ${MAX_STRING_LENGTH}` });
|
||||
}
|
||||
}
|
||||
|
||||
const hasPartialModel = !hasModel && !!(fav.model || fav.endpoint);
|
||||
|
||||
if (hasPartialModel && !hasAgent && !hasSpec) {
|
||||
return res.status(400).json({ message: 'model and endpoint must be provided together' });
|
||||
}
|
||||
|
||||
const typeCount = [hasAgent, hasModel, hasSpec].filter(Boolean).length;
|
||||
if (typeCount === 0) {
|
||||
return res.status(400).json({
|
||||
message: 'Each favorite must have either agentId, model+endpoint, or spec',
|
||||
});
|
||||
}
|
||||
|
||||
if (typeCount > 1) {
|
||||
return res.status(400).json({
|
||||
message: 'Favorite cannot have multiple types (agentId, model/endpoint, or spec)',
|
||||
});
|
||||
}
|
||||
|
||||
if (hasSpec && (fav.agentId || fav.model || fav.endpoint)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: 'spec cannot be combined with agentId, model, or endpoint' });
|
||||
}
|
||||
if (hasAgent && (fav.model || fav.endpoint)) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ message: 'agentId cannot be combined with model or endpoint' });
|
||||
}
|
||||
}
|
||||
|
||||
const user = await updateUser(userId, { favorites });
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'User not found' });
|
||||
}
|
||||
|
||||
return res.status(200).json(user.favorites);
|
||||
} catch (error) {
|
||||
console.error('Error updating favorites:', error);
|
||||
return res.status(500).json({ message: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
const getFavoritesController = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const user = await getUserById(userId, 'favorites');
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'User not found' });
|
||||
}
|
||||
|
||||
let favorites = user.favorites || [];
|
||||
|
||||
if (!Array.isArray(favorites)) {
|
||||
favorites = [];
|
||||
await updateUser(userId, { favorites: [] });
|
||||
}
|
||||
|
||||
return res.status(200).json(favorites);
|
||||
} catch (error) {
|
||||
console.error('Error fetching favorites:', error);
|
||||
return res.status(500).json({ message: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
updateFavoritesController,
|
||||
getFavoritesController,
|
||||
};
|
||||
@@ -0,0 +1,308 @@
|
||||
jest.mock('~/models', () => ({
|
||||
updateUser: jest.fn(),
|
||||
getUserById: jest.fn(),
|
||||
}));
|
||||
|
||||
const { updateUser, getUserById } = require('~/models');
|
||||
const { updateFavoritesController, getFavoritesController } = require('./FavoritesController');
|
||||
|
||||
const makeRes = () => {
|
||||
const res = {};
|
||||
res.status = jest.fn().mockReturnValue(res);
|
||||
res.json = jest.fn().mockReturnValue(res);
|
||||
return res;
|
||||
};
|
||||
|
||||
const makeReq = (body = {}) => ({
|
||||
body,
|
||||
user: { id: 'user-123' },
|
||||
});
|
||||
|
||||
describe('FavoritesController', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('updateFavoritesController - payload envelope', () => {
|
||||
it('rejects missing favorites key with 400', async () => {
|
||||
const req = makeReq({});
|
||||
const res = makeRes();
|
||||
await updateFavoritesController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ message: 'Favorites data is required' });
|
||||
expect(updateUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects non-array favorites with 400', async () => {
|
||||
const req = makeReq({ favorites: 'not-an-array' });
|
||||
const res = makeRes();
|
||||
await updateFavoritesController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ message: 'Favorites must be an array' });
|
||||
});
|
||||
|
||||
it('rejects favorites over MAX_FAVORITES with 400 + code', async () => {
|
||||
const favorites = Array.from({ length: 51 }, (_, i) => ({ agentId: `agent-${i}` }));
|
||||
const req = makeReq({ favorites });
|
||||
const res = makeRes();
|
||||
await updateFavoritesController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
code: 'MAX_FAVORITES_EXCEEDED',
|
||||
message: 'Maximum 50 favorites allowed',
|
||||
limit: 50,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateFavoritesController - agent/model length validation', () => {
|
||||
it('rejects oversized agentId', async () => {
|
||||
const req = makeReq({ favorites: [{ agentId: 'a'.repeat(257) }] });
|
||||
const res = makeRes();
|
||||
await updateFavoritesController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ message: 'agentId exceeds maximum length of 256' });
|
||||
});
|
||||
|
||||
it('rejects oversized model', async () => {
|
||||
const req = makeReq({ favorites: [{ model: 'm'.repeat(257), endpoint: 'openai' }] });
|
||||
const res = makeRes();
|
||||
await updateFavoritesController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ message: 'model exceeds maximum length of 256' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateFavoritesController - spec validation', () => {
|
||||
it('accepts a valid spec favorite', async () => {
|
||||
updateUser.mockResolvedValue({ favorites: [{ spec: 'my-spec' }] });
|
||||
const req = makeReq({ favorites: [{ spec: 'my-spec' }] });
|
||||
const res = makeRes();
|
||||
await updateFavoritesController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith([{ spec: 'my-spec' }]);
|
||||
expect(updateUser).toHaveBeenCalledWith('user-123', {
|
||||
favorites: [{ spec: 'my-spec' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-string spec with 400', async () => {
|
||||
const req = makeReq({ favorites: [{ spec: 42 }] });
|
||||
const res = makeRes();
|
||||
await updateFavoritesController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ message: 'spec must be a non-empty string' });
|
||||
});
|
||||
|
||||
it('rejects empty string spec with 400', async () => {
|
||||
const req = makeReq({ favorites: [{ spec: '' }] });
|
||||
const res = makeRes();
|
||||
await updateFavoritesController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ message: 'spec must be a non-empty string' });
|
||||
});
|
||||
|
||||
it('rejects oversized spec with 400', async () => {
|
||||
const req = makeReq({ favorites: [{ spec: 's'.repeat(257) }] });
|
||||
const res = makeRes();
|
||||
await updateFavoritesController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({ message: 'spec exceeds maximum length of 256' });
|
||||
});
|
||||
|
||||
it('allows undefined/null spec (treated as absent)', async () => {
|
||||
updateUser.mockResolvedValue({ favorites: [{ agentId: 'a1' }] });
|
||||
const req = makeReq({ favorites: [{ agentId: 'a1', spec: null }] });
|
||||
const res = makeRes();
|
||||
await updateFavoritesController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateFavoritesController - exclusivity (typeCount)', () => {
|
||||
it('rejects empty favorite entry with 400', async () => {
|
||||
const req = makeReq({ favorites: [{}] });
|
||||
const res = makeRes();
|
||||
await updateFavoritesController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
message: 'Each favorite must have either agentId, model+endpoint, or spec',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects agentId + model combination', async () => {
|
||||
const req = makeReq({
|
||||
favorites: [{ agentId: 'a1', model: 'gpt-5', endpoint: 'openai' }],
|
||||
});
|
||||
const res = makeRes();
|
||||
await updateFavoritesController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
message: 'Favorite cannot have multiple types (agentId, model/endpoint, or spec)',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects agentId + spec combination', async () => {
|
||||
const req = makeReq({ favorites: [{ agentId: 'a1', spec: 's1' }] });
|
||||
const res = makeRes();
|
||||
await updateFavoritesController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
message: 'Favorite cannot have multiple types (agentId, model/endpoint, or spec)',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects model + spec combination', async () => {
|
||||
const req = makeReq({
|
||||
favorites: [{ model: 'gpt-5', endpoint: 'openai', spec: 's1' }],
|
||||
});
|
||||
const res = makeRes();
|
||||
await updateFavoritesController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
});
|
||||
|
||||
it('rejects spec with stray endpoint field', async () => {
|
||||
const req = makeReq({ favorites: [{ spec: 's1', endpoint: 'openai' }] });
|
||||
const res = makeRes();
|
||||
await updateFavoritesController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
message: 'spec cannot be combined with agentId, model, or endpoint',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects spec with stray model field', async () => {
|
||||
const req = makeReq({ favorites: [{ spec: 's1', model: 'gpt-5' }] });
|
||||
const res = makeRes();
|
||||
await updateFavoritesController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
message: 'spec cannot be combined with agentId, model, or endpoint',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects agentId with stray model field (no endpoint)', async () => {
|
||||
const req = makeReq({ favorites: [{ agentId: 'a1', model: 'gpt-5' }] });
|
||||
const res = makeRes();
|
||||
await updateFavoritesController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
message: 'agentId cannot be combined with model or endpoint',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects agentId with stray endpoint field (no model)', async () => {
|
||||
const req = makeReq({ favorites: [{ agentId: 'a1', endpoint: 'openai' }] });
|
||||
const res = makeRes();
|
||||
await updateFavoritesController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
message: 'agentId cannot be combined with model or endpoint',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects model without endpoint (partial model pair)', async () => {
|
||||
const req = makeReq({ favorites: [{ model: 'gpt-5' }] });
|
||||
const res = makeRes();
|
||||
await updateFavoritesController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
message: 'model and endpoint must be provided together',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects endpoint without model (partial model pair)', async () => {
|
||||
const req = makeReq({ favorites: [{ endpoint: 'openai' }] });
|
||||
const res = makeRes();
|
||||
await updateFavoritesController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
message: 'model and endpoint must be provided together',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts a mixed array of valid single-type favorites', async () => {
|
||||
const favorites = [{ agentId: 'a1' }, { model: 'gpt-5', endpoint: 'openai' }, { spec: 's1' }];
|
||||
updateUser.mockResolvedValue({ favorites });
|
||||
const req = makeReq({ favorites });
|
||||
const res = makeRes();
|
||||
await updateFavoritesController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith(favorites);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateFavoritesController - persistence', () => {
|
||||
it('returns 404 when user is not found', async () => {
|
||||
updateUser.mockResolvedValue(null);
|
||||
const req = makeReq({ favorites: [{ spec: 's1' }] });
|
||||
const res = makeRes();
|
||||
await updateFavoritesController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({ message: 'User not found' });
|
||||
});
|
||||
|
||||
it('returns 500 when updateUser throws', async () => {
|
||||
updateUser.mockRejectedValue(new Error('db down'));
|
||||
const req = makeReq({ favorites: [{ spec: 's1' }] });
|
||||
const res = makeRes();
|
||||
await updateFavoritesController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({ message: 'Internal server error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFavoritesController', () => {
|
||||
it('returns the user favorites array', async () => {
|
||||
const favorites = [{ agentId: 'a1' }, { spec: 's1' }];
|
||||
getUserById.mockResolvedValue({ favorites });
|
||||
const req = makeReq();
|
||||
const res = makeRes();
|
||||
await getFavoritesController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith(favorites);
|
||||
});
|
||||
|
||||
it('returns [] when user.favorites is null (falsy)', async () => {
|
||||
getUserById.mockResolvedValue({ favorites: null });
|
||||
const req = makeReq();
|
||||
const res = makeRes();
|
||||
await getFavoritesController(req, res);
|
||||
expect(updateUser).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('repairs corrupt favorites field (non-array truthy)', async () => {
|
||||
getUserById.mockResolvedValue({ favorites: 'corrupt' });
|
||||
updateUser.mockResolvedValue({ favorites: [] });
|
||||
const req = makeReq();
|
||||
const res = makeRes();
|
||||
await getFavoritesController(req, res);
|
||||
expect(updateUser).toHaveBeenCalledWith('user-123', { favorites: [] });
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('returns 404 when user not found', async () => {
|
||||
getUserById.mockResolvedValue(null);
|
||||
const req = makeReq();
|
||||
const res = makeRes();
|
||||
await getFavoritesController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
});
|
||||
|
||||
it('returns 500 when getUserById throws', async () => {
|
||||
getUserById.mockRejectedValue(new Error('db down'));
|
||||
const req = makeReq();
|
||||
const res = makeRes();
|
||||
await getFavoritesController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { loadDefaultModels, loadConfigModels } = require('~/server/services/Config');
|
||||
|
||||
const getModelsConfig = (req) => loadModels(req);
|
||||
|
||||
async function loadModels(req) {
|
||||
const defaultModelsConfig = await loadDefaultModels(req);
|
||||
const customModelsConfig = await loadConfigModels(req);
|
||||
return { ...defaultModelsConfig, ...customModelsConfig };
|
||||
}
|
||||
|
||||
async function modelController(req, res) {
|
||||
try {
|
||||
const modelConfig = await loadModels(req);
|
||||
res.send(modelConfig);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching models:', error);
|
||||
res.status(500).send({ error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { modelController, loadModels, getModelsConfig };
|
||||
@@ -0,0 +1,570 @@
|
||||
/**
|
||||
* @import { TUpdateResourcePermissionsRequest, TUpdateResourcePermissionsResponse } from 'librechat-data-provider'
|
||||
*/
|
||||
|
||||
const mongoose = require('mongoose');
|
||||
const { logger, getTenantId, SYSTEM_TENANT_ID } = require('@librechat/data-schemas');
|
||||
const { ResourceType, PrincipalType, PermissionBits } = require('librechat-data-provider');
|
||||
const { enrichRemoteAgentPrincipals, backfillRemoteAgentPermissions } = require('@librechat/api');
|
||||
const {
|
||||
bulkUpdateResourcePermissions,
|
||||
ensureGroupPrincipalExists,
|
||||
getResourcePermissionsMap,
|
||||
findAccessibleResources,
|
||||
getEffectivePermissions,
|
||||
ensurePrincipalExists,
|
||||
getAvailableRoles,
|
||||
} = require('~/server/services/PermissionService');
|
||||
const {
|
||||
entraIdPrincipalFeatureEnabled,
|
||||
searchEntraIdPrincipals,
|
||||
} = require('~/server/services/GraphApiService');
|
||||
const db = require('~/models');
|
||||
|
||||
const matchesCurrentTenant = (principal, tenantId) => {
|
||||
if (!tenantId || tenantId === SYSTEM_TENANT_ID) {
|
||||
return true;
|
||||
}
|
||||
return principal?.tenantId === tenantId;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generic controller for resource permission endpoints
|
||||
* Delegates validation and logic to PermissionService
|
||||
*/
|
||||
|
||||
/**
|
||||
* Validates that the resourceType is one of the supported enum values
|
||||
* @param {string} resourceType - The resource type to validate
|
||||
* @throws {Error} If resourceType is not valid
|
||||
*/
|
||||
const validateResourceType = (resourceType) => {
|
||||
const validTypes = Object.values(ResourceType);
|
||||
if (!validTypes.includes(resourceType)) {
|
||||
throw new Error(`Invalid resourceType: ${resourceType}. Valid types: ${validTypes.join(', ')}`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Bulk update permissions for a resource (grant, update, remove)
|
||||
* @route PUT /api/{resourceType}/{resourceId}/permissions
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} req.params - Route parameters
|
||||
* @param {string} req.params.resourceType - Resource type (e.g., 'agent')
|
||||
* @param {string} req.params.resourceId - Resource ID
|
||||
* @param {TUpdateResourcePermissionsRequest} req.body - Request body
|
||||
* @param {Object} res - Express response object
|
||||
* @returns {Promise<TUpdateResourcePermissionsResponse>} Updated permissions response
|
||||
*/
|
||||
const updateResourcePermissions = async (req, res) => {
|
||||
try {
|
||||
const { resourceType, resourceId } = req.params;
|
||||
validateResourceType(resourceType);
|
||||
|
||||
/** @type {TUpdateResourcePermissionsRequest} */
|
||||
const { updated, removed, public: isPublic, publicAccessRoleId } = req.body;
|
||||
const { id: userId } = req.user;
|
||||
|
||||
// Prepare principals for the service call
|
||||
const updatedPrincipals = [];
|
||||
const revokedPrincipals = [];
|
||||
|
||||
// Add updated principals
|
||||
if (updated && Array.isArray(updated)) {
|
||||
updatedPrincipals.push(...updated);
|
||||
}
|
||||
|
||||
// Add public permission if enabled
|
||||
if (isPublic && publicAccessRoleId) {
|
||||
updatedPrincipals.push({
|
||||
type: PrincipalType.PUBLIC,
|
||||
id: null,
|
||||
accessRoleId: publicAccessRoleId,
|
||||
});
|
||||
}
|
||||
|
||||
// Prepare authentication context for enhanced group member fetching
|
||||
const useEntraId = entraIdPrincipalFeatureEnabled(req.user);
|
||||
const authHeader = req.headers.authorization;
|
||||
const accessToken =
|
||||
authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : null;
|
||||
const authContext =
|
||||
useEntraId && accessToken
|
||||
? {
|
||||
accessToken,
|
||||
sub: req.user.openidId,
|
||||
}
|
||||
: null;
|
||||
|
||||
// Ensure updated principals exist in the database before processing permissions
|
||||
const validatedPrincipals = [];
|
||||
for (const principal of updatedPrincipals) {
|
||||
try {
|
||||
let principalId;
|
||||
|
||||
if (principal.type === PrincipalType.PUBLIC) {
|
||||
principalId = null; // Public principals don't need database records
|
||||
} else if (principal.type === PrincipalType.ROLE) {
|
||||
principalId = principal.id; // Role principals use role name as ID
|
||||
} else if (principal.type === PrincipalType.USER) {
|
||||
principalId = await ensurePrincipalExists(principal);
|
||||
} else if (principal.type === PrincipalType.GROUP) {
|
||||
// Pass authContext to enable member fetching for Entra ID groups when available
|
||||
principalId = await ensureGroupPrincipalExists(principal, authContext);
|
||||
} else {
|
||||
logger.error(`Unsupported principal type: ${principal.type}`);
|
||||
continue; // Skip invalid principal types
|
||||
}
|
||||
|
||||
// Update the principal with the validated ID for ACL operations
|
||||
validatedPrincipals.push({
|
||||
...principal,
|
||||
id: principalId,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error ensuring principal exists:', {
|
||||
principal: {
|
||||
type: principal.type,
|
||||
id: principal.id,
|
||||
name: principal.name,
|
||||
source: principal.source,
|
||||
},
|
||||
error: error.message,
|
||||
});
|
||||
// Continue with other principals instead of failing the entire operation
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Add removed principals
|
||||
if (removed && Array.isArray(removed)) {
|
||||
revokedPrincipals.push(...removed);
|
||||
}
|
||||
|
||||
// If public is explicitly disabled, add public to revoked list
|
||||
if (isPublic === false) {
|
||||
revokedPrincipals.push({
|
||||
type: PrincipalType.PUBLIC,
|
||||
id: null,
|
||||
});
|
||||
}
|
||||
|
||||
const results = await bulkUpdateResourcePermissions({
|
||||
resourceType,
|
||||
resourceId,
|
||||
updatedPrincipals: validatedPrincipals,
|
||||
revokedPrincipals,
|
||||
grantedBy: userId,
|
||||
});
|
||||
|
||||
const isAgentResource =
|
||||
resourceType === ResourceType.AGENT || resourceType === ResourceType.REMOTE_AGENT;
|
||||
const revokedUserIds = results.revoked
|
||||
.filter((p) => p.type === PrincipalType.USER && p.id)
|
||||
.map((p) => p.id);
|
||||
|
||||
if (isAgentResource && revokedUserIds.length > 0) {
|
||||
db.removeAgentFromUserFavorites(resourceId, revokedUserIds).catch((err) => {
|
||||
logger.error('[removeRevokedAgentFromFavorites] Error cleaning up favorites', err);
|
||||
});
|
||||
}
|
||||
|
||||
/** @type {TUpdateResourcePermissionsResponse} */
|
||||
const response = {
|
||||
message: 'Permissions updated successfully',
|
||||
results: {
|
||||
principals: results.granted,
|
||||
...(isPublic !== undefined ? { public: isPublic } : {}),
|
||||
publicAccessRoleId: isPublic ? publicAccessRoleId : undefined,
|
||||
},
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error('Error updating resource permissions:', error);
|
||||
res.status(400).json({
|
||||
error: 'Failed to update permissions',
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get principals with their permission roles for a resource (UI-friendly format)
|
||||
* Uses efficient aggregation pipeline to join User/Group data in single query
|
||||
* @route GET /api/permissions/{resourceType}/{resourceId}
|
||||
*/
|
||||
const getResourcePermissions = async (req, res) => {
|
||||
try {
|
||||
const { resourceType, resourceId } = req.params;
|
||||
validateResourceType(resourceType);
|
||||
const tenantId = getTenantId();
|
||||
|
||||
const results = await db.aggregateAclEntries([
|
||||
// Match ACL entries for this resource
|
||||
{
|
||||
$match: {
|
||||
resourceType,
|
||||
resourceId: mongoose.Types.ObjectId.isValid(resourceId)
|
||||
? mongoose.Types.ObjectId.createFromHexString(resourceId)
|
||||
: resourceId,
|
||||
},
|
||||
},
|
||||
// Lookup AccessRole information
|
||||
{
|
||||
$lookup: {
|
||||
from: 'accessroles',
|
||||
localField: 'roleId',
|
||||
foreignField: '_id',
|
||||
as: 'role',
|
||||
},
|
||||
},
|
||||
// Lookup User information (for user principals)
|
||||
{
|
||||
$lookup: {
|
||||
from: 'users',
|
||||
localField: 'principalId',
|
||||
foreignField: '_id',
|
||||
as: 'userInfo',
|
||||
},
|
||||
},
|
||||
// Lookup Group information (for group principals)
|
||||
{
|
||||
$lookup: {
|
||||
from: 'groups',
|
||||
localField: 'principalId',
|
||||
foreignField: '_id',
|
||||
as: 'groupInfo',
|
||||
},
|
||||
},
|
||||
// Project final structure
|
||||
{
|
||||
$project: {
|
||||
principalType: 1,
|
||||
principalId: 1,
|
||||
accessRoleId: { $arrayElemAt: ['$role.accessRoleId', 0] },
|
||||
userInfo: { $arrayElemAt: ['$userInfo', 0] },
|
||||
groupInfo: { $arrayElemAt: ['$groupInfo', 0] },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
let principals = [];
|
||||
let publicPermission = null;
|
||||
|
||||
for (const result of results) {
|
||||
if (result.principalType === PrincipalType.PUBLIC) {
|
||||
publicPermission = {
|
||||
public: true,
|
||||
publicAccessRoleId: result.accessRoleId,
|
||||
};
|
||||
} else if (
|
||||
result.principalType === PrincipalType.USER &&
|
||||
result.userInfo &&
|
||||
matchesCurrentTenant(result.userInfo, tenantId)
|
||||
) {
|
||||
principals.push({
|
||||
type: PrincipalType.USER,
|
||||
id: result.userInfo._id.toString(),
|
||||
name: result.userInfo.name || result.userInfo.username,
|
||||
email: result.userInfo.email,
|
||||
avatar: result.userInfo.avatar,
|
||||
source: !result.userInfo._id ? 'entra' : 'local',
|
||||
idOnTheSource: result.userInfo.idOnTheSource || result.userInfo._id.toString(),
|
||||
accessRoleId: result.accessRoleId,
|
||||
});
|
||||
} else if (
|
||||
result.principalType === PrincipalType.GROUP &&
|
||||
result.groupInfo &&
|
||||
matchesCurrentTenant(result.groupInfo, tenantId)
|
||||
) {
|
||||
principals.push({
|
||||
type: PrincipalType.GROUP,
|
||||
id: result.groupInfo._id.toString(),
|
||||
name: result.groupInfo.name,
|
||||
email: result.groupInfo.email,
|
||||
description: result.groupInfo.description,
|
||||
avatar: result.groupInfo.avatar,
|
||||
source: result.groupInfo.source || 'local',
|
||||
idOnTheSource: result.groupInfo.idOnTheSource || result.groupInfo._id.toString(),
|
||||
accessRoleId: result.accessRoleId,
|
||||
});
|
||||
} else if (result.principalType === PrincipalType.ROLE) {
|
||||
principals.push({
|
||||
type: PrincipalType.ROLE,
|
||||
/** Role name as ID */
|
||||
id: result.principalId,
|
||||
/** Display the role name */
|
||||
name: result.principalId,
|
||||
description: `System role: ${result.principalId}`,
|
||||
accessRoleId: result.accessRoleId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (resourceType === ResourceType.REMOTE_AGENT) {
|
||||
const enricherDeps = {
|
||||
aggregateAclEntries: db.aggregateAclEntries,
|
||||
bulkWriteAclEntries: db.bulkWriteAclEntries,
|
||||
findRoleByIdentifier: db.findRoleByIdentifier,
|
||||
logger,
|
||||
};
|
||||
const enrichResult = await enrichRemoteAgentPrincipals(enricherDeps, resourceId, principals);
|
||||
principals = enrichResult.principals;
|
||||
backfillRemoteAgentPermissions(enricherDeps, resourceId, enrichResult.entriesToBackfill);
|
||||
}
|
||||
|
||||
// Return response in format expected by frontend
|
||||
const response = {
|
||||
resourceType,
|
||||
resourceId,
|
||||
principals,
|
||||
public: publicPermission?.public || false,
|
||||
...(publicPermission?.publicAccessRoleId && {
|
||||
publicAccessRoleId: publicPermission.publicAccessRoleId,
|
||||
}),
|
||||
};
|
||||
|
||||
res.status(200).json(response);
|
||||
} catch (error) {
|
||||
logger.error('Error getting resource permissions principals:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to get permissions principals',
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get available roles for a resource type
|
||||
* @route GET /api/{resourceType}/roles
|
||||
*/
|
||||
const getResourceRoles = async (req, res) => {
|
||||
try {
|
||||
const { resourceType } = req.params;
|
||||
validateResourceType(resourceType);
|
||||
|
||||
const roles = await getAvailableRoles({ resourceType });
|
||||
|
||||
res.status(200).json(
|
||||
roles.map((role) => ({
|
||||
accessRoleId: role.accessRoleId,
|
||||
name: role.name,
|
||||
description: role.description,
|
||||
permBits: role.permBits,
|
||||
})),
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Error getting resource roles:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to get roles',
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get user's effective permission bitmask for a resource
|
||||
* @route GET /api/{resourceType}/{resourceId}/effective
|
||||
*/
|
||||
const getUserEffectivePermissions = async (req, res) => {
|
||||
try {
|
||||
const { resourceType, resourceId } = req.params;
|
||||
validateResourceType(resourceType);
|
||||
|
||||
const { id: userId } = req.user;
|
||||
|
||||
const permissionBits = await getEffectivePermissions({
|
||||
userId,
|
||||
role: req.user.role,
|
||||
resourceType,
|
||||
resourceId,
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
permissionBits,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error getting user effective permissions:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to get effective permissions',
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Search for users and groups to grant permissions
|
||||
* Supports hybrid local database + Entra ID search when configured
|
||||
* @route GET /api/permissions/search-principals
|
||||
*/
|
||||
const searchPrincipals = async (req, res) => {
|
||||
try {
|
||||
const { q: rawQuery, limit = 20, types } = req.query;
|
||||
|
||||
if (typeof rawQuery !== 'string' || rawQuery.trim().length === 0) {
|
||||
return res.status(400).json({
|
||||
error: 'Query parameter "q" is required and must not be empty',
|
||||
});
|
||||
}
|
||||
|
||||
const query = rawQuery.trim();
|
||||
|
||||
if (query.length < 2) {
|
||||
return res.status(400).json({
|
||||
error: 'Query must be at least 2 characters long',
|
||||
});
|
||||
}
|
||||
|
||||
const searchLimit = Math.min(Math.max(1, parseInt(limit) || 10), 50);
|
||||
|
||||
let typeFilters = null;
|
||||
if (types) {
|
||||
const typesArray = Array.isArray(types) ? types : types.split(',');
|
||||
const validTypes = typesArray.filter((t) =>
|
||||
[PrincipalType.USER, PrincipalType.GROUP, PrincipalType.ROLE].includes(t),
|
||||
);
|
||||
typeFilters = validTypes.length > 0 ? validTypes : null;
|
||||
}
|
||||
|
||||
const localResults = await db.searchPrincipals(query, searchLimit, typeFilters);
|
||||
let allPrincipals = [...localResults];
|
||||
|
||||
const useEntraId = entraIdPrincipalFeatureEnabled(req.user);
|
||||
|
||||
if (useEntraId && localResults.length < searchLimit) {
|
||||
try {
|
||||
let graphType = 'all';
|
||||
if (typeFilters && typeFilters.length === 1) {
|
||||
const graphTypeMap = {
|
||||
[PrincipalType.USER]: 'users',
|
||||
[PrincipalType.GROUP]: 'groups',
|
||||
};
|
||||
const mappedType = graphTypeMap[typeFilters[0]];
|
||||
if (mappedType) {
|
||||
graphType = mappedType;
|
||||
}
|
||||
}
|
||||
|
||||
const authHeader = req.headers.authorization;
|
||||
const accessToken =
|
||||
authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : null;
|
||||
|
||||
if (accessToken) {
|
||||
const graphResults = await searchEntraIdPrincipals(
|
||||
accessToken,
|
||||
req.user.openidId,
|
||||
query,
|
||||
graphType,
|
||||
searchLimit - localResults.length,
|
||||
);
|
||||
|
||||
const localEmails = new Set(
|
||||
localResults.map((p) => p.email?.toLowerCase()).filter(Boolean),
|
||||
);
|
||||
const localGroupSourceIds = new Set(
|
||||
localResults.map((p) => p.idOnTheSource).filter(Boolean),
|
||||
);
|
||||
|
||||
for (const principal of graphResults) {
|
||||
const isDuplicateByEmail =
|
||||
principal.email && localEmails.has(principal.email.toLowerCase());
|
||||
const isDuplicateBySourceId =
|
||||
principal.idOnTheSource && localGroupSourceIds.has(principal.idOnTheSource);
|
||||
|
||||
if (!isDuplicateByEmail && !isDuplicateBySourceId) {
|
||||
allPrincipals.push(principal);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (graphError) {
|
||||
logger.warn('Graph API search failed, falling back to local results:', graphError.message);
|
||||
}
|
||||
}
|
||||
const scoredResults = allPrincipals.map((item) => ({
|
||||
...item,
|
||||
_searchScore: db.calculateRelevanceScore(item, query),
|
||||
}));
|
||||
|
||||
const finalResults = db
|
||||
.sortPrincipalsByRelevance(scoredResults)
|
||||
.slice(0, searchLimit)
|
||||
.map((result) => {
|
||||
const { _searchScore, ...resultWithoutScore } = result;
|
||||
return resultWithoutScore;
|
||||
});
|
||||
|
||||
res.status(200).json({
|
||||
query,
|
||||
limit: searchLimit,
|
||||
types: typeFilters,
|
||||
results: finalResults,
|
||||
count: finalResults.length,
|
||||
sources: {
|
||||
local: finalResults.filter((r) => r.source === 'local').length,
|
||||
entra: finalResults.filter((r) => r.source === 'entra').length,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error searching principals:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to search principals',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get user's effective permissions for all accessible resources of a type
|
||||
* @route GET /api/permissions/{resourceType}/effective/all
|
||||
*/
|
||||
const getAllEffectivePermissions = async (req, res) => {
|
||||
try {
|
||||
const { resourceType } = req.params;
|
||||
validateResourceType(resourceType);
|
||||
|
||||
const { id: userId } = req.user;
|
||||
|
||||
// Find all resources the user has at least VIEW access to
|
||||
const accessibleResourceIds = await findAccessibleResources({
|
||||
userId,
|
||||
role: req.user.role,
|
||||
resourceType,
|
||||
requiredPermissions: PermissionBits.VIEW,
|
||||
});
|
||||
|
||||
if (accessibleResourceIds.length === 0) {
|
||||
return res.status(200).json({});
|
||||
}
|
||||
|
||||
// Get effective permissions for all accessible resources
|
||||
const permissionsMap = await getResourcePermissionsMap({
|
||||
userId,
|
||||
role: req.user.role,
|
||||
resourceType,
|
||||
resourceIds: accessibleResourceIds,
|
||||
});
|
||||
|
||||
// Convert Map to plain object for JSON response
|
||||
const result = {};
|
||||
for (const [resourceId, permBits] of permissionsMap) {
|
||||
result[resourceId] = permBits;
|
||||
}
|
||||
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
logger.error('Error getting all effective permissions:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to get all effective permissions',
|
||||
details: error.message,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
updateResourcePermissions,
|
||||
getResourcePermissions,
|
||||
getResourceRoles,
|
||||
getUserEffectivePermissions,
|
||||
getAllEffectivePermissions,
|
||||
searchPrincipals,
|
||||
};
|
||||
@@ -0,0 +1,114 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { getToolkitKey, checkPluginAuth, filterUniquePlugins } = require('@librechat/api');
|
||||
const { getCachedTools, setCachedTools } = require('~/server/services/Config');
|
||||
const { availableTools, toolkits } = require('~/app/clients/tools');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
|
||||
const getAvailablePluginsController = async (req, res) => {
|
||||
try {
|
||||
const appConfig =
|
||||
req.config ??
|
||||
(await getAppConfig({
|
||||
role: req.user?.role,
|
||||
userId: req.user?.id,
|
||||
tenantId: req.user?.tenantId,
|
||||
}));
|
||||
const { filteredTools = [], includedTools = [] } = appConfig;
|
||||
|
||||
const uniquePlugins = filterUniquePlugins(availableTools);
|
||||
const includeSet = new Set(includedTools);
|
||||
const filterSet = new Set(filteredTools);
|
||||
|
||||
/** includedTools takes precedence — filteredTools ignored when both are set. */
|
||||
const plugins = [];
|
||||
for (const plugin of uniquePlugins) {
|
||||
/** Agents-runtime-only tools (e.g. ask_user_question) never work on the
|
||||
* legacy plugins endpoint — no run to pause, no resume surface. */
|
||||
if (plugin.agentsOnly === true) {
|
||||
continue;
|
||||
}
|
||||
if (includeSet.size > 0) {
|
||||
if (!includeSet.has(plugin.pluginKey)) {
|
||||
continue;
|
||||
}
|
||||
} else if (filterSet.has(plugin.pluginKey)) {
|
||||
continue;
|
||||
}
|
||||
plugins.push(checkPluginAuth(plugin) ? { ...plugin, authenticated: true } : plugin);
|
||||
}
|
||||
|
||||
res.status(200).json(plugins);
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
const getAvailableTools = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) {
|
||||
logger.warn('[getAvailableTools] User ID not found in request');
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const appConfig =
|
||||
req.config ??
|
||||
(await getAppConfig({
|
||||
role: req.user?.role,
|
||||
userId: req.user?.id,
|
||||
tenantId: req.user?.tenantId,
|
||||
}));
|
||||
|
||||
let toolDefinitions = await getCachedTools();
|
||||
|
||||
if (toolDefinitions == null && appConfig?.availableTools != null) {
|
||||
logger.warn('[getAvailableTools] Tool cache was empty, re-initializing from app config');
|
||||
await setCachedTools(appConfig.availableTools);
|
||||
toolDefinitions = appConfig.availableTools;
|
||||
}
|
||||
|
||||
const uniquePlugins = filterUniquePlugins(availableTools);
|
||||
const toolDefKeysList = toolDefinitions ? Object.keys(toolDefinitions) : null;
|
||||
const toolDefKeys = toolDefKeysList ? new Set(toolDefKeysList) : null;
|
||||
|
||||
/**
|
||||
* `getAvailableTools` serves BOTH tool dialogs — /api/agents/tools and
|
||||
* /api/assistants/tools. Tools flagged `agentsOnly` in the manifest (e.g.
|
||||
* ask_user_question, which pauses an agents run via a LangGraph interrupt)
|
||||
* cannot work on the assistants runtime: it executes tools directly with no
|
||||
* run to pause and no resume surface, so attaching one there guarantees a
|
||||
* permanent tool error. Scope them out of the assistants listing by route.
|
||||
*/
|
||||
const isAssistantsRoute = req.baseUrl?.includes('/assistants') === true;
|
||||
|
||||
const toolsOutput = [];
|
||||
for (const plugin of uniquePlugins) {
|
||||
if (plugin.agentsOnly === true && isAssistantsRoute) {
|
||||
continue;
|
||||
}
|
||||
const isToolDefined = toolDefKeys?.has(plugin.pluginKey) === true;
|
||||
const isToolkit =
|
||||
plugin.toolkit === true &&
|
||||
toolDefKeysList != null &&
|
||||
toolDefKeysList.some(
|
||||
(key) => getToolkitKey({ toolkits, toolName: key }) === plugin.pluginKey,
|
||||
);
|
||||
|
||||
if (!isToolDefined && !isToolkit) {
|
||||
continue;
|
||||
}
|
||||
|
||||
toolsOutput.push(checkPluginAuth(plugin) ? { ...plugin, authenticated: true } : plugin);
|
||||
}
|
||||
|
||||
res.status(200).json(toolsOutput);
|
||||
} catch (error) {
|
||||
logger.error('[getAvailableTools]', error);
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getAvailableTools,
|
||||
getAvailablePluginsController,
|
||||
};
|
||||
@@ -0,0 +1,478 @@
|
||||
const { getCachedTools, getAppConfig } = require('~/server/services/Config');
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: {
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
getCachedTools: jest.fn(),
|
||||
getAppConfig: jest.fn().mockResolvedValue({
|
||||
filteredTools: [],
|
||||
includedTools: [],
|
||||
}),
|
||||
setCachedTools: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/app/clients/tools', () => ({
|
||||
availableTools: [],
|
||||
toolkits: [],
|
||||
}));
|
||||
|
||||
const { getAvailableTools, getAvailablePluginsController } = require('./PluginController');
|
||||
|
||||
describe('PluginController', () => {
|
||||
let mockReq, mockRes;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockReq = {
|
||||
user: { id: 'test-user-id' },
|
||||
config: {
|
||||
filteredTools: [],
|
||||
includedTools: [],
|
||||
},
|
||||
};
|
||||
mockRes = { status: jest.fn().mockReturnThis(), json: jest.fn() };
|
||||
|
||||
require('~/app/clients/tools').availableTools.length = 0;
|
||||
require('~/app/clients/tools').toolkits.length = 0;
|
||||
|
||||
getCachedTools.mockReset();
|
||||
|
||||
getAppConfig.mockReset();
|
||||
getAppConfig.mockResolvedValue({
|
||||
filteredTools: [],
|
||||
includedTools: [],
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailablePluginsController', () => {
|
||||
it('should use filterUniquePlugins to remove duplicate plugins', async () => {
|
||||
const mockPlugins = [
|
||||
{ name: 'Plugin1', pluginKey: 'key1', description: 'First' },
|
||||
{ name: 'Plugin1', pluginKey: 'key1', description: 'First duplicate' },
|
||||
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
|
||||
];
|
||||
|
||||
require('~/app/clients/tools').availableTools.push(...mockPlugins);
|
||||
|
||||
getAppConfig.mockResolvedValueOnce({
|
||||
filteredTools: [],
|
||||
includedTools: [],
|
||||
});
|
||||
|
||||
await getAvailablePluginsController(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||
const responseData = mockRes.json.mock.calls[0][0];
|
||||
expect(responseData).toHaveLength(2);
|
||||
expect(responseData[0].pluginKey).toBe('key1');
|
||||
expect(responseData[1].pluginKey).toBe('key2');
|
||||
});
|
||||
|
||||
it('should use checkPluginAuth to verify plugin authentication', async () => {
|
||||
const mockPlugin = { name: 'Plugin1', pluginKey: 'key1', description: 'First' };
|
||||
|
||||
require('~/app/clients/tools').availableTools.push(mockPlugin);
|
||||
|
||||
getAppConfig.mockResolvedValueOnce({
|
||||
filteredTools: [],
|
||||
includedTools: [],
|
||||
});
|
||||
|
||||
await getAvailablePluginsController(mockReq, mockRes);
|
||||
|
||||
const responseData = mockRes.json.mock.calls[0][0];
|
||||
expect(responseData[0].authenticated).toBeUndefined();
|
||||
});
|
||||
|
||||
it('excludes agentsOnly plugins from the legacy plugins endpoint (no run to pause)', async () => {
|
||||
require('~/app/clients/tools').availableTools.push(
|
||||
{ name: 'Ask User', pluginKey: 'ask_user_question', description: 'q', agentsOnly: true },
|
||||
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
|
||||
);
|
||||
|
||||
await getAvailablePluginsController(mockReq, mockRes);
|
||||
|
||||
const responseData = mockRes.json.mock.calls[0][0];
|
||||
expect(responseData.map((p) => p.pluginKey)).toEqual(['key2']);
|
||||
});
|
||||
|
||||
it('should filter plugins based on includedTools', async () => {
|
||||
const mockPlugins = [
|
||||
{ name: 'Plugin1', pluginKey: 'key1', description: 'First' },
|
||||
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
|
||||
];
|
||||
|
||||
require('~/app/clients/tools').availableTools.push(...mockPlugins);
|
||||
|
||||
mockReq.config = {
|
||||
filteredTools: [],
|
||||
includedTools: ['key1'],
|
||||
};
|
||||
|
||||
await getAvailablePluginsController(mockReq, mockRes);
|
||||
|
||||
const responseData = mockRes.json.mock.calls[0][0];
|
||||
expect(responseData).toHaveLength(1);
|
||||
expect(responseData[0].pluginKey).toBe('key1');
|
||||
});
|
||||
|
||||
it('should exclude plugins in filteredTools', async () => {
|
||||
const mockPlugins = [
|
||||
{ name: 'Plugin1', pluginKey: 'key1', description: 'First' },
|
||||
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
|
||||
];
|
||||
|
||||
require('~/app/clients/tools').availableTools.push(...mockPlugins);
|
||||
|
||||
mockReq.config = {
|
||||
filteredTools: ['key2'],
|
||||
includedTools: [],
|
||||
};
|
||||
|
||||
await getAvailablePluginsController(mockReq, mockRes);
|
||||
|
||||
const responseData = mockRes.json.mock.calls[0][0];
|
||||
expect(responseData).toHaveLength(1);
|
||||
expect(responseData[0].pluginKey).toBe('key1');
|
||||
});
|
||||
|
||||
it('should ignore filteredTools when includedTools is set', async () => {
|
||||
const mockPlugins = [
|
||||
{ name: 'Plugin1', pluginKey: 'key1', description: 'First' },
|
||||
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
|
||||
{ name: 'Plugin3', pluginKey: 'key3', description: 'Third' },
|
||||
];
|
||||
|
||||
require('~/app/clients/tools').availableTools.push(...mockPlugins);
|
||||
|
||||
mockReq.config = {
|
||||
includedTools: ['key1', 'key2'],
|
||||
filteredTools: ['key2'],
|
||||
};
|
||||
|
||||
await getAvailablePluginsController(mockReq, mockRes);
|
||||
|
||||
const responseData = mockRes.json.mock.calls[0][0];
|
||||
expect(responseData).toHaveLength(2);
|
||||
expect(responseData.map((p) => p.pluginKey)).toEqual(['key1', 'key2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAvailableTools', () => {
|
||||
it('scopes agentsOnly plugins out of the ASSISTANTS listing but keeps them for agents', async () => {
|
||||
const cached = {
|
||||
ask_user_question: {
|
||||
type: 'function',
|
||||
function: { name: 'ask_user_question', description: 'q', parameters: {} },
|
||||
},
|
||||
};
|
||||
require('~/app/clients/tools').availableTools.push({
|
||||
name: 'Ask User',
|
||||
pluginKey: 'ask_user_question',
|
||||
description: 'q',
|
||||
agentsOnly: true,
|
||||
});
|
||||
|
||||
// Agents route: listed.
|
||||
getCachedTools.mockResolvedValueOnce(cached);
|
||||
mockReq.baseUrl = '/api/agents/tools';
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
expect(mockRes.json.mock.calls[0][0].map((t) => t.pluginKey)).toContain('ask_user_question');
|
||||
|
||||
// Assistants route: the runtime executes tools with no run to pause — excluded.
|
||||
mockRes.json.mockClear();
|
||||
getCachedTools.mockResolvedValueOnce(cached);
|
||||
mockReq.baseUrl = '/api/assistants/v2/tools';
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
expect(mockRes.json.mock.calls[0][0].map((t) => t.pluginKey)).not.toContain(
|
||||
'ask_user_question',
|
||||
);
|
||||
});
|
||||
|
||||
it('should use filterUniquePlugins to deduplicate combined tools', async () => {
|
||||
const mockUserTools = {
|
||||
'user-tool': {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'user-tool',
|
||||
description: 'User tool',
|
||||
parameters: { type: 'object', properties: {} },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
require('~/app/clients/tools').availableTools.push(
|
||||
{ name: 'user-tool', pluginKey: 'user-tool', description: 'Duplicate user tool' },
|
||||
{ name: 'ManifestTool', pluginKey: 'manifest-tool', description: 'Manifest tool' },
|
||||
);
|
||||
|
||||
getCachedTools.mockResolvedValueOnce(mockUserTools);
|
||||
mockReq.config = {
|
||||
mcpConfig: null,
|
||||
paths: { structuredTools: '/mock/path' },
|
||||
};
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||
const responseData = mockRes.json.mock.calls[0][0];
|
||||
expect(Array.isArray(responseData)).toBe(true);
|
||||
const userToolCount = responseData.filter((tool) => tool.pluginKey === 'user-tool').length;
|
||||
expect(userToolCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should use checkPluginAuth to verify authentication status', async () => {
|
||||
const mockPlugin = {
|
||||
name: 'Tool1',
|
||||
pluginKey: 'tool1',
|
||||
description: 'Tool 1',
|
||||
};
|
||||
|
||||
require('~/app/clients/tools').availableTools.push(mockPlugin);
|
||||
|
||||
getCachedTools.mockResolvedValueOnce({
|
||||
tool1: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'tool1',
|
||||
description: 'Tool 1',
|
||||
parameters: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
mockReq.config = {
|
||||
mcpConfig: null,
|
||||
paths: { structuredTools: '/mock/path' },
|
||||
};
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||
const responseData = mockRes.json.mock.calls[0][0];
|
||||
expect(Array.isArray(responseData)).toBe(true);
|
||||
const tool = responseData.find((t) => t.pluginKey === 'tool1');
|
||||
expect(tool).toBeDefined();
|
||||
expect(tool.authenticated).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should use getToolkitKey for toolkit validation', async () => {
|
||||
const mockToolkit = {
|
||||
name: 'Toolkit1',
|
||||
pluginKey: 'toolkit1',
|
||||
description: 'Toolkit 1',
|
||||
toolkit: true,
|
||||
};
|
||||
|
||||
require('~/app/clients/tools').availableTools.push(mockToolkit);
|
||||
|
||||
require('~/app/clients/tools').toolkits.push({
|
||||
name: 'Toolkit1',
|
||||
pluginKey: 'toolkit1',
|
||||
tools: ['toolkit1_function'],
|
||||
});
|
||||
|
||||
getCachedTools.mockResolvedValueOnce({
|
||||
toolkit1_function: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'toolkit1_function',
|
||||
description: 'Toolkit function',
|
||||
parameters: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
mockReq.config = {
|
||||
mcpConfig: null,
|
||||
paths: { structuredTools: '/mock/path' },
|
||||
};
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||
const responseData = mockRes.json.mock.calls[0][0];
|
||||
expect(Array.isArray(responseData)).toBe(true);
|
||||
const toolkit = responseData.find((t) => t.pluginKey === 'toolkit1');
|
||||
expect(toolkit).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('helper function integration', () => {
|
||||
it('should handle error cases gracefully', async () => {
|
||||
getCachedTools.mockRejectedValue(new Error('Cache error'));
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ message: 'Cache error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases with undefined/null values', () => {
|
||||
it('should handle null cachedTools', async () => {
|
||||
getCachedTools.mockResolvedValueOnce({});
|
||||
mockReq.config = {
|
||||
mcpConfig: null,
|
||||
paths: { structuredTools: '/mock/path' },
|
||||
};
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||
expect(mockRes.json).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('should handle when getCachedTools returns undefined', async () => {
|
||||
mockReq.config = {
|
||||
mcpConfig: null,
|
||||
paths: { structuredTools: '/mock/path' },
|
||||
};
|
||||
|
||||
getCachedTools.mockReset();
|
||||
getCachedTools.mockResolvedValueOnce(undefined);
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||
expect(mockRes.json).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('should handle empty toolDefinitions object', async () => {
|
||||
getCachedTools.mockReset();
|
||||
getCachedTools.mockResolvedValue({});
|
||||
mockReq.config = {};
|
||||
|
||||
require('~/app/clients/tools').availableTools.length = 0;
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('should handle undefined filteredTools and includedTools', async () => {
|
||||
mockReq.config = {};
|
||||
|
||||
getAppConfig.mockResolvedValueOnce({});
|
||||
|
||||
await getAvailablePluginsController(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||
expect(mockRes.json).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('should handle toolkit with undefined toolDefinitions keys', async () => {
|
||||
const mockToolkit = {
|
||||
name: 'Toolkit1',
|
||||
pluginKey: 'toolkit1',
|
||||
description: 'Toolkit 1',
|
||||
toolkit: true,
|
||||
};
|
||||
|
||||
require('~/app/clients/tools').availableTools.push(mockToolkit);
|
||||
|
||||
getCachedTools.mockResolvedValueOnce({});
|
||||
mockReq.config = {
|
||||
mcpConfig: null,
|
||||
paths: { structuredTools: '/mock/path' },
|
||||
};
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
|
||||
it('should handle undefined toolDefinitions when checking isToolDefined', async () => {
|
||||
const mockPlugin = {
|
||||
name: 'Traversaal Search',
|
||||
pluginKey: 'traversaal_search',
|
||||
description: 'Search plugin',
|
||||
};
|
||||
|
||||
require('~/app/clients/tools').availableTools.push(mockPlugin);
|
||||
|
||||
mockReq.config = {
|
||||
mcpConfig: null,
|
||||
paths: { structuredTools: '/mock/path' },
|
||||
};
|
||||
|
||||
getCachedTools.mockResolvedValueOnce(undefined);
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||
expect(mockRes.json).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('should re-initialize tools from appConfig when cache returns null', async () => {
|
||||
const mockAppTools = {
|
||||
tool1: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'tool1',
|
||||
description: 'Tool 1',
|
||||
parameters: {},
|
||||
},
|
||||
},
|
||||
tool2: {
|
||||
type: 'function',
|
||||
function: {
|
||||
name: 'tool2',
|
||||
description: 'Tool 2',
|
||||
parameters: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
require('~/app/clients/tools').availableTools.push(
|
||||
{ name: 'Tool 1', pluginKey: 'tool1', description: 'Tool 1' },
|
||||
{ name: 'Tool 2', pluginKey: 'tool2', description: 'Tool 2' },
|
||||
);
|
||||
|
||||
getCachedTools.mockResolvedValueOnce(null);
|
||||
|
||||
mockReq.config = {
|
||||
filteredTools: [],
|
||||
includedTools: [],
|
||||
availableTools: mockAppTools,
|
||||
};
|
||||
|
||||
const { setCachedTools } = require('~/server/services/Config');
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
expect(setCachedTools).toHaveBeenCalledWith(mockAppTools);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||
const responseData = mockRes.json.mock.calls[0][0];
|
||||
expect(responseData).toHaveLength(2);
|
||||
expect(responseData.find((t) => t.pluginKey === 'tool1')).toBeDefined();
|
||||
expect(responseData.find((t) => t.pluginKey === 'tool2')).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle cache clear without appConfig.availableTools gracefully', async () => {
|
||||
getAppConfig.mockResolvedValue({
|
||||
filteredTools: [],
|
||||
includedTools: [],
|
||||
});
|
||||
|
||||
require('~/app/clients/tools').availableTools.length = 0;
|
||||
|
||||
getCachedTools.mockResolvedValueOnce(null);
|
||||
|
||||
mockReq.config = {
|
||||
filteredTools: [],
|
||||
includedTools: [],
|
||||
};
|
||||
|
||||
await getAvailableTools(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||
expect(mockRes.json).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
const mongoose = require('mongoose');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const {
|
||||
MAX_SKILL_STATES,
|
||||
toSkillStatesRecord,
|
||||
validateSkillStatesPayload,
|
||||
pruneOrphanSkillStates,
|
||||
getDeploymentSkillIds,
|
||||
mergeDeploymentSkillIds,
|
||||
} = require('@librechat/api');
|
||||
const { ResourceType, PermissionBits } = require('librechat-data-provider');
|
||||
const { findAccessibleResources } = require('~/server/services/PermissionService');
|
||||
const { updateUser, getUserById } = require('~/models');
|
||||
|
||||
/** Builds the injected deps for `pruneOrphanSkillStates` from live models. */
|
||||
function buildPruneDeps(user) {
|
||||
return {
|
||||
findExistingSkillIds: async (validIds) => {
|
||||
const Skill = mongoose.models.Skill;
|
||||
if (!Skill) {
|
||||
return validIds;
|
||||
}
|
||||
const existing = await Skill.find({ _id: { $in: validIds } })
|
||||
.select('_id')
|
||||
.lean();
|
||||
const deploymentIds = getDeploymentSkillIds()
|
||||
.map((id) => id.toString())
|
||||
.filter((id) => validIds.includes(id));
|
||||
return [...existing.map((doc) => doc._id.toString()), ...deploymentIds];
|
||||
},
|
||||
findAccessibleSkillIds: async () =>
|
||||
mergeDeploymentSkillIds(
|
||||
await findAccessibleResources({
|
||||
userId: user.id,
|
||||
role: user.role,
|
||||
resourceType: ResourceType.SKILL,
|
||||
requiredPermissions: PermissionBits.VIEW,
|
||||
}),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const getSkillStatesController = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const user = await getUserById(userId, 'skillStates');
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'User not found' });
|
||||
}
|
||||
|
||||
const states = toSkillStatesRecord(user.skillStates);
|
||||
const pruned = await pruneOrphanSkillStates(states, buildPruneDeps(req.user));
|
||||
return res.status(200).json(pruned);
|
||||
} catch (error) {
|
||||
logger.error('[SkillStatesController] Error fetching skill states:', error);
|
||||
return res.status(500).json({ message: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
const updateSkillStatesController = async (req, res) => {
|
||||
try {
|
||||
const { skillStates } = req.body;
|
||||
|
||||
const validationError = validateSkillStatesPayload(skillStates);
|
||||
if (validationError) {
|
||||
const { message, code, limit } = validationError;
|
||||
const payload = { message };
|
||||
if (code) payload.code = code;
|
||||
if (limit != null) payload.limit = limit;
|
||||
return res.status(400).json(payload);
|
||||
}
|
||||
|
||||
const pruned = await pruneOrphanSkillStates(skillStates, buildPruneDeps(req.user));
|
||||
|
||||
if (Object.keys(pruned).length > MAX_SKILL_STATES) {
|
||||
return res.status(400).json({
|
||||
code: 'MAX_SKILL_STATES_EXCEEDED',
|
||||
message: `Maximum ${MAX_SKILL_STATES} skill state overrides allowed`,
|
||||
limit: MAX_SKILL_STATES,
|
||||
});
|
||||
}
|
||||
|
||||
const user = await updateUser(req.user.id, { skillStates: pruned });
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'User not found' });
|
||||
}
|
||||
|
||||
return res.status(200).json(toSkillStatesRecord(user.skillStates));
|
||||
} catch (error) {
|
||||
logger.error('[SkillStatesController] Error updating skill states:', error);
|
||||
return res.status(500).json({ message: 'Internal server error' });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getSkillStatesController,
|
||||
updateSkillStatesController,
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { resolveTokenConfigMap } = require('@librechat/api');
|
||||
const { getModelsConfig } = require('~/server/controllers/ModelController');
|
||||
const { getValueKey, getMultiplier, getCacheMultiplier } = require('~/models');
|
||||
|
||||
/**
|
||||
* Returns server-resolved context windows (and pricing when
|
||||
* `interface.contextCost` is enabled) for every configured model. Resolution
|
||||
* lives in `@librechat/api`; this controller only supplies request-scoped deps.
|
||||
* @param {ServerRequest} req
|
||||
* @param {ServerResponse} res
|
||||
*/
|
||||
async function tokenConfigController(req, res) {
|
||||
try {
|
||||
const modelsConfig = await getModelsConfig(req);
|
||||
const tokenConfigMap = await resolveTokenConfigMap(
|
||||
{
|
||||
appConfig: req.config,
|
||||
modelsConfig,
|
||||
userId: req.user.id,
|
||||
tenantId: req.user.tenantId,
|
||||
},
|
||||
{ getValueKey, getMultiplier, getCacheMultiplier },
|
||||
);
|
||||
res.json(tokenConfigMap);
|
||||
} catch (error) {
|
||||
logger.error('[tokenConfigController]', error);
|
||||
res.status(500).json({ error: 'Failed to resolve token config' });
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = tokenConfigController;
|
||||
@@ -0,0 +1,210 @@
|
||||
const { encryptV3, logger } = require('@librechat/data-schemas');
|
||||
const {
|
||||
verifyOTPOrBackupCode,
|
||||
generateBackupCodes,
|
||||
generateTOTPSecret,
|
||||
verifyBackupCode,
|
||||
getTOTPSecret,
|
||||
verifyTOTP,
|
||||
} = require('~/server/services/twoFactorService');
|
||||
const { getUserById, updateUser } = require('~/models');
|
||||
|
||||
const safeAppTitle = (process.env.APP_TITLE || 'LibreChat').replace(/\s+/g, '');
|
||||
|
||||
/**
|
||||
* Enable 2FA for the user by generating a new TOTP secret and backup codes.
|
||||
* The secret is encrypted and stored, and 2FA is marked as disabled until confirmed.
|
||||
* If 2FA is already enabled, requires OTP or backup code verification to re-enroll.
|
||||
*/
|
||||
const enable2FA = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const existingUser = await getUserById(
|
||||
userId,
|
||||
'+totpSecret +backupCodes _id twoFactorEnabled email',
|
||||
);
|
||||
|
||||
if (existingUser && existingUser.twoFactorEnabled) {
|
||||
const { token, backupCode } = req.body;
|
||||
const result = await verifyOTPOrBackupCode({
|
||||
user: existingUser,
|
||||
token,
|
||||
backupCode,
|
||||
persistBackupUse: false,
|
||||
});
|
||||
|
||||
if (!result.verified) {
|
||||
const msg = result.message ?? 'TOTP token or backup code is required to re-enroll 2FA';
|
||||
return res.status(result.status ?? 400).json({ message: msg });
|
||||
}
|
||||
}
|
||||
|
||||
const secret = generateTOTPSecret();
|
||||
const { plainCodes, codeObjects } = await generateBackupCodes();
|
||||
const encryptedSecret = encryptV3(secret);
|
||||
|
||||
const user = await updateUser(userId, {
|
||||
pendingTotpSecret: encryptedSecret,
|
||||
pendingBackupCodes: codeObjects,
|
||||
});
|
||||
|
||||
const email = user.email || (existingUser && existingUser.email) || '';
|
||||
const otpauthUrl = `otpauth://totp/${safeAppTitle}:${email}?secret=${secret}&issuer=${safeAppTitle}`;
|
||||
|
||||
return res.status(200).json({ otpauthUrl, backupCodes: plainCodes });
|
||||
} catch (err) {
|
||||
logger.error('[enable2FA]', err);
|
||||
return res.status(500).json({ message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify a 2FA code (either TOTP or backup code) during setup.
|
||||
*/
|
||||
const verify2FA = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { token, backupCode } = req.body;
|
||||
const user = await getUserById(userId, '+totpSecret +pendingTotpSecret +backupCodes _id');
|
||||
const secretSource = user?.pendingTotpSecret ?? user?.totpSecret;
|
||||
|
||||
if (!user || !secretSource) {
|
||||
return res.status(400).json({ message: '2FA not initiated' });
|
||||
}
|
||||
|
||||
const secret = await getTOTPSecret(secretSource);
|
||||
let isVerified = false;
|
||||
|
||||
if (token) {
|
||||
isVerified = await verifyTOTP(secret, token);
|
||||
} else if (backupCode) {
|
||||
isVerified = await verifyBackupCode({ user, backupCode });
|
||||
}
|
||||
|
||||
if (isVerified) {
|
||||
return res.status(200).json();
|
||||
}
|
||||
return res.status(400).json({ message: 'Invalid token or backup code.' });
|
||||
} catch (err) {
|
||||
logger.error('[verify2FA]', err);
|
||||
return res.status(500).json({ message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Confirm and enable 2FA after a successful verification.
|
||||
*/
|
||||
const confirm2FA = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { token } = req.body;
|
||||
const user = await getUserById(
|
||||
userId,
|
||||
'+totpSecret +pendingTotpSecret +pendingBackupCodes _id',
|
||||
);
|
||||
const secretSource = user?.pendingTotpSecret ?? user?.totpSecret;
|
||||
|
||||
if (!user || !secretSource) {
|
||||
return res.status(400).json({ message: '2FA not initiated' });
|
||||
}
|
||||
|
||||
const secret = await getTOTPSecret(secretSource);
|
||||
if (await verifyTOTP(secret, token)) {
|
||||
const update = {
|
||||
totpSecret: user.pendingTotpSecret ?? user.totpSecret,
|
||||
twoFactorEnabled: true,
|
||||
pendingTotpSecret: null,
|
||||
pendingBackupCodes: [],
|
||||
};
|
||||
if (user.pendingBackupCodes?.length) {
|
||||
update.backupCodes = user.pendingBackupCodes;
|
||||
}
|
||||
await updateUser(userId, update);
|
||||
return res.status(200).json();
|
||||
}
|
||||
return res.status(400).json({ message: 'Invalid token.' });
|
||||
} catch (err) {
|
||||
logger.error('[confirm2FA]', err);
|
||||
return res.status(500).json({ message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Disable 2FA by clearing the stored secret and backup codes.
|
||||
* Requires verification with either TOTP token or backup code if 2FA is fully enabled.
|
||||
*/
|
||||
const disable2FA = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const { token, backupCode } = req.body;
|
||||
const user = await getUserById(userId, '+totpSecret +backupCodes _id twoFactorEnabled');
|
||||
|
||||
if (!user || !user.totpSecret) {
|
||||
return res.status(400).json({ message: '2FA is not setup for this user' });
|
||||
}
|
||||
|
||||
if (user.twoFactorEnabled) {
|
||||
const result = await verifyOTPOrBackupCode({ user, token, backupCode });
|
||||
|
||||
if (!result.verified) {
|
||||
const msg = result.message ?? 'Either token or backup code is required to disable 2FA';
|
||||
return res.status(result.status ?? 400).json({ message: msg });
|
||||
}
|
||||
}
|
||||
await updateUser(userId, {
|
||||
totpSecret: null,
|
||||
backupCodes: [],
|
||||
twoFactorEnabled: false,
|
||||
pendingTotpSecret: null,
|
||||
pendingBackupCodes: [],
|
||||
});
|
||||
return res.status(200).json();
|
||||
} catch (err) {
|
||||
logger.error('[disable2FA]', err);
|
||||
return res.status(500).json({ message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Regenerate backup codes for the user.
|
||||
* Requires OTP or backup code verification if 2FA is already enabled.
|
||||
*/
|
||||
const regenerateBackupCodes = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user.id;
|
||||
const user = await getUserById(userId, '+totpSecret +backupCodes _id twoFactorEnabled');
|
||||
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'User not found' });
|
||||
}
|
||||
|
||||
if (user.twoFactorEnabled) {
|
||||
const { token, backupCode } = req.body;
|
||||
const result = await verifyOTPOrBackupCode({ user, token, backupCode });
|
||||
|
||||
if (!result.verified) {
|
||||
const msg =
|
||||
result.message ?? 'TOTP token or backup code is required to regenerate backup codes';
|
||||
return res.status(result.status ?? 400).json({ message: msg });
|
||||
}
|
||||
}
|
||||
|
||||
const { plainCodes, codeObjects } = await generateBackupCodes();
|
||||
await updateUser(userId, { backupCodes: codeObjects });
|
||||
return res.status(200).json({
|
||||
backupCodes: plainCodes,
|
||||
backupCodesHash: codeObjects,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('[regenerateBackupCodes]', err);
|
||||
return res.status(500).json({ message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
enable2FA,
|
||||
verify2FA,
|
||||
confirm2FA,
|
||||
disable2FA,
|
||||
regenerateBackupCodes,
|
||||
};
|
||||
@@ -0,0 +1,611 @@
|
||||
const mongoose = require('mongoose');
|
||||
const { logger, getTenantId, webSearchKeys } = require('@librechat/data-schemas');
|
||||
const {
|
||||
getNewS3URL,
|
||||
needsRefresh,
|
||||
MCPOAuthHandler,
|
||||
MCPTokenStorage,
|
||||
getAppConfigOptionsFromUser,
|
||||
normalizeHttpError,
|
||||
extractWebSearchEnvVars,
|
||||
deleteAgentCheckpoints,
|
||||
deleteAllSharedLinksWithCleanup,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
Tools,
|
||||
CacheKeys,
|
||||
Constants,
|
||||
FileSources,
|
||||
ResourceType,
|
||||
} = require('librechat-data-provider');
|
||||
const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService');
|
||||
const { verifyOTPOrBackupCode } = require('~/server/services/twoFactorService');
|
||||
const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService');
|
||||
const { getMCPManager, getFlowStateManager, getMCPServersRegistry } = require('~/config');
|
||||
const { invalidateCachedTools } = require('~/server/services/Config/getCachedTools');
|
||||
const { processDeleteRequest } = require('~/server/services/Files/process');
|
||||
const { getAppConfig } = require('~/server/services/Config');
|
||||
const { getLogStores } = require('~/cache');
|
||||
const db = require('~/models');
|
||||
|
||||
const PUBLIC_USER_RESPONSE_FIELDS = [
|
||||
'_id',
|
||||
'id',
|
||||
'name',
|
||||
'username',
|
||||
'email',
|
||||
'emailVerified',
|
||||
'avatar',
|
||||
'provider',
|
||||
'role',
|
||||
'plugins',
|
||||
'twoFactorEnabled',
|
||||
'termsAccepted',
|
||||
'personalization',
|
||||
'favorites',
|
||||
'skillStates',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'tenantId',
|
||||
];
|
||||
|
||||
const sanitizeUserForResponse = (user) => {
|
||||
const source = user.toObject != null ? user.toObject() : user;
|
||||
return PUBLIC_USER_RESPONSE_FIELDS.reduce((userData, field) => {
|
||||
if (source[field] !== undefined) {
|
||||
userData[field] = source[field];
|
||||
}
|
||||
return userData;
|
||||
}, {});
|
||||
};
|
||||
|
||||
const getUserController = async (req, res) => {
|
||||
const appConfig = req.config ?? (await getAppConfig(getAppConfigOptionsFromUser(req.user)));
|
||||
/** @type {IUser} */
|
||||
const userData = sanitizeUserForResponse(req.user);
|
||||
if (appConfig.fileStrategy === FileSources.s3 && userData.avatar) {
|
||||
const avatarNeedsRefresh = needsRefresh(userData.avatar, 3600);
|
||||
if (!avatarNeedsRefresh) {
|
||||
return res.status(200).send(userData);
|
||||
}
|
||||
const originalAvatar = userData.avatar;
|
||||
try {
|
||||
userData.avatar = await getNewS3URL(userData.avatar);
|
||||
await db.updateUser(userData.id, { avatar: userData.avatar });
|
||||
} catch (error) {
|
||||
userData.avatar = originalAvatar;
|
||||
logger.error('Error getting new S3 URL for avatar:', error);
|
||||
}
|
||||
}
|
||||
res.status(200).send(userData);
|
||||
};
|
||||
|
||||
const getTermsStatusController = async (req, res) => {
|
||||
try {
|
||||
const user = await db.getUserById(req.user.id, 'termsAccepted termsAcceptedAt');
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'User not found' });
|
||||
}
|
||||
res.status(200).json({
|
||||
termsAccepted: !!user.termsAccepted,
|
||||
termsAcceptedAt: user.termsAcceptedAt || null,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error fetching terms acceptance status:', error);
|
||||
res.status(500).json({ message: 'Error fetching terms acceptance status' });
|
||||
}
|
||||
};
|
||||
|
||||
const acceptTermsController = async (req, res) => {
|
||||
try {
|
||||
const user = await db.acceptTerms(req.user.id);
|
||||
if (!user) {
|
||||
return res.status(404).json({ message: 'User not found' });
|
||||
}
|
||||
res.status(200).json({
|
||||
message: 'Terms accepted successfully',
|
||||
termsAcceptedAt: user.termsAcceptedAt,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error accepting terms:', error);
|
||||
res.status(500).json({ message: 'Error accepting terms' });
|
||||
}
|
||||
};
|
||||
|
||||
const deleteUserFiles = async (req) => {
|
||||
try {
|
||||
const userFiles = await db.getFiles({ user: req.user.id });
|
||||
await processDeleteRequest({
|
||||
req,
|
||||
files: userFiles,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[deleteUserFiles]', error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes MCP servers solely owned by the user and cleans up their ACLs.
|
||||
* Disconnects live sessions for deleted servers before removing DB records.
|
||||
* Servers with other owners are left intact; the caller is responsible for
|
||||
* removing the user's own ACL principal entries separately.
|
||||
*
|
||||
* Also handles legacy (pre-ACL) MCP servers that only have the author field set,
|
||||
* ensuring they are not orphaned if no permission migration has been run.
|
||||
* @param {string} userId - The ID of the user.
|
||||
*/
|
||||
const deleteUserMcpServers = async (userId) => {
|
||||
try {
|
||||
const MCPServer = mongoose.models.MCPServer;
|
||||
const AclEntry = mongoose.models.AclEntry;
|
||||
if (!MCPServer) {
|
||||
return;
|
||||
}
|
||||
|
||||
const userObjectId = new mongoose.Types.ObjectId(userId);
|
||||
const soleOwnedIds = await db.getSoleOwnedResourceIds(userObjectId, ResourceType.MCPSERVER);
|
||||
|
||||
const authoredServers = await MCPServer.find({ author: userObjectId })
|
||||
.select('_id serverName')
|
||||
.lean();
|
||||
|
||||
const migratedEntries =
|
||||
authoredServers.length > 0
|
||||
? await AclEntry.find({
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
resourceId: { $in: authoredServers.map((s) => s._id) },
|
||||
})
|
||||
.select('resourceId')
|
||||
.lean()
|
||||
: [];
|
||||
const migratedIds = new Set(migratedEntries.map((e) => e.resourceId.toString()));
|
||||
const legacyServers = authoredServers.filter((s) => !migratedIds.has(s._id.toString()));
|
||||
const legacyServerIds = legacyServers.map((s) => s._id);
|
||||
|
||||
const allServerIdsToDelete = [...soleOwnedIds, ...legacyServerIds];
|
||||
|
||||
if (allServerIdsToDelete.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const aclOwnedServers =
|
||||
soleOwnedIds.length > 0
|
||||
? await MCPServer.find({ _id: { $in: soleOwnedIds } })
|
||||
.select('serverName')
|
||||
.lean()
|
||||
: [];
|
||||
const allServersToDelete = [...aclOwnedServers, ...legacyServers];
|
||||
|
||||
const mcpManager = getMCPManager();
|
||||
if (mcpManager) {
|
||||
await Promise.all(
|
||||
allServersToDelete.map(async (s) => {
|
||||
await mcpManager.disconnectUserConnection(userId, s.serverName);
|
||||
await invalidateCachedTools({ userId, serverName: s.serverName });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
await AclEntry.deleteMany({
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
resourceId: { $in: allServerIdsToDelete },
|
||||
});
|
||||
|
||||
await MCPServer.deleteMany({ _id: { $in: allServerIdsToDelete } });
|
||||
} catch (error) {
|
||||
logger.error('[deleteUserMcpServers] General error:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const updateUserPluginsController = async (req, res) => {
|
||||
const appConfig = req.config ?? (await getAppConfig(getAppConfigOptionsFromUser(req.user)));
|
||||
const { user } = req;
|
||||
const { pluginKey, action, auth, isEntityTool } = req.body;
|
||||
try {
|
||||
if (!isEntityTool) {
|
||||
await db.updateUserPlugins(user._id, user.plugins, pluginKey, action);
|
||||
}
|
||||
|
||||
if (auth == null) {
|
||||
return res.status(200).send();
|
||||
}
|
||||
|
||||
let keys = Object.keys(auth);
|
||||
const values = Object.values(auth); // Used in 'install' block
|
||||
|
||||
const isMCPTool = pluginKey.startsWith('mcp_') || pluginKey.includes(Constants.mcp_delimiter);
|
||||
|
||||
// Early exit condition:
|
||||
// If keys are empty (meaning auth: {} was likely sent for uninstall, or auth was empty for install)
|
||||
// AND it's not web_search (which has special key handling to populate `keys` for uninstall)
|
||||
// AND it's NOT (an uninstall action FOR an MCP tool - we need to proceed for this case to clear all its auth)
|
||||
// THEN return.
|
||||
if (
|
||||
keys.length === 0 &&
|
||||
pluginKey !== Tools.web_search &&
|
||||
!(action === 'uninstall' && isMCPTool)
|
||||
) {
|
||||
return res.status(200).send();
|
||||
}
|
||||
|
||||
/** @type {number} */
|
||||
let status = 200;
|
||||
/** @type {string} */
|
||||
let message;
|
||||
/** @type {IPluginAuth | Error} */
|
||||
let authService;
|
||||
|
||||
if (pluginKey === Tools.web_search) {
|
||||
/** @type {TCustomConfig['webSearch']} */
|
||||
const webSearchConfig = appConfig?.webSearch;
|
||||
keys = extractWebSearchEnvVars({
|
||||
keys: action === 'install' ? keys : webSearchKeys,
|
||||
config: webSearchConfig,
|
||||
});
|
||||
}
|
||||
|
||||
if (action === 'install') {
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
authService = await updateUserPluginAuth(user.id, keys[i], pluginKey, values[i]);
|
||||
if (authService instanceof Error) {
|
||||
logger.error('[authService]', authService);
|
||||
({ status, message } = normalizeHttpError(authService));
|
||||
}
|
||||
}
|
||||
} else if (action === 'uninstall') {
|
||||
// const isMCPTool was defined earlier
|
||||
if (isMCPTool && keys.length === 0) {
|
||||
// This handles the case where auth: {} is sent for an MCP tool uninstall.
|
||||
// It means "delete all credentials associated with this MCP pluginKey".
|
||||
authService = await deleteUserPluginAuth(user.id, null, true, pluginKey);
|
||||
if (authService instanceof Error) {
|
||||
logger.error(
|
||||
`[authService] Error deleting all auth for MCP tool ${pluginKey}:`,
|
||||
authService,
|
||||
);
|
||||
({ status, message } = normalizeHttpError(authService));
|
||||
}
|
||||
try {
|
||||
// if the MCP server uses OAuth, perform a full cleanup and token revocation
|
||||
await maybeUninstallOAuthMCP(user.id, pluginKey, appConfig);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[updateUserPluginsController] Error uninstalling OAuth MCP for ${pluginKey}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// This handles:
|
||||
// 1. Web_search uninstall (keys will be populated with all webSearchKeys if auth was {}).
|
||||
// 2. Other tools uninstall (if keys were provided).
|
||||
// 3. MCP tool uninstall if specific keys were provided in `auth` (not current frontend behavior).
|
||||
// If keys is empty for non-MCP tools (and not web_search), this loop won't run, and nothing is deleted.
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
authService = await deleteUserPluginAuth(user.id, keys[i]); // Deletes by authField name
|
||||
if (authService instanceof Error) {
|
||||
logger.error('[authService] Error deleting specific auth key:', authService);
|
||||
({ status, message } = normalizeHttpError(authService));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (status === 200) {
|
||||
// If auth was updated successfully, disconnect MCP sessions as they might use these credentials
|
||||
if (pluginKey.startsWith(Constants.mcp_prefix)) {
|
||||
try {
|
||||
const mcpManager = getMCPManager();
|
||||
if (mcpManager) {
|
||||
// Extract server name from pluginKey (format: "mcp_<serverName>")
|
||||
const serverName = pluginKey.replace(Constants.mcp_prefix, '');
|
||||
logger.info(
|
||||
`[updateUserPluginsController] Attempting disconnect of MCP server "${serverName}" for user ${user.id} after plugin auth update.`,
|
||||
);
|
||||
await mcpManager.disconnectUserConnection(user.id, serverName);
|
||||
await invalidateCachedTools({ userId: user.id, serverName });
|
||||
}
|
||||
} catch (disconnectError) {
|
||||
logger.error(
|
||||
`[updateUserPluginsController] Error disconnecting MCP connection for user ${user.id} after plugin auth update:`,
|
||||
disconnectError,
|
||||
);
|
||||
// Do not fail the request for this, but log it.
|
||||
}
|
||||
}
|
||||
return res.status(status).send();
|
||||
}
|
||||
|
||||
const normalized = normalizeHttpError({ status, message });
|
||||
return res.status(normalized.status).send({ message: normalized.message });
|
||||
} catch (err) {
|
||||
logger.error('[updateUserPluginsController]', err);
|
||||
return res.status(500).json({ message: 'Something went wrong.' });
|
||||
}
|
||||
};
|
||||
|
||||
const deleteUserController = async (req, res) => {
|
||||
const { user } = req;
|
||||
|
||||
try {
|
||||
const existingUser = await db.getUserById(
|
||||
user.id,
|
||||
'+totpSecret +backupCodes _id twoFactorEnabled',
|
||||
);
|
||||
if (existingUser && existingUser.twoFactorEnabled) {
|
||||
const { token, backupCode } = req.body;
|
||||
const result = await verifyOTPOrBackupCode({ user: existingUser, token, backupCode });
|
||||
|
||||
if (!result.verified) {
|
||||
const msg =
|
||||
result.message ??
|
||||
'TOTP token or backup code is required to delete account with 2FA enabled';
|
||||
return res.status(result.status ?? 400).json({ message: msg });
|
||||
}
|
||||
}
|
||||
|
||||
await db.deleteMessages({ user: user.id });
|
||||
await db.deleteAllUserSessions({ userId: user.id });
|
||||
await db.deleteTransactions({ user: user.id });
|
||||
await db.deleteUserKey({ userId: user.id, all: true });
|
||||
await db.deleteBalances({ user: user._id });
|
||||
await db.deletePresets(user.id);
|
||||
try {
|
||||
const convoDeletion = await db.deleteConvos(user.id);
|
||||
// HITL: prune the deleted conversations' durable checkpoints — a paused run's
|
||||
// checkpoint would otherwise persist until the Mongo TTL. Never throws.
|
||||
const appConfig =
|
||||
req.config ??
|
||||
(await getAppConfig({
|
||||
role: req.user?.role,
|
||||
userId: req.user?.id,
|
||||
tenantId: req.user?.tenantId,
|
||||
}));
|
||||
await deleteAgentCheckpoints(
|
||||
convoDeletion?.conversationIds,
|
||||
appConfig?.endpoints?.agents?.checkpointer,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('[deleteUserController] Error deleting user convos, likely no convos', error);
|
||||
}
|
||||
await deleteUserPluginAuth(user.id, null, true);
|
||||
await db.deleteUserById(user.id);
|
||||
await deleteAllSharedLinksWithCleanup(user.id);
|
||||
await deleteUserFiles(req);
|
||||
await db.deleteFiles(null, user.id);
|
||||
await db.deleteToolCalls(user.id);
|
||||
await db.deleteUserAgents(user.id);
|
||||
await db.deleteAllAgentApiKeys(user._id);
|
||||
await db.deleteAssistants({ user: user.id });
|
||||
await db.deleteConversationTags({ user: user.id });
|
||||
await db.deleteAllUserMemories(user.id);
|
||||
await db.deleteUserPrompts(user.id);
|
||||
await db.deleteUserSkills(user.id);
|
||||
await deleteUserMcpServers(user.id);
|
||||
await db.deleteActions({ user: user.id });
|
||||
await db.deleteTokens({ userId: user.id });
|
||||
await db.removeUserFromAllGroups(user.id);
|
||||
await db.deleteAclEntries({ principalId: user._id });
|
||||
logger.info(`User deleted account. Email: ${user.email} ID: ${user.id}`);
|
||||
res.status(200).send({ message: 'User deleted' });
|
||||
} catch (err) {
|
||||
logger.error('[deleteUserController]', err);
|
||||
return res.status(500).json({ message: 'Something went wrong.' });
|
||||
}
|
||||
};
|
||||
|
||||
const verifyEmailController = async (req, res) => {
|
||||
try {
|
||||
const verifyEmailService = await verifyEmail(req);
|
||||
if (verifyEmailService instanceof Error) {
|
||||
return res.status(400).json({ message: verifyEmailService.message });
|
||||
} else {
|
||||
return res.status(200).json(verifyEmailService);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('[verifyEmailController]', e);
|
||||
return res.status(500).json({ message: 'Something went wrong.' });
|
||||
}
|
||||
};
|
||||
|
||||
const resendVerificationController = async (req, res) => {
|
||||
try {
|
||||
const result = await resendVerificationEmail(req);
|
||||
if (result instanceof Error) {
|
||||
return res.status(400).json({ message: result.message });
|
||||
} else {
|
||||
return res.status(result.status ?? 200).json({ message: result.message });
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('[verifyEmailController]', e);
|
||||
return res.status(500).json({ message: 'Something went wrong.' });
|
||||
}
|
||||
};
|
||||
|
||||
/** Best-effort cleanup of stored MCP OAuth tokens and flow state. */
|
||||
const clearStoredMCPOAuthState = async (userId, serverName) => {
|
||||
try {
|
||||
await MCPTokenStorage.deleteUserTokens({
|
||||
userId,
|
||||
serverName,
|
||||
deleteToken: async (filter) => {
|
||||
await db.deleteTokens(filter);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[clearStoredMCPOAuthState] Failed to delete MCP OAuth tokens for ${serverName}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const flowsCache = getLogStores(CacheKeys.FLOWS);
|
||||
const flowManager = getFlowStateManager(flowsCache);
|
||||
const baseFlowId = MCPOAuthHandler.generateFlowId(userId, serverName);
|
||||
const tenantId = getTenantId();
|
||||
const tokenFlowId = MCPOAuthHandler.generateTokenFlowId(userId, serverName, tenantId);
|
||||
const oauthFlowId = MCPOAuthHandler.generateFlowId(userId, serverName, tenantId);
|
||||
const flowDeletes = [
|
||||
[tokenFlowId, 'mcp_get_tokens'],
|
||||
[oauthFlowId, 'mcp_oauth'],
|
||||
[baseFlowId, 'mcp_get_tokens'],
|
||||
[baseFlowId, 'mcp_oauth'],
|
||||
].filter(
|
||||
([flowId, type], index, deletes) =>
|
||||
deletes.findIndex(([candidateId, candidateType]) => {
|
||||
return candidateId === flowId && candidateType === type;
|
||||
}) === index,
|
||||
);
|
||||
const results = await Promise.allSettled(
|
||||
flowDeletes.map(([flowId, type]) => flowManager.deleteFlow(flowId, type)),
|
||||
);
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
logger.warn(
|
||||
`[clearStoredMCPOAuthState] Failed to clear MCP OAuth flow state for ${serverName}:`,
|
||||
result.reason,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[clearStoredMCPOAuthState] Failed to clear MCP OAuth flow state for ${serverName}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** Revokes MCP OAuth tokens at the provider when possible, then clears local state. */
|
||||
const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => {
|
||||
if (!pluginKey.startsWith(Constants.mcp_prefix)) {
|
||||
// this is not an MCP server, so nothing to do here
|
||||
return;
|
||||
}
|
||||
|
||||
const serverName = pluginKey.replace(Constants.mcp_prefix, '');
|
||||
const serverConfig =
|
||||
(await getMCPServersRegistry().getServerConfig(serverName, userId)) ??
|
||||
appConfig?.mcpServers?.[serverName];
|
||||
const oauthServers = await getMCPServersRegistry().getOAuthServers(userId);
|
||||
if (!oauthServers.has(serverName) || !serverConfig) {
|
||||
await clearStoredMCPOAuthState(userId, serverName);
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. get client info used for revocation (client id, secret)
|
||||
let clientTokenData = null;
|
||||
try {
|
||||
clientTokenData = await MCPTokenStorage.getClientInfoAndMetadata({
|
||||
userId,
|
||||
serverName,
|
||||
findToken: db.findToken,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[maybeUninstallOAuthMCP] Unable to load OAuth client metadata for ${serverName}; clearing local MCP OAuth state only.`,
|
||||
error,
|
||||
);
|
||||
await clearStoredMCPOAuthState(userId, serverName);
|
||||
return;
|
||||
}
|
||||
if (clientTokenData == null) {
|
||||
logger.info(
|
||||
`[maybeUninstallOAuthMCP] Missing OAuth client metadata for ${serverName}; clearing local MCP OAuth state only.`,
|
||||
);
|
||||
await clearStoredMCPOAuthState(userId, serverName);
|
||||
return;
|
||||
}
|
||||
const { clientInfo, clientMetadata } = clientTokenData;
|
||||
|
||||
// 2. get decrypted tokens before deletion
|
||||
let tokens = null;
|
||||
try {
|
||||
tokens = await MCPTokenStorage.getTokens({
|
||||
userId,
|
||||
serverName,
|
||||
findToken: db.findToken,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`[maybeUninstallOAuthMCP] Unable to load OAuth tokens for ${serverName}; clearing local token state.`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
|
||||
// 3. revoke OAuth tokens at the provider
|
||||
const revocationEndpoint =
|
||||
serverConfig.oauth?.revocation_endpoint ?? clientMetadata.revocation_endpoint;
|
||||
const revocationEndpointAuthMethodsSupported =
|
||||
serverConfig.oauth?.revocation_endpoint_auth_methods_supported ??
|
||||
clientMetadata.revocation_endpoint_auth_methods_supported;
|
||||
const oauthHeaders = serverConfig.oauth_headers ?? {};
|
||||
// Use the request's merged (tenant/principal-scoped) allowlists so admin-panel mcpSettings
|
||||
// overrides are honored for OAuth revocation, consistent with inspection/connection.
|
||||
const allowedDomains = appConfig?.mcpSettings?.allowedDomains;
|
||||
const allowedAddresses = appConfig?.mcpSettings?.allowedAddresses;
|
||||
|
||||
if (tokens?.access_token) {
|
||||
try {
|
||||
await MCPOAuthHandler.revokeOAuthToken(
|
||||
serverName,
|
||||
tokens.access_token,
|
||||
'access',
|
||||
{
|
||||
serverUrl: serverConfig.url,
|
||||
clientId: clientInfo.client_id,
|
||||
clientSecret: clientInfo.client_secret ?? '',
|
||||
revocationEndpoint,
|
||||
revocationEndpointAuthMethodsSupported,
|
||||
},
|
||||
oauthHeaders,
|
||||
allowedDomains,
|
||||
allowedAddresses,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[maybeUninstallOAuthMCP] Error revoking OAuth access token for ${serverName}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (tokens?.refresh_token) {
|
||||
try {
|
||||
await MCPOAuthHandler.revokeOAuthToken(
|
||||
serverName,
|
||||
tokens.refresh_token,
|
||||
'refresh',
|
||||
{
|
||||
serverUrl: serverConfig.url,
|
||||
clientId: clientInfo.client_id,
|
||||
clientSecret: clientInfo.client_secret ?? '',
|
||||
revocationEndpoint,
|
||||
revocationEndpointAuthMethodsSupported,
|
||||
},
|
||||
oauthHeaders,
|
||||
allowedDomains,
|
||||
allowedAddresses,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[maybeUninstallOAuthMCP] Error revoking OAuth refresh token for ${serverName}:`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. delete tokens from the DB and clear the flow state after revocation attempts
|
||||
await clearStoredMCPOAuthState(userId, serverName);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getUserController,
|
||||
getTermsStatusController,
|
||||
acceptTermsController,
|
||||
deleteUserController,
|
||||
verifyEmailController,
|
||||
updateUserPluginsController,
|
||||
resendVerificationController,
|
||||
deleteUserMcpServers,
|
||||
maybeUninstallOAuthMCP,
|
||||
};
|
||||
@@ -0,0 +1,416 @@
|
||||
const mongoose = require('mongoose');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => {
|
||||
const actual = jest.requireActual('@librechat/data-schemas');
|
||||
return {
|
||||
...actual,
|
||||
logger: {
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
info: jest.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('~/models', () => {
|
||||
const _mongoose = require('mongoose');
|
||||
return {
|
||||
deleteAllUserSessions: jest.fn().mockResolvedValue(undefined),
|
||||
deleteAllSharedLinks: jest.fn().mockResolvedValue(undefined),
|
||||
deleteAllAgentApiKeys: jest.fn().mockResolvedValue(undefined),
|
||||
deleteConversationTags: jest.fn().mockResolvedValue(undefined),
|
||||
deleteAllUserMemories: jest.fn().mockResolvedValue(undefined),
|
||||
deleteTransactions: jest.fn().mockResolvedValue(undefined),
|
||||
deleteAclEntries: jest.fn().mockResolvedValue(undefined),
|
||||
updateUserPlugins: jest.fn(),
|
||||
deleteAssistants: jest.fn().mockResolvedValue(undefined),
|
||||
deleteUserById: jest.fn().mockResolvedValue(undefined),
|
||||
deleteUserPrompts: jest.fn().mockResolvedValue(undefined),
|
||||
deleteUserSkills: jest.fn().mockResolvedValue(undefined),
|
||||
deleteMessages: jest.fn().mockResolvedValue(undefined),
|
||||
deleteBalances: jest.fn().mockResolvedValue(undefined),
|
||||
deleteActions: jest.fn().mockResolvedValue(undefined),
|
||||
deletePresets: jest.fn().mockResolvedValue(undefined),
|
||||
deleteUserKey: jest.fn().mockResolvedValue(undefined),
|
||||
deleteToolCalls: jest.fn().mockResolvedValue(undefined),
|
||||
deleteUserAgents: jest.fn().mockResolvedValue(undefined),
|
||||
deleteTokens: jest.fn().mockResolvedValue(undefined),
|
||||
deleteConvos: jest.fn().mockResolvedValue(undefined),
|
||||
deleteFiles: jest.fn().mockResolvedValue(undefined),
|
||||
updateUser: jest.fn(),
|
||||
acceptTerms: jest.fn(),
|
||||
getUserById: jest.fn().mockResolvedValue(null),
|
||||
findToken: jest.fn(),
|
||||
getFiles: jest.fn().mockResolvedValue([]),
|
||||
removeUserFromAllGroups: jest.fn().mockImplementation(async (userId) => {
|
||||
const Group = _mongoose.models.Group;
|
||||
await Group.updateMany({ memberIds: userId }, { $pullAll: { memberIds: [userId] } });
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('~/server/services/PluginService', () => ({
|
||||
updateUserPluginAuth: jest.fn(),
|
||||
deleteUserPluginAuth: jest.fn().mockResolvedValue(undefined),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/AuthService', () => ({
|
||||
verifyEmail: jest.fn(),
|
||||
resendVerificationEmail: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('sharp', () =>
|
||||
jest.fn(() => ({
|
||||
metadata: jest.fn().mockResolvedValue({}),
|
||||
toFormat: jest.fn().mockReturnThis(),
|
||||
toBuffer: jest.fn().mockResolvedValue(Buffer.alloc(0)),
|
||||
})),
|
||||
);
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
...jest.requireActual('@librechat/api'),
|
||||
needsRefresh: jest.fn(),
|
||||
getNewS3URL: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/process', () => ({
|
||||
processDeleteRequest: jest.fn().mockResolvedValue({ deletedFileIds: [], failedFileIds: [] }),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
getAppConfig: jest.fn().mockResolvedValue({}),
|
||||
getMCPManager: jest.fn(),
|
||||
getFlowStateManager: jest.fn(),
|
||||
getMCPServersRegistry: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/cache', () => ({
|
||||
getLogStores: jest.fn(),
|
||||
}));
|
||||
|
||||
let mongoServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
await mongoose.connect(mongoServer.getUri());
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
await mongoServer.stop();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const collections = mongoose.connection.collections;
|
||||
for (const key in collections) {
|
||||
await collections[key].deleteMany({});
|
||||
}
|
||||
});
|
||||
|
||||
const {
|
||||
deleteUserController,
|
||||
getUserController,
|
||||
acceptTermsController,
|
||||
resendVerificationController,
|
||||
verifyEmailController,
|
||||
} = require('./UserController');
|
||||
const { Group } = require('~/db/models');
|
||||
const { deleteConvos, acceptTerms } = require('~/models');
|
||||
const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService');
|
||||
|
||||
describe('verifyEmailController', () => {
|
||||
const mockRes = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns the generic verification error message from service failures', async () => {
|
||||
verifyEmail.mockResolvedValue(new Error('Invalid or expired email verification token'));
|
||||
|
||||
await verifyEmailController(
|
||||
{ body: { email: 'user%40example.com', token: 'not-the-token' } },
|
||||
mockRes,
|
||||
);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(400);
|
||||
expect(mockRes.json).toHaveBeenCalledWith({
|
||||
message: 'Invalid or expired email verification token',
|
||||
});
|
||||
});
|
||||
|
||||
it('uses the service status for resend verification responses', async () => {
|
||||
resendVerificationEmail.mockResolvedValue({ status: 500, message: 'Something went wrong.' });
|
||||
|
||||
await resendVerificationController({ body: { email: 'user@example.com' } }, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ message: 'Something went wrong.' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserController', () => {
|
||||
const mockRes = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should only expose public user response fields from the request user', async () => {
|
||||
const createdAt = new Date('2026-01-01T00:00:00.000Z');
|
||||
const updatedAt = new Date('2026-01-02T00:00:00.000Z');
|
||||
const req = {
|
||||
config: {},
|
||||
user: {
|
||||
id: 'user-id',
|
||||
_id: 'user-id',
|
||||
name: 'OpenID User',
|
||||
username: 'openid-user',
|
||||
email: 'openid@test.com',
|
||||
emailVerified: true,
|
||||
avatar: '/avatars/user-id.png',
|
||||
provider: 'openid',
|
||||
role: 'USER',
|
||||
plugins: ['web_search'],
|
||||
twoFactorEnabled: true,
|
||||
termsAccepted: true,
|
||||
personalization: { memories: false },
|
||||
favorites: [{ model: 'gpt-5', endpoint: 'openAI' }],
|
||||
skillStates: { skill_one: true },
|
||||
createdAt,
|
||||
updatedAt,
|
||||
tenantId: 'tenant-id',
|
||||
password: 'hashed-password',
|
||||
__v: 1,
|
||||
totpSecret: 'totp-secret',
|
||||
backupCodes: [{ codeHash: 'backup-code' }],
|
||||
pendingTotpSecret: 'pending-totp-secret',
|
||||
pendingBackupCodes: [{ codeHash: 'pending-backup-code' }],
|
||||
refreshToken: [{ refreshToken: 'legacy-refresh-token' }],
|
||||
googleId: 'google-id',
|
||||
openidId: 'openid-id',
|
||||
openidIssuer: 'openid-issuer',
|
||||
idOnTheSource: 'external-source-id',
|
||||
federatedTokens: {
|
||||
access_token: 'access-token',
|
||||
id_token: 'id-token',
|
||||
refresh_token: 'refresh-token',
|
||||
},
|
||||
openidTokens: {
|
||||
access_token: 'openid-access-token',
|
||||
refresh_token: 'openid-refresh-token',
|
||||
},
|
||||
tokenset: {
|
||||
access_token: 'tokenset-access-token',
|
||||
refresh_token: 'tokenset-refresh-token',
|
||||
},
|
||||
safeLookingRuntimeField: 'internal-value',
|
||||
},
|
||||
};
|
||||
|
||||
await getUserController(req, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||
const sentUser = mockRes.send.mock.calls[0][0];
|
||||
expect(sentUser).toMatchObject({
|
||||
id: 'user-id',
|
||||
_id: 'user-id',
|
||||
name: 'OpenID User',
|
||||
username: 'openid-user',
|
||||
email: 'openid@test.com',
|
||||
emailVerified: true,
|
||||
avatar: '/avatars/user-id.png',
|
||||
provider: 'openid',
|
||||
role: 'USER',
|
||||
plugins: ['web_search'],
|
||||
twoFactorEnabled: true,
|
||||
termsAccepted: true,
|
||||
personalization: { memories: false },
|
||||
favorites: [{ model: 'gpt-5', endpoint: 'openAI' }],
|
||||
skillStates: { skill_one: true },
|
||||
createdAt,
|
||||
updatedAt,
|
||||
tenantId: 'tenant-id',
|
||||
});
|
||||
expect(sentUser).not.toHaveProperty('password');
|
||||
expect(sentUser).not.toHaveProperty('__v');
|
||||
expect(sentUser).not.toHaveProperty('totpSecret');
|
||||
expect(sentUser).not.toHaveProperty('backupCodes');
|
||||
expect(sentUser).not.toHaveProperty('pendingTotpSecret');
|
||||
expect(sentUser).not.toHaveProperty('pendingBackupCodes');
|
||||
expect(sentUser).not.toHaveProperty('refreshToken');
|
||||
expect(sentUser).not.toHaveProperty('googleId');
|
||||
expect(sentUser).not.toHaveProperty('openidId');
|
||||
expect(sentUser).not.toHaveProperty('openidIssuer');
|
||||
expect(sentUser).not.toHaveProperty('idOnTheSource');
|
||||
expect(sentUser).not.toHaveProperty('federatedTokens');
|
||||
expect(sentUser).not.toHaveProperty('openidTokens');
|
||||
expect(sentUser).not.toHaveProperty('tokenset');
|
||||
expect(sentUser).not.toHaveProperty('safeLookingRuntimeField');
|
||||
});
|
||||
});
|
||||
|
||||
describe('acceptTermsController', () => {
|
||||
const mockRes = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns 404 when the user does not exist', async () => {
|
||||
acceptTerms.mockResolvedValueOnce(null);
|
||||
|
||||
await acceptTermsController({ user: { id: 'missing-user' } }, mockRes);
|
||||
|
||||
expect(acceptTerms).toHaveBeenCalledWith('missing-user');
|
||||
expect(mockRes.status).toHaveBeenCalledWith(404);
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ message: 'User not found' });
|
||||
});
|
||||
|
||||
it('returns the recorded acceptance timestamp on success', async () => {
|
||||
const acceptedAt = new Date('2026-06-14T10:00:00.000Z');
|
||||
acceptTerms.mockResolvedValueOnce({ termsAccepted: true, termsAcceptedAt: acceptedAt });
|
||||
|
||||
await acceptTermsController({ user: { id: 'user-id' } }, mockRes);
|
||||
|
||||
expect(acceptTerms).toHaveBeenCalledWith('user-id');
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||
expect(mockRes.json).toHaveBeenCalledWith({
|
||||
message: 'Terms accepted successfully',
|
||||
termsAcceptedAt: acceptedAt,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 500 when the update throws', async () => {
|
||||
acceptTerms.mockRejectedValueOnce(new Error('db down'));
|
||||
|
||||
await acceptTermsController({ user: { id: 'user-id' } }, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ message: 'Error accepting terms' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteUserController', () => {
|
||||
const mockRes = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return 200 on successful deletion', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const req = { user: { id: userId.toString(), _id: userId, email: 'test@test.com' } };
|
||||
|
||||
await deleteUserController(req, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({ message: 'User deleted' });
|
||||
});
|
||||
|
||||
it('should remove the user from all groups via $pullAll', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const userIdStr = userId.toString();
|
||||
const otherUser = new mongoose.Types.ObjectId().toString();
|
||||
|
||||
await Group.create([
|
||||
{ name: 'Group A', memberIds: [userIdStr, otherUser], source: 'local' },
|
||||
{ name: 'Group B', memberIds: [userIdStr], source: 'local' },
|
||||
{ name: 'Group C', memberIds: [otherUser], source: 'local' },
|
||||
]);
|
||||
|
||||
const req = { user: { id: userIdStr, _id: userId, email: 'del@test.com' } };
|
||||
await deleteUserController(req, mockRes);
|
||||
|
||||
const groups = await Group.find({}).sort({ name: 1 }).lean();
|
||||
expect(groups[0].memberIds).toEqual([otherUser]);
|
||||
expect(groups[1].memberIds).toEqual([]);
|
||||
expect(groups[2].memberIds).toEqual([otherUser]);
|
||||
});
|
||||
|
||||
it('should handle user that exists in no groups', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
await Group.create({ name: 'Empty', memberIds: ['someone-else'], source: 'local' });
|
||||
|
||||
const req = { user: { id: userId.toString(), _id: userId, email: 'no-groups@test.com' } };
|
||||
await deleteUserController(req, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||
const group = await Group.findOne({ name: 'Empty' }).lean();
|
||||
expect(group.memberIds).toEqual(['someone-else']);
|
||||
});
|
||||
|
||||
it('should remove duplicate memberIds if the user appears more than once', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const userIdStr = userId.toString();
|
||||
|
||||
await Group.create({
|
||||
name: 'Dupes',
|
||||
memberIds: [userIdStr, 'other', userIdStr],
|
||||
source: 'local',
|
||||
});
|
||||
|
||||
const req = { user: { id: userIdStr, _id: userId, email: 'dupe@test.com' } };
|
||||
await deleteUserController(req, mockRes);
|
||||
|
||||
const group = await Group.findOne({ name: 'Dupes' }).lean();
|
||||
expect(group.memberIds).toEqual(['other']);
|
||||
});
|
||||
|
||||
it('should still succeed when deleteConvos throws', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
deleteConvos.mockRejectedValueOnce(new Error('no convos'));
|
||||
|
||||
const req = { user: { id: userId.toString(), _id: userId, email: 'convos@test.com' } };
|
||||
await deleteUserController(req, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||
expect(mockRes.send).toHaveBeenCalledWith({ message: 'User deleted' });
|
||||
});
|
||||
|
||||
it('should return 500 when a critical operation fails', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const { deleteMessages } = require('~/models');
|
||||
deleteMessages.mockRejectedValueOnce(new Error('db down'));
|
||||
|
||||
const req = { user: { id: userId.toString(), _id: userId, email: 'fail@test.com' } };
|
||||
await deleteUserController(req, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(500);
|
||||
expect(mockRes.json).toHaveBeenCalledWith({ message: 'Something went wrong.' });
|
||||
});
|
||||
|
||||
it('should use string user.id (not ObjectId user._id) for memberIds removal', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const userIdStr = userId.toString();
|
||||
const otherUser = 'other-user-id';
|
||||
|
||||
await Group.create({
|
||||
name: 'StringCheck',
|
||||
memberIds: [userIdStr, otherUser],
|
||||
source: 'local',
|
||||
});
|
||||
|
||||
const req = { user: { id: userIdStr, _id: userId, email: 'stringcheck@test.com' } };
|
||||
await deleteUserController(req, mockRes);
|
||||
|
||||
const group = await Group.findOne({ name: 'StringCheck' }).lean();
|
||||
expect(group.memberIds).toEqual([otherUser]);
|
||||
expect(group.memberIds).not.toContain(userIdStr);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,426 @@
|
||||
const mongoose = require('mongoose');
|
||||
|
||||
const mockLogger = { error: jest.fn(), warn: jest.fn(), info: jest.fn(), debug: jest.fn() };
|
||||
const mockGetTenantId = jest.fn();
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: mockLogger,
|
||||
getTenantId: mockGetTenantId,
|
||||
SYSTEM_TENANT_ID: '__SYSTEM__',
|
||||
}));
|
||||
|
||||
const { AccessRoleIds, ResourceType, PrincipalType } =
|
||||
jest.requireActual('librechat-data-provider');
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
...jest.requireActual('librechat-data-provider'),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
enrichRemoteAgentPrincipals: jest.fn(),
|
||||
backfillRemoteAgentPermissions: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockBulkUpdateResourcePermissions = jest.fn();
|
||||
|
||||
jest.mock('~/server/services/PermissionService', () => ({
|
||||
bulkUpdateResourcePermissions: (...args) => mockBulkUpdateResourcePermissions(...args),
|
||||
ensureGroupPrincipalExists: jest.fn(),
|
||||
getEffectivePermissions: jest.fn(),
|
||||
ensurePrincipalExists: jest.fn(),
|
||||
getAvailableRoles: jest.fn(),
|
||||
findAccessibleResources: jest.fn(),
|
||||
getResourcePermissionsMap: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockRemoveAgentFromUserFavorites = jest.fn();
|
||||
|
||||
jest.mock('~/models', () => ({
|
||||
aggregateAclEntries: jest.fn(),
|
||||
searchPrincipals: jest.fn(),
|
||||
sortPrincipalsByRelevance: jest.fn(),
|
||||
calculateRelevanceScore: jest.fn(),
|
||||
removeAgentFromUserFavorites: (...args) => mockRemoveAgentFromUserFavorites(...args),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/GraphApiService', () => ({
|
||||
entraIdPrincipalFeatureEnabled: jest.fn(() => false),
|
||||
searchEntraIdPrincipals: jest.fn(),
|
||||
}));
|
||||
|
||||
const db = require('~/models');
|
||||
const {
|
||||
updateResourcePermissions,
|
||||
searchPrincipals,
|
||||
getResourcePermissions,
|
||||
} = require('../PermissionsController');
|
||||
|
||||
const createMockReq = (overrides = {}) => ({
|
||||
params: { resourceType: ResourceType.AGENT, resourceId: '507f1f77bcf86cd799439011' },
|
||||
body: { updated: [], removed: [], public: false },
|
||||
user: { id: 'user-1', role: 'USER' },
|
||||
headers: { authorization: '' },
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockRes = () => {
|
||||
const res = {};
|
||||
res.status = jest.fn().mockReturnValue(res);
|
||||
res.json = jest.fn().mockReturnValue(res);
|
||||
return res;
|
||||
};
|
||||
|
||||
const flushPromises = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
describe('PermissionsController', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGetTenantId.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
describe('searchPrincipals', () => {
|
||||
beforeEach(() => {
|
||||
db.searchPrincipals.mockResolvedValue([]);
|
||||
db.calculateRelevanceScore.mockReturnValue(50);
|
||||
db.sortPrincipalsByRelevance.mockImplementation((results) => results);
|
||||
});
|
||||
|
||||
it('rejects non-string query parameters', async () => {
|
||||
const req = createMockReq({
|
||||
query: { q: ['alice'] },
|
||||
});
|
||||
const res = createMockRes();
|
||||
|
||||
await searchPrincipals(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Query parameter "q" is required and must not be empty',
|
||||
});
|
||||
expect(db.searchPrincipals).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('searches with the trimmed literal query', async () => {
|
||||
db.searchPrincipals.mockResolvedValue([
|
||||
{
|
||||
id: 'user-1',
|
||||
type: PrincipalType.USER,
|
||||
name: 'Regex [invalid User',
|
||||
source: 'local',
|
||||
},
|
||||
]);
|
||||
|
||||
const req = createMockReq({
|
||||
query: { q: ' [invalid ', limit: '5', types: PrincipalType.USER },
|
||||
});
|
||||
const res = createMockRes();
|
||||
|
||||
await searchPrincipals(req, res);
|
||||
|
||||
expect(db.searchPrincipals).toHaveBeenCalledWith('[invalid', 5, [PrincipalType.USER]);
|
||||
expect(db.calculateRelevanceScore).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ name: 'Regex [invalid User' }),
|
||||
'[invalid',
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
query: '[invalid',
|
||||
limit: 5,
|
||||
count: 1,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('does not expose internal error details on search failures', async () => {
|
||||
db.searchPrincipals.mockRejectedValue(new Error('database failure with internal detail'));
|
||||
|
||||
const req = createMockReq({
|
||||
query: { q: 'alice' },
|
||||
});
|
||||
const res = createMockRes();
|
||||
|
||||
await searchPrincipals(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Failed to search principals',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getResourcePermissions — principal details', () => {
|
||||
const currentTenantId = 'tenant-a';
|
||||
const otherTenantId = 'tenant-b';
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const groupId = new mongoose.Types.ObjectId();
|
||||
|
||||
it('omits joined user and group details outside the current request context', async () => {
|
||||
mockGetTenantId.mockReturnValue(currentTenantId);
|
||||
db.aggregateAclEntries.mockResolvedValue([
|
||||
{
|
||||
principalType: PrincipalType.USER,
|
||||
accessRoleId: AccessRoleIds.AGENT_VIEWER,
|
||||
userInfo: {
|
||||
_id: userId,
|
||||
tenantId: otherTenantId,
|
||||
name: 'Outside User',
|
||||
email: 'outside-user@example.com',
|
||||
avatar: 'outside-user.png',
|
||||
},
|
||||
},
|
||||
{
|
||||
principalType: PrincipalType.GROUP,
|
||||
accessRoleId: AccessRoleIds.AGENT_VIEWER,
|
||||
groupInfo: {
|
||||
_id: groupId,
|
||||
tenantId: otherTenantId,
|
||||
name: 'Outside Group',
|
||||
email: 'outside-group@example.com',
|
||||
avatar: 'outside-group.png',
|
||||
},
|
||||
},
|
||||
{
|
||||
principalType: PrincipalType.PUBLIC,
|
||||
accessRoleId: AccessRoleIds.AGENT_VIEWER,
|
||||
},
|
||||
]);
|
||||
|
||||
const req = createMockReq();
|
||||
const res = createMockRes();
|
||||
|
||||
await getResourcePermissions(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
resourceType: ResourceType.AGENT,
|
||||
resourceId: req.params.resourceId,
|
||||
principals: [],
|
||||
public: true,
|
||||
publicAccessRoleId: AccessRoleIds.AGENT_VIEWER,
|
||||
});
|
||||
expect(JSON.stringify(res.json.mock.calls[0][0])).not.toContain('outside-user@example.com');
|
||||
expect(JSON.stringify(res.json.mock.calls[0][0])).not.toContain('outside-group@example.com');
|
||||
});
|
||||
|
||||
it('includes joined user and group details in the current request context', async () => {
|
||||
mockGetTenantId.mockReturnValue(currentTenantId);
|
||||
db.aggregateAclEntries.mockResolvedValue([
|
||||
{
|
||||
principalType: PrincipalType.USER,
|
||||
accessRoleId: AccessRoleIds.AGENT_VIEWER,
|
||||
userInfo: {
|
||||
_id: userId,
|
||||
tenantId: currentTenantId,
|
||||
name: 'Current User',
|
||||
email: 'current-user@example.com',
|
||||
avatar: 'current-user.png',
|
||||
},
|
||||
},
|
||||
{
|
||||
principalType: PrincipalType.GROUP,
|
||||
accessRoleId: AccessRoleIds.AGENT_VIEWER,
|
||||
groupInfo: {
|
||||
_id: groupId,
|
||||
tenantId: currentTenantId,
|
||||
name: 'Current Group',
|
||||
email: 'current-group@example.com',
|
||||
avatar: 'current-group.png',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const req = createMockReq();
|
||||
const res = createMockRes();
|
||||
|
||||
await getResourcePermissions(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json.mock.calls[0][0].principals).toEqual([
|
||||
expect.objectContaining({
|
||||
type: PrincipalType.USER,
|
||||
id: userId.toString(),
|
||||
email: 'current-user@example.com',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
type: PrincipalType.GROUP,
|
||||
id: groupId.toString(),
|
||||
email: 'current-group@example.com',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateResourcePermissions — favorites cleanup', () => {
|
||||
const agentObjectId = new mongoose.Types.ObjectId().toString();
|
||||
const revokedUserId = new mongoose.Types.ObjectId().toString();
|
||||
|
||||
beforeEach(() => {
|
||||
mockBulkUpdateResourcePermissions.mockResolvedValue({
|
||||
granted: [],
|
||||
updated: [],
|
||||
revoked: [{ type: PrincipalType.USER, id: revokedUserId, name: 'Revoked User' }],
|
||||
errors: [],
|
||||
});
|
||||
|
||||
mockRemoveAgentFromUserFavorites.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('removes agent from revoked users favorites on AGENT resource type', async () => {
|
||||
const req = createMockReq({
|
||||
params: { resourceType: ResourceType.AGENT, resourceId: agentObjectId },
|
||||
body: {
|
||||
updated: [],
|
||||
removed: [{ type: PrincipalType.USER, id: revokedUserId }],
|
||||
public: false,
|
||||
},
|
||||
});
|
||||
const res = createMockRes();
|
||||
|
||||
await updateResourcePermissions(req, res);
|
||||
await flushPromises();
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(mockRemoveAgentFromUserFavorites).toHaveBeenCalledWith(agentObjectId, [revokedUserId]);
|
||||
});
|
||||
|
||||
it('removes agent from revoked users favorites on REMOTE_AGENT resource type', async () => {
|
||||
const req = createMockReq({
|
||||
params: { resourceType: ResourceType.REMOTE_AGENT, resourceId: agentObjectId },
|
||||
body: {
|
||||
updated: [],
|
||||
removed: [{ type: PrincipalType.USER, id: revokedUserId }],
|
||||
public: false,
|
||||
},
|
||||
});
|
||||
const res = createMockRes();
|
||||
|
||||
await updateResourcePermissions(req, res);
|
||||
await flushPromises();
|
||||
|
||||
expect(mockRemoveAgentFromUserFavorites).toHaveBeenCalledWith(agentObjectId, [revokedUserId]);
|
||||
});
|
||||
|
||||
it('uses results.revoked (validated) not raw request payload', async () => {
|
||||
const validId = new mongoose.Types.ObjectId().toString();
|
||||
const invalidId = 'not-a-valid-id';
|
||||
|
||||
mockBulkUpdateResourcePermissions.mockResolvedValue({
|
||||
granted: [],
|
||||
updated: [],
|
||||
revoked: [{ type: PrincipalType.USER, id: validId }],
|
||||
errors: [{ principal: { type: PrincipalType.USER, id: invalidId }, error: 'Invalid ID' }],
|
||||
});
|
||||
|
||||
const req = createMockReq({
|
||||
params: { resourceType: ResourceType.AGENT, resourceId: agentObjectId },
|
||||
body: {
|
||||
updated: [],
|
||||
removed: [
|
||||
{ type: PrincipalType.USER, id: validId },
|
||||
{ type: PrincipalType.USER, id: invalidId },
|
||||
],
|
||||
public: false,
|
||||
},
|
||||
});
|
||||
const res = createMockRes();
|
||||
|
||||
await updateResourcePermissions(req, res);
|
||||
await flushPromises();
|
||||
|
||||
expect(mockRemoveAgentFromUserFavorites).toHaveBeenCalledWith(agentObjectId, [validId]);
|
||||
});
|
||||
|
||||
it('skips cleanup when no USER principals are revoked', async () => {
|
||||
mockBulkUpdateResourcePermissions.mockResolvedValue({
|
||||
granted: [],
|
||||
updated: [],
|
||||
revoked: [{ type: PrincipalType.GROUP, id: 'group-1' }],
|
||||
errors: [],
|
||||
});
|
||||
|
||||
const req = createMockReq({
|
||||
params: { resourceType: ResourceType.AGENT, resourceId: agentObjectId },
|
||||
body: {
|
||||
updated: [],
|
||||
removed: [{ type: PrincipalType.GROUP, id: 'group-1' }],
|
||||
public: false,
|
||||
},
|
||||
});
|
||||
const res = createMockRes();
|
||||
|
||||
await updateResourcePermissions(req, res);
|
||||
await flushPromises();
|
||||
|
||||
expect(mockRemoveAgentFromUserFavorites).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips cleanup for non-agent resource types', async () => {
|
||||
mockBulkUpdateResourcePermissions.mockResolvedValue({
|
||||
granted: [],
|
||||
updated: [],
|
||||
revoked: [{ type: PrincipalType.USER, id: revokedUserId }],
|
||||
errors: [],
|
||||
});
|
||||
|
||||
const req = createMockReq({
|
||||
params: { resourceType: ResourceType.PROMPTGROUP, resourceId: agentObjectId },
|
||||
body: {
|
||||
updated: [],
|
||||
removed: [{ type: PrincipalType.USER, id: revokedUserId }],
|
||||
public: false,
|
||||
},
|
||||
});
|
||||
const res = createMockRes();
|
||||
|
||||
await updateResourcePermissions(req, res);
|
||||
await flushPromises();
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(mockRemoveAgentFromUserFavorites).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles agent not found gracefully', async () => {
|
||||
mockRemoveAgentFromUserFavorites.mockResolvedValue(undefined);
|
||||
|
||||
const req = createMockReq({
|
||||
params: { resourceType: ResourceType.AGENT, resourceId: agentObjectId },
|
||||
body: {
|
||||
updated: [],
|
||||
removed: [{ type: PrincipalType.USER, id: revokedUserId }],
|
||||
public: false,
|
||||
},
|
||||
});
|
||||
const res = createMockRes();
|
||||
|
||||
await updateResourcePermissions(req, res);
|
||||
await flushPromises();
|
||||
|
||||
expect(mockRemoveAgentFromUserFavorites).toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
|
||||
it('logs error when removeAgentFromUserFavorites fails without blocking response', async () => {
|
||||
mockRemoveAgentFromUserFavorites.mockRejectedValue(new Error('DB connection lost'));
|
||||
|
||||
const req = createMockReq({
|
||||
params: { resourceType: ResourceType.AGENT, resourceId: agentObjectId },
|
||||
body: {
|
||||
updated: [],
|
||||
removed: [{ type: PrincipalType.USER, id: revokedUserId }],
|
||||
public: false,
|
||||
},
|
||||
});
|
||||
const res = createMockRes();
|
||||
|
||||
await updateResourcePermissions(req, res);
|
||||
await flushPromises();
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(mockLogger.error).toHaveBeenCalledWith(
|
||||
'[removeRevokedAgentFromFavorites] Error cleaning up favorites',
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
const mockGetUserById = jest.fn();
|
||||
const mockUpdateUser = jest.fn();
|
||||
const mockVerifyOTPOrBackupCode = jest.fn();
|
||||
const mockGenerateTOTPSecret = jest.fn();
|
||||
const mockGenerateBackupCodes = jest.fn();
|
||||
const mockEncryptV3 = jest.fn();
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
encryptV3: (...args) => mockEncryptV3(...args),
|
||||
logger: { error: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/twoFactorService', () => ({
|
||||
verifyOTPOrBackupCode: (...args) => mockVerifyOTPOrBackupCode(...args),
|
||||
generateBackupCodes: (...args) => mockGenerateBackupCodes(...args),
|
||||
generateTOTPSecret: (...args) => mockGenerateTOTPSecret(...args),
|
||||
verifyBackupCode: jest.fn(),
|
||||
getTOTPSecret: jest.fn(),
|
||||
verifyTOTP: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/models', () => ({
|
||||
getUserById: (...args) => mockGetUserById(...args),
|
||||
updateUser: (...args) => mockUpdateUser(...args),
|
||||
}));
|
||||
|
||||
const { enable2FA, regenerateBackupCodes } = require('~/server/controllers/TwoFactorController');
|
||||
|
||||
function createRes() {
|
||||
const res = {};
|
||||
res.status = jest.fn().mockReturnValue(res);
|
||||
res.json = jest.fn().mockReturnValue(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
const PLAIN_CODES = ['code1', 'code2', 'code3'];
|
||||
const CODE_OBJECTS = [
|
||||
{ codeHash: 'h1', used: false, usedAt: null },
|
||||
{ codeHash: 'h2', used: false, usedAt: null },
|
||||
{ codeHash: 'h3', used: false, usedAt: null },
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGenerateTOTPSecret.mockReturnValue('NEWSECRET');
|
||||
mockGenerateBackupCodes.mockResolvedValue({ plainCodes: PLAIN_CODES, codeObjects: CODE_OBJECTS });
|
||||
mockEncryptV3.mockReturnValue('encrypted-secret');
|
||||
});
|
||||
|
||||
describe('enable2FA', () => {
|
||||
it('allows first-time setup without token — writes to pending fields', async () => {
|
||||
const req = { user: { id: 'user1' }, body: {} };
|
||||
const res = createRes();
|
||||
mockGetUserById.mockResolvedValue({ _id: 'user1', twoFactorEnabled: false, email: 'a@b.com' });
|
||||
mockUpdateUser.mockResolvedValue({ email: 'a@b.com' });
|
||||
|
||||
await enable2FA(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ otpauthUrl: expect.any(String), backupCodes: PLAIN_CODES }),
|
||||
);
|
||||
expect(mockVerifyOTPOrBackupCode).not.toHaveBeenCalled();
|
||||
const updateCall = mockUpdateUser.mock.calls[0][1];
|
||||
expect(updateCall).toHaveProperty('pendingTotpSecret', 'encrypted-secret');
|
||||
expect(updateCall).toHaveProperty('pendingBackupCodes', CODE_OBJECTS);
|
||||
expect(updateCall).not.toHaveProperty('twoFactorEnabled');
|
||||
expect(updateCall).not.toHaveProperty('totpSecret');
|
||||
expect(updateCall).not.toHaveProperty('backupCodes');
|
||||
});
|
||||
|
||||
it('re-enrollment writes to pending fields, leaving live 2FA intact', async () => {
|
||||
const req = { user: { id: 'user1' }, body: { token: '123456' } };
|
||||
const res = createRes();
|
||||
const existingUser = {
|
||||
_id: 'user1',
|
||||
twoFactorEnabled: true,
|
||||
totpSecret: 'enc-secret',
|
||||
email: 'a@b.com',
|
||||
};
|
||||
mockGetUserById.mockResolvedValue(existingUser);
|
||||
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
|
||||
mockUpdateUser.mockResolvedValue({ email: 'a@b.com' });
|
||||
|
||||
await enable2FA(req, res);
|
||||
|
||||
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith({
|
||||
user: existingUser,
|
||||
token: '123456',
|
||||
backupCode: undefined,
|
||||
persistBackupUse: false,
|
||||
});
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
const updateCall = mockUpdateUser.mock.calls[0][1];
|
||||
expect(updateCall).toHaveProperty('pendingTotpSecret', 'encrypted-secret');
|
||||
expect(updateCall).toHaveProperty('pendingBackupCodes', CODE_OBJECTS);
|
||||
expect(updateCall).not.toHaveProperty('twoFactorEnabled');
|
||||
expect(updateCall).not.toHaveProperty('totpSecret');
|
||||
});
|
||||
|
||||
it('allows re-enrollment with valid backup code (persistBackupUse: false)', async () => {
|
||||
const req = { user: { id: 'user1' }, body: { backupCode: 'backup123' } };
|
||||
const res = createRes();
|
||||
const existingUser = {
|
||||
_id: 'user1',
|
||||
twoFactorEnabled: true,
|
||||
totpSecret: 'enc-secret',
|
||||
email: 'a@b.com',
|
||||
};
|
||||
mockGetUserById.mockResolvedValue(existingUser);
|
||||
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
|
||||
mockUpdateUser.mockResolvedValue({ email: 'a@b.com' });
|
||||
|
||||
await enable2FA(req, res);
|
||||
|
||||
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ persistBackupUse: false }),
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
});
|
||||
|
||||
it('returns error when no token provided and 2FA is enabled', async () => {
|
||||
const req = { user: { id: 'user1' }, body: {} };
|
||||
const res = createRes();
|
||||
mockGetUserById.mockResolvedValue({
|
||||
_id: 'user1',
|
||||
twoFactorEnabled: true,
|
||||
totpSecret: 'enc-secret',
|
||||
});
|
||||
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: false, status: 400 });
|
||||
|
||||
await enable2FA(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(mockUpdateUser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 401 when invalid token provided and 2FA is enabled', async () => {
|
||||
const req = { user: { id: 'user1' }, body: { token: 'wrong' } };
|
||||
const res = createRes();
|
||||
mockGetUserById.mockResolvedValue({
|
||||
_id: 'user1',
|
||||
twoFactorEnabled: true,
|
||||
totpSecret: 'enc-secret',
|
||||
});
|
||||
mockVerifyOTPOrBackupCode.mockResolvedValue({
|
||||
verified: false,
|
||||
status: 401,
|
||||
message: 'Invalid token or backup code',
|
||||
});
|
||||
|
||||
await enable2FA(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({ message: 'Invalid token or backup code' });
|
||||
expect(mockUpdateUser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('regenerateBackupCodes', () => {
|
||||
it('returns 404 when user not found', async () => {
|
||||
const req = { user: { id: 'user1' }, body: {} };
|
||||
const res = createRes();
|
||||
mockGetUserById.mockResolvedValue(null);
|
||||
|
||||
await regenerateBackupCodes(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({ message: 'User not found' });
|
||||
});
|
||||
|
||||
it('requires OTP when 2FA is enabled', async () => {
|
||||
const req = { user: { id: 'user1' }, body: { token: '123456' } };
|
||||
const res = createRes();
|
||||
mockGetUserById.mockResolvedValue({
|
||||
_id: 'user1',
|
||||
twoFactorEnabled: true,
|
||||
totpSecret: 'enc-secret',
|
||||
});
|
||||
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
|
||||
mockUpdateUser.mockResolvedValue({});
|
||||
|
||||
await regenerateBackupCodes(req, res);
|
||||
|
||||
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
backupCodes: PLAIN_CODES,
|
||||
backupCodesHash: CODE_OBJECTS,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns error when no token provided and 2FA is enabled', async () => {
|
||||
const req = { user: { id: 'user1' }, body: {} };
|
||||
const res = createRes();
|
||||
mockGetUserById.mockResolvedValue({
|
||||
_id: 'user1',
|
||||
twoFactorEnabled: true,
|
||||
totpSecret: 'enc-secret',
|
||||
});
|
||||
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: false, status: 400 });
|
||||
|
||||
await regenerateBackupCodes(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
});
|
||||
|
||||
it('returns 401 when invalid token provided and 2FA is enabled', async () => {
|
||||
const req = { user: { id: 'user1' }, body: { token: 'wrong' } };
|
||||
const res = createRes();
|
||||
mockGetUserById.mockResolvedValue({
|
||||
_id: 'user1',
|
||||
twoFactorEnabled: true,
|
||||
totpSecret: 'enc-secret',
|
||||
});
|
||||
mockVerifyOTPOrBackupCode.mockResolvedValue({
|
||||
verified: false,
|
||||
status: 401,
|
||||
message: 'Invalid token or backup code',
|
||||
});
|
||||
|
||||
await regenerateBackupCodes(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({ message: 'Invalid token or backup code' });
|
||||
});
|
||||
|
||||
it('includes backupCodesHash in response', async () => {
|
||||
const req = { user: { id: 'user1' }, body: { token: '123456' } };
|
||||
const res = createRes();
|
||||
mockGetUserById.mockResolvedValue({
|
||||
_id: 'user1',
|
||||
twoFactorEnabled: true,
|
||||
totpSecret: 'enc-secret',
|
||||
});
|
||||
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
|
||||
mockUpdateUser.mockResolvedValue({});
|
||||
|
||||
await regenerateBackupCodes(req, res);
|
||||
|
||||
const responseBody = res.json.mock.calls[0][0];
|
||||
expect(responseBody).toHaveProperty('backupCodesHash', CODE_OBJECTS);
|
||||
expect(responseBody).toHaveProperty('backupCodes', PLAIN_CODES);
|
||||
});
|
||||
|
||||
it('allows regeneration without token when 2FA is not enabled', async () => {
|
||||
const req = { user: { id: 'user1' }, body: {} };
|
||||
const res = createRes();
|
||||
mockGetUserById.mockResolvedValue({
|
||||
_id: 'user1',
|
||||
twoFactorEnabled: false,
|
||||
});
|
||||
mockUpdateUser.mockResolvedValue({});
|
||||
|
||||
await regenerateBackupCodes(req, res);
|
||||
|
||||
expect(mockVerifyOTPOrBackupCode).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
backupCodes: PLAIN_CODES,
|
||||
backupCodesHash: CODE_OBJECTS,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,467 @@
|
||||
const mockUpdateUserPlugins = jest.fn();
|
||||
const mockFindToken = jest.fn();
|
||||
const mockDeleteUserPluginAuth = jest.fn();
|
||||
const mockGetAppConfig = jest.fn();
|
||||
const mockInvalidateCachedTools = jest.fn();
|
||||
const mockGetLogStores = jest.fn();
|
||||
const mockGetMCPManager = jest.fn();
|
||||
const mockGetFlowStateManager = jest.fn();
|
||||
const mockGetMCPServersRegistry = jest.fn();
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { error: jest.fn(), info: jest.fn(), warn: jest.fn() },
|
||||
getTenantId: jest.fn(),
|
||||
webSearchKeys: [],
|
||||
}));
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
Tools: {},
|
||||
CacheKeys: { FLOWS: 'flows' },
|
||||
Constants: { mcp_delimiter: '_mcp_', mcp_prefix: 'mcp_' },
|
||||
FileSources: {},
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
MCPOAuthHandler: {
|
||||
generateFlowId: jest.fn((userId, serverName, tenantId) => {
|
||||
const flowId = `${userId}:${serverName}`;
|
||||
return tenantId ? `tenant:${encodeURIComponent(tenantId)}:${flowId}` : flowId;
|
||||
}),
|
||||
generateTokenFlowId: jest.fn((userId, serverName, tenantId) => {
|
||||
const flowId = `${userId}:${serverName}`;
|
||||
return tenantId ? `tenant:${encodeURIComponent(tenantId)}:${flowId}` : flowId;
|
||||
}),
|
||||
revokeOAuthToken: jest.fn(),
|
||||
},
|
||||
MCPTokenStorage: {
|
||||
getClientInfoAndMetadata: jest.fn(),
|
||||
getTokens: jest.fn(),
|
||||
deleteUserTokens: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
normalizeHttpError: jest.fn((error) => error),
|
||||
extractWebSearchEnvVars: jest.fn((params) => params.keys),
|
||||
getAppConfigOptionsFromUser: jest.fn((user) => {
|
||||
const hasSourceIdentity =
|
||||
user != null && Object.prototype.hasOwnProperty.call(user, 'idOnTheSource');
|
||||
return {
|
||||
role: user?.role,
|
||||
userId: user?.id,
|
||||
idOnTheSource: user?.id && hasSourceIdentity ? (user.idOnTheSource ?? null) : undefined,
|
||||
tenantId: user?.tenantId,
|
||||
};
|
||||
}),
|
||||
needsRefresh: jest.fn(),
|
||||
getNewS3URL: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/models', () => ({
|
||||
updateUserPlugins: (...args) => mockUpdateUserPlugins(...args),
|
||||
findToken: mockFindToken,
|
||||
deleteTokens: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/PluginService', () => ({
|
||||
updateUserPluginAuth: jest.fn(),
|
||||
deleteUserPluginAuth: (...args) => mockDeleteUserPluginAuth(...args),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/twoFactorService', () => ({
|
||||
verifyOTPOrBackupCode: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/AuthService', () => ({
|
||||
verifyEmail: jest.fn(),
|
||||
resendVerificationEmail: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/config', () => ({
|
||||
getMCPManager: (...args) => mockGetMCPManager(...args),
|
||||
getFlowStateManager: (...args) => mockGetFlowStateManager(...args),
|
||||
getMCPServersRegistry: (...args) => mockGetMCPServersRegistry(...args),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Config/getCachedTools', () => ({
|
||||
invalidateCachedTools: (...args) => mockInvalidateCachedTools(...args),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/process', () => ({
|
||||
processDeleteRequest: jest.fn().mockResolvedValue({ deletedFileIds: [], failedFileIds: [] }),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
getAppConfig: (...args) => mockGetAppConfig(...args),
|
||||
}));
|
||||
|
||||
jest.mock('~/cache', () => ({
|
||||
getLogStores: (...args) => mockGetLogStores(...args),
|
||||
}));
|
||||
|
||||
const { logger, getTenantId } = require('@librechat/data-schemas');
|
||||
const { MCPTokenStorage, MCPOAuthHandler } = require('@librechat/api');
|
||||
const { updateUserPluginsController } = require('~/server/controllers/UserController');
|
||||
|
||||
function createResponse() {
|
||||
const res = {};
|
||||
res.status = jest.fn().mockReturnValue(res);
|
||||
res.json = jest.fn().mockReturnValue(res);
|
||||
res.send = jest.fn().mockReturnValue(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
function createRequest() {
|
||||
return {
|
||||
user: {
|
||||
id: 'user-1',
|
||||
_id: 'user-1',
|
||||
plugins: [],
|
||||
role: 'USER',
|
||||
},
|
||||
body: {
|
||||
pluginKey: 'mcp_test-server',
|
||||
action: 'uninstall',
|
||||
auth: {},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setupMCPMocks() {
|
||||
const flowManager = {
|
||||
deleteFlow: jest.fn().mockResolvedValue(true),
|
||||
};
|
||||
const mcpManager = {
|
||||
disconnectUserConnection: jest.fn().mockResolvedValue(),
|
||||
};
|
||||
const registry = {
|
||||
getServerConfig: jest.fn().mockResolvedValue({
|
||||
url: 'https://example.com/mcp',
|
||||
oauth: {},
|
||||
oauth_headers: {},
|
||||
}),
|
||||
getOAuthServers: jest.fn().mockResolvedValue(new Set(['test-server'])),
|
||||
getAllowedDomains: jest.fn().mockReturnValue([]),
|
||||
getAllowedAddresses: jest.fn().mockReturnValue(null),
|
||||
};
|
||||
|
||||
// Revocation reads the merged config's mcpSettings allowlists (not the registry getters).
|
||||
mockGetAppConfig.mockResolvedValue({
|
||||
mcpSettings: { allowedDomains: [], allowedAddresses: null },
|
||||
});
|
||||
mockUpdateUserPlugins.mockResolvedValue();
|
||||
mockDeleteUserPluginAuth.mockResolvedValue();
|
||||
mockInvalidateCachedTools.mockResolvedValue();
|
||||
mockGetLogStores.mockReturnValue({});
|
||||
mockGetFlowStateManager.mockReturnValue(flowManager);
|
||||
mockGetMCPManager.mockReturnValue(mcpManager);
|
||||
mockGetMCPServersRegistry.mockReturnValue(registry);
|
||||
|
||||
return { flowManager, mcpManager, registry };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getTenantId.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
describe('updateUserPluginsController MCP OAuth cleanup', () => {
|
||||
it('clears stored OAuth token state when client metadata is missing', async () => {
|
||||
const { flowManager, mcpManager } = setupMCPMocks();
|
||||
MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue(null);
|
||||
|
||||
const res = createResponse();
|
||||
await updateUserPluginsController(createRequest(), res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(MCPTokenStorage.getClientInfoAndMetadata).toHaveBeenCalledWith({
|
||||
userId: 'user-1',
|
||||
serverName: 'test-server',
|
||||
findToken: mockFindToken,
|
||||
});
|
||||
expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
|
||||
userId: 'user-1',
|
||||
serverName: 'test-server',
|
||||
deleteToken: expect.any(Function),
|
||||
});
|
||||
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens');
|
||||
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_oauth');
|
||||
expect(MCPOAuthHandler.revokeOAuthToken).not.toHaveBeenCalled();
|
||||
expect(mcpManager.disconnectUserConnection).toHaveBeenCalledWith('user-1', 'test-server');
|
||||
});
|
||||
|
||||
it('still clears OAuth flow state when stored token deletion fails', async () => {
|
||||
const { flowManager } = setupMCPMocks();
|
||||
const cleanupError = new Error('DB down');
|
||||
MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue(null);
|
||||
MCPTokenStorage.deleteUserTokens.mockRejectedValueOnce(cleanupError);
|
||||
|
||||
const res = createResponse();
|
||||
await updateUserPluginsController(createRequest(), res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens');
|
||||
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_oauth');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'[clearStoredMCPOAuthState] Failed to delete MCP OAuth tokens for test-server:',
|
||||
cleanupError,
|
||||
);
|
||||
});
|
||||
|
||||
it('logs all flow cleanup failures without failing MCP OAuth cleanup', async () => {
|
||||
const { flowManager } = setupMCPMocks();
|
||||
const getTokensFlowError = new Error('get tokens flow cache down');
|
||||
const oauthFlowError = new Error('oauth flow cache down');
|
||||
MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue(null);
|
||||
flowManager.deleteFlow
|
||||
.mockRejectedValueOnce(getTokensFlowError)
|
||||
.mockRejectedValueOnce(oauthFlowError);
|
||||
|
||||
const res = createResponse();
|
||||
await updateUserPluginsController(createRequest(), res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens');
|
||||
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_oauth');
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'[clearStoredMCPOAuthState] Failed to clear MCP OAuth flow state for test-server:',
|
||||
getTokensFlowError,
|
||||
);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'[clearStoredMCPOAuthState] Failed to clear MCP OAuth flow state for test-server:',
|
||||
oauthFlowError,
|
||||
);
|
||||
});
|
||||
|
||||
it('clears stored OAuth token state when client metadata cannot be loaded', async () => {
|
||||
const { flowManager } = setupMCPMocks();
|
||||
MCPTokenStorage.getClientInfoAndMetadata.mockRejectedValue(new Error('invalid client info'));
|
||||
|
||||
const res = createResponse();
|
||||
await updateUserPluginsController(createRequest(), res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'[maybeUninstallOAuthMCP] Unable to load OAuth client metadata for test-server; clearing local MCP OAuth state only.',
|
||||
expect.any(Error),
|
||||
);
|
||||
expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
|
||||
userId: 'user-1',
|
||||
serverName: 'test-server',
|
||||
deleteToken: expect.any(Function),
|
||||
});
|
||||
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens');
|
||||
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_oauth');
|
||||
expect(MCPTokenStorage.getTokens).not.toHaveBeenCalled();
|
||||
expect(MCPOAuthHandler.revokeOAuthToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears tenant-scoped and legacy OAuth flow state when tenant context exists', async () => {
|
||||
const { flowManager } = setupMCPMocks();
|
||||
getTenantId.mockReturnValue('tenant-a');
|
||||
MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue(null);
|
||||
|
||||
const res = createResponse();
|
||||
await updateUserPluginsController(createRequest(), res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(flowManager.deleteFlow).toHaveBeenCalledWith(
|
||||
'tenant:tenant-a:user-1:test-server',
|
||||
'mcp_get_tokens',
|
||||
);
|
||||
expect(flowManager.deleteFlow).toHaveBeenCalledWith(
|
||||
'tenant:tenant-a:user-1:test-server',
|
||||
'mcp_oauth',
|
||||
);
|
||||
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens');
|
||||
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_oauth');
|
||||
});
|
||||
|
||||
it('clears stored OAuth token state when server config is missing', async () => {
|
||||
const { flowManager, registry } = setupMCPMocks();
|
||||
registry.getServerConfig.mockResolvedValue(undefined);
|
||||
|
||||
const res = createResponse();
|
||||
await updateUserPluginsController(createRequest(), res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
|
||||
userId: 'user-1',
|
||||
serverName: 'test-server',
|
||||
deleteToken: expect.any(Function),
|
||||
});
|
||||
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens');
|
||||
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_oauth');
|
||||
expect(MCPTokenStorage.getClientInfoAndMetadata).not.toHaveBeenCalled();
|
||||
expect(MCPOAuthHandler.revokeOAuthToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears stored OAuth token state when server no longer requires OAuth', async () => {
|
||||
const { flowManager, registry } = setupMCPMocks();
|
||||
registry.getOAuthServers.mockResolvedValue(new Set());
|
||||
|
||||
const res = createResponse();
|
||||
await updateUserPluginsController(createRequest(), res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
|
||||
userId: 'user-1',
|
||||
serverName: 'test-server',
|
||||
deleteToken: expect.any(Function),
|
||||
});
|
||||
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens');
|
||||
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_oauth');
|
||||
expect(MCPTokenStorage.getClientInfoAndMetadata).not.toHaveBeenCalled();
|
||||
expect(MCPOAuthHandler.revokeOAuthToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clears stored OAuth token state when token loading fails before provider revocation', async () => {
|
||||
const { flowManager } = setupMCPMocks();
|
||||
MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue({
|
||||
clientInfo: { client_id: 'client-1' },
|
||||
clientMetadata: {},
|
||||
});
|
||||
MCPTokenStorage.getTokens.mockRejectedValue(new Error('token lookup failed'));
|
||||
|
||||
const res = createResponse();
|
||||
await updateUserPluginsController(createRequest(), res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(MCPTokenStorage.getTokens).toHaveBeenCalledWith({
|
||||
userId: 'user-1',
|
||||
serverName: 'test-server',
|
||||
findToken: mockFindToken,
|
||||
});
|
||||
expect(logger.warn).toHaveBeenCalledWith(
|
||||
'[maybeUninstallOAuthMCP] Unable to load OAuth tokens for test-server; clearing local token state.',
|
||||
expect.any(Error),
|
||||
);
|
||||
expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
|
||||
userId: 'user-1',
|
||||
serverName: 'test-server',
|
||||
deleteToken: expect.any(Function),
|
||||
});
|
||||
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_get_tokens');
|
||||
expect(flowManager.deleteFlow).toHaveBeenCalledWith('user-1:test-server', 'mcp_oauth');
|
||||
expect(MCPOAuthHandler.revokeOAuthToken).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('revokes provider tokens before clearing local token state when token data is available', async () => {
|
||||
setupMCPMocks();
|
||||
MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue({
|
||||
clientInfo: { client_id: 'client-1', client_secret: 'secret-1' },
|
||||
clientMetadata: { revocation_endpoint: 'https://example.com/revoke' },
|
||||
});
|
||||
MCPTokenStorage.getTokens.mockResolvedValue({
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
});
|
||||
MCPOAuthHandler.revokeOAuthToken.mockResolvedValue();
|
||||
|
||||
const res = createResponse();
|
||||
await updateUserPluginsController(createRequest(), res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(MCPTokenStorage.getTokens).toHaveBeenCalledWith({
|
||||
userId: 'user-1',
|
||||
serverName: 'test-server',
|
||||
findToken: mockFindToken,
|
||||
});
|
||||
expect(MCPOAuthHandler.revokeOAuthToken).toHaveBeenCalledWith(
|
||||
'test-server',
|
||||
'access-token',
|
||||
'access',
|
||||
{
|
||||
serverUrl: 'https://example.com/mcp',
|
||||
clientId: 'client-1',
|
||||
clientSecret: 'secret-1',
|
||||
revocationEndpoint: 'https://example.com/revoke',
|
||||
revocationEndpointAuthMethodsSupported: undefined,
|
||||
},
|
||||
{},
|
||||
[],
|
||||
null,
|
||||
);
|
||||
expect(MCPOAuthHandler.revokeOAuthToken).toHaveBeenCalledWith(
|
||||
'test-server',
|
||||
'refresh-token',
|
||||
'refresh',
|
||||
{
|
||||
serverUrl: 'https://example.com/mcp',
|
||||
clientId: 'client-1',
|
||||
clientSecret: 'secret-1',
|
||||
revocationEndpoint: 'https://example.com/revoke',
|
||||
revocationEndpointAuthMethodsSupported: undefined,
|
||||
},
|
||||
{},
|
||||
[],
|
||||
null,
|
||||
);
|
||||
expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
|
||||
userId: 'user-1',
|
||||
serverName: 'test-server',
|
||||
deleteToken: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it('revokes only the access token when refresh token data is absent', async () => {
|
||||
setupMCPMocks();
|
||||
MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue({
|
||||
clientInfo: { client_id: 'client-1', client_secret: 'secret-1' },
|
||||
clientMetadata: {},
|
||||
});
|
||||
MCPTokenStorage.getTokens.mockResolvedValue({
|
||||
access_token: 'access-token',
|
||||
});
|
||||
MCPOAuthHandler.revokeOAuthToken.mockResolvedValue();
|
||||
|
||||
const res = createResponse();
|
||||
await updateUserPluginsController(createRequest(), res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(MCPOAuthHandler.revokeOAuthToken).toHaveBeenCalledTimes(1);
|
||||
expect(MCPOAuthHandler.revokeOAuthToken).toHaveBeenCalledWith(
|
||||
'test-server',
|
||||
'access-token',
|
||||
'access',
|
||||
expect.objectContaining({ clientId: 'client-1' }),
|
||||
{},
|
||||
[],
|
||||
null,
|
||||
);
|
||||
expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
|
||||
userId: 'user-1',
|
||||
serverName: 'test-server',
|
||||
deleteToken: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it('revokes only the refresh token when access token data is absent', async () => {
|
||||
setupMCPMocks();
|
||||
MCPTokenStorage.getClientInfoAndMetadata.mockResolvedValue({
|
||||
clientInfo: { client_id: 'client-1', client_secret: 'secret-1' },
|
||||
clientMetadata: {},
|
||||
});
|
||||
MCPTokenStorage.getTokens.mockResolvedValue({
|
||||
refresh_token: 'refresh-token',
|
||||
});
|
||||
MCPOAuthHandler.revokeOAuthToken.mockResolvedValue();
|
||||
|
||||
const res = createResponse();
|
||||
await updateUserPluginsController(createRequest(), res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(MCPOAuthHandler.revokeOAuthToken).toHaveBeenCalledTimes(1);
|
||||
expect(MCPOAuthHandler.revokeOAuthToken).toHaveBeenCalledWith(
|
||||
'test-server',
|
||||
'refresh-token',
|
||||
'refresh',
|
||||
expect.objectContaining({ clientId: 'client-1' }),
|
||||
{},
|
||||
[],
|
||||
null,
|
||||
);
|
||||
expect(MCPTokenStorage.deleteUserTokens).toHaveBeenCalledWith({
|
||||
userId: 'user-1',
|
||||
serverName: 'test-server',
|
||||
deleteToken: expect.any(Function),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,296 @@
|
||||
const mockGetUserById = jest.fn();
|
||||
const mockDeleteMessages = jest.fn();
|
||||
const mockDeleteAllUserSessions = jest.fn();
|
||||
const mockDeleteUserById = jest.fn();
|
||||
const mockDeleteAllSharedLinks = jest.fn();
|
||||
const mockDeleteAllSharedLinksWithCleanup = jest.fn();
|
||||
const mockDeletePresets = jest.fn();
|
||||
const mockDeleteUserKey = jest.fn();
|
||||
const mockDeleteConvos = jest.fn();
|
||||
const mockDeleteFiles = jest.fn();
|
||||
const mockGetFiles = jest.fn();
|
||||
const mockUpdateUserPlugins = jest.fn();
|
||||
const mockUpdateUser = jest.fn();
|
||||
const mockFindToken = jest.fn();
|
||||
const mockVerifyOTPOrBackupCode = jest.fn();
|
||||
const mockDeleteUserPluginAuth = jest.fn();
|
||||
const mockProcessDeleteRequest = jest.fn();
|
||||
const mockDeleteToolCalls = jest.fn();
|
||||
const mockDeleteUserAgents = jest.fn();
|
||||
const mockDeleteUserPrompts = jest.fn();
|
||||
const mockDeleteUserSkills = jest.fn();
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { error: jest.fn(), info: jest.fn() },
|
||||
webSearchKeys: [],
|
||||
}));
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
Tools: {},
|
||||
CacheKeys: {},
|
||||
Constants: { mcp_delimiter: '::', mcp_prefix: 'mcp_' },
|
||||
FileSources: {},
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
MCPOAuthHandler: {},
|
||||
MCPTokenStorage: {},
|
||||
normalizeHttpError: jest.fn(),
|
||||
extractWebSearchEnvVars: jest.fn(),
|
||||
needsRefresh: jest.fn(),
|
||||
getNewS3URL: jest.fn(),
|
||||
deleteAllSharedLinksWithCleanup: (...args) => mockDeleteAllSharedLinksWithCleanup(...args),
|
||||
}));
|
||||
|
||||
jest.mock('~/models', () => ({
|
||||
deleteAllUserSessions: (...args) => mockDeleteAllUserSessions(...args),
|
||||
deleteAllSharedLinks: (...args) => mockDeleteAllSharedLinks(...args),
|
||||
updateUserPlugins: (...args) => mockUpdateUserPlugins(...args),
|
||||
deleteUserById: (...args) => mockDeleteUserById(...args),
|
||||
deleteMessages: (...args) => mockDeleteMessages(...args),
|
||||
deletePresets: (...args) => mockDeletePresets(...args),
|
||||
deleteUserKey: (...args) => mockDeleteUserKey(...args),
|
||||
getUserById: (...args) => mockGetUserById(...args),
|
||||
deleteConvos: (...args) => mockDeleteConvos(...args),
|
||||
deleteFiles: (...args) => mockDeleteFiles(...args),
|
||||
updateUser: (...args) => mockUpdateUser(...args),
|
||||
findToken: (...args) => mockFindToken(...args),
|
||||
getFiles: (...args) => mockGetFiles(...args),
|
||||
deleteToolCalls: (...args) => mockDeleteToolCalls(...args),
|
||||
deleteUserAgents: (...args) => mockDeleteUserAgents(...args),
|
||||
deleteUserPrompts: (...args) => mockDeleteUserPrompts(...args),
|
||||
deleteUserSkills: (...args) => mockDeleteUserSkills(...args),
|
||||
deleteTransactions: jest.fn(),
|
||||
deleteBalances: jest.fn(),
|
||||
deleteAllAgentApiKeys: jest.fn(),
|
||||
deleteAssistants: jest.fn(),
|
||||
deleteConversationTags: jest.fn(),
|
||||
deleteAllUserMemories: jest.fn(),
|
||||
deleteActions: jest.fn(),
|
||||
deleteTokens: jest.fn(),
|
||||
removeUserFromAllGroups: jest.fn(),
|
||||
deleteAclEntries: jest.fn(),
|
||||
getSoleOwnedResourceIds: jest.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/PluginService', () => ({
|
||||
updateUserPluginAuth: jest.fn(),
|
||||
deleteUserPluginAuth: (...args) => mockDeleteUserPluginAuth(...args),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/twoFactorService', () => ({
|
||||
verifyOTPOrBackupCode: (...args) => mockVerifyOTPOrBackupCode(...args),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/AuthService', () => ({
|
||||
verifyEmail: jest.fn(),
|
||||
resendVerificationEmail: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/config', () => ({
|
||||
getMCPManager: jest.fn(),
|
||||
getFlowStateManager: jest.fn(),
|
||||
getMCPServersRegistry: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Config/getCachedTools', () => ({
|
||||
invalidateCachedTools: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/process', () => ({
|
||||
processDeleteRequest: (...args) => mockProcessDeleteRequest(...args),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
getAppConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/cache', () => ({
|
||||
getLogStores: jest.fn(),
|
||||
}));
|
||||
|
||||
const { deleteUserController } = require('~/server/controllers/UserController');
|
||||
|
||||
function createRes() {
|
||||
const res = {};
|
||||
res.status = jest.fn().mockReturnValue(res);
|
||||
res.json = jest.fn().mockReturnValue(res);
|
||||
res.send = jest.fn().mockReturnValue(res);
|
||||
return res;
|
||||
}
|
||||
|
||||
function stubDeletionMocks() {
|
||||
mockDeleteMessages.mockResolvedValue();
|
||||
mockDeleteAllUserSessions.mockResolvedValue();
|
||||
mockDeleteUserKey.mockResolvedValue();
|
||||
mockDeletePresets.mockResolvedValue();
|
||||
mockDeleteConvos.mockResolvedValue();
|
||||
mockDeleteUserPluginAuth.mockResolvedValue();
|
||||
mockDeleteUserById.mockResolvedValue();
|
||||
mockDeleteAllSharedLinks.mockResolvedValue();
|
||||
mockDeleteAllSharedLinksWithCleanup.mockResolvedValue({ deletedCount: 0 });
|
||||
mockGetFiles.mockResolvedValue([]);
|
||||
mockProcessDeleteRequest.mockResolvedValue({ deletedFileIds: [], failedFileIds: [] });
|
||||
mockDeleteFiles.mockResolvedValue();
|
||||
mockDeleteToolCalls.mockResolvedValue();
|
||||
mockDeleteUserAgents.mockResolvedValue();
|
||||
mockDeleteUserPrompts.mockResolvedValue();
|
||||
mockDeleteUserSkills.mockResolvedValue(0);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
stubDeletionMocks();
|
||||
});
|
||||
|
||||
describe('deleteUserController - 2FA enforcement', () => {
|
||||
it('proceeds with deletion when 2FA is not enabled', async () => {
|
||||
const req = { user: { id: 'user1', _id: 'user1', email: 'a@b.com' }, body: {} };
|
||||
const res = createRes();
|
||||
mockGetUserById.mockResolvedValue({ _id: 'user1', twoFactorEnabled: false });
|
||||
|
||||
await deleteUserController(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.send).toHaveBeenCalledWith({ message: 'User deleted' });
|
||||
expect(mockDeleteMessages).toHaveBeenCalled();
|
||||
expect(mockDeleteUserAgents).toHaveBeenCalledWith('user1');
|
||||
expect(mockDeleteUserPrompts).toHaveBeenCalledWith('user1');
|
||||
expect(mockDeleteUserSkills).toHaveBeenCalledWith('user1');
|
||||
expect(mockVerifyOTPOrBackupCode).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('proceeds with deletion when user has no 2FA record', async () => {
|
||||
const req = { user: { id: 'user1', _id: 'user1', email: 'a@b.com' }, body: {} };
|
||||
const res = createRes();
|
||||
mockGetUserById.mockResolvedValue(null);
|
||||
|
||||
await deleteUserController(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.send).toHaveBeenCalledWith({ message: 'User deleted' });
|
||||
});
|
||||
|
||||
it('returns error when 2FA is enabled and verification fails with 400', async () => {
|
||||
const req = { user: { id: 'user1', _id: 'user1' }, body: {} };
|
||||
const res = createRes();
|
||||
mockGetUserById.mockResolvedValue({
|
||||
_id: 'user1',
|
||||
twoFactorEnabled: true,
|
||||
totpSecret: 'enc-secret',
|
||||
});
|
||||
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: false, status: 400 });
|
||||
|
||||
await deleteUserController(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(mockDeleteMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 401 when 2FA is enabled and invalid TOTP token provided', async () => {
|
||||
const existingUser = {
|
||||
_id: 'user1',
|
||||
twoFactorEnabled: true,
|
||||
totpSecret: 'enc-secret',
|
||||
};
|
||||
const req = { user: { id: 'user1', _id: 'user1' }, body: { token: 'wrong' } };
|
||||
const res = createRes();
|
||||
mockGetUserById.mockResolvedValue(existingUser);
|
||||
mockVerifyOTPOrBackupCode.mockResolvedValue({
|
||||
verified: false,
|
||||
status: 401,
|
||||
message: 'Invalid token or backup code',
|
||||
});
|
||||
|
||||
await deleteUserController(req, res);
|
||||
|
||||
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith({
|
||||
user: existingUser,
|
||||
token: 'wrong',
|
||||
backupCode: undefined,
|
||||
});
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(res.json).toHaveBeenCalledWith({ message: 'Invalid token or backup code' });
|
||||
expect(mockDeleteMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns 401 when 2FA is enabled and invalid backup code provided', async () => {
|
||||
const existingUser = {
|
||||
_id: 'user1',
|
||||
twoFactorEnabled: true,
|
||||
totpSecret: 'enc-secret',
|
||||
backupCodes: [],
|
||||
};
|
||||
const req = { user: { id: 'user1', _id: 'user1' }, body: { backupCode: 'bad-code' } };
|
||||
const res = createRes();
|
||||
mockGetUserById.mockResolvedValue(existingUser);
|
||||
mockVerifyOTPOrBackupCode.mockResolvedValue({
|
||||
verified: false,
|
||||
status: 401,
|
||||
message: 'Invalid token or backup code',
|
||||
});
|
||||
|
||||
await deleteUserController(req, res);
|
||||
|
||||
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith({
|
||||
user: existingUser,
|
||||
token: undefined,
|
||||
backupCode: 'bad-code',
|
||||
});
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(mockDeleteMessages).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes account when valid TOTP token provided with 2FA enabled', async () => {
|
||||
const existingUser = {
|
||||
_id: 'user1',
|
||||
twoFactorEnabled: true,
|
||||
totpSecret: 'enc-secret',
|
||||
};
|
||||
const req = {
|
||||
user: { id: 'user1', _id: 'user1', email: 'a@b.com' },
|
||||
body: { token: '123456' },
|
||||
};
|
||||
const res = createRes();
|
||||
mockGetUserById.mockResolvedValue(existingUser);
|
||||
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
|
||||
|
||||
await deleteUserController(req, res);
|
||||
|
||||
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith({
|
||||
user: existingUser,
|
||||
token: '123456',
|
||||
backupCode: undefined,
|
||||
});
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.send).toHaveBeenCalledWith({ message: 'User deleted' });
|
||||
expect(mockDeleteMessages).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes account when valid backup code provided with 2FA enabled', async () => {
|
||||
const existingUser = {
|
||||
_id: 'user1',
|
||||
twoFactorEnabled: true,
|
||||
totpSecret: 'enc-secret',
|
||||
backupCodes: [{ codeHash: 'h1', used: false }],
|
||||
};
|
||||
const req = {
|
||||
user: { id: 'user1', _id: 'user1', email: 'a@b.com' },
|
||||
body: { backupCode: 'valid-code' },
|
||||
};
|
||||
const res = createRes();
|
||||
mockGetUserById.mockResolvedValue(existingUser);
|
||||
mockVerifyOTPOrBackupCode.mockResolvedValue({ verified: true });
|
||||
|
||||
await deleteUserController(req, res);
|
||||
|
||||
expect(mockVerifyOTPOrBackupCode).toHaveBeenCalledWith({
|
||||
user: existingUser,
|
||||
token: undefined,
|
||||
backupCode: 'valid-code',
|
||||
});
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.send).toHaveBeenCalledWith({ message: 'User deleted' });
|
||||
expect(mockDeleteMessages).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,319 @@
|
||||
const mockGetMCPManager = jest.fn();
|
||||
const mockInvalidateCachedTools = jest.fn();
|
||||
|
||||
jest.mock('~/config', () => ({
|
||||
getMCPManager: (...args) => mockGetMCPManager(...args),
|
||||
getFlowStateManager: jest.fn(),
|
||||
getMCPServersRegistry: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Config/getCachedTools', () => ({
|
||||
invalidateCachedTools: (...args) => mockInvalidateCachedTools(...args),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
getAppConfig: jest.fn(),
|
||||
getMCPServerTools: jest.fn(),
|
||||
}));
|
||||
|
||||
const mongoose = require('mongoose');
|
||||
const { mcpServerSchema } = require('@librechat/data-schemas');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
const {
|
||||
ResourceType,
|
||||
AccessRoleIds,
|
||||
PrincipalType,
|
||||
PermissionBits,
|
||||
} = require('librechat-data-provider');
|
||||
const permissionService = require('~/server/services/PermissionService');
|
||||
const { deleteUserMcpServers } = require('~/server/controllers/UserController');
|
||||
const { AclEntry, AccessRole } = require('~/db/models');
|
||||
|
||||
let MCPServer;
|
||||
|
||||
describe('deleteUserMcpServers', () => {
|
||||
let mongoServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
const mongoUri = mongoServer.getUri();
|
||||
MCPServer = mongoose.models.MCPServer || mongoose.model('MCPServer', mcpServerSchema);
|
||||
await mongoose.connect(mongoUri);
|
||||
|
||||
await AccessRole.create({
|
||||
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
|
||||
name: 'MCP Server Owner',
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
permBits:
|
||||
PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE,
|
||||
});
|
||||
|
||||
await AccessRole.create({
|
||||
accessRoleId: AccessRoleIds.MCPSERVER_VIEWER,
|
||||
name: 'MCP Server Viewer',
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
permBits: PermissionBits.VIEW,
|
||||
});
|
||||
}, 20000);
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
await mongoServer.stop();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await MCPServer.deleteMany({});
|
||||
await AclEntry.deleteMany({});
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should delete solely-owned MCP servers and their ACL entries', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
|
||||
const server = await MCPServer.create({
|
||||
serverName: 'sole-owned-server',
|
||||
config: { title: 'Test Server' },
|
||||
author: userId,
|
||||
});
|
||||
|
||||
await permissionService.grantPermission({
|
||||
principalType: PrincipalType.USER,
|
||||
principalId: userId,
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
resourceId: server._id,
|
||||
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
|
||||
grantedBy: userId,
|
||||
});
|
||||
|
||||
mockGetMCPManager.mockReturnValue({
|
||||
disconnectUserConnection: jest.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
await deleteUserMcpServers(userId.toString());
|
||||
|
||||
expect(await MCPServer.findById(server._id)).toBeNull();
|
||||
|
||||
const aclEntries = await AclEntry.find({
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
resourceId: server._id,
|
||||
});
|
||||
expect(aclEntries).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('should disconnect MCP sessions and invalidate tool cache before deletion', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const mockDisconnect = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
const server = await MCPServer.create({
|
||||
serverName: 'session-server',
|
||||
config: { title: 'Session Server' },
|
||||
author: userId,
|
||||
});
|
||||
|
||||
await permissionService.grantPermission({
|
||||
principalType: PrincipalType.USER,
|
||||
principalId: userId,
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
resourceId: server._id,
|
||||
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
|
||||
grantedBy: userId,
|
||||
});
|
||||
|
||||
mockGetMCPManager.mockReturnValue({ disconnectUserConnection: mockDisconnect });
|
||||
|
||||
await deleteUserMcpServers(userId.toString());
|
||||
|
||||
expect(mockDisconnect).toHaveBeenCalledWith(userId.toString(), 'session-server');
|
||||
expect(mockInvalidateCachedTools).toHaveBeenCalledWith({
|
||||
userId: userId.toString(),
|
||||
serverName: 'session-server',
|
||||
});
|
||||
});
|
||||
|
||||
test('should preserve multi-owned MCP servers', async () => {
|
||||
const deletingUserId = new mongoose.Types.ObjectId();
|
||||
const otherOwnerId = new mongoose.Types.ObjectId();
|
||||
|
||||
const soleServer = await MCPServer.create({
|
||||
serverName: 'sole-server',
|
||||
config: { title: 'Sole Server' },
|
||||
author: deletingUserId,
|
||||
});
|
||||
|
||||
const multiServer = await MCPServer.create({
|
||||
serverName: 'multi-server',
|
||||
config: { title: 'Multi Server' },
|
||||
author: deletingUserId,
|
||||
});
|
||||
|
||||
await permissionService.grantPermission({
|
||||
principalType: PrincipalType.USER,
|
||||
principalId: deletingUserId,
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
resourceId: soleServer._id,
|
||||
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
|
||||
grantedBy: deletingUserId,
|
||||
});
|
||||
|
||||
await permissionService.grantPermission({
|
||||
principalType: PrincipalType.USER,
|
||||
principalId: deletingUserId,
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
resourceId: multiServer._id,
|
||||
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
|
||||
grantedBy: deletingUserId,
|
||||
});
|
||||
await permissionService.grantPermission({
|
||||
principalType: PrincipalType.USER,
|
||||
principalId: otherOwnerId,
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
resourceId: multiServer._id,
|
||||
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
|
||||
grantedBy: otherOwnerId,
|
||||
});
|
||||
|
||||
mockGetMCPManager.mockReturnValue({
|
||||
disconnectUserConnection: jest.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
await deleteUserMcpServers(deletingUserId.toString());
|
||||
|
||||
expect(await MCPServer.findById(soleServer._id)).toBeNull();
|
||||
expect(await MCPServer.findById(multiServer._id)).not.toBeNull();
|
||||
|
||||
const soleAcl = await AclEntry.find({
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
resourceId: soleServer._id,
|
||||
});
|
||||
expect(soleAcl).toHaveLength(0);
|
||||
|
||||
const multiAclOther = await AclEntry.find({
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
resourceId: multiServer._id,
|
||||
principalId: otherOwnerId,
|
||||
});
|
||||
expect(multiAclOther).toHaveLength(1);
|
||||
expect(multiAclOther[0].permBits & PermissionBits.DELETE).toBeTruthy();
|
||||
|
||||
const multiAclDeleting = await AclEntry.find({
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
resourceId: multiServer._id,
|
||||
principalId: deletingUserId,
|
||||
});
|
||||
expect(multiAclDeleting).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('should be a no-op when user has no owned MCP servers', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
|
||||
const otherUserId = new mongoose.Types.ObjectId();
|
||||
const server = await MCPServer.create({
|
||||
serverName: 'other-server',
|
||||
config: { title: 'Other Server' },
|
||||
author: otherUserId,
|
||||
});
|
||||
|
||||
await permissionService.grantPermission({
|
||||
principalType: PrincipalType.USER,
|
||||
principalId: otherUserId,
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
resourceId: server._id,
|
||||
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
|
||||
grantedBy: otherUserId,
|
||||
});
|
||||
|
||||
await deleteUserMcpServers(userId.toString());
|
||||
|
||||
expect(await MCPServer.findById(server._id)).not.toBeNull();
|
||||
expect(mockGetMCPManager).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should handle gracefully when MCPServer model is not registered', async () => {
|
||||
const originalModel = mongoose.models.MCPServer;
|
||||
delete mongoose.models.MCPServer;
|
||||
|
||||
try {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
await expect(deleteUserMcpServers(userId.toString())).resolves.toBeUndefined();
|
||||
} finally {
|
||||
mongoose.models.MCPServer = originalModel;
|
||||
}
|
||||
});
|
||||
|
||||
test('should handle gracefully when MCPManager is not available', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
|
||||
const server = await MCPServer.create({
|
||||
serverName: 'no-manager-server',
|
||||
config: { title: 'No Manager Server' },
|
||||
author: userId,
|
||||
});
|
||||
|
||||
await permissionService.grantPermission({
|
||||
principalType: PrincipalType.USER,
|
||||
principalId: userId,
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
resourceId: server._id,
|
||||
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
|
||||
grantedBy: userId,
|
||||
});
|
||||
|
||||
mockGetMCPManager.mockReturnValue(null);
|
||||
|
||||
await deleteUserMcpServers(userId.toString());
|
||||
|
||||
expect(await MCPServer.findById(server._id)).toBeNull();
|
||||
});
|
||||
|
||||
test('should delete legacy MCP servers that have author but no ACL entries', async () => {
|
||||
const legacyUserId = new mongoose.Types.ObjectId();
|
||||
|
||||
const legacyServer = await MCPServer.create({
|
||||
serverName: 'legacy-server',
|
||||
config: { title: 'Legacy Server' },
|
||||
author: legacyUserId,
|
||||
});
|
||||
|
||||
mockGetMCPManager.mockReturnValue({
|
||||
disconnectUserConnection: jest.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
await deleteUserMcpServers(legacyUserId.toString());
|
||||
|
||||
expect(await MCPServer.findById(legacyServer._id)).toBeNull();
|
||||
});
|
||||
|
||||
test('should delete both ACL-owned and legacy servers in one call', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
|
||||
const aclServer = await MCPServer.create({
|
||||
serverName: 'acl-server',
|
||||
config: { title: 'ACL Server' },
|
||||
author: userId,
|
||||
});
|
||||
|
||||
await permissionService.grantPermission({
|
||||
principalType: PrincipalType.USER,
|
||||
principalId: userId,
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
resourceId: aclServer._id,
|
||||
accessRoleId: AccessRoleIds.MCPSERVER_OWNER,
|
||||
grantedBy: userId,
|
||||
});
|
||||
|
||||
const legacyServer = await MCPServer.create({
|
||||
serverName: 'legacy-mixed-server',
|
||||
config: { title: 'Legacy Mixed' },
|
||||
author: userId,
|
||||
});
|
||||
|
||||
mockGetMCPManager.mockReturnValue({
|
||||
disconnectUserConnection: jest.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
await deleteUserMcpServers(userId.toString());
|
||||
|
||||
expect(await MCPServer.findById(aclServer._id)).toBeNull();
|
||||
expect(await MCPServer.findById(legacyServer._id)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { ResourceType } = require('librechat-data-provider');
|
||||
|
||||
/**
|
||||
* Maps each ResourceType to the cleanup function name that must appear in
|
||||
* deleteUserController's source to prove it is handled during user deletion.
|
||||
*
|
||||
* When a new ResourceType is added, this test will fail until a corresponding
|
||||
* entry is added here (or to NO_USER_CLEANUP_NEEDED) AND the actual cleanup
|
||||
* logic is implemented.
|
||||
*/
|
||||
const HANDLED_RESOURCE_TYPES = {
|
||||
[ResourceType.AGENT]: 'deleteUserAgents',
|
||||
[ResourceType.REMOTE_AGENT]: 'deleteUserAgents',
|
||||
[ResourceType.PROMPTGROUP]: 'deleteUserPrompts',
|
||||
[ResourceType.MCPSERVER]: 'deleteUserMcpServers',
|
||||
[ResourceType.SKILL]: 'deleteUserSkills',
|
||||
[ResourceType.SHARED_LINK]: 'deleteAllSharedLinksWithCleanup',
|
||||
};
|
||||
|
||||
/**
|
||||
* ResourceTypes that are ACL-tracked but have no per-user deletion semantics
|
||||
* (e.g., system resources, public-only). Must be explicitly listed here with
|
||||
* a justification to prevent silent omissions.
|
||||
*/
|
||||
const NO_USER_CLEANUP_NEEDED = new Set([
|
||||
// Example: ResourceType.SYSTEM_TEMPLATE — public/system; not user-owned
|
||||
]);
|
||||
|
||||
describe('deleteUserController - resource type coverage guard', () => {
|
||||
let controllerSource;
|
||||
|
||||
beforeAll(() => {
|
||||
controllerSource = fs.readFileSync(path.resolve(__dirname, '../UserController.js'), 'utf-8');
|
||||
});
|
||||
|
||||
test('every ResourceType must have a documented cleanup handler or explicit exclusion', () => {
|
||||
const allTypes = Object.values(ResourceType);
|
||||
const handledTypes = Object.keys(HANDLED_RESOURCE_TYPES);
|
||||
const unhandledTypes = allTypes.filter(
|
||||
(t) => !handledTypes.includes(t) && !NO_USER_CLEANUP_NEEDED.has(t),
|
||||
);
|
||||
|
||||
expect(unhandledTypes).toEqual([]);
|
||||
});
|
||||
|
||||
test('every cleanup handler referenced in HANDLED_RESOURCE_TYPES must appear in the controller source', () => {
|
||||
const uniqueHandlers = [...new Set(Object.values(HANDLED_RESOURCE_TYPES))];
|
||||
|
||||
for (const handler of uniqueHandlers) {
|
||||
expect(controllerSource).toContain(handler);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,324 @@
|
||||
const mockGetTokens = jest.fn();
|
||||
const mockDeleteUserTokens = jest.fn();
|
||||
const mockGetClientInfoAndMetadata = jest.fn();
|
||||
const mockRevokeOAuthToken = jest.fn();
|
||||
const mockGetServerConfig = jest.fn();
|
||||
const mockGetOAuthServers = jest.fn();
|
||||
const mockGetAllowedDomains = jest.fn();
|
||||
const mockGetAllowedAddresses = jest.fn();
|
||||
const mockDeleteFlow = jest.fn();
|
||||
const mockGetLogStores = jest.fn();
|
||||
const mockFindToken = jest.fn();
|
||||
const mockDeleteTokens = jest.fn();
|
||||
const mockLoggerInfo = jest.fn();
|
||||
const mockLoggerWarn = jest.fn();
|
||||
const mockLoggerError = jest.fn();
|
||||
const mockGetTenantId = jest.fn();
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { info: mockLoggerInfo, warn: mockLoggerWarn, error: mockLoggerError },
|
||||
getTenantId: (...args) => mockGetTenantId(...args),
|
||||
webSearchKeys: [],
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => {
|
||||
return {
|
||||
MCPOAuthHandler: {
|
||||
revokeOAuthToken: (...args) => mockRevokeOAuthToken(...args),
|
||||
generateFlowId: (userId, serverName, tenantId) => {
|
||||
const flowId = `${userId}:${serverName}`;
|
||||
return tenantId ? `tenant:${encodeURIComponent(tenantId)}:${flowId}` : flowId;
|
||||
},
|
||||
generateTokenFlowId: (userId, serverName, tenantId) => {
|
||||
const flowId = `${userId}:${serverName}`;
|
||||
return tenantId ? `tenant:${encodeURIComponent(tenantId)}:${flowId}` : flowId;
|
||||
},
|
||||
},
|
||||
MCPTokenStorage: {
|
||||
getTokens: (...args) => mockGetTokens(...args),
|
||||
getClientInfoAndMetadata: (...args) => mockGetClientInfoAndMetadata(...args),
|
||||
deleteUserTokens: (...args) => mockDeleteUserTokens(...args),
|
||||
},
|
||||
normalizeHttpError: jest.fn(),
|
||||
extractWebSearchEnvVars: jest.fn(),
|
||||
needsRefresh: jest.fn(),
|
||||
getNewS3URL: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
Tools: {},
|
||||
CacheKeys: { FLOWS: 'flows' },
|
||||
Constants: { mcp_delimiter: '::', mcp_prefix: 'mcp_' },
|
||||
FileSources: {},
|
||||
ResourceType: {},
|
||||
}));
|
||||
|
||||
jest.mock('~/config', () => ({
|
||||
getMCPManager: jest.fn(),
|
||||
getFlowStateManager: jest.fn(() => ({
|
||||
deleteFlow: (...args) => mockDeleteFlow(...args),
|
||||
})),
|
||||
getMCPServersRegistry: jest.fn(() => ({
|
||||
getServerConfig: (...args) => mockGetServerConfig(...args),
|
||||
getOAuthServers: (...args) => mockGetOAuthServers(...args),
|
||||
getAllowedDomains: (...args) => mockGetAllowedDomains(...args),
|
||||
getAllowedAddresses: (...args) => mockGetAllowedAddresses(...args),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('~/cache', () => ({
|
||||
getLogStores: (...args) => mockGetLogStores(...args),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/PluginService', () => ({
|
||||
updateUserPluginAuth: jest.fn(),
|
||||
deleteUserPluginAuth: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/twoFactorService', () => ({
|
||||
verifyOTPOrBackupCode: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/AuthService', () => ({
|
||||
verifyEmail: jest.fn(),
|
||||
resendVerificationEmail: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Config/getCachedTools', () => ({
|
||||
invalidateCachedTools: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/process', () => ({
|
||||
processDeleteRequest: jest.fn().mockResolvedValue({ deletedFileIds: [], failedFileIds: [] }),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
getAppConfig: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/models', () => ({
|
||||
findToken: (...args) => mockFindToken(...args),
|
||||
deleteTokens: (...args) => mockDeleteTokens(...args),
|
||||
updateUser: jest.fn(),
|
||||
deleteAllUserSessions: jest.fn(),
|
||||
deleteAllSharedLinks: jest.fn(),
|
||||
updateUserPlugins: jest.fn(),
|
||||
deleteUserById: jest.fn(),
|
||||
deleteMessages: jest.fn(),
|
||||
deletePresets: jest.fn(),
|
||||
deleteUserKey: jest.fn(),
|
||||
getUserById: jest.fn(),
|
||||
deleteConvos: jest.fn(),
|
||||
deleteFiles: jest.fn(),
|
||||
getFiles: jest.fn(),
|
||||
deleteToolCalls: jest.fn(),
|
||||
deleteUserAgents: jest.fn(),
|
||||
deleteUserPrompts: jest.fn(),
|
||||
deleteTransactions: jest.fn(),
|
||||
deleteBalances: jest.fn(),
|
||||
deleteAllAgentApiKeys: jest.fn(),
|
||||
deleteAssistants: jest.fn(),
|
||||
deleteConversationTags: jest.fn(),
|
||||
deleteAllUserMemories: jest.fn(),
|
||||
deleteActions: jest.fn(),
|
||||
removeUserFromAllGroups: jest.fn(),
|
||||
deleteAclEntries: jest.fn(),
|
||||
getSoleOwnedResourceIds: jest.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
const { maybeUninstallOAuthMCP } = require('~/server/controllers/UserController');
|
||||
|
||||
const userId = 'user-123';
|
||||
const pluginKey = 'mcp_acme';
|
||||
const serverName = 'acme';
|
||||
|
||||
const serverConfig = {
|
||||
url: 'https://acme.example.com',
|
||||
oauth: {
|
||||
revocation_endpoint: 'https://acme.example.com/revoke',
|
||||
revocation_endpoint_auth_methods_supported: ['client_secret_basic'],
|
||||
},
|
||||
oauth_headers: { 'X-Tenant': 'acme' },
|
||||
};
|
||||
|
||||
const appConfig = {
|
||||
mcpServers: { acme: serverConfig },
|
||||
};
|
||||
|
||||
const clientInfo = { client_id: 'cid', client_secret: 'csec' };
|
||||
const clientMetadata = {};
|
||||
|
||||
function setupOAuthServerFound() {
|
||||
mockGetServerConfig.mockResolvedValue(serverConfig);
|
||||
mockGetOAuthServers.mockResolvedValue(new Set([serverName]));
|
||||
mockGetAllowedDomains.mockReturnValue(['https://acme.example.com']);
|
||||
mockGetAllowedAddresses.mockReturnValue(null);
|
||||
mockGetClientInfoAndMetadata.mockResolvedValue({ clientInfo, clientMetadata });
|
||||
}
|
||||
|
||||
describe('maybeUninstallOAuthMCP', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGetTenantId.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
test('is a no-op when pluginKey is not an MCP key', async () => {
|
||||
await maybeUninstallOAuthMCP(userId, 'plugin_google_calendar', appConfig);
|
||||
|
||||
expect(mockGetServerConfig).not.toHaveBeenCalled();
|
||||
expect(mockGetTokens).not.toHaveBeenCalled();
|
||||
expect(mockDeleteUserTokens).not.toHaveBeenCalled();
|
||||
expect(mockDeleteFlow).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('clears stored state when the MCP server is not an OAuth server', async () => {
|
||||
mockGetServerConfig.mockResolvedValue(serverConfig);
|
||||
mockGetOAuthServers.mockResolvedValue(new Set(['other']));
|
||||
|
||||
await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);
|
||||
|
||||
expect(mockGetClientInfoAndMetadata).not.toHaveBeenCalled();
|
||||
expect(mockGetTokens).not.toHaveBeenCalled();
|
||||
expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
|
||||
expect(mockDeleteUserTokens.mock.calls[0][0]).toMatchObject({ userId, serverName });
|
||||
expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('clears stored state when client info is missing', async () => {
|
||||
setupOAuthServerFound();
|
||||
mockGetClientInfoAndMetadata.mockResolvedValue(null);
|
||||
|
||||
await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);
|
||||
|
||||
expect(mockGetTokens).not.toHaveBeenCalled();
|
||||
expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
|
||||
expect(mockDeleteUserTokens.mock.calls[0][0]).toMatchObject({ userId, serverName });
|
||||
expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('clears stored state when client info cannot be loaded', async () => {
|
||||
setupOAuthServerFound();
|
||||
mockGetClientInfoAndMetadata.mockRejectedValue(new Error('bad client data'));
|
||||
mockDeleteUserTokens.mockResolvedValue(undefined);
|
||||
mockDeleteFlow.mockResolvedValue(undefined);
|
||||
|
||||
await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);
|
||||
|
||||
expect(mockGetTokens).not.toHaveBeenCalled();
|
||||
expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
|
||||
expect(mockDeleteUserTokens.mock.calls[0][0]).toMatchObject({ userId, serverName });
|
||||
expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
|
||||
expect(mockLoggerWarn).toHaveBeenCalledWith(
|
||||
`[maybeUninstallOAuthMCP] Unable to load OAuth client metadata for ${serverName}; clearing local MCP OAuth state only.`,
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
test('clears tenant-scoped and legacy flow state when tenant context exists', async () => {
|
||||
setupOAuthServerFound();
|
||||
mockGetTenantId.mockReturnValue('tenant-a');
|
||||
mockGetClientInfoAndMetadata.mockResolvedValue(null);
|
||||
|
||||
await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);
|
||||
|
||||
expect(mockDeleteFlow).toHaveBeenCalledTimes(4);
|
||||
expect(mockDeleteFlow).toHaveBeenCalledWith('tenant:tenant-a:user-123:acme', 'mcp_get_tokens');
|
||||
expect(mockDeleteFlow).toHaveBeenCalledWith('tenant:tenant-a:user-123:acme', 'mcp_oauth');
|
||||
expect(mockDeleteFlow).toHaveBeenCalledWith('user-123:acme', 'mcp_get_tokens');
|
||||
expect(mockDeleteFlow).toHaveBeenCalledWith('user-123:acme', 'mcp_oauth');
|
||||
});
|
||||
|
||||
test('revokes both tokens and runs cleanup on happy path', async () => {
|
||||
setupOAuthServerFound();
|
||||
mockGetTokens.mockResolvedValue({
|
||||
access_token: 'access-abc',
|
||||
refresh_token: 'refresh-xyz',
|
||||
});
|
||||
mockRevokeOAuthToken.mockResolvedValue(undefined);
|
||||
mockDeleteUserTokens.mockResolvedValue(undefined);
|
||||
mockDeleteFlow.mockResolvedValue(undefined);
|
||||
|
||||
await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);
|
||||
|
||||
expect(mockRevokeOAuthToken).toHaveBeenCalledTimes(2);
|
||||
expect(mockRevokeOAuthToken.mock.calls[0][1]).toBe('access-abc');
|
||||
expect(mockRevokeOAuthToken.mock.calls[0][2]).toBe('access');
|
||||
expect(mockRevokeOAuthToken.mock.calls[1][1]).toBe('refresh-xyz');
|
||||
expect(mockRevokeOAuthToken.mock.calls[1][2]).toBe('refresh');
|
||||
|
||||
expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
|
||||
expect(mockDeleteUserTokens.mock.calls[0][0]).toMatchObject({ userId, serverName });
|
||||
|
||||
expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
|
||||
expect(mockDeleteFlow.mock.calls[0][1]).toBe('mcp_get_tokens');
|
||||
expect(mockDeleteFlow.mock.calls[1][1]).toBe('mcp_oauth');
|
||||
});
|
||||
|
||||
test('skips revocation but still runs cleanup when token retrieval fails', async () => {
|
||||
setupOAuthServerFound();
|
||||
mockGetTokens.mockRejectedValue(new Error('missing'));
|
||||
mockDeleteUserTokens.mockResolvedValue(undefined);
|
||||
mockDeleteFlow.mockResolvedValue(undefined);
|
||||
|
||||
await expect(maybeUninstallOAuthMCP(userId, pluginKey, appConfig)).resolves.toBeUndefined();
|
||||
|
||||
expect(mockRevokeOAuthToken).not.toHaveBeenCalled();
|
||||
expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
|
||||
expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
|
||||
expect(mockLoggerWarn).toHaveBeenCalledWith(
|
||||
`[maybeUninstallOAuthMCP] Unable to load OAuth tokens for ${serverName}; clearing local token state.`,
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
test('skips revocation, logs warn, and still runs cleanup on unexpected token-retrieval error', async () => {
|
||||
setupOAuthServerFound();
|
||||
mockGetTokens.mockRejectedValue(new Error('boom: unreachable'));
|
||||
mockDeleteUserTokens.mockResolvedValue(undefined);
|
||||
mockDeleteFlow.mockResolvedValue(undefined);
|
||||
|
||||
await expect(maybeUninstallOAuthMCP(userId, pluginKey, appConfig)).resolves.toBeUndefined();
|
||||
|
||||
expect(mockRevokeOAuthToken).not.toHaveBeenCalled();
|
||||
expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
|
||||
expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
|
||||
expect(mockLoggerWarn).toHaveBeenCalledWith(
|
||||
`[maybeUninstallOAuthMCP] Unable to load OAuth tokens for ${serverName}; clearing local token state.`,
|
||||
expect.any(Error),
|
||||
);
|
||||
});
|
||||
|
||||
test('continues cleanup when only one token type is present', async () => {
|
||||
setupOAuthServerFound();
|
||||
mockGetTokens.mockResolvedValue({ access_token: 'only-access' });
|
||||
mockRevokeOAuthToken.mockResolvedValue(undefined);
|
||||
mockDeleteUserTokens.mockResolvedValue(undefined);
|
||||
mockDeleteFlow.mockResolvedValue(undefined);
|
||||
|
||||
await maybeUninstallOAuthMCP(userId, pluginKey, appConfig);
|
||||
|
||||
expect(mockRevokeOAuthToken).toHaveBeenCalledTimes(1);
|
||||
expect(mockRevokeOAuthToken.mock.calls[0][2]).toBe('access');
|
||||
expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
|
||||
expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test('still runs cleanup even when both revocation calls fail', async () => {
|
||||
setupOAuthServerFound();
|
||||
mockGetTokens.mockResolvedValue({
|
||||
access_token: 'a',
|
||||
refresh_token: 'r',
|
||||
});
|
||||
mockRevokeOAuthToken.mockRejectedValue(new Error('network down'));
|
||||
mockDeleteUserTokens.mockResolvedValue(undefined);
|
||||
mockDeleteFlow.mockResolvedValue(undefined);
|
||||
|
||||
await expect(maybeUninstallOAuthMCP(userId, pluginKey, appConfig)).resolves.toBeUndefined();
|
||||
|
||||
expect(mockRevokeOAuthToken).toHaveBeenCalledTimes(2);
|
||||
expect(mockDeleteUserTokens).toHaveBeenCalledTimes(1);
|
||||
expect(mockDeleteFlow).toHaveBeenCalledTimes(2);
|
||||
expect(mockLoggerError).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
const mongoose = require('mongoose');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
const { SystemCapabilities } = require('@librechat/data-schemas');
|
||||
const {
|
||||
SystemRoles,
|
||||
ResourceType,
|
||||
AccessRoleIds,
|
||||
PrincipalType,
|
||||
} = require('librechat-data-provider');
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
...jest.requireActual('@librechat/data-schemas'),
|
||||
getTransactionSupport: jest.fn().mockResolvedValue(false),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/GraphApiService', () => ({
|
||||
entraIdPrincipalFeatureEnabled: jest.fn().mockReturnValue(false),
|
||||
getUserOwnedEntraGroups: jest.fn().mockResolvedValue([]),
|
||||
getUserEntraGroups: jest.fn().mockResolvedValue([]),
|
||||
getEntraGroupDetailsBatch: jest.fn().mockResolvedValue([]),
|
||||
getGroupMembers: jest.fn().mockResolvedValue([]),
|
||||
getGroupOwners: jest.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
const mockRegistryInstance = {
|
||||
getServerConfig: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('~/config', () => ({
|
||||
logger: { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() },
|
||||
getMCPManager: jest.fn(),
|
||||
getMCPServersRegistry: jest.fn(() => mockRegistryInstance),
|
||||
}));
|
||||
|
||||
const mockResolveAllMcpConfigs = jest.fn();
|
||||
jest.mock('~/server/services/MCP', () => ({
|
||||
resolveConfigServers: jest.fn().mockResolvedValue({}),
|
||||
resolveMcpConfigNames: jest.fn().mockResolvedValue([]),
|
||||
resolveAllMcpConfigs: (...args) => mockResolveAllMcpConfigs(...args),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
cacheMCPServerTools: jest.fn(),
|
||||
getMCPServerTools: jest.fn(),
|
||||
}));
|
||||
|
||||
const { getMCPServersList, getMCPServerById } = require('~/server/controllers/mcp');
|
||||
const { grantPermission } = require('~/server/services/PermissionService');
|
||||
const { seedDefaultRoles } = require('~/models');
|
||||
|
||||
let mongoServer;
|
||||
let SystemGrant;
|
||||
let AclEntry;
|
||||
let User;
|
||||
|
||||
const yamlConfig = {
|
||||
type: 'streamable-http',
|
||||
url: 'https://internal.example.com/mcp',
|
||||
title: 'YAML Server',
|
||||
source: 'yaml',
|
||||
oauth: {
|
||||
client_id: 'client-id',
|
||||
authorization_url: 'https://internal.example.com/auth',
|
||||
token_url: 'https://internal.example.com/token',
|
||||
},
|
||||
};
|
||||
|
||||
const createRes = () => {
|
||||
const res = {};
|
||||
res.status = jest.fn(() => res);
|
||||
res.json = jest.fn(() => res);
|
||||
return res;
|
||||
};
|
||||
|
||||
const createDbConfig = (dbId) => ({
|
||||
type: 'streamable-http',
|
||||
url: 'https://user.example.com/mcp',
|
||||
title: 'DB Server',
|
||||
source: 'user',
|
||||
dbId: String(dbId),
|
||||
});
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
await mongoose.connect(mongoServer.getUri());
|
||||
|
||||
const { createModels } = jest.requireActual('@librechat/data-schemas');
|
||||
createModels(mongoose);
|
||||
const dbModels = require('~/db/models');
|
||||
Object.assign(mongoose.models, dbModels);
|
||||
SystemGrant = dbModels.SystemGrant;
|
||||
AclEntry = dbModels.AclEntry;
|
||||
User = dbModels.User;
|
||||
|
||||
await seedDefaultRoles();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
await mongoServer.stop();
|
||||
});
|
||||
|
||||
let existsSpy;
|
||||
|
||||
beforeEach(async () => {
|
||||
await SystemGrant.deleteMany({});
|
||||
await AclEntry.deleteMany({});
|
||||
await User.deleteMany({});
|
||||
mockResolveAllMcpConfigs.mockReset();
|
||||
mockRegistryInstance.getServerConfig.mockReset();
|
||||
existsSpy = jest.spyOn(SystemGrant, 'exists');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
existsSpy.mockRestore();
|
||||
});
|
||||
|
||||
const seedManageMcpGrant = async (role = SystemRoles.ADMIN) => {
|
||||
await SystemGrant.create({
|
||||
principalType: PrincipalType.ROLE,
|
||||
principalId: role,
|
||||
capability: SystemCapabilities.MANAGE_MCP_SERVERS,
|
||||
grantedAt: new Date(),
|
||||
});
|
||||
};
|
||||
|
||||
const createUser = async (role = SystemRoles.USER) => {
|
||||
const user = await User.create({
|
||||
name: 'Test User',
|
||||
email: `user-${new mongoose.Types.ObjectId().toString()}@example.com`,
|
||||
provider: 'local',
|
||||
role,
|
||||
});
|
||||
return { id: user._id.toString(), role, idOnTheSource: null };
|
||||
};
|
||||
|
||||
describe('getMCPServersList', () => {
|
||||
it('skips the capability probe when no server is DB-backed', async () => {
|
||||
await seedManageMcpGrant();
|
||||
const reqUser = await createUser(SystemRoles.ADMIN);
|
||||
mockResolveAllMcpConfigs.mockResolvedValue({ yamlServer: { ...yamlConfig } });
|
||||
|
||||
const res = createRes();
|
||||
await getMCPServersList({ user: reqUser }, res);
|
||||
|
||||
expect(existsSpy).not.toHaveBeenCalled();
|
||||
const payload = res.json.mock.calls[0][0];
|
||||
expect(payload.yamlServer.title).toBe('YAML Server');
|
||||
expect(payload.yamlServer.url).toBeUndefined();
|
||||
expect(payload.yamlServer.oauth.authorization_url).toBeUndefined();
|
||||
});
|
||||
|
||||
it('skips the probe entirely for an empty server map', async () => {
|
||||
const reqUser = await createUser();
|
||||
mockResolveAllMcpConfigs.mockResolvedValue({});
|
||||
|
||||
const res = createRes();
|
||||
await getMCPServersList({ user: reqUser }, res);
|
||||
|
||||
expect(existsSpy).not.toHaveBeenCalled();
|
||||
expect(res.json).toHaveBeenCalledWith({});
|
||||
});
|
||||
|
||||
it('applies the capability bypass to all servers when a DB-backed server is present', async () => {
|
||||
await seedManageMcpGrant();
|
||||
const reqUser = await createUser(SystemRoles.ADMIN);
|
||||
const dbId = new mongoose.Types.ObjectId();
|
||||
mockResolveAllMcpConfigs.mockResolvedValue({
|
||||
dbServer: createDbConfig(dbId),
|
||||
yamlServer: { ...yamlConfig },
|
||||
});
|
||||
|
||||
const res = createRes();
|
||||
await getMCPServersList({ user: reqUser }, res);
|
||||
|
||||
expect(existsSpy).toHaveBeenCalledTimes(1);
|
||||
const payload = res.json.mock.calls[0][0];
|
||||
expect(payload.dbServer.url).toBe('https://user.example.com/mcp');
|
||||
expect(payload.yamlServer.url).toBe('https://internal.example.com/mcp');
|
||||
});
|
||||
|
||||
it('falls back to ACL EDIT for DB-backed servers without the capability', async () => {
|
||||
const reqUser = await createUser();
|
||||
const dbId = new mongoose.Types.ObjectId();
|
||||
await grantPermission({
|
||||
principalType: PrincipalType.USER,
|
||||
principalId: reqUser.id,
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
resourceId: dbId,
|
||||
accessRoleId: AccessRoleIds.MCPSERVER_EDITOR,
|
||||
grantedBy: reqUser.id,
|
||||
});
|
||||
mockResolveAllMcpConfigs.mockResolvedValue({
|
||||
dbServer: createDbConfig(dbId),
|
||||
yamlServer: { ...yamlConfig },
|
||||
});
|
||||
|
||||
const res = createRes();
|
||||
await getMCPServersList({ user: reqUser }, res);
|
||||
|
||||
expect(existsSpy).toHaveBeenCalledTimes(1);
|
||||
const payload = res.json.mock.calls[0][0];
|
||||
expect(payload.dbServer.url).toBe('https://user.example.com/mcp');
|
||||
expect(payload.yamlServer.url).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves DB-backed servers redacted for viewer-only ACL', async () => {
|
||||
const reqUser = await createUser();
|
||||
const dbId = new mongoose.Types.ObjectId();
|
||||
await grantPermission({
|
||||
principalType: PrincipalType.USER,
|
||||
principalId: reqUser.id,
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
resourceId: dbId,
|
||||
accessRoleId: AccessRoleIds.MCPSERVER_VIEWER,
|
||||
grantedBy: reqUser.id,
|
||||
});
|
||||
mockResolveAllMcpConfigs.mockResolvedValue({ dbServer: createDbConfig(dbId) });
|
||||
|
||||
const res = createRes();
|
||||
await getMCPServersList({ user: reqUser }, res);
|
||||
|
||||
expect(existsSpy).toHaveBeenCalledTimes(1);
|
||||
const payload = res.json.mock.calls[0][0];
|
||||
expect(payload.dbServer.title).toBe('DB Server');
|
||||
expect(payload.dbServer.url).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMCPServerById', () => {
|
||||
it('still runs the capability probe for YAML servers on the detail route', async () => {
|
||||
await seedManageMcpGrant();
|
||||
const reqUser = await createUser(SystemRoles.ADMIN);
|
||||
mockRegistryInstance.getServerConfig.mockResolvedValue({ ...yamlConfig });
|
||||
|
||||
const res = createRes();
|
||||
await getMCPServerById({ user: reqUser, params: { serverName: 'yamlServer' } }, res);
|
||||
|
||||
expect(existsSpy).toHaveBeenCalledTimes(1);
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
const payload = res.json.mock.calls[0][0];
|
||||
expect(payload.url).toBe('https://internal.example.com/mcp');
|
||||
expect(payload.oauth.authorization_url).toBe('https://internal.example.com/auth');
|
||||
});
|
||||
|
||||
it('redacts YAML server details for users without the capability', async () => {
|
||||
const reqUser = await createUser();
|
||||
mockRegistryInstance.getServerConfig.mockResolvedValue({ ...yamlConfig });
|
||||
|
||||
const res = createRes();
|
||||
await getMCPServerById({ user: reqUser, params: { serverName: 'yamlServer' } }, res);
|
||||
|
||||
expect(existsSpy).toHaveBeenCalledTimes(1);
|
||||
const payload = res.json.mock.calls[0][0];
|
||||
expect(payload.url).toBeUndefined();
|
||||
expect(payload.oauth.authorization_url).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { debug: jest.fn(), error: jest.fn(), warn: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
checkAccess: jest.fn(),
|
||||
loadWebSearchAuth: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/models', () => ({
|
||||
getRoleByName: jest.fn(),
|
||||
createToolCall: jest.fn(),
|
||||
getToolCallsByConvo: jest.fn(),
|
||||
getMessage: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/process', () => ({
|
||||
processFileURL: jest.fn(),
|
||||
uploadImageBuffer: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/Code/process', () => ({
|
||||
processCodeOutput: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Tools/credentials', () => ({
|
||||
loadAuthValues: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/app/clients/tools/util', () => ({
|
||||
loadTools: jest.fn(),
|
||||
}));
|
||||
|
||||
const { Tools, AuthType } = require('librechat-data-provider');
|
||||
const { verifyToolAuth } = require('../tools');
|
||||
|
||||
/**
|
||||
* Phase 8 behavioral pin: `verifyToolAuth(execute_code)` unconditionally
|
||||
* returns system-authenticated. Sandbox auth moved server-side into the
|
||||
* agents library, so the per-user `CODE_API_KEY` check that previously
|
||||
* gated this endpoint is gone. The deployment contract is: if the
|
||||
* admin enabled the `execute_code` capability, the sandbox is
|
||||
* reachable. This endpoint does not probe reachability (would be too
|
||||
* expensive per UI-gate query); failures surface at execution time.
|
||||
*
|
||||
* A regression where someone re-adds an auth check here would
|
||||
* resurrect the per-user key-entry dialog on the client, which Phase 8
|
||||
* explicitly removed. Pin the contract.
|
||||
*/
|
||||
describe('verifyToolAuth — execute_code system-auth contract', () => {
|
||||
const makeReq = (toolId) => ({
|
||||
params: { toolId },
|
||||
user: { id: 'user-1' },
|
||||
config: {},
|
||||
});
|
||||
|
||||
const makeRes = () => {
|
||||
const res = {};
|
||||
res.status = jest.fn().mockReturnValue(res);
|
||||
res.json = jest.fn().mockReturnValue(res);
|
||||
return res;
|
||||
};
|
||||
|
||||
it('returns authenticated: true with SYSTEM_DEFINED for execute_code', async () => {
|
||||
const res = makeRes();
|
||||
await verifyToolAuth(makeReq(Tools.execute_code), res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(200);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
authenticated: true,
|
||||
message: AuthType.SYSTEM_DEFINED,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 404 for unknown tool ids (not in directCallableTools)', async () => {
|
||||
const res = makeRes();
|
||||
await verifyToolAuth(makeReq('not_a_real_tool'), res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({ message: 'Tool not found' });
|
||||
});
|
||||
|
||||
it('does NOT invoke loadAuthValues for execute_code (no per-user credential check)', async () => {
|
||||
/* Regression guard: a future refactor that threads per-user auth back
|
||||
in would resurface the key-entry dialog on the client. Pin that
|
||||
the auth path is never consulted. */
|
||||
const { loadAuthValues } = require('~/server/services/Tools/credentials');
|
||||
loadAuthValues.mockClear();
|
||||
|
||||
await verifyToolAuth(makeReq(Tools.execute_code), makeRes());
|
||||
|
||||
expect(loadAuthValues).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT reference AuthType.USER_PROVIDED in the response (Phase 8 removed the path)', async () => {
|
||||
const res = makeRes();
|
||||
await verifyToolAuth(makeReq(Tools.execute_code), res);
|
||||
|
||||
const payload = res.json.mock.calls[0][0];
|
||||
expect(payload.message).not.toBe(AuthType.USER_PROVIDED);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,489 @@
|
||||
/**
|
||||
* Full-wiring ask_user_question lifecycle e2e.
|
||||
*
|
||||
* Companion to `hitlCheckpoint.e2e.spec.js`, same REAL components: the
|
||||
* `@librechat/agents` Run (FakeChatModel scripted to call the ask tool), the
|
||||
* LazyMongoSaver over mongodb-memory-server, the GenerationJobManager, and the
|
||||
* `/agents/chat/resume` controller via supertest. The seam under test here is
|
||||
* different, though: the interrupt is raised INSIDE a tool body (the tool's
|
||||
* func calls the SDK's `askUserQuestion()` helper, which wraps LangGraph
|
||||
* `interrupt()`), not by the PreToolUse approval gate — and the run carries a
|
||||
* checkpointer but NO `humanInTheLoop` switch and NO hooks, proving the
|
||||
* question flow works with the approval policy fully disabled.
|
||||
*/
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const mongoose = require('mongoose');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
const { z } = require('zod');
|
||||
const { tool } = require('@langchain/core/tools');
|
||||
const { HumanMessage } = require('@langchain/core/messages');
|
||||
const { Run, Providers, FakeChatModel, askUserQuestion } = require('@librechat/agents');
|
||||
|
||||
const mockLogger = { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() };
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
...jest.requireActual('@librechat/data-schemas'),
|
||||
logger: mockLogger,
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
...jest.requireActual('@librechat/api'),
|
||||
checkAndIncrementPendingRequest: jest.fn(async () => ({ allowed: true })),
|
||||
decrementPendingRequest: jest.fn(async () => {}),
|
||||
}));
|
||||
|
||||
jest.mock('~/models', () => ({
|
||||
saveMessage: jest.fn(async (req, message) => message),
|
||||
getConvo: jest.fn(async () => null),
|
||||
getMessages: jest.fn(async () => []),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/cleanup', () => ({
|
||||
disposeClient: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/MCPRequestContext', () => ({
|
||||
getMCPRequestContext: jest.fn(() => null),
|
||||
cleanupMCPRequestContextForReq: jest.fn(),
|
||||
}));
|
||||
|
||||
// Import after mocks — these are the REAL implementations.
|
||||
const {
|
||||
GenerationJobManager,
|
||||
createStreamServices,
|
||||
buildPendingAction,
|
||||
getAgentCheckpointer,
|
||||
deleteAgentCheckpoint,
|
||||
__resetCheckpointerForTests,
|
||||
} = require('@librechat/api');
|
||||
const ResumeAgentController = require('~/server/controllers/agents/resume');
|
||||
|
||||
const USER_ID = 'ask-e2e-user';
|
||||
const MONGO_CFG = { type: 'mongo', ttl: 3600 };
|
||||
const ASK_TOOL = 'ask_user_question';
|
||||
|
||||
/**
|
||||
* Body-run counter + captured resolution. The body executes TWICE per answered
|
||||
* question by LangGraph contract (pass 1 runs until `interrupt()` throws; the
|
||||
* resume pass re-runs the body from the top and `askUserQuestion()` returns the
|
||||
* host's answer), so `bodyRuns` proves the re-entry semantics and
|
||||
* `resolvedAnswers` proves the answer round-trip.
|
||||
*/
|
||||
let bodyRuns = 0;
|
||||
let resolvedAnswers = [];
|
||||
const askTool = tool(
|
||||
async (input) => {
|
||||
bodyRuns += 1;
|
||||
const { answer } = askUserQuestion(input);
|
||||
resolvedAnswers.push(answer);
|
||||
return answer;
|
||||
},
|
||||
{
|
||||
name: ASK_TOOL,
|
||||
description: 'Ask the user a clarifying question and wait for their answer.',
|
||||
schema: z.object({
|
||||
question: z.string(),
|
||||
description: z.string().optional(),
|
||||
options: z.array(z.object({ label: z.string(), value: z.string() })).optional(),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Build a REAL run shaped like production `createRun` for the ask-only case:
|
||||
* durable checkpointer attached, `eagerEventToolExecution` on with the ask
|
||||
* tool excluded (mirrors the planned run.ts wiring) — and, deliberately, NO
|
||||
* `humanInTheLoop` and NO hooks.
|
||||
*/
|
||||
async function buildAskRun({ saver, responses, toolCalls, runId }) {
|
||||
const run = await Run.create({
|
||||
runId,
|
||||
graphConfig: {
|
||||
type: 'standard',
|
||||
llmConfig: {
|
||||
provider: Providers.OPENAI,
|
||||
model: 'gpt-4o-mini',
|
||||
streaming: true,
|
||||
streamUsage: false,
|
||||
},
|
||||
instructions: 'You are a helpful assistant.',
|
||||
tools: [askTool],
|
||||
compileOptions: { checkpointer: saver },
|
||||
},
|
||||
returnContent: true,
|
||||
customHandlers: {},
|
||||
tokenCounter: (text) => String(text ?? '').length,
|
||||
indexTokenCountMap: {},
|
||||
eagerEventToolExecution: { enabled: true, excludeToolNames: [ASK_TOOL] },
|
||||
});
|
||||
run.Graph.overrideModel = new FakeChatModel({ responses, toolCalls });
|
||||
return run;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a REAL run in the PRODUCTION shape: the agents endpoint loads tools
|
||||
* definitions-only, so the run is EVENT-DRIVEN (`toolDefinitions` non-empty flips
|
||||
* the SDK ToolNode to event dispatch) and the ask tool rides `graphTools` — the
|
||||
* SDK's in-graph direct-tool seam (agents#289, > 3.2.57) — because an event-
|
||||
* dispatched tool body executes in the host handler outside the Pregel task
|
||||
* frame, where `interrupt()` throws instead of pausing. This is the mode
|
||||
* `createRun` produces via `buildAgentInput`; the traditional-mode harness above
|
||||
* covers runs with zero toolDefinitions.
|
||||
*/
|
||||
async function buildAskRunEventMode({ saver, responses, toolCalls, runId }) {
|
||||
const run = await Run.create({
|
||||
runId,
|
||||
graphConfig: {
|
||||
type: 'standard',
|
||||
agents: [
|
||||
{
|
||||
agentId: 'agent-ask-event',
|
||||
provider: Providers.OPENAI,
|
||||
clientOptions: { model: 'gpt-4o-mini', streaming: true, streamUsage: false },
|
||||
instructions: 'You are a helpful assistant.',
|
||||
maxContextTokens: 8000,
|
||||
toolDefinitions: [{ name: 'dummy_event_tool', description: 'host-executed event tool' }],
|
||||
graphTools: [askTool],
|
||||
},
|
||||
],
|
||||
compileOptions: { checkpointer: saver },
|
||||
},
|
||||
returnContent: true,
|
||||
customHandlers: {},
|
||||
tokenCounter: (text) => String(text ?? '').length,
|
||||
indexTokenCountMap: {},
|
||||
eagerEventToolExecution: { enabled: true, excludeToolNames: [ASK_TOOL] },
|
||||
});
|
||||
run.Graph.overrideModel = new FakeChatModel({ responses, toolCalls });
|
||||
return run;
|
||||
}
|
||||
|
||||
const runConfig = (conversationId) => ({
|
||||
runName: 'AgentRun',
|
||||
configurable: { thread_id: conversationId, user_id: USER_ID },
|
||||
streamMode: 'values',
|
||||
version: 'v2',
|
||||
});
|
||||
|
||||
/** Poll until `predicate` returns true (the resume continuation is fire-and-forget). */
|
||||
async function waitFor(predicate, { timeoutMs = 10_000, intervalMs = 50 } = {}) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await predicate()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
throw new Error('waitFor: condition not met within timeout');
|
||||
}
|
||||
|
||||
async function checkpointCounts(conversationId) {
|
||||
const db = mongoose.connection.db;
|
||||
return {
|
||||
checkpoints: await db
|
||||
.collection('agent_checkpoints')
|
||||
.countDocuments({ thread_id: conversationId }),
|
||||
writes: await db
|
||||
.collection('agent_checkpoint_writes')
|
||||
.countDocuments({ thread_id: conversationId }),
|
||||
};
|
||||
}
|
||||
|
||||
let mongoServer;
|
||||
let saver;
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
await mongoose.connect(mongoServer.getUri());
|
||||
__resetCheckpointerForTests();
|
||||
saver = await getAgentCheckpointer(MONGO_CFG);
|
||||
|
||||
GenerationJobManager.configure({ ...createStreamServices(), cleanupOnComplete: false });
|
||||
GenerationJobManager.initialize();
|
||||
GenerationJobManager.setApprovalExpiredHandler(async (conversationId) => {
|
||||
await deleteAgentCheckpoint(conversationId, MONGO_CFG);
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
GenerationJobManager.setApprovalExpiredHandler(null);
|
||||
await GenerationJobManager.destroy();
|
||||
await mongoose.disconnect();
|
||||
await mongoServer.stop();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
bodyRuns = 0;
|
||||
resolvedAnswers = [];
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('ask_user_question lifecycle (full wiring, approval policy disabled)', () => {
|
||||
jest.setTimeout(30000);
|
||||
|
||||
test('a tool-body interrupt pauses durably and the REAL /resume controller delivers the answer as the tool result', async () => {
|
||||
const conversationId = `ask-e2e-resume-${Date.now()}`;
|
||||
const responseMessageId = 'resp-ask-1';
|
||||
|
||||
// --- Turn 1: the model calls the ask tool → interrupt() from inside the tool body. ---
|
||||
const run = await buildAskRun({
|
||||
saver,
|
||||
responses: ['Let me check with you.'],
|
||||
toolCalls: [
|
||||
{
|
||||
name: ASK_TOOL,
|
||||
args: {
|
||||
question: 'Which environment should I deploy to?',
|
||||
options: [
|
||||
{ label: 'Staging', value: 'staging' },
|
||||
{ label: 'Production', value: 'production' },
|
||||
],
|
||||
},
|
||||
id: 'tc_ask_1',
|
||||
type: 'tool_call',
|
||||
},
|
||||
],
|
||||
runId: responseMessageId,
|
||||
});
|
||||
await run.processStream(
|
||||
{ messages: [new HumanMessage('deploy the app')] },
|
||||
runConfig(conversationId),
|
||||
);
|
||||
|
||||
const interrupt = run.getInterrupt();
|
||||
expect(interrupt?.payload?.type).toBe('ask_user_question');
|
||||
expect(interrupt.payload.question).toEqual({
|
||||
question: 'Which environment should I deploy to?',
|
||||
options: [
|
||||
{ label: 'Staging', value: 'staging' },
|
||||
{ label: 'Production', value: 'production' },
|
||||
],
|
||||
});
|
||||
expect(bodyRuns).toBe(1); // body entered once; interrupt() threw before any answer
|
||||
expect(resolvedAnswers).toEqual([]);
|
||||
const paused = await checkpointCounts(conversationId);
|
||||
expect(paused.checkpoints).toBeGreaterThan(0); // the interrupt checkpoint is durable
|
||||
|
||||
// --- Pause bookkeeping (mirrors AgentClient.handleRunInterrupt). ---
|
||||
await GenerationJobManager.createJob(conversationId, USER_ID, conversationId);
|
||||
await GenerationJobManager.updateMetadata(conversationId, {
|
||||
endpoint: 'agents',
|
||||
agent_id: 'agent-ask-e2e',
|
||||
responseMessageId,
|
||||
});
|
||||
const pendingAction = buildPendingAction(interrupt.payload, {
|
||||
streamId: conversationId,
|
||||
conversationId,
|
||||
runId: responseMessageId,
|
||||
responseMessageId,
|
||||
ttlMs: 60_000,
|
||||
});
|
||||
expect(await GenerationJobManager.approvals.pause(conversationId, pendingAction)).toBe(true);
|
||||
|
||||
// --- Turn 2: answer through the REAL controller; the thin client rebuilds a REAL run. ---
|
||||
const thinClient = {
|
||||
contentParts: [],
|
||||
artifactPromises: [],
|
||||
conversationId,
|
||||
responseMessageId,
|
||||
pendingApproval: null,
|
||||
async resumeCompletion({ resumeValue, abortController }) {
|
||||
const resumed = await buildAskRun({
|
||||
saver,
|
||||
responses: ['Deploying to staging.'],
|
||||
runId: responseMessageId,
|
||||
});
|
||||
await resumed.resume(resumeValue, {
|
||||
...runConfig(conversationId),
|
||||
signal: (abortController ?? new AbortController()).signal,
|
||||
});
|
||||
const reInterrupt = resumed.getInterrupt?.();
|
||||
if (reInterrupt?.payload) {
|
||||
this.pendingApproval = reInterrupt.payload;
|
||||
}
|
||||
this.contentParts.push({ type: 'text', text: 'Deploying to staging.' });
|
||||
return resumed;
|
||||
},
|
||||
};
|
||||
const initializeClient = jest.fn(async () => ({ client: thinClient }));
|
||||
const addTitle = jest.fn();
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.user = { id: USER_ID };
|
||||
req.config = { endpoints: { agents: { checkpointer: MONGO_CFG } }, interfaceConfig: {} };
|
||||
next();
|
||||
});
|
||||
app.post('/api/agents/chat/resume', (req, res, next) =>
|
||||
ResumeAgentController(req, res, next, initializeClient, addTitle),
|
||||
);
|
||||
|
||||
const response = await request(app).post('/api/agents/chat/resume').send({
|
||||
conversationId,
|
||||
actionId: pendingAction.actionId,
|
||||
agent_id: 'agent-ask-e2e',
|
||||
endpoint: 'agents',
|
||||
answer: 'staging',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.status).toBe('resuming');
|
||||
await waitFor(async () => {
|
||||
const liveJob = await GenerationJobManager.getJob(conversationId);
|
||||
return liveJob?.status !== 'requires_action' && liveJob?.status !== 'running';
|
||||
});
|
||||
|
||||
expect(initializeClient).toHaveBeenCalledTimes(1);
|
||||
// Resume pass re-ran the body from the top; askUserQuestion() returned the answer.
|
||||
expect(bodyRuns).toBe(2);
|
||||
expect(resolvedAnswers).toEqual(['staging']);
|
||||
expect(thinClient.pendingApproval).toBeNull(); // no second question — turn completed
|
||||
|
||||
// Terminal state: the checkpoint was pruned by the REAL finalize path.
|
||||
await waitFor(async () => (await checkpointCounts(conversationId)).checkpoints === 0);
|
||||
expect(await checkpointCounts(conversationId)).toEqual({ checkpoints: 0, writes: 0 });
|
||||
});
|
||||
|
||||
test('EVENT-DRIVEN mode (production shape): the graphTools ask tool pauses and resumes over the REAL /resume controller', async () => {
|
||||
const conversationId = `ask-e2e-event-${Date.now()}`;
|
||||
const responseMessageId = 'resp-ask-event-1';
|
||||
|
||||
const run = await buildAskRunEventMode({
|
||||
saver,
|
||||
responses: ['Let me check with you.'],
|
||||
toolCalls: [
|
||||
{
|
||||
name: ASK_TOOL,
|
||||
args: { question: 'Proceed with the migration?' },
|
||||
id: 'tc_ask_ev1',
|
||||
type: 'tool_call',
|
||||
},
|
||||
],
|
||||
runId: responseMessageId,
|
||||
});
|
||||
await run.processStream(
|
||||
{ messages: [new HumanMessage('run the migration')] },
|
||||
runConfig(conversationId),
|
||||
);
|
||||
|
||||
const interrupt = run.getInterrupt();
|
||||
expect(interrupt?.payload?.type).toBe('ask_user_question');
|
||||
expect(interrupt.payload.question).toEqual({ question: 'Proceed with the migration?' });
|
||||
expect(bodyRuns).toBe(1);
|
||||
expect((await checkpointCounts(conversationId)).checkpoints).toBeGreaterThan(0);
|
||||
|
||||
await GenerationJobManager.createJob(conversationId, USER_ID, conversationId);
|
||||
await GenerationJobManager.updateMetadata(conversationId, {
|
||||
endpoint: 'agents',
|
||||
agent_id: 'agent-ask-e2e',
|
||||
responseMessageId,
|
||||
});
|
||||
const pendingAction = buildPendingAction(interrupt.payload, {
|
||||
streamId: conversationId,
|
||||
conversationId,
|
||||
runId: responseMessageId,
|
||||
responseMessageId,
|
||||
ttlMs: 60_000,
|
||||
});
|
||||
expect(await GenerationJobManager.approvals.pause(conversationId, pendingAction)).toBe(true);
|
||||
|
||||
const thinClient = {
|
||||
contentParts: [],
|
||||
artifactPromises: [],
|
||||
conversationId,
|
||||
responseMessageId,
|
||||
pendingApproval: null,
|
||||
async resumeCompletion({ resumeValue, abortController }) {
|
||||
const resumed = await buildAskRunEventMode({
|
||||
saver,
|
||||
responses: ['Migration underway.'],
|
||||
runId: responseMessageId,
|
||||
});
|
||||
await resumed.resume(resumeValue, {
|
||||
...runConfig(conversationId),
|
||||
signal: (abortController ?? new AbortController()).signal,
|
||||
});
|
||||
this.contentParts.push({ type: 'text', text: 'Migration underway.' });
|
||||
return resumed;
|
||||
},
|
||||
};
|
||||
const initializeClient = jest.fn(async () => ({ client: thinClient }));
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.user = { id: USER_ID };
|
||||
req.config = { endpoints: { agents: { checkpointer: MONGO_CFG } }, interfaceConfig: {} };
|
||||
next();
|
||||
});
|
||||
app.post('/api/agents/chat/resume', (req, res, next) =>
|
||||
ResumeAgentController(req, res, next, initializeClient, jest.fn()),
|
||||
);
|
||||
|
||||
const response = await request(app).post('/api/agents/chat/resume').send({
|
||||
conversationId,
|
||||
actionId: pendingAction.actionId,
|
||||
agent_id: 'agent-ask-e2e',
|
||||
endpoint: 'agents',
|
||||
answer: 'yes, proceed',
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await waitFor(async () => {
|
||||
const liveJob = await GenerationJobManager.getJob(conversationId);
|
||||
return liveJob?.status !== 'requires_action' && liveJob?.status !== 'running';
|
||||
});
|
||||
|
||||
expect(bodyRuns).toBe(2);
|
||||
expect(resolvedAnswers).toEqual(['yes, proceed']);
|
||||
await waitFor(async () => (await checkpointCounts(conversationId)).checkpoints === 0);
|
||||
});
|
||||
|
||||
test('a second question raised after resume re-pauses with a fresh ask_user_question interrupt', async () => {
|
||||
const conversationId = `ask-e2e-seq-${Date.now()}`;
|
||||
|
||||
const run = await buildAskRun({
|
||||
saver,
|
||||
responses: ['First question coming.'],
|
||||
toolCalls: [
|
||||
{
|
||||
name: ASK_TOOL,
|
||||
args: { question: 'Pick a color?' },
|
||||
id: 'tc_ask_q1',
|
||||
type: 'tool_call',
|
||||
},
|
||||
],
|
||||
runId: 'resp-ask-seq',
|
||||
});
|
||||
await run.processStream({ messages: [new HumanMessage('start')] }, runConfig(conversationId));
|
||||
expect(run.getInterrupt()?.payload?.type).toBe('ask_user_question');
|
||||
expect(bodyRuns).toBe(1);
|
||||
|
||||
// Resume with the first answer; the NEXT model turn asks a second question.
|
||||
const resumed = await buildAskRun({
|
||||
saver,
|
||||
responses: ['And one more thing.'],
|
||||
toolCalls: [
|
||||
{
|
||||
name: ASK_TOOL,
|
||||
args: { question: 'Pick a size?' },
|
||||
id: 'tc_ask_q2',
|
||||
type: 'tool_call',
|
||||
},
|
||||
],
|
||||
runId: 'resp-ask-seq',
|
||||
});
|
||||
await resumed.resume({ answer: 'blue' }, runConfig(conversationId));
|
||||
|
||||
expect(resolvedAnswers).toEqual(['blue']);
|
||||
const second = resumed.getInterrupt();
|
||||
expect(second?.payload?.type).toBe('ask_user_question');
|
||||
expect(second.payload.question).toEqual({ question: 'Pick a size?' });
|
||||
// q1 body ran twice (pass + resume); q2 body entered once and interrupted.
|
||||
expect(bodyRuns).toBe(3);
|
||||
|
||||
await deleteAgentCheckpoint(conversationId, MONGO_CFG);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,685 @@
|
||||
const { Tools } = require('librechat-data-provider');
|
||||
|
||||
// Mock all dependencies before requiring the module
|
||||
jest.mock('nanoid', () => ({
|
||||
nanoid: jest.fn(() => 'mock-id'),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
sendEvent: jest.fn(),
|
||||
HOST_FILE_AUTHORING_ARTIFACT_KEY: '__librechat_file_authoring',
|
||||
isCodeSessionToolName: jest.fn((name) =>
|
||||
['execute_code', 'bash_tool', 'read_file'].includes(name),
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: {
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/agents', () => ({
|
||||
...jest.requireActual('@librechat/agents'),
|
||||
getMessageId: jest.fn(),
|
||||
ToolEndHandler: jest.fn(),
|
||||
handleToolCalls: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/Citations', () => ({
|
||||
processFileCitations: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/Code/process', () => ({
|
||||
processCodeOutput: jest.fn(),
|
||||
/* `runPreviewFinalize` is the runtime pairing for `finalize` (defined
|
||||
* alongside processCodeOutput in process.js). The callback wires
|
||||
* the deferred render through it; reproduce the basic happy-path here so the
|
||||
* SSE-emit assertions still work. The catch/defensive-updateFile
|
||||
* branch is unit-tested directly against the real helper in
|
||||
* process.spec.js — exercising it here would add test coupling
|
||||
* without coverage benefit. */
|
||||
runPreviewFinalize: ({ finalize, onResolved }) => {
|
||||
if (typeof finalize !== 'function') {
|
||||
return;
|
||||
}
|
||||
finalize()
|
||||
.then((updated) => {
|
||||
if (!updated || !onResolved) {
|
||||
return;
|
||||
}
|
||||
onResolved(updated);
|
||||
})
|
||||
.catch(() => {
|
||||
/* swallowed in the mock — see process.spec.js for catch coverage */
|
||||
});
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Tools/credentials', () => ({
|
||||
loadAuthValues: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/process', () => ({
|
||||
saveBase64Image: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('createToolEndCallback', () => {
|
||||
let req, res, artifactPromises, createToolEndCallback;
|
||||
let logger;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Get the mocked logger
|
||||
logger = require('@librechat/data-schemas').logger;
|
||||
|
||||
// Now require the module after all mocks are set up
|
||||
const callbacks = require('../callbacks');
|
||||
createToolEndCallback = callbacks.createToolEndCallback;
|
||||
|
||||
req = {
|
||||
user: { id: 'user123' },
|
||||
};
|
||||
res = {
|
||||
headersSent: false,
|
||||
write: jest.fn(),
|
||||
};
|
||||
artifactPromises = [];
|
||||
});
|
||||
|
||||
describe('ui_resources artifact handling', () => {
|
||||
it('should process ui_resources artifact and return attachment when headers not sent', async () => {
|
||||
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||
|
||||
const output = {
|
||||
tool_call_id: 'tool123',
|
||||
artifact: {
|
||||
[Tools.ui_resources]: {
|
||||
data: [
|
||||
{ type: 'button', label: 'Click me' },
|
||||
{ type: 'input', placeholder: 'Enter text' },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const metadata = {
|
||||
run_id: 'run456',
|
||||
thread_id: 'thread789',
|
||||
};
|
||||
|
||||
await toolEndCallback({ output }, metadata);
|
||||
|
||||
// Wait for all promises to resolve
|
||||
const results = await Promise.all(artifactPromises);
|
||||
|
||||
// When headers are not sent, it returns attachment without writing
|
||||
expect(res.write).not.toHaveBeenCalled();
|
||||
|
||||
const attachment = results[0];
|
||||
expect(attachment).toEqual({
|
||||
type: Tools.ui_resources,
|
||||
messageId: 'run456',
|
||||
toolCallId: 'tool123',
|
||||
conversationId: 'thread789',
|
||||
[Tools.ui_resources]: [
|
||||
{ type: 'button', label: 'Click me' },
|
||||
{ type: 'input', placeholder: 'Enter text' },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should write to response when headers are already sent', async () => {
|
||||
res.headersSent = true;
|
||||
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||
|
||||
const output = {
|
||||
tool_call_id: 'tool123',
|
||||
artifact: {
|
||||
[Tools.ui_resources]: {
|
||||
data: [{ type: 'carousel', items: [] }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const metadata = {
|
||||
run_id: 'run456',
|
||||
thread_id: 'thread789',
|
||||
};
|
||||
|
||||
await toolEndCallback({ output }, metadata);
|
||||
const results = await Promise.all(artifactPromises);
|
||||
|
||||
expect(res.write).toHaveBeenCalled();
|
||||
expect(results[0]).toEqual({
|
||||
type: Tools.ui_resources,
|
||||
messageId: 'run456',
|
||||
toolCallId: 'tool123',
|
||||
conversationId: 'thread789',
|
||||
[Tools.ui_resources]: [{ type: 'carousel', items: [] }],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors when processing ui_resources', async () => {
|
||||
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||
|
||||
// Mock res.write to throw an error
|
||||
res.headersSent = true;
|
||||
res.write.mockImplementation(() => {
|
||||
throw new Error('Write failed');
|
||||
});
|
||||
|
||||
const output = {
|
||||
tool_call_id: 'tool123',
|
||||
artifact: {
|
||||
[Tools.ui_resources]: {
|
||||
data: [{ type: 'test' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const metadata = {
|
||||
run_id: 'run456',
|
||||
thread_id: 'thread789',
|
||||
};
|
||||
|
||||
await toolEndCallback({ output }, metadata);
|
||||
const results = await Promise.all(artifactPromises);
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
'Error processing artifact content:',
|
||||
expect.any(Error),
|
||||
);
|
||||
expect(results[0]).toBeNull();
|
||||
});
|
||||
|
||||
it('should handle multiple artifacts including ui_resources', async () => {
|
||||
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||
|
||||
const output = {
|
||||
tool_call_id: 'tool123',
|
||||
artifact: {
|
||||
[Tools.ui_resources]: {
|
||||
data: [{ type: 'chart', data: [] }],
|
||||
},
|
||||
[Tools.web_search]: {
|
||||
results: ['result1', 'result2'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const metadata = {
|
||||
run_id: 'run456',
|
||||
thread_id: 'thread789',
|
||||
};
|
||||
|
||||
await toolEndCallback({ output }, metadata);
|
||||
const results = await Promise.all(artifactPromises);
|
||||
|
||||
// Both ui_resources and web_search should be processed
|
||||
expect(artifactPromises).toHaveLength(2);
|
||||
expect(results).toHaveLength(2);
|
||||
|
||||
// Check ui_resources attachment
|
||||
const uiResourceAttachment = results.find((r) => r?.type === Tools.ui_resources);
|
||||
expect(uiResourceAttachment).toBeTruthy();
|
||||
expect(uiResourceAttachment[Tools.ui_resources]).toEqual([{ type: 'chart', data: [] }]);
|
||||
|
||||
// Check web_search attachment
|
||||
const webSearchAttachment = results.find((r) => r?.type === Tools.web_search);
|
||||
expect(webSearchAttachment).toBeTruthy();
|
||||
expect(webSearchAttachment[Tools.web_search]).toEqual({
|
||||
results: ['result1', 'result2'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should not process artifacts when output has no artifacts', async () => {
|
||||
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||
|
||||
const output = {
|
||||
tool_call_id: 'tool123',
|
||||
content: 'Some regular content',
|
||||
// No artifact property
|
||||
};
|
||||
|
||||
const metadata = {
|
||||
run_id: 'run456',
|
||||
thread_id: 'thread789',
|
||||
};
|
||||
|
||||
await toolEndCallback({ output }, metadata);
|
||||
|
||||
expect(artifactPromises).toHaveLength(0);
|
||||
expect(res.write).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle empty ui_resources data object', async () => {
|
||||
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||
|
||||
const output = {
|
||||
tool_call_id: 'tool123',
|
||||
artifact: {
|
||||
[Tools.ui_resources]: {
|
||||
data: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const metadata = {
|
||||
run_id: 'run456',
|
||||
thread_id: 'thread789',
|
||||
};
|
||||
|
||||
await toolEndCallback({ output }, metadata);
|
||||
const results = await Promise.all(artifactPromises);
|
||||
|
||||
expect(results[0]).toEqual({
|
||||
type: Tools.ui_resources,
|
||||
messageId: 'run456',
|
||||
toolCallId: 'tool123',
|
||||
conversationId: 'thread789',
|
||||
[Tools.ui_resources]: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle ui_resources with complex nested data', async () => {
|
||||
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||
|
||||
const complexData = {
|
||||
0: {
|
||||
type: 'form',
|
||||
fields: [
|
||||
{ name: 'field1', type: 'text', required: true },
|
||||
{ name: 'field2', type: 'select', options: ['a', 'b', 'c'] },
|
||||
],
|
||||
nested: {
|
||||
deep: {
|
||||
value: 123,
|
||||
array: [1, 2, 3],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const output = {
|
||||
tool_call_id: 'tool123',
|
||||
artifact: {
|
||||
[Tools.ui_resources]: {
|
||||
data: complexData,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const metadata = {
|
||||
run_id: 'run456',
|
||||
thread_id: 'thread789',
|
||||
};
|
||||
|
||||
await toolEndCallback({ output }, metadata);
|
||||
const results = await Promise.all(artifactPromises);
|
||||
|
||||
expect(results[0][Tools.ui_resources]).toEqual(complexData);
|
||||
});
|
||||
|
||||
it('should handle when output is undefined', async () => {
|
||||
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||
|
||||
const metadata = {
|
||||
run_id: 'run456',
|
||||
thread_id: 'thread789',
|
||||
};
|
||||
|
||||
await toolEndCallback({ output: undefined }, metadata);
|
||||
|
||||
expect(artifactPromises).toHaveLength(0);
|
||||
expect(res.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle when data parameter is undefined', async () => {
|
||||
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||
|
||||
const metadata = {
|
||||
run_id: 'run456',
|
||||
thread_id: 'thread789',
|
||||
};
|
||||
|
||||
await toolEndCallback(undefined, metadata);
|
||||
|
||||
expect(artifactPromises).toHaveLength(0);
|
||||
expect(res.write).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('code execution deferred-preview emit', () => {
|
||||
/* The deferred-preview code-execution flow emits the attachment twice over
|
||||
* SSE: the initial emit with `status: 'pending'` and the current run's
|
||||
* messageId, the deferred render with the resolved record. The preview update emit
|
||||
* must use the CURRENT run's messageId (not the persisted DB one)
|
||||
* because `processCodeOutput` intentionally preserves the original
|
||||
* `messageId` on cross-turn filename reuse — `getCodeGeneratedFiles`
|
||||
* needs that for prior-turn priming.
|
||||
*
|
||||
* Codex P1 review on PR #12957: shipping `updated.messageId`
|
||||
* straight from the DB record routed preview-update patches to the wrong
|
||||
* message slot, leaving the current turn's pending chip stuck. */
|
||||
|
||||
const { processCodeOutput } = require('~/server/services/Files/Code/process');
|
||||
|
||||
function makeCodeExecutionEvent({
|
||||
runId,
|
||||
threadId,
|
||||
toolCallId,
|
||||
fileId,
|
||||
name,
|
||||
toolName = 'execute_code',
|
||||
hostFileAuthoring = false,
|
||||
}) {
|
||||
return {
|
||||
output: {
|
||||
name: toolName,
|
||||
tool_call_id: toolCallId,
|
||||
artifact: {
|
||||
...(hostFileAuthoring ? { __librechat_file_authoring: true } : {}),
|
||||
session_id: 'sess-1',
|
||||
files: [{ id: fileId, name, session_id: 'sess-1' }],
|
||||
},
|
||||
},
|
||||
metadata: { run_id: runId, thread_id: threadId },
|
||||
};
|
||||
}
|
||||
|
||||
/** Parse the SSE frame `res.write` produces back to a payload object. */
|
||||
function parseSseAttachment(call) {
|
||||
const frame = call[0];
|
||||
const dataLine = frame.split('\n').find((l) => l.startsWith('data: '));
|
||||
return JSON.parse(dataLine.slice('data: '.length));
|
||||
}
|
||||
|
||||
it('the preview update emit uses the current run messageId, not the persisted DB messageId (cross-turn filename reuse)', async () => {
|
||||
/* Simulate turn-2 reusing `output.csv` from turn-1. The DB record
|
||||
* surfaced by `updateFile` carries the original `turn-1-msg`
|
||||
* messageId; the runtime emit must rewrite to `turn-2-msg`. */
|
||||
res.headersSent = true;
|
||||
const finalize = jest.fn().mockResolvedValue({
|
||||
file_id: 'fid-shared',
|
||||
filename: 'output.csv',
|
||||
filepath: '/uploads/output.csv',
|
||||
type: 'text/csv',
|
||||
conversationId: 'thread789',
|
||||
messageId: 'turn-1-original-msg', // persisted DB id (older turn)
|
||||
status: 'ready',
|
||||
text: '<table></table>',
|
||||
textFormat: 'html',
|
||||
});
|
||||
processCodeOutput.mockResolvedValue({
|
||||
file: {
|
||||
file_id: 'fid-shared',
|
||||
filename: 'output.csv',
|
||||
filepath: '/uploads/output.csv',
|
||||
type: 'text/csv',
|
||||
conversationId: 'thread789',
|
||||
messageId: 'turn-2-current-run', // runtime overlay (current turn)
|
||||
toolCallId: 'tool-2',
|
||||
status: 'pending',
|
||||
text: null,
|
||||
textFormat: null,
|
||||
},
|
||||
finalize,
|
||||
});
|
||||
|
||||
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||
const event = makeCodeExecutionEvent({
|
||||
runId: 'turn-2-current-run',
|
||||
threadId: 'thread789',
|
||||
toolCallId: 'tool-2',
|
||||
fileId: 'fid-shared',
|
||||
name: 'output.csv',
|
||||
});
|
||||
await toolEndCallback({ output: event.output }, event.metadata);
|
||||
await Promise.all(artifactPromises);
|
||||
// Wait one more tick so the fire-and-forget finalize() chain settles.
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
// Two SSE writes: the initial emit (pending) and the deferred render (ready).
|
||||
expect(res.write).toHaveBeenCalledTimes(2);
|
||||
const phase1 = parseSseAttachment(res.write.mock.calls[0]);
|
||||
const phase2 = parseSseAttachment(res.write.mock.calls[1]);
|
||||
|
||||
// Initial emit already used the runtime messageId (sourced from result.file).
|
||||
expect(phase1.messageId).toBe('turn-2-current-run');
|
||||
expect(phase1.status).toBe('pending');
|
||||
|
||||
/* The preview update MUST also route to the current run's messageId so the
|
||||
* frontend's `useAttachmentHandler` upserts under the same
|
||||
* messageAttachmentsMap slot as the initial emit. Routing to
|
||||
* `turn-1-original-msg` would land the patch on a stale message
|
||||
* and leave turn-2's pending chip stuck. */
|
||||
expect(phase2.messageId).toBe('turn-2-current-run');
|
||||
expect(phase2.file_id).toBe('fid-shared');
|
||||
expect(phase2.status).toBe('ready');
|
||||
expect(phase2.text).toBe('<table></table>');
|
||||
expect(phase2.toolCallId).toBe('tool-2');
|
||||
/* Wire-shape parity with the initial emit: preview update emits the full updated
|
||||
* record so the client doesn't see one shape on the initial emit and a
|
||||
* narrower projection on the deferred render. (Codex audit on PR #12957
|
||||
* Finding 1.) */
|
||||
expect(phase2.filename).toBe('output.csv');
|
||||
expect(phase2.filepath).toBe('/uploads/output.csv');
|
||||
expect(phase2.type).toBe('text/csv');
|
||||
expect(phase2.conversationId).toBe('thread789');
|
||||
expect(phase2.textFormat).toBe('html');
|
||||
});
|
||||
|
||||
it('the preview update emit is skipped when finalize resolves to null (no DB update happened)', async () => {
|
||||
res.headersSent = true;
|
||||
processCodeOutput.mockResolvedValue({
|
||||
file: {
|
||||
file_id: 'fid-1',
|
||||
filename: 'data.xlsx',
|
||||
filepath: '/uploads/data.xlsx',
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
messageId: 'run-1',
|
||||
toolCallId: 'tool-1',
|
||||
status: 'pending',
|
||||
},
|
||||
finalize: jest.fn().mockResolvedValue(null),
|
||||
});
|
||||
|
||||
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||
const event = makeCodeExecutionEvent({
|
||||
runId: 'run-1',
|
||||
threadId: 'thread-1',
|
||||
toolCallId: 'tool-1',
|
||||
fileId: 'fid-1',
|
||||
name: 'data.xlsx',
|
||||
});
|
||||
await toolEndCallback({ output: event.output }, event.metadata);
|
||||
await Promise.all(artifactPromises);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
// Only the initial emit fired; preview update noop'd because finalize returned null.
|
||||
expect(res.write).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('the preview update emit is skipped when the response stream has already closed', async () => {
|
||||
res.headersSent = true;
|
||||
/* Hand-rolled deferred so we can hold finalize() open until
|
||||
* AFTER setting `res.writableEnded = true`. Otherwise the mock
|
||||
* resolves synchronously, the .then() runs in the same microtask
|
||||
* queue as the artifactPromises await, and writableEnded is set
|
||||
* too late. */
|
||||
let resolveFinalize;
|
||||
const finalizeDeferred = new Promise((resolve) => {
|
||||
resolveFinalize = resolve;
|
||||
});
|
||||
processCodeOutput.mockResolvedValue({
|
||||
file: {
|
||||
file_id: 'fid-1',
|
||||
filename: 'data.xlsx',
|
||||
filepath: '/uploads/data.xlsx',
|
||||
type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
messageId: 'run-1',
|
||||
toolCallId: 'tool-1',
|
||||
status: 'pending',
|
||||
},
|
||||
finalize: jest.fn().mockReturnValue(finalizeDeferred),
|
||||
});
|
||||
|
||||
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||
const event = makeCodeExecutionEvent({
|
||||
runId: 'run-1',
|
||||
threadId: 'thread-1',
|
||||
toolCallId: 'tool-1',
|
||||
fileId: 'fid-1',
|
||||
name: 'data.xlsx',
|
||||
});
|
||||
await toolEndCallback({ output: event.output }, event.metadata);
|
||||
await Promise.all(artifactPromises);
|
||||
// Simulate the response closing AFTER the initial emit fires but BEFORE
|
||||
// the deferred render lands. The frontend's polling path will catch the
|
||||
// resolved record on its next tick.
|
||||
res.writableEnded = true;
|
||||
// Now resolve finalize and let the .then() chain run.
|
||||
resolveFinalize({
|
||||
file_id: 'fid-1',
|
||||
filename: 'data.xlsx',
|
||||
messageId: 'run-1',
|
||||
status: 'ready',
|
||||
text: '<x/>',
|
||||
textFormat: 'html',
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
// Initial emit wrote; preview update noop'd because writableEnded.
|
||||
expect(res.write).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not call finalize for a non-office file (no preview expected)', async () => {
|
||||
res.headersSent = true;
|
||||
processCodeOutput.mockResolvedValue({
|
||||
file: {
|
||||
file_id: 'fid-txt',
|
||||
filename: 'note.txt',
|
||||
filepath: '/uploads/note.txt',
|
||||
type: 'text/plain',
|
||||
messageId: 'run-1',
|
||||
toolCallId: 'tool-1',
|
||||
// No status — non-office files skip the deferred render entirely.
|
||||
},
|
||||
// No finalize key — caller should not call anything.
|
||||
});
|
||||
|
||||
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||
const event = makeCodeExecutionEvent({
|
||||
runId: 'run-1',
|
||||
threadId: 'thread-1',
|
||||
toolCallId: 'tool-1',
|
||||
fileId: 'fid-txt',
|
||||
name: 'note.txt',
|
||||
});
|
||||
await toolEndCallback({ output: event.output }, event.metadata);
|
||||
await Promise.all(artifactPromises);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
expect(res.write).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('processes create_file sandbox artifacts like code execution outputs', async () => {
|
||||
res.headersSent = true;
|
||||
processCodeOutput.mockResolvedValue({
|
||||
file: {
|
||||
file_id: 'fid-created',
|
||||
filename: 'created.txt',
|
||||
filepath: '/uploads/created.txt',
|
||||
type: 'text/plain',
|
||||
conversationId: 'thread789',
|
||||
messageId: 'run-create',
|
||||
toolCallId: 'tool-create',
|
||||
status: 'ready',
|
||||
},
|
||||
});
|
||||
|
||||
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||
const event = makeCodeExecutionEvent({
|
||||
runId: 'run-create',
|
||||
threadId: 'thread789',
|
||||
toolCallId: 'tool-create',
|
||||
fileId: 'fid-created',
|
||||
name: 'created.txt',
|
||||
toolName: 'create_file',
|
||||
hostFileAuthoring: true,
|
||||
});
|
||||
await toolEndCallback({ output: event.output }, event.metadata);
|
||||
await Promise.all(artifactPromises);
|
||||
|
||||
expect(processCodeOutput).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'fid-created',
|
||||
name: 'created.txt',
|
||||
messageId: 'run-create',
|
||||
toolCallId: 'tool-create',
|
||||
conversationId: 'thread789',
|
||||
}),
|
||||
);
|
||||
expect(res.write).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not process arbitrary user tool artifacts named create_file as code outputs', async () => {
|
||||
res.headersSent = true;
|
||||
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||
const event = makeCodeExecutionEvent({
|
||||
runId: 'run-user-create',
|
||||
threadId: 'thread789',
|
||||
toolCallId: 'tool-user-create',
|
||||
fileId: 'fid-user-created',
|
||||
name: 'created.txt',
|
||||
toolName: 'create_file',
|
||||
});
|
||||
|
||||
await toolEndCallback({ output: event.output }, event.metadata);
|
||||
await Promise.all(artifactPromises);
|
||||
|
||||
expect(processCodeOutput).not.toHaveBeenCalled();
|
||||
expect(res.write).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isStreamWritable', () => {
|
||||
/* Direct parametric coverage of the predicate that gates SSE writes
|
||||
* in both the chat-completions and Open Responses callbacks. The
|
||||
* existing deferred-preview tests cover this indirectly via the
|
||||
* `writeAttachmentUpdate` writableEnded path; these tests pin down
|
||||
* each individual branch so a future modification (e.g. adding a
|
||||
* new condition) can't silently regress.
|
||||
* (Comprehensive review NIT on PR #12957.) */
|
||||
const { isStreamWritable } = require('../callbacks');
|
||||
|
||||
it('returns true when streamId is truthy regardless of res state', () => {
|
||||
/* Resumable mode writes go to the job emitter; res state is
|
||||
* irrelevant. Even a closed res with no headers should not block. */
|
||||
expect(isStreamWritable(null, 'stream-1')).toBe(true);
|
||||
expect(isStreamWritable({ headersSent: false, writableEnded: true }, 'stream-1')).toBe(true);
|
||||
expect(isStreamWritable(undefined, 'stream-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when streamId is falsy and res is null/undefined', () => {
|
||||
expect(isStreamWritable(null, null)).toBe(false);
|
||||
expect(isStreamWritable(undefined, null)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when headers have not been sent yet', () => {
|
||||
expect(isStreamWritable({ headersSent: false, writableEnded: false }, null)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when the stream has already ended', () => {
|
||||
expect(isStreamWritable({ headersSent: true, writableEnded: true }, null)).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true on the happy path: headers sent, not ended, no streamId', () => {
|
||||
expect(isStreamWritable({ headersSent: true, writableEnded: false }, null)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
const AgentClient = require('../client');
|
||||
|
||||
/** Minimal post-(maybe-)summary snapshot. baseUsed = maxContextTokens(1000) -
|
||||
* remainingContextTokens(700) = 300, so the marker (summaryUsedTokens) is 300. */
|
||||
const snapshot = (summaryTokens) => ({
|
||||
runId: 'run-1',
|
||||
agentId: 'agent-1',
|
||||
breakdown: {
|
||||
maxContextTokens: 1000,
|
||||
instructionTokens: 50,
|
||||
systemMessageTokens: 50,
|
||||
dynamicInstructionTokens: 0,
|
||||
toolSchemaTokens: 0,
|
||||
summaryTokens,
|
||||
toolCount: 0,
|
||||
messageCount: 1,
|
||||
messageTokens: 20,
|
||||
availableForMessages: 900,
|
||||
},
|
||||
contextBudget: 1000,
|
||||
remainingContextTokens: 700,
|
||||
prePruneContextTokens: 300,
|
||||
effectiveInstructionTokens: 50,
|
||||
calibrationRatio: 1,
|
||||
});
|
||||
|
||||
const primary = { input_tokens: 10, output_tokens: 5, total_tokens: 15 };
|
||||
const summarizationUsage = { ...primary, usage_type: 'summarization' };
|
||||
const primaryFor = (runId, output_tokens) => ({
|
||||
input_tokens: 10,
|
||||
output_tokens,
|
||||
total_tokens: 10 + output_tokens,
|
||||
provider: 'openAI',
|
||||
runId,
|
||||
});
|
||||
|
||||
function buildMeta({ snap, latestUsageIndex, usageEvents }) {
|
||||
const self = {
|
||||
collectedThoughtSignatures: null,
|
||||
usageEmitSink: usageEvents,
|
||||
contextUsageSink: snap
|
||||
? { latest: snap, count: 1, latestUsageIndex }
|
||||
: { latest: null, count: 0 },
|
||||
};
|
||||
return AgentClient.prototype.buildResponseMetadata.call(self);
|
||||
}
|
||||
|
||||
describe('AgentClient.buildResponseMetadata — snapshot persistence + summary marker', () => {
|
||||
it('persists the snapshot when a primary usage follows it (normal turn)', () => {
|
||||
const meta = buildMeta({ snap: snapshot(0), latestUsageIndex: 0, usageEvents: [primary] });
|
||||
expect(meta.contextUsage).toBeDefined();
|
||||
expect(meta.summaryUsedTokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it('persists the post-summary snapshot when the only pre-primary usage is the summarization', () => {
|
||||
/** A summarized turn: the summarization usage precedes the post-summary
|
||||
* snapshot (index 1), then the model's primary usage follows it. The old
|
||||
* count guard miscounted and dropped this; the new guard keeps it. The
|
||||
* marker subtracts the summarization output (5): the generated summary is in
|
||||
* the snapshot baseline (summaryTokens) AND the response tokenCount, so
|
||||
* 300 − 5 = 295 keeps the client estimate from counting it twice. */
|
||||
const meta = buildMeta({
|
||||
snap: snapshot(80),
|
||||
latestUsageIndex: 1,
|
||||
usageEvents: [summarizationUsage, primary],
|
||||
});
|
||||
expect(meta.contextUsage).toBeDefined();
|
||||
expect(meta.summaryUsedTokens).toBe(295);
|
||||
});
|
||||
|
||||
it('still emits the summary marker when the final call emitted no usage', () => {
|
||||
/** Interrupted summarized turn: no primary usage follows the latest snapshot,
|
||||
* so the snapshot is (correctly) not persisted — but the coarse marker
|
||||
* survives so the client estimate still caps the discarded history. The
|
||||
* summarization output (5) is subtracted (300 − 5 = 295). */
|
||||
const meta = buildMeta({
|
||||
snap: snapshot(80),
|
||||
latestUsageIndex: 1,
|
||||
usageEvents: [summarizationUsage],
|
||||
});
|
||||
expect(meta.contextUsage).toBeUndefined();
|
||||
expect(meta.summaryUsedTokens).toBe(295);
|
||||
});
|
||||
|
||||
it('drops the snapshot and emits no marker when the final call had no usage and no summary', () => {
|
||||
const meta = buildMeta({ snap: snapshot(0), latestUsageIndex: 1, usageEvents: [primary] });
|
||||
expect(meta.contextUsage).toBeUndefined();
|
||||
expect(meta.summaryUsedTokens).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does not persist the snapshot when only a parallel run produced post-snapshot usage', () => {
|
||||
/** A snapshot (run-1) → B snapshot (run-1 is latest) but the only following
|
||||
* usage belongs to a sibling run (run-2). The guard must NOT persist run-1's
|
||||
* snapshot with run-2's output — it falls back to the per-message estimate. */
|
||||
const meta = buildMeta({
|
||||
snap: snapshot(0),
|
||||
latestUsageIndex: 0,
|
||||
usageEvents: [primaryFor('run-2', 99)],
|
||||
});
|
||||
expect(meta.contextUsage).toBeUndefined();
|
||||
});
|
||||
|
||||
it('persists with the snapshot run output when its own primary usage follows', () => {
|
||||
const meta = buildMeta({
|
||||
snap: snapshot(0),
|
||||
latestUsageIndex: 0,
|
||||
usageEvents: [primaryFor('run-2', 99), primaryFor('run-1', 7)],
|
||||
});
|
||||
expect(meta.contextUsage).toBeDefined();
|
||||
expect(meta.contextUsage.completedOutputTokens).toBe(7);
|
||||
});
|
||||
|
||||
it('subtracts earlier tool-loop output from the summary marker (interrupted turn)', () => {
|
||||
/** Multi-call summarized turn stopped before the final usage: the earlier
|
||||
* call (output 40) is baked into baseUsed (300), so the marker is 300 − 40 =
|
||||
* 260. No primary follows the snapshot, so the full snapshot is not persisted
|
||||
* and the client uses this marker — which must not double-count the 40 that
|
||||
* the response tokenCount also carries. */
|
||||
const meta = buildMeta({
|
||||
snap: snapshot(80),
|
||||
latestUsageIndex: 1,
|
||||
usageEvents: [primaryFor('run-1', 40)],
|
||||
});
|
||||
expect(meta.contextUsage).toBeUndefined();
|
||||
expect(meta.summaryUsedTokens).toBe(260);
|
||||
});
|
||||
|
||||
it('subtracts only this run’s earlier output, not a parallel run’s', () => {
|
||||
const meta = buildMeta({
|
||||
snap: snapshot(80),
|
||||
latestUsageIndex: 2,
|
||||
usageEvents: [primaryFor('run-2', 999), primaryFor('run-1', 40), primaryFor('run-1', 5)],
|
||||
});
|
||||
/** baseUsed 300 − run-1's earlier 40 = 260; run-2's 999 is ignored. */
|
||||
expect(meta.summaryUsedTokens).toBe(260);
|
||||
/** run-1's own primary follows the snapshot → snapshot persisted with output 5. */
|
||||
expect(meta.contextUsage.completedOutputTokens).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
const { EModelEndpoint, AgentCapabilities } = require('librechat-data-provider');
|
||||
|
||||
/**
|
||||
* Pins the capability-flag derivation that `AgentClient::useMemory` uses when
|
||||
* it calls `initializeAgent` for the memory-extraction agent. The expression
|
||||
* is trivial but lives in a controller path that's otherwise hard to unit-
|
||||
* test, so a focused regression guard at the pure-logic layer ensures any
|
||||
* drift in config-key names (`agents`, `capabilities`) or capability enum
|
||||
* values (`execute_code`) surfaces here instead of silently stripping
|
||||
* `bash_tool` + `read_file` from memory agents in production.
|
||||
*
|
||||
* The expression mirrored below is the one in
|
||||
* `api/server/controllers/agents/client.js::useMemory`:
|
||||
*
|
||||
* new Set(appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities)
|
||||
* .has(AgentCapabilities.execute_code)
|
||||
*/
|
||||
function deriveMemoryCodeEnvAvailable(appConfig) {
|
||||
return new Set(appConfig?.endpoints?.[EModelEndpoint.agents]?.capabilities).has(
|
||||
AgentCapabilities.execute_code,
|
||||
);
|
||||
}
|
||||
|
||||
describe('AgentClient::useMemory — codeEnvAvailable derivation', () => {
|
||||
it('returns true when appConfig lists execute_code under the agents endpoint capabilities', () => {
|
||||
expect(
|
||||
deriveMemoryCodeEnvAvailable({
|
||||
endpoints: {
|
||||
[EModelEndpoint.agents]: {
|
||||
capabilities: [AgentCapabilities.execute_code, AgentCapabilities.file_search],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when the agents endpoint omits execute_code', () => {
|
||||
expect(
|
||||
deriveMemoryCodeEnvAvailable({
|
||||
endpoints: {
|
||||
[EModelEndpoint.agents]: {
|
||||
capabilities: [AgentCapabilities.file_search, AgentCapabilities.web_search],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when the capabilities array is absent', () => {
|
||||
expect(deriveMemoryCodeEnvAvailable({ endpoints: { [EModelEndpoint.agents]: {} } })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('returns false when the agents endpoint config is absent', () => {
|
||||
expect(deriveMemoryCodeEnvAvailable({ endpoints: {} })).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false when appConfig is null / undefined', () => {
|
||||
/* Defensive — `req.config` can be unset in edge-case test harnesses and
|
||||
ephemeral-agent flows; the memory path must not throw on access. */
|
||||
expect(deriveMemoryCodeEnvAvailable(null)).toBe(false);
|
||||
expect(deriveMemoryCodeEnvAvailable(undefined)).toBe(false);
|
||||
});
|
||||
|
||||
it('matches the literal string "execute_code" — catches enum rename drift', () => {
|
||||
/* Pins the capability enum value so a rename of `AgentCapabilities.execute_code`
|
||||
that doesn't propagate to the controllers surfaces here. If this test breaks,
|
||||
update the underlying expression in `useMemory` and the helpers in
|
||||
`initialize.js` / `openai.js` / `responses.js` to match. */
|
||||
expect(AgentCapabilities.execute_code).toBe('execute_code');
|
||||
expect(EModelEndpoint.agents).toBe('agents');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
/**
|
||||
* Full-wiring HITL checkpoint lifecycle e2e.
|
||||
*
|
||||
* Every HITL-specific component here is REAL: the `@librechat/agents` Run (driven by the
|
||||
* SDK's FakeChatModel scripted to call a gated tool), the PreToolUse approval hook +
|
||||
* `humanInTheLoop` wiring, the LazyMongoSaver over mongodb-memory-server, the
|
||||
* GenerationJobManager (in-memory services), and the `/agents/chat/resume` controller via
|
||||
* supertest. Only LibreChat's persistence adapters (`~/models`), request cleanup, and the
|
||||
* concurrency gate are mocked. This is the cross-layer seam none of the unit suites cover:
|
||||
* pause → durable checkpoint → HTTP approval → rebuilt-run resume → finalize prune.
|
||||
*/
|
||||
const express = require('express');
|
||||
const request = require('supertest');
|
||||
const mongoose = require('mongoose');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
const { z } = require('zod');
|
||||
const { tool } = require('@langchain/core/tools');
|
||||
const { HumanMessage } = require('@langchain/core/messages');
|
||||
const { Run, Providers, FakeChatModel } = require('@librechat/agents');
|
||||
|
||||
const mockLogger = { debug: jest.fn(), info: jest.fn(), warn: jest.fn(), error: jest.fn() };
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
...jest.requireActual('@librechat/data-schemas'),
|
||||
logger: mockLogger,
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
...jest.requireActual('@librechat/api'),
|
||||
checkAndIncrementPendingRequest: jest.fn(async () => ({ allowed: true })),
|
||||
decrementPendingRequest: jest.fn(async () => {}),
|
||||
}));
|
||||
|
||||
jest.mock('~/models', () => ({
|
||||
saveMessage: jest.fn(async (req, message) => message),
|
||||
getConvo: jest.fn(async () => null),
|
||||
getMessages: jest.fn(async () => []),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/cleanup', () => ({
|
||||
disposeClient: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/MCPRequestContext', () => ({
|
||||
getMCPRequestContext: jest.fn(() => null),
|
||||
cleanupMCPRequestContextForReq: jest.fn(),
|
||||
}));
|
||||
|
||||
// Import after mocks — these are the REAL implementations.
|
||||
const {
|
||||
GenerationJobManager,
|
||||
createStreamServices,
|
||||
buildPendingAction,
|
||||
getAgentCheckpointer,
|
||||
deleteAgentCheckpoint,
|
||||
buildHITLRunWiring,
|
||||
resolveToolApprovalPolicy,
|
||||
__resetCheckpointerForTests,
|
||||
} = require('@librechat/api');
|
||||
const ResumeAgentController = require('~/server/controllers/agents/resume');
|
||||
|
||||
const USER_ID = 'hitl-e2e-user';
|
||||
const MONGO_CFG = { type: 'mongo', ttl: 3600 };
|
||||
const GATED_TOOL = 'guarded_echo';
|
||||
|
||||
/** Side-effect counter: proves the gated tool runs exactly once across pause+resume. */
|
||||
let toolExecutions = 0;
|
||||
const guardedTool = tool(async ({ text }) => `echo:${text}`, {
|
||||
name: GATED_TOOL,
|
||||
description: 'Echoes text back, but requires human approval first.',
|
||||
schema: z.object({ text: z.string() }),
|
||||
});
|
||||
guardedTool.func = async ({ text }) => {
|
||||
toolExecutions += 1;
|
||||
return `echo:${text}`;
|
||||
};
|
||||
|
||||
/** Build a REAL run with the HITL wiring + durable checkpointer attached (mirrors createRun). */
|
||||
async function buildHitlRun({ saver, conversationId, responses, toolCalls, runId }) {
|
||||
const hitl = buildHITLRunWiring(
|
||||
resolveToolApprovalPolicy({ endpoint: { enabled: true, ask: [GATED_TOOL] } }),
|
||||
{ userId: USER_ID, conversationId, appConfig: {} },
|
||||
);
|
||||
const run = await Run.create({
|
||||
runId,
|
||||
graphConfig: {
|
||||
type: 'standard',
|
||||
llmConfig: {
|
||||
provider: Providers.OPENAI,
|
||||
model: 'gpt-4o-mini',
|
||||
streaming: true,
|
||||
streamUsage: false,
|
||||
},
|
||||
instructions: 'You are a helpful assistant.',
|
||||
tools: [guardedTool],
|
||||
compileOptions: { checkpointer: saver },
|
||||
},
|
||||
returnContent: true,
|
||||
customHandlers: {},
|
||||
tokenCounter: (text) => String(text ?? '').length,
|
||||
indexTokenCountMap: {},
|
||||
...(hitl && { humanInTheLoop: hitl.humanInTheLoop, hooks: hitl.hooks }),
|
||||
});
|
||||
run.Graph.overrideModel = new FakeChatModel({ responses, toolCalls });
|
||||
return run;
|
||||
}
|
||||
|
||||
const runConfig = (conversationId) => ({
|
||||
runName: 'AgentRun',
|
||||
configurable: { thread_id: conversationId, user_id: USER_ID },
|
||||
streamMode: 'values',
|
||||
version: 'v2',
|
||||
});
|
||||
|
||||
/** Poll until `predicate` returns true (the resume continuation is fire-and-forget). */
|
||||
async function waitFor(predicate, { timeoutMs = 10_000, intervalMs = 50 } = {}) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
while (Date.now() < deadline) {
|
||||
if (await predicate()) {
|
||||
return;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
||||
}
|
||||
throw new Error('waitFor: condition not met within timeout');
|
||||
}
|
||||
|
||||
async function checkpointCounts(conversationId) {
|
||||
const db = mongoose.connection.db;
|
||||
return {
|
||||
checkpoints: await db
|
||||
.collection('agent_checkpoints')
|
||||
.countDocuments({ thread_id: conversationId }),
|
||||
writes: await db
|
||||
.collection('agent_checkpoint_writes')
|
||||
.countDocuments({ thread_id: conversationId }),
|
||||
};
|
||||
}
|
||||
|
||||
let mongoServer;
|
||||
let saver;
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
await mongoose.connect(mongoServer.getUri());
|
||||
__resetCheckpointerForTests();
|
||||
saver = await getAgentCheckpointer(MONGO_CFG);
|
||||
|
||||
GenerationJobManager.configure({ ...createStreamServices(), cleanupOnComplete: false });
|
||||
GenerationJobManager.initialize();
|
||||
// Mirrors api/server/index.js: expiry prunes the paused run's durable checkpoint.
|
||||
GenerationJobManager.setApprovalExpiredHandler(async (conversationId) => {
|
||||
await deleteAgentCheckpoint(conversationId, MONGO_CFG);
|
||||
});
|
||||
}, 60000);
|
||||
|
||||
afterAll(async () => {
|
||||
GenerationJobManager.setApprovalExpiredHandler(null);
|
||||
await GenerationJobManager.destroy();
|
||||
await mongoose.disconnect();
|
||||
await mongoServer.stop();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
toolExecutions = 0;
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('HITL checkpoint lifecycle (full wiring)', () => {
|
||||
jest.setTimeout(30000);
|
||||
|
||||
test('a clean turn (no tool gating triggered) persists NOTHING durable', async () => {
|
||||
const conversationId = `e2e-clean-${Date.now()}`;
|
||||
const run = await buildHitlRun({
|
||||
saver,
|
||||
conversationId,
|
||||
responses: ['Hello there!'],
|
||||
runId: 'resp-clean',
|
||||
});
|
||||
await run.processStream({ messages: [new HumanMessage('hi')] }, runConfig(conversationId));
|
||||
|
||||
expect(run.getInterrupt?.()).toBeFalsy();
|
||||
expect(await checkpointCounts(conversationId)).toEqual({ checkpoints: 0, writes: 0 });
|
||||
});
|
||||
|
||||
test('a turn that ERRORS before pausing persists NOTHING durable', async () => {
|
||||
const conversationId = `e2e-error-${Date.now()}`;
|
||||
const run = await buildHitlRun({
|
||||
saver,
|
||||
conversationId,
|
||||
responses: ['unused'],
|
||||
runId: 'resp-error',
|
||||
});
|
||||
class BoomModel extends FakeChatModel {
|
||||
// eslint-disable-next-line require-yield
|
||||
async *_streamResponseChunks() {
|
||||
throw new Error('model boom');
|
||||
}
|
||||
}
|
||||
run.Graph.overrideModel = new BoomModel({ responses: ['unused'] });
|
||||
|
||||
await expect(
|
||||
run.processStream({ messages: [new HumanMessage('hi')] }, runConfig(conversationId)),
|
||||
).rejects.toThrow('model boom');
|
||||
|
||||
expect(await checkpointCounts(conversationId)).toEqual({ checkpoints: 0, writes: 0 });
|
||||
});
|
||||
|
||||
test('pause → approve over the REAL /resume controller → tool runs once → checkpoint pruned', async () => {
|
||||
const conversationId = `e2e-resume-${Date.now()}`;
|
||||
const responseMessageId = 'resp-pause-1';
|
||||
|
||||
// --- Turn 1: the model calls the gated tool → PreToolUse 'ask' → interrupt. ---
|
||||
const run = await buildHitlRun({
|
||||
saver,
|
||||
conversationId,
|
||||
responses: ['Let me run that.'],
|
||||
toolCalls: [{ name: GATED_TOOL, args: { text: 'hi' }, id: 'tc_1', type: 'tool_call' }],
|
||||
runId: responseMessageId,
|
||||
});
|
||||
await run.processStream(
|
||||
{ messages: [new HumanMessage('run the guarded tool')] },
|
||||
runConfig(conversationId),
|
||||
);
|
||||
|
||||
const interrupt = run.getInterrupt();
|
||||
expect(interrupt?.payload?.type).toBe('tool_approval');
|
||||
expect(toolExecutions).toBe(0); // gated — must NOT have run pre-approval
|
||||
const paused = await checkpointCounts(conversationId);
|
||||
expect(paused.checkpoints).toBeGreaterThan(0); // the interrupt checkpoint is durable
|
||||
|
||||
// --- Pause bookkeeping (mirrors AgentClient.handleRunInterrupt). ---
|
||||
const job = await GenerationJobManager.createJob(conversationId, USER_ID, conversationId);
|
||||
await GenerationJobManager.updateMetadata(conversationId, {
|
||||
endpoint: 'agents',
|
||||
agent_id: 'agent-e2e',
|
||||
responseMessageId,
|
||||
});
|
||||
const pendingAction = buildPendingAction(interrupt.payload, {
|
||||
streamId: conversationId,
|
||||
conversationId,
|
||||
runId: responseMessageId,
|
||||
responseMessageId,
|
||||
ttlMs: 60_000,
|
||||
});
|
||||
expect(await GenerationJobManager.approvals.pause(conversationId, pendingAction)).toBe(true);
|
||||
|
||||
// --- Turn 2: approve through the REAL controller; the thin client rebuilds a REAL run. ---
|
||||
const thinClient = {
|
||||
contentParts: [],
|
||||
artifactPromises: [],
|
||||
conversationId,
|
||||
responseMessageId,
|
||||
pendingApproval: null,
|
||||
async resumeCompletion({ resumeValue, abortController }) {
|
||||
const resumed = await buildHitlRun({
|
||||
saver,
|
||||
conversationId,
|
||||
responses: ['Done after approval.'],
|
||||
runId: responseMessageId,
|
||||
});
|
||||
await resumed.resume(resumeValue, {
|
||||
...runConfig(conversationId),
|
||||
signal: (abortController ?? new AbortController()).signal,
|
||||
});
|
||||
const reInterrupt = resumed.getInterrupt?.();
|
||||
if (reInterrupt?.payload) {
|
||||
this.pendingApproval = reInterrupt.payload;
|
||||
}
|
||||
this.contentParts.push({ type: 'text', text: 'Done after approval.' });
|
||||
return resumed;
|
||||
},
|
||||
};
|
||||
const initializeClient = jest.fn(async () => ({ client: thinClient }));
|
||||
const addTitle = jest.fn();
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use((req, _res, next) => {
|
||||
req.user = { id: USER_ID };
|
||||
req.config = { endpoints: { agents: { checkpointer: MONGO_CFG } }, interfaceConfig: {} };
|
||||
next();
|
||||
});
|
||||
app.post('/api/agents/chat/resume', (req, res, next) =>
|
||||
ResumeAgentController(req, res, next, initializeClient, addTitle),
|
||||
);
|
||||
|
||||
const response = await request(app)
|
||||
.post('/api/agents/chat/resume')
|
||||
.send({
|
||||
conversationId,
|
||||
actionId: pendingAction.actionId,
|
||||
agent_id: 'agent-e2e',
|
||||
endpoint: 'agents',
|
||||
decisions: [{ tool_call_id: 'tc_1', decision: 'approve' }],
|
||||
});
|
||||
|
||||
// The controller ACKs immediately ({ status: 'resuming' }) and drives the resumed run
|
||||
// asynchronously — wait for the terminal side effects before asserting.
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.body.status).toBe('resuming');
|
||||
await waitFor(async () => {
|
||||
const liveJob = await GenerationJobManager.getJob(conversationId);
|
||||
return liveJob?.status !== 'requires_action' && liveJob?.status !== 'running';
|
||||
});
|
||||
|
||||
expect(initializeClient).toHaveBeenCalledTimes(1);
|
||||
expect(toolExecutions).toBe(1); // approved tool ran exactly ONCE across pause+resume
|
||||
|
||||
// Terminal state: the checkpoint was pruned by the REAL finalize path.
|
||||
await waitFor(async () => (await checkpointCounts(conversationId)).checkpoints === 0);
|
||||
expect(await checkpointCounts(conversationId)).toEqual({ checkpoints: 0, writes: 0 });
|
||||
|
||||
expect(job).toBeDefined();
|
||||
});
|
||||
|
||||
test('an abandoned pause is pruned eagerly on approval EXPIRY (not left to the TTL)', async () => {
|
||||
const conversationId = `e2e-expiry-${Date.now()}`;
|
||||
const run = await buildHitlRun({
|
||||
saver,
|
||||
conversationId,
|
||||
responses: ['Let me run that.'],
|
||||
toolCalls: [{ name: GATED_TOOL, args: { text: 'x' }, id: 'tc_exp', type: 'tool_call' }],
|
||||
runId: 'resp-expire',
|
||||
});
|
||||
await run.processStream({ messages: [new HumanMessage('run it')] }, runConfig(conversationId));
|
||||
const interrupt = run.getInterrupt();
|
||||
expect((await checkpointCounts(conversationId)).checkpoints).toBeGreaterThan(0);
|
||||
|
||||
await GenerationJobManager.createJob(conversationId, USER_ID, conversationId);
|
||||
const pendingAction = buildPendingAction(interrupt.payload, {
|
||||
streamId: conversationId,
|
||||
conversationId,
|
||||
runId: 'resp-expire',
|
||||
responseMessageId: 'resp-expire',
|
||||
ttlMs: 60_000,
|
||||
});
|
||||
await GenerationJobManager.approvals.pause(conversationId, pendingAction);
|
||||
|
||||
// The sweeper/stale-submit path: expiry fires the registered checkpoint prune.
|
||||
expect(await GenerationJobManager.expireApproval(conversationId, pendingAction.actionId)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
expect(await GenerationJobManager.getJobStatus(conversationId)).toBe('aborted');
|
||||
expect(await checkpointCounts(conversationId)).toEqual({ checkpoints: 0, writes: 0 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,597 @@
|
||||
/**
|
||||
* Tests for job replacement detection in ResumableAgentController
|
||||
*
|
||||
* Tests the following fixes from PR #11462:
|
||||
* 1. Job creation timestamp tracking
|
||||
* 2. Stale job detection and event skipping
|
||||
* 3. Response message saving before final event emission
|
||||
*/
|
||||
|
||||
const mockLogger = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
};
|
||||
|
||||
const mockGenerationJobManager = {
|
||||
createJob: jest.fn(),
|
||||
getJob: jest.fn(),
|
||||
emitDone: jest.fn(),
|
||||
emitChunk: jest.fn(),
|
||||
completeJob: jest.fn(),
|
||||
updateMetadata: jest.fn(),
|
||||
setContentParts: jest.fn(),
|
||||
subscribe: jest.fn(),
|
||||
};
|
||||
|
||||
const mockSaveMessage = jest.fn();
|
||||
const mockDecrementPendingRequest = jest.fn();
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: mockLogger,
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
isEnabled: jest.fn().mockReturnValue(false),
|
||||
GenerationJobManager: mockGenerationJobManager,
|
||||
getReferencedQuotes: jest.fn((quotes) => {
|
||||
if (!Array.isArray(quotes)) {
|
||||
return null;
|
||||
}
|
||||
const normalized = quotes
|
||||
.filter((quote) => typeof quote === 'string' && quote.trim().length > 0)
|
||||
.map((quote) => quote.trim());
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}),
|
||||
checkAndIncrementPendingRequest: jest.fn().mockResolvedValue({ allowed: true }),
|
||||
decrementPendingRequest: (...args) => mockDecrementPendingRequest(...args),
|
||||
getViolationInfo: jest.fn(),
|
||||
sanitizeMessageForTransmit: jest.fn((msg) => msg),
|
||||
sanitizeFileForTransmit: jest.fn((file) => file),
|
||||
Constants: { NO_PARENT: '00000000-0000-0000-0000-000000000000' },
|
||||
}));
|
||||
|
||||
jest.mock('~/models', () => ({
|
||||
saveMessage: (...args) => mockSaveMessage(...args),
|
||||
}));
|
||||
|
||||
describe('Job Replacement Detection', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Job Creation Timestamp Tracking', () => {
|
||||
it('should capture createdAt when job is created', async () => {
|
||||
const streamId = 'test-stream-123';
|
||||
const createdAt = Date.now();
|
||||
|
||||
mockGenerationJobManager.createJob.mockResolvedValue({
|
||||
createdAt,
|
||||
readyPromise: Promise.resolve(),
|
||||
abortController: new AbortController(),
|
||||
emitter: { on: jest.fn() },
|
||||
});
|
||||
|
||||
const job = await mockGenerationJobManager.createJob(streamId, 'user-123', streamId);
|
||||
|
||||
expect(job.createdAt).toBe(createdAt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Job Replacement Detection Logic', () => {
|
||||
/**
|
||||
* Simulates the job replacement detection logic from request.js
|
||||
* This is extracted for unit testing since the full controller is complex
|
||||
*/
|
||||
const detectJobReplacement = async (streamId, originalCreatedAt) => {
|
||||
const currentJob = await mockGenerationJobManager.getJob(streamId);
|
||||
return !currentJob || currentJob.createdAt !== originalCreatedAt;
|
||||
};
|
||||
|
||||
it('should detect when job was replaced (different createdAt)', async () => {
|
||||
const streamId = 'test-stream-123';
|
||||
const originalCreatedAt = 1000;
|
||||
const newCreatedAt = 2000;
|
||||
|
||||
mockGenerationJobManager.getJob.mockResolvedValue({
|
||||
createdAt: newCreatedAt,
|
||||
});
|
||||
|
||||
const wasReplaced = await detectJobReplacement(streamId, originalCreatedAt);
|
||||
|
||||
expect(wasReplaced).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect when job was deleted', async () => {
|
||||
const streamId = 'test-stream-123';
|
||||
const originalCreatedAt = 1000;
|
||||
|
||||
mockGenerationJobManager.getJob.mockResolvedValue(null);
|
||||
|
||||
const wasReplaced = await detectJobReplacement(streamId, originalCreatedAt);
|
||||
|
||||
expect(wasReplaced).toBe(true);
|
||||
});
|
||||
|
||||
it('should not detect replacement when same job (same createdAt)', async () => {
|
||||
const streamId = 'test-stream-123';
|
||||
const originalCreatedAt = 1000;
|
||||
|
||||
mockGenerationJobManager.getJob.mockResolvedValue({
|
||||
createdAt: originalCreatedAt,
|
||||
});
|
||||
|
||||
const wasReplaced = await detectJobReplacement(streamId, originalCreatedAt);
|
||||
|
||||
expect(wasReplaced).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Event Emission Behavior', () => {
|
||||
/**
|
||||
* Simulates the final event emission logic from request.js
|
||||
*/
|
||||
const emitFinalEventIfNotReplaced = async ({
|
||||
streamId,
|
||||
originalCreatedAt,
|
||||
finalEvent,
|
||||
userId,
|
||||
}) => {
|
||||
const currentJob = await mockGenerationJobManager.getJob(streamId);
|
||||
const jobWasReplaced = !currentJob || currentJob.createdAt !== originalCreatedAt;
|
||||
|
||||
if (jobWasReplaced) {
|
||||
mockLogger.debug('Skipping FINAL emit - job was replaced', {
|
||||
streamId,
|
||||
originalCreatedAt,
|
||||
currentCreatedAt: currentJob?.createdAt,
|
||||
});
|
||||
await mockDecrementPendingRequest(userId);
|
||||
return false;
|
||||
}
|
||||
|
||||
mockGenerationJobManager.emitDone(streamId, finalEvent);
|
||||
mockGenerationJobManager.completeJob(streamId);
|
||||
await mockDecrementPendingRequest(userId);
|
||||
return true;
|
||||
};
|
||||
|
||||
it('should skip emitting when job was replaced', async () => {
|
||||
const streamId = 'test-stream-123';
|
||||
const originalCreatedAt = 1000;
|
||||
const newCreatedAt = 2000;
|
||||
const userId = 'user-123';
|
||||
|
||||
mockGenerationJobManager.getJob.mockResolvedValue({
|
||||
createdAt: newCreatedAt,
|
||||
});
|
||||
|
||||
const emitted = await emitFinalEventIfNotReplaced({
|
||||
streamId,
|
||||
originalCreatedAt,
|
||||
finalEvent: { final: true },
|
||||
userId,
|
||||
});
|
||||
|
||||
expect(emitted).toBe(false);
|
||||
expect(mockGenerationJobManager.emitDone).not.toHaveBeenCalled();
|
||||
expect(mockGenerationJobManager.completeJob).not.toHaveBeenCalled();
|
||||
expect(mockDecrementPendingRequest).toHaveBeenCalledWith(userId);
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(
|
||||
'Skipping FINAL emit - job was replaced',
|
||||
expect.objectContaining({
|
||||
streamId,
|
||||
originalCreatedAt,
|
||||
currentCreatedAt: newCreatedAt,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should emit when job was not replaced', async () => {
|
||||
const streamId = 'test-stream-123';
|
||||
const originalCreatedAt = 1000;
|
||||
const userId = 'user-123';
|
||||
const finalEvent = { final: true, conversation: { conversationId: streamId } };
|
||||
|
||||
mockGenerationJobManager.getJob.mockResolvedValue({
|
||||
createdAt: originalCreatedAt,
|
||||
});
|
||||
|
||||
const emitted = await emitFinalEventIfNotReplaced({
|
||||
streamId,
|
||||
originalCreatedAt,
|
||||
finalEvent,
|
||||
userId,
|
||||
});
|
||||
|
||||
expect(emitted).toBe(true);
|
||||
expect(mockGenerationJobManager.emitDone).toHaveBeenCalledWith(streamId, finalEvent);
|
||||
expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(streamId);
|
||||
expect(mockDecrementPendingRequest).toHaveBeenCalledWith(userId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Response Message Saving Order', () => {
|
||||
/**
|
||||
* Tests that response messages are saved BEFORE final events are emitted
|
||||
* This prevents race conditions where clients send follow-up messages
|
||||
* before the response is in the database
|
||||
*/
|
||||
it('should save message before emitting final event', async () => {
|
||||
const callOrder = [];
|
||||
|
||||
mockSaveMessage.mockImplementation(async () => {
|
||||
callOrder.push('saveMessage');
|
||||
});
|
||||
|
||||
mockGenerationJobManager.emitDone.mockImplementation(() => {
|
||||
callOrder.push('emitDone');
|
||||
});
|
||||
|
||||
mockGenerationJobManager.getJob.mockResolvedValue({
|
||||
createdAt: 1000,
|
||||
});
|
||||
|
||||
// Simulate the order of operations from request.js
|
||||
const streamId = 'test-stream-123';
|
||||
const originalCreatedAt = 1000;
|
||||
const response = { messageId: 'response-123' };
|
||||
const userId = 'user-123';
|
||||
|
||||
// Step 1: Save message
|
||||
await mockSaveMessage({}, { ...response, user: userId }, { context: 'test' });
|
||||
|
||||
// Step 2: Check for replacement
|
||||
const currentJob = await mockGenerationJobManager.getJob(streamId);
|
||||
const jobWasReplaced = !currentJob || currentJob.createdAt !== originalCreatedAt;
|
||||
|
||||
// Step 3: Emit if not replaced
|
||||
if (!jobWasReplaced) {
|
||||
mockGenerationJobManager.emitDone(streamId, { final: true });
|
||||
}
|
||||
|
||||
expect(callOrder).toEqual(['saveMessage', 'emitDone']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Aborted Request Handling', () => {
|
||||
it('should use unfinished: true instead of error: true for aborted requests', () => {
|
||||
const response = { messageId: 'response-123', content: [] };
|
||||
|
||||
// The new format for aborted responses
|
||||
const abortedResponse = { ...response, unfinished: true };
|
||||
|
||||
expect(abortedResponse.unfinished).toBe(true);
|
||||
expect(abortedResponse.error).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should include unfinished flag in final event for aborted requests', () => {
|
||||
const response = { messageId: 'response-123', content: [] };
|
||||
|
||||
// Old format (deprecated)
|
||||
const _oldFinalEvent = {
|
||||
final: true,
|
||||
responseMessage: { ...response, error: true },
|
||||
error: { message: 'Request was aborted' },
|
||||
};
|
||||
|
||||
// New format (PR #11462)
|
||||
const newFinalEvent = {
|
||||
final: true,
|
||||
responseMessage: { ...response, unfinished: true },
|
||||
};
|
||||
|
||||
expect(newFinalEvent.responseMessage.unfinished).toBe(true);
|
||||
expect(newFinalEvent.error).toBeUndefined();
|
||||
expect(newFinalEvent.responseMessage.error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* HITL terminal-side-effect guards (PR #13942).
|
||||
*
|
||||
* Jobs are keyed by streamId == conversationId, so a NEW request REPLACES the running
|
||||
* one on the same conversation. The replaced generation's tail (its pause attempt, its
|
||||
* checkpoint prune, its resume catch-path terminal writes) must not clobber the live
|
||||
* generation's state. Each guard re-reads the live job and compares createdAt against the
|
||||
* generation's own captured identity before acting. These mirror the predicates in
|
||||
* client.js (handleRunInterrupt / chatCompletion finally) and resume.js.
|
||||
*/
|
||||
describe('HITL Terminal-Side-Effect Guards', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('F22 — pause is skipped when the generation was replaced', () => {
|
||||
// Mirrors client.js handleRunInterrupt pre-check, run BEFORE approvals.pause.
|
||||
const shouldPause = async ({ jobCreatedAt, streamId }) => {
|
||||
if (jobCreatedAt != null) {
|
||||
const liveJob = await mockGenerationJobManager.getJob(streamId);
|
||||
if (!liveJob || liveJob.createdAt !== jobCreatedAt) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
it('does not pause when a newer job replaced this one', async () => {
|
||||
mockGenerationJobManager.getJob.mockResolvedValue({ createdAt: 2000 });
|
||||
expect(await shouldPause({ jobCreatedAt: 1000, streamId: 'c1' })).toBe(false);
|
||||
});
|
||||
|
||||
it('does not pause when the job is already gone', async () => {
|
||||
mockGenerationJobManager.getJob.mockResolvedValue(null);
|
||||
expect(await shouldPause({ jobCreatedAt: 1000, streamId: 'c1' })).toBe(false);
|
||||
});
|
||||
|
||||
it('pauses when this is still the live job', async () => {
|
||||
mockGenerationJobManager.getJob.mockResolvedValue({ createdAt: 1000 });
|
||||
expect(await shouldPause({ jobCreatedAt: 1000, streamId: 'c1' })).toBe(true);
|
||||
});
|
||||
|
||||
it('pauses without a lookup when identity is unknown (legacy job)', async () => {
|
||||
expect(await shouldPause({ jobCreatedAt: null, streamId: 'c1' })).toBe(true);
|
||||
expect(mockGenerationJobManager.getJob).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// (Removed: F21 — the chatCompletion clean-path checkpoint prune + its job-replacement
|
||||
// guard no longer exist. The lazy checkpointer never writes a clean-exit checkpoint, so
|
||||
// there is nothing to prune after a non-paused turn; the pre-run prune (before
|
||||
// processStream) clears any orphaned interrupt checkpoint instead. See
|
||||
// checkpointer.ts LazyMongoSaver and client.js chatCompletion.)
|
||||
|
||||
describe('F24 — resume catch-path terminal writes are skipped when replaced', () => {
|
||||
// Mirrors resume.js: stillLive gate around emitError/completeJob/deleteAgentCheckpoint.
|
||||
const stillLive = async ({ streamId, jobCreatedAt }) => {
|
||||
let live = true;
|
||||
try {
|
||||
const liveJob = await mockGenerationJobManager.getJob(streamId);
|
||||
live = !!liveJob && liveJob.createdAt === jobCreatedAt;
|
||||
} catch {
|
||||
live = true; // read failed — fail open and run the terminal writes
|
||||
}
|
||||
return live;
|
||||
};
|
||||
|
||||
it('runs terminal writes when this is still the live job', async () => {
|
||||
mockGenerationJobManager.getJob.mockResolvedValue({ createdAt: 1000 });
|
||||
expect(await stillLive({ streamId: 'c1', jobCreatedAt: 1000 })).toBe(true);
|
||||
});
|
||||
|
||||
it('skips terminal writes when a newer job replaced this one', async () => {
|
||||
mockGenerationJobManager.getJob.mockResolvedValue({ createdAt: 2000 });
|
||||
expect(await stillLive({ streamId: 'c1', jobCreatedAt: 1000 })).toBe(false);
|
||||
});
|
||||
|
||||
it('fails open (runs terminal writes) when the liveness read throws', async () => {
|
||||
mockGenerationJobManager.getJob.mockRejectedValue(new Error('store down'));
|
||||
expect(await stillLive({ streamId: 'c1', jobCreatedAt: 1000 })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('F23 — resumed turn sources files from the job, not the racy DB row', () => {
|
||||
// Mirrors resume.js: prefer the body, then job.metadata.userMessage.files, then DB.
|
||||
const resolveFiles = ({ bodyFiles, metaFiles, dbFiles }) => {
|
||||
if (Array.isArray(bodyFiles) && bodyFiles.length > 0) {
|
||||
return bodyFiles;
|
||||
}
|
||||
if (Array.isArray(metaFiles) && metaFiles.length > 0) {
|
||||
return metaFiles;
|
||||
}
|
||||
return Array.isArray(dbFiles) && dbFiles.length > 0 ? dbFiles : undefined;
|
||||
};
|
||||
|
||||
it('prefers job-metadata files over the DB row (no DB-save race)', () => {
|
||||
expect(
|
||||
resolveFiles({
|
||||
bodyFiles: [],
|
||||
metaFiles: [{ file_id: 'meta' }],
|
||||
dbFiles: [{ file_id: 'db' }],
|
||||
}),
|
||||
).toEqual([{ file_id: 'meta' }]);
|
||||
});
|
||||
|
||||
it('falls back to the DB row when the job has no persisted files (older job)', () => {
|
||||
expect(
|
||||
resolveFiles({ bodyFiles: [], metaFiles: undefined, dbFiles: [{ file_id: 'db' }] }),
|
||||
).toEqual([{ file_id: 'db' }]);
|
||||
});
|
||||
|
||||
it('keeps files already present on the resume body', () => {
|
||||
expect(
|
||||
resolveFiles({
|
||||
bodyFiles: [{ file_id: 'body' }],
|
||||
metaFiles: [{ file_id: 'meta' }],
|
||||
dbFiles: [],
|
||||
}),
|
||||
).toEqual([{ file_id: 'body' }]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Round-18 follow-ups to the guards above (Codex review 4594099963).
|
||||
*/
|
||||
describe('HITL Resume Fidelity Guards (round 18)', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('G1 — resume re-checks ownership AGAIN right before terminal writes', () => {
|
||||
// The start-of-finalize guard can go stale across saveMessage + title generation,
|
||||
// so resume.js re-reads the live job immediately before emitDone/completeJob/prune.
|
||||
// Same predicate as the catch-path (F24), applied at the success path's second point.
|
||||
const stillLiveBeforeFinalize = async ({ streamId, jobCreatedAt }) => {
|
||||
const liveJob = await mockGenerationJobManager.getJob(streamId);
|
||||
return !!liveJob && liveJob.createdAt === jobCreatedAt;
|
||||
};
|
||||
|
||||
it('runs terminal writes when still the live job at the second check', async () => {
|
||||
mockGenerationJobManager.getJob.mockResolvedValue({ createdAt: 1000 });
|
||||
expect(await stillLiveBeforeFinalize({ streamId: 'c1', jobCreatedAt: 1000 })).toBe(true);
|
||||
});
|
||||
|
||||
it('skips terminal writes when replaced DURING finalize (after the first check passed)', async () => {
|
||||
// First check passed earlier with createdAt 1000; a new request replaced it to 2000
|
||||
// while saveMessage + title generation awaited. The second check must catch it.
|
||||
mockGenerationJobManager.getJob.mockResolvedValue({ createdAt: 2000 });
|
||||
expect(await stillLiveBeforeFinalize({ streamId: 'c1', jobCreatedAt: 1000 })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('G2 — uploaded files are seeded into the AWAITED preliminary user message', () => {
|
||||
// Mirrors getPreliminaryUserMessage: files from the request are persisted on the
|
||||
// preliminary (awaited, pre-run) metadata so they land before any interrupt emits.
|
||||
const buildPreliminaryUserMessage = ({ messageId, files }) => {
|
||||
if (typeof messageId !== 'string' || messageId.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
messageId,
|
||||
...(Array.isArray(files) && files.length > 0 && { files }),
|
||||
};
|
||||
};
|
||||
|
||||
it('includes files when the request carries them', () => {
|
||||
const msg = buildPreliminaryUserMessage({ messageId: 'm1', files: [{ file_id: 'a' }] });
|
||||
expect(msg.files).toEqual([{ file_id: 'a' }]);
|
||||
});
|
||||
|
||||
it('omits files when none were uploaded (no empty array)', () => {
|
||||
const msg = buildPreliminaryUserMessage({ messageId: 'm1', files: [] });
|
||||
expect(msg).not.toHaveProperty('files');
|
||||
});
|
||||
});
|
||||
|
||||
describe('G3 — resume replays pre-pause discovered deferred tools', () => {
|
||||
// Mirrors createRun's merge: discovered set is union(message-extracted, replayed),
|
||||
// gated entirely on the agent actually having deferred tools.
|
||||
const resolveDiscovered = ({ hasAnyDeferredTools, messageExtracted, replayed }) => {
|
||||
const set = new Set();
|
||||
if (hasAnyDeferredTools) {
|
||||
for (const n of messageExtracted ?? []) {
|
||||
set.add(n);
|
||||
}
|
||||
for (const n of replayed ?? []) {
|
||||
set.add(n);
|
||||
}
|
||||
}
|
||||
return set;
|
||||
};
|
||||
|
||||
it('replays captured names on resume (messages empty) so the paused tool is present', () => {
|
||||
const set = resolveDiscovered({
|
||||
hasAnyDeferredTools: true,
|
||||
messageExtracted: [],
|
||||
replayed: ['deep_tool'],
|
||||
});
|
||||
expect(set.has('deep_tool')).toBe(true);
|
||||
});
|
||||
|
||||
it('unions replayed names with message-extracted names', () => {
|
||||
const set = resolveDiscovered({
|
||||
hasAnyDeferredTools: true,
|
||||
messageExtracted: ['from_history'],
|
||||
replayed: ['deep_tool'],
|
||||
});
|
||||
expect([...set].sort()).toEqual(['deep_tool', 'from_history']);
|
||||
});
|
||||
|
||||
it('is inert when the agent has no deferred tools', () => {
|
||||
const set = resolveDiscovered({
|
||||
hasAnyDeferredTools: false,
|
||||
messageExtracted: ['x'],
|
||||
replayed: ['deep_tool'],
|
||||
});
|
||||
expect(set.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("H3 — resume replays the paused turn's model parameters (ephemeral agents)", () => {
|
||||
// Mirrors restoreResumeContext: spread persisted model_parameters back onto the body,
|
||||
// excluding `model` (replayed via the fingerprinted RESUME_CONTEXT_KEYS path).
|
||||
const replayModelParameters = (body, resumeContext) => {
|
||||
const params = resumeContext?.model_parameters;
|
||||
if (params && typeof params === 'object') {
|
||||
const { model: _model, ...rest } = params;
|
||||
Object.assign(body, rest);
|
||||
}
|
||||
return body;
|
||||
};
|
||||
|
||||
it('restores non-default params (temperature, max tokens) onto the resume body', () => {
|
||||
const body = { conversationId: 'c1', endpoint: 'agents' };
|
||||
replayModelParameters(body, {
|
||||
model_parameters: { model: 'gpt-4o', temperature: 0.2, max_tokens: 1024 },
|
||||
});
|
||||
expect(body).toMatchObject({ temperature: 0.2, max_tokens: 1024 });
|
||||
});
|
||||
|
||||
it('does NOT overwrite model (kept consistent with the resume fingerprint)', () => {
|
||||
const body = { model: 'pinned-model' };
|
||||
replayModelParameters(body, { model_parameters: { model: 'other-model', temperature: 0.9 } });
|
||||
expect(body.model).toBe('pinned-model');
|
||||
});
|
||||
|
||||
it('overwrites a client-supplied param with the captured authoritative value', () => {
|
||||
const body = { temperature: 1.0 }; // crafted/stale client value
|
||||
replayModelParameters(body, { model_parameters: { temperature: 0.2 } });
|
||||
expect(body.temperature).toBe(0.2);
|
||||
});
|
||||
|
||||
it('is a no-op when nothing was captured', () => {
|
||||
const body = { conversationId: 'c1' };
|
||||
replayModelParameters(body, {});
|
||||
expect(body).toEqual({ conversationId: 'c1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('J2 — pause unfinished-save is skipped once a fast resume took over', () => {
|
||||
// Mirrors request.js: only mark the paused row unfinished while the job is STILL paused
|
||||
// on THIS generation's action. A claim transitions it out of requires_action and a
|
||||
// replacement bumps createdAt — either means a /resume now owns the row, so marking it
|
||||
// unfinished would clobber the resumed turn's completed content. Fail open on read error.
|
||||
const shouldMarkUnfinished = async ({ jobCreatedAt, streamId }) => {
|
||||
let stillPaused = true;
|
||||
try {
|
||||
const liveJob = await mockGenerationJobManager.getJob(streamId);
|
||||
stillPaused =
|
||||
!!liveJob &&
|
||||
liveJob.status === 'requires_action' &&
|
||||
(jobCreatedAt == null || liveJob.createdAt === jobCreatedAt);
|
||||
} catch {
|
||||
stillPaused = true;
|
||||
}
|
||||
return stillPaused;
|
||||
};
|
||||
|
||||
it('marks unfinished while still paused on this generation', async () => {
|
||||
mockGenerationJobManager.getJob.mockResolvedValue({
|
||||
status: 'requires_action',
|
||||
createdAt: 1000,
|
||||
});
|
||||
expect(await shouldMarkUnfinished({ jobCreatedAt: 1000, streamId: 'c1' })).toBe(true);
|
||||
});
|
||||
|
||||
it('skips the unfinished-save once a fast resume claimed it (no longer requires_action)', async () => {
|
||||
mockGenerationJobManager.getJob.mockResolvedValue({ status: 'running', createdAt: 1000 });
|
||||
expect(await shouldMarkUnfinished({ jobCreatedAt: 1000, streamId: 'c1' })).toBe(false);
|
||||
});
|
||||
|
||||
it('skips the unfinished-save when a newer request replaced the job', async () => {
|
||||
mockGenerationJobManager.getJob.mockResolvedValue({
|
||||
status: 'requires_action',
|
||||
createdAt: 2000,
|
||||
});
|
||||
expect(await shouldMarkUnfinished({ jobCreatedAt: 1000, streamId: 'c1' })).toBe(false);
|
||||
});
|
||||
|
||||
it('fails open (marks unfinished) when the liveness read throws', async () => {
|
||||
mockGenerationJobManager.getJob.mockRejectedValue(new Error('store down'));
|
||||
expect(await shouldMarkUnfinished({ jobCreatedAt: 1000, streamId: 'c1' })).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { error: jest.fn(), debug: jest.fn() },
|
||||
}));
|
||||
jest.mock('@librechat/api', () => ({
|
||||
sendEvent: jest.fn(),
|
||||
emitEvent: jest.fn(),
|
||||
createToolExecuteHandler: jest.fn(),
|
||||
markSummarizationUsage: (usage) => usage,
|
||||
}));
|
||||
jest.mock('~/server/services/Files/Citations', () => ({
|
||||
processFileCitations: jest.fn(),
|
||||
}));
|
||||
jest.mock('~/server/services/Files/Code/process', () => ({
|
||||
processCodeOutput: jest.fn(),
|
||||
runPreviewFinalize: jest.fn(),
|
||||
}));
|
||||
jest.mock('~/server/services/Files/process', () => ({
|
||||
saveBase64Image: jest.fn(),
|
||||
}));
|
||||
|
||||
const { ModelEndHandler } = require('../callbacks');
|
||||
|
||||
const buildGraph = () => ({
|
||||
getAgentContext: () => ({
|
||||
provider: 'vertexai',
|
||||
clientOptions: { model: 'gemini-3.1-flash-lite-preview' },
|
||||
}),
|
||||
});
|
||||
|
||||
describe('ModelEndHandler — Vertex thoughtSignature capture (issue #13006 follow-up)', () => {
|
||||
it('maps non-empty signatures onto tool_call_ids in order', async () => {
|
||||
const collectedUsage = [];
|
||||
const collectedThoughtSignatures = {};
|
||||
const handler = new ModelEndHandler(collectedUsage, collectedThoughtSignatures);
|
||||
|
||||
await handler.handle(
|
||||
'on_chat_model_end',
|
||||
{
|
||||
output: {
|
||||
usage_metadata: { input_tokens: 10, output_tokens: 5, total_tokens: 15 },
|
||||
tool_calls: [
|
||||
{ id: 'tc_a', name: 'a', args: {} },
|
||||
{ id: 'tc_b', name: 'b', args: {} },
|
||||
],
|
||||
additional_kwargs: { signatures: ['SIG_A', '', 'SIG_B'] },
|
||||
},
|
||||
},
|
||||
{ ls_model_name: 'gemini-3.1-flash-lite-preview', user_id: 'u1' },
|
||||
buildGraph(),
|
||||
);
|
||||
|
||||
expect(collectedThoughtSignatures).toEqual({ tc_a: 'SIG_A', tc_b: 'SIG_B' });
|
||||
expect(collectedUsage).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('accumulates per-id across multiple model_end events (multi-step tool turn)', async () => {
|
||||
const collectedUsage = [];
|
||||
const collectedThoughtSignatures = {};
|
||||
const handler = new ModelEndHandler(collectedUsage, collectedThoughtSignatures);
|
||||
|
||||
await handler.handle(
|
||||
'on_chat_model_end',
|
||||
{
|
||||
output: {
|
||||
usage_metadata: { input_tokens: 5, output_tokens: 5, total_tokens: 10 },
|
||||
tool_calls: [{ id: 'tc_step1', name: 'a', args: {} }],
|
||||
additional_kwargs: { signatures: ['SIG_step1'] },
|
||||
},
|
||||
},
|
||||
{ ls_model_name: 'g', user_id: 'u' },
|
||||
buildGraph(),
|
||||
);
|
||||
await handler.handle(
|
||||
'on_chat_model_end',
|
||||
{
|
||||
output: {
|
||||
usage_metadata: { input_tokens: 5, output_tokens: 5, total_tokens: 10 },
|
||||
tool_calls: [{ id: 'tc_step2', name: 'b', args: {} }],
|
||||
additional_kwargs: { signatures: ['SIG_step2'] },
|
||||
},
|
||||
},
|
||||
{ ls_model_name: 'g', user_id: 'u' },
|
||||
buildGraph(),
|
||||
);
|
||||
|
||||
expect(collectedThoughtSignatures).toEqual({
|
||||
tc_step1: 'SIG_step1',
|
||||
tc_step2: 'SIG_step2',
|
||||
});
|
||||
});
|
||||
|
||||
it('is a no-op for signatures when collectedThoughtSignatures is null', async () => {
|
||||
const collectedUsage = [];
|
||||
const handler = new ModelEndHandler(collectedUsage, null);
|
||||
|
||||
await handler.handle(
|
||||
'on_chat_model_end',
|
||||
{
|
||||
output: {
|
||||
usage_metadata: { input_tokens: 5, output_tokens: 5, total_tokens: 10 },
|
||||
tool_calls: [{ id: 'tc1', name: 'a', args: {} }],
|
||||
additional_kwargs: { signatures: ['SIG'] },
|
||||
},
|
||||
},
|
||||
{ ls_model_name: 'g', user_id: 'u' },
|
||||
buildGraph(),
|
||||
);
|
||||
|
||||
expect(collectedUsage).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('does not store anything when signatures field is missing (non-Vertex providers)', async () => {
|
||||
const collectedUsage = [];
|
||||
const collectedThoughtSignatures = {};
|
||||
const handler = new ModelEndHandler(collectedUsage, collectedThoughtSignatures);
|
||||
|
||||
await handler.handle(
|
||||
'on_chat_model_end',
|
||||
{
|
||||
output: {
|
||||
usage_metadata: { input_tokens: 5, output_tokens: 5, total_tokens: 10 },
|
||||
tool_calls: [{ id: 'tc1', name: 'a', args: {} }],
|
||||
additional_kwargs: {},
|
||||
},
|
||||
},
|
||||
{ ls_model_name: 'gpt-4', user_id: 'u' },
|
||||
buildGraph(),
|
||||
);
|
||||
|
||||
expect(collectedThoughtSignatures).toEqual({});
|
||||
});
|
||||
|
||||
it('does not store anything when tool_calls is missing', async () => {
|
||||
const collectedUsage = [];
|
||||
const collectedThoughtSignatures = {};
|
||||
const handler = new ModelEndHandler(collectedUsage, collectedThoughtSignatures);
|
||||
|
||||
await handler.handle(
|
||||
'on_chat_model_end',
|
||||
{
|
||||
output: {
|
||||
usage_metadata: { input_tokens: 5, output_tokens: 5, total_tokens: 10 },
|
||||
additional_kwargs: { signatures: ['SIG_orphan'] },
|
||||
},
|
||||
},
|
||||
{ ls_model_name: 'g', user_id: 'u' },
|
||||
buildGraph(),
|
||||
);
|
||||
|
||||
expect(collectedThoughtSignatures).toEqual({});
|
||||
});
|
||||
|
||||
it('tags the producing agent on collected + emitted usage for per-endpoint pricing', async () => {
|
||||
const collectedUsage = [];
|
||||
const emitUsage = jest.fn();
|
||||
const handler = new ModelEndHandler(collectedUsage, null, emitUsage);
|
||||
const graph = {
|
||||
getAgentContext: () => ({
|
||||
provider: 'openai',
|
||||
agentId: 'agent_sub',
|
||||
clientOptions: { model: 'gpt-4' },
|
||||
}),
|
||||
};
|
||||
|
||||
await handler.handle(
|
||||
'on_chat_model_end',
|
||||
{ output: { usage_metadata: { input_tokens: 10, output_tokens: 5, total_tokens: 15 } } },
|
||||
{ ls_model_name: 'gpt-4', run_id: 'r1', user_id: 'u1' },
|
||||
graph,
|
||||
);
|
||||
|
||||
expect(collectedUsage[0].agentId).toBe('agent_sub');
|
||||
expect(emitUsage).toHaveBeenCalledWith(expect.objectContaining({ agentId: 'agent_sub' }));
|
||||
});
|
||||
|
||||
it('leaves usage untagged when the graph context has no agentId (single-endpoint)', async () => {
|
||||
const collectedUsage = [];
|
||||
const emitUsage = jest.fn();
|
||||
const handler = new ModelEndHandler(collectedUsage, null, emitUsage);
|
||||
|
||||
await handler.handle(
|
||||
'on_chat_model_end',
|
||||
{ output: { usage_metadata: { input_tokens: 10, output_tokens: 5, total_tokens: 15 } } },
|
||||
{ ls_model_name: 'gemini-3.1-flash-lite-preview', run_id: 'r1', user_id: 'u1' },
|
||||
buildGraph(),
|
||||
);
|
||||
|
||||
expect(collectedUsage[0].agentId).toBeUndefined();
|
||||
expect(emitUsage).toHaveBeenCalledWith(expect.objectContaining({ agentId: undefined }));
|
||||
});
|
||||
|
||||
it('throws when collectedUsage is not an array (existing contract)', () => {
|
||||
expect(() => new ModelEndHandler(null)).toThrow('collectedUsage must be an array');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,511 @@
|
||||
/**
|
||||
* Unit tests for OpenAI-compatible API controller
|
||||
* Tests that recordCollectedUsage is called correctly for token spending
|
||||
*/
|
||||
|
||||
const mockProcessStream = jest.fn().mockResolvedValue(undefined);
|
||||
const mockSpendTokens = jest.fn().mockResolvedValue({});
|
||||
const mockSpendStructuredTokens = jest.fn().mockResolvedValue({});
|
||||
const mockRecordCollectedUsage = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
|
||||
const mockGetBalanceConfig = jest.fn().mockReturnValue({ enabled: true });
|
||||
const mockGetTransactionsConfig = jest.fn().mockReturnValue({ enabled: true });
|
||||
const mockBuildSkillPrimedIdsByName = jest.fn((manualSkillPrimes, alwaysApplySkillPrimes) => {
|
||||
const primed = {};
|
||||
for (const skill of alwaysApplySkillPrimes ?? []) {
|
||||
primed[skill.name] = skill._id.toString();
|
||||
}
|
||||
for (const skill of manualSkillPrimes ?? []) {
|
||||
primed[skill.name] = skill._id.toString();
|
||||
}
|
||||
return Object.keys(primed).length > 0 ? primed : undefined;
|
||||
});
|
||||
const mockEnrichWithSkillConfigurable = jest.fn((result) => result);
|
||||
const mockBuildAgentToolContext = jest.fn(({ agent, config }) => ({
|
||||
agent,
|
||||
toolRegistry: config.toolRegistry,
|
||||
userMCPAuthMap: config.userMCPAuthMap,
|
||||
tool_resources: config.tool_resources,
|
||||
actionsEnabled: config.actionsEnabled,
|
||||
accessibleSkillIds: config.accessibleSkillIds,
|
||||
activeSkillNames: config.activeSkillNames,
|
||||
codeEnvAvailable: config.codeEnvAvailable,
|
||||
skillAuthoringAvailable: config.skillAuthoringAvailable,
|
||||
fileAuthoringToolNames: config.fileAuthoringToolNames,
|
||||
skillPrimedIdsByName:
|
||||
mockBuildSkillPrimedIdsByName(config.manualSkillPrimes, config.alwaysApplySkillPrimes) ?? {},
|
||||
}));
|
||||
const mockEnrichLoadedToolsWithAgentContext = jest.fn(({ result, req, ctx }) =>
|
||||
mockEnrichWithSkillConfigurable({
|
||||
result,
|
||||
context: {
|
||||
req,
|
||||
accessibleSkillIds: ctx.accessibleSkillIds,
|
||||
codeEnvAvailable: ctx.codeEnvAvailable === true,
|
||||
skillPrimedIdsByName: ctx.skillPrimedIdsByName,
|
||||
activeSkillNames: ctx.activeSkillNames,
|
||||
skillAuthoringAvailable: ctx.skillAuthoringAvailable === true,
|
||||
fileAuthoringToolNames: ctx.fileAuthoringToolNames,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const mockCanAuthorSkillFiles = jest.fn(
|
||||
({ scopedEditableSkillIds = [], skillCreateAllowed }) =>
|
||||
scopedEditableSkillIds.length > 0 || skillCreateAllowed === true,
|
||||
);
|
||||
const mockGetSkillToolDeps = jest.fn(() => ({}));
|
||||
|
||||
jest.mock('nanoid', () => ({
|
||||
nanoid: jest.fn(() => 'mock-nanoid-123'),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: {
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/agents', () => ({
|
||||
Callback: { TOOL_ERROR: 'TOOL_ERROR' },
|
||||
ToolEndHandler: jest.fn(),
|
||||
formatAgentMessages: jest.fn().mockReturnValue({
|
||||
messages: [],
|
||||
indexTokenCountMap: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
writeSSE: jest.fn(),
|
||||
createRun: jest.fn().mockResolvedValue({
|
||||
processStream: mockProcessStream,
|
||||
}),
|
||||
createChunk: jest.fn().mockReturnValue({}),
|
||||
buildToolSet: jest.fn().mockReturnValue(new Set()),
|
||||
scopeSkillIds: jest.fn().mockImplementation((ids) => ids),
|
||||
resolveAgentScopedSkillIds: jest
|
||||
.fn()
|
||||
.mockImplementation(({ accessibleSkillIds }) => accessibleSkillIds),
|
||||
loadSkillStates: jest.fn().mockResolvedValue({ skillStates: {}, defaultActiveOnShare: false }),
|
||||
sendFinalChunk: jest.fn(),
|
||||
createSafeUser: jest.fn().mockReturnValue({ id: 'user-123' }),
|
||||
validateRequest: jest
|
||||
.fn()
|
||||
.mockReturnValue({ request: { model: 'agent-123', messages: [], stream: false } }),
|
||||
initializeAgent: jest.fn().mockResolvedValue({
|
||||
id: 'agent-123',
|
||||
model: 'gpt-4',
|
||||
model_parameters: {},
|
||||
toolRegistry: {},
|
||||
edges: [],
|
||||
}),
|
||||
getBalanceConfig: mockGetBalanceConfig,
|
||||
createErrorResponse: jest.fn(),
|
||||
getTransactionsConfig: mockGetTransactionsConfig,
|
||||
recordCollectedUsage: mockRecordCollectedUsage,
|
||||
createSubagentUsageSink: jest.fn().mockReturnValue(jest.fn()),
|
||||
extractManualSkills: jest.fn().mockReturnValue(undefined),
|
||||
injectSkillPrimes: jest.fn().mockReturnValue({
|
||||
initialMessages: [],
|
||||
indexTokenCountMap: {},
|
||||
inserted: 0,
|
||||
insertIdx: -1,
|
||||
alwaysApplyDropped: 0,
|
||||
alwaysApplyDedupedFromManual: 0,
|
||||
}),
|
||||
buildNonStreamingResponse: jest.fn().mockReturnValue({ id: 'resp-123' }),
|
||||
createOpenAIStreamTracker: jest.fn().mockReturnValue({
|
||||
addText: jest.fn(),
|
||||
addReasoning: jest.fn(),
|
||||
toolCalls: new Map(),
|
||||
usage: { promptTokens: 0, completionTokens: 0, reasoningTokens: 0 },
|
||||
}),
|
||||
createOpenAIContentAggregator: jest.fn().mockReturnValue({
|
||||
addText: jest.fn(),
|
||||
addReasoning: jest.fn(),
|
||||
getText: jest.fn().mockReturnValue(''),
|
||||
getReasoning: jest.fn().mockReturnValue(''),
|
||||
toolCalls: new Map(),
|
||||
usage: { promptTokens: 100, completionTokens: 50, reasoningTokens: 0 },
|
||||
}),
|
||||
resolveRecursionLimit: jest.fn().mockReturnValue(50),
|
||||
createToolExecuteHandler: jest.fn().mockReturnValue({ handle: jest.fn() }),
|
||||
isChatCompletionValidationFailure: jest.fn().mockReturnValue(false),
|
||||
findPiiMatchInMessages: jest.fn().mockReturnValue(null),
|
||||
discoverConnectedAgents: jest.fn().mockResolvedValue({
|
||||
agentConfigs: new Map(),
|
||||
edges: [],
|
||||
skippedAgentIds: new Set(),
|
||||
userMCPAuthMap: undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/controllers/ModelController', () => ({
|
||||
getModelsConfig: jest.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/permissions', () => ({
|
||||
filterFilesByAgentAccess: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Endpoints/agents/skillDeps', () => ({
|
||||
getSkillToolDeps: mockGetSkillToolDeps,
|
||||
getSkillDbMethods: jest.fn(() => ({})),
|
||||
canAuthorSkillFiles: mockCanAuthorSkillFiles,
|
||||
withDeploymentSkillIds: jest.fn((ids = []) => ids),
|
||||
enrichWithSkillConfigurable: mockEnrichWithSkillConfigurable,
|
||||
buildSkillPrimedIdsByName: mockBuildSkillPrimedIdsByName,
|
||||
buildAgentToolContext: mockBuildAgentToolContext,
|
||||
enrichLoadedToolsWithAgentContext: mockEnrichLoadedToolsWithAgentContext,
|
||||
}));
|
||||
|
||||
jest.mock('~/cache', () => ({
|
||||
logViolation: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/ToolService', () => ({
|
||||
loadAgentTools: jest.fn().mockResolvedValue([]),
|
||||
loadToolsForExecution: jest.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
const mockGetMultiplier = jest.fn().mockReturnValue(1);
|
||||
const mockGetCacheMultiplier = jest.fn().mockReturnValue(null);
|
||||
|
||||
jest.mock('~/server/controllers/agents/callbacks', () => ({
|
||||
createToolEndCallback: jest.fn().mockReturnValue(jest.fn()),
|
||||
buildSummarizationHandlers: jest.fn().mockReturnValue({}),
|
||||
markSummarizationUsage: jest.fn().mockImplementation((usage) => usage),
|
||||
agentLogHandlerObj: { handle: jest.fn() },
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/PermissionService', () => ({
|
||||
findAccessibleResources: jest.fn().mockResolvedValue([]),
|
||||
checkPermission: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/strategies', () => ({
|
||||
getStrategyFunctions: jest.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/Code/crud', () => ({
|
||||
batchUploadCodeEnvFiles: jest.fn().mockResolvedValue({ session_id: '', files: [] }),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/Code/process', () => ({
|
||||
getSessionInfo: jest.fn().mockResolvedValue(null),
|
||||
checkIfActive: jest.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
const mockUpdateBalance = jest.fn().mockResolvedValue({});
|
||||
const mockBulkInsertTransactions = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
jest.mock('~/models', () => ({
|
||||
getAgent: jest.fn().mockResolvedValue({ id: 'agent-123', name: 'Test Agent' }),
|
||||
getFiles: jest.fn(),
|
||||
getUserKey: jest.fn(),
|
||||
getMessages: jest.fn(),
|
||||
updateFilesUsage: jest.fn(),
|
||||
getUserKeyValues: jest.fn(),
|
||||
getUserCodeFiles: jest.fn(),
|
||||
getToolFilesByIds: jest.fn(),
|
||||
getCodeGeneratedFiles: jest.fn(),
|
||||
updateBalance: mockUpdateBalance,
|
||||
bulkInsertTransactions: mockBulkInsertTransactions,
|
||||
spendTokens: mockSpendTokens,
|
||||
spendStructuredTokens: mockSpendStructuredTokens,
|
||||
getMultiplier: mockGetMultiplier,
|
||||
getCacheMultiplier: mockGetCacheMultiplier,
|
||||
getConvoFiles: jest.fn().mockResolvedValue([]),
|
||||
getConvo: jest.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
describe('OpenAIChatCompletionController', () => {
|
||||
let OpenAIChatCompletionController;
|
||||
let req, res;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
const controller = require('../openai');
|
||||
OpenAIChatCompletionController = controller.OpenAIChatCompletionController;
|
||||
|
||||
req = {
|
||||
body: {
|
||||
model: 'agent-123',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
stream: false,
|
||||
},
|
||||
user: { id: 'user-123' },
|
||||
config: {
|
||||
endpoints: {
|
||||
agents: { allowedProviders: ['openAI'] },
|
||||
},
|
||||
},
|
||||
on: jest.fn(),
|
||||
};
|
||||
|
||||
res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn(),
|
||||
setHeader: jest.fn(),
|
||||
flushHeaders: jest.fn(),
|
||||
end: jest.fn(),
|
||||
write: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('conversation ownership validation', () => {
|
||||
it('should skip ownership check when conversation_id is not provided', async () => {
|
||||
const { getConvo } = require('~/models');
|
||||
await OpenAIChatCompletionController(req, res);
|
||||
expect(getConvo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 400 when conversation_id is not a string', async () => {
|
||||
const { validateRequest } = require('@librechat/api');
|
||||
validateRequest.mockReturnValueOnce({
|
||||
request: { model: 'agent-123', messages: [], stream: false, conversation_id: { $gt: '' } },
|
||||
});
|
||||
|
||||
await OpenAIChatCompletionController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
});
|
||||
|
||||
it('should return 404 when conversation is not owned by user', async () => {
|
||||
const { validateRequest } = require('@librechat/api');
|
||||
const { getConvo } = require('~/models');
|
||||
validateRequest.mockReturnValueOnce({
|
||||
request: {
|
||||
model: 'agent-123',
|
||||
messages: [],
|
||||
stream: false,
|
||||
conversation_id: 'convo-abc',
|
||||
},
|
||||
});
|
||||
getConvo.mockResolvedValueOnce(null);
|
||||
|
||||
await OpenAIChatCompletionController(req, res);
|
||||
expect(getConvo).toHaveBeenCalledWith('user-123', 'convo-abc');
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
});
|
||||
|
||||
it('should proceed when conversation is owned by user', async () => {
|
||||
const { validateRequest } = require('@librechat/api');
|
||||
const { getConvo } = require('~/models');
|
||||
validateRequest.mockReturnValueOnce({
|
||||
request: {
|
||||
model: 'agent-123',
|
||||
messages: [],
|
||||
stream: false,
|
||||
conversation_id: 'convo-abc',
|
||||
},
|
||||
});
|
||||
getConvo.mockResolvedValueOnce({ conversationId: 'convo-abc', user: 'user-123' });
|
||||
|
||||
await OpenAIChatCompletionController(req, res);
|
||||
expect(getConvo).toHaveBeenCalledWith('user-123', 'convo-abc');
|
||||
expect(res.status).not.toHaveBeenCalledWith(404);
|
||||
});
|
||||
|
||||
it('should return 500 when getConvo throws a DB error', async () => {
|
||||
const { validateRequest } = require('@librechat/api');
|
||||
const { getConvo } = require('~/models');
|
||||
validateRequest.mockReturnValueOnce({
|
||||
request: {
|
||||
model: 'agent-123',
|
||||
messages: [],
|
||||
stream: false,
|
||||
conversation_id: 'convo-abc',
|
||||
},
|
||||
});
|
||||
getConvo.mockRejectedValueOnce(new Error('DB connection failed'));
|
||||
|
||||
await OpenAIChatCompletionController(req, res);
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
});
|
||||
});
|
||||
|
||||
describe('token usage recording', () => {
|
||||
it('should call recordCollectedUsage after successful non-streaming completion', async () => {
|
||||
await OpenAIChatCompletionController(req, res);
|
||||
|
||||
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
|
||||
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
|
||||
{
|
||||
spendTokens: mockSpendTokens,
|
||||
spendStructuredTokens: mockSpendStructuredTokens,
|
||||
pricing: { getMultiplier: mockGetMultiplier, getCacheMultiplier: mockGetCacheMultiplier },
|
||||
bulkWriteOps: {
|
||||
insertMany: mockBulkInsertTransactions,
|
||||
updateBalance: mockUpdateBalance,
|
||||
},
|
||||
},
|
||||
expect.objectContaining({
|
||||
user: 'user-123',
|
||||
conversationId: expect.any(String),
|
||||
collectedUsage: expect.any(Array),
|
||||
context: 'message',
|
||||
balance: { enabled: true },
|
||||
transactions: { enabled: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass balance and transactions config to recordCollectedUsage', async () => {
|
||||
mockGetBalanceConfig.mockReturnValue({ enabled: true, startBalance: 1000 });
|
||||
mockGetTransactionsConfig.mockReturnValue({ enabled: true, rateLimit: 100 });
|
||||
|
||||
await OpenAIChatCompletionController(req, res);
|
||||
|
||||
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
balance: { enabled: true, startBalance: 1000 },
|
||||
transactions: { enabled: true, rateLimit: 100 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass spendTokens, spendStructuredTokens, pricing, and bulkWriteOps as dependencies', async () => {
|
||||
await OpenAIChatCompletionController(req, res);
|
||||
|
||||
const [deps] = mockRecordCollectedUsage.mock.calls[0];
|
||||
expect(deps).toHaveProperty('spendTokens', mockSpendTokens);
|
||||
expect(deps).toHaveProperty('spendStructuredTokens', mockSpendStructuredTokens);
|
||||
expect(deps).toHaveProperty('pricing');
|
||||
expect(deps.pricing).toHaveProperty('getMultiplier', mockGetMultiplier);
|
||||
expect(deps.pricing).toHaveProperty('getCacheMultiplier', mockGetCacheMultiplier);
|
||||
expect(deps).toHaveProperty('bulkWriteOps');
|
||||
expect(deps.bulkWriteOps).toHaveProperty('insertMany', mockBulkInsertTransactions);
|
||||
expect(deps.bulkWriteOps).toHaveProperty('updateBalance', mockUpdateBalance);
|
||||
});
|
||||
|
||||
it('should include model from primaryConfig in recordCollectedUsage params', async () => {
|
||||
await OpenAIChatCompletionController(req, res);
|
||||
|
||||
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
model: 'gpt-4',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('recursionLimit resolution', () => {
|
||||
it('should pass resolveRecursionLimit result to processStream config', async () => {
|
||||
const { resolveRecursionLimit } = require('@librechat/api');
|
||||
resolveRecursionLimit.mockReturnValueOnce(75);
|
||||
|
||||
await OpenAIChatCompletionController(req, res);
|
||||
|
||||
expect(mockProcessStream).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ recursionLimit: 75 }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should call resolveRecursionLimit with agentsEConfig and agent', async () => {
|
||||
const { resolveRecursionLimit } = require('@librechat/api');
|
||||
const { getAgent } = require('~/models');
|
||||
const mockAgent = { id: 'agent-123', name: 'Test', recursion_limit: 200 };
|
||||
getAgent.mockResolvedValueOnce(mockAgent);
|
||||
|
||||
req.config = {
|
||||
endpoints: {
|
||||
agents: { recursionLimit: 100, maxRecursionLimit: 150, allowedProviders: [] },
|
||||
},
|
||||
};
|
||||
|
||||
await OpenAIChatCompletionController(req, res);
|
||||
|
||||
expect(resolveRecursionLimit).toHaveBeenCalledWith(req.config.endpoints.agents, mockAgent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sub-agent skill priming', () => {
|
||||
it('passes the sub-agent primed skill IDs into tool execution', async () => {
|
||||
const {
|
||||
initializeAgent,
|
||||
discoverConnectedAgents,
|
||||
createToolExecuteHandler,
|
||||
} = require('@librechat/api');
|
||||
const { loadToolsForExecution } = require('~/server/services/ToolService');
|
||||
const subAgent = { id: 'agent-sub', name: 'Sub Agent' };
|
||||
const subConfig = {
|
||||
id: 'agent-sub',
|
||||
model: 'gpt-4',
|
||||
model_parameters: {},
|
||||
toolRegistry: new Map(),
|
||||
userMCPAuthMap: { sub: { token: 'sub-token' } },
|
||||
tool_resources: { code_interpreter: { file_ids: ['sub-file'] } },
|
||||
actionsEnabled: true,
|
||||
accessibleSkillIds: ['sub-skill-id'],
|
||||
activeSkillNames: ['sub-hidden-skill'],
|
||||
codeEnvAvailable: true,
|
||||
skillAuthoringAvailable: true,
|
||||
fileAuthoringToolNames: ['create_file', 'edit_file'],
|
||||
manualSkillPrimes: [{ name: 'sub-hidden-skill', _id: { toString: () => 'sub-manual-id' } }],
|
||||
alwaysApplySkillPrimes: [
|
||||
{ name: 'sub-always-skill', _id: { toString: () => 'sub-always-id' } },
|
||||
],
|
||||
};
|
||||
|
||||
initializeAgent.mockResolvedValueOnce({
|
||||
id: 'agent-123',
|
||||
model: 'gpt-4',
|
||||
model_parameters: {},
|
||||
toolRegistry: new Map(),
|
||||
edges: [{ source: 'agent-123', target: 'agent-sub' }],
|
||||
accessibleSkillIds: ['primary-skill-id'],
|
||||
activeSkillNames: ['primary-skill'],
|
||||
codeEnvAvailable: false,
|
||||
skillAuthoringAvailable: false,
|
||||
fileAuthoringToolNames: [],
|
||||
manualSkillPrimes: [{ name: 'primary-skill', _id: { toString: () => 'primary-skill-id' } }],
|
||||
});
|
||||
discoverConnectedAgents.mockImplementationOnce(async (_params, deps) => {
|
||||
deps.onAgentInitialized('agent-sub', subAgent, subConfig);
|
||||
return {
|
||||
agentConfigs: new Map([['agent-sub', subConfig]]),
|
||||
edges: [],
|
||||
skippedAgentIds: new Set(),
|
||||
userMCPAuthMap: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
await OpenAIChatCompletionController(req, res);
|
||||
|
||||
const toolExecuteOptions = createToolExecuteHandler.mock.calls.at(-1)[0];
|
||||
await toolExecuteOptions.loadTools(['read_file'], 'agent-sub');
|
||||
|
||||
expect(loadToolsForExecution).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
agent: subAgent,
|
||||
toolRegistry: subConfig.toolRegistry,
|
||||
userMCPAuthMap: subConfig.userMCPAuthMap,
|
||||
tool_resources: subConfig.tool_resources,
|
||||
actionsEnabled: true,
|
||||
}),
|
||||
);
|
||||
expect(mockEnrichWithSkillConfigurable).toHaveBeenLastCalledWith({
|
||||
result: expect.anything(),
|
||||
context: {
|
||||
req,
|
||||
accessibleSkillIds: ['sub-skill-id'],
|
||||
codeEnvAvailable: true,
|
||||
skillPrimedIdsByName: {
|
||||
'sub-always-skill': 'sub-always-id',
|
||||
'sub-hidden-skill': 'sub-manual-id',
|
||||
},
|
||||
activeSkillNames: ['sub-hidden-skill'],
|
||||
skillAuthoringAvailable: true,
|
||||
fileAuthoringToolNames: ['create_file', 'edit_file'],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,621 @@
|
||||
const { EventEmitter } = require('events');
|
||||
|
||||
const mockLogger = {
|
||||
debug: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
};
|
||||
|
||||
const mockGenerationJobManager = {
|
||||
createJob: jest.fn(),
|
||||
emitError: jest.fn(),
|
||||
completeJob: jest.fn(),
|
||||
getResumeState: jest.fn(),
|
||||
updateMetadata: jest.fn(),
|
||||
};
|
||||
|
||||
const mockCheckAndIncrementPendingRequest = jest.fn();
|
||||
const mockDecrementPendingRequest = jest.fn();
|
||||
const mockFilterPersistableAbortContent = jest.fn((content) =>
|
||||
content.filter((part) => part?.type !== 'tool_call'),
|
||||
);
|
||||
const mockGetConvo = jest.fn();
|
||||
const mockGetMessages = jest.fn();
|
||||
const mockSaveMessage = jest.fn();
|
||||
let mockMCPContexts = new WeakMap();
|
||||
|
||||
const mockCreateMCPRequestContext = jest.fn(() => ({
|
||||
connections: new Map(),
|
||||
pending: new Map(),
|
||||
cleanupStarted: false,
|
||||
cleanupOnResponse: false,
|
||||
responseCleanupAttached: false,
|
||||
}));
|
||||
const mockGetMCPRequestContext = jest.fn((req) => {
|
||||
if (!req) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let context = mockMCPContexts.get(req);
|
||||
if (!context) {
|
||||
context = mockCreateMCPRequestContext();
|
||||
mockMCPContexts.set(req, context);
|
||||
}
|
||||
|
||||
return context.cleanupStarted ? undefined : context;
|
||||
});
|
||||
const mockCleanupMCPRequestContext = jest.fn(async (context) => {
|
||||
if (!context || context.cleanupStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.cleanupStarted = true;
|
||||
const connections = new Set(context.connections.values());
|
||||
const settled = await Promise.allSettled(context.pending.values());
|
||||
for (const result of settled) {
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
connections.add(result.value);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.allSettled(Array.from(connections).map((connection) => connection.disconnect?.()));
|
||||
context.connections.clear();
|
||||
context.pending.clear();
|
||||
});
|
||||
const mockCleanupMCPRequestContextForReq = jest.fn(async (req) => {
|
||||
const context = mockMCPContexts.get(req);
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await mockCleanupMCPRequestContext(context);
|
||||
} finally {
|
||||
mockMCPContexts.delete(req);
|
||||
}
|
||||
});
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: mockLogger,
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
sendEvent: jest.fn(),
|
||||
getViolationInfo: jest.fn(),
|
||||
buildMessageFiles: jest.fn(() => []),
|
||||
resolveTitleTiming: jest.fn(() => 'immediate'),
|
||||
GenerationJobManager: mockGenerationJobManager,
|
||||
getReferencedQuotes: jest.fn((quotes) => {
|
||||
if (!Array.isArray(quotes)) {
|
||||
return null;
|
||||
}
|
||||
const normalized = quotes
|
||||
.filter((quote) => typeof quote === 'string' && quote.trim().length > 0)
|
||||
.map((quote) => quote.trim());
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}),
|
||||
cleanupMCPRequestContext: (...args) => mockCleanupMCPRequestContext(...args),
|
||||
createMCPRequestContext: (...args) => mockCreateMCPRequestContext(...args),
|
||||
getMCPRequestContext: (...args) => mockGetMCPRequestContext(...args),
|
||||
filterPersistableAbortContent: (...args) => mockFilterPersistableAbortContent(...args),
|
||||
cleanupMCPRequestContextForReq: (...args) => mockCleanupMCPRequestContextForReq(...args),
|
||||
decrementPendingRequest: (...args) => mockDecrementPendingRequest(...args),
|
||||
sanitizeMessageForTransmit: jest.fn((message) => message),
|
||||
checkAndIncrementPendingRequest: (...args) => mockCheckAndIncrementPendingRequest(...args),
|
||||
isUnpersistedPreliminaryParent: async ({
|
||||
userId,
|
||||
conversationId,
|
||||
parentMessageId,
|
||||
getMessages,
|
||||
}) => {
|
||||
if (typeof parentMessageId !== 'string' || !parentMessageId.endsWith('_')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const filter = { user: userId, messageId: parentMessageId };
|
||||
if (conversationId && conversationId !== 'new') {
|
||||
filter.conversationId = conversationId;
|
||||
}
|
||||
|
||||
const messages = await getMessages(filter, '_id');
|
||||
return messages.length === 0;
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('~/server/cleanup', () => ({
|
||||
disposeClient: jest.fn(),
|
||||
clientRegistry: null,
|
||||
requestDataMap: {
|
||||
set: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('~/server/middleware', () => ({
|
||||
handleAbortError: jest.fn(() => Promise.resolve()),
|
||||
}));
|
||||
|
||||
jest.mock('~/cache', () => ({
|
||||
logViolation: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/models', () => ({
|
||||
saveMessage: (...args) => mockSaveMessage(...args),
|
||||
getMessages: (...args) => mockGetMessages(...args),
|
||||
getConvo: (...args) => mockGetConvo(...args),
|
||||
}));
|
||||
|
||||
const AgentController = require('../request');
|
||||
const { getMCPRequestContext } = require('~/server/services/MCPRequestContext');
|
||||
|
||||
function createResumableResponse() {
|
||||
const res = new EventEmitter();
|
||||
res.headersSent = false;
|
||||
res.writableEnded = false;
|
||||
res.finished = false;
|
||||
res.destroyed = false;
|
||||
res.json = jest.fn(() => {
|
||||
res.headersSent = true;
|
||||
res.writableEnded = true;
|
||||
res.finished = true;
|
||||
res.emit('finish');
|
||||
return res;
|
||||
});
|
||||
res.status = jest.fn(() => res);
|
||||
return res;
|
||||
}
|
||||
|
||||
function nextTick() {
|
||||
return new Promise((resolve) => setImmediate(resolve));
|
||||
}
|
||||
|
||||
describe('ResumableAgentController resume metadata', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockMCPContexts = new WeakMap();
|
||||
mockCheckAndIncrementPendingRequest.mockResolvedValue({ allowed: true });
|
||||
mockDecrementPendingRequest.mockResolvedValue(undefined);
|
||||
mockGetConvo.mockResolvedValue({ createdAt: '2026-06-07T00:00:00.000Z' });
|
||||
mockGetMessages.mockResolvedValue([]);
|
||||
mockGenerationJobManager.createJob.mockResolvedValue({
|
||||
createdAt: 1000,
|
||||
readyPromise: Promise.resolve(),
|
||||
abortController: new AbortController(),
|
||||
emitter: { on: jest.fn() },
|
||||
});
|
||||
mockGenerationJobManager.getResumeState.mockResolvedValue(null);
|
||||
mockGenerationJobManager.updateMetadata.mockResolvedValue(undefined);
|
||||
mockGenerationJobManager.emitError.mockResolvedValue(undefined);
|
||||
mockSaveMessage.mockResolvedValue({});
|
||||
});
|
||||
|
||||
it('rejects an underscore-suffixed parent that is not persisted', async () => {
|
||||
const conversationId = 'conversation-123';
|
||||
const initializeClient = jest.fn();
|
||||
const req = {
|
||||
user: { id: 'user-123' },
|
||||
body: {
|
||||
text: 'Follow up too early.',
|
||||
messageId: 'follow-up-user',
|
||||
parentMessageId: 'pending-response_',
|
||||
conversationId,
|
||||
endpointOption: {
|
||||
endpoint: 'agents',
|
||||
modelOptions: { model: 'gpt-3.5-turbo' },
|
||||
},
|
||||
},
|
||||
config: {},
|
||||
};
|
||||
const res = {
|
||||
json: jest.fn(),
|
||||
status: jest.fn(() => res),
|
||||
};
|
||||
|
||||
await AgentController(req, res, jest.fn(), initializeClient, null);
|
||||
|
||||
expect(mockGetMessages).toHaveBeenCalledWith(
|
||||
{ user: 'user-123', messageId: 'pending-response_', conversationId },
|
||||
'_id',
|
||||
);
|
||||
expect(res.status).toHaveBeenCalledWith(409);
|
||||
expect(res.json).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
error: expect.stringContaining('selected parent response is still being saved'),
|
||||
}),
|
||||
);
|
||||
expect(mockCheckAndIncrementPendingRequest).not.toHaveBeenCalled();
|
||||
expect(mockGenerationJobManager.createJob).not.toHaveBeenCalled();
|
||||
expect(initializeClient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('allows an underscore-suffixed parent when it is already persisted', async () => {
|
||||
const conversationId = 'conversation-123';
|
||||
mockGetMessages.mockResolvedValue([{ _id: 'persisted-parent' }]);
|
||||
const initializeClient = jest.fn().mockRejectedValue(new Error('stop before tool loading'));
|
||||
const req = {
|
||||
user: { id: 'user-123' },
|
||||
body: {
|
||||
text: 'Follow up to persisted underscore id.',
|
||||
messageId: 'follow-up-user',
|
||||
parentMessageId: 'persisted-response_',
|
||||
conversationId,
|
||||
endpointOption: {
|
||||
endpoint: 'agents',
|
||||
modelOptions: { model: 'gpt-3.5-turbo' },
|
||||
},
|
||||
},
|
||||
config: {},
|
||||
};
|
||||
const res = {
|
||||
headersSent: true,
|
||||
json: jest.fn(() => {
|
||||
res.headersSent = true;
|
||||
}),
|
||||
status: jest.fn(() => res),
|
||||
};
|
||||
|
||||
await AgentController(req, res, jest.fn(), initializeClient, null);
|
||||
|
||||
expect(mockGetMessages).toHaveBeenCalledWith(
|
||||
{ user: 'user-123', messageId: 'persisted-response_', conversationId },
|
||||
'_id',
|
||||
);
|
||||
expect(res.status).not.toHaveBeenCalledWith(409);
|
||||
expect(mockCheckAndIncrementPendingRequest).toHaveBeenCalledWith('user-123');
|
||||
expect(mockGenerationJobManager.createJob).toHaveBeenCalledWith(
|
||||
conversationId,
|
||||
'user-123',
|
||||
conversationId,
|
||||
);
|
||||
});
|
||||
|
||||
it('stores the in-flight turn before MCP initialization can emit OAuth', async () => {
|
||||
const conversationId = 'conversation-123';
|
||||
const initializeClient = jest.fn().mockRejectedValue(new Error('stop before tool loading'));
|
||||
const req = {
|
||||
user: { id: 'user-123' },
|
||||
body: {
|
||||
text: 'Check Google Workspace availability.',
|
||||
messageId: 'follow-up-user',
|
||||
parentMessageId: 'original-response',
|
||||
conversationId,
|
||||
endpointOption: {
|
||||
endpoint: 'agents',
|
||||
iconURL: 'https://example.com/spec-icon.png',
|
||||
modelOptions: { model: 'gpt-3.5-turbo' },
|
||||
},
|
||||
},
|
||||
config: {},
|
||||
};
|
||||
const res = {
|
||||
headersSent: true,
|
||||
json: jest.fn(() => {
|
||||
res.headersSent = true;
|
||||
}),
|
||||
status: jest.fn(() => res),
|
||||
};
|
||||
|
||||
await AgentController(req, res, jest.fn(), initializeClient, null);
|
||||
|
||||
expect(mockGenerationJobManager.updateMetadata).toHaveBeenCalledWith(
|
||||
conversationId,
|
||||
expect.objectContaining({
|
||||
conversationId,
|
||||
endpoint: 'agents',
|
||||
iconURL: 'https://example.com/spec-icon.png',
|
||||
model: 'gpt-3.5-turbo',
|
||||
responseMessageId: 'follow-up-user_',
|
||||
userMessage: {
|
||||
messageId: 'follow-up-user',
|
||||
parentMessageId: 'original-response',
|
||||
conversationId,
|
||||
text: 'Check Google Workspace availability.',
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(mockGenerationJobManager.updateMetadata.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
initializeClient.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps request-scoped MCP connections until resumable initialization finishes', async () => {
|
||||
const conversationId = 'conversation-123';
|
||||
const disconnect = jest.fn().mockResolvedValue(undefined);
|
||||
const initializeClient = jest.fn(async ({ req, res }) => {
|
||||
const context = getMCPRequestContext(req, res);
|
||||
context.connections.set('mcp-server', { disconnect });
|
||||
|
||||
await nextTick();
|
||||
expect(disconnect).not.toHaveBeenCalled();
|
||||
|
||||
throw new Error('stop after request-scoped MCP connection');
|
||||
});
|
||||
const req = {
|
||||
user: { id: 'user-123' },
|
||||
body: {
|
||||
text: 'Use a BODY-scoped MCP server.',
|
||||
messageId: 'user-message',
|
||||
parentMessageId: 'parent-message',
|
||||
conversationId,
|
||||
endpointOption: {
|
||||
endpoint: 'agents',
|
||||
modelOptions: { model: 'gpt-4.1' },
|
||||
},
|
||||
},
|
||||
config: {},
|
||||
};
|
||||
const res = createResumableResponse();
|
||||
|
||||
await AgentController(req, res, jest.fn(), initializeClient, null);
|
||||
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
streamId: conversationId,
|
||||
conversationId,
|
||||
status: 'started',
|
||||
});
|
||||
expect(disconnect).toHaveBeenCalledTimes(1);
|
||||
expect(disconnect.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
mockDecrementPendingRequest.mock.invocationCallOrder[0],
|
||||
);
|
||||
});
|
||||
|
||||
it('stores model spec icon fallbacks and agent ids in early resume metadata', async () => {
|
||||
const conversationId = 'conversation-123';
|
||||
const initializeClient = jest.fn().mockRejectedValue(new Error('stop before tool loading'));
|
||||
const req = {
|
||||
user: { id: 'user-123' },
|
||||
body: {
|
||||
text: 'Use the resume spec.',
|
||||
messageId: 'follow-up-user',
|
||||
parentMessageId: 'original-response',
|
||||
conversationId,
|
||||
endpointOption: {
|
||||
endpoint: 'agents',
|
||||
spec: 'agent-spec',
|
||||
agent_id: 'agent_resume_spec',
|
||||
model_parameters: { model: 'gpt-4.1' },
|
||||
},
|
||||
},
|
||||
config: {
|
||||
modelSpecs: {
|
||||
list: [
|
||||
{
|
||||
name: 'agent-spec',
|
||||
preset: {
|
||||
endpoint: 'openAI',
|
||||
iconURL: 'https://example.com/preset-icon.png',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const res = {
|
||||
headersSent: true,
|
||||
json: jest.fn(() => {
|
||||
res.headersSent = true;
|
||||
}),
|
||||
status: jest.fn(() => res),
|
||||
};
|
||||
|
||||
await AgentController(req, res, jest.fn(), initializeClient, null);
|
||||
|
||||
expect(mockGenerationJobManager.updateMetadata).toHaveBeenCalledWith(
|
||||
conversationId,
|
||||
expect.objectContaining({
|
||||
iconURL: 'https://example.com/preset-icon.png',
|
||||
model: 'agent_resume_spec',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the model spec preset endpoint when no icon URL is configured', async () => {
|
||||
const conversationId = 'conversation-123';
|
||||
const initializeClient = jest.fn().mockRejectedValue(new Error('stop before tool loading'));
|
||||
const req = {
|
||||
user: { id: 'user-123' },
|
||||
body: {
|
||||
text: 'Use the endpoint icon.',
|
||||
messageId: 'follow-up-user',
|
||||
parentMessageId: 'original-response',
|
||||
conversationId,
|
||||
endpointOption: {
|
||||
endpoint: 'agents',
|
||||
spec: 'endpoint-icon-spec',
|
||||
model_parameters: { model: 'gpt-4.1' },
|
||||
},
|
||||
},
|
||||
config: {
|
||||
modelSpecs: {
|
||||
list: [
|
||||
{
|
||||
name: 'endpoint-icon-spec',
|
||||
preset: {
|
||||
endpoint: 'anthropic',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const res = {
|
||||
headersSent: true,
|
||||
json: jest.fn(() => {
|
||||
res.headersSent = true;
|
||||
}),
|
||||
status: jest.fn(() => res),
|
||||
};
|
||||
|
||||
await AgentController(req, res, jest.fn(), initializeClient, null);
|
||||
|
||||
expect(mockGenerationJobManager.updateMetadata).toHaveBeenCalledWith(
|
||||
conversationId,
|
||||
expect.objectContaining({
|
||||
iconURL: 'anthropic',
|
||||
model: 'gpt-4.1',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('filters OAuth prompts before saving partial responses on disconnect', async () => {
|
||||
const conversationId = 'conversation-123';
|
||||
let allSubscribersLeftHandler;
|
||||
mockGenerationJobManager.createJob.mockResolvedValue({
|
||||
createdAt: 1000,
|
||||
readyPromise: Promise.resolve(),
|
||||
abortController: new AbortController(),
|
||||
emitter: {
|
||||
on: jest.fn((event, handler) => {
|
||||
if (event === 'allSubscribersLeft') {
|
||||
allSubscribersLeftHandler = handler;
|
||||
}
|
||||
}),
|
||||
},
|
||||
});
|
||||
mockGenerationJobManager.getResumeState.mockResolvedValue({
|
||||
conversationId,
|
||||
responseMessageId: 'response-message',
|
||||
iconURL: 'https://example.com/spec-icon.png',
|
||||
model: 'gpt-4.1',
|
||||
userMessage: {
|
||||
messageId: 'user-message',
|
||||
parentMessageId: 'parent-message',
|
||||
conversationId,
|
||||
text: 'Use Google Workspace',
|
||||
},
|
||||
});
|
||||
|
||||
const initializeClient = jest.fn().mockRejectedValue(new Error('stop after setup'));
|
||||
const req = {
|
||||
user: { id: 'user-123' },
|
||||
body: {
|
||||
text: 'Use Google Workspace',
|
||||
messageId: 'user-message',
|
||||
parentMessageId: 'parent-message',
|
||||
conversationId,
|
||||
endpointOption: {
|
||||
endpoint: 'agents',
|
||||
iconURL: 'https://example.com/fallback-icon.png',
|
||||
modelOptions: { model: 'gpt-3.5-turbo' },
|
||||
},
|
||||
},
|
||||
config: {},
|
||||
};
|
||||
const res = {
|
||||
headersSent: true,
|
||||
json: jest.fn(() => {
|
||||
res.headersSent = true;
|
||||
}),
|
||||
status: jest.fn(() => res),
|
||||
};
|
||||
|
||||
await AgentController(req, res, jest.fn(), initializeClient, null);
|
||||
expect(allSubscribersLeftHandler).toEqual(expect.any(Function));
|
||||
|
||||
const oauthPart = {
|
||||
type: 'tool_call',
|
||||
tool_call: {
|
||||
name: 'oauth_mcp_Google-Workspace',
|
||||
auth: 'https://auth.example.com/oauth',
|
||||
},
|
||||
};
|
||||
const textPart = { type: 'text', text: 'Partial response...' };
|
||||
|
||||
await allSubscribersLeftHandler([oauthPart, textPart]);
|
||||
|
||||
expect(mockFilterPersistableAbortContent).toHaveBeenCalledWith([oauthPart, textPart]);
|
||||
expect(mockSaveMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: 'user-123' }),
|
||||
expect.objectContaining({
|
||||
content: [textPart],
|
||||
iconURL: 'https://example.com/spec-icon.png',
|
||||
model: 'gpt-4.1',
|
||||
messageId: 'response-message',
|
||||
parentMessageId: 'user-message',
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('uses model spec and agent fallbacks when saving partial responses on disconnect', async () => {
|
||||
const conversationId = 'conversation-123';
|
||||
let allSubscribersLeftHandler;
|
||||
mockGenerationJobManager.createJob.mockResolvedValue({
|
||||
createdAt: 1000,
|
||||
readyPromise: Promise.resolve(),
|
||||
abortController: new AbortController(),
|
||||
emitter: {
|
||||
on: jest.fn((event, handler) => {
|
||||
if (event === 'allSubscribersLeft') {
|
||||
allSubscribersLeftHandler = handler;
|
||||
}
|
||||
}),
|
||||
},
|
||||
});
|
||||
mockGenerationJobManager.getResumeState.mockResolvedValue({
|
||||
conversationId,
|
||||
responseMessageId: 'response-message',
|
||||
userMessage: {
|
||||
messageId: 'user-message',
|
||||
parentMessageId: 'parent-message',
|
||||
conversationId,
|
||||
text: 'Use fallback metadata',
|
||||
},
|
||||
});
|
||||
|
||||
const initializeClient = jest.fn().mockRejectedValue(new Error('stop after setup'));
|
||||
const req = {
|
||||
user: { id: 'user-123' },
|
||||
body: {
|
||||
text: 'Use fallback metadata',
|
||||
messageId: 'user-message',
|
||||
parentMessageId: 'parent-message',
|
||||
conversationId,
|
||||
endpointOption: {
|
||||
endpoint: 'agents',
|
||||
spec: 'agent-spec',
|
||||
agent_id: 'agent_resume_spec',
|
||||
model_parameters: { model: 'gpt-4.1' },
|
||||
},
|
||||
},
|
||||
config: {
|
||||
modelSpecs: {
|
||||
list: [
|
||||
{
|
||||
name: 'agent-spec',
|
||||
preset: {
|
||||
endpoint: 'openAI',
|
||||
iconURL: 'https://example.com/preset-icon.png',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const res = {
|
||||
headersSent: true,
|
||||
json: jest.fn(() => {
|
||||
res.headersSent = true;
|
||||
}),
|
||||
status: jest.fn(() => res),
|
||||
};
|
||||
|
||||
await AgentController(req, res, jest.fn(), initializeClient, null);
|
||||
expect(allSubscribersLeftHandler).toEqual(expect.any(Function));
|
||||
|
||||
const textPart = { type: 'text', text: 'Partial response...' };
|
||||
await allSubscribersLeftHandler([textPart]);
|
||||
|
||||
expect(mockSaveMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: 'user-123' }),
|
||||
expect.objectContaining({
|
||||
content: [textPart],
|
||||
iconURL: 'https://example.com/preset-icon.png',
|
||||
model: 'agent_resume_spec',
|
||||
messageId: 'response-message',
|
||||
parentMessageId: 'user-message',
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,711 @@
|
||||
/**
|
||||
* Unit tests for Open Responses API controller
|
||||
* Tests that recordCollectedUsage is called correctly for token spending
|
||||
*/
|
||||
|
||||
const mockSpendTokens = jest.fn().mockResolvedValue({});
|
||||
const mockSpendStructuredTokens = jest.fn().mockResolvedValue({});
|
||||
const mockRecordCollectedUsage = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
|
||||
const mockGetBalanceConfig = jest.fn().mockReturnValue({ enabled: true });
|
||||
const mockGetTransactionsConfig = jest.fn().mockReturnValue({ enabled: true });
|
||||
const mockBuildSkillPrimedIdsByName = jest.fn((manualSkillPrimes, alwaysApplySkillPrimes) => {
|
||||
const primed = {};
|
||||
for (const skill of alwaysApplySkillPrimes ?? []) {
|
||||
primed[skill.name] = skill._id.toString();
|
||||
}
|
||||
for (const skill of manualSkillPrimes ?? []) {
|
||||
primed[skill.name] = skill._id.toString();
|
||||
}
|
||||
return Object.keys(primed).length > 0 ? primed : undefined;
|
||||
});
|
||||
const mockEnrichWithSkillConfigurable = jest.fn((result) => result);
|
||||
const mockBuildAgentToolContext = jest.fn(({ agent, config }) => ({
|
||||
agent,
|
||||
toolRegistry: config.toolRegistry,
|
||||
userMCPAuthMap: config.userMCPAuthMap,
|
||||
tool_resources: config.tool_resources,
|
||||
actionsEnabled: config.actionsEnabled,
|
||||
accessibleSkillIds: config.accessibleSkillIds,
|
||||
activeSkillNames: config.activeSkillNames,
|
||||
codeEnvAvailable: config.codeEnvAvailable,
|
||||
skillAuthoringAvailable: config.skillAuthoringAvailable,
|
||||
fileAuthoringToolNames: config.fileAuthoringToolNames,
|
||||
skillPrimedIdsByName:
|
||||
mockBuildSkillPrimedIdsByName(config.manualSkillPrimes, config.alwaysApplySkillPrimes) ?? {},
|
||||
}));
|
||||
const mockEnrichLoadedToolsWithAgentContext = jest.fn(({ result, req, ctx }) =>
|
||||
mockEnrichWithSkillConfigurable({
|
||||
result,
|
||||
context: {
|
||||
req,
|
||||
accessibleSkillIds: ctx.accessibleSkillIds,
|
||||
codeEnvAvailable: ctx.codeEnvAvailable === true,
|
||||
skillPrimedIdsByName: ctx.skillPrimedIdsByName,
|
||||
activeSkillNames: ctx.activeSkillNames,
|
||||
skillAuthoringAvailable: ctx.skillAuthoringAvailable === true,
|
||||
fileAuthoringToolNames: ctx.fileAuthoringToolNames,
|
||||
},
|
||||
}),
|
||||
);
|
||||
const mockCanAuthorSkillFiles = jest.fn(
|
||||
({ scopedEditableSkillIds = [], skillCreateAllowed }) =>
|
||||
scopedEditableSkillIds.length > 0 || skillCreateAllowed === true,
|
||||
);
|
||||
const mockGetSkillToolDeps = jest.fn(() => ({}));
|
||||
const mockBuildAgentScopedContext = jest.fn().mockResolvedValue(new Map());
|
||||
const mockBuildAgentContextAttachmentsByAgentId = jest.fn().mockReturnValue(new Map());
|
||||
const mockApplyContextToAgent = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
jest.mock('nanoid', () => ({
|
||||
nanoid: jest.fn(() => 'mock-nanoid-123'),
|
||||
}));
|
||||
|
||||
jest.mock('uuid', () => ({
|
||||
v4: jest.fn(() => 'mock-uuid-456'),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: {
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/agents', () => ({
|
||||
Callback: { TOOL_ERROR: 'TOOL_ERROR' },
|
||||
ToolEndHandler: jest.fn(),
|
||||
formatAgentMessages: jest.fn().mockReturnValue({
|
||||
messages: [],
|
||||
indexTokenCountMap: {},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
createRun: jest.fn().mockResolvedValue({
|
||||
processStream: jest.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
applyContextToAgent: (...args) => mockApplyContextToAgent(...args),
|
||||
buildToolSet: jest.fn().mockReturnValue(new Set()),
|
||||
buildAgentScopedContext: (...args) => mockBuildAgentScopedContext(...args),
|
||||
buildAgentContextAttachmentsByAgentId: (...args) =>
|
||||
mockBuildAgentContextAttachmentsByAgentId(...args),
|
||||
scopeSkillIds: jest.fn().mockImplementation((ids) => ids),
|
||||
resolveAgentScopedSkillIds: jest
|
||||
.fn()
|
||||
.mockImplementation(({ accessibleSkillIds }) => accessibleSkillIds),
|
||||
loadSkillStates: jest.fn().mockResolvedValue({ skillStates: {}, defaultActiveOnShare: false }),
|
||||
createSafeUser: jest.fn().mockReturnValue({ id: 'user-123' }),
|
||||
initializeAgent: jest.fn().mockResolvedValue({
|
||||
id: 'agent-123',
|
||||
model: 'claude-3',
|
||||
model_parameters: {},
|
||||
toolRegistry: {},
|
||||
edges: [],
|
||||
agentContextAttachments: [],
|
||||
}),
|
||||
discoverConnectedAgents: jest.fn().mockImplementation(async (computedParams, deps) => {
|
||||
// Call onAgentInitialized for each agent config if provided by the mock setup
|
||||
if (deps?.onAgentInitialized && mockGlobalDiscoveredAgentConfigs) {
|
||||
for (const [agentId, config] of mockGlobalDiscoveredAgentConfigs) {
|
||||
deps.onAgentInitialized(agentId, config, config);
|
||||
}
|
||||
}
|
||||
return {
|
||||
agentConfigs: mockGlobalDiscoveredAgentConfigs ?? new Map(),
|
||||
edges: [],
|
||||
skippedAgentIds: new Set(),
|
||||
userMCPAuthMap: undefined,
|
||||
};
|
||||
}),
|
||||
getBalanceConfig: mockGetBalanceConfig,
|
||||
getTransactionsConfig: mockGetTransactionsConfig,
|
||||
recordCollectedUsage: mockRecordCollectedUsage,
|
||||
createSubagentUsageSink: jest.fn().mockReturnValue(jest.fn()),
|
||||
extractManualSkills: jest.fn().mockReturnValue(undefined),
|
||||
injectSkillPrimes: jest.fn().mockReturnValue({
|
||||
initialMessages: [],
|
||||
indexTokenCountMap: {},
|
||||
inserted: 0,
|
||||
insertIdx: -1,
|
||||
alwaysApplyDropped: 0,
|
||||
alwaysApplyDedupedFromManual: 0,
|
||||
}),
|
||||
createToolExecuteHandler: jest.fn().mockReturnValue({ handle: jest.fn() }),
|
||||
// Responses API
|
||||
writeDone: jest.fn(),
|
||||
buildResponse: jest.fn().mockReturnValue({ id: 'resp_123', output: [] }),
|
||||
generateResponseId: jest.fn().mockReturnValue('resp_mock-123'),
|
||||
isValidationFailure: jest.fn().mockReturnValue(false),
|
||||
findPiiMatchInMessages: jest.fn().mockReturnValue(null),
|
||||
emitResponseCreated: jest.fn(),
|
||||
createResponseContext: jest.fn().mockReturnValue({ responseId: 'resp_123' }),
|
||||
createResponseTracker: jest.fn().mockReturnValue({
|
||||
usage: { promptTokens: 100, completionTokens: 50 },
|
||||
}),
|
||||
setupStreamingResponse: jest.fn(),
|
||||
emitResponseInProgress: jest.fn(),
|
||||
convertInputToMessages: jest.fn().mockReturnValue([]),
|
||||
validateResponseRequest: jest.fn().mockReturnValue({
|
||||
request: { model: 'agent-123', input: 'Hello', stream: false },
|
||||
}),
|
||||
buildAggregatedResponse: jest.fn().mockReturnValue({
|
||||
id: 'resp_123',
|
||||
status: 'completed',
|
||||
output: [],
|
||||
usage: { input_tokens: 100, output_tokens: 50, total_tokens: 150 },
|
||||
}),
|
||||
createResponseAggregator: jest.fn().mockReturnValue({
|
||||
usage: { promptTokens: 100, completionTokens: 50 },
|
||||
}),
|
||||
sendResponsesErrorResponse: jest.fn(),
|
||||
createResponsesEventHandlers: jest.fn().mockReturnValue({
|
||||
handlers: {
|
||||
on_message_delta: { handle: jest.fn() },
|
||||
on_reasoning_delta: { handle: jest.fn() },
|
||||
on_run_step: { handle: jest.fn() },
|
||||
on_run_step_delta: { handle: jest.fn() },
|
||||
on_chat_model_end: { handle: jest.fn() },
|
||||
},
|
||||
finalizeStream: jest.fn(),
|
||||
}),
|
||||
createAggregatorEventHandlers: jest.fn().mockReturnValue({
|
||||
on_message_delta: { handle: jest.fn() },
|
||||
on_reasoning_delta: { handle: jest.fn() },
|
||||
on_run_step: { handle: jest.fn() },
|
||||
on_run_step_delta: { handle: jest.fn() },
|
||||
on_chat_model_end: { handle: jest.fn() },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/ToolService', () => ({
|
||||
loadAgentTools: jest.fn().mockResolvedValue([]),
|
||||
loadToolsForExecution: jest.fn().mockResolvedValue([]),
|
||||
}));
|
||||
|
||||
const mockGetMultiplier = jest.fn().mockReturnValue(1);
|
||||
const mockGetCacheMultiplier = jest.fn().mockReturnValue(null);
|
||||
|
||||
jest.mock('~/server/controllers/agents/callbacks', () => {
|
||||
const noop = { handle: jest.fn() };
|
||||
return {
|
||||
createToolEndCallback: jest.fn().mockReturnValue(jest.fn()),
|
||||
createResponsesToolEndCallback: jest.fn().mockReturnValue(jest.fn()),
|
||||
markSummarizationUsage: jest.fn().mockImplementation((usage) => usage),
|
||||
agentLogHandlerObj: noop,
|
||||
buildSummarizationHandlers: jest.fn().mockReturnValue({
|
||||
on_summarize_start: noop,
|
||||
on_summarize_delta: noop,
|
||||
on_summarize_complete: noop,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('~/server/services/PermissionService', () => ({
|
||||
findAccessibleResources: jest.fn().mockResolvedValue([]),
|
||||
checkPermission: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/controllers/ModelController', () => ({
|
||||
getModelsConfig: jest.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/MCP', () => ({
|
||||
resolveConfigServers: jest.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
jest.mock('~/config', () => ({
|
||||
getMCPManager: jest.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/permissions', () => ({
|
||||
filterFilesByAgentAccess: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Endpoints/agents/skillDeps', () => ({
|
||||
getSkillToolDeps: mockGetSkillToolDeps,
|
||||
getSkillDbMethods: jest.fn(() => ({})),
|
||||
canAuthorSkillFiles: mockCanAuthorSkillFiles,
|
||||
withDeploymentSkillIds: jest.fn((ids = []) => ids),
|
||||
enrichWithSkillConfigurable: mockEnrichWithSkillConfigurable,
|
||||
buildSkillPrimedIdsByName: mockBuildSkillPrimedIdsByName,
|
||||
buildAgentToolContext: mockBuildAgentToolContext,
|
||||
enrichLoadedToolsWithAgentContext: mockEnrichLoadedToolsWithAgentContext,
|
||||
}));
|
||||
|
||||
jest.mock('~/cache', () => ({
|
||||
logViolation: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/strategies', () => ({
|
||||
getStrategyFunctions: jest.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/Code/crud', () => ({
|
||||
batchUploadCodeEnvFiles: jest.fn().mockResolvedValue({ session_id: '', files: [] }),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/Code/process', () => ({
|
||||
getSessionInfo: jest.fn().mockResolvedValue(null),
|
||||
checkIfActive: jest.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
const mockUpdateBalance = jest.fn().mockResolvedValue({});
|
||||
const mockBulkInsertTransactions = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
jest.mock('~/models', () => ({
|
||||
getAgent: jest.fn().mockResolvedValue({ id: 'agent-123', name: 'Test Agent' }),
|
||||
getFiles: jest.fn(),
|
||||
getUserKey: jest.fn(),
|
||||
getMessages: jest.fn().mockResolvedValue([]),
|
||||
saveMessage: jest.fn().mockResolvedValue({}),
|
||||
updateFilesUsage: jest.fn(),
|
||||
getUserKeyValues: jest.fn(),
|
||||
getUserCodeFiles: jest.fn(),
|
||||
getToolFilesByIds: jest.fn(),
|
||||
getCodeGeneratedFiles: jest.fn(),
|
||||
updateBalance: mockUpdateBalance,
|
||||
bulkInsertTransactions: mockBulkInsertTransactions,
|
||||
spendTokens: mockSpendTokens,
|
||||
spendStructuredTokens: mockSpendStructuredTokens,
|
||||
getMultiplier: mockGetMultiplier,
|
||||
getCacheMultiplier: mockGetCacheMultiplier,
|
||||
getConvoFiles: jest.fn().mockResolvedValue([]),
|
||||
saveConvo: jest.fn().mockResolvedValue({}),
|
||||
getConvo: jest.fn().mockResolvedValue(null),
|
||||
}));
|
||||
|
||||
let mockGlobalDiscoveredAgentConfigs = null;
|
||||
|
||||
describe('createResponse controller', () => {
|
||||
let createResponse;
|
||||
let req, res;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGlobalDiscoveredAgentConfigs = null;
|
||||
|
||||
const controller = require('../responses');
|
||||
createResponse = controller.createResponse;
|
||||
|
||||
req = {
|
||||
body: {
|
||||
model: 'agent-123',
|
||||
input: 'Hello',
|
||||
stream: false,
|
||||
},
|
||||
user: { id: 'user-123' },
|
||||
config: {
|
||||
endpoints: {
|
||||
agents: { allowedProviders: ['anthropic'] },
|
||||
},
|
||||
},
|
||||
on: jest.fn(),
|
||||
};
|
||||
|
||||
res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn(),
|
||||
setHeader: jest.fn(),
|
||||
flushHeaders: jest.fn(),
|
||||
end: jest.fn(),
|
||||
write: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('conversation ownership validation', () => {
|
||||
it('should skip ownership check when previous_response_id is not provided', async () => {
|
||||
const { getConvo } = require('~/models');
|
||||
await createResponse(req, res);
|
||||
expect(getConvo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should return 400 when previous_response_id is not a string', async () => {
|
||||
const { validateResponseRequest, sendResponsesErrorResponse } = require('@librechat/api');
|
||||
validateResponseRequest.mockReturnValueOnce({
|
||||
request: {
|
||||
model: 'agent-123',
|
||||
input: 'Hello',
|
||||
stream: false,
|
||||
previous_response_id: { $gt: '' },
|
||||
},
|
||||
});
|
||||
|
||||
await createResponse(req, res);
|
||||
expect(sendResponsesErrorResponse).toHaveBeenCalledWith(
|
||||
res,
|
||||
400,
|
||||
'previous_response_id must be a string',
|
||||
'invalid_request',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 404 when conversation is not owned by user', async () => {
|
||||
const { validateResponseRequest, sendResponsesErrorResponse } = require('@librechat/api');
|
||||
const { getConvo } = require('~/models');
|
||||
validateResponseRequest.mockReturnValueOnce({
|
||||
request: {
|
||||
model: 'agent-123',
|
||||
input: 'Hello',
|
||||
stream: false,
|
||||
previous_response_id: 'resp_abc',
|
||||
},
|
||||
});
|
||||
getConvo.mockResolvedValueOnce(null);
|
||||
|
||||
await createResponse(req, res);
|
||||
expect(getConvo).toHaveBeenCalledWith('user-123', 'resp_abc');
|
||||
expect(sendResponsesErrorResponse).toHaveBeenCalledWith(
|
||||
res,
|
||||
404,
|
||||
'Conversation not found',
|
||||
'not_found',
|
||||
);
|
||||
});
|
||||
|
||||
it('should proceed when conversation is owned by user', async () => {
|
||||
const { validateResponseRequest, sendResponsesErrorResponse } = require('@librechat/api');
|
||||
const { getConvo } = require('~/models');
|
||||
validateResponseRequest.mockReturnValueOnce({
|
||||
request: {
|
||||
model: 'agent-123',
|
||||
input: 'Hello',
|
||||
stream: false,
|
||||
previous_response_id: 'resp_abc',
|
||||
},
|
||||
});
|
||||
getConvo.mockResolvedValueOnce({ conversationId: 'resp_abc', user: 'user-123' });
|
||||
|
||||
await createResponse(req, res);
|
||||
expect(getConvo).toHaveBeenCalledWith('user-123', 'resp_abc');
|
||||
expect(sendResponsesErrorResponse).not.toHaveBeenCalledWith(
|
||||
res,
|
||||
404,
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return 500 when getConvo throws a DB error', async () => {
|
||||
const { validateResponseRequest, sendResponsesErrorResponse } = require('@librechat/api');
|
||||
const { getConvo } = require('~/models');
|
||||
validateResponseRequest.mockReturnValueOnce({
|
||||
request: {
|
||||
model: 'agent-123',
|
||||
input: 'Hello',
|
||||
stream: false,
|
||||
previous_response_id: 'resp_abc',
|
||||
},
|
||||
});
|
||||
getConvo.mockRejectedValueOnce(new Error('DB connection failed'));
|
||||
|
||||
await createResponse(req, res);
|
||||
expect(sendResponsesErrorResponse).toHaveBeenCalledWith(
|
||||
res,
|
||||
500,
|
||||
expect.any(String),
|
||||
expect.any(String),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('token usage recording - non-streaming', () => {
|
||||
it('should call recordCollectedUsage after successful non-streaming completion', async () => {
|
||||
await createResponse(req, res);
|
||||
|
||||
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
|
||||
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
|
||||
{
|
||||
spendTokens: mockSpendTokens,
|
||||
spendStructuredTokens: mockSpendStructuredTokens,
|
||||
pricing: { getMultiplier: mockGetMultiplier, getCacheMultiplier: mockGetCacheMultiplier },
|
||||
bulkWriteOps: {
|
||||
insertMany: mockBulkInsertTransactions,
|
||||
updateBalance: mockUpdateBalance,
|
||||
},
|
||||
},
|
||||
expect.objectContaining({
|
||||
user: 'user-123',
|
||||
conversationId: expect.any(String),
|
||||
collectedUsage: expect.any(Array),
|
||||
context: 'message',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass balance and transactions config to recordCollectedUsage', async () => {
|
||||
mockGetBalanceConfig.mockReturnValue({ enabled: true, startBalance: 2000 });
|
||||
mockGetTransactionsConfig.mockReturnValue({ enabled: true });
|
||||
|
||||
await createResponse(req, res);
|
||||
|
||||
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
balance: { enabled: true, startBalance: 2000 },
|
||||
transactions: { enabled: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should pass spendTokens, spendStructuredTokens, pricing, and bulkWriteOps as dependencies', async () => {
|
||||
await createResponse(req, res);
|
||||
|
||||
const [deps] = mockRecordCollectedUsage.mock.calls[0];
|
||||
expect(deps).toHaveProperty('spendTokens', mockSpendTokens);
|
||||
expect(deps).toHaveProperty('spendStructuredTokens', mockSpendStructuredTokens);
|
||||
expect(deps).toHaveProperty('pricing');
|
||||
expect(deps.pricing).toHaveProperty('getMultiplier', mockGetMultiplier);
|
||||
expect(deps.pricing).toHaveProperty('getCacheMultiplier', mockGetCacheMultiplier);
|
||||
expect(deps).toHaveProperty('bulkWriteOps');
|
||||
expect(deps.bulkWriteOps).toHaveProperty('insertMany', mockBulkInsertTransactions);
|
||||
expect(deps.bulkWriteOps).toHaveProperty('updateBalance', mockUpdateBalance);
|
||||
});
|
||||
|
||||
it('should include model from primaryConfig in recordCollectedUsage params', async () => {
|
||||
await createResponse(req, res);
|
||||
|
||||
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
model: 'claude-3',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('agent context parity with UI path', () => {
|
||||
it('applies agent-scoped attachment context before createRun', async () => {
|
||||
const api = require('@librechat/api');
|
||||
api.initializeAgent.mockResolvedValueOnce({
|
||||
id: 'agent-123',
|
||||
model: 'claude-3',
|
||||
model_parameters: {},
|
||||
toolRegistry: {},
|
||||
edges: [],
|
||||
agentContextAttachments: [{ file_id: 'file-1', filename: 'ocr_file.pdf' }],
|
||||
});
|
||||
mockBuildAgentContextAttachmentsByAgentId.mockReturnValueOnce(
|
||||
new Map([['agent-123', [{ file_id: 'file-1', filename: 'ocr_file.pdf' }]]]),
|
||||
);
|
||||
mockBuildAgentScopedContext.mockResolvedValueOnce(
|
||||
new Map([['agent-123', 'PDF context: ocr_file.pdf']]),
|
||||
);
|
||||
|
||||
await createResponse(req, res);
|
||||
|
||||
expect(mockBuildAgentContextAttachmentsByAgentId).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ id: 'agent-123' }),
|
||||
]);
|
||||
expect(mockBuildAgentScopedContext).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentIds: ['agent-123'],
|
||||
attachmentsByAgentId: expect.any(Map),
|
||||
req,
|
||||
}),
|
||||
);
|
||||
expect(mockApplyContextToAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agent: expect.objectContaining({ id: 'agent-123' }),
|
||||
agentId: 'agent-123',
|
||||
sharedRunContext: 'PDF context: ocr_file.pdf',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('applies context to primary and discovered handoff agents', async () => {
|
||||
const api = require('@librechat/api');
|
||||
const handoffConfig = {
|
||||
id: 'agent-handoff',
|
||||
model: 'claude-3',
|
||||
model_parameters: {},
|
||||
toolRegistry: {},
|
||||
edges: [],
|
||||
agentContextAttachments: [{ file_id: 'file-2', filename: 'handoff_context.pdf' }],
|
||||
};
|
||||
|
||||
// Set primary agent to have edges pointing to handoff agent
|
||||
api.initializeAgent.mockResolvedValueOnce({
|
||||
id: 'agent-123',
|
||||
model: 'claude-3',
|
||||
model_parameters: {},
|
||||
toolRegistry: {},
|
||||
edges: [{ source: 'agent-123', target: 'agent-handoff' }],
|
||||
agentContextAttachments: [{ file_id: 'file-1', filename: 'primary_context.pdf' }],
|
||||
});
|
||||
|
||||
// Set global config so discoverConnectedAgents mock can invoke onAgentInitialized
|
||||
mockGlobalDiscoveredAgentConfigs = new Map([['agent-handoff', handoffConfig]]);
|
||||
|
||||
mockBuildAgentScopedContext.mockResolvedValueOnce(
|
||||
new Map([
|
||||
['agent-123', 'Primary context'],
|
||||
['agent-handoff', 'Handoff context'],
|
||||
]),
|
||||
);
|
||||
|
||||
await createResponse(req, res);
|
||||
|
||||
const appliedAgentIds = mockApplyContextToAgent.mock.calls.map((call) => call[0].agentId);
|
||||
expect(appliedAgentIds).toEqual(expect.arrayContaining(['agent-123', 'agent-handoff']));
|
||||
expect(mockApplyContextToAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
agentId: 'agent-handoff',
|
||||
sharedRunContext: 'Handoff context',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('token usage recording - streaming', () => {
|
||||
beforeEach(() => {
|
||||
req.body.stream = true;
|
||||
|
||||
const api = require('@librechat/api');
|
||||
api.validateResponseRequest.mockReturnValue({
|
||||
request: { model: 'agent-123', input: 'Hello', stream: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('should call recordCollectedUsage after successful streaming completion', async () => {
|
||||
await createResponse(req, res);
|
||||
|
||||
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
|
||||
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
|
||||
{
|
||||
spendTokens: mockSpendTokens,
|
||||
spendStructuredTokens: mockSpendStructuredTokens,
|
||||
pricing: { getMultiplier: mockGetMultiplier, getCacheMultiplier: mockGetCacheMultiplier },
|
||||
bulkWriteOps: {
|
||||
insertMany: mockBulkInsertTransactions,
|
||||
updateBalance: mockUpdateBalance,
|
||||
},
|
||||
},
|
||||
expect.objectContaining({
|
||||
user: 'user-123',
|
||||
context: 'message',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('collectedUsage population', () => {
|
||||
it('should collect usage from on_chat_model_end events', async () => {
|
||||
const api = require('@librechat/api');
|
||||
|
||||
api.createRun.mockImplementation(async ({ customHandlers }) => {
|
||||
return {
|
||||
processStream: jest.fn().mockImplementation(async () => {
|
||||
customHandlers.on_chat_model_end.handle('on_chat_model_end', {
|
||||
output: {
|
||||
usage_metadata: {
|
||||
input_tokens: 150,
|
||||
output_tokens: 75,
|
||||
model: 'claude-3',
|
||||
},
|
||||
},
|
||||
});
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
await createResponse(req, res);
|
||||
expect(mockRecordCollectedUsage).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
expect.objectContaining({
|
||||
collectedUsage: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
input_tokens: 150,
|
||||
output_tokens: 75,
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sub-agent skill priming', () => {
|
||||
it('passes the sub-agent primed skill IDs into non-streaming tool execution', async () => {
|
||||
const {
|
||||
initializeAgent,
|
||||
discoverConnectedAgents,
|
||||
createToolExecuteHandler,
|
||||
} = require('@librechat/api');
|
||||
const { loadToolsForExecution } = require('~/server/services/ToolService');
|
||||
const subAgent = { id: 'agent-sub', name: 'Sub Agent' };
|
||||
const subConfig = {
|
||||
id: 'agent-sub',
|
||||
model: 'claude-3',
|
||||
model_parameters: {},
|
||||
toolRegistry: new Map(),
|
||||
userMCPAuthMap: { sub: { token: 'sub-token' } },
|
||||
tool_resources: { code_interpreter: { file_ids: ['sub-file'] } },
|
||||
actionsEnabled: true,
|
||||
accessibleSkillIds: ['sub-skill-id'],
|
||||
activeSkillNames: ['sub-hidden-skill'],
|
||||
codeEnvAvailable: true,
|
||||
skillAuthoringAvailable: true,
|
||||
fileAuthoringToolNames: ['create_file', 'edit_file'],
|
||||
manualSkillPrimes: [{ name: 'sub-hidden-skill', _id: { toString: () => 'sub-manual-id' } }],
|
||||
alwaysApplySkillPrimes: [
|
||||
{ name: 'sub-always-skill', _id: { toString: () => 'sub-always-id' } },
|
||||
],
|
||||
};
|
||||
|
||||
initializeAgent.mockResolvedValueOnce({
|
||||
id: 'agent-123',
|
||||
model: 'claude-3',
|
||||
model_parameters: {},
|
||||
toolRegistry: new Map(),
|
||||
edges: [{ source: 'agent-123', target: 'agent-sub' }],
|
||||
accessibleSkillIds: ['primary-skill-id'],
|
||||
activeSkillNames: ['primary-skill'],
|
||||
codeEnvAvailable: false,
|
||||
skillAuthoringAvailable: false,
|
||||
fileAuthoringToolNames: [],
|
||||
manualSkillPrimes: [{ name: 'primary-skill', _id: { toString: () => 'primary-skill-id' } }],
|
||||
});
|
||||
discoverConnectedAgents.mockImplementationOnce(async (_params, deps) => {
|
||||
deps.onAgentInitialized('agent-sub', subAgent, subConfig);
|
||||
return {
|
||||
agentConfigs: new Map([['agent-sub', subConfig]]),
|
||||
edges: [],
|
||||
skippedAgentIds: new Set(),
|
||||
userMCPAuthMap: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
await createResponse(req, res);
|
||||
|
||||
const toolExecuteOptions = createToolExecuteHandler.mock.calls.at(-1)[0];
|
||||
await toolExecuteOptions.loadTools(['read_file'], 'agent-sub');
|
||||
|
||||
expect(loadToolsForExecution).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
agent: subAgent,
|
||||
toolRegistry: subConfig.toolRegistry,
|
||||
userMCPAuthMap: subConfig.userMCPAuthMap,
|
||||
tool_resources: subConfig.tool_resources,
|
||||
actionsEnabled: true,
|
||||
}),
|
||||
);
|
||||
expect(mockEnrichWithSkillConfigurable).toHaveBeenLastCalledWith({
|
||||
result: expect.anything(),
|
||||
context: {
|
||||
req,
|
||||
accessibleSkillIds: ['sub-skill-id'],
|
||||
codeEnvAvailable: true,
|
||||
skillPrimedIdsByName: {
|
||||
'sub-always-skill': 'sub-always-id',
|
||||
'sub-hidden-skill': 'sub-manual-id',
|
||||
},
|
||||
activeSkillNames: ['sub-hidden-skill'],
|
||||
skillAuthoringAvailable: true,
|
||||
fileAuthoringToolNames: ['create_file', 'edit_file'],
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,477 @@
|
||||
const { z } = require('zod');
|
||||
const { tool } = require('@langchain/core/tools');
|
||||
const { ChatGenerationChunk } = require('@langchain/core/outputs');
|
||||
const { HumanMessage, AIMessage, AIMessageChunk } = require('@langchain/core/messages');
|
||||
const {
|
||||
Run,
|
||||
Providers,
|
||||
GraphEvents,
|
||||
FakeChatModel,
|
||||
createContentAggregator,
|
||||
} = require('@librechat/agents');
|
||||
const {
|
||||
GenerationJobManager,
|
||||
aggregateEmittedUsage,
|
||||
resolveAgentTokenConfig,
|
||||
buildPersistedContextUsage,
|
||||
} = require('@librechat/api');
|
||||
const { getDefaultHandlers } = require('~/server/controllers/agents/callbacks');
|
||||
|
||||
jest.mock('nanoid', () => ({
|
||||
nanoid: jest.fn(() => 'mock-nanoid'),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/Citations', () => ({
|
||||
processFileCitations: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/Code/process', () => ({
|
||||
processCodeOutput: jest.fn(),
|
||||
runPreviewFinalize: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/process', () => ({
|
||||
saveBase64Image: jest.fn(),
|
||||
}));
|
||||
|
||||
/** Real pipeline guard: published lib versions without the event skip its assertions */
|
||||
const hasContextUsageEvent = GraphEvents.ON_CONTEXT_USAGE != null;
|
||||
|
||||
/**
|
||||
* FakeChatModel that attaches provider-style usage_metadata on a final
|
||||
* empty chunk (the OpenAI streaming pattern), so CHAT_MODEL_END carries
|
||||
* aggregated usage through the real @librechat/agents pipeline.
|
||||
*/
|
||||
class UsageFakeModel extends FakeChatModel {
|
||||
constructor(options, usagePerCall) {
|
||||
super(options);
|
||||
this.usagePerCall = usagePerCall;
|
||||
this.usageCallIndex = 0;
|
||||
}
|
||||
|
||||
async *_streamResponseChunks(messages, options, runManager) {
|
||||
yield* super._streamResponseChunks(messages, options, runManager);
|
||||
const index = Math.min(this.usageCallIndex, this.usagePerCall.length - 1);
|
||||
this.usageCallIndex += 1;
|
||||
yield new ChatGenerationChunk({
|
||||
text: '',
|
||||
message: new AIMessageChunk({ content: '', usage_metadata: this.usagePerCall[index] }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const addTool = tool(async ({ a, b }) => String(a + b), {
|
||||
name: 'add',
|
||||
description: 'Add two numbers',
|
||||
schema: z.object({ a: z.number(), b: z.number() }),
|
||||
});
|
||||
|
||||
const charCounter = (msg) => {
|
||||
const content = msg.content;
|
||||
if (typeof content === 'string') {
|
||||
return content.length + 3;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
let length = 3;
|
||||
for (const part of content) {
|
||||
if (typeof part === 'string') {
|
||||
length += part.length;
|
||||
} else if (typeof part?.text === 'string') {
|
||||
length += part.text.length;
|
||||
}
|
||||
}
|
||||
return length;
|
||||
}
|
||||
return 3;
|
||||
};
|
||||
|
||||
function createMockRes() {
|
||||
const events = [];
|
||||
return {
|
||||
events,
|
||||
headersSent: true,
|
||||
writableEnded: false,
|
||||
write(payload) {
|
||||
for (const line of String(payload).split('\n')) {
|
||||
if (line.startsWith('data: ')) {
|
||||
events.push(JSON.parse(line.slice(6)));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const FIRST_CALL_USAGE = {
|
||||
input_tokens: 100,
|
||||
output_tokens: 20,
|
||||
total_tokens: 120,
|
||||
};
|
||||
|
||||
const SECOND_CALL_USAGE = {
|
||||
input_tokens: 150,
|
||||
output_tokens: 10,
|
||||
total_tokens: 160,
|
||||
input_token_details: { cache_creation: 30, cache_read: 50 },
|
||||
};
|
||||
|
||||
const MAX_CONTEXT_TOKENS = 8000;
|
||||
|
||||
async function runToolLoop({
|
||||
res,
|
||||
streamId = null,
|
||||
collectedUsage,
|
||||
contextUsageSink = null,
|
||||
usageEmitSink = null,
|
||||
usageCost = null,
|
||||
}) {
|
||||
const { contentParts, aggregateContent } = createContentAggregator();
|
||||
const handlers = getDefaultHandlers({
|
||||
res,
|
||||
aggregateContent,
|
||||
toolEndCallback: () => {},
|
||||
collectedUsage,
|
||||
streamId,
|
||||
contextUsageSink,
|
||||
usageEmitSink,
|
||||
usageCost,
|
||||
});
|
||||
|
||||
const run = await Run.create({
|
||||
runId: 'usage-e2e-response',
|
||||
graphConfig: {
|
||||
type: 'standard',
|
||||
llmConfig: {
|
||||
provider: Providers.OPENAI,
|
||||
model: 'gpt-4o-mini',
|
||||
streaming: true,
|
||||
streamUsage: false,
|
||||
},
|
||||
instructions: 'You are a helpful assistant.',
|
||||
maxContextTokens: MAX_CONTEXT_TOKENS,
|
||||
tools: [addTool],
|
||||
},
|
||||
returnContent: true,
|
||||
customHandlers: handlers,
|
||||
tokenCounter: charCounter,
|
||||
indexTokenCountMap: {},
|
||||
});
|
||||
|
||||
run.Graph.overrideModel = new UsageFakeModel(
|
||||
{
|
||||
responses: ['Let me calculate that.', 'The answer is 4.'],
|
||||
toolCalls: [{ name: 'add', args: { a: 2, b: 2 }, id: 'tc_1', type: 'tool_call' }],
|
||||
},
|
||||
[FIRST_CALL_USAGE, SECOND_CALL_USAGE],
|
||||
);
|
||||
|
||||
await run.processStream(
|
||||
{ messages: [new HumanMessage('What is 2+2?')] },
|
||||
{
|
||||
configurable: { thread_id: 'usage-e2e-thread', user_id: 'user-1' },
|
||||
streamMode: 'values',
|
||||
version: 'v2',
|
||||
},
|
||||
);
|
||||
|
||||
return { run, contentParts };
|
||||
}
|
||||
|
||||
describe('usage events through the real agents pipeline', () => {
|
||||
jest.setTimeout(30000);
|
||||
|
||||
afterAll(async () => {
|
||||
await GenerationJobManager.destroy();
|
||||
});
|
||||
|
||||
test('emits on_token_usage per model call with collectedUsage parity', async () => {
|
||||
const res = createMockRes();
|
||||
const collectedUsage = [];
|
||||
const { contentParts } = await runToolLoop({ res, collectedUsage });
|
||||
|
||||
const usageEvents = res.events.filter((e) => e.event === 'on_token_usage');
|
||||
expect(usageEvents).toHaveLength(2);
|
||||
|
||||
expect(usageEvents[0].data).toMatchObject(FIRST_CALL_USAGE);
|
||||
expect(usageEvents[1].data).toMatchObject(SECOND_CALL_USAGE);
|
||||
expect(usageEvents[0].data.provider).toBe(Providers.OPENAI);
|
||||
expect(usageEvents[0].data.model).toBeTruthy();
|
||||
expect(usageEvents[0].data.usage_type).toBeUndefined();
|
||||
|
||||
expect(collectedUsage).toHaveLength(2);
|
||||
expect(collectedUsage[0]).toMatchObject(FIRST_CALL_USAGE);
|
||||
expect(collectedUsage[1]).toMatchObject(SECOND_CALL_USAGE);
|
||||
|
||||
const text = contentParts
|
||||
.filter((part) => part?.type === 'text')
|
||||
.map((part) => part.text)
|
||||
.join('');
|
||||
expect(text).toContain('The answer is 4.');
|
||||
});
|
||||
|
||||
test('emits a context snapshot before each model call', async () => {
|
||||
if (!hasContextUsageEvent) {
|
||||
console.warn('Skipping: installed @librechat/agents predates ON_CONTEXT_USAGE');
|
||||
return;
|
||||
}
|
||||
const res = createMockRes();
|
||||
const { run } = await runToolLoop({ res, collectedUsage: [] });
|
||||
expect(run).toBeDefined();
|
||||
|
||||
const contextEvents = res.events.filter((e) => e.event === 'on_context_usage');
|
||||
expect(contextEvents).toHaveLength(2);
|
||||
|
||||
for (const event of contextEvents) {
|
||||
const { breakdown, contextBudget, remainingContextTokens, effectiveInstructionTokens } =
|
||||
event.data;
|
||||
expect(breakdown.maxContextTokens).toBe(MAX_CONTEXT_TOKENS);
|
||||
expect(contextBudget).toBeGreaterThan(0);
|
||||
expect(contextBudget).toBeLessThanOrEqual(MAX_CONTEXT_TOKENS);
|
||||
expect(effectiveInstructionTokens).toBeGreaterThan(0);
|
||||
expect(remainingContextTokens).toBeGreaterThan(0);
|
||||
expect(remainingContextTokens).toBeLessThan(contextBudget);
|
||||
expect(breakdown.toolTokenCounts.add).toBeGreaterThan(0);
|
||||
}
|
||||
|
||||
/** Tool loop grows the context between calls */
|
||||
expect(contextEvents[1].data.prePruneContextTokens).toBeGreaterThan(
|
||||
contextEvents[0].data.prePruneContextTokens,
|
||||
);
|
||||
|
||||
/** Snapshot precedes the call's usage event */
|
||||
const firstContextIndex = res.events.findIndex((e) => e.event === 'on_context_usage');
|
||||
const firstUsageIndex = res.events.findIndex((e) => e.event === 'on_token_usage');
|
||||
expect(firstContextIndex).toBeGreaterThanOrEqual(0);
|
||||
expect(firstContextIndex).toBeLessThan(firstUsageIndex);
|
||||
});
|
||||
|
||||
test('captures the usage rollup + latest context snapshot for message persistence', () => {
|
||||
const res = createMockRes();
|
||||
const contextUsageSink = { latest: null };
|
||||
const usageEmitSink = [];
|
||||
return runToolLoop({ res, collectedUsage: [], contextUsageSink, usageEmitSink }).then(() => {
|
||||
/** Both model calls' emitted payloads are captured for the rollup */
|
||||
expect(usageEmitSink).toHaveLength(2);
|
||||
|
||||
const usage = aggregateEmittedUsage(usageEmitSink);
|
||||
/** Display units: openAI is cache-subset, so input excludes cache
|
||||
* (150−30−50=70); output is repaired completion */
|
||||
expect(usage).toEqual({
|
||||
input:
|
||||
FIRST_CALL_USAGE.input_tokens +
|
||||
(SECOND_CALL_USAGE.input_tokens -
|
||||
SECOND_CALL_USAGE.input_token_details.cache_creation -
|
||||
SECOND_CALL_USAGE.input_token_details.cache_read),
|
||||
output: FIRST_CALL_USAGE.output_tokens + SECOND_CALL_USAGE.output_tokens,
|
||||
cacheWrite: SECOND_CALL_USAGE.input_token_details.cache_creation,
|
||||
cacheRead: SECOND_CALL_USAGE.input_token_details.cache_read,
|
||||
});
|
||||
/** contextCost off → no cost folded into the rollup */
|
||||
expect(usage.cost).toBeUndefined();
|
||||
|
||||
if (hasContextUsageEvent) {
|
||||
expect(contextUsageSink.latest).not.toBeNull();
|
||||
const persisted = buildPersistedContextUsage(contextUsageSink.latest);
|
||||
expect(persisted.breakdown.maxContextTokens).toBe(MAX_CONTEXT_TOKENS);
|
||||
/** Zero-valued tool counts are trimmed from the persisted blob */
|
||||
for (const count of Object.values(persisted.breakdown.toolTokenCounts ?? {})) {
|
||||
expect(count).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test('folds authoritative per-event cost into the rollup when contextCost is on', async () => {
|
||||
const res = createMockRes();
|
||||
const usageEmitSink = [];
|
||||
/** Stub pricing mirroring getMultiplier/getCacheMultiplier shape */
|
||||
const usageCost = {
|
||||
enabled: true,
|
||||
pricing: {
|
||||
getMultiplier: ({ tokenType }) => (tokenType === 'completion' ? 15 : 3),
|
||||
getCacheMultiplier: ({ cacheType }) => (cacheType === 'write' ? 3.75 : 0.3),
|
||||
},
|
||||
};
|
||||
await runToolLoop({ res, collectedUsage: [], usageEmitSink, usageCost });
|
||||
|
||||
for (const event of usageEmitSink) {
|
||||
expect(typeof event.cost).toBe('number');
|
||||
}
|
||||
const usage = aggregateEmittedUsage(usageEmitSink);
|
||||
expect(usage.cost).toBeGreaterThan(0);
|
||||
expect(usage.cost).toBeCloseTo(usageEmitSink.reduce((sum, e) => sum + e.cost, 0));
|
||||
});
|
||||
|
||||
test('emit path prices each call by its producing agent and strips the agentId tag', () => {
|
||||
const res = createMockRes();
|
||||
const usageEmitSink = [];
|
||||
/** Two endpoints share a model id but bill at different rates. */
|
||||
const primaryConfig = { 'gpt-4': { prompt: 0.01, completion: 0.03, context: 8192 } };
|
||||
const subagentConfig = { 'gpt-4': { prompt: 0.05, completion: 0.15, context: 8192 } };
|
||||
const byAgentId = new Map([
|
||||
['primary', primaryConfig],
|
||||
['sub', subagentConfig],
|
||||
]);
|
||||
const usageCost = {
|
||||
enabled: true,
|
||||
endpointTokenConfig: primaryConfig,
|
||||
pricing: {
|
||||
getMultiplier: ({ tokenType, model, endpointTokenConfig }) =>
|
||||
endpointTokenConfig?.[model]?.[tokenType] ?? 0,
|
||||
getCacheMultiplier: () => 0,
|
||||
},
|
||||
resolveEndpointTokenConfig: (usage) =>
|
||||
resolveAgentTokenConfig({ agentId: usage?.agentId, byAgentId, fallback: primaryConfig }),
|
||||
};
|
||||
|
||||
const { aggregateContent } = createContentAggregator();
|
||||
const handlers = getDefaultHandlers({
|
||||
res,
|
||||
aggregateContent,
|
||||
toolEndCallback: () => {},
|
||||
collectedUsage: [],
|
||||
usageEmitSink,
|
||||
usageCost,
|
||||
});
|
||||
/** The CHAT_MODEL_END handler's emitUsage IS the real emitTokenUsage closure. */
|
||||
const emitUsage = handlers[GraphEvents.CHAT_MODEL_END].emitUsage;
|
||||
const call = { model: 'gpt-4', input_tokens: 100, output_tokens: 50, total_tokens: 150 };
|
||||
emitUsage({ ...call, agentId: 'sub' });
|
||||
emitUsage({ ...call, agentId: 'primary' });
|
||||
|
||||
const events = res.events.filter((e) => e.event === 'on_token_usage');
|
||||
expect(events).toHaveLength(2);
|
||||
/** agentId is an internal pricing tag — never streamed to the client nor
|
||||
* folded into the persisted rollup. */
|
||||
for (const e of events) {
|
||||
expect(e.data.agentId).toBeUndefined();
|
||||
}
|
||||
for (const entry of usageEmitSink) {
|
||||
expect(entry.agentId).toBeUndefined();
|
||||
}
|
||||
/** Same tokens + model id, but the subagent endpoint's higher rates price
|
||||
* its call above the primary — proving per-agent emit pricing. The 5× ratio
|
||||
* ((100·0.05+50·0.15)/(100·0.01+50·0.03)) is scale-independent of credit units. */
|
||||
expect(events[1].data.cost).toBeGreaterThan(0);
|
||||
expect(events[0].data.cost).toBeGreaterThan(events[1].data.cost);
|
||||
expect(events[0].data.cost / events[1].data.cost).toBeCloseTo(5);
|
||||
});
|
||||
|
||||
test('persists usage and context snapshot for resume via GenerationJobManager', async () => {
|
||||
const streamId = `usage-e2e-stream-${Date.now()}`;
|
||||
await GenerationJobManager.createJob(streamId, 'user-1', 'convo-1');
|
||||
|
||||
const res = createMockRes();
|
||||
await runToolLoop({ res, streamId, collectedUsage: [] });
|
||||
|
||||
const resumeState = await GenerationJobManager.getResumeState(streamId);
|
||||
expect(resumeState).not.toBeNull();
|
||||
|
||||
expect(resumeState.collectedUsage).toHaveLength(2);
|
||||
expect(resumeState.collectedUsage[0]).toMatchObject(FIRST_CALL_USAGE);
|
||||
expect(resumeState.collectedUsage[1]).toMatchObject(SECOND_CALL_USAGE);
|
||||
|
||||
if (hasContextUsageEvent) {
|
||||
expect(resumeState.contextUsage.breakdown.maxContextTokens).toBe(MAX_CONTEXT_TOKENS);
|
||||
/** Latest-wins: the persisted snapshot is the second call's */
|
||||
expect(resumeState.contextUsage.prePruneContextTokens).toBeGreaterThan(0);
|
||||
/** Reconciled to the final primary call's actual prompt: openAI folds cache
|
||||
* into input_tokens (150), so the resume snapshot's used = 150 — the real
|
||||
* context, not the calibrated estimate. */
|
||||
const used =
|
||||
resumeState.contextUsage.contextBudget - resumeState.contextUsage.remainingContextTokens;
|
||||
expect(used).toBe(SECOND_CALL_USAGE.input_tokens);
|
||||
}
|
||||
});
|
||||
|
||||
/** Drives a real summarization (tight context + padded history); self-summarize
|
||||
* reuses the overridden fake model so no API key is needed. */
|
||||
async function runSummarizationLoop({ res, collectedUsage, contextUsageSink, usageEmitSink }) {
|
||||
const { aggregateContent } = createContentAggregator();
|
||||
const handlers = getDefaultHandlers({
|
||||
res,
|
||||
aggregateContent,
|
||||
toolEndCallback: () => {},
|
||||
collectedUsage,
|
||||
contextUsageSink,
|
||||
usageEmitSink,
|
||||
summarizationOptions: { enabled: true },
|
||||
});
|
||||
|
||||
const pad = 'context detail to overflow the tiny budget. '.repeat(40);
|
||||
const history = [
|
||||
new HumanMessage(`Turn 1 question. ${pad}`),
|
||||
new AIMessage(`Turn 1 answer. ${pad}`),
|
||||
new HumanMessage(`Turn 2 question. ${pad}`),
|
||||
new AIMessage(`Turn 2 answer. ${pad}`),
|
||||
new HumanMessage(`Final question after a lot of prior history. ${pad}`),
|
||||
];
|
||||
const indexTokenCountMap = {};
|
||||
history.forEach((message, i) => {
|
||||
indexTokenCountMap[i] = charCounter(message);
|
||||
});
|
||||
|
||||
const run = await Run.create({
|
||||
runId: `summ-e2e-${Date.now()}`,
|
||||
graphConfig: {
|
||||
type: 'standard',
|
||||
llmConfig: {
|
||||
provider: Providers.OPENAI,
|
||||
model: 'gpt-4o-mini',
|
||||
streaming: true,
|
||||
streamUsage: false,
|
||||
},
|
||||
instructions: 'You are a helpful assistant.',
|
||||
maxContextTokens: 700,
|
||||
summarizationEnabled: true,
|
||||
summarizationConfig: { provider: Providers.OPENAI, model: 'gpt-4o-mini' },
|
||||
},
|
||||
returnContent: true,
|
||||
customHandlers: handlers,
|
||||
tokenCounter: charCounter,
|
||||
indexTokenCountMap,
|
||||
});
|
||||
|
||||
run.Graph.overrideModel = new UsageFakeModel(
|
||||
{ responses: ['## Summary\nPrior turns compacted.', 'Here is the final answer.'] },
|
||||
[{ input_tokens: 40, output_tokens: 8, total_tokens: 48 }],
|
||||
);
|
||||
|
||||
await run.processStream(
|
||||
{ messages: history },
|
||||
{
|
||||
configurable: { thread_id: 'summ-e2e-thread', user_id: 'user-1' },
|
||||
streamMode: 'values',
|
||||
version: 'v2',
|
||||
},
|
||||
);
|
||||
return run;
|
||||
}
|
||||
|
||||
/** A summarized turn compacts the context (summary tokens replace the older
|
||||
* turns) and the reduced snapshot is persisted — the latest snapshot is
|
||||
* followed by a primary usage, so the save guard keeps it and the client
|
||||
* uses the snapshot (not the inflated whole-history estimate). */
|
||||
test('persists the reduced (compacted) snapshot after summarization', async () => {
|
||||
if (!hasContextUsageEvent) {
|
||||
return;
|
||||
}
|
||||
const res = createMockRes();
|
||||
const contextUsageSink = { latest: null, count: 0 };
|
||||
const usageEmitSink = [];
|
||||
await runSummarizationLoop({ res, collectedUsage: [], contextUsageSink, usageEmitSink });
|
||||
|
||||
const snapshot = contextUsageSink.latest;
|
||||
/** Summarization fired: a summary exists and the kept message tokens are
|
||||
* small (the compacted context, not the full history). */
|
||||
expect(snapshot?.breakdown?.summaryTokens).toBeGreaterThan(0);
|
||||
expect(snapshot?.breakdown?.messageTokens).toBeLessThan(snapshot?.breakdown?.summaryTokens);
|
||||
|
||||
/** The save guard keeps it: a primary usage follows the latest snapshot. */
|
||||
const afterLatest = usageEmitSink.slice(contextUsageSink.latestUsageIndex ?? 0);
|
||||
expect(afterLatest.some((e) => e.usage_type == null)).toBe(true);
|
||||
expect(
|
||||
buildPersistedContextUsage(snapshot, usageEmitSink).breakdown.summaryTokens,
|
||||
).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,161 @@
|
||||
/**
|
||||
* Live host-layer verification: real Anthropic run through the actual
|
||||
* getDefaultHandlers pipeline, asserting the SSE usage/context events the
|
||||
* client consumes and their resume persistence.
|
||||
*
|
||||
* Run with:
|
||||
* RUN_USAGE_LIVE_TESTS=1 ANTHROPIC_API_KEY=... npx jest usageEvents.live --runInBand
|
||||
*/
|
||||
const { HumanMessage } = require('@langchain/core/messages');
|
||||
const { Run, Providers, GraphEvents } = require('@librechat/agents');
|
||||
const { GenerationJobManager } = require('@librechat/api');
|
||||
const { getDefaultHandlers } = require('~/server/controllers/agents/callbacks');
|
||||
|
||||
jest.mock('nanoid', () => ({
|
||||
nanoid: jest.fn(() => 'mock-nanoid'),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/Citations', () => ({
|
||||
processFileCitations: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/Code/process', () => ({
|
||||
processCodeOutput: jest.fn(),
|
||||
runPreviewFinalize: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/process', () => ({
|
||||
saveBase64Image: jest.fn(),
|
||||
}));
|
||||
|
||||
const shouldRunLive =
|
||||
process.env.RUN_USAGE_LIVE_TESTS === '1' &&
|
||||
process.env.ANTHROPIC_API_KEY != null &&
|
||||
process.env.ANTHROPIC_API_KEY !== '';
|
||||
|
||||
const describeIfLive = shouldRunLive ? describe : describe.skip;
|
||||
const modelName = process.env.ANTHROPIC_USAGE_LIVE_MODEL ?? 'claude-haiku-4-5';
|
||||
const hasContextUsageEvent = GraphEvents.ON_CONTEXT_USAGE != null;
|
||||
|
||||
const charCounter = (msg) => {
|
||||
const content = msg.content;
|
||||
if (typeof content === 'string') {
|
||||
return Math.ceil(content.length / 4) + 3;
|
||||
}
|
||||
if (Array.isArray(content)) {
|
||||
let length = 3;
|
||||
for (const part of content) {
|
||||
if (typeof part === 'string') {
|
||||
length += Math.ceil(part.length / 4);
|
||||
} else if (typeof part?.text === 'string') {
|
||||
length += Math.ceil(part.text.length / 4);
|
||||
}
|
||||
}
|
||||
return length;
|
||||
}
|
||||
return 3;
|
||||
};
|
||||
|
||||
function createMockRes() {
|
||||
const events = [];
|
||||
return {
|
||||
events,
|
||||
headersSent: true,
|
||||
writableEnded: false,
|
||||
write(payload) {
|
||||
for (const line of String(payload).split('\n')) {
|
||||
if (line.startsWith('data: ')) {
|
||||
events.push(JSON.parse(line.slice(6)));
|
||||
}
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describeIfLive('live usage events through the host pipeline', () => {
|
||||
jest.setTimeout(120000);
|
||||
|
||||
afterAll(async () => {
|
||||
await GenerationJobManager.destroy();
|
||||
});
|
||||
|
||||
test('streams real provider usage and persists it for resume', async () => {
|
||||
const streamId = `usage-live-${Date.now()}`;
|
||||
await GenerationJobManager.createJob(streamId, 'user-live', 'convo-live');
|
||||
|
||||
/** streamId mode routes events through the job emitter — capture them
|
||||
* as a subscribed resumable client would, not via res.write */
|
||||
const res = createMockRes();
|
||||
await GenerationJobManager.subscribe(streamId, (event) => {
|
||||
res.events.push(event);
|
||||
});
|
||||
const collectedUsage = [];
|
||||
const handlers = getDefaultHandlers({
|
||||
res,
|
||||
aggregateContent: () => {},
|
||||
toolEndCallback: () => {},
|
||||
collectedUsage,
|
||||
streamId,
|
||||
});
|
||||
|
||||
const run = await Run.create({
|
||||
runId: 'usage-live-response',
|
||||
graphConfig: {
|
||||
type: 'standard',
|
||||
llmConfig: {
|
||||
provider: Providers.ANTHROPIC,
|
||||
model: modelName,
|
||||
apiKey: process.env.ANTHROPIC_API_KEY,
|
||||
temperature: 0,
|
||||
maxTokens: 64,
|
||||
streaming: true,
|
||||
streamUsage: true,
|
||||
},
|
||||
instructions: 'You are concise. Reply with one short sentence.',
|
||||
maxContextTokens: 8000,
|
||||
},
|
||||
returnContent: true,
|
||||
customHandlers: handlers,
|
||||
tokenCounter: charCounter,
|
||||
indexTokenCountMap: {},
|
||||
});
|
||||
|
||||
await run.processStream(
|
||||
{ messages: [new HumanMessage('Say hello in five words or fewer.')] },
|
||||
{
|
||||
configurable: { thread_id: 'usage-live-thread', user_id: 'user-live' },
|
||||
streamMode: 'values',
|
||||
version: 'v2',
|
||||
},
|
||||
);
|
||||
|
||||
const usageEvents = res.events.filter((e) => e.event === 'on_token_usage');
|
||||
expect(usageEvents).toHaveLength(1);
|
||||
const usage = usageEvents[0].data;
|
||||
expect(usage.input_tokens).toBeGreaterThan(0);
|
||||
expect(usage.output_tokens).toBeGreaterThan(0);
|
||||
expect(usage.provider).toBe(Providers.ANTHROPIC);
|
||||
expect(usage.model).toBe(modelName);
|
||||
expect(collectedUsage).toHaveLength(1);
|
||||
expect(usage.input_tokens).toBe(collectedUsage[0].input_tokens);
|
||||
|
||||
if (hasContextUsageEvent) {
|
||||
const contextEvents = res.events.filter((e) => e.event === 'on_context_usage');
|
||||
expect(contextEvents).toHaveLength(1);
|
||||
const snapshot = contextEvents[0].data;
|
||||
expect(snapshot.breakdown.maxContextTokens).toBe(8000);
|
||||
const estimatedUsed = snapshot.contextBudget - snapshot.remainingContextTokens;
|
||||
expect(estimatedUsed).toBeGreaterThan(0);
|
||||
expect(estimatedUsed / usage.input_tokens).toBeGreaterThan(0.2);
|
||||
expect(estimatedUsed / usage.input_tokens).toBeLessThan(5);
|
||||
}
|
||||
|
||||
const resumeState = await GenerationJobManager.getResumeState(streamId);
|
||||
expect(resumeState.collectedUsage).toHaveLength(1);
|
||||
expect(resumeState.collectedUsage[0].input_tokens).toBe(usage.input_tokens);
|
||||
if (hasContextUsageEvent) {
|
||||
expect(resumeState.contextUsage.breakdown.maxContextTokens).toBe(8000);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
jest.mock('~/server/services/PermissionService', () => ({
|
||||
findPubliclyAccessibleResources: jest.fn(),
|
||||
findAccessibleResources: jest.fn(),
|
||||
hasPublicPermission: jest.fn(),
|
||||
grantPermission: jest.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
getCachedTools: jest.fn(),
|
||||
getMCPServerTools: jest.fn(),
|
||||
}));
|
||||
|
||||
const mongoose = require('mongoose');
|
||||
const { actionDelimiter } = require('librechat-data-provider');
|
||||
const { agentSchema, actionSchema } = require('@librechat/data-schemas');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
const { duplicateAgent } = require('../v1');
|
||||
|
||||
let mongoServer;
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
const mongoUri = mongoServer.getUri();
|
||||
if (!mongoose.models.Agent) {
|
||||
mongoose.model('Agent', agentSchema);
|
||||
}
|
||||
if (!mongoose.models.Action) {
|
||||
mongoose.model('Action', actionSchema);
|
||||
}
|
||||
await mongoose.connect(mongoUri);
|
||||
}, 20000);
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
await mongoServer.stop();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await mongoose.models.Agent.deleteMany({});
|
||||
await mongoose.models.Action.deleteMany({});
|
||||
});
|
||||
|
||||
describe('duplicateAgentHandler — action domain extraction', () => {
|
||||
it('builds duplicated action entries using metadata.domain, not action_id', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const originalAgentId = `agent_original`;
|
||||
|
||||
const agent = await mongoose.models.Agent.create({
|
||||
id: originalAgentId,
|
||||
name: 'Test Agent',
|
||||
author: userId.toString(),
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
tools: [],
|
||||
actions: [`api.example.com${actionDelimiter}act_original`],
|
||||
versions: [{ name: 'Test Agent', createdAt: new Date(), updatedAt: new Date() }],
|
||||
});
|
||||
|
||||
await mongoose.models.Action.create({
|
||||
user: userId,
|
||||
action_id: 'act_original',
|
||||
agent_id: originalAgentId,
|
||||
metadata: { domain: 'api.example.com' },
|
||||
});
|
||||
|
||||
const req = {
|
||||
params: { id: agent.id },
|
||||
user: { id: userId.toString() },
|
||||
};
|
||||
const res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn(),
|
||||
};
|
||||
|
||||
await duplicateAgent(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(201);
|
||||
|
||||
const { agent: newAgent, actions: newActions } = res.json.mock.calls[0][0];
|
||||
|
||||
expect(newAgent.id).not.toBe(originalAgentId);
|
||||
expect(String(newAgent.author)).toBe(userId.toString());
|
||||
expect(newActions).toHaveLength(1);
|
||||
expect(newActions[0].metadata.domain).toBe('api.example.com');
|
||||
expect(newActions[0].agent_id).toBe(newAgent.id);
|
||||
|
||||
for (const actionEntry of newAgent.actions) {
|
||||
const [domain, actionId] = actionEntry.split(actionDelimiter);
|
||||
expect(domain).toBe('api.example.com');
|
||||
expect(actionId).toBeTruthy();
|
||||
expect(actionId).not.toBe('act_original');
|
||||
}
|
||||
|
||||
const allActions = await mongoose.models.Action.find({}).lean();
|
||||
expect(allActions).toHaveLength(2);
|
||||
|
||||
const originalAction = allActions.find((a) => a.action_id === 'act_original');
|
||||
expect(originalAction.agent_id).toBe(originalAgentId);
|
||||
|
||||
const duplicatedAction = allActions.find((a) => a.action_id !== 'act_original');
|
||||
expect(duplicatedAction.agent_id).toBe(newAgent.id);
|
||||
expect(duplicatedAction.metadata.domain).toBe('api.example.com');
|
||||
});
|
||||
|
||||
it('strips sensitive metadata fields from duplicated actions', async () => {
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const originalAgentId = 'agent_sensitive';
|
||||
|
||||
await mongoose.models.Agent.create({
|
||||
id: originalAgentId,
|
||||
name: 'Sensitive Agent',
|
||||
author: userId.toString(),
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
tools: [],
|
||||
actions: [`secure.api.com${actionDelimiter}act_secret`],
|
||||
versions: [{ name: 'Sensitive Agent', createdAt: new Date(), updatedAt: new Date() }],
|
||||
});
|
||||
|
||||
await mongoose.models.Action.create({
|
||||
user: userId,
|
||||
action_id: 'act_secret',
|
||||
agent_id: originalAgentId,
|
||||
metadata: {
|
||||
domain: 'secure.api.com',
|
||||
api_key: 'sk-secret-key-12345',
|
||||
oauth_client_id: 'client_id_xyz',
|
||||
oauth_client_secret: 'client_secret_xyz',
|
||||
},
|
||||
});
|
||||
|
||||
const req = {
|
||||
params: { id: originalAgentId },
|
||||
user: { id: userId.toString() },
|
||||
};
|
||||
const res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn(),
|
||||
};
|
||||
|
||||
await duplicateAgent(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(201);
|
||||
|
||||
const duplicatedAction = await mongoose.models.Action.findOne({
|
||||
agent_id: { $ne: originalAgentId },
|
||||
}).lean();
|
||||
|
||||
expect(duplicatedAction.metadata.domain).toBe('secure.api.com');
|
||||
expect(duplicatedAction.metadata.api_key).toBeUndefined();
|
||||
expect(duplicatedAction.metadata.oauth_client_id).toBeUndefined();
|
||||
expect(duplicatedAction.metadata.oauth_client_secret).toBeUndefined();
|
||||
|
||||
const originalAction = await mongoose.models.Action.findOne({
|
||||
action_id: 'act_secret',
|
||||
}).lean();
|
||||
expect(originalAction.metadata.api_key).toBe('sk-secret-key-12345');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,193 @@
|
||||
const { duplicateAgent } = require('../v1');
|
||||
const { getAgent, createAgent, getActions } = require('~/models');
|
||||
const { nanoid } = require('nanoid');
|
||||
|
||||
jest.mock('~/models');
|
||||
jest.mock('nanoid');
|
||||
|
||||
describe('duplicateAgent', () => {
|
||||
let req, res;
|
||||
|
||||
beforeEach(() => {
|
||||
req = {
|
||||
params: { id: 'agent_123' },
|
||||
user: { id: 'user_456' },
|
||||
};
|
||||
res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn(),
|
||||
};
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should duplicate an agent successfully', async () => {
|
||||
const mockAgent = {
|
||||
id: 'agent_123',
|
||||
name: 'Test Agent',
|
||||
description: 'Test Description',
|
||||
instructions: 'Test Instructions',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
tools: ['file_search'],
|
||||
actions: [],
|
||||
author: 'user_789',
|
||||
versions: [{ name: 'Test Agent', version: 1 }],
|
||||
__v: 0,
|
||||
};
|
||||
|
||||
const mockNewAgent = {
|
||||
id: 'agent_new_123',
|
||||
name: 'Test Agent (1/2/23, 12:34)',
|
||||
description: 'Test Description',
|
||||
instructions: 'Test Instructions',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
tools: ['file_search'],
|
||||
actions: [],
|
||||
author: 'user_456',
|
||||
versions: [
|
||||
{
|
||||
name: 'Test Agent (1/2/23, 12:34)',
|
||||
description: 'Test Description',
|
||||
instructions: 'Test Instructions',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
tools: ['file_search'],
|
||||
actions: [],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
getAgent.mockResolvedValue(mockAgent);
|
||||
getActions.mockResolvedValue([]);
|
||||
nanoid.mockReturnValue('new_123');
|
||||
createAgent.mockResolvedValue(mockNewAgent);
|
||||
|
||||
await duplicateAgent(req, res);
|
||||
|
||||
expect(getAgent).toHaveBeenCalledWith({ id: 'agent_123' });
|
||||
expect(getActions).toHaveBeenCalledWith({ agent_id: 'agent_123' }, true);
|
||||
expect(createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'agent_new_123',
|
||||
author: 'user_456',
|
||||
name: expect.stringContaining('Test Agent ('),
|
||||
description: 'Test Description',
|
||||
instructions: 'Test Instructions',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
tools: ['file_search'],
|
||||
actions: [],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(createAgent).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({
|
||||
versions: expect.anything(),
|
||||
__v: expect.anything(),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(201);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
agent: mockNewAgent,
|
||||
actions: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should ensure duplicated agent has clean versions array without nested fields', async () => {
|
||||
const mockAgent = {
|
||||
id: 'agent_123',
|
||||
name: 'Test Agent',
|
||||
description: 'Test Description',
|
||||
versions: [
|
||||
{
|
||||
name: 'Test Agent',
|
||||
versions: [{ name: 'Nested' }],
|
||||
__v: 1,
|
||||
},
|
||||
],
|
||||
__v: 2,
|
||||
};
|
||||
|
||||
const mockNewAgent = {
|
||||
id: 'agent_new_123',
|
||||
name: 'Test Agent (1/2/23, 12:34)',
|
||||
description: 'Test Description',
|
||||
versions: [
|
||||
{
|
||||
name: 'Test Agent (1/2/23, 12:34)',
|
||||
description: 'Test Description',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
getAgent.mockResolvedValue(mockAgent);
|
||||
getActions.mockResolvedValue([]);
|
||||
nanoid.mockReturnValue('new_123');
|
||||
createAgent.mockResolvedValue(mockNewAgent);
|
||||
|
||||
await duplicateAgent(req, res);
|
||||
|
||||
expect(mockNewAgent.versions).toHaveLength(1);
|
||||
|
||||
const firstVersion = mockNewAgent.versions[0];
|
||||
expect(firstVersion).not.toHaveProperty('versions');
|
||||
expect(firstVersion).not.toHaveProperty('__v');
|
||||
|
||||
expect(mockNewAgent).not.toHaveProperty('__v');
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(201);
|
||||
});
|
||||
|
||||
it('should return 404 if agent not found', async () => {
|
||||
getAgent.mockResolvedValue(null);
|
||||
|
||||
await duplicateAgent(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Agent not found',
|
||||
status: 'error',
|
||||
});
|
||||
});
|
||||
|
||||
it('should convert `tool_resources.ocr` to `tool_resources.context`', async () => {
|
||||
const mockAgent = {
|
||||
id: 'agent_123',
|
||||
name: 'Test Agent',
|
||||
tool_resources: {
|
||||
ocr: { enabled: true, config: 'test' },
|
||||
other: { should: 'not be copied' },
|
||||
},
|
||||
};
|
||||
|
||||
getAgent.mockResolvedValue(mockAgent);
|
||||
getActions.mockResolvedValue([]);
|
||||
nanoid.mockReturnValue('new_123');
|
||||
createAgent.mockResolvedValue({ id: 'agent_new_123' });
|
||||
|
||||
await duplicateAgent(req, res);
|
||||
|
||||
expect(createAgent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tool_resources: {
|
||||
context: { enabled: true, config: 'test' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
getAgent.mockRejectedValue(new Error('Database error'));
|
||||
|
||||
await duplicateAgent(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({ error: 'Database error' });
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,141 @@
|
||||
// errorHandler.js
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { CacheKeys, ViolationTypes } = require('librechat-data-provider');
|
||||
const { sendResponse } = require('~/server/middleware/error');
|
||||
const { recordUsage } = require('~/server/services/Threads');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const { getConvo } = require('~/models');
|
||||
|
||||
/**
|
||||
* @typedef {Object} ErrorHandlerContext
|
||||
* @property {OpenAIClient} openai - The OpenAI client
|
||||
* @property {string} run_id - The run ID
|
||||
* @property {boolean} completedRun - Whether the run has completed
|
||||
* @property {string} assistant_id - The assistant ID
|
||||
* @property {string} conversationId - The conversation ID
|
||||
* @property {string} parentMessageId - The parent message ID
|
||||
* @property {string} responseMessageId - The response message ID
|
||||
* @property {string} endpoint - The endpoint being used
|
||||
* @property {string} cacheKey - The cache key for the current request
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ErrorHandlerDependencies
|
||||
* @property {ServerRequest} req - The Express request object
|
||||
* @property {Express.Response} res - The Express response object
|
||||
* @property {() => ErrorHandlerContext} getContext - Function to get the current context
|
||||
* @property {string} [originPath] - The origin path for the error handler
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates an error handler function with the given dependencies
|
||||
* @param {ErrorHandlerDependencies} dependencies - The dependencies for the error handler
|
||||
* @returns {(error: Error) => Promise<void>} The error handler function
|
||||
*/
|
||||
const createErrorHandler = ({ req, res, getContext, originPath = '/assistants/chat/' }) => {
|
||||
const cache = getLogStores(CacheKeys.ABORT_KEYS);
|
||||
|
||||
/**
|
||||
* Handles errors that occur during the chat process
|
||||
* @param {Error} error - The error that occurred
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
return async (error) => {
|
||||
const {
|
||||
openai,
|
||||
run_id,
|
||||
endpoint,
|
||||
cacheKey,
|
||||
completedRun,
|
||||
assistant_id,
|
||||
conversationId,
|
||||
parentMessageId,
|
||||
responseMessageId,
|
||||
} = getContext();
|
||||
|
||||
const defaultErrorMessage =
|
||||
'The Assistant run failed to initialize. Try sending a message in a new conversation.';
|
||||
const messageData = {
|
||||
assistant_id,
|
||||
conversationId,
|
||||
parentMessageId,
|
||||
sender: 'System',
|
||||
user: req.user.id,
|
||||
shouldSaveMessage: false,
|
||||
messageId: responseMessageId,
|
||||
endpoint,
|
||||
};
|
||||
|
||||
if (error.message === 'Run cancelled') {
|
||||
return res.end();
|
||||
} else if (error.message === 'Request closed' && completedRun) {
|
||||
return;
|
||||
} else if (error.message === 'Request closed') {
|
||||
logger.debug(`[${originPath}] Request aborted on close`);
|
||||
} else if (/Files.*are invalid/.test(error.message)) {
|
||||
const errorMessage = `Files are invalid, or may not have uploaded yet.${
|
||||
endpoint === 'azureAssistants'
|
||||
? " If using Azure OpenAI, files are only available in the region of the assistant's model at the time of upload."
|
||||
: ''
|
||||
}`;
|
||||
return sendResponse(req, res, messageData, errorMessage);
|
||||
} else if (error?.message?.includes('string too long')) {
|
||||
return sendResponse(
|
||||
req,
|
||||
res,
|
||||
messageData,
|
||||
'Message too long. The Assistants API has a limit of 32,768 characters per message. Please shorten it and try again.',
|
||||
);
|
||||
} else if (error?.message?.includes(ViolationTypes.TOKEN_BALANCE)) {
|
||||
return sendResponse(req, res, messageData, error.message);
|
||||
} else {
|
||||
logger.error(`[${originPath}]`, error);
|
||||
}
|
||||
|
||||
if (!openai || !run_id) {
|
||||
return sendResponse(req, res, messageData, defaultErrorMessage);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
try {
|
||||
const status = await cache.get(cacheKey);
|
||||
if (status === 'cancelled') {
|
||||
logger.debug(`[${originPath}] Run already cancelled`);
|
||||
return res.end();
|
||||
}
|
||||
await cache.delete(cacheKey);
|
||||
} catch (error) {
|
||||
logger.error(`[${originPath}] Error cancelling run`, error);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
let run;
|
||||
try {
|
||||
await recordUsage({
|
||||
...run.usage,
|
||||
model: run.model,
|
||||
user: req.user.id,
|
||||
conversationId,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`[${originPath}] Error fetching or processing run`, error);
|
||||
}
|
||||
|
||||
let finalEvent;
|
||||
try {
|
||||
finalEvent = {
|
||||
final: true,
|
||||
conversation: await getConvo(req.user.id, conversationId),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`[${originPath}] Error finalizing error process`, error);
|
||||
return sendResponse(req, res, messageData, 'The Assistant run failed');
|
||||
}
|
||||
|
||||
return sendResponse(req, res, finalEvent);
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = { createErrorHandler };
|
||||
@@ -0,0 +1,923 @@
|
||||
const mongoose = require('mongoose');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const { Constants, actionDelimiter } = require('librechat-data-provider');
|
||||
const { agentSchema } = require('@librechat/data-schemas');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
|
||||
const d = Constants.mcp_delimiter;
|
||||
|
||||
const mockGetAllServerConfigs = jest.fn();
|
||||
const mockUserCanUseMCPServers = jest.fn();
|
||||
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
getCachedTools: jest.fn().mockResolvedValue({
|
||||
web_search: true,
|
||||
execute_code: true,
|
||||
file_search: true,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('~/config', () => ({
|
||||
getMCPServersRegistry: jest.fn(() => ({
|
||||
getAllServerConfigs: mockGetAllServerConfigs,
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/MCP', () => ({
|
||||
resolveConfigServers: jest.fn().mockResolvedValue({}),
|
||||
createMCPPermissionContext: jest.fn((req) => ({
|
||||
canUseServers: (user) => mockUserCanUseMCPServers(user, req),
|
||||
})),
|
||||
userCanUseMCPServers: (...args) => mockUserCanUseMCPServers(...args),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/strategies', () => ({
|
||||
getStrategyFunctions: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/images/avatar', () => ({
|
||||
resizeAvatar: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/process', () => ({
|
||||
filterFile: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/PermissionService', () => ({
|
||||
findAccessibleResources: jest.fn().mockResolvedValue([]),
|
||||
findPubliclyAccessibleResources: jest.fn().mockResolvedValue([]),
|
||||
grantPermission: jest.fn(),
|
||||
hasPublicPermission: jest.fn().mockResolvedValue(false),
|
||||
checkPermission: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
jest.mock('~/models', () => {
|
||||
const mongoose = require('mongoose');
|
||||
const { createModels, createMethods } = require('@librechat/data-schemas');
|
||||
createModels(mongoose);
|
||||
const methods = createMethods(mongoose);
|
||||
return {
|
||||
...methods,
|
||||
getCategoriesWithCounts: jest.fn(),
|
||||
deleteFileByFilter: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('~/cache', () => ({
|
||||
getLogStores: jest.fn(() => ({
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
const {
|
||||
filterAuthorizedTools,
|
||||
createAgent: createAgentHandler,
|
||||
updateAgent: updateAgentHandler,
|
||||
duplicateAgent: duplicateAgentHandler,
|
||||
revertAgentVersion: revertAgentVersionHandler,
|
||||
} = require('./v1');
|
||||
|
||||
const { getMCPServersRegistry } = require('~/config');
|
||||
|
||||
let Agent;
|
||||
|
||||
describe('MCP Tool Authorization', () => {
|
||||
let mongoServer;
|
||||
let mockReq;
|
||||
let mockRes;
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
const mongoUri = mongoServer.getUri();
|
||||
await mongoose.connect(mongoUri);
|
||||
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
|
||||
}, 20000);
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
await mongoServer.stop();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await Agent.deleteMany({});
|
||||
jest.clearAllMocks();
|
||||
|
||||
getMCPServersRegistry.mockImplementation(() => ({
|
||||
getAllServerConfigs: mockGetAllServerConfigs,
|
||||
}));
|
||||
mockGetAllServerConfigs.mockResolvedValue({
|
||||
authorizedServer: { type: 'sse', url: 'https://authorized.example.com' },
|
||||
anotherServer: { type: 'sse', url: 'https://another.example.com' },
|
||||
});
|
||||
mockUserCanUseMCPServers.mockResolvedValue(true);
|
||||
|
||||
mockReq = {
|
||||
user: {
|
||||
id: new mongoose.Types.ObjectId().toString(),
|
||||
role: 'USER',
|
||||
},
|
||||
body: {},
|
||||
params: {},
|
||||
query: {},
|
||||
app: { locals: { fileStrategy: 'local' } },
|
||||
};
|
||||
|
||||
mockRes = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('filterAuthorizedTools', () => {
|
||||
const availableTools = { web_search: true, custom_tool: true };
|
||||
const userId = 'test-user-123';
|
||||
const testUser = { id: userId, role: 'USER' };
|
||||
|
||||
test('should keep authorized MCP tools and strip unauthorized ones', async () => {
|
||||
const result = await filterAuthorizedTools({
|
||||
tools: [`toolA${d}authorizedServer`, `toolB${d}forbiddenServer`, 'web_search'],
|
||||
userId,
|
||||
user: testUser,
|
||||
availableTools,
|
||||
});
|
||||
|
||||
expect(result).toContain(`toolA${d}authorizedServer`);
|
||||
expect(result).toContain('web_search');
|
||||
expect(result).not.toContain(`toolB${d}forbiddenServer`);
|
||||
});
|
||||
|
||||
test('should strip MCP tools when user lacks MCP server use permission', async () => {
|
||||
mockUserCanUseMCPServers.mockResolvedValue(false);
|
||||
|
||||
const result = await filterAuthorizedTools({
|
||||
tools: [
|
||||
`toolA${d}authorizedServer`,
|
||||
`${Constants.mcp_all}${d}authorizedServer`,
|
||||
'web_search',
|
||||
],
|
||||
userId,
|
||||
user: testUser,
|
||||
availableTools,
|
||||
});
|
||||
|
||||
expect(result).toEqual(['web_search']);
|
||||
expect(mockUserCanUseMCPServers).toHaveBeenCalledWith({ id: userId, role: 'USER' });
|
||||
expect(mockGetAllServerConfigs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should strip MCP tools when user context is missing', async () => {
|
||||
mockUserCanUseMCPServers.mockResolvedValueOnce(false);
|
||||
|
||||
const result = await filterAuthorizedTools({
|
||||
tools: [`toolA${d}authorizedServer`, 'web_search'],
|
||||
userId,
|
||||
availableTools,
|
||||
});
|
||||
|
||||
expect(result).toEqual(['web_search']);
|
||||
expect(mockUserCanUseMCPServers).toHaveBeenCalledWith(undefined);
|
||||
expect(mockGetAllServerConfigs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should keep system tools without querying MCP registry', async () => {
|
||||
const result = await filterAuthorizedTools({
|
||||
tools: ['execute_code', 'file_search', 'web_search', 'memory'],
|
||||
userId,
|
||||
availableTools: {},
|
||||
});
|
||||
|
||||
expect(result).toEqual(['execute_code', 'file_search', 'web_search', 'memory']);
|
||||
expect(mockGetAllServerConfigs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should not query MCP registry when no MCP tools are present', async () => {
|
||||
const result = await filterAuthorizedTools({
|
||||
tools: ['web_search', 'custom_tool'],
|
||||
userId,
|
||||
availableTools,
|
||||
});
|
||||
|
||||
expect(result).toEqual(['web_search', 'custom_tool']);
|
||||
expect(mockGetAllServerConfigs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should filter all MCP tools when registry is uninitialized', async () => {
|
||||
getMCPServersRegistry.mockImplementation(() => {
|
||||
throw new Error('MCPServersRegistry has not been initialized.');
|
||||
});
|
||||
|
||||
const result = await filterAuthorizedTools({
|
||||
tools: [`toolA${d}someServer`, 'web_search'],
|
||||
userId,
|
||||
user: testUser,
|
||||
availableTools,
|
||||
});
|
||||
|
||||
expect(result).toEqual(['web_search']);
|
||||
expect(result).not.toContain(`toolA${d}someServer`);
|
||||
});
|
||||
|
||||
test('should handle mixed authorized and unauthorized MCP tools', async () => {
|
||||
const result = await filterAuthorizedTools({
|
||||
tools: [
|
||||
'web_search',
|
||||
`search${d}authorizedServer`,
|
||||
`attack${d}victimServer`,
|
||||
'execute_code',
|
||||
`list${d}anotherServer`,
|
||||
`steal${d}nonexistent`,
|
||||
],
|
||||
userId,
|
||||
user: testUser,
|
||||
availableTools,
|
||||
});
|
||||
|
||||
expect(result).toEqual([
|
||||
'web_search',
|
||||
`search${d}authorizedServer`,
|
||||
'execute_code',
|
||||
`list${d}anotherServer`,
|
||||
]);
|
||||
});
|
||||
|
||||
test('should handle empty tools array', async () => {
|
||||
const result = await filterAuthorizedTools({
|
||||
tools: [],
|
||||
userId,
|
||||
availableTools,
|
||||
});
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(mockGetAllServerConfigs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should handle null/undefined tool entries gracefully', async () => {
|
||||
const result = await filterAuthorizedTools({
|
||||
tools: [null, undefined, '', 'web_search'],
|
||||
userId,
|
||||
availableTools,
|
||||
});
|
||||
|
||||
expect(result).toEqual(['web_search']);
|
||||
});
|
||||
|
||||
test('should call getAllServerConfigs with the correct userId', async () => {
|
||||
await filterAuthorizedTools({
|
||||
tools: [`tool${d}authorizedServer`],
|
||||
userId: 'specific-user-id',
|
||||
user: { id: 'specific-user-id', role: 'USER' },
|
||||
availableTools,
|
||||
});
|
||||
|
||||
expect(mockGetAllServerConfigs).toHaveBeenCalledWith('specific-user-id', undefined);
|
||||
});
|
||||
|
||||
test('should pass configServers to getAllServerConfigs and allow config-override servers', async () => {
|
||||
const configServers = {
|
||||
'config-override-server': { type: 'sse', url: 'https://override.example.com' },
|
||||
};
|
||||
mockGetAllServerConfigs.mockResolvedValue({
|
||||
'config-override-server': configServers['config-override-server'],
|
||||
});
|
||||
|
||||
const result = await filterAuthorizedTools({
|
||||
tools: [`tool${d}config-override-server`, `tool${d}unauthorizedServer`],
|
||||
userId,
|
||||
user: testUser,
|
||||
availableTools,
|
||||
configServers,
|
||||
});
|
||||
|
||||
expect(mockGetAllServerConfigs).toHaveBeenCalledWith(userId, configServers);
|
||||
expect(result).toContain(`tool${d}config-override-server`);
|
||||
expect(result).not.toContain(`tool${d}unauthorizedServer`);
|
||||
});
|
||||
|
||||
test('should only call getAllServerConfigs once even with multiple MCP tools', async () => {
|
||||
await filterAuthorizedTools({
|
||||
tools: [`tool1${d}authorizedServer`, `tool2${d}anotherServer`, `tool3${d}unknownServer`],
|
||||
userId,
|
||||
user: testUser,
|
||||
availableTools,
|
||||
});
|
||||
|
||||
expect(mockGetAllServerConfigs).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should preserve existing MCP tools when registry is unavailable', async () => {
|
||||
getMCPServersRegistry.mockImplementation(() => {
|
||||
throw new Error('MCPServersRegistry has not been initialized.');
|
||||
});
|
||||
|
||||
const existingTools = [`toolA${d}serverA`, `toolB${d}serverB`];
|
||||
|
||||
const result = await filterAuthorizedTools({
|
||||
tools: [...existingTools, `newTool${d}unknownServer`, 'web_search'],
|
||||
userId,
|
||||
user: testUser,
|
||||
availableTools,
|
||||
existingTools,
|
||||
});
|
||||
|
||||
expect(result).toContain(`toolA${d}serverA`);
|
||||
expect(result).toContain(`toolB${d}serverB`);
|
||||
expect(result).toContain('web_search');
|
||||
expect(result).not.toContain(`newTool${d}unknownServer`);
|
||||
});
|
||||
|
||||
test('should still reject all MCP tools when registry is unavailable and no existingTools', async () => {
|
||||
getMCPServersRegistry.mockImplementation(() => {
|
||||
throw new Error('MCPServersRegistry has not been initialized.');
|
||||
});
|
||||
|
||||
const result = await filterAuthorizedTools({
|
||||
tools: [`toolA${d}serverA`, 'web_search'],
|
||||
userId,
|
||||
user: testUser,
|
||||
availableTools,
|
||||
});
|
||||
|
||||
expect(result).toEqual(['web_search']);
|
||||
});
|
||||
|
||||
test('should not preserve malformed existing tools when registry is unavailable', async () => {
|
||||
getMCPServersRegistry.mockImplementation(() => {
|
||||
throw new Error('MCPServersRegistry has not been initialized.');
|
||||
});
|
||||
|
||||
const malformedTool = `a${d}b${d}c`;
|
||||
const result = await filterAuthorizedTools({
|
||||
tools: [malformedTool, `legit${d}serverA`, 'web_search'],
|
||||
userId,
|
||||
user: testUser,
|
||||
availableTools,
|
||||
existingTools: [malformedTool, `legit${d}serverA`],
|
||||
});
|
||||
|
||||
expect(result).toContain(`legit${d}serverA`);
|
||||
expect(result).toContain('web_search');
|
||||
expect(result).not.toContain(malformedTool);
|
||||
});
|
||||
|
||||
test('should gate app-level MCP tools present in the global tool cache', async () => {
|
||||
const appMcpTool = `appTool${d}authorizedServer`;
|
||||
const forbiddenAppMcpTool = `appTool${d}forbiddenServer`;
|
||||
const cacheWithMCPTools = {
|
||||
...availableTools,
|
||||
[appMcpTool]: true,
|
||||
[forbiddenAppMcpTool]: true,
|
||||
};
|
||||
|
||||
const result = await filterAuthorizedTools({
|
||||
tools: [appMcpTool, forbiddenAppMcpTool, 'web_search'],
|
||||
userId,
|
||||
user: testUser,
|
||||
availableTools: cacheWithMCPTools,
|
||||
});
|
||||
|
||||
expect(result).toContain(appMcpTool);
|
||||
expect(result).toContain('web_search');
|
||||
expect(result).not.toContain(forbiddenAppMcpTool);
|
||||
});
|
||||
|
||||
test('should strip app-level MCP tools from the cache when user lacks MCP server use permission', async () => {
|
||||
mockUserCanUseMCPServers.mockResolvedValue(false);
|
||||
const appMcpTool = `appTool${d}authorizedServer`;
|
||||
const cacheWithMCPTools = { ...availableTools, [appMcpTool]: true };
|
||||
|
||||
const result = await filterAuthorizedTools({
|
||||
tools: [appMcpTool, 'web_search'],
|
||||
userId,
|
||||
user: testUser,
|
||||
availableTools: cacheWithMCPTools,
|
||||
});
|
||||
|
||||
expect(result).toEqual(['web_search']);
|
||||
expect(mockGetAllServerConfigs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should reject malformed MCP tool keys with multiple delimiters', async () => {
|
||||
const result = await filterAuthorizedTools({
|
||||
tools: [
|
||||
`attack${d}victimServer${d}authorizedServer`,
|
||||
`legit${d}authorizedServer`,
|
||||
`a${d}b${d}c${d}d`,
|
||||
'web_search',
|
||||
],
|
||||
userId,
|
||||
user: testUser,
|
||||
availableTools,
|
||||
});
|
||||
|
||||
expect(result).toEqual([`legit${d}authorizedServer`, 'web_search']);
|
||||
expect(result).not.toContainEqual(expect.stringContaining('victimServer'));
|
||||
expect(result).not.toContainEqual(expect.stringContaining(`a${d}b`));
|
||||
});
|
||||
});
|
||||
|
||||
describe('createAgentHandler - MCP tool authorization', () => {
|
||||
test('should strip unauthorized MCP tools on create', async () => {
|
||||
mockReq.body = {
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
name: 'MCP Test Agent',
|
||||
tools: ['web_search', `validTool${d}authorizedServer`, `attack${d}forbiddenServer`],
|
||||
};
|
||||
|
||||
await createAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(201);
|
||||
const agent = mockRes.json.mock.calls[0][0];
|
||||
expect(agent.tools).toContain('web_search');
|
||||
expect(agent.tools).toContain(`validTool${d}authorizedServer`);
|
||||
expect(agent.tools).not.toContain(`attack${d}forbiddenServer`);
|
||||
});
|
||||
|
||||
test('should strip all MCP tools on create when user lacks MCP server use permission', async () => {
|
||||
mockUserCanUseMCPServers.mockResolvedValue(false);
|
||||
mockReq.body = {
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
name: 'MCP Denied Test Agent',
|
||||
tools: [
|
||||
'web_search',
|
||||
`validTool${d}authorizedServer`,
|
||||
`${Constants.mcp_all}${d}authorizedServer`,
|
||||
],
|
||||
};
|
||||
|
||||
await createAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(201);
|
||||
const agent = mockRes.json.mock.calls[0][0];
|
||||
expect(agent.tools).toEqual(['web_search']);
|
||||
expect(agent.mcpServerNames).toEqual([]);
|
||||
});
|
||||
|
||||
test('should not 500 when MCP registry is uninitialized', async () => {
|
||||
getMCPServersRegistry.mockImplementation(() => {
|
||||
throw new Error('MCPServersRegistry has not been initialized.');
|
||||
});
|
||||
|
||||
mockReq.body = {
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
name: 'MCP Uninitialized Test',
|
||||
tools: [`tool${d}someServer`, 'web_search'],
|
||||
};
|
||||
|
||||
await createAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(201);
|
||||
const agent = mockRes.json.mock.calls[0][0];
|
||||
expect(agent.tools).toEqual(['web_search']);
|
||||
});
|
||||
|
||||
test('should store mcpServerNames only for authorized servers', async () => {
|
||||
mockReq.body = {
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
name: 'MCP Names Test',
|
||||
tools: [`toolA${d}authorizedServer`, `toolB${d}forbiddenServer`],
|
||||
};
|
||||
|
||||
await createAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(201);
|
||||
const agent = mockRes.json.mock.calls[0][0];
|
||||
const agentInDb = await Agent.findOne({ id: agent.id });
|
||||
expect(agentInDb.mcpServerNames).toContain('authorizedServer');
|
||||
expect(agentInDb.mcpServerNames).not.toContain('forbiddenServer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateAgentHandler - MCP tool authorization', () => {
|
||||
let existingAgentId;
|
||||
let existingAgentAuthorId;
|
||||
|
||||
beforeEach(async () => {
|
||||
existingAgentAuthorId = new mongoose.Types.ObjectId();
|
||||
const agent = await Agent.create({
|
||||
id: `agent_${uuidv4()}`,
|
||||
name: 'Original Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: existingAgentAuthorId,
|
||||
tools: ['web_search', `existingTool${d}authorizedServer`],
|
||||
mcpServerNames: ['authorizedServer'],
|
||||
versions: [
|
||||
{
|
||||
name: 'Original Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
tools: ['web_search', `existingTool${d}authorizedServer`],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
});
|
||||
existingAgentId = agent.id;
|
||||
});
|
||||
|
||||
test('should preserve existing MCP tools even if editor lacks access', async () => {
|
||||
mockGetAllServerConfigs.mockResolvedValue({});
|
||||
|
||||
mockReq.user.id = existingAgentAuthorId.toString();
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.body = {
|
||||
tools: ['web_search', `existingTool${d}authorizedServer`],
|
||||
};
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalled();
|
||||
const updatedAgent = mockRes.json.mock.calls[0][0];
|
||||
expect(updatedAgent.tools).toContain(`existingTool${d}authorizedServer`);
|
||||
expect(updatedAgent.tools).toContain('web_search');
|
||||
});
|
||||
|
||||
test('should reject newly added unauthorized MCP tools', async () => {
|
||||
mockReq.user.id = existingAgentAuthorId.toString();
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.body = {
|
||||
tools: ['web_search', `existingTool${d}authorizedServer`, `attack${d}forbiddenServer`],
|
||||
};
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalled();
|
||||
const updatedAgent = mockRes.json.mock.calls[0][0];
|
||||
expect(updatedAgent.tools).toContain('web_search');
|
||||
expect(updatedAgent.tools).toContain(`existingTool${d}authorizedServer`);
|
||||
expect(updatedAgent.tools).not.toContain(`attack${d}forbiddenServer`);
|
||||
});
|
||||
|
||||
test('should strip all MCP tools, including retained ones, when user lacks MCP server use permission', async () => {
|
||||
mockUserCanUseMCPServers.mockResolvedValue(false);
|
||||
mockReq.user.id = existingAgentAuthorId.toString();
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.body = {
|
||||
tools: ['web_search', `existingTool${d}authorizedServer`, `newTool${d}anotherServer`],
|
||||
};
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalled();
|
||||
const updatedAgent = mockRes.json.mock.calls[0][0];
|
||||
// Permission revoked: update must not preserve stale MCP bindings, matching
|
||||
// the create/duplicate/revert paths.
|
||||
expect(updatedAgent.tools).toEqual(['web_search']);
|
||||
expect(mockGetAllServerConfigs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should strip retained MCP tools on an unrelated owner edit after permission revocation', async () => {
|
||||
mockUserCanUseMCPServers.mockResolvedValue(false);
|
||||
mockReq.user.id = existingAgentAuthorId.toString();
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.body = {
|
||||
name: 'Renamed After Revocation',
|
||||
};
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalled();
|
||||
const updatedAgent = mockRes.json.mock.calls[0][0];
|
||||
expect(updatedAgent.tools).toEqual(['web_search']);
|
||||
expect(updatedAgent.name).toBe('Renamed After Revocation');
|
||||
});
|
||||
|
||||
test('should not strip shared agent MCP tools on unrelated editor changes after revocation', async () => {
|
||||
mockUserCanUseMCPServers.mockResolvedValue(false);
|
||||
mockReq.user.id = new mongoose.Types.ObjectId().toString();
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.body = {
|
||||
name: 'Shared Rename After Revocation',
|
||||
};
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalled();
|
||||
const updatedAgent = mockRes.json.mock.calls[0][0];
|
||||
const agentInDb = await Agent.findOne({ id: existingAgentId });
|
||||
expect(updatedAgent.tools).toContain(`existingTool${d}authorizedServer`);
|
||||
expect(updatedAgent.name).toBe('Shared Rename After Revocation');
|
||||
expect(agentInDb.tools).toContain(`existingTool${d}authorizedServer`);
|
||||
expect(agentInDb.mcpServerNames).toEqual(['authorizedServer']);
|
||||
});
|
||||
|
||||
test('should not strip shared agent MCP tools on frontend-style full tools save after revocation', async () => {
|
||||
mockUserCanUseMCPServers.mockResolvedValue(false);
|
||||
mockReq.user.id = new mongoose.Types.ObjectId().toString();
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.body = {
|
||||
name: 'Shared Full Save After Revocation',
|
||||
tools: ['web_search', `existingTool${d}authorizedServer`],
|
||||
};
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalled();
|
||||
const updatedAgent = mockRes.json.mock.calls[0][0];
|
||||
const agentInDb = await Agent.findOne({ id: existingAgentId });
|
||||
expect(updatedAgent.tools).toContain(`existingTool${d}authorizedServer`);
|
||||
expect(updatedAgent.name).toBe('Shared Full Save After Revocation');
|
||||
expect(agentInDb.tools).toContain(`existingTool${d}authorizedServer`);
|
||||
expect(agentInDb.mcpServerNames).toEqual(['authorizedServer']);
|
||||
expect(mockGetAllServerConfigs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should reject new shared-agent MCP tools after revocation while retaining existing MCP tools', async () => {
|
||||
mockUserCanUseMCPServers.mockResolvedValue(false);
|
||||
mockReq.user.id = new mongoose.Types.ObjectId().toString();
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.body = {
|
||||
tools: ['web_search', `existingTool${d}authorizedServer`, `newTool${d}anotherServer`],
|
||||
};
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalled();
|
||||
const updatedAgent = mockRes.json.mock.calls[0][0];
|
||||
const agentInDb = await Agent.findOne({ id: existingAgentId });
|
||||
expect(updatedAgent.tools).toContain(`existingTool${d}authorizedServer`);
|
||||
expect(updatedAgent.tools).not.toContain(`newTool${d}anotherServer`);
|
||||
expect(agentInDb.tools).toContain(`existingTool${d}authorizedServer`);
|
||||
expect(agentInDb.tools).not.toContain(`newTool${d}anotherServer`);
|
||||
expect(agentInDb.mcpServerNames).toEqual(['authorizedServer']);
|
||||
expect(mockGetAllServerConfigs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should not strip action tools whose operationId contains the MCP delimiter on revocation', async () => {
|
||||
// `sync_mcp_state_action_...` contains the `_mcp_` substring but is a
|
||||
// genuine OpenAPI action tool (isActionTool === true). Losing
|
||||
// MCP_SERVERS.USE must not drop it — action use is unrelated to MCP.
|
||||
const actionTool = `sync_mcp_state${actionDelimiter}api---example---com`;
|
||||
await Agent.updateOne(
|
||||
{ id: existingAgentId },
|
||||
{ $set: { tools: ['web_search', actionTool] } },
|
||||
);
|
||||
|
||||
mockUserCanUseMCPServers.mockResolvedValue(false);
|
||||
mockReq.user.id = existingAgentAuthorId.toString();
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.body = {
|
||||
name: 'Edited Without MCP Permission',
|
||||
tools: ['web_search', actionTool],
|
||||
};
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalled();
|
||||
const updatedAgent = mockRes.json.mock.calls[0][0];
|
||||
const agentInDb = await Agent.findOne({ id: existingAgentId });
|
||||
expect(updatedAgent.tools).toContain(actionTool);
|
||||
expect(updatedAgent.tools).toContain('web_search');
|
||||
expect(agentInDb.mcpServerNames).toEqual([]);
|
||||
});
|
||||
|
||||
test('should allow adding authorized MCP tools', async () => {
|
||||
mockReq.user.id = existingAgentAuthorId.toString();
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.body = {
|
||||
tools: ['web_search', `existingTool${d}authorizedServer`, `newTool${d}anotherServer`],
|
||||
};
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalled();
|
||||
const updatedAgent = mockRes.json.mock.calls[0][0];
|
||||
expect(updatedAgent.tools).toContain(`newTool${d}anotherServer`);
|
||||
});
|
||||
|
||||
test('should not query MCP registry when no new MCP tools added', async () => {
|
||||
mockReq.user.id = existingAgentAuthorId.toString();
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.body = {
|
||||
tools: ['web_search', `existingTool${d}authorizedServer`],
|
||||
};
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockGetAllServerConfigs).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should preserve existing MCP tools when registry unavailable and user edits agent', async () => {
|
||||
getMCPServersRegistry.mockImplementation(() => {
|
||||
throw new Error('MCPServersRegistry has not been initialized.');
|
||||
});
|
||||
|
||||
mockReq.user.id = existingAgentAuthorId.toString();
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.body = {
|
||||
name: 'Renamed After Restart',
|
||||
tools: ['web_search', `existingTool${d}authorizedServer`],
|
||||
};
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalled();
|
||||
const updatedAgent = mockRes.json.mock.calls[0][0];
|
||||
expect(updatedAgent.tools).toContain(`existingTool${d}authorizedServer`);
|
||||
expect(updatedAgent.tools).toContain('web_search');
|
||||
expect(updatedAgent.name).toBe('Renamed After Restart');
|
||||
});
|
||||
|
||||
test('should preserve existing MCP tools when server not in configs (disconnected)', async () => {
|
||||
mockGetAllServerConfigs.mockResolvedValue({});
|
||||
|
||||
mockReq.user.id = existingAgentAuthorId.toString();
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.body = {
|
||||
name: 'Edited While Disconnected',
|
||||
tools: ['web_search', `existingTool${d}authorizedServer`],
|
||||
};
|
||||
|
||||
await updateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalled();
|
||||
const updatedAgent = mockRes.json.mock.calls[0][0];
|
||||
expect(updatedAgent.tools).toContain(`existingTool${d}authorizedServer`);
|
||||
expect(updatedAgent.name).toBe('Edited While Disconnected');
|
||||
});
|
||||
});
|
||||
|
||||
describe('duplicateAgentHandler - MCP tool authorization', () => {
|
||||
let sourceAgentId;
|
||||
let sourceAgentAuthorId;
|
||||
|
||||
beforeEach(async () => {
|
||||
sourceAgentAuthorId = new mongoose.Types.ObjectId();
|
||||
const agent = await Agent.create({
|
||||
id: `agent_${uuidv4()}`,
|
||||
name: 'Source Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: sourceAgentAuthorId,
|
||||
tools: ['web_search', `tool${d}authorizedServer`, `tool${d}forbiddenServer`],
|
||||
mcpServerNames: ['authorizedServer', 'forbiddenServer'],
|
||||
versions: [
|
||||
{
|
||||
name: 'Source Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
tools: ['web_search', `tool${d}authorizedServer`, `tool${d}forbiddenServer`],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
});
|
||||
sourceAgentId = agent.id;
|
||||
});
|
||||
|
||||
test('should strip unauthorized MCP tools from duplicated agent', async () => {
|
||||
mockGetAllServerConfigs.mockResolvedValue({
|
||||
authorizedServer: { type: 'sse' },
|
||||
});
|
||||
|
||||
mockReq.user.id = sourceAgentAuthorId.toString();
|
||||
mockReq.params.id = sourceAgentId;
|
||||
|
||||
await duplicateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(201);
|
||||
const { agent: newAgent } = mockRes.json.mock.calls[0][0];
|
||||
expect(newAgent.id).not.toBe(sourceAgentId);
|
||||
expect(newAgent.tools).toContain('web_search');
|
||||
expect(newAgent.tools).toContain(`tool${d}authorizedServer`);
|
||||
expect(newAgent.tools).not.toContain(`tool${d}forbiddenServer`);
|
||||
|
||||
const agentInDb = await Agent.findOne({ id: newAgent.id });
|
||||
expect(agentInDb.mcpServerNames).toContain('authorizedServer');
|
||||
expect(agentInDb.mcpServerNames).not.toContain('forbiddenServer');
|
||||
});
|
||||
|
||||
test('should preserve source agent MCP tools when registry is unavailable', async () => {
|
||||
getMCPServersRegistry.mockImplementation(() => {
|
||||
throw new Error('MCPServersRegistry has not been initialized.');
|
||||
});
|
||||
|
||||
mockReq.user.id = sourceAgentAuthorId.toString();
|
||||
mockReq.params.id = sourceAgentId;
|
||||
|
||||
await duplicateAgentHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.status).toHaveBeenCalledWith(201);
|
||||
const { agent: newAgent } = mockRes.json.mock.calls[0][0];
|
||||
expect(newAgent.tools).toContain('web_search');
|
||||
expect(newAgent.tools).toContain(`tool${d}authorizedServer`);
|
||||
expect(newAgent.tools).toContain(`tool${d}forbiddenServer`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('revertAgentVersionHandler - MCP tool authorization', () => {
|
||||
let existingAgentId;
|
||||
let existingAgentAuthorId;
|
||||
|
||||
beforeEach(async () => {
|
||||
existingAgentAuthorId = new mongoose.Types.ObjectId();
|
||||
const agent = await Agent.create({
|
||||
id: `agent_${uuidv4()}`,
|
||||
name: 'Reverted Agent V2',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: existingAgentAuthorId,
|
||||
tools: ['web_search'],
|
||||
versions: [
|
||||
{
|
||||
name: 'Reverted Agent V1',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
tools: ['web_search', `oldTool${d}revokedServer`],
|
||||
createdAt: new Date(Date.now() - 10000),
|
||||
updatedAt: new Date(Date.now() - 10000),
|
||||
},
|
||||
{
|
||||
name: 'Reverted Agent V2',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
tools: ['web_search'],
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
],
|
||||
});
|
||||
existingAgentId = agent.id;
|
||||
});
|
||||
|
||||
test('should strip unauthorized MCP tools after reverting to a previous version', async () => {
|
||||
mockGetAllServerConfigs.mockResolvedValue({
|
||||
authorizedServer: { type: 'sse' },
|
||||
});
|
||||
|
||||
mockReq.user.id = existingAgentAuthorId.toString();
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.body = { version_index: 0 };
|
||||
|
||||
await revertAgentVersionHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalled();
|
||||
const result = mockRes.json.mock.calls[0][0];
|
||||
expect(result.tools).toContain('web_search');
|
||||
expect(result.tools).not.toContain(`oldTool${d}revokedServer`);
|
||||
|
||||
const agentInDb = await Agent.findOne({ id: existingAgentId });
|
||||
expect(agentInDb.tools).toContain('web_search');
|
||||
expect(agentInDb.tools).not.toContain(`oldTool${d}revokedServer`);
|
||||
});
|
||||
|
||||
test('should keep authorized MCP tools after revert', async () => {
|
||||
await Agent.updateOne(
|
||||
{ id: existingAgentId },
|
||||
{ $set: { 'versions.0.tools': ['web_search', `tool${d}authorizedServer`] } },
|
||||
);
|
||||
|
||||
mockReq.user.id = existingAgentAuthorId.toString();
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.body = { version_index: 0 };
|
||||
|
||||
await revertAgentVersionHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalled();
|
||||
const result = mockRes.json.mock.calls[0][0];
|
||||
expect(result.tools).toContain('web_search');
|
||||
expect(result.tools).toContain(`tool${d}authorizedServer`);
|
||||
});
|
||||
|
||||
test('should preserve version MCP tools when registry is unavailable on revert', async () => {
|
||||
await Agent.updateOne(
|
||||
{ id: existingAgentId },
|
||||
{
|
||||
$set: {
|
||||
'versions.0.tools': [
|
||||
'web_search',
|
||||
`validTool${d}authorizedServer`,
|
||||
`otherTool${d}anotherServer`,
|
||||
],
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
getMCPServersRegistry.mockImplementation(() => {
|
||||
throw new Error('MCPServersRegistry has not been initialized.');
|
||||
});
|
||||
|
||||
mockReq.user.id = existingAgentAuthorId.toString();
|
||||
mockReq.params.id = existingAgentId;
|
||||
mockReq.body = { version_index: 0 };
|
||||
|
||||
await revertAgentVersionHandler(mockReq, mockRes);
|
||||
|
||||
expect(mockRes.json).toHaveBeenCalled();
|
||||
const result = mockRes.json.mock.calls[0][0];
|
||||
expect(result.tools).toContain('web_search');
|
||||
expect(result.tools).toContain(`validTool${d}authorizedServer`);
|
||||
expect(result.tools).toContain(`otherTool${d}anotherServer`);
|
||||
|
||||
const agentInDb = await Agent.findOne({ id: existingAgentId });
|
||||
expect(agentInDb.tools).toContain(`validTool${d}authorizedServer`);
|
||||
expect(agentInDb.tools).toContain(`otherTool${d}anotherServer`);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,358 @@
|
||||
/**
|
||||
* Tests for AgentClient.recordCollectedUsage
|
||||
*
|
||||
* This is a critical function that handles token spending for agent LLM calls.
|
||||
* The client now delegates to the TS recordCollectedUsage from @librechat/api,
|
||||
* passing pricing and bulkWriteOps deps.
|
||||
*/
|
||||
|
||||
const { EModelEndpoint } = require('librechat-data-provider');
|
||||
|
||||
const mockSpendTokens = jest.fn().mockResolvedValue();
|
||||
const mockSpendStructuredTokens = jest.fn().mockResolvedValue();
|
||||
const mockGetMultiplier = jest.fn().mockReturnValue(1);
|
||||
const mockGetCacheMultiplier = jest.fn().mockReturnValue(null);
|
||||
const mockUpdateBalance = jest.fn().mockResolvedValue({});
|
||||
const mockBulkInsertTransactions = jest.fn().mockResolvedValue(undefined);
|
||||
const mockRecordCollectedUsage = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
|
||||
|
||||
jest.mock('~/models', () => ({
|
||||
spendTokens: (...args) => mockSpendTokens(...args),
|
||||
spendStructuredTokens: (...args) => mockSpendStructuredTokens(...args),
|
||||
getMultiplier: mockGetMultiplier,
|
||||
getCacheMultiplier: mockGetCacheMultiplier,
|
||||
updateBalance: mockUpdateBalance,
|
||||
bulkInsertTransactions: mockBulkInsertTransactions,
|
||||
}));
|
||||
|
||||
jest.mock('~/config', () => ({
|
||||
logger: {
|
||||
debug: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
info: jest.fn(),
|
||||
},
|
||||
getMCPManager: jest.fn(() => ({
|
||||
formatInstructionsForContext: jest.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/agents', () => ({
|
||||
...jest.requireActual('@librechat/agents'),
|
||||
createMetadataAggregator: () => ({
|
||||
handleLLMEnd: jest.fn(),
|
||||
collected: [],
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => {
|
||||
const actual = jest.requireActual('@librechat/api');
|
||||
return {
|
||||
...actual,
|
||||
recordCollectedUsage: (...args) => mockRecordCollectedUsage(...args),
|
||||
};
|
||||
});
|
||||
|
||||
const AgentClient = require('./client');
|
||||
|
||||
describe('AgentClient - recordCollectedUsage', () => {
|
||||
let client;
|
||||
let mockAgent;
|
||||
let mockOptions;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
mockAgent = {
|
||||
id: 'agent-123',
|
||||
endpoint: EModelEndpoint.openAI,
|
||||
provider: EModelEndpoint.openAI,
|
||||
model_parameters: {
|
||||
model: 'gpt-4',
|
||||
},
|
||||
};
|
||||
|
||||
mockOptions = {
|
||||
req: {
|
||||
user: { id: 'user-123' },
|
||||
body: { model: 'gpt-4', endpoint: EModelEndpoint.openAI },
|
||||
},
|
||||
res: {},
|
||||
agent: mockAgent,
|
||||
endpointTokenConfig: {},
|
||||
};
|
||||
|
||||
client = new AgentClient(mockOptions);
|
||||
client.conversationId = 'convo-123';
|
||||
client.user = 'user-123';
|
||||
});
|
||||
|
||||
describe('basic functionality', () => {
|
||||
it('should delegate to recordCollectedUsage with full deps', async () => {
|
||||
const collectedUsage = [{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' }];
|
||||
|
||||
await client.recordCollectedUsage({
|
||||
collectedUsage,
|
||||
balance: { enabled: true },
|
||||
transactions: { enabled: true },
|
||||
});
|
||||
|
||||
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
|
||||
const [deps, params] = mockRecordCollectedUsage.mock.calls[0];
|
||||
|
||||
expect(deps).toHaveProperty('spendTokens');
|
||||
expect(deps).toHaveProperty('spendStructuredTokens');
|
||||
expect(deps).toHaveProperty('pricing');
|
||||
expect(deps.pricing).toHaveProperty('getMultiplier');
|
||||
expect(deps.pricing).toHaveProperty('getCacheMultiplier');
|
||||
expect(deps).toHaveProperty('bulkWriteOps');
|
||||
expect(deps.bulkWriteOps).toHaveProperty('insertMany');
|
||||
expect(deps.bulkWriteOps).toHaveProperty('updateBalance');
|
||||
|
||||
expect(params).toEqual(
|
||||
expect.objectContaining({
|
||||
user: 'user-123',
|
||||
conversationId: 'convo-123',
|
||||
collectedUsage,
|
||||
context: 'message',
|
||||
balance: { enabled: true },
|
||||
transactions: { enabled: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not set this.usage if collectedUsage is empty (returns undefined)', async () => {
|
||||
mockRecordCollectedUsage.mockResolvedValue(undefined);
|
||||
|
||||
await client.recordCollectedUsage({
|
||||
collectedUsage: [],
|
||||
balance: { enabled: true },
|
||||
transactions: { enabled: true },
|
||||
});
|
||||
|
||||
expect(client.usage).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not set this.usage if collectedUsage is null (returns undefined)', async () => {
|
||||
mockRecordCollectedUsage.mockResolvedValue(undefined);
|
||||
|
||||
await client.recordCollectedUsage({
|
||||
collectedUsage: null,
|
||||
balance: { enabled: true },
|
||||
transactions: { enabled: true },
|
||||
});
|
||||
|
||||
expect(client.usage).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should set this.usage from recordCollectedUsage result', async () => {
|
||||
mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 200, output_tokens: 75 });
|
||||
const collectedUsage = [{ input_tokens: 200, output_tokens: 75, model: 'gpt-4' }];
|
||||
|
||||
await client.recordCollectedUsage({
|
||||
collectedUsage,
|
||||
balance: { enabled: true },
|
||||
transactions: { enabled: true },
|
||||
});
|
||||
|
||||
expect(client.usage).toEqual({ input_tokens: 200, output_tokens: 75 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('sequential execution (single agent with tool calls)', () => {
|
||||
it('should pass all usage entries to recordCollectedUsage', async () => {
|
||||
const collectedUsage = [
|
||||
{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' },
|
||||
{ input_tokens: 150, output_tokens: 30, model: 'gpt-4' },
|
||||
{ input_tokens: 180, output_tokens: 20, model: 'gpt-4' },
|
||||
];
|
||||
|
||||
mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 100, output_tokens: 100 });
|
||||
|
||||
await client.recordCollectedUsage({
|
||||
collectedUsage,
|
||||
balance: { enabled: true },
|
||||
transactions: { enabled: true },
|
||||
});
|
||||
|
||||
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
|
||||
const [, params] = mockRecordCollectedUsage.mock.calls[0];
|
||||
expect(params.collectedUsage).toHaveLength(3);
|
||||
expect(client.usage.output_tokens).toBe(100);
|
||||
expect(client.usage.input_tokens).toBe(100);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parallel execution (multiple agents)', () => {
|
||||
it('should pass parallel agent usage to recordCollectedUsage', async () => {
|
||||
const collectedUsage = [
|
||||
{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' },
|
||||
{ input_tokens: 80, output_tokens: 40, model: 'gpt-4' },
|
||||
];
|
||||
|
||||
mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 100, output_tokens: 90 });
|
||||
|
||||
await client.recordCollectedUsage({
|
||||
collectedUsage,
|
||||
balance: { enabled: true },
|
||||
transactions: { enabled: true },
|
||||
});
|
||||
|
||||
expect(mockRecordCollectedUsage).toHaveBeenCalledTimes(1);
|
||||
expect(client.usage.output_tokens).toBe(90);
|
||||
expect(client.usage.output_tokens).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
/** Bug regression: parallel agents where second agent has LOWER input tokens produced negative output via incremental calculation. */
|
||||
it('should NOT produce negative output_tokens', async () => {
|
||||
const collectedUsage = [
|
||||
{ input_tokens: 200, output_tokens: 100, model: 'gpt-4' },
|
||||
{ input_tokens: 50, output_tokens: 30, model: 'gpt-4' },
|
||||
];
|
||||
|
||||
mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 200, output_tokens: 130 });
|
||||
|
||||
await client.recordCollectedUsage({
|
||||
collectedUsage,
|
||||
balance: { enabled: true },
|
||||
transactions: { enabled: true },
|
||||
});
|
||||
|
||||
expect(client.usage.output_tokens).toBeGreaterThan(0);
|
||||
expect(client.usage.output_tokens).toBe(130);
|
||||
});
|
||||
});
|
||||
|
||||
describe('real-world scenarios', () => {
|
||||
it('should correctly handle sequential tool calls with growing context', async () => {
|
||||
const collectedUsage = [
|
||||
{ input_tokens: 31596, output_tokens: 151, model: 'claude-opus-4-5-20251101' },
|
||||
{ input_tokens: 35368, output_tokens: 150, model: 'claude-opus-4-5-20251101' },
|
||||
{ input_tokens: 58362, output_tokens: 295, model: 'claude-opus-4-5-20251101' },
|
||||
{ input_tokens: 112604, output_tokens: 193, model: 'claude-opus-4-5-20251101' },
|
||||
{ input_tokens: 257440, output_tokens: 2217, model: 'claude-opus-4-5-20251101' },
|
||||
];
|
||||
|
||||
mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 31596, output_tokens: 3006 });
|
||||
|
||||
await client.recordCollectedUsage({
|
||||
collectedUsage,
|
||||
balance: { enabled: true },
|
||||
transactions: { enabled: true },
|
||||
});
|
||||
|
||||
expect(client.usage.input_tokens).toBe(31596);
|
||||
expect(client.usage.output_tokens).toBe(3006);
|
||||
});
|
||||
|
||||
it('should correctly handle cache tokens', async () => {
|
||||
const collectedUsage = [
|
||||
{
|
||||
input_tokens: 788,
|
||||
output_tokens: 163,
|
||||
input_token_details: { cache_read: 0, cache_creation: 30808 },
|
||||
model: 'claude-opus-4-5-20251101',
|
||||
},
|
||||
];
|
||||
|
||||
mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 31596, output_tokens: 163 });
|
||||
|
||||
await client.recordCollectedUsage({
|
||||
collectedUsage,
|
||||
balance: { enabled: true },
|
||||
transactions: { enabled: true },
|
||||
});
|
||||
|
||||
expect(client.usage.input_tokens).toBe(31596);
|
||||
expect(client.usage.output_tokens).toBe(163);
|
||||
});
|
||||
});
|
||||
|
||||
describe('model fallback', () => {
|
||||
it('should use param model when available', async () => {
|
||||
mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
|
||||
const collectedUsage = [{ input_tokens: 100, output_tokens: 50 }];
|
||||
|
||||
await client.recordCollectedUsage({
|
||||
model: 'param-model',
|
||||
collectedUsage,
|
||||
balance: { enabled: true },
|
||||
transactions: { enabled: true },
|
||||
});
|
||||
|
||||
const [, params] = mockRecordCollectedUsage.mock.calls[0];
|
||||
expect(params.model).toBe('param-model');
|
||||
});
|
||||
|
||||
it('should fallback to client.model when param model is missing', async () => {
|
||||
client.model = 'client-model';
|
||||
mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
|
||||
const collectedUsage = [{ input_tokens: 100, output_tokens: 50 }];
|
||||
|
||||
await client.recordCollectedUsage({
|
||||
collectedUsage,
|
||||
balance: { enabled: true },
|
||||
transactions: { enabled: true },
|
||||
});
|
||||
|
||||
const [, params] = mockRecordCollectedUsage.mock.calls[0];
|
||||
expect(params.model).toBe('client-model');
|
||||
});
|
||||
|
||||
it('should fallback to agent model_parameters.model as last resort', async () => {
|
||||
mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
|
||||
const collectedUsage = [{ input_tokens: 100, output_tokens: 50 }];
|
||||
|
||||
await client.recordCollectedUsage({
|
||||
collectedUsage,
|
||||
balance: { enabled: true },
|
||||
transactions: { enabled: true },
|
||||
});
|
||||
|
||||
const [, params] = mockRecordCollectedUsage.mock.calls[0];
|
||||
expect(params.model).toBe('gpt-4');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStreamUsage integration', () => {
|
||||
it('should return the usage object set by recordCollectedUsage', async () => {
|
||||
mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 100, output_tokens: 50 });
|
||||
const collectedUsage = [{ input_tokens: 100, output_tokens: 50, model: 'gpt-4' }];
|
||||
|
||||
await client.recordCollectedUsage({
|
||||
collectedUsage,
|
||||
balance: { enabled: true },
|
||||
transactions: { enabled: true },
|
||||
});
|
||||
|
||||
const usage = client.getStreamUsage();
|
||||
expect(usage).toEqual({ input_tokens: 100, output_tokens: 50 });
|
||||
});
|
||||
|
||||
it('should return undefined before recordCollectedUsage is called', () => {
|
||||
const usage = client.getStreamUsage();
|
||||
expect(usage).toBeUndefined();
|
||||
});
|
||||
|
||||
/** Verifies usage passes the check in BaseClient.sendMessage: if (usage != null && Number(usage[this.outputTokensKey]) > 0) */
|
||||
it('should have output_tokens > 0 for BaseClient.sendMessage check', async () => {
|
||||
mockRecordCollectedUsage.mockResolvedValue({ input_tokens: 200, output_tokens: 130 });
|
||||
const collectedUsage = [
|
||||
{ input_tokens: 200, output_tokens: 100, model: 'gpt-4' },
|
||||
{ input_tokens: 50, output_tokens: 30, model: 'gpt-4' },
|
||||
];
|
||||
|
||||
await client.recordCollectedUsage({
|
||||
collectedUsage,
|
||||
balance: { enabled: true },
|
||||
transactions: { enabled: true },
|
||||
});
|
||||
|
||||
const usage = client.getStreamUsage();
|
||||
expect(usage).not.toBeNull();
|
||||
expect(Number(usage.output_tokens)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,725 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { Constants, EModelEndpoint } = require('librechat-data-provider');
|
||||
const {
|
||||
GenerationJobManager,
|
||||
isPendingActionStale,
|
||||
mapToolApprovalResolutions,
|
||||
mapAskUserAnswer,
|
||||
attachAskUserQuestionAnswer,
|
||||
findUndecidedToolCalls,
|
||||
findDisallowedDecisions,
|
||||
findIncompleteDecisions,
|
||||
computeAgentRequestFingerprint,
|
||||
deleteAgentCheckpoint,
|
||||
buildAbortedResponseMetadata,
|
||||
sanitizeMessageForTransmit,
|
||||
filterMalformedContentParts,
|
||||
decrementPendingRequest,
|
||||
checkAndIncrementPendingRequest,
|
||||
} = require('@librechat/api');
|
||||
const { disposeClient } = require('~/server/cleanup');
|
||||
const {
|
||||
getMCPRequestContext,
|
||||
cleanupMCPRequestContextForReq,
|
||||
} = require('~/server/services/MCPRequestContext');
|
||||
const { saveMessage, getConvo, getMessages } = require('~/models');
|
||||
|
||||
/**
|
||||
* Upper bound on an `ask_user_question` answer (characters). Generous for any real
|
||||
* reply typed into the question card while still bounding what a crafted POST can
|
||||
* inject into the resumed run's ToolMessage.
|
||||
*/
|
||||
const MAX_ASK_ANSWER_LENGTH = 16_000;
|
||||
|
||||
/** De-duplicate a merged attachment list by a stable artifact identity. */
|
||||
function mergeAttachments(existing, incoming) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const attachment of [...(existing ?? []), ...(incoming ?? [])]) {
|
||||
if (!attachment) {
|
||||
continue;
|
||||
}
|
||||
const key =
|
||||
attachment.file_id ??
|
||||
attachment.filepath ??
|
||||
attachment.filename ??
|
||||
JSON.stringify(attachment);
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
out.push(attachment);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the current segment's tool artifacts and merge them with any already
|
||||
* persisted on the response row. A resumed turn can span multiple pause segments;
|
||||
* each rebuilt client has its own `artifactPromises`, and the final finalize would
|
||||
* otherwise OVERWRITE the row's attachments with only the last segment's. Reading
|
||||
* the persisted row and merging keeps every segment's artifacts on the saved message.
|
||||
*/
|
||||
async function resolveAccumulatedAttachments({ client, conversationId, responseMessageId }) {
|
||||
const promises = Array.isArray(client?.artifactPromises) ? client.artifactPromises : [];
|
||||
const resolved = promises.length > 0 ? (await Promise.all(promises)).filter(Boolean) : [];
|
||||
let existing = [];
|
||||
if (responseMessageId) {
|
||||
try {
|
||||
const [row] = await getMessages(
|
||||
{ conversationId, messageId: responseMessageId },
|
||||
'attachments',
|
||||
);
|
||||
existing = Array.isArray(row?.attachments) ? row.attachments : [];
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
'[ResumeAgentController] Failed to read prior attachments for merge',
|
||||
err?.message ?? err,
|
||||
);
|
||||
}
|
||||
}
|
||||
return mergeAttachments(existing, resolved);
|
||||
}
|
||||
|
||||
/** Resolve the segment's content for an unfinished save (mirrors finalize's source). */
|
||||
async function resolveSegmentContent(client, streamId) {
|
||||
const liveContent = Array.isArray(client?.contentParts) ? client.contentParts : [];
|
||||
const rawContent =
|
||||
liveContent.length > 0
|
||||
? liveContent
|
||||
: ((await GenerationJobManager.getResumeState(streamId))?.aggregatedContent ?? []);
|
||||
return filterMalformedContentParts(rawContent);
|
||||
}
|
||||
|
||||
/**
|
||||
* A resumed segment that streamed content / produced artifacts and then paused AGAIN
|
||||
* must persist that progress before returning. The next resume rebuilds a fresh client
|
||||
* (empty `contentParts`/`artifactPromises`), so without this an approval that later
|
||||
* expires or is reaped would leave only the EARLIER pause's content on the saved row —
|
||||
* the user loses everything streamed during this segment. Saved as a partial (`$set`,
|
||||
* still `unfinished`) so a subsequent successful resume overwrites it on finalize.
|
||||
*/
|
||||
async function persistRePauseProgress({ req, client, job, streamId, conversationId }) {
|
||||
const userId = req.user.id;
|
||||
const meta = job.metadata ?? {};
|
||||
const responseMessageId = meta.responseMessageId ?? client.responseMessageId;
|
||||
if (!responseMessageId) {
|
||||
return;
|
||||
}
|
||||
const content = await resolveSegmentContent(client, streamId);
|
||||
const attachments = await resolveAccumulatedAttachments({
|
||||
client,
|
||||
conversationId,
|
||||
responseMessageId,
|
||||
});
|
||||
if (content.length === 0 && attachments.length === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await saveMessage(
|
||||
{
|
||||
userId,
|
||||
isTemporary: meta.isTemporary ?? req.body?.isTemporary,
|
||||
interfaceConfig: req?.config?.interfaceConfig,
|
||||
},
|
||||
{
|
||||
messageId: responseMessageId,
|
||||
conversationId,
|
||||
...(content.length > 0 && { content }),
|
||||
...(attachments.length > 0 && { attachments }),
|
||||
unfinished: true,
|
||||
user: userId,
|
||||
},
|
||||
{ context: 'api/server/controllers/agents/resume.js - re-pause progress persist' },
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error('[ResumeAgentController] Failed to persist re-pause progress', err);
|
||||
}
|
||||
}
|
||||
|
||||
/** Untenanted jobs (pre-multi-tenancy) remain accessible if the userId check passes. */
|
||||
function hasTenantMismatch(job, user) {
|
||||
return job.metadata?.tenantId != null && job.metadata.tenantId !== user.tenantId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the SDK resume value from the wire decision payload, validating against the
|
||||
* pending action. Returns `{ resumeValue }` on success or `{ error }` with an HTTP
|
||||
* status for the route to surface.
|
||||
*/
|
||||
function resolveResumeValue(pendingAction, body) {
|
||||
const payload = pendingAction.payload;
|
||||
if (payload?.type === 'tool_approval') {
|
||||
const resolutions = Array.isArray(body.decisions) ? body.decisions : [];
|
||||
const undecided = findUndecidedToolCalls(payload, resolutions);
|
||||
if (undecided.length > 0) {
|
||||
return { status: 400, error: 'Every paused tool call must be decided', undecided };
|
||||
}
|
||||
// Enforce the policy's per-tool allowed_decisions — a crafted POST must not
|
||||
// approve a tool the policy restricted to (e.g.) reject/respond.
|
||||
const disallowed = findDisallowedDecisions(payload, resolutions);
|
||||
if (disallowed.length > 0) {
|
||||
return { status: 403, error: 'Decision not permitted for one or more tools', disallowed };
|
||||
}
|
||||
// `edit`/`respond` must carry their payload — otherwise toSdkDecision's defensive
|
||||
// defaults ({} / '') would resume with an empty input/result the user didn't approve.
|
||||
const incomplete = findIncompleteDecisions(resolutions);
|
||||
if (incomplete.length > 0) {
|
||||
return {
|
||||
status: 400,
|
||||
error: 'edit requires editedArguments and respond requires responseText',
|
||||
incomplete,
|
||||
};
|
||||
}
|
||||
return { resumeValue: mapToolApprovalResolutions(resolutions) };
|
||||
}
|
||||
if (payload?.type === 'ask_user_question') {
|
||||
if (typeof body.answer !== 'string' || body.answer.length === 0) {
|
||||
return { status: 400, error: 'An answer is required' };
|
||||
}
|
||||
// The answer becomes a ToolMessage the model must ingest — bound it like any
|
||||
// other user-controlled wire field rather than trusting the client.
|
||||
if (body.answer.length > MAX_ASK_ANSWER_LENGTH) {
|
||||
return { status: 400, error: 'Answer exceeds the maximum length' };
|
||||
}
|
||||
return { resumeValue: mapAskUserAnswer({ answer: body.answer }) };
|
||||
}
|
||||
return { status: 400, error: 'Unsupported pending action type' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Finalize a resumed turn that ran to completion: persist the (now complete)
|
||||
* response message, emit the terminal event over the existing SSE, complete the
|
||||
* job, and prune the checkpoint. Mirrors the abort route's save shape but for a
|
||||
* successful finish. Best-effort title generation for a first-turn pause.
|
||||
*/
|
||||
async function finalizeResumedTurn({ req, client, job, streamId, conversationId, addTitle }) {
|
||||
const userId = req.user.id;
|
||||
const checkpointerCfg = req.config?.endpoints?.[EModelEndpoint.agents]?.checkpointer;
|
||||
const meta = job.metadata ?? {};
|
||||
const userMessage = meta.userMessage;
|
||||
// The response hangs off the user message; the *user* message's own parent decides
|
||||
// whether this is the first turn of the conversation (title eligibility).
|
||||
const parentMessageId = userMessage?.messageId ?? Constants.NO_PARENT;
|
||||
const isFirstTurn = (userMessage?.parentMessageId ?? Constants.NO_PARENT) === Constants.NO_PARENT;
|
||||
const responseMessageId = meta.responseMessageId ?? `${userMessage?.messageId ?? 'resumed'}_`;
|
||||
// Sourced from the paused job (persisted at creation), not the resume body — a
|
||||
// temporary chat must stay temporary on resume so its messages aren't persisted.
|
||||
const isTemporary = meta.isTemporary ?? req.body?.isTemporary;
|
||||
|
||||
// Read the raw job data BEFORE completeJob deletes it — its tracked token/context
|
||||
// usage backs the response message's cost rollup (parity with normal completion).
|
||||
const jobData = await GenerationJobManager.getJobStore().getJob(streamId);
|
||||
|
||||
// Job-replacement guard (mirrors the normal request path): jobs are keyed by streamId
|
||||
// (== conversationId), so a new/concurrent request reusing this conversation overwrites
|
||||
// the record with a fresh createdAt. If that happened while we were resuming, finalizing
|
||||
// now would emit `done` to / complete / delete the NEWER turn's job. Skip all terminal
|
||||
// side effects when the job we paused is no longer the live one; the caller's `finally`
|
||||
// still disposes the client + releases the slot.
|
||||
if (!jobData || jobData.createdAt !== job.createdAt) {
|
||||
logger.warn(
|
||||
`[ResumeAgentController] Skipping resumed finalization — job ${streamId} was replaced`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// Prefer the resumed run's live content: it's complete (seeded with the pre-pause
|
||||
// content) and avoids a Redis re-read that can race appendChunk writes still in
|
||||
// flight. Fall back to the aggregated store content only when the live array is empty.
|
||||
const liveContent = Array.isArray(client?.contentParts) ? client.contentParts : [];
|
||||
const rawContent =
|
||||
liveContent.length > 0
|
||||
? liveContent
|
||||
: ((await GenerationJobManager.getResumeState(streamId))?.aggregatedContent ?? []);
|
||||
// Parity with the normal agents path (AgentClient strips these before saving):
|
||||
// drop empty/malformed tool_call parts so a resumed turn can't persist an invalid
|
||||
// part that breaks reload/rendering.
|
||||
const content = filterMalformedContentParts(rawContent);
|
||||
|
||||
const responseMessage = {
|
||||
messageId: responseMessageId,
|
||||
parentMessageId,
|
||||
conversationId,
|
||||
content,
|
||||
sender: meta.sender ?? client?.sender ?? 'AI',
|
||||
endpoint: meta.endpoint,
|
||||
iconURL: meta.iconURL,
|
||||
model: meta.model,
|
||||
unfinished: false,
|
||||
error: false,
|
||||
isCreatedByUser: false,
|
||||
user: userId,
|
||||
};
|
||||
if (meta.agent_id ?? req.body?.agent_id) {
|
||||
responseMessage.agent_id = meta.agent_id ?? req.body.agent_id;
|
||||
}
|
||||
// Persist tool artifacts (code files, images, UI resources) the resumed continuation
|
||||
// produced — BaseClient.sendMessage awaits these before saving, but the lean resume
|
||||
// path bypasses it, so do it here or they vanish on reload / for late subscribers.
|
||||
// MERGE with any already on the row (earlier pause segments) rather than overwrite —
|
||||
// the final segment's client only holds its own segment's artifacts.
|
||||
const attachments = await resolveAccumulatedAttachments({
|
||||
client,
|
||||
conversationId,
|
||||
responseMessageId,
|
||||
});
|
||||
if (attachments.length > 0) {
|
||||
responseMessage.attachments = attachments;
|
||||
}
|
||||
|
||||
// Response metadata: the resume client only sees POST-resume usage, while the job's
|
||||
// tracked tokenUsage is cumulative across the pause. Take the cumulative usage (+
|
||||
// summary marker) from the job, and contextUsage / thoughtSignatures from the client
|
||||
// (which the abort-only helper drops). Cumulative usage wins so cost isn't underreported.
|
||||
const clientMeta = client?.buildResponseMetadata?.() ?? null;
|
||||
const cumulativeMeta = jobData ? buildAbortedResponseMetadata(jobData) : null;
|
||||
const responseMetadata = {
|
||||
...(clientMeta ?? {}),
|
||||
...(cumulativeMeta?.usage ? { usage: cumulativeMeta.usage } : {}),
|
||||
...(cumulativeMeta?.summaryUsedTokens != null
|
||||
? { summaryUsedTokens: cumulativeMeta.summaryUsedTokens }
|
||||
: {}),
|
||||
};
|
||||
if (Object.keys(responseMetadata).length > 0) {
|
||||
responseMessage.metadata = responseMetadata;
|
||||
}
|
||||
// Carry the resumed run's context-window calibration (BaseClient.sendMessage persists
|
||||
// this on the response). Without it, the NEXT turn can't seed its pruner from this
|
||||
// run and falls back to uncalibrated token accounting.
|
||||
if (client?.contextMeta != null) {
|
||||
responseMessage.contextMeta = client.contextMeta;
|
||||
}
|
||||
|
||||
await saveMessage(
|
||||
{ userId, isTemporary, interfaceConfig: req?.config?.interfaceConfig },
|
||||
responseMessage,
|
||||
{ context: 'api/server/controllers/agents/resume.js - resumed response end' },
|
||||
);
|
||||
|
||||
const convo = await getConvo(userId, conversationId);
|
||||
const conversation = { ...(convo ?? {}), conversationId };
|
||||
|
||||
// First-turn pause: the title was deferred when the turn paused. Generate it BEFORE
|
||||
// completing the stream so the `title` event still reaches the live client (emitChunk
|
||||
// no-ops once completeJob tears down the runtime) and the final event carries the real
|
||||
// title instead of "New Chat". Best-effort — a failure must not fail the resumed turn.
|
||||
if (
|
||||
addTitle &&
|
||||
isFirstTurn &&
|
||||
!isTemporary &&
|
||||
userMessage?.text &&
|
||||
(!convo || !convo.title || convo.title === 'New Chat')
|
||||
) {
|
||||
try {
|
||||
await addTitle(req, {
|
||||
text: userMessage.text,
|
||||
conversationId,
|
||||
client,
|
||||
onTitleGenerated: ({ conversationId: titleConvoId, title }) => {
|
||||
conversation.title = title;
|
||||
return GenerationJobManager.emitChunk(streamId, {
|
||||
event: 'title',
|
||||
data: { conversationId: titleConvoId, title },
|
||||
});
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('[ResumeAgentController] Title generation failed after resume', err);
|
||||
}
|
||||
}
|
||||
conversation.title = conversation.title || 'New Chat';
|
||||
|
||||
// Re-check ownership immediately before the terminal writes. The start-of-function
|
||||
// guard can go stale across the awaits above: saveMessage and (first-turn) title
|
||||
// generation can take long enough for a new request to replace this job on the same
|
||||
// conversationId (streamId == conversationId). Without this second read, emitDone /
|
||||
// completeJob / prune below would emit `done` to and tear down the REPLACEMENT job —
|
||||
// the same hazard the catch-path guard prevents on the failure path.
|
||||
const liveJobBeforeFinalize = await GenerationJobManager.getJobStore().getJob(streamId);
|
||||
if (!liveJobBeforeFinalize || liveJobBeforeFinalize.createdAt !== job.createdAt) {
|
||||
logger.warn(
|
||||
`[ResumeAgentController] Skipping resumed terminal writes — job ${streamId} was replaced mid-finalize`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const finalEvent = {
|
||||
final: true,
|
||||
conversation,
|
||||
title: conversation.title,
|
||||
requestMessage: userMessage
|
||||
? sanitizeMessageForTransmit({
|
||||
...userMessage,
|
||||
conversationId,
|
||||
isCreatedByUser: true,
|
||||
// job.metadata.userMessage is persisted without files; carry the restored
|
||||
// uploads (seeded onto req.body.files before reconstruction) so the final SSE
|
||||
// doesn't blank the user bubble's attachments — matching the normal path.
|
||||
...(Array.isArray(req.body?.files) && req.body.files.length > 0
|
||||
? { files: req.body.files }
|
||||
: {}),
|
||||
})
|
||||
: null,
|
||||
responseMessage: { ...responseMessage },
|
||||
};
|
||||
|
||||
await GenerationJobManager.emitDone(streamId, finalEvent);
|
||||
// Awaited (not fire-and-forget) so the job's terminal write lands before the
|
||||
// checkpoint prune, and so a failure here doesn't race the controller's error path.
|
||||
try {
|
||||
await GenerationJobManager.completeJob(streamId);
|
||||
} catch (completeErr) {
|
||||
logger.error('[ResumeAgentController] Failed to complete resumed turn', completeErr);
|
||||
}
|
||||
await deleteAgentCheckpoint(conversationId, checkpointerCfg);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resume a generation that paused for human-in-the-loop review.
|
||||
*
|
||||
* The original run lives in a detached background task that exits when the run
|
||||
* pauses, so this REBUILDS the run from the durable checkpoint (same `thread_id`)
|
||||
* and continues it with the user's decision. The continuation streams over the
|
||||
* client's existing SSE (events flow through the same `streamId`).
|
||||
*
|
||||
* Flow: authorize → map decisions → atomically claim the resume (single-winner) →
|
||||
* ACK → reconstruct the client → `resumeCompletion` → finalize (or re-pause).
|
||||
*
|
||||
* Shares chat.js's middleware (auth, agent access, `buildEndpointOption`) so the
|
||||
* agent/endpoint are reconstructed from the request exactly like a normal turn.
|
||||
*
|
||||
* @param {express.Request} req
|
||||
* @param {express.Response} res
|
||||
* @param {express.NextFunction} next
|
||||
* @param {Function} initializeClient
|
||||
* @param {Function} addTitle
|
||||
*/
|
||||
const ResumeAgentController = async (req, res, next, initializeClient, addTitle) => {
|
||||
const userId = req.user.id;
|
||||
const { conversationId, actionId } = req.body;
|
||||
const streamId = conversationId;
|
||||
|
||||
if (!streamId || streamId === 'new') {
|
||||
return res.status(400).json({ error: 'conversationId is required to resume' });
|
||||
}
|
||||
|
||||
const job = await GenerationJobManager.getJob(streamId);
|
||||
if (!job) {
|
||||
return res.status(404).json({ error: 'No paused generation for this conversation' });
|
||||
}
|
||||
if (job.metadata?.userId && job.metadata.userId !== userId) {
|
||||
return res.status(403).json({ error: 'Unauthorized' });
|
||||
}
|
||||
if (hasTenantMismatch(job, req.user)) {
|
||||
return res.status(403).json({ error: 'Unauthorized' });
|
||||
}
|
||||
|
||||
// The resume must rebuild the SAME agent/endpoint that paused. Require an EXACT
|
||||
// agent_id match when the paused job had one — a request that omits agent_id (or
|
||||
// claims an ephemeral / non-agents endpoint) must not rebuild the claimed checkpoint
|
||||
// on a different graph. The conversation's agent is stable, so a correct client always
|
||||
// sends the right one.
|
||||
const originalAgentId = job.metadata?.agent_id;
|
||||
if (originalAgentId && req.body.agent_id !== originalAgentId) {
|
||||
return res.status(403).json({ error: 'Cannot resume with a different agent' });
|
||||
}
|
||||
// Require an EXACT endpoint match (like agent_id): a request that OMITS endpoint must
|
||||
// not fall through — the shared chat middleware treats a missing/non-agents endpoint
|
||||
// as the ephemeral agent, so omitting it could rebuild the claimed checkpoint on a
|
||||
// different graph. A correct client always echoes the paused endpoint.
|
||||
const originalEndpoint = job.metadata?.endpoint;
|
||||
if (originalEndpoint && req.body.endpoint !== originalEndpoint) {
|
||||
return res.status(403).json({ error: 'Cannot resume on a different endpoint' });
|
||||
}
|
||||
|
||||
const pendingAction = job.metadata?.pendingAction;
|
||||
if (job.status !== 'requires_action') {
|
||||
return res.status(409).json({ error: 'No live pending action to resume' });
|
||||
}
|
||||
if (isPendingActionStale({ pendingAction })) {
|
||||
// The action expired between the pending-action SSE and this submit. Drive the expiry
|
||||
// NOW (expire CAS + terminal SSE) instead of waiting for the periodic sweeper —
|
||||
// otherwise the job sits `requires_action` with a dead action and any attached SSE
|
||||
// client never gets a terminal event, so the stream appears to hang even though the
|
||||
// UI already reported the action as expired.
|
||||
try {
|
||||
await GenerationJobManager.expireApproval(streamId, pendingAction?.actionId);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
'[ResumeAgentController] Failed to expire stale action on submit',
|
||||
err?.message ?? err,
|
||||
);
|
||||
}
|
||||
return res.status(409).json({ error: 'No live pending action to resume' });
|
||||
}
|
||||
// Require the actionId the UI sends: without it, a stale/malformed client could
|
||||
// resolve whatever action is currently pending (e.g. answer a different question).
|
||||
if (!actionId) {
|
||||
return res.status(400).json({ error: 'actionId is required to resume' });
|
||||
}
|
||||
if (pendingAction.actionId !== actionId) {
|
||||
return res.status(409).json({ error: 'This decision targets a stale action' });
|
||||
}
|
||||
|
||||
// Pin the graph identity: the resume must rebuild the SAME agent/graph + tool set the
|
||||
// run paused on. The agent_id + endpoint guards above cover saved agents; the
|
||||
// fingerprint additionally catches an ephemeral-agent config swap (its agent_id is
|
||||
// undefined, so the id guard can't tell two ephemeral configs apart). Enforced only
|
||||
// when the paused action carries a fingerprint (in-flight pauses from before this
|
||||
// change won't), and recomputed from the resume body's graph-determining fields.
|
||||
const pinnedFingerprint = pendingAction.requestFingerprint;
|
||||
if (pinnedFingerprint && pinnedFingerprint !== computeAgentRequestFingerprint(req.body ?? {})) {
|
||||
return res.status(403).json({ error: 'Cannot resume with a different agent configuration' });
|
||||
}
|
||||
|
||||
const mapped = resolveResumeValue(pendingAction, req.body);
|
||||
if (mapped.error) {
|
||||
return res.status(mapped.status).json({
|
||||
error: mapped.error,
|
||||
...(mapped.undecided && { undecided: mapped.undecided }),
|
||||
...(mapped.disallowed && { disallowed: mapped.disallowed }),
|
||||
...(mapped.incomplete && { incomplete: mapped.incomplete }),
|
||||
});
|
||||
}
|
||||
|
||||
// Count the resume against the concurrency limit. The original turn released its slot
|
||||
// when it paused, so resuming must re-acquire one — otherwise pausing several turns
|
||||
// and resuming them at once would bypass LIMIT_CONCURRENT_MESSAGES.
|
||||
const { allowed } = await checkAndIncrementPendingRequest(userId);
|
||||
if (!allowed) {
|
||||
return res.status(429).json({ error: 'Too many concurrent requests' });
|
||||
}
|
||||
|
||||
// Atomically claim the resume. The single winner drives the run; a racing second
|
||||
// submit (double-click, two tabs) gets false and must not re-drive — that would
|
||||
// re-execute tools and double-bill.
|
||||
//
|
||||
// The claim runs AFTER the slot increment above but BEFORE the run's own try/finally
|
||||
// that releases it, so a store/Redis error here (unlike the clean `!claimed` branch)
|
||||
// would leak the concurrency slot until the counter TTL expires — spuriously 429'ing
|
||||
// the user when they retry the still-paused approval. Release the slot on that path too.
|
||||
let claimed;
|
||||
try {
|
||||
claimed = await GenerationJobManager.approvals.resolve(streamId, pendingAction.actionId);
|
||||
} catch (err) {
|
||||
await decrementPendingRequest(userId);
|
||||
logger.error('[ResumeAgentController] Failed to claim resume', err);
|
||||
return res.status(500).json({ error: 'Failed to resume' });
|
||||
}
|
||||
if (!claimed) {
|
||||
await decrementPendingRequest(userId);
|
||||
return res.status(409).json({ error: 'This action was already resolved or has expired' });
|
||||
}
|
||||
|
||||
// Seed the run-scoped MCP request-context store BEFORE the ACK: once `res.json`
|
||||
// finishes the response, a later `getMCPRequestContext(req, res)` (from tool loading)
|
||||
// sees `res` as ended and returns undefined, leaving the resumed run without its MCP
|
||||
// connection store — approved MCP / OAuth-overlay tools would then run without their
|
||||
// request-scoped connections. Pre-seeding with a null `res` + `cleanupOnResponse:false`
|
||||
// mirrors the normal stream path (request.js); torn down in the `finally` below.
|
||||
req._resumableStreamId = streamId;
|
||||
getMCPRequestContext(req, undefined, { cleanupOnResponse: false });
|
||||
|
||||
// ACK immediately; the continuation streams over the client's existing SSE.
|
||||
res.json({ streamId, conversationId, status: 'resuming' });
|
||||
|
||||
// Seed the original thread parent BEFORE initializeClient: initializeAgent scopes
|
||||
// thread files / code artifacts off `req.body.parentMessageId`, and the resume body
|
||||
// doesn't carry it. This is the user message's parent (the thread position);
|
||||
// `client.parentMessageId` below is a different value — the response's parent, i.e.
|
||||
// the user message id.
|
||||
req.body.parentMessageId = job.metadata.userMessage?.parentMessageId ?? Constants.NO_PARENT;
|
||||
|
||||
// Restore the paused user message's OWN uploaded files. initializeAgent rebuilds
|
||||
// code/file sessions by walking the conversation from `parentMessageId`, but
|
||||
// execute-code files are excluded from that lookup, so files uploaded on the paused
|
||||
// turn would be dropped — an approved code/read-file tool would resume without them.
|
||||
//
|
||||
// SECURITY: ALWAYS source files from the paused job, never from the `/resume` body.
|
||||
// `files` is not pinned by the resume fingerprint or replayed via resumeContext, so
|
||||
// honoring a client-supplied `files` array would let a crafted/buggy client resume an
|
||||
// approved code/read-file tool against a DIFFERENT file set than the one the user
|
||||
// approved. A resume reconstructs the SAME paused turn, so there is no legitimate
|
||||
// reason for the client to supply its own files. Prefer the files persisted on the JOB
|
||||
// at onStart (race-free), fall back to the DB row for older jobs, and CLEAR otherwise
|
||||
// so a client-supplied set can never leak through.
|
||||
const metaFiles = job.metadata.userMessage?.files;
|
||||
if (Array.isArray(metaFiles) && metaFiles.length > 0) {
|
||||
req.body.files = metaFiles;
|
||||
} else {
|
||||
let restoredFiles = false;
|
||||
const pausedUserMessageId = job.metadata.userMessage?.messageId;
|
||||
if (pausedUserMessageId) {
|
||||
try {
|
||||
const [row] = await getMessages(
|
||||
{ conversationId, messageId: pausedUserMessageId },
|
||||
'files',
|
||||
);
|
||||
if (Array.isArray(row?.files) && row.files.length > 0) {
|
||||
req.body.files = row.files;
|
||||
restoredFiles = true;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
'[ResumeAgentController] Failed to restore paused user message files',
|
||||
err?.message ?? err,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!restoredFiles) {
|
||||
// No paused files (or the lookup failed): drop any client-supplied files so a
|
||||
// crafted resume body can't inject a file set the paused turn never had.
|
||||
req.body.files = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the conversation's createdAt so temporal prompt vars ({{current_datetime}},
|
||||
// {{iso_datetime}}, ...) resolve against the SAME anchor the paused graph used rather
|
||||
// than the resume wall-clock. initializeAgent reads `req.conversationCreatedAt`; the
|
||||
// normal path sets it from the convo timestamp (resolveConversationCreatedAt), so mirror
|
||||
// that here. (The original `timezone` is replayed onto req.body via RESUME_CONTEXT_KEYS.)
|
||||
try {
|
||||
const resumedConvo = await getConvo(userId, conversationId);
|
||||
const createdAt = resumedConvo?.createdAt ? new Date(resumedConvo.createdAt) : null;
|
||||
if (createdAt && !Number.isNaN(createdAt.getTime())) {
|
||||
req.conversationCreatedAt = createdAt.toISOString();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
'[ResumeAgentController] Failed to restore conversation timestamp anchor',
|
||||
err?.message ?? err,
|
||||
);
|
||||
}
|
||||
|
||||
let client = null;
|
||||
try {
|
||||
const result = await initializeClient({
|
||||
req,
|
||||
res,
|
||||
endpointOption: req.body.endpointOption,
|
||||
signal: job.abortController.signal,
|
||||
});
|
||||
client = result.client;
|
||||
|
||||
// Bind the rebuilt client to the in-flight turn's identity (no new user message).
|
||||
client.conversationId = streamId;
|
||||
// The resume operates on the SAME job (it moved it running again), so its identity is
|
||||
// the paused job's createdAt — used by the re-pause CAS pre-check + checkpoint prune to
|
||||
// avoid acting on a job a newer request has since replaced.
|
||||
client.jobCreatedAt = job.createdAt;
|
||||
client.responseMessageId = job.metadata.responseMessageId;
|
||||
client.parentMessageId = job.metadata.userMessage?.messageId ?? Constants.NO_PARENT;
|
||||
// Read the pre-pause content BEFORE swapping the store's content reference: the
|
||||
// in-memory store's setContentParts REPLACES the stored array, so reading the
|
||||
// resume state afterward would see the new (empty) client array and lose the seed.
|
||||
const resumeState = await GenerationJobManager.getResumeState(streamId);
|
||||
let seedContent = resumeState?.aggregatedContent ?? [];
|
||||
// Stamp the answered question onto the paused ask_user_question tool-call part
|
||||
// (args = the pendingAction's authoritative question, output = the user's answer):
|
||||
// the streamed arg chunks carry no tool name so the aggregator dropped them, and
|
||||
// no completion event ever fires for this tool — without this the saved part is
|
||||
// an empty "cancelled-looking" tool call. See attachAskUserQuestionAnswer.
|
||||
if (pendingAction.payload?.type === 'ask_user_question') {
|
||||
seedContent = attachAskUserQuestionAnswer(
|
||||
seedContent,
|
||||
pendingAction.payload.question,
|
||||
req.body.answer,
|
||||
);
|
||||
}
|
||||
if (client.contentParts) {
|
||||
GenerationJobManager.setContentParts(streamId, client.contentParts);
|
||||
}
|
||||
|
||||
await client.resumeCompletion({
|
||||
resumeValue: mapped.resumeValue,
|
||||
seedContent,
|
||||
abortController: job.abortController,
|
||||
// Carry the user's MCP auth so approved MCP tools run with their credentials.
|
||||
userMCPAuthMap: result.userMCPAuthMap,
|
||||
// Replay deferred tools discovered before the pause (captured at pause). The rebuilt
|
||||
// graph passes `messages: []`, so without these an approved deferred tool would be
|
||||
// absent from the schema-only toolMap and resume would fail with "unknown tool".
|
||||
discoveredToolNames: job.metadata?.discoveredTools,
|
||||
});
|
||||
|
||||
// The model may pause AGAIN (another tool, or a follow-up question). The pending
|
||||
// action is already persisted + emitted; leave the job `requires_action`.
|
||||
if (client.pendingApproval) {
|
||||
logger.debug(`[ResumeAgentController] Re-paused for approval: ${streamId}`);
|
||||
// Persist this segment's content + artifacts before the fresh client (next
|
||||
// resume) drops them, so an expiring re-pause doesn't lose them; finalize later
|
||||
// overwrites content and merges attachments onto the saved message.
|
||||
await persistRePauseProgress({ req, client, job, streamId, conversationId });
|
||||
return;
|
||||
}
|
||||
|
||||
// If the user aborted mid-resume, the abort route already emitted the terminal
|
||||
// event and finalized the job — don't double-save / double-finalize here.
|
||||
if (job.abortController.signal.aborted) {
|
||||
logger.debug(
|
||||
`[ResumeAgentController] Aborted during resume; abort route finalizes: ${streamId}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await finalizeResumedTurn({ req, client, job, streamId, conversationId, addTitle });
|
||||
} catch (err) {
|
||||
logger.error('[ResumeAgentController] Resume failed', err);
|
||||
// Job-replacement guard (mirrors finalizeResumedTurn's success-path guard): if a
|
||||
// newer request reused this conversationId while the resume was failing, do NOT emit
|
||||
// the error to / complete / prune the NEWER turn's job. The finally still releases
|
||||
// the slot + disposes. Proceed with finalization if the replacement check itself fails.
|
||||
let stillLive = true;
|
||||
try {
|
||||
const liveJob = await GenerationJobManager.getJobStore().getJob(streamId);
|
||||
stillLive = !!liveJob && liveJob.createdAt === job.createdAt;
|
||||
} catch (readErr) {
|
||||
logger.warn('[ResumeAgentController] Replacement check failed; finalizing anyway', readErr);
|
||||
}
|
||||
if (!stillLive) {
|
||||
logger.warn(
|
||||
`[ResumeAgentController] Skipping failed-resume finalization — job ${streamId} was replaced`,
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
await GenerationJobManager.emitError(streamId, err?.message ?? 'Resume failed');
|
||||
} catch (emitErr) {
|
||||
logger.error('[ResumeAgentController] Failed to emit resume error', emitErr);
|
||||
}
|
||||
try {
|
||||
await GenerationJobManager.completeJob(streamId, err?.message ?? 'Resume failed');
|
||||
} catch (completeErr) {
|
||||
logger.error('[ResumeAgentController] Failed to finalize failed resume', completeErr);
|
||||
// Last resort: force a terminal state so the job isn't orphaned in `running`.
|
||||
await GenerationJobManager.getJobStore()
|
||||
.updateJob(streamId, {
|
||||
status: 'error',
|
||||
completedAt: Date.now(),
|
||||
error: 'Resume failed',
|
||||
})
|
||||
.catch((updErr) =>
|
||||
logger.error('[ResumeAgentController] Fallback job finalize failed', updErr),
|
||||
);
|
||||
}
|
||||
await deleteAgentCheckpoint(
|
||||
conversationId,
|
||||
req.config?.endpoints?.[EModelEndpoint.agents]?.checkpointer,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
// Tear down the MCP request-context store seeded before the ACK (parity with
|
||||
// request.js's finishResumableRequest). No-op if it was never seeded.
|
||||
await cleanupMCPRequestContextForReq(req);
|
||||
// Release the concurrency slot taken above — UNLESS handleRunInterrupt already
|
||||
// released it on a re-pause (so a fast /resume isn't 429'd). On a normal finish or
|
||||
// error it didn't, so release here. A re-pause re-acquires its own slot next resume.
|
||||
if (!client?.pendingRequestReleased) {
|
||||
await decrementPendingRequest(userId);
|
||||
}
|
||||
if (client) {
|
||||
disposeClient(client);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = ResumeAgentController;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,676 @@
|
||||
const { v4 } = require('uuid');
|
||||
const { sleep } = require('@librechat/agents');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const {
|
||||
sendEvent,
|
||||
countTokens,
|
||||
checkBalance,
|
||||
getBalanceConfig,
|
||||
getModelMaxTokens,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
Time,
|
||||
Constants,
|
||||
RunStatus,
|
||||
CacheKeys,
|
||||
VisionModes,
|
||||
ContentTypes,
|
||||
EModelEndpoint,
|
||||
ViolationTypes,
|
||||
ImageVisionTool,
|
||||
checkOpenAIStorage,
|
||||
AssistantStreamEvents,
|
||||
} = require('librechat-data-provider');
|
||||
const {
|
||||
initThread,
|
||||
recordUsage,
|
||||
saveUserMessage,
|
||||
checkMessageGaps,
|
||||
addThreadMetadata,
|
||||
saveAssistantMessage,
|
||||
} = require('~/server/services/Threads');
|
||||
const { runAssistant, createOnTextProgress } = require('~/server/services/AssistantService');
|
||||
const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
|
||||
const { formatMessage, createVisionPrompt } = require('~/app/clients/prompts');
|
||||
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
|
||||
const { createRun, StreamRunManager } = require('~/server/services/Runs');
|
||||
const { addTitle } = require('~/server/services/Endpoints/assistants');
|
||||
const { createRunBody } = require('~/server/services/createRunBody');
|
||||
const { sendResponse } = require('~/server/middleware/error');
|
||||
const {
|
||||
createAutoRefillTransaction,
|
||||
findBalanceByUser,
|
||||
upsertBalanceFields,
|
||||
getTransactions,
|
||||
getMultiplier,
|
||||
getConvo,
|
||||
} = require('~/models');
|
||||
const { logViolation, getLogStores } = require('~/cache');
|
||||
const { getOpenAIClient } = require('./helpers');
|
||||
|
||||
/**
|
||||
* @route POST /
|
||||
* @desc Chat with an assistant
|
||||
* @access Public
|
||||
* @param {object} req - The request object, containing the request data.
|
||||
* @param {object} req.body - The request payload.
|
||||
* @param {Express.Response} res - The response object, used to send back a response.
|
||||
* @returns {void}
|
||||
*/
|
||||
const chatV1 = async (req, res) => {
|
||||
const appConfig = req.config;
|
||||
logger.debug('[/assistants/chat/] req.body', req.body);
|
||||
|
||||
const {
|
||||
text,
|
||||
model,
|
||||
endpoint,
|
||||
files = [],
|
||||
promptPrefix,
|
||||
assistant_id,
|
||||
instructions,
|
||||
endpointOption,
|
||||
thread_id: _thread_id,
|
||||
messageId: _messageId,
|
||||
conversationId: convoId,
|
||||
parentMessageId: _parentId = Constants.NO_PARENT,
|
||||
clientTimestamp,
|
||||
} = req.body;
|
||||
|
||||
/** @type {OpenAI} */
|
||||
let openai;
|
||||
/** @type {string|undefined} - the current thread id */
|
||||
let thread_id = _thread_id;
|
||||
/** @type {string|undefined} - the current run id */
|
||||
let run_id;
|
||||
/** @type {string|undefined} - the parent messageId */
|
||||
let parentMessageId = _parentId;
|
||||
/** @type {TMessage[]} */
|
||||
let previousMessages = [];
|
||||
/** @type {import('librechat-data-provider').TConversation | null} */
|
||||
let conversation = null;
|
||||
/** @type {string[]} */
|
||||
let file_ids = [];
|
||||
/** @type {Set<string>} */
|
||||
let attachedFileIds = new Set();
|
||||
/** @type {TMessage | null} */
|
||||
let requestMessage = null;
|
||||
/** @type {undefined | Promise<ChatCompletion>} */
|
||||
let visionPromise;
|
||||
|
||||
const userMessageId = v4();
|
||||
const responseMessageId = v4();
|
||||
|
||||
/** @type {string} - The conversation UUID - created if undefined */
|
||||
const conversationId = convoId ?? v4();
|
||||
|
||||
const cache = getLogStores(CacheKeys.ABORT_KEYS);
|
||||
const cacheKey = `${req.user.id}:${conversationId}`;
|
||||
|
||||
/** @type {Run | undefined} - The completed run, undefined if incomplete */
|
||||
let completedRun;
|
||||
|
||||
const handleError = async (error) => {
|
||||
const defaultErrorMessage =
|
||||
'The Assistant run failed to initialize. Try sending a message in a new conversation.';
|
||||
const messageData = {
|
||||
thread_id,
|
||||
assistant_id,
|
||||
conversationId,
|
||||
parentMessageId,
|
||||
sender: 'System',
|
||||
user: req.user.id,
|
||||
shouldSaveMessage: false,
|
||||
messageId: responseMessageId,
|
||||
endpoint,
|
||||
};
|
||||
|
||||
if (error.message === 'Run cancelled') {
|
||||
return res.end();
|
||||
} else if (error.message === 'Request closed' && completedRun) {
|
||||
return;
|
||||
} else if (error.message === 'Request closed') {
|
||||
logger.debug('[/assistants/chat/] Request aborted on close');
|
||||
} else if (/Files.*are invalid/.test(error.message)) {
|
||||
const errorMessage = `Files are invalid, or may not have uploaded yet.${
|
||||
endpoint === EModelEndpoint.azureAssistants
|
||||
? " If using Azure OpenAI, files are only available in the region of the assistant's model at the time of upload."
|
||||
: ''
|
||||
}`;
|
||||
return sendResponse(req, res, messageData, errorMessage);
|
||||
} else if (error?.message?.includes('string too long')) {
|
||||
return sendResponse(
|
||||
req,
|
||||
res,
|
||||
messageData,
|
||||
'Message too long. The Assistants API has a limit of 32,768 characters per message. Please shorten it and try again.',
|
||||
);
|
||||
} else if (error?.message?.includes(ViolationTypes.TOKEN_BALANCE)) {
|
||||
return sendResponse(req, res, messageData, error.message);
|
||||
} else {
|
||||
logger.error('[/assistants/chat/]', error);
|
||||
}
|
||||
|
||||
if (!openai || !thread_id || !run_id) {
|
||||
return sendResponse(req, res, messageData, defaultErrorMessage);
|
||||
}
|
||||
|
||||
await sleep(2000);
|
||||
|
||||
try {
|
||||
const status = await cache.get(cacheKey);
|
||||
if (status === 'cancelled') {
|
||||
logger.debug('[/assistants/chat/] Run already cancelled');
|
||||
return res.end();
|
||||
}
|
||||
await cache.delete(cacheKey);
|
||||
const cancelledRun = await openai.beta.threads.runs.cancel(run_id, { thread_id });
|
||||
logger.debug('[/assistants/chat/] Cancelled run:', cancelledRun);
|
||||
} catch (error) {
|
||||
logger.error('[/assistants/chat/] Error cancelling run', error);
|
||||
}
|
||||
|
||||
await sleep(2000);
|
||||
|
||||
let run;
|
||||
try {
|
||||
run = await openai.beta.threads.runs.retrieve(run_id, { thread_id });
|
||||
await recordUsage({
|
||||
...run.usage,
|
||||
model: run.model,
|
||||
user: req.user.id,
|
||||
conversationId,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[/assistants/chat/] Error fetching or processing run', error);
|
||||
}
|
||||
|
||||
let finalEvent;
|
||||
try {
|
||||
const runMessages = await checkMessageGaps({
|
||||
openai,
|
||||
run_id,
|
||||
endpoint,
|
||||
thread_id,
|
||||
conversationId,
|
||||
latestMessageId: responseMessageId,
|
||||
});
|
||||
|
||||
const errorContentPart = {
|
||||
text: {
|
||||
value:
|
||||
error?.message ?? 'There was an error processing your request. Please try again later.',
|
||||
},
|
||||
type: ContentTypes.ERROR,
|
||||
};
|
||||
|
||||
if (!Array.isArray(runMessages[runMessages.length - 1]?.content)) {
|
||||
runMessages[runMessages.length - 1].content = [errorContentPart];
|
||||
} else {
|
||||
const contentParts = runMessages[runMessages.length - 1].content;
|
||||
for (let i = 0; i < contentParts.length; i++) {
|
||||
const currentPart = contentParts[i];
|
||||
/** @type {CodeToolCall | RetrievalToolCall | FunctionToolCall | undefined} */
|
||||
const toolCall = currentPart?.[ContentTypes.TOOL_CALL];
|
||||
if (
|
||||
toolCall &&
|
||||
toolCall?.function &&
|
||||
!(toolCall?.function?.output || toolCall?.function?.output?.length)
|
||||
) {
|
||||
contentParts[i] = {
|
||||
...currentPart,
|
||||
[ContentTypes.TOOL_CALL]: {
|
||||
...toolCall,
|
||||
function: {
|
||||
...toolCall.function,
|
||||
output: 'error processing tool',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
runMessages[runMessages.length - 1].content.push(errorContentPart);
|
||||
}
|
||||
|
||||
finalEvent = {
|
||||
final: true,
|
||||
conversation: await getConvo(req.user.id, conversationId),
|
||||
runMessages,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error('[/assistants/chat/] Error finalizing error process', error);
|
||||
return sendResponse(req, res, messageData, 'The Assistant run failed');
|
||||
}
|
||||
|
||||
return sendResponse(req, res, finalEvent);
|
||||
};
|
||||
|
||||
try {
|
||||
res.on('close', async () => {
|
||||
if (!completedRun) {
|
||||
await handleError(new Error('Request closed'));
|
||||
}
|
||||
});
|
||||
|
||||
if (convoId && !_thread_id) {
|
||||
completedRun = true;
|
||||
throw new Error('Missing thread_id for existing conversation');
|
||||
}
|
||||
|
||||
if (!assistant_id) {
|
||||
completedRun = true;
|
||||
throw new Error('Missing assistant_id');
|
||||
}
|
||||
|
||||
const checkBalanceBeforeRun = async () => {
|
||||
const balanceConfig = getBalanceConfig(appConfig);
|
||||
if (!balanceConfig?.enabled) {
|
||||
return;
|
||||
}
|
||||
const transactions =
|
||||
(await getTransactions({
|
||||
user: req.user.id,
|
||||
context: 'message',
|
||||
conversationId,
|
||||
})) ?? [];
|
||||
|
||||
const totalPreviousTokens = Math.abs(
|
||||
transactions.reduce((acc, curr) => acc + curr.rawAmount, 0),
|
||||
);
|
||||
|
||||
// TODO: make promptBuffer a config option; buffer for titles, needs buffer for system instructions
|
||||
const promptBuffer = parentMessageId === Constants.NO_PARENT && !_thread_id ? 200 : 0;
|
||||
// 5 is added for labels
|
||||
let promptTokens = (await countTokens(text + (promptPrefix ?? ''))) + 5;
|
||||
promptTokens += totalPreviousTokens + promptBuffer;
|
||||
// Count tokens up to the current context window
|
||||
promptTokens = Math.min(promptTokens, getModelMaxTokens(model));
|
||||
|
||||
await checkBalance(
|
||||
{
|
||||
req,
|
||||
res,
|
||||
txData: {
|
||||
model,
|
||||
user: req.user.id,
|
||||
tokenType: 'prompt',
|
||||
amount: promptTokens,
|
||||
},
|
||||
},
|
||||
{
|
||||
findBalanceByUser,
|
||||
getMultiplier,
|
||||
createAutoRefillTransaction,
|
||||
logViolation,
|
||||
balanceConfig,
|
||||
upsertBalanceFields,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const { openai: _openai } = await getOpenAIClient({
|
||||
req,
|
||||
res,
|
||||
endpointOption,
|
||||
});
|
||||
|
||||
openai = _openai;
|
||||
await validateAuthor({ req, openai });
|
||||
|
||||
if (previousMessages.length) {
|
||||
parentMessageId = previousMessages[previousMessages.length - 1].messageId;
|
||||
}
|
||||
|
||||
let userMessage = {
|
||||
role: 'user',
|
||||
content: text,
|
||||
metadata: {
|
||||
messageId: userMessageId,
|
||||
},
|
||||
};
|
||||
|
||||
/** @type {CreateRunBody | undefined} */
|
||||
const body = createRunBody({
|
||||
assistant_id,
|
||||
model,
|
||||
promptPrefix,
|
||||
instructions,
|
||||
endpointOption,
|
||||
clientTimestamp,
|
||||
});
|
||||
|
||||
const getRequestFileIds = async () => {
|
||||
let thread_file_ids = [];
|
||||
if (convoId) {
|
||||
const convo = await getConvo(req.user.id, convoId);
|
||||
if (convo && convo.file_ids) {
|
||||
thread_file_ids = convo.file_ids;
|
||||
}
|
||||
}
|
||||
|
||||
file_ids = files.map(({ file_id }) => file_id);
|
||||
if (file_ids.length || thread_file_ids.length) {
|
||||
attachedFileIds = new Set([...file_ids, ...thread_file_ids]);
|
||||
if (endpoint === EModelEndpoint.azureAssistants) {
|
||||
userMessage.attachments = Array.from(attachedFileIds).map((file_id) => ({
|
||||
file_id,
|
||||
tools: [{ type: 'file_search' }],
|
||||
}));
|
||||
} else {
|
||||
userMessage.file_ids = Array.from(attachedFileIds);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const addVisionPrompt = async () => {
|
||||
if (!endpointOption.attachments) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {MongoFile[]} */
|
||||
const attachments = await endpointOption.attachments;
|
||||
if (attachments && attachments.every((attachment) => checkOpenAIStorage(attachment.source))) {
|
||||
return;
|
||||
}
|
||||
|
||||
const assistant = await openai.beta.assistants.retrieve(assistant_id);
|
||||
const visionToolIndex = assistant.tools.findIndex(
|
||||
(tool) => tool?.function && tool?.function?.name === ImageVisionTool.function.name,
|
||||
);
|
||||
|
||||
if (visionToolIndex === -1) {
|
||||
return;
|
||||
}
|
||||
|
||||
let visionMessage = {
|
||||
role: 'user',
|
||||
content: '',
|
||||
};
|
||||
const { files, image_urls } = await encodeAndFormat(
|
||||
req,
|
||||
attachments,
|
||||
{
|
||||
endpoint: EModelEndpoint.assistants,
|
||||
},
|
||||
VisionModes.generative,
|
||||
);
|
||||
visionMessage.image_urls = image_urls.length ? image_urls : undefined;
|
||||
if (!visionMessage.image_urls?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const imageCount = visionMessage.image_urls.length;
|
||||
const plural = imageCount > 1;
|
||||
visionMessage.content = createVisionPrompt(plural);
|
||||
visionMessage = formatMessage({ message: visionMessage, endpoint: EModelEndpoint.openAI });
|
||||
|
||||
visionPromise = openai.chat.completions
|
||||
.create({
|
||||
messages: [visionMessage],
|
||||
max_tokens: 4000,
|
||||
})
|
||||
.catch((error) => {
|
||||
logger.error('[/assistants/chat/] Error creating vision prompt', error);
|
||||
});
|
||||
|
||||
const pluralized = plural ? 's' : '';
|
||||
body.additional_instructions = `${
|
||||
body.additional_instructions ? `${body.additional_instructions}\n` : ''
|
||||
}The user has uploaded ${imageCount} image${pluralized}.
|
||||
Use the \`${ImageVisionTool.function.name}\` tool to retrieve ${
|
||||
plural ? '' : 'a '
|
||||
}detailed text description${pluralized} for ${plural ? 'each' : 'the'} image${pluralized}.`;
|
||||
|
||||
return files;
|
||||
};
|
||||
|
||||
/** @type {Promise<Run>|undefined} */
|
||||
let userMessagePromise;
|
||||
|
||||
const initializeThread = async () => {
|
||||
/** @type {[ undefined | MongoFile[]]}*/
|
||||
const [processedFiles] = await Promise.all([addVisionPrompt(), getRequestFileIds()]);
|
||||
// TODO: may allow multiple messages to be created beforehand in a future update
|
||||
const initThreadBody = {
|
||||
messages: [userMessage],
|
||||
metadata: {
|
||||
user: req.user.id,
|
||||
conversationId,
|
||||
},
|
||||
};
|
||||
|
||||
if (processedFiles) {
|
||||
for (const file of processedFiles) {
|
||||
if (!checkOpenAIStorage(file.source)) {
|
||||
attachedFileIds.delete(file.file_id);
|
||||
const index = file_ids.indexOf(file.file_id);
|
||||
if (index > -1) {
|
||||
file_ids.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
userMessage.file_ids = file_ids;
|
||||
}
|
||||
|
||||
const result = await initThread({ openai, body: initThreadBody, thread_id });
|
||||
thread_id = result.thread_id;
|
||||
|
||||
createOnTextProgress({
|
||||
openai,
|
||||
conversationId,
|
||||
userMessageId,
|
||||
messageId: responseMessageId,
|
||||
thread_id,
|
||||
});
|
||||
|
||||
requestMessage = {
|
||||
user: req.user.id,
|
||||
text,
|
||||
messageId: userMessageId,
|
||||
parentMessageId,
|
||||
// TODO: make sure client sends correct format for `files`, use zod
|
||||
files,
|
||||
file_ids,
|
||||
conversationId,
|
||||
isCreatedByUser: true,
|
||||
assistant_id,
|
||||
thread_id,
|
||||
model: assistant_id,
|
||||
endpoint,
|
||||
};
|
||||
|
||||
previousMessages.push(requestMessage);
|
||||
|
||||
/* asynchronous */
|
||||
userMessagePromise = saveUserMessage(req, { ...requestMessage, model });
|
||||
|
||||
conversation = {
|
||||
conversationId,
|
||||
endpoint,
|
||||
promptPrefix: promptPrefix,
|
||||
instructions: instructions,
|
||||
assistant_id,
|
||||
// model,
|
||||
};
|
||||
|
||||
if (file_ids.length) {
|
||||
conversation.file_ids = file_ids;
|
||||
}
|
||||
};
|
||||
|
||||
const promises = [initializeThread(), checkBalanceBeforeRun()];
|
||||
await Promise.all(promises);
|
||||
|
||||
const sendInitialResponse = () => {
|
||||
sendEvent(res, {
|
||||
sync: true,
|
||||
conversationId,
|
||||
// messages: previousMessages,
|
||||
requestMessage,
|
||||
responseMessage: {
|
||||
user: req.user.id,
|
||||
messageId: openai.responseMessage.messageId,
|
||||
parentMessageId: userMessageId,
|
||||
conversationId,
|
||||
assistant_id,
|
||||
thread_id,
|
||||
model: assistant_id,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** @type {RunResponse | typeof StreamRunManager | undefined} */
|
||||
let response;
|
||||
|
||||
const processRun = async (retry = false) => {
|
||||
if (endpoint === EModelEndpoint.azureAssistants) {
|
||||
body.model = openai._options.model;
|
||||
openai.attachedFileIds = attachedFileIds;
|
||||
openai.visionPromise = visionPromise;
|
||||
if (retry) {
|
||||
response = await runAssistant({
|
||||
openai,
|
||||
thread_id,
|
||||
run_id,
|
||||
in_progress: openai.in_progress,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
/* NOTE:
|
||||
* By default, a Run will use the model and tools configuration specified in Assistant object,
|
||||
* but you can override most of these when creating the Run for added flexibility:
|
||||
*/
|
||||
const run = await createRun({
|
||||
openai,
|
||||
thread_id,
|
||||
body,
|
||||
});
|
||||
|
||||
run_id = run.id;
|
||||
await cache.set(cacheKey, `${thread_id}:${run_id}`, Time.TEN_MINUTES);
|
||||
sendInitialResponse();
|
||||
|
||||
// todo: retry logic
|
||||
response = await runAssistant({ openai, thread_id, run_id });
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {{[AssistantStreamEvents.ThreadRunCreated]: (event: ThreadRunCreated) => Promise<void>}} */
|
||||
const handlers = {
|
||||
[AssistantStreamEvents.ThreadRunCreated]: async (event) => {
|
||||
await cache.set(cacheKey, `${thread_id}:${event.data.id}`, Time.TEN_MINUTES);
|
||||
run_id = event.data.id;
|
||||
sendInitialResponse();
|
||||
},
|
||||
};
|
||||
|
||||
const streamRunManager = new StreamRunManager({
|
||||
req,
|
||||
res,
|
||||
openai,
|
||||
handlers,
|
||||
thread_id,
|
||||
visionPromise,
|
||||
attachedFileIds,
|
||||
responseMessage: openai.responseMessage,
|
||||
// streamOptions: {
|
||||
|
||||
// },
|
||||
});
|
||||
|
||||
await streamRunManager.runAssistant({
|
||||
thread_id,
|
||||
body,
|
||||
});
|
||||
|
||||
response = streamRunManager;
|
||||
};
|
||||
|
||||
await processRun();
|
||||
logger.debug('[/assistants/chat/] response', {
|
||||
run: response.run,
|
||||
steps: response.steps,
|
||||
});
|
||||
|
||||
if (response.run.status === RunStatus.CANCELLED) {
|
||||
logger.debug('[/assistants/chat/] Run cancelled, handled by `abortRun`');
|
||||
return res.end();
|
||||
}
|
||||
|
||||
if (response.run.status === RunStatus.IN_PROGRESS) {
|
||||
processRun(true);
|
||||
}
|
||||
|
||||
completedRun = response.run;
|
||||
|
||||
/** @type {ResponseMessage} */
|
||||
const responseMessage = {
|
||||
...(response.responseMessage ?? response.finalMessage),
|
||||
parentMessageId: userMessageId,
|
||||
conversationId,
|
||||
user: req.user.id,
|
||||
assistant_id,
|
||||
thread_id,
|
||||
model: assistant_id,
|
||||
endpoint,
|
||||
spec: endpointOption.spec,
|
||||
iconURL: endpointOption.iconURL,
|
||||
};
|
||||
|
||||
sendEvent(res, {
|
||||
final: true,
|
||||
conversation,
|
||||
requestMessage: {
|
||||
parentMessageId,
|
||||
thread_id,
|
||||
},
|
||||
});
|
||||
res.end();
|
||||
|
||||
if (userMessagePromise) {
|
||||
await userMessagePromise;
|
||||
}
|
||||
await saveAssistantMessage(req, { ...responseMessage, model });
|
||||
|
||||
if (parentMessageId === Constants.NO_PARENT && !_thread_id) {
|
||||
addTitle(req, {
|
||||
text,
|
||||
responseText: response.text,
|
||||
conversationId,
|
||||
});
|
||||
}
|
||||
|
||||
await addThreadMetadata({
|
||||
openai,
|
||||
thread_id,
|
||||
messageId: responseMessage.messageId,
|
||||
messages: response.messages,
|
||||
});
|
||||
|
||||
if (!response.run.usage) {
|
||||
await sleep(3000);
|
||||
completedRun = await openai.beta.threads.runs.retrieve(response.run.id, { thread_id });
|
||||
if (completedRun.usage) {
|
||||
await recordUsage({
|
||||
...completedRun.usage,
|
||||
user: req.user.id,
|
||||
model: completedRun.model ?? model,
|
||||
conversationId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await recordUsage({
|
||||
...response.run.usage,
|
||||
user: req.user.id,
|
||||
model: response.run.model ?? model,
|
||||
conversationId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
await handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = chatV1;
|
||||
@@ -0,0 +1,510 @@
|
||||
const { v4 } = require('uuid');
|
||||
const { sleep } = require('@librechat/agents');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const {
|
||||
sendEvent,
|
||||
countTokens,
|
||||
checkBalance,
|
||||
getBalanceConfig,
|
||||
getModelMaxTokens,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
Time,
|
||||
Constants,
|
||||
RunStatus,
|
||||
CacheKeys,
|
||||
ContentTypes,
|
||||
ToolCallTypes,
|
||||
EModelEndpoint,
|
||||
retrievalMimeTypes,
|
||||
AssistantStreamEvents,
|
||||
} = require('librechat-data-provider');
|
||||
const {
|
||||
initThread,
|
||||
recordUsage,
|
||||
saveUserMessage,
|
||||
addThreadMetadata,
|
||||
saveAssistantMessage,
|
||||
} = require('~/server/services/Threads');
|
||||
const { runAssistant, createOnTextProgress } = require('~/server/services/AssistantService');
|
||||
const { createErrorHandler } = require('~/server/controllers/assistants/errors');
|
||||
const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
|
||||
const { createRun, StreamRunManager } = require('~/server/services/Runs');
|
||||
const { addTitle } = require('~/server/services/Endpoints/assistants');
|
||||
const { createRunBody } = require('~/server/services/createRunBody');
|
||||
const {
|
||||
getConvo,
|
||||
getMultiplier,
|
||||
getTransactions,
|
||||
findBalanceByUser,
|
||||
upsertBalanceFields,
|
||||
createAutoRefillTransaction,
|
||||
} = require('~/models');
|
||||
const { logViolation, getLogStores } = require('~/cache');
|
||||
const { getOpenAIClient } = require('./helpers');
|
||||
|
||||
/**
|
||||
* @route POST /
|
||||
* @desc Chat with an assistant
|
||||
* @access Public
|
||||
* @param {ServerRequest} req - The request object, containing the request data.
|
||||
* @param {Express.Response} res - The response object, used to send back a response.
|
||||
* @returns {void}
|
||||
*/
|
||||
const chatV2 = async (req, res) => {
|
||||
logger.debug('[/assistants/chat/] req.body', req.body);
|
||||
const appConfig = req.config;
|
||||
|
||||
/** @type {{files: MongoFile[]}} */
|
||||
const {
|
||||
text,
|
||||
model,
|
||||
endpoint,
|
||||
files = [],
|
||||
promptPrefix,
|
||||
assistant_id,
|
||||
instructions,
|
||||
endpointOption,
|
||||
thread_id: _thread_id,
|
||||
messageId: _messageId,
|
||||
conversationId: convoId,
|
||||
parentMessageId: _parentId = Constants.NO_PARENT,
|
||||
clientTimestamp,
|
||||
} = req.body;
|
||||
|
||||
/** @type {OpenAI} */
|
||||
let openai;
|
||||
/** @type {string|undefined} - the current thread id */
|
||||
let thread_id = _thread_id;
|
||||
/** @type {string|undefined} - the current run id */
|
||||
let run_id;
|
||||
/** @type {string|undefined} - the parent messageId */
|
||||
let parentMessageId = _parentId;
|
||||
/** @type {TMessage[]} */
|
||||
let previousMessages = [];
|
||||
/** @type {import('librechat-data-provider').TConversation | null} */
|
||||
let conversation = null;
|
||||
/** @type {string[]} */
|
||||
let file_ids = [];
|
||||
/** @type {Set<string>} */
|
||||
let attachedFileIds = new Set();
|
||||
/** @type {TMessage | null} */
|
||||
let requestMessage = null;
|
||||
|
||||
const userMessageId = v4();
|
||||
const responseMessageId = v4();
|
||||
|
||||
/** @type {string} - The conversation UUID - created if undefined */
|
||||
const conversationId = convoId ?? v4();
|
||||
|
||||
const cache = getLogStores(CacheKeys.ABORT_KEYS);
|
||||
const cacheKey = `${req.user.id}:${conversationId}`;
|
||||
|
||||
/** @type {Run | undefined} - The completed run, undefined if incomplete */
|
||||
let completedRun;
|
||||
|
||||
const getContext = () => ({
|
||||
openai,
|
||||
run_id,
|
||||
endpoint,
|
||||
cacheKey,
|
||||
thread_id,
|
||||
completedRun,
|
||||
assistant_id,
|
||||
conversationId,
|
||||
parentMessageId,
|
||||
responseMessageId,
|
||||
});
|
||||
|
||||
const handleError = createErrorHandler({ req, res, getContext });
|
||||
|
||||
try {
|
||||
res.on('close', async () => {
|
||||
if (!completedRun) {
|
||||
await handleError(new Error('Request closed'));
|
||||
}
|
||||
});
|
||||
|
||||
if (convoId && !_thread_id) {
|
||||
completedRun = true;
|
||||
throw new Error('Missing thread_id for existing conversation');
|
||||
}
|
||||
|
||||
if (!assistant_id) {
|
||||
completedRun = true;
|
||||
throw new Error('Missing assistant_id');
|
||||
}
|
||||
|
||||
const checkBalanceBeforeRun = async () => {
|
||||
const balanceConfig = getBalanceConfig(appConfig);
|
||||
if (!balanceConfig?.enabled) {
|
||||
return;
|
||||
}
|
||||
const transactions =
|
||||
(await getTransactions({
|
||||
user: req.user.id,
|
||||
context: 'message',
|
||||
conversationId,
|
||||
})) ?? [];
|
||||
|
||||
const totalPreviousTokens = Math.abs(
|
||||
transactions.reduce((acc, curr) => acc + curr.rawAmount, 0),
|
||||
);
|
||||
|
||||
// TODO: make promptBuffer a config option; buffer for titles, needs buffer for system instructions
|
||||
const promptBuffer = parentMessageId === Constants.NO_PARENT && !_thread_id ? 200 : 0;
|
||||
// 5 is added for labels
|
||||
let promptTokens = (await countTokens(text + (promptPrefix ?? ''))) + 5;
|
||||
promptTokens += totalPreviousTokens + promptBuffer;
|
||||
// Count tokens up to the current context window
|
||||
promptTokens = Math.min(promptTokens, getModelMaxTokens(model));
|
||||
|
||||
await checkBalance(
|
||||
{
|
||||
req,
|
||||
res,
|
||||
txData: {
|
||||
model,
|
||||
user: req.user.id,
|
||||
tokenType: 'prompt',
|
||||
amount: promptTokens,
|
||||
},
|
||||
},
|
||||
{
|
||||
findBalanceByUser,
|
||||
getMultiplier,
|
||||
createAutoRefillTransaction,
|
||||
logViolation,
|
||||
balanceConfig,
|
||||
upsertBalanceFields,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
const { openai: _openai } = await getOpenAIClient({
|
||||
req,
|
||||
res,
|
||||
endpointOption,
|
||||
});
|
||||
|
||||
openai = _openai;
|
||||
await validateAuthor({ req, openai });
|
||||
|
||||
if (previousMessages.length) {
|
||||
parentMessageId = previousMessages[previousMessages.length - 1].messageId;
|
||||
}
|
||||
|
||||
let userMessage = {
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: ContentTypes.TEXT,
|
||||
text,
|
||||
},
|
||||
],
|
||||
metadata: {
|
||||
messageId: userMessageId,
|
||||
},
|
||||
};
|
||||
|
||||
/** @type {CreateRunBody | undefined} */
|
||||
const body = createRunBody({
|
||||
assistant_id,
|
||||
model,
|
||||
promptPrefix,
|
||||
instructions,
|
||||
endpointOption,
|
||||
clientTimestamp,
|
||||
});
|
||||
|
||||
const getRequestFileIds = async () => {
|
||||
let thread_file_ids = [];
|
||||
if (convoId) {
|
||||
const convo = await getConvo(req.user.id, convoId);
|
||||
if (convo && convo.file_ids) {
|
||||
thread_file_ids = convo.file_ids;
|
||||
}
|
||||
}
|
||||
|
||||
if (files.length || thread_file_ids.length) {
|
||||
attachedFileIds = new Set([...file_ids, ...thread_file_ids]);
|
||||
|
||||
let attachmentIndex = 0;
|
||||
for (const file of files) {
|
||||
file_ids.push(file.file_id);
|
||||
if (file.type.startsWith('image')) {
|
||||
userMessage.content.push({
|
||||
type: ContentTypes.IMAGE_FILE,
|
||||
[ContentTypes.IMAGE_FILE]: { file_id: file.file_id },
|
||||
});
|
||||
}
|
||||
|
||||
if (!userMessage.attachments) {
|
||||
userMessage.attachments = [];
|
||||
}
|
||||
|
||||
userMessage.attachments.push({
|
||||
file_id: file.file_id,
|
||||
tools: [{ type: ToolCallTypes.CODE_INTERPRETER }],
|
||||
});
|
||||
|
||||
if (file.type.startsWith('image')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const mimeType = file.type;
|
||||
const isSupportedByRetrieval = retrievalMimeTypes.some((regex) => regex.test(mimeType));
|
||||
if (isSupportedByRetrieval) {
|
||||
userMessage.attachments[attachmentIndex].tools.push({
|
||||
type: ToolCallTypes.FILE_SEARCH,
|
||||
});
|
||||
}
|
||||
|
||||
attachmentIndex++;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/** @type {Promise<Run>|undefined} */
|
||||
let userMessagePromise;
|
||||
|
||||
const initializeThread = async () => {
|
||||
await getRequestFileIds();
|
||||
|
||||
// TODO: may allow multiple messages to be created beforehand in a future update
|
||||
const initThreadBody = {
|
||||
messages: [userMessage],
|
||||
metadata: {
|
||||
user: req.user.id,
|
||||
conversationId,
|
||||
},
|
||||
};
|
||||
|
||||
const result = await initThread({ openai, body: initThreadBody, thread_id });
|
||||
thread_id = result.thread_id;
|
||||
|
||||
createOnTextProgress({
|
||||
openai,
|
||||
conversationId,
|
||||
userMessageId,
|
||||
messageId: responseMessageId,
|
||||
thread_id,
|
||||
});
|
||||
|
||||
requestMessage = {
|
||||
user: req.user.id,
|
||||
text,
|
||||
messageId: userMessageId,
|
||||
parentMessageId,
|
||||
// TODO: make sure client sends correct format for `files`, use zod
|
||||
files,
|
||||
file_ids,
|
||||
conversationId,
|
||||
isCreatedByUser: true,
|
||||
assistant_id,
|
||||
thread_id,
|
||||
model: assistant_id,
|
||||
endpoint,
|
||||
};
|
||||
|
||||
previousMessages.push(requestMessage);
|
||||
|
||||
/* asynchronous */
|
||||
userMessagePromise = saveUserMessage(req, { ...requestMessage, model });
|
||||
|
||||
conversation = {
|
||||
conversationId,
|
||||
endpoint,
|
||||
promptPrefix: promptPrefix,
|
||||
instructions: instructions,
|
||||
assistant_id,
|
||||
// model,
|
||||
};
|
||||
|
||||
if (file_ids.length) {
|
||||
conversation.file_ids = file_ids;
|
||||
}
|
||||
};
|
||||
|
||||
const promises = [initializeThread(), checkBalanceBeforeRun()];
|
||||
await Promise.all(promises);
|
||||
|
||||
const sendInitialResponse = () => {
|
||||
sendEvent(res, {
|
||||
sync: true,
|
||||
conversationId,
|
||||
// messages: previousMessages,
|
||||
requestMessage,
|
||||
responseMessage: {
|
||||
user: req.user.id,
|
||||
messageId: openai.responseMessage.messageId,
|
||||
parentMessageId: userMessageId,
|
||||
conversationId,
|
||||
assistant_id,
|
||||
thread_id,
|
||||
model: assistant_id,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/** @type {RunResponse | typeof StreamRunManager | undefined} */
|
||||
let response;
|
||||
|
||||
const processRun = async (retry = false) => {
|
||||
if (endpoint === EModelEndpoint.azureAssistants) {
|
||||
body.model = openai._options.model;
|
||||
openai.attachedFileIds = attachedFileIds;
|
||||
if (retry) {
|
||||
response = await runAssistant({
|
||||
openai,
|
||||
thread_id,
|
||||
run_id,
|
||||
in_progress: openai.in_progress,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
/* NOTE:
|
||||
* By default, a Run will use the model and tools configuration specified in Assistant object,
|
||||
* but you can override most of these when creating the Run for added flexibility:
|
||||
*/
|
||||
const run = await createRun({
|
||||
openai,
|
||||
thread_id,
|
||||
body,
|
||||
});
|
||||
|
||||
run_id = run.id;
|
||||
await cache.set(cacheKey, `${thread_id}:${run_id}`, Time.TEN_MINUTES);
|
||||
sendInitialResponse();
|
||||
|
||||
// todo: retry logic
|
||||
response = await runAssistant({ openai, thread_id, run_id });
|
||||
return;
|
||||
}
|
||||
|
||||
/** @type {{[AssistantStreamEvents.ThreadRunCreated]: (event: ThreadRunCreated) => Promise<void>}} */
|
||||
const handlers = {
|
||||
[AssistantStreamEvents.ThreadRunCreated]: async (event) => {
|
||||
await cache.set(cacheKey, `${thread_id}:${event.data.id}`, Time.TEN_MINUTES);
|
||||
run_id = event.data.id;
|
||||
sendInitialResponse();
|
||||
},
|
||||
};
|
||||
|
||||
/** @type {undefined | TAssistantEndpoint} */
|
||||
const config = appConfig.endpoints?.[endpoint] ?? {};
|
||||
/** @type {undefined | TBaseEndpoint} */
|
||||
const allConfig = appConfig.endpoints?.all;
|
||||
|
||||
const streamRunManager = new StreamRunManager({
|
||||
req,
|
||||
res,
|
||||
openai,
|
||||
handlers,
|
||||
thread_id,
|
||||
attachedFileIds,
|
||||
parentMessageId: userMessageId,
|
||||
responseMessage: openai.responseMessage,
|
||||
streamRate: allConfig?.streamRate ?? config.streamRate,
|
||||
// streamOptions: {
|
||||
|
||||
// },
|
||||
});
|
||||
|
||||
await streamRunManager.runAssistant({
|
||||
thread_id,
|
||||
body,
|
||||
});
|
||||
|
||||
response = streamRunManager;
|
||||
response.text = streamRunManager.intermediateText;
|
||||
};
|
||||
|
||||
await processRun();
|
||||
logger.debug('[/assistants/chat/] response', {
|
||||
run: response.run,
|
||||
steps: response.steps,
|
||||
});
|
||||
|
||||
if (response.run.status === RunStatus.CANCELLED) {
|
||||
logger.debug('[/assistants/chat/] Run cancelled, handled by `abortRun`');
|
||||
return res.end();
|
||||
}
|
||||
|
||||
if (response.run.status === RunStatus.IN_PROGRESS) {
|
||||
processRun(true);
|
||||
}
|
||||
|
||||
completedRun = response.run;
|
||||
|
||||
/** @type {ResponseMessage} */
|
||||
const responseMessage = {
|
||||
...(response.responseMessage ?? response.finalMessage),
|
||||
text: response.text,
|
||||
parentMessageId: userMessageId,
|
||||
conversationId,
|
||||
user: req.user.id,
|
||||
assistant_id,
|
||||
thread_id,
|
||||
model: assistant_id,
|
||||
endpoint,
|
||||
spec: endpointOption.spec,
|
||||
iconURL: endpointOption.iconURL,
|
||||
};
|
||||
|
||||
sendEvent(res, {
|
||||
final: true,
|
||||
conversation,
|
||||
requestMessage: {
|
||||
parentMessageId,
|
||||
thread_id,
|
||||
},
|
||||
});
|
||||
res.end();
|
||||
|
||||
if (userMessagePromise) {
|
||||
await userMessagePromise;
|
||||
}
|
||||
await saveAssistantMessage(req, { ...responseMessage, model });
|
||||
|
||||
if (parentMessageId === Constants.NO_PARENT && !_thread_id) {
|
||||
addTitle(req, {
|
||||
text,
|
||||
responseText: response.text,
|
||||
conversationId,
|
||||
});
|
||||
}
|
||||
|
||||
await addThreadMetadata({
|
||||
openai,
|
||||
thread_id,
|
||||
messageId: responseMessage.messageId,
|
||||
messages: response.messages,
|
||||
});
|
||||
|
||||
if (!response.run.usage) {
|
||||
await sleep(3000);
|
||||
completedRun = await openai.beta.threads.runs.retrieve(response.run.id, { thread_id });
|
||||
if (completedRun.usage) {
|
||||
await recordUsage({
|
||||
...completedRun.usage,
|
||||
user: req.user.id,
|
||||
model: completedRun.model ?? model,
|
||||
conversationId,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await recordUsage({
|
||||
...response.run.usage,
|
||||
user: req.user.id,
|
||||
model: response.run.model ?? model,
|
||||
conversationId,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
await handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = chatV2;
|
||||
@@ -0,0 +1,193 @@
|
||||
// errorHandler.js
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { CacheKeys, ViolationTypes, ContentTypes } = require('librechat-data-provider');
|
||||
const { recordUsage, checkMessageGaps } = require('~/server/services/Threads');
|
||||
const { sendResponse } = require('~/server/middleware/error');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const { getConvo } = require('~/models');
|
||||
|
||||
/**
|
||||
* @typedef {Object} ErrorHandlerContext
|
||||
* @property {OpenAIClient} openai - The OpenAI client
|
||||
* @property {string} thread_id - The thread ID
|
||||
* @property {string} run_id - The run ID
|
||||
* @property {boolean} completedRun - Whether the run has completed
|
||||
* @property {string} assistant_id - The assistant ID
|
||||
* @property {string} conversationId - The conversation ID
|
||||
* @property {string} parentMessageId - The parent message ID
|
||||
* @property {string} responseMessageId - The response message ID
|
||||
* @property {string} endpoint - The endpoint being used
|
||||
* @property {string} cacheKey - The cache key for the current request
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ErrorHandlerDependencies
|
||||
* @property {ServerRequest} req - The Express request object
|
||||
* @property {Express.Response} res - The Express response object
|
||||
* @property {() => ErrorHandlerContext} getContext - Function to get the current context
|
||||
* @property {string} [originPath] - The origin path for the error handler
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates an error handler function with the given dependencies
|
||||
* @param {ErrorHandlerDependencies} dependencies - The dependencies for the error handler
|
||||
* @returns {(error: Error) => Promise<void>} The error handler function
|
||||
*/
|
||||
const createErrorHandler = ({ req, res, getContext, originPath = '/assistants/chat/' }) => {
|
||||
const cache = getLogStores(CacheKeys.ABORT_KEYS);
|
||||
|
||||
/**
|
||||
* Handles errors that occur during the chat process
|
||||
* @param {Error} error - The error that occurred
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
return async (error) => {
|
||||
const {
|
||||
openai,
|
||||
run_id,
|
||||
endpoint,
|
||||
cacheKey,
|
||||
thread_id,
|
||||
completedRun,
|
||||
assistant_id,
|
||||
conversationId,
|
||||
parentMessageId,
|
||||
responseMessageId,
|
||||
} = getContext();
|
||||
|
||||
const defaultErrorMessage =
|
||||
'The Assistant run failed to initialize. Try sending a message in a new conversation.';
|
||||
const messageData = {
|
||||
thread_id,
|
||||
assistant_id,
|
||||
conversationId,
|
||||
parentMessageId,
|
||||
sender: 'System',
|
||||
user: req.user.id,
|
||||
shouldSaveMessage: false,
|
||||
messageId: responseMessageId,
|
||||
endpoint,
|
||||
};
|
||||
|
||||
if (error.message === 'Run cancelled') {
|
||||
return res.end();
|
||||
} else if (error.message === 'Request closed' && completedRun) {
|
||||
return;
|
||||
} else if (error.message === 'Request closed') {
|
||||
logger.debug(`[${originPath}] Request aborted on close`);
|
||||
} else if (/Files.*are invalid/.test(error.message)) {
|
||||
const errorMessage = `Files are invalid, or may not have uploaded yet.${
|
||||
endpoint === 'azureAssistants'
|
||||
? " If using Azure OpenAI, files are only available in the region of the assistant's model at the time of upload."
|
||||
: ''
|
||||
}`;
|
||||
return sendResponse(req, res, messageData, errorMessage);
|
||||
} else if (error?.message?.includes('string too long')) {
|
||||
return sendResponse(
|
||||
req,
|
||||
res,
|
||||
messageData,
|
||||
'Message too long. The Assistants API has a limit of 32,768 characters per message. Please shorten it and try again.',
|
||||
);
|
||||
} else if (error?.message?.includes(ViolationTypes.TOKEN_BALANCE)) {
|
||||
return sendResponse(req, res, messageData, error.message);
|
||||
} else {
|
||||
logger.error(`[${originPath}]`, error);
|
||||
}
|
||||
|
||||
if (!openai || !thread_id || !run_id) {
|
||||
return sendResponse(req, res, messageData, defaultErrorMessage);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
try {
|
||||
const status = await cache.get(cacheKey);
|
||||
if (status === 'cancelled') {
|
||||
logger.debug(`[${originPath}] Run already cancelled`);
|
||||
return res.end();
|
||||
}
|
||||
await cache.delete(cacheKey);
|
||||
const cancelledRun = await openai.beta.threads.runs.cancel(run_id, { thread_id });
|
||||
logger.debug(`[${originPath}] Cancelled run:`, cancelledRun);
|
||||
} catch (error) {
|
||||
logger.error(`[${originPath}] Error cancelling run`, error);
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
let run;
|
||||
try {
|
||||
run = await openai.beta.threads.runs.retrieve(run_id, { thread_id });
|
||||
await recordUsage({
|
||||
...run.usage,
|
||||
model: run.model,
|
||||
user: req.user.id,
|
||||
conversationId,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`[${originPath}] Error fetching or processing run`, error);
|
||||
}
|
||||
|
||||
let finalEvent;
|
||||
try {
|
||||
const runMessages = await checkMessageGaps({
|
||||
openai,
|
||||
run_id,
|
||||
endpoint,
|
||||
thread_id,
|
||||
conversationId,
|
||||
latestMessageId: responseMessageId,
|
||||
});
|
||||
|
||||
const errorContentPart = {
|
||||
text: {
|
||||
value:
|
||||
error?.message ?? 'There was an error processing your request. Please try again later.',
|
||||
},
|
||||
type: ContentTypes.ERROR,
|
||||
};
|
||||
|
||||
if (!Array.isArray(runMessages[runMessages.length - 1]?.content)) {
|
||||
runMessages[runMessages.length - 1].content = [errorContentPart];
|
||||
} else {
|
||||
const contentParts = runMessages[runMessages.length - 1].content;
|
||||
for (let i = 0; i < contentParts.length; i++) {
|
||||
const currentPart = contentParts[i];
|
||||
/** @type {CodeToolCall | RetrievalToolCall | FunctionToolCall | undefined} */
|
||||
const toolCall = currentPart?.[ContentTypes.TOOL_CALL];
|
||||
if (
|
||||
toolCall &&
|
||||
toolCall?.function &&
|
||||
!(toolCall?.function?.output || toolCall?.function?.output?.length)
|
||||
) {
|
||||
contentParts[i] = {
|
||||
...currentPart,
|
||||
[ContentTypes.TOOL_CALL]: {
|
||||
...toolCall,
|
||||
function: {
|
||||
...toolCall.function,
|
||||
output: 'error processing tool',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
runMessages[runMessages.length - 1].content.push(errorContentPart);
|
||||
}
|
||||
|
||||
finalEvent = {
|
||||
final: true,
|
||||
conversation: await getConvo(req.user.id, conversationId),
|
||||
runMessages,
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`[${originPath}] Error finalizing error process`, error);
|
||||
return sendResponse(req, res, messageData, 'The Assistant run failed');
|
||||
}
|
||||
|
||||
return sendResponse(req, res, finalEvent);
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = { createErrorHandler };
|
||||
@@ -0,0 +1,289 @@
|
||||
const {
|
||||
EModelEndpoint,
|
||||
defaultOrderQuery,
|
||||
defaultAssistantsVersion,
|
||||
} = require('librechat-data-provider');
|
||||
const { logger, SystemCapabilities } = require('@librechat/data-schemas');
|
||||
const {
|
||||
initializeClient: initAzureClient,
|
||||
} = require('~/server/services/Endpoints/azureAssistants');
|
||||
const { initializeClient } = require('~/server/services/Endpoints/assistants');
|
||||
const { hasCapability } = require('~/server/middleware/roles/capabilities');
|
||||
const { getEndpointsConfig } = require('~/server/services/Config');
|
||||
|
||||
/**
|
||||
* @param {ServerRequest} req
|
||||
* @param {string} [endpoint]
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
const getCurrentVersion = async (req, endpoint) => {
|
||||
const index = req.baseUrl.lastIndexOf('/v');
|
||||
let version = index !== -1 ? req.baseUrl.substring(index + 1, index + 3) : null;
|
||||
if (!version && req.body.version) {
|
||||
version = `v${req.body.version}`;
|
||||
}
|
||||
if (!version && endpoint) {
|
||||
const endpointsConfig = await getEndpointsConfig(req);
|
||||
version = `v${endpointsConfig?.[endpoint]?.version ?? defaultAssistantsVersion[endpoint]}`;
|
||||
}
|
||||
if (!version?.startsWith('v') && version.length !== 2) {
|
||||
throw new Error(`[${req.baseUrl}] Invalid version: ${version}`);
|
||||
}
|
||||
return version;
|
||||
};
|
||||
|
||||
/**
|
||||
* Asynchronously lists assistants based on provided query parameters.
|
||||
*
|
||||
* Initializes the client with the current request and response objects and lists assistants
|
||||
* according to the query parameters. This function abstracts the logic for non-Azure paths.
|
||||
*
|
||||
* @deprecated
|
||||
* @async
|
||||
* @param {object} params - The parameters object.
|
||||
* @param {object} params.req - The request object, used for initializing the client.
|
||||
* @param {object} params.res - The response object, used for initializing the client.
|
||||
* @param {string} params.version - The API version to use.
|
||||
* @param {object} params.query - The query parameters to list assistants (e.g., limit, order).
|
||||
* @returns {Promise<object>} A promise that resolves to the response from the `openai.beta.assistants.list` method call.
|
||||
*/
|
||||
const _listAssistants = async ({ req, res, version, query }) => {
|
||||
const { openai } = await getOpenAIClient({ req, res, version });
|
||||
return openai.beta.assistants.list(query);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches all assistants based on provided query params, until `has_more` is `false`.
|
||||
*
|
||||
* @async
|
||||
* @param {object} params - The parameters object.
|
||||
* @param {object} params.req - The request object, used for initializing the client.
|
||||
* @param {object} params.res - The response object, used for initializing the client.
|
||||
* @param {string} params.version - The API version to use.
|
||||
* @param {Omit<AssistantListParams, 'endpoint'>} params.query - The query parameters to list assistants (e.g., limit, order).
|
||||
* @returns {Promise<Array<Assistant>>} A promise that resolves to the response from the `openai.beta.assistants.list` method call.
|
||||
*/
|
||||
const listAllAssistants = async ({ req, res, version, query }) => {
|
||||
/** @type {{ openai: OpenAI }} */
|
||||
const { openai } = await getOpenAIClient({ req, res, version });
|
||||
const allAssistants = [];
|
||||
|
||||
let first_id;
|
||||
let last_id;
|
||||
let afterToken = query.after;
|
||||
let hasMore = true;
|
||||
|
||||
while (hasMore) {
|
||||
const response = await openai.beta.assistants.list({
|
||||
...query,
|
||||
after: afterToken,
|
||||
});
|
||||
|
||||
const { body } = response;
|
||||
|
||||
allAssistants.push(...body.data);
|
||||
hasMore = body.has_more;
|
||||
|
||||
if (!first_id) {
|
||||
first_id = body.first_id;
|
||||
}
|
||||
|
||||
if (hasMore) {
|
||||
afterToken = body.last_id;
|
||||
} else {
|
||||
last_id = body.last_id;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data: allAssistants,
|
||||
body: {
|
||||
data: allAssistants,
|
||||
has_more: false,
|
||||
first_id,
|
||||
last_id,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Asynchronously lists assistants for Azure configured groups.
|
||||
*
|
||||
* Iterates through Azure configured assistant groups, initializes the client with the current request and response objects,
|
||||
* lists assistants based on the provided query parameters, and merges their data alongside the model information into a single array.
|
||||
*
|
||||
* @async
|
||||
* @param {object} params - The parameters object.
|
||||
* @param {object} params.req - The request object, used for initializing the client and manipulating the request body.
|
||||
* @param {object} params.res - The response object, used for initializing the client.
|
||||
* @param {string} params.version - The API version to use.
|
||||
* @param {TAzureConfig} params.azureConfig - The Azure configuration object containing assistantGroups and groupMap.
|
||||
* @param {object} params.query - The query parameters to list assistants (e.g., limit, order).
|
||||
* @returns {Promise<AssistantListResponse>} A promise that resolves to an array of assistant data merged with their respective model information.
|
||||
*/
|
||||
const listAssistantsForAzure = async ({ req, res, version, azureConfig = {}, query }) => {
|
||||
/** @type {Array<[string, TAzureModelConfig]>} */
|
||||
const groupModelTuples = [];
|
||||
const promises = [];
|
||||
/** @type {Array<TAzureGroup>} */
|
||||
const groups = [];
|
||||
|
||||
const { groupMap, assistantGroups } = azureConfig;
|
||||
|
||||
for (const groupName of assistantGroups) {
|
||||
const group = groupMap[groupName];
|
||||
groups.push(group);
|
||||
|
||||
const currentModelTuples = Object.entries(group?.models);
|
||||
groupModelTuples.push(currentModelTuples);
|
||||
|
||||
/* The specified model is only necessary to
|
||||
fetch assistants for the shared instance */
|
||||
req.body = req.body || {}; // Express 5: req.body is undefined instead of {} when no body parser runs
|
||||
req.body.model = currentModelTuples[0][0];
|
||||
promises.push(listAllAssistants({ req, res, version, query }));
|
||||
}
|
||||
|
||||
const resolvedQueries = await Promise.all(promises);
|
||||
const data = resolvedQueries.flatMap((res, i) =>
|
||||
res.data.map((assistant) => {
|
||||
const deploymentName = assistant.model;
|
||||
const currentGroup = groups[i];
|
||||
const currentModelTuples = groupModelTuples[i];
|
||||
const firstModel = currentModelTuples[0][0];
|
||||
|
||||
if (currentGroup.deploymentName === deploymentName) {
|
||||
return { ...assistant, model: firstModel };
|
||||
}
|
||||
|
||||
for (const [model, modelConfig] of currentModelTuples) {
|
||||
if (modelConfig.deploymentName === deploymentName) {
|
||||
return { ...assistant, model };
|
||||
}
|
||||
}
|
||||
|
||||
return { ...assistant, model: firstModel };
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
first_id: data[0]?.id,
|
||||
last_id: data[data.length - 1]?.id,
|
||||
object: 'list',
|
||||
has_more: false,
|
||||
data,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Initializes the OpenAI client.
|
||||
* @param {object} params - The parameters object.
|
||||
* @param {ServerRequest} params.req - The request object.
|
||||
* @param {ServerResponse} params.res - The response object.
|
||||
* @param {TEndpointOption} params.endpointOption - The endpoint options.
|
||||
* @param {boolean} params.initAppClient - Whether to initialize the app client.
|
||||
* @param {string} params.overrideEndpoint - The endpoint to override.
|
||||
* @returns {Promise<{ openai: OpenAI, openAIApiKey: string }>} - The initialized OpenAI SDK client.
|
||||
*/
|
||||
async function getOpenAIClient({ req, res, endpointOption, initAppClient, overrideEndpoint }) {
|
||||
let endpoint = overrideEndpoint ?? req.body?.endpoint ?? req.query?.endpoint;
|
||||
const version = await getCurrentVersion(req, endpoint);
|
||||
if (!endpoint) {
|
||||
throw new Error(`[${req.baseUrl}] Endpoint is required`);
|
||||
}
|
||||
|
||||
let result;
|
||||
if (endpoint === EModelEndpoint.assistants) {
|
||||
result = await initializeClient({ req, res, version, endpointOption, initAppClient });
|
||||
} else if (endpoint === EModelEndpoint.azureAssistants) {
|
||||
result = await initAzureClient({ req, res, version, endpointOption, initAppClient });
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of assistants.
|
||||
* @param {object} params
|
||||
* @param {object} params.req - Express Request
|
||||
* @param {AssistantListParams} [params.req.query] - The assistant list parameters for pagination and sorting.
|
||||
* @param {object} params.res - Express Response
|
||||
* @param {string} [params.overrideEndpoint] - The endpoint to override the request endpoint.
|
||||
* @returns {Promise<AssistantListResponse>} 200 - success response - application/json
|
||||
*/
|
||||
const fetchAssistants = async ({ req, res, overrideEndpoint }) => {
|
||||
const appConfig = req.config;
|
||||
const {
|
||||
limit = 100,
|
||||
order = 'desc',
|
||||
after,
|
||||
before,
|
||||
endpoint,
|
||||
} = req.query ?? {
|
||||
endpoint: overrideEndpoint,
|
||||
...defaultOrderQuery,
|
||||
};
|
||||
|
||||
const version = await getCurrentVersion(req, endpoint);
|
||||
const query = { limit, order, after, before };
|
||||
|
||||
/** @type {AssistantListResponse} */
|
||||
let body;
|
||||
|
||||
if (endpoint === EModelEndpoint.assistants) {
|
||||
({ body } = await listAllAssistants({ req, res, version, query }));
|
||||
} else if (endpoint === EModelEndpoint.azureAssistants) {
|
||||
const azureConfig = appConfig.endpoints?.[EModelEndpoint.azureOpenAI];
|
||||
body = await listAssistantsForAzure({ req, res, version, azureConfig, query });
|
||||
}
|
||||
|
||||
if (!appConfig.endpoints?.[endpoint]) {
|
||||
return body;
|
||||
}
|
||||
|
||||
let canManageAssistants = false;
|
||||
try {
|
||||
canManageAssistants = await hasCapability(req.user, SystemCapabilities.MANAGE_ASSISTANTS);
|
||||
} catch (err) {
|
||||
logger.warn(`[fetchAssistants] capability check failed, denying bypass: ${err.message}`);
|
||||
}
|
||||
|
||||
if (canManageAssistants) {
|
||||
logger.debug(`[fetchAssistants] MANAGE_ASSISTANTS bypass for user ${req.user.id}`);
|
||||
return body;
|
||||
}
|
||||
|
||||
body.data = filterAssistants({
|
||||
userId: req.user.id,
|
||||
assistants: body.data,
|
||||
assistantsConfig: appConfig.endpoints?.[endpoint],
|
||||
});
|
||||
return body;
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter assistants based on configuration.
|
||||
*
|
||||
* @param {object} params - The parameters object.
|
||||
* @param {string} params.userId - The user ID to filter private assistants.
|
||||
* @param {Assistant[]} params.assistants - The list of assistants to filter.
|
||||
* @param {Partial<TAssistantEndpoint>} params.assistantsConfig - The assistant configuration.
|
||||
* @returns {Assistant[]} - The filtered list of assistants.
|
||||
*/
|
||||
function filterAssistants({ assistants, userId, assistantsConfig }) {
|
||||
const { supportedIds, excludedIds, privateAssistants } = assistantsConfig;
|
||||
if (privateAssistants) {
|
||||
return assistants.filter((assistant) => userId === assistant.metadata?.author);
|
||||
} else if (supportedIds?.length) {
|
||||
return assistants.filter((assistant) => supportedIds.includes(assistant.id));
|
||||
} else if (excludedIds?.length) {
|
||||
return assistants.filter((assistant) => !excludedIds.includes(assistant.id));
|
||||
}
|
||||
return assistants;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getOpenAIClient,
|
||||
fetchAssistants,
|
||||
getCurrentVersion,
|
||||
};
|
||||
@@ -0,0 +1,405 @@
|
||||
const fs = require('fs').promises;
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { FileContext } = require('librechat-data-provider');
|
||||
const { deleteFileByFilter, updateAssistantDoc, getAssistants } = require('~/models');
|
||||
const { uploadImageBuffer, filterFile } = require('~/server/services/Files/process');
|
||||
const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
const { deleteAssistantActions } = require('~/server/services/ActionService');
|
||||
const { getOpenAIClient, fetchAssistants } = require('./helpers');
|
||||
const { getCachedTools } = require('~/server/services/Config');
|
||||
const { manifestToolMap, isAgentsOnlyTool } = require('~/app/clients/tools');
|
||||
|
||||
/**
|
||||
* Create an assistant.
|
||||
* @route POST /assistants
|
||||
* @param {AssistantCreateParams} req.body - The assistant creation parameters.
|
||||
* @returns {Assistant} 201 - success response - application/json
|
||||
*/
|
||||
const createAssistant = async (req, res) => {
|
||||
try {
|
||||
const { openai } = await getOpenAIClient({ req, res });
|
||||
|
||||
const {
|
||||
tools = [],
|
||||
endpoint,
|
||||
conversation_starters,
|
||||
append_current_datetime,
|
||||
...assistantData
|
||||
} = req.body;
|
||||
delete assistantData.conversation_starters;
|
||||
delete assistantData.append_current_datetime;
|
||||
|
||||
const toolDefinitions = (await getCachedTools()) ?? {};
|
||||
|
||||
assistantData.tools = tools
|
||||
.map((tool) => {
|
||||
/** Agents-runtime-only tools (e.g. ask_user_question) cannot execute on
|
||||
* the assistants runtime — drop them even when posted directly, since
|
||||
* the tools-dialog scoping doesn't gate REST clients or stale payloads. */
|
||||
if (isAgentsOnlyTool(tool)) {
|
||||
logger.warn(
|
||||
`[/assistants] Dropping agents-only tool from assistant payload: ${typeof tool === 'string' ? tool : tool?.function?.name}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
if (typeof tool !== 'string') {
|
||||
return tool;
|
||||
}
|
||||
|
||||
const toolDef = toolDefinitions[tool];
|
||||
if (!toolDef && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
|
||||
return Object.entries(toolDefinitions)
|
||||
.filter(([key]) => key.startsWith(`${tool}_`))
|
||||
|
||||
.map(([_, val]) => val);
|
||||
}
|
||||
|
||||
return toolDef;
|
||||
})
|
||||
.filter((tool) => tool)
|
||||
.flat();
|
||||
|
||||
let azureModelIdentifier = null;
|
||||
if (openai.locals?.azureOptions) {
|
||||
azureModelIdentifier = assistantData.model;
|
||||
assistantData.model = openai.locals.azureOptions.azureOpenAIApiDeploymentName;
|
||||
}
|
||||
|
||||
assistantData.metadata = {
|
||||
author: req.user.id,
|
||||
endpoint,
|
||||
};
|
||||
|
||||
const assistant = await openai.beta.assistants.create(assistantData);
|
||||
|
||||
const createData = { user: req.user.id };
|
||||
if (conversation_starters) {
|
||||
createData.conversation_starters = conversation_starters;
|
||||
}
|
||||
if (append_current_datetime !== undefined) {
|
||||
createData.append_current_datetime = append_current_datetime;
|
||||
}
|
||||
|
||||
const document = await updateAssistantDoc({ assistant_id: assistant.id }, createData);
|
||||
|
||||
if (azureModelIdentifier) {
|
||||
assistant.model = azureModelIdentifier;
|
||||
}
|
||||
|
||||
if (document.conversation_starters) {
|
||||
assistant.conversation_starters = document.conversation_starters;
|
||||
}
|
||||
|
||||
if (append_current_datetime !== undefined) {
|
||||
assistant.append_current_datetime = append_current_datetime;
|
||||
}
|
||||
|
||||
logger.debug('/assistants/', assistant);
|
||||
res.status(201).json(assistant);
|
||||
} catch (error) {
|
||||
logger.error('[/assistants] Error creating assistant', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves an assistant.
|
||||
* @route GET /assistants/:id
|
||||
* @param {string} req.params.id - Assistant identifier.
|
||||
* @returns {Assistant} 200 - success response - application/json
|
||||
*/
|
||||
const retrieveAssistant = async (req, res) => {
|
||||
try {
|
||||
/* NOTE: not actually being used right now */
|
||||
const { openai } = await getOpenAIClient({ req, res });
|
||||
const assistant_id = req.params.id;
|
||||
const assistant = await openai.beta.assistants.retrieve(assistant_id);
|
||||
res.json(assistant);
|
||||
} catch (error) {
|
||||
logger.error('[/assistants/:id] Error retrieving assistant', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Modifies an assistant.
|
||||
* @route PATCH /assistants/:id
|
||||
* @param {object} req - Express Request
|
||||
* @param {object} req.params - Request params
|
||||
* @param {string} req.params.id - Assistant identifier.
|
||||
* @param {AssistantUpdateParams} req.body - The assistant update parameters.
|
||||
* @returns {Assistant} 200 - success response - application/json
|
||||
*/
|
||||
const patchAssistant = async (req, res) => {
|
||||
try {
|
||||
const { openai } = await getOpenAIClient({ req, res });
|
||||
await validateAuthor({ req, openai });
|
||||
|
||||
const assistant_id = req.params.id;
|
||||
const {
|
||||
endpoint: _e,
|
||||
conversation_starters,
|
||||
append_current_datetime,
|
||||
...updateData
|
||||
} = req.body;
|
||||
|
||||
const toolDefinitions = (await getCachedTools()) ?? {};
|
||||
|
||||
updateData.tools = (updateData.tools ?? [])
|
||||
.map((tool) => {
|
||||
/** Agents-runtime-only tools (e.g. ask_user_question) cannot execute on
|
||||
* the assistants runtime — drop them even when posted directly, since
|
||||
* the tools-dialog scoping doesn't gate REST clients or stale payloads. */
|
||||
if (isAgentsOnlyTool(tool)) {
|
||||
logger.warn(
|
||||
`[/assistants] Dropping agents-only tool from assistant payload: ${typeof tool === 'string' ? tool : tool?.function?.name}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
if (typeof tool !== 'string') {
|
||||
return tool;
|
||||
}
|
||||
|
||||
const toolDef = toolDefinitions[tool];
|
||||
if (!toolDef && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
|
||||
return Object.entries(toolDefinitions)
|
||||
.filter(([key]) => key.startsWith(`${tool}_`))
|
||||
|
||||
.map(([_, val]) => val);
|
||||
}
|
||||
|
||||
return toolDef;
|
||||
})
|
||||
.filter((tool) => tool)
|
||||
.flat();
|
||||
|
||||
if (openai.locals?.azureOptions && updateData.model) {
|
||||
updateData.model = openai.locals.azureOptions.azureOpenAIApiDeploymentName;
|
||||
}
|
||||
|
||||
const updatedAssistant = await openai.beta.assistants.update(assistant_id, updateData);
|
||||
|
||||
if (conversation_starters !== undefined) {
|
||||
const conversationStartersUpdate = await updateAssistantDoc(
|
||||
{ assistant_id },
|
||||
{ conversation_starters },
|
||||
);
|
||||
updatedAssistant.conversation_starters = conversationStartersUpdate.conversation_starters;
|
||||
}
|
||||
|
||||
if (append_current_datetime !== undefined) {
|
||||
await updateAssistantDoc({ assistant_id }, { append_current_datetime });
|
||||
updatedAssistant.append_current_datetime = append_current_datetime;
|
||||
}
|
||||
|
||||
res.json(updatedAssistant);
|
||||
} catch (error) {
|
||||
logger.error('[/assistants/:id] Error updating assistant', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes an assistant.
|
||||
* @route DELETE /assistants/:id
|
||||
* @param {object} req - Express Request
|
||||
* @param {object} req.params - Request params
|
||||
* @param {string} req.params.id - Assistant identifier.
|
||||
* @returns {Assistant} 200 - success response - application/json
|
||||
*/
|
||||
const deleteAssistant = async (req, res) => {
|
||||
try {
|
||||
const { openai } = await getOpenAIClient({ req, res });
|
||||
await validateAuthor({ req, openai });
|
||||
|
||||
const assistant_id = req.params.id;
|
||||
const deletionStatus = await openai.beta.assistants.delete(assistant_id);
|
||||
if (deletionStatus?.deleted) {
|
||||
await deleteAssistantActions({ req, assistant_id });
|
||||
}
|
||||
res.json(deletionStatus);
|
||||
} catch (error) {
|
||||
logger.error('[/assistants/:id] Error deleting assistant', error);
|
||||
res.status(500).json({ error: 'Error deleting assistant' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a list of assistants.
|
||||
* @route GET /assistants
|
||||
* @param {object} req - Express Request
|
||||
* @param {AssistantListParams} req.query - The assistant list parameters for pagination and sorting.
|
||||
* @returns {AssistantListResponse} 200 - success response - application/json
|
||||
*/
|
||||
const listAssistants = async (req, res) => {
|
||||
try {
|
||||
const body = await fetchAssistants({ req, res });
|
||||
res.json(body);
|
||||
} catch (error) {
|
||||
logger.error('[/assistants] Error listing assistants', error);
|
||||
res.status(500).json({ message: 'Error listing assistants' });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Filter assistants based on configuration.
|
||||
*
|
||||
* @param {object} params - The parameters object.
|
||||
* @param {string} params.userId - The user ID to filter private assistants.
|
||||
* @param {AssistantDocument[]} params.assistants - The list of assistants to filter.
|
||||
* @param {Partial<TAssistantEndpoint>} [params.assistantsConfig] - The assistant configuration.
|
||||
* @returns {AssistantDocument[]} - The filtered list of assistants.
|
||||
*/
|
||||
function filterAssistantDocs({ documents, userId, assistantsConfig = {} }) {
|
||||
const { supportedIds, excludedIds, privateAssistants } = assistantsConfig;
|
||||
const removeUserId = (doc) => {
|
||||
const { user: _u, ...document } = doc;
|
||||
return document;
|
||||
};
|
||||
|
||||
if (privateAssistants) {
|
||||
return documents.filter((doc) => userId === doc.user.toString()).map(removeUserId);
|
||||
} else if (supportedIds?.length) {
|
||||
return documents.filter((doc) => supportedIds.includes(doc.assistant_id)).map(removeUserId);
|
||||
} else if (excludedIds?.length) {
|
||||
return documents.filter((doc) => !excludedIds.includes(doc.assistant_id)).map(removeUserId);
|
||||
}
|
||||
return documents.map(removeUserId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of the user's assistant documents (metadata saved to database).
|
||||
* @route GET /assistants/documents
|
||||
* @returns {AssistantDocument[]} 200 - success response - application/json
|
||||
*/
|
||||
const getAssistantDocuments = async (req, res) => {
|
||||
try {
|
||||
const appConfig = req.config;
|
||||
const endpoint = req.query?.endpoint;
|
||||
const assistantsConfig = appConfig.endpoints?.[endpoint];
|
||||
const documents = await getAssistants(
|
||||
{},
|
||||
{
|
||||
user: 1,
|
||||
assistant_id: 1,
|
||||
conversation_starters: 1,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
append_current_datetime: 1,
|
||||
},
|
||||
);
|
||||
|
||||
const docs = filterAssistantDocs({
|
||||
documents,
|
||||
userId: req.user.id,
|
||||
assistantsConfig,
|
||||
});
|
||||
res.json(docs);
|
||||
} catch (error) {
|
||||
logger.error('[/assistants/documents] Error listing assistant documents', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Uploads and updates an avatar for a specific assistant.
|
||||
* @route POST /:assistant_id/avatar
|
||||
* @param {object} req - Express Request
|
||||
* @param {object} req.params - Request params
|
||||
* @param {string} req.params.assistant_id - The ID of the assistant.
|
||||
* @param {Express.Multer.File} req.file - The avatar image file.
|
||||
* @param {object} req.body - Request body
|
||||
* @returns {Object} 200 - success response - application/json
|
||||
*/
|
||||
const uploadAssistantAvatar = async (req, res) => {
|
||||
try {
|
||||
const appConfig = req.config;
|
||||
filterFile({ req, file: req.file, image: true, isAvatar: true });
|
||||
const { assistant_id } = req.params;
|
||||
if (!assistant_id) {
|
||||
return res.status(400).json({ message: 'Assistant ID is required' });
|
||||
}
|
||||
|
||||
const { openai } = await getOpenAIClient({ req, res });
|
||||
await validateAuthor({ req, openai });
|
||||
|
||||
const buffer = await fs.readFile(req.file.path);
|
||||
const image = await uploadImageBuffer({
|
||||
req,
|
||||
context: FileContext.avatar,
|
||||
metadata: { buffer },
|
||||
});
|
||||
|
||||
let _metadata;
|
||||
|
||||
try {
|
||||
const assistant = await openai.beta.assistants.retrieve(assistant_id);
|
||||
if (assistant) {
|
||||
_metadata = assistant.metadata;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('[/:assistant_id/avatar] Error fetching assistant', error);
|
||||
_metadata = {};
|
||||
}
|
||||
|
||||
if (_metadata.avatar && _metadata.avatar_source) {
|
||||
const { deleteFile } = getStrategyFunctions(_metadata.avatar_source);
|
||||
try {
|
||||
await deleteFile(req, {
|
||||
filepath: _metadata.avatar,
|
||||
user: req.user.id,
|
||||
tenantId: req.user.tenantId,
|
||||
});
|
||||
await deleteFileByFilter({ user: req.user.id, filepath: _metadata.avatar });
|
||||
} catch (error) {
|
||||
logger.error('[/:assistant_id/avatar] Error deleting old avatar', error);
|
||||
}
|
||||
}
|
||||
|
||||
const metadata = {
|
||||
..._metadata,
|
||||
avatar: image.filepath,
|
||||
avatar_source: appConfig.fileStrategy,
|
||||
};
|
||||
|
||||
const promises = [];
|
||||
promises.push(
|
||||
updateAssistantDoc(
|
||||
{ assistant_id },
|
||||
{
|
||||
avatar: {
|
||||
filepath: image.filepath,
|
||||
source: appConfig.fileStrategy,
|
||||
},
|
||||
user: req.user.id,
|
||||
},
|
||||
),
|
||||
);
|
||||
promises.push(openai.beta.assistants.update(assistant_id, { metadata }));
|
||||
|
||||
const resolved = await Promise.all(promises);
|
||||
res.status(201).json(resolved[1]);
|
||||
} catch (error) {
|
||||
const message = 'An error occurred while updating the Assistant Avatar';
|
||||
logger.error(message, error);
|
||||
res.status(500).json({ message });
|
||||
} finally {
|
||||
try {
|
||||
await fs.unlink(req.file.path);
|
||||
logger.debug('[/:agent_id/avatar] Temp. image upload file deleted');
|
||||
} catch {
|
||||
logger.debug('[/:agent_id/avatar] Temp. image upload file already deleted');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
createAssistant,
|
||||
retrieveAssistant,
|
||||
patchAssistant,
|
||||
deleteAssistant,
|
||||
listAssistants,
|
||||
getAssistantDocuments,
|
||||
uploadAssistantAvatar,
|
||||
};
|
||||
@@ -0,0 +1,315 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { ToolCallTypes } = require('librechat-data-provider');
|
||||
const validateAuthor = require('~/server/middleware/assistants/validateAuthor');
|
||||
const { validateAndUpdateTool } = require('~/server/services/ActionService');
|
||||
const { getCachedTools } = require('~/server/services/Config');
|
||||
const { manifestToolMap, isAgentsOnlyTool } = require('~/app/clients/tools');
|
||||
const { updateAssistantDoc } = require('~/models');
|
||||
const { getOpenAIClient } = require('./helpers');
|
||||
|
||||
/**
|
||||
* Create an assistant.
|
||||
* @route POST /assistants
|
||||
* @param {AssistantCreateParams} req.body - The assistant creation parameters.
|
||||
* @returns {Assistant} 201 - success response - application/json
|
||||
*/
|
||||
const createAssistant = async (req, res) => {
|
||||
try {
|
||||
/** @type {{ openai: OpenAIClient }} */
|
||||
const { openai } = await getOpenAIClient({ req, res });
|
||||
|
||||
const {
|
||||
tools = [],
|
||||
endpoint,
|
||||
conversation_starters,
|
||||
append_current_datetime,
|
||||
...assistantData
|
||||
} = req.body;
|
||||
delete assistantData.conversation_starters;
|
||||
delete assistantData.append_current_datetime;
|
||||
|
||||
const toolDefinitions = (await getCachedTools()) ?? {};
|
||||
|
||||
assistantData.tools = tools
|
||||
.map((tool) => {
|
||||
/** Agents-runtime-only tools (e.g. ask_user_question) cannot execute on
|
||||
* the assistants runtime — drop them even when posted directly, since
|
||||
* the tools-dialog scoping doesn't gate REST clients or stale payloads. */
|
||||
if (isAgentsOnlyTool(tool)) {
|
||||
logger.warn(
|
||||
`[/assistants] Dropping agents-only tool from assistant payload: ${typeof tool === 'string' ? tool : tool?.function?.name}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
if (typeof tool !== 'string') {
|
||||
return tool;
|
||||
}
|
||||
|
||||
const toolDef = toolDefinitions[tool];
|
||||
if (!toolDef && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
|
||||
return Object.entries(toolDefinitions)
|
||||
.filter(([key]) => key.startsWith(`${tool}_`))
|
||||
|
||||
.map(([_, val]) => val);
|
||||
}
|
||||
|
||||
return toolDef;
|
||||
})
|
||||
.filter((tool) => tool)
|
||||
.flat();
|
||||
|
||||
let azureModelIdentifier = null;
|
||||
if (openai.locals?.azureOptions) {
|
||||
azureModelIdentifier = assistantData.model;
|
||||
assistantData.model = openai.locals.azureOptions.azureOpenAIApiDeploymentName;
|
||||
}
|
||||
|
||||
assistantData.metadata = {
|
||||
author: req.user.id,
|
||||
endpoint,
|
||||
};
|
||||
|
||||
const assistant = await openai.beta.assistants.create(assistantData);
|
||||
|
||||
const createData = { user: req.user.id };
|
||||
if (conversation_starters) {
|
||||
createData.conversation_starters = conversation_starters;
|
||||
}
|
||||
if (append_current_datetime !== undefined) {
|
||||
createData.append_current_datetime = append_current_datetime;
|
||||
}
|
||||
|
||||
const document = await updateAssistantDoc({ assistant_id: assistant.id }, createData);
|
||||
|
||||
if (azureModelIdentifier) {
|
||||
assistant.model = azureModelIdentifier;
|
||||
}
|
||||
|
||||
if (document.conversation_starters) {
|
||||
assistant.conversation_starters = document.conversation_starters;
|
||||
}
|
||||
if (append_current_datetime !== undefined) {
|
||||
assistant.append_current_datetime = append_current_datetime;
|
||||
}
|
||||
|
||||
logger.debug('/assistants/', assistant);
|
||||
res.status(201).json(assistant);
|
||||
} catch (error) {
|
||||
logger.error('[/assistants] Error creating assistant', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Modifies an assistant.
|
||||
* @param {object} params
|
||||
* @param {ServerRequest} params.req
|
||||
* @param {OpenAIClient} params.openai
|
||||
* @param {string} params.assistant_id
|
||||
* @param {AssistantUpdateParams} params.updateData
|
||||
* @returns {Promise<Assistant>} The updated assistant.
|
||||
*/
|
||||
const updateAssistant = async ({ req, openai, assistant_id, updateData }) => {
|
||||
await validateAuthor({ req, openai });
|
||||
const tools = [];
|
||||
let conversation_starters = null;
|
||||
|
||||
if (updateData?.conversation_starters) {
|
||||
const conversationStartersUpdate = await updateAssistantDoc(
|
||||
{ assistant_id: assistant_id },
|
||||
{ conversation_starters: updateData.conversation_starters },
|
||||
);
|
||||
conversation_starters = conversationStartersUpdate.conversation_starters;
|
||||
|
||||
delete updateData.conversation_starters;
|
||||
}
|
||||
|
||||
if (updateData?.append_current_datetime !== undefined) {
|
||||
await updateAssistantDoc(
|
||||
{ assistant_id: assistant_id },
|
||||
{ append_current_datetime: updateData.append_current_datetime },
|
||||
);
|
||||
delete updateData.append_current_datetime;
|
||||
}
|
||||
|
||||
let hasFileSearch = false;
|
||||
for (const tool of updateData.tools ?? []) {
|
||||
/** Agents-runtime-only tools (e.g. ask_user_question) cannot execute on
|
||||
* the assistants runtime — drop them even when posted directly, since
|
||||
* the tools-dialog scoping doesn't gate REST clients or stale payloads. */
|
||||
if (isAgentsOnlyTool(tool)) {
|
||||
logger.warn(
|
||||
`[/assistants] Dropping agents-only tool from assistant payload: ${typeof tool === 'string' ? tool : tool?.function?.name}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
const toolDefinitions = (await getCachedTools()) ?? {};
|
||||
let actualTool = typeof tool === 'string' ? toolDefinitions[tool] : tool;
|
||||
|
||||
if (!actualTool && manifestToolMap[tool] && manifestToolMap[tool].toolkit === true) {
|
||||
actualTool = Object.entries(toolDefinitions)
|
||||
.filter(([key]) => key.startsWith(`${tool}_`))
|
||||
|
||||
.map(([_, val]) => val);
|
||||
} else if (!actualTool) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (Array.isArray(actualTool)) {
|
||||
for (const subTool of actualTool) {
|
||||
if (!subTool.function) {
|
||||
tools.push(subTool);
|
||||
continue;
|
||||
}
|
||||
|
||||
const updatedTool = await validateAndUpdateTool({ req, tool: subTool, assistant_id });
|
||||
if (updatedTool) {
|
||||
tools.push(updatedTool);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (actualTool.type === ToolCallTypes.FILE_SEARCH) {
|
||||
hasFileSearch = true;
|
||||
}
|
||||
|
||||
if (!actualTool.function) {
|
||||
tools.push(actualTool);
|
||||
continue;
|
||||
}
|
||||
|
||||
const updatedTool = await validateAndUpdateTool({ req, tool: actualTool, assistant_id });
|
||||
if (updatedTool) {
|
||||
tools.push(updatedTool);
|
||||
}
|
||||
}
|
||||
|
||||
if (hasFileSearch && !updateData.tool_resources) {
|
||||
const assistant = await openai.beta.assistants.retrieve(assistant_id);
|
||||
updateData.tool_resources = assistant.tool_resources ?? null;
|
||||
}
|
||||
|
||||
if (hasFileSearch && !updateData.tool_resources?.file_search) {
|
||||
updateData.tool_resources = {
|
||||
...(updateData.tool_resources ?? {}),
|
||||
file_search: {
|
||||
vector_store_ids: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
updateData.tools = tools;
|
||||
|
||||
if (openai.locals?.azureOptions && updateData.model) {
|
||||
updateData.model = openai.locals.azureOptions.azureOpenAIApiDeploymentName;
|
||||
}
|
||||
|
||||
const assistant = await openai.beta.assistants.update(assistant_id, updateData);
|
||||
|
||||
if (conversation_starters) {
|
||||
assistant.conversation_starters = conversation_starters;
|
||||
}
|
||||
|
||||
return assistant;
|
||||
};
|
||||
|
||||
/**
|
||||
* Modifies an assistant with the resource file id.
|
||||
* @param {object} params
|
||||
* @param {ServerRequest} params.req
|
||||
* @param {OpenAIClient} params.openai
|
||||
* @param {string} params.assistant_id
|
||||
* @param {string} params.tool_resource
|
||||
* @param {string} params.file_id
|
||||
* @returns {Promise<Assistant>} The updated assistant.
|
||||
*/
|
||||
const addResourceFileId = async ({ req, openai, assistant_id, tool_resource, file_id }) => {
|
||||
const assistant = await openai.beta.assistants.retrieve(assistant_id);
|
||||
const { tool_resources = {} } = assistant;
|
||||
if (tool_resources[tool_resource]) {
|
||||
tool_resources[tool_resource].file_ids.push(file_id);
|
||||
} else {
|
||||
tool_resources[tool_resource] = { file_ids: [file_id] };
|
||||
}
|
||||
|
||||
delete assistant.id;
|
||||
return await updateAssistant({
|
||||
req,
|
||||
openai,
|
||||
assistant_id,
|
||||
updateData: { tools: assistant.tools, tool_resources },
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes a file ID from an assistant's resource.
|
||||
* @param {object} params
|
||||
* @param {ServerRequest} params.req
|
||||
* @param {OpenAIClient} params.openai
|
||||
* @param {string} params.assistant_id
|
||||
* @param {string} [params.tool_resource]
|
||||
* @param {string} params.file_id
|
||||
* @param {AssistantUpdateParams} params.updateData
|
||||
* @returns {Promise<Assistant>} The updated assistant.
|
||||
*/
|
||||
const deleteResourceFileId = async ({ req, openai, assistant_id, tool_resource, file_id }) => {
|
||||
const assistant = await openai.beta.assistants.retrieve(assistant_id);
|
||||
const { tool_resources = {} } = assistant;
|
||||
|
||||
if (tool_resource && tool_resources[tool_resource]) {
|
||||
const resource = tool_resources[tool_resource];
|
||||
const index = resource.file_ids.indexOf(file_id);
|
||||
if (index !== -1) {
|
||||
resource.file_ids.splice(index, 1);
|
||||
}
|
||||
} else {
|
||||
for (const resourceKey in tool_resources) {
|
||||
const resource = tool_resources[resourceKey];
|
||||
const index = resource.file_ids.indexOf(file_id);
|
||||
if (index !== -1) {
|
||||
resource.file_ids.splice(index, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delete assistant.id;
|
||||
return await updateAssistant({
|
||||
req,
|
||||
openai,
|
||||
assistant_id,
|
||||
updateData: { tools: assistant.tools, tool_resources },
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Modifies an assistant.
|
||||
* @route PATCH /assistants/:id
|
||||
* @param {object} req - Express Request
|
||||
* @param {object} req.params - Request params
|
||||
* @param {string} req.params.id - Assistant identifier.
|
||||
* @param {AssistantUpdateParams} req.body - The assistant update parameters.
|
||||
* @returns {Assistant} 200 - success response - application/json
|
||||
*/
|
||||
const patchAssistant = async (req, res) => {
|
||||
try {
|
||||
const { openai } = await getOpenAIClient({ req, res });
|
||||
const assistant_id = req.params.id;
|
||||
const { endpoint: _e, ...updateData } = req.body;
|
||||
updateData.tools = updateData.tools ?? [];
|
||||
const updatedAssistant = await updateAssistant({ req, openai, assistant_id, updateData });
|
||||
res.json(updatedAssistant);
|
||||
} catch (error) {
|
||||
logger.error('[/assistants/:id] Error updating assistant', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
patchAssistant,
|
||||
createAssistant,
|
||||
updateAssistant,
|
||||
addResourceFileId,
|
||||
deleteResourceFileId,
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { generate2FATempToken } = require('~/server/services/twoFactorService');
|
||||
const { setAuthTokens } = require('~/server/services/AuthService');
|
||||
|
||||
const loginController = async (req, res) => {
|
||||
try {
|
||||
if (!req.user) {
|
||||
return res.status(400).json({ message: 'Invalid credentials' });
|
||||
}
|
||||
|
||||
if (req.user.twoFactorEnabled) {
|
||||
const tempToken = generate2FATempToken(req.user._id);
|
||||
return res.status(200).json({ twoFAPending: true, tempToken });
|
||||
}
|
||||
|
||||
const { password: _p, totpSecret: _t, __v, ...user } = req.user;
|
||||
user.id = user._id.toString();
|
||||
|
||||
const token = await setAuthTokens(req.user._id, res, null, req);
|
||||
|
||||
return res.status(200).send({ token, user });
|
||||
} catch (err) {
|
||||
logger.error('[loginController]', err);
|
||||
return res.status(500).json({ message: 'Something went wrong' });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
loginController,
|
||||
};
|
||||
@@ -0,0 +1,144 @@
|
||||
const cookies = require('cookie');
|
||||
const { isEnabled, clearCloudFrontCookies } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { logoutUser } = require('~/server/services/AuthService');
|
||||
const { getOpenIdConfig } = require('~/strategies');
|
||||
|
||||
/** Parses and validates OPENID_MAX_LOGOUT_URL_LENGTH, returning defaultValue on invalid input */
|
||||
function parseMaxLogoutUrlLength(defaultValue = 2000) {
|
||||
const raw = process.env.OPENID_MAX_LOGOUT_URL_LENGTH;
|
||||
const trimmed = raw == null ? '' : raw.trim();
|
||||
if (trimmed === '') {
|
||||
return defaultValue;
|
||||
}
|
||||
const parsed = /^\d+$/.test(trimmed) ? Number(trimmed) : NaN;
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
logger.warn(
|
||||
`[logoutController] Invalid OPENID_MAX_LOGOUT_URL_LENGTH value "${raw}", using default ${defaultValue}`,
|
||||
);
|
||||
return defaultValue;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const logoutController = async (req, res) => {
|
||||
const parsedCookies = req.headers.cookie ? cookies.parse(req.headers.cookie) : {};
|
||||
const isOpenIdUser = req.user?.openidId != null && req.user?.provider === 'openid';
|
||||
|
||||
let refreshToken;
|
||||
let idToken;
|
||||
if (isOpenIdUser && req.session?.openidTokens) {
|
||||
refreshToken = req.session.openidTokens.refreshToken;
|
||||
idToken = req.session.openidTokens.idToken;
|
||||
delete req.session.openidTokens;
|
||||
}
|
||||
refreshToken = refreshToken || parsedCookies.refreshToken;
|
||||
idToken = idToken || parsedCookies.openid_id_token;
|
||||
|
||||
try {
|
||||
const logout = await logoutUser(req, refreshToken);
|
||||
const { status, message } = logout;
|
||||
|
||||
res.clearCookie('refreshToken');
|
||||
res.clearCookie('openid_access_token');
|
||||
res.clearCookie('openid_id_token');
|
||||
res.clearCookie('openid_user_id');
|
||||
res.clearCookie('token_provider');
|
||||
clearCloudFrontCookies(res, {
|
||||
userId: req.user?.id ?? req.user?._id?.toString?.(),
|
||||
tenantId: req.user?.tenantId,
|
||||
});
|
||||
const response = { message };
|
||||
if (
|
||||
isOpenIdUser &&
|
||||
isEnabled(process.env.OPENID_USE_END_SESSION_ENDPOINT) &&
|
||||
process.env.OPENID_ISSUER
|
||||
) {
|
||||
let openIdConfig;
|
||||
try {
|
||||
openIdConfig = getOpenIdConfig();
|
||||
} catch (err) {
|
||||
logger.warn('[logoutController] OpenID config not available:', err.message);
|
||||
}
|
||||
if (openIdConfig) {
|
||||
const endSessionEndpoint = openIdConfig.serverMetadata().end_session_endpoint;
|
||||
if (endSessionEndpoint) {
|
||||
const endSessionUrl = new URL(endSessionEndpoint);
|
||||
const postLogoutRedirectUri =
|
||||
process.env.OPENID_POST_LOGOUT_REDIRECT_URI || `${process.env.DOMAIN_CLIENT}/login`;
|
||||
endSessionUrl.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri);
|
||||
|
||||
/**
|
||||
* OIDC RP-Initiated Logout cascading strategy:
|
||||
* 1. id_token_hint (most secure, identifies exact session)
|
||||
* 2. logout_hint + client_id (when URL would exceed safe length)
|
||||
* 3. client_id only (when no token available)
|
||||
*
|
||||
* JWT tokens from spec-compliant OIDC providers use base64url
|
||||
* encoding (RFC 7515), whose characters are all URL-safe, so
|
||||
* token length equals URL-encoded length for projection.
|
||||
* Non-compliant issuers using standard base64 (+/=) will cause
|
||||
* underestimation; increase OPENID_MAX_LOGOUT_URL_LENGTH if the
|
||||
* fallback does not trigger as expected.
|
||||
*/
|
||||
const maxLogoutUrlLength = parseMaxLogoutUrlLength();
|
||||
let strategy = 'no_token';
|
||||
if (idToken) {
|
||||
const baseLength = endSessionUrl.toString().length;
|
||||
const projectedLength = baseLength + '&id_token_hint='.length + idToken.length;
|
||||
if (projectedLength > maxLogoutUrlLength) {
|
||||
strategy = 'too_long';
|
||||
logger.debug(
|
||||
`[logoutController] Logout URL too long (${projectedLength} chars, max ${maxLogoutUrlLength}), ` +
|
||||
'switching to logout_hint strategy',
|
||||
);
|
||||
} else {
|
||||
strategy = 'use_token';
|
||||
}
|
||||
}
|
||||
|
||||
if (strategy === 'use_token') {
|
||||
endSessionUrl.searchParams.set('id_token_hint', idToken);
|
||||
} else {
|
||||
if (strategy === 'too_long') {
|
||||
const logoutHint = req.user?.email || req.user?.username || req.user?.openidId;
|
||||
if (logoutHint) {
|
||||
endSessionUrl.searchParams.set('logout_hint', logoutHint);
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.OPENID_CLIENT_ID) {
|
||||
endSessionUrl.searchParams.set('client_id', process.env.OPENID_CLIENT_ID);
|
||||
} else if (strategy === 'too_long') {
|
||||
logger.warn(
|
||||
'[logoutController] Logout URL exceeds max length and OPENID_CLIENT_ID is not set. ' +
|
||||
'The OIDC end-session request may be rejected. ' +
|
||||
'Consider setting OPENID_CLIENT_ID or increasing OPENID_MAX_LOGOUT_URL_LENGTH.',
|
||||
);
|
||||
} else {
|
||||
logger.warn(
|
||||
'[logoutController] Neither id_token_hint nor OPENID_CLIENT_ID is available. ' +
|
||||
'To enable id_token_hint, set OPENID_REUSE_TOKENS=true. ' +
|
||||
'The OIDC end-session request may be rejected by the identity provider.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
response.redirect = endSessionUrl.toString();
|
||||
} else {
|
||||
logger.warn(
|
||||
'[logoutController] end_session_endpoint not found in OpenID issuer metadata. Please verify that the issuer is correct.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return res.status(status).send(response);
|
||||
} catch (err) {
|
||||
logger.error('[logoutController]', err);
|
||||
return res.status(500).json({ message: err.message });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
logoutController,
|
||||
};
|
||||
@@ -0,0 +1,583 @@
|
||||
const cookies = require('cookie');
|
||||
|
||||
const mockLogoutUser = jest.fn();
|
||||
const mockLogger = { warn: jest.fn(), error: jest.fn(), debug: jest.fn() };
|
||||
const mockIsEnabled = jest.fn();
|
||||
const mockGetOpenIdConfig = jest.fn();
|
||||
const mockClearCloudFrontCookies = jest.fn();
|
||||
|
||||
jest.mock('cookie');
|
||||
jest.mock('@librechat/api', () => ({
|
||||
isEnabled: (...args) => mockIsEnabled(...args),
|
||||
clearCloudFrontCookies: (...args) => mockClearCloudFrontCookies(...args),
|
||||
}));
|
||||
jest.mock('@librechat/data-schemas', () => ({ logger: mockLogger }));
|
||||
jest.mock('~/server/services/AuthService', () => ({
|
||||
logoutUser: (...args) => mockLogoutUser(...args),
|
||||
}));
|
||||
jest.mock('~/strategies', () => ({ getOpenIdConfig: () => mockGetOpenIdConfig() }));
|
||||
|
||||
const { logoutController } = require('./LogoutController');
|
||||
|
||||
function buildReq(overrides = {}) {
|
||||
return {
|
||||
user: { _id: 'user1', openidId: 'oid1', provider: 'openid' },
|
||||
headers: { cookie: 'refreshToken=rt1' },
|
||||
session: {
|
||||
openidTokens: { refreshToken: 'srt', idToken: 'small-id-token' },
|
||||
destroy: jest.fn(),
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRes() {
|
||||
const res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
json: jest.fn().mockReturnThis(),
|
||||
clearCookie: jest.fn(),
|
||||
};
|
||||
return res;
|
||||
}
|
||||
|
||||
const ORIGINAL_ENV = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env = {
|
||||
...ORIGINAL_ENV,
|
||||
OPENID_USE_END_SESSION_ENDPOINT: 'true',
|
||||
OPENID_ISSUER: 'https://idp.example.com',
|
||||
OPENID_CLIENT_ID: 'my-client-id',
|
||||
DOMAIN_CLIENT: 'https://app.example.com',
|
||||
};
|
||||
cookies.parse.mockReturnValue({ refreshToken: 'cookie-rt' });
|
||||
mockLogoutUser.mockResolvedValue({ status: 200, message: 'Logout successful' });
|
||||
mockIsEnabled.mockReturnValue(true);
|
||||
mockGetOpenIdConfig.mockReturnValue({
|
||||
serverMetadata: () => ({
|
||||
end_session_endpoint: 'https://idp.example.com/logout',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = ORIGINAL_ENV;
|
||||
});
|
||||
|
||||
describe('LogoutController', () => {
|
||||
describe('id_token_hint from session', () => {
|
||||
it('sets id_token_hint when session has idToken', async () => {
|
||||
const req = buildReq();
|
||||
const res = buildRes();
|
||||
|
||||
await logoutController(req, res);
|
||||
|
||||
const body = res.send.mock.calls[0][0];
|
||||
expect(body.redirect).toContain('id_token_hint=small-id-token');
|
||||
expect(body.redirect).not.toContain('client_id=');
|
||||
});
|
||||
});
|
||||
|
||||
describe('id_token_hint from cookie fallback', () => {
|
||||
it('uses cookie id_token when session has no tokens', async () => {
|
||||
cookies.parse.mockReturnValue({
|
||||
refreshToken: 'cookie-rt',
|
||||
openid_id_token: 'cookie-id-token',
|
||||
});
|
||||
const req = buildReq({ session: { destroy: jest.fn() } });
|
||||
const res = buildRes();
|
||||
|
||||
await logoutController(req, res);
|
||||
|
||||
const body = res.send.mock.calls[0][0];
|
||||
expect(body.redirect).toContain('id_token_hint=cookie-id-token');
|
||||
});
|
||||
});
|
||||
|
||||
describe('client_id fallback', () => {
|
||||
it('falls back to client_id when no idToken is available', async () => {
|
||||
cookies.parse.mockReturnValue({ refreshToken: 'cookie-rt' });
|
||||
const req = buildReq({ session: { destroy: jest.fn() } });
|
||||
const res = buildRes();
|
||||
|
||||
await logoutController(req, res);
|
||||
|
||||
const body = res.send.mock.calls[0][0];
|
||||
expect(body.redirect).toContain('client_id=my-client-id');
|
||||
expect(body.redirect).not.toContain('id_token_hint=');
|
||||
});
|
||||
|
||||
it('does not produce client_id=undefined when OPENID_CLIENT_ID is unset', async () => {
|
||||
delete process.env.OPENID_CLIENT_ID;
|
||||
cookies.parse.mockReturnValue({ refreshToken: 'cookie-rt' });
|
||||
const req = buildReq({ session: { destroy: jest.fn() } });
|
||||
const res = buildRes();
|
||||
|
||||
await logoutController(req, res);
|
||||
|
||||
const body = res.send.mock.calls[0][0];
|
||||
expect(body.redirect).not.toContain('client_id=');
|
||||
expect(body.redirect).not.toContain('undefined');
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Neither id_token_hint nor OPENID_CLIENT_ID'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OPENID_USE_END_SESSION_ENDPOINT disabled', () => {
|
||||
it('does not include redirect when disabled', async () => {
|
||||
mockIsEnabled.mockReturnValue(false);
|
||||
const req = buildReq();
|
||||
const res = buildRes();
|
||||
|
||||
await logoutController(req, res);
|
||||
|
||||
const body = res.send.mock.calls[0][0];
|
||||
expect(body.redirect).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('OPENID_ISSUER unset', () => {
|
||||
it('does not include redirect when OPENID_ISSUER is missing', async () => {
|
||||
delete process.env.OPENID_ISSUER;
|
||||
const req = buildReq();
|
||||
const res = buildRes();
|
||||
|
||||
await logoutController(req, res);
|
||||
|
||||
const body = res.send.mock.calls[0][0];
|
||||
expect(body.redirect).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('non-OpenID user', () => {
|
||||
it('does not include redirect for non-OpenID users', async () => {
|
||||
const req = buildReq({
|
||||
user: { _id: 'user1', provider: 'local' },
|
||||
});
|
||||
const res = buildRes();
|
||||
|
||||
await logoutController(req, res);
|
||||
|
||||
const body = res.send.mock.calls[0][0];
|
||||
expect(body.redirect).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('post_logout_redirect_uri', () => {
|
||||
it('uses OPENID_POST_LOGOUT_REDIRECT_URI when set', async () => {
|
||||
process.env.OPENID_POST_LOGOUT_REDIRECT_URI = 'https://custom.example.com/logged-out';
|
||||
const req = buildReq();
|
||||
const res = buildRes();
|
||||
|
||||
await logoutController(req, res);
|
||||
|
||||
const body = res.send.mock.calls[0][0];
|
||||
const url = new URL(body.redirect);
|
||||
expect(url.searchParams.get('post_logout_redirect_uri')).toBe(
|
||||
'https://custom.example.com/logged-out',
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults to DOMAIN_CLIENT/login when OPENID_POST_LOGOUT_REDIRECT_URI is unset', async () => {
|
||||
delete process.env.OPENID_POST_LOGOUT_REDIRECT_URI;
|
||||
const req = buildReq();
|
||||
const res = buildRes();
|
||||
|
||||
await logoutController(req, res);
|
||||
|
||||
const body = res.send.mock.calls[0][0];
|
||||
const url = new URL(body.redirect);
|
||||
expect(url.searchParams.get('post_logout_redirect_uri')).toBe(
|
||||
'https://app.example.com/login',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenID config not available', () => {
|
||||
it('warns and returns no redirect when getOpenIdConfig throws', async () => {
|
||||
mockGetOpenIdConfig.mockImplementation(() => {
|
||||
throw new Error('OpenID configuration has not been initialized');
|
||||
});
|
||||
const req = buildReq();
|
||||
const res = buildRes();
|
||||
|
||||
await logoutController(req, res);
|
||||
|
||||
const body = res.send.mock.calls[0][0];
|
||||
expect(body.redirect).toBeUndefined();
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('OpenID config not available'),
|
||||
'OpenID configuration has not been initialized',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('end_session_endpoint not in metadata', () => {
|
||||
it('warns and returns no redirect when end_session_endpoint is missing', async () => {
|
||||
mockGetOpenIdConfig.mockReturnValue({
|
||||
serverMetadata: () => ({}),
|
||||
});
|
||||
const req = buildReq();
|
||||
const res = buildRes();
|
||||
|
||||
await logoutController(req, res);
|
||||
|
||||
const body = res.send.mock.calls[0][0];
|
||||
expect(body.redirect).toBeUndefined();
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('end_session_endpoint not found'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('returns 500 on logoutUser error', async () => {
|
||||
mockLogoutUser.mockRejectedValue(new Error('session error'));
|
||||
const req = buildReq();
|
||||
const res = buildRes();
|
||||
|
||||
await logoutController(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({ message: 'session error' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('cookie clearing', () => {
|
||||
it('clears all auth cookies on successful logout', async () => {
|
||||
const req = buildReq();
|
||||
const res = buildRes();
|
||||
|
||||
await logoutController(req, res);
|
||||
|
||||
expect(res.clearCookie).toHaveBeenCalledWith('refreshToken');
|
||||
expect(res.clearCookie).toHaveBeenCalledWith('openid_access_token');
|
||||
expect(res.clearCookie).toHaveBeenCalledWith('openid_id_token');
|
||||
expect(res.clearCookie).toHaveBeenCalledWith('openid_user_id');
|
||||
expect(res.clearCookie).toHaveBeenCalledWith('token_provider');
|
||||
});
|
||||
|
||||
it('calls clearCloudFrontCookies on successful logout', async () => {
|
||||
const req = buildReq({ user: { _id: 'user1', tenantId: 'tenantA' } });
|
||||
const res = buildRes();
|
||||
|
||||
await logoutController(req, res);
|
||||
|
||||
expect(mockClearCloudFrontCookies).toHaveBeenCalledWith(res, {
|
||||
userId: 'user1',
|
||||
tenantId: 'tenantA',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('URL length limit and logout_hint fallback', () => {
|
||||
it('uses logout_hint when id_token makes URL exceed default limit (2000 chars)', async () => {
|
||||
const longIdToken = 'a'.repeat(3000);
|
||||
const req = buildReq({
|
||||
user: { _id: 'user1', openidId: 'oid1', provider: 'openid', email: 'user@example.com' },
|
||||
session: {
|
||||
openidTokens: { refreshToken: 'srt', idToken: longIdToken },
|
||||
destroy: jest.fn(),
|
||||
},
|
||||
});
|
||||
const res = buildRes();
|
||||
|
||||
await logoutController(req, res);
|
||||
|
||||
const body = res.send.mock.calls[0][0];
|
||||
expect(body.redirect).not.toContain('id_token_hint=');
|
||||
expect(body.redirect).toContain('logout_hint=user%40example.com');
|
||||
expect(body.redirect).toContain('client_id=my-client-id');
|
||||
expect(mockLogger.debug).toHaveBeenCalledWith(expect.stringContaining('Logout URL too long'));
|
||||
});
|
||||
|
||||
it('uses id_token_hint when URL is within default limit', async () => {
|
||||
const shortIdToken = 'short-token';
|
||||
const req = buildReq({
|
||||
session: {
|
||||
openidTokens: { refreshToken: 'srt', idToken: shortIdToken },
|
||||
destroy: jest.fn(),
|
||||
},
|
||||
});
|
||||
const res = buildRes();
|
||||
|
||||
await logoutController(req, res);
|
||||
|
||||
const body = res.send.mock.calls[0][0];
|
||||
expect(body.redirect).toContain('id_token_hint=short-token');
|
||||
expect(body.redirect).not.toContain('logout_hint=');
|
||||
expect(body.redirect).not.toContain('client_id=');
|
||||
});
|
||||
|
||||
it('respects custom OPENID_MAX_LOGOUT_URL_LENGTH', async () => {
|
||||
process.env.OPENID_MAX_LOGOUT_URL_LENGTH = '500';
|
||||
const mediumIdToken = 'a'.repeat(600);
|
||||
const req = buildReq({
|
||||
user: { _id: 'user1', openidId: 'oid1', provider: 'openid', email: 'user@example.com' },
|
||||
session: {
|
||||
openidTokens: { refreshToken: 'srt', idToken: mediumIdToken },
|
||||
destroy: jest.fn(),
|
||||
},
|
||||
});
|
||||
const res = buildRes();
|
||||
|
||||
await logoutController(req, res);
|
||||
|
||||
const body = res.send.mock.calls[0][0];
|
||||
expect(body.redirect).not.toContain('id_token_hint=');
|
||||
expect(body.redirect).toContain('logout_hint=user%40example.com');
|
||||
});
|
||||
|
||||
it('uses username as logout_hint when email is not available', async () => {
|
||||
const longIdToken = 'a'.repeat(3000);
|
||||
const req = buildReq({
|
||||
user: {
|
||||
_id: 'user1',
|
||||
openidId: 'oid1',
|
||||
provider: 'openid',
|
||||
username: 'testuser',
|
||||
},
|
||||
session: {
|
||||
openidTokens: { refreshToken: 'srt', idToken: longIdToken },
|
||||
destroy: jest.fn(),
|
||||
},
|
||||
});
|
||||
const res = buildRes();
|
||||
|
||||
await logoutController(req, res);
|
||||
|
||||
const body = res.send.mock.calls[0][0];
|
||||
expect(body.redirect).toContain('logout_hint=testuser');
|
||||
});
|
||||
|
||||
it('uses openidId as logout_hint when email and username are not available', async () => {
|
||||
const longIdToken = 'a'.repeat(3000);
|
||||
const req = buildReq({
|
||||
user: { _id: 'user1', openidId: 'unique-oid-123', provider: 'openid' },
|
||||
session: {
|
||||
openidTokens: { refreshToken: 'srt', idToken: longIdToken },
|
||||
destroy: jest.fn(),
|
||||
},
|
||||
});
|
||||
const res = buildRes();
|
||||
|
||||
await logoutController(req, res);
|
||||
|
||||
const body = res.send.mock.calls[0][0];
|
||||
expect(body.redirect).toContain('logout_hint=unique-oid-123');
|
||||
});
|
||||
|
||||
it('uses openidId as logout_hint when email and username are explicitly null', async () => {
|
||||
const longIdToken = 'a'.repeat(3000);
|
||||
const req = buildReq({
|
||||
user: {
|
||||
_id: 'user1',
|
||||
openidId: 'oid-without-email',
|
||||
provider: 'openid',
|
||||
email: null,
|
||||
username: null,
|
||||
},
|
||||
session: {
|
||||
openidTokens: { refreshToken: 'srt', idToken: longIdToken },
|
||||
destroy: jest.fn(),
|
||||
},
|
||||
});
|
||||
const res = buildRes();
|
||||
|
||||
await logoutController(req, res);
|
||||
|
||||
const body = res.send.mock.calls[0][0];
|
||||
expect(body.redirect).not.toContain('id_token_hint=');
|
||||
expect(body.redirect).toContain('logout_hint=oid-without-email');
|
||||
expect(body.redirect).toContain('client_id=my-client-id');
|
||||
});
|
||||
|
||||
it('uses only client_id when absolutely no hint is available', async () => {
|
||||
const longIdToken = 'a'.repeat(3000);
|
||||
const req = buildReq({
|
||||
user: {
|
||||
_id: 'user1',
|
||||
openidId: '',
|
||||
provider: 'openid',
|
||||
email: '',
|
||||
username: '',
|
||||
},
|
||||
session: {
|
||||
openidTokens: { refreshToken: 'srt', idToken: longIdToken },
|
||||
destroy: jest.fn(),
|
||||
},
|
||||
});
|
||||
const res = buildRes();
|
||||
|
||||
await logoutController(req, res);
|
||||
|
||||
const body = res.send.mock.calls[0][0];
|
||||
expect(body.redirect).not.toContain('id_token_hint=');
|
||||
expect(body.redirect).not.toContain('logout_hint=');
|
||||
expect(body.redirect).toContain('client_id=my-client-id');
|
||||
});
|
||||
|
||||
it('warns about missing OPENID_CLIENT_ID when URL is too long', async () => {
|
||||
delete process.env.OPENID_CLIENT_ID;
|
||||
const longIdToken = 'a'.repeat(3000);
|
||||
const req = buildReq({
|
||||
user: { _id: 'user1', openidId: 'oid1', provider: 'openid', email: 'user@example.com' },
|
||||
session: {
|
||||
openidTokens: { refreshToken: 'srt', idToken: longIdToken },
|
||||
destroy: jest.fn(),
|
||||
},
|
||||
});
|
||||
const res = buildRes();
|
||||
|
||||
await logoutController(req, res);
|
||||
|
||||
const body = res.send.mock.calls[0][0];
|
||||
expect(body.redirect).not.toContain('id_token_hint=');
|
||||
expect(body.redirect).toContain('logout_hint=');
|
||||
expect(body.redirect).not.toContain('client_id=');
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('OPENID_CLIENT_ID is not set'),
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to logout_hint for cookie-sourced long token', async () => {
|
||||
const longCookieToken = 'a'.repeat(3000);
|
||||
cookies.parse.mockReturnValue({
|
||||
refreshToken: 'cookie-rt',
|
||||
openid_id_token: longCookieToken,
|
||||
});
|
||||
const req = buildReq({
|
||||
user: { _id: 'user1', openidId: 'oid1', provider: 'openid', email: 'user@example.com' },
|
||||
session: { destroy: jest.fn() },
|
||||
});
|
||||
const res = buildRes();
|
||||
|
||||
await logoutController(req, res);
|
||||
|
||||
const body = res.send.mock.calls[0][0];
|
||||
expect(body.redirect).not.toContain('id_token_hint=');
|
||||
expect(body.redirect).toContain('logout_hint=user%40example.com');
|
||||
expect(body.redirect).toContain('client_id=my-client-id');
|
||||
});
|
||||
|
||||
it('keeps id_token_hint when projected URL length equals the max', async () => {
|
||||
const baseUrl = new URL('https://idp.example.com/logout');
|
||||
baseUrl.searchParams.set('post_logout_redirect_uri', 'https://app.example.com/login');
|
||||
const baseLength = baseUrl.toString().length;
|
||||
const tokenLength = 2000 - baseLength - '&id_token_hint='.length;
|
||||
const exactToken = 'a'.repeat(tokenLength);
|
||||
|
||||
const req = buildReq({
|
||||
session: {
|
||||
openidTokens: { refreshToken: 'srt', idToken: exactToken },
|
||||
destroy: jest.fn(),
|
||||
},
|
||||
});
|
||||
const res = buildRes();
|
||||
|
||||
await logoutController(req, res);
|
||||
|
||||
const body = res.send.mock.calls[0][0];
|
||||
expect(body.redirect).toContain('id_token_hint=');
|
||||
expect(body.redirect).not.toContain('logout_hint=');
|
||||
});
|
||||
|
||||
it('falls back to logout_hint when projected URL is one char over the max', async () => {
|
||||
const baseUrl = new URL('https://idp.example.com/logout');
|
||||
baseUrl.searchParams.set('post_logout_redirect_uri', 'https://app.example.com/login');
|
||||
const baseLength = baseUrl.toString().length;
|
||||
const tokenLength = 2000 - baseLength - '&id_token_hint='.length + 1;
|
||||
const overToken = 'a'.repeat(tokenLength);
|
||||
|
||||
const req = buildReq({
|
||||
user: { _id: 'user1', openidId: 'oid1', provider: 'openid', email: 'user@example.com' },
|
||||
session: {
|
||||
openidTokens: { refreshToken: 'srt', idToken: overToken },
|
||||
destroy: jest.fn(),
|
||||
},
|
||||
});
|
||||
const res = buildRes();
|
||||
|
||||
await logoutController(req, res);
|
||||
|
||||
const body = res.send.mock.calls[0][0];
|
||||
expect(body.redirect).not.toContain('id_token_hint=');
|
||||
expect(body.redirect).toContain('logout_hint=');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalid OPENID_MAX_LOGOUT_URL_LENGTH values', () => {
|
||||
it('silently uses default when value is empty', async () => {
|
||||
process.env.OPENID_MAX_LOGOUT_URL_LENGTH = '';
|
||||
const req = buildReq();
|
||||
const res = buildRes();
|
||||
|
||||
await logoutController(req, res);
|
||||
|
||||
expect(mockLogger.warn).not.toHaveBeenCalledWith(
|
||||
expect.stringContaining('Invalid OPENID_MAX_LOGOUT_URL_LENGTH'),
|
||||
);
|
||||
const body = res.send.mock.calls[0][0];
|
||||
expect(body.redirect).toContain('id_token_hint=small-id-token');
|
||||
});
|
||||
|
||||
it('warns and uses default for partial numeric string', async () => {
|
||||
process.env.OPENID_MAX_LOGOUT_URL_LENGTH = '500abc';
|
||||
const req = buildReq();
|
||||
const res = buildRes();
|
||||
|
||||
await logoutController(req, res);
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Invalid OPENID_MAX_LOGOUT_URL_LENGTH'),
|
||||
);
|
||||
const body = res.send.mock.calls[0][0];
|
||||
expect(body.redirect).toContain('id_token_hint=small-id-token');
|
||||
});
|
||||
|
||||
it('warns and uses default for zero value', async () => {
|
||||
process.env.OPENID_MAX_LOGOUT_URL_LENGTH = '0';
|
||||
const req = buildReq();
|
||||
const res = buildRes();
|
||||
|
||||
await logoutController(req, res);
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Invalid OPENID_MAX_LOGOUT_URL_LENGTH'),
|
||||
);
|
||||
const body = res.send.mock.calls[0][0];
|
||||
expect(body.redirect).toContain('id_token_hint=small-id-token');
|
||||
});
|
||||
|
||||
it('warns and uses default for negative value', async () => {
|
||||
process.env.OPENID_MAX_LOGOUT_URL_LENGTH = '-1';
|
||||
const req = buildReq();
|
||||
const res = buildRes();
|
||||
|
||||
await logoutController(req, res);
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Invalid OPENID_MAX_LOGOUT_URL_LENGTH'),
|
||||
);
|
||||
const body = res.send.mock.calls[0][0];
|
||||
expect(body.redirect).toContain('id_token_hint=small-id-token');
|
||||
});
|
||||
|
||||
it('warns and uses default for non-numeric string', async () => {
|
||||
process.env.OPENID_MAX_LOGOUT_URL_LENGTH = 'abc';
|
||||
const req = buildReq();
|
||||
const res = buildRes();
|
||||
|
||||
await logoutController(req, res);
|
||||
|
||||
expect(mockLogger.warn).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Invalid OPENID_MAX_LOGOUT_URL_LENGTH'),
|
||||
);
|
||||
const body = res.send.mock.calls[0][0];
|
||||
expect(body.redirect).toContain('id_token_hint=small-id-token');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const {
|
||||
verifyTOTP,
|
||||
getTOTPSecret,
|
||||
verifyBackupCode,
|
||||
} = require('~/server/services/twoFactorService');
|
||||
const { setAuthTokens } = require('~/server/services/AuthService');
|
||||
const { getUserById } = require('~/models');
|
||||
|
||||
/**
|
||||
* Verifies the 2FA code during login using a temporary token.
|
||||
*/
|
||||
const verify2FAWithTempToken = async (req, res) => {
|
||||
try {
|
||||
const { tempToken, token, backupCode } = req.body;
|
||||
if (!tempToken) {
|
||||
return res.status(400).json({ message: 'Missing temporary token' });
|
||||
}
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = jwt.verify(tempToken, process.env.JWT_SECRET);
|
||||
} catch (err) {
|
||||
logger.error('Failed to verify temporary token:', err);
|
||||
return res.status(401).json({ message: 'Invalid or expired temporary token' });
|
||||
}
|
||||
|
||||
const user = await getUserById(payload.userId, '+totpSecret +backupCodes');
|
||||
if (!user || !user.twoFactorEnabled) {
|
||||
return res.status(400).json({ message: '2FA is not enabled for this user' });
|
||||
}
|
||||
|
||||
const secret = await getTOTPSecret(user.totpSecret);
|
||||
let isVerified = false;
|
||||
if (token) {
|
||||
isVerified = await verifyTOTP(secret, token);
|
||||
} else if (backupCode) {
|
||||
isVerified = await verifyBackupCode({ user, backupCode });
|
||||
}
|
||||
|
||||
if (!isVerified) {
|
||||
return res.status(401).json({ message: 'Invalid 2FA code or backup code' });
|
||||
}
|
||||
|
||||
const userData = user.toObject ? user.toObject() : { ...user };
|
||||
delete userData.__v;
|
||||
delete userData.password;
|
||||
delete userData.totpSecret;
|
||||
delete userData.backupCodes;
|
||||
userData.id = user._id.toString();
|
||||
|
||||
const authToken = await setAuthTokens(user._id, res, null, req);
|
||||
return res.status(200).json({ token: authToken, user: userData });
|
||||
} catch (err) {
|
||||
logger.error('[verify2FAWithTempToken]', err);
|
||||
return res.status(500).json({ message: 'Something went wrong' });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { verify2FAWithTempToken };
|
||||
@@ -0,0 +1,92 @@
|
||||
const { CacheKeys } = require('librechat-data-provider');
|
||||
const { logger, DEFAULT_SESSION_EXPIRY } = require('@librechat/data-schemas');
|
||||
const {
|
||||
isEnabled,
|
||||
getAdminPanelUrl,
|
||||
isAdminPanelRedirect,
|
||||
generateAdminExchangeCode,
|
||||
} = require('@librechat/api');
|
||||
const { syncUserEntraGroupMemberships } = require('~/server/services/PermissionService');
|
||||
const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const { checkBan } = require('~/server/middleware');
|
||||
const { generateToken } = require('~/models');
|
||||
|
||||
const domains = {
|
||||
client: process.env.DOMAIN_CLIENT,
|
||||
server: process.env.DOMAIN_SERVER,
|
||||
};
|
||||
|
||||
function createOAuthHandler(redirectUri = domains.client) {
|
||||
/**
|
||||
* A handler to process OAuth authentication results.
|
||||
* @type {Function}
|
||||
* @param {ServerRequest} req - Express request object.
|
||||
* @param {ServerResponse} res - Express response object.
|
||||
* @param {NextFunction} next - Express next middleware function.
|
||||
*/
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
if (res.headersSent) {
|
||||
return;
|
||||
}
|
||||
|
||||
await checkBan(req, res);
|
||||
if (req.banned) {
|
||||
return;
|
||||
}
|
||||
|
||||
/** Check if this is an admin panel redirect (cross-origin or same-origin subpath) */
|
||||
if (isAdminPanelRedirect(redirectUri, getAdminPanelUrl(), domains.client)) {
|
||||
/** For admin panel, generate exchange code instead of setting cookies */
|
||||
const cache = getLogStores(CacheKeys.ADMIN_OAUTH_EXCHANGE);
|
||||
const sessionExpiry = Number(process.env.SESSION_EXPIRY) || DEFAULT_SESSION_EXPIRY;
|
||||
const token = await generateToken(req.user, sessionExpiry);
|
||||
|
||||
/** Get refresh token from tokenset for OpenID users */
|
||||
const refreshToken =
|
||||
req.user.provider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS) === true
|
||||
? req.user.tokenset?.refresh_token || req.user.federatedTokens?.refresh_token
|
||||
: undefined;
|
||||
const expiresAt = Date.now() + sessionExpiry;
|
||||
|
||||
const callbackUrl = new URL(redirectUri);
|
||||
const exchangeCode = await generateAdminExchangeCode(
|
||||
cache,
|
||||
req.user,
|
||||
token,
|
||||
refreshToken,
|
||||
callbackUrl.origin,
|
||||
req.pkceChallenge,
|
||||
expiresAt,
|
||||
);
|
||||
callbackUrl.searchParams.set('code', exchangeCode);
|
||||
logger.info(`[OAuth] Admin panel redirect with exchange code for user: ${req.user.email}`);
|
||||
return res.redirect(callbackUrl.toString());
|
||||
}
|
||||
|
||||
/** Standard OAuth flow - set cookies and redirect */
|
||||
if (
|
||||
req.user &&
|
||||
req.user.provider == 'openid' &&
|
||||
isEnabled(process.env.OPENID_REUSE_TOKENS) === true
|
||||
) {
|
||||
await syncUserEntraGroupMemberships(req.user, req.user.tokenset.access_token);
|
||||
setOpenIDAuthTokens(req.user.tokenset, req, res, {
|
||||
userId: req.user._id.toString(),
|
||||
tenantId: req.user.tenantId,
|
||||
});
|
||||
} else {
|
||||
await setAuthTokens(req.user._id, res, null, req);
|
||||
}
|
||||
res.redirect(redirectUri);
|
||||
} catch (err) {
|
||||
logger.error('Error in setting authentication tokens:', err);
|
||||
next(err);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createOAuthHandler,
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
const mockIsEnabled = jest.fn();
|
||||
const mockGetAdminPanelUrl = jest.fn();
|
||||
const mockIsAdminPanelRedirect = jest.fn();
|
||||
const mockGenerateAdminExchangeCode = jest.fn();
|
||||
const mockSyncUserEntraGroupMemberships = jest.fn();
|
||||
const mockSetAuthTokens = jest.fn();
|
||||
const mockSetOpenIDAuthTokens = jest.fn();
|
||||
const mockGetLogStores = jest.fn();
|
||||
const mockCheckBan = jest.fn();
|
||||
const mockGenerateToken = jest.fn();
|
||||
const mockLogger = { info: jest.fn(), error: jest.fn() };
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
CacheKeys: { ADMIN_OAUTH_EXCHANGE: 'admin-oauth-exchange' },
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: mockLogger,
|
||||
DEFAULT_SESSION_EXPIRY: 60000,
|
||||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
isEnabled: (...args) => mockIsEnabled(...args),
|
||||
getAdminPanelUrl: (...args) => mockGetAdminPanelUrl(...args),
|
||||
isAdminPanelRedirect: (...args) => mockIsAdminPanelRedirect(...args),
|
||||
generateAdminExchangeCode: (...args) => mockGenerateAdminExchangeCode(...args),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/PermissionService', () => ({
|
||||
syncUserEntraGroupMemberships: (...args) => mockSyncUserEntraGroupMemberships(...args),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/AuthService', () => ({
|
||||
setAuthTokens: (...args) => mockSetAuthTokens(...args),
|
||||
setOpenIDAuthTokens: (...args) => mockSetOpenIDAuthTokens(...args),
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'~/cache/getLogStores',
|
||||
() =>
|
||||
(...args) =>
|
||||
mockGetLogStores(...args),
|
||||
);
|
||||
|
||||
jest.mock('~/server/middleware', () => ({
|
||||
checkBan: (...args) => mockCheckBan(...args),
|
||||
}));
|
||||
|
||||
jest.mock('~/models', () => ({
|
||||
generateToken: (...args) => mockGenerateToken(...args),
|
||||
}));
|
||||
|
||||
const { createOAuthHandler } = require('./oauth');
|
||||
|
||||
const ORIGINAL_ENV = process.env;
|
||||
|
||||
function buildReq(overrides = {}) {
|
||||
return {
|
||||
user: {
|
||||
_id: 'user-123',
|
||||
email: 'admin@example.com',
|
||||
provider: 'openid',
|
||||
tokenset: { refresh_token: 'openid-refresh-token', access_token: 'openid-access-token' },
|
||||
federatedTokens: { refresh_token: 'federated-refresh-token' },
|
||||
},
|
||||
pkceChallenge: 'pkce-challenge',
|
||||
banned: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRes() {
|
||||
return {
|
||||
headersSent: false,
|
||||
redirect: jest.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe('createOAuthHandler', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
process.env = {
|
||||
...ORIGINAL_ENV,
|
||||
DOMAIN_CLIENT: 'http://localhost:3080',
|
||||
DOMAIN_SERVER: 'http://localhost:3080',
|
||||
OPENID_REUSE_TOKENS: 'false',
|
||||
};
|
||||
mockIsEnabled.mockImplementation((value) => value === 'true' || value === true);
|
||||
mockGetAdminPanelUrl.mockReturnValue('http://admin.example.com');
|
||||
mockIsAdminPanelRedirect.mockReturnValue(true);
|
||||
mockGetLogStores.mockReturnValue({});
|
||||
mockCheckBan.mockResolvedValue(undefined);
|
||||
mockGenerateToken.mockResolvedValue('jwt-token');
|
||||
mockGenerateAdminExchangeCode.mockResolvedValue('exchange-code');
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
process.env = ORIGINAL_ENV;
|
||||
});
|
||||
|
||||
it('omits refresh token from admin exchange when OPENID_REUSE_TOKENS is disabled', async () => {
|
||||
const handler = createOAuthHandler('http://admin.example.com/auth/openid/callback');
|
||||
const req = buildReq();
|
||||
const res = buildRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await handler(req, res, next);
|
||||
|
||||
expect(mockGenerateAdminExchangeCode).toHaveBeenCalledWith(
|
||||
{},
|
||||
req.user,
|
||||
'jwt-token',
|
||||
undefined,
|
||||
'http://admin.example.com',
|
||||
'pkce-challenge',
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(res.redirect).toHaveBeenCalledWith(
|
||||
'http://admin.example.com/auth/openid/callback?code=exchange-code',
|
||||
);
|
||||
expect(mockSetOpenIDAuthTokens).not.toHaveBeenCalled();
|
||||
expect(mockSetAuthTokens).not.toHaveBeenCalled();
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('includes refresh token in admin exchange when OPENID_REUSE_TOKENS is enabled', async () => {
|
||||
process.env.OPENID_REUSE_TOKENS = 'true';
|
||||
const handler = createOAuthHandler('http://admin.example.com/auth/openid/callback');
|
||||
const req = buildReq();
|
||||
const res = buildRes();
|
||||
const next = jest.fn();
|
||||
|
||||
await handler(req, res, next);
|
||||
|
||||
expect(mockGenerateAdminExchangeCode).toHaveBeenCalledWith(
|
||||
{},
|
||||
req.user,
|
||||
'jwt-token',
|
||||
'openid-refresh-token',
|
||||
'http://admin.example.com',
|
||||
'pkce-challenge',
|
||||
expect.any(Number),
|
||||
);
|
||||
expect(res.redirect).toHaveBeenCalledWith(
|
||||
'http://admin.example.com/auth/openid/callback?code=exchange-code',
|
||||
);
|
||||
expect(mockSetOpenIDAuthTokens).not.toHaveBeenCalled();
|
||||
expect(mockSetAuthTokens).not.toHaveBeenCalled();
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,504 @@
|
||||
/**
|
||||
* MCP Tools Controller
|
||||
* Handles MCP-specific tool endpoints, decoupled from regular LibreChat tools
|
||||
*
|
||||
* @import { MCPServerRegistry } from '@librechat/api'
|
||||
* @import { MCPServerDocument } from 'librechat-data-provider'
|
||||
*/
|
||||
const { logger, SystemCapabilities } = require('@librechat/data-schemas');
|
||||
const {
|
||||
checkAccess,
|
||||
isUserSourced,
|
||||
MCPErrorCodes,
|
||||
redactServerSecrets,
|
||||
redactAllServerSecrets,
|
||||
isMCPDomainNotAllowedError,
|
||||
isMCPInspectionFailedError,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
Constants,
|
||||
Permissions,
|
||||
ResourceType,
|
||||
PermissionBits,
|
||||
PermissionTypes,
|
||||
MCP_USER_INPUT_FIELDS,
|
||||
MCPServerUserInputSchema,
|
||||
} = require('librechat-data-provider');
|
||||
const {
|
||||
resolveConfigServers,
|
||||
resolveMcpConfigNames,
|
||||
resolveAllMcpConfigs,
|
||||
} = require('~/server/services/MCP');
|
||||
const { cacheMCPServerTools, getMCPServerTools } = require('~/server/services/Config');
|
||||
const { getResourcePermissionsMap } = require('~/server/services/PermissionService');
|
||||
const { hasCapability } = require('~/server/middleware/roles/capabilities');
|
||||
const { getMCPManager, getMCPServersRegistry } = require('~/config');
|
||||
const db = require('~/models');
|
||||
|
||||
/**
|
||||
* Handles MCP-specific errors and sends appropriate HTTP responses.
|
||||
* @param {Error} error - The error to handle
|
||||
* @param {import('express').Response} res - Express response object
|
||||
* @returns {import('express').Response | null} Response if handled, null if not an MCP error
|
||||
*/
|
||||
function handleMCPError(error, res) {
|
||||
if (isMCPDomainNotAllowedError(error)) {
|
||||
return res.status(error.statusCode).json({
|
||||
error: error.code,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
if (isMCPInspectionFailedError(error)) {
|
||||
return res.status(error.statusCode).json({
|
||||
error: error.code,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback for legacy string-based error handling (backwards compatibility)
|
||||
if (error.message?.startsWith(MCPErrorCodes.DOMAIN_NOT_ALLOWED)) {
|
||||
return res.status(403).json({
|
||||
error: MCPErrorCodes.DOMAIN_NOT_ALLOWED,
|
||||
message: error.message.replace(/^MCP_DOMAIN_NOT_ALLOWED\s*:\s*/i, ''),
|
||||
});
|
||||
}
|
||||
|
||||
if (error.message?.startsWith(MCPErrorCodes.INSPECTION_FAILED)) {
|
||||
return res.status(400).json({
|
||||
error: MCPErrorCodes.INSPECTION_FAILED,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all MCP tools available to the user.
|
||||
*/
|
||||
const getMCPTools = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) {
|
||||
logger.warn('[getMCPTools] User ID not found in request');
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const mcpConfig = await resolveAllMcpConfigs(userId, req.user);
|
||||
const configuredServers = Object.keys(mcpConfig);
|
||||
|
||||
if (!configuredServers.length) {
|
||||
return res.status(200).json({ servers: {} });
|
||||
}
|
||||
|
||||
const mcpManager = getMCPManager();
|
||||
const mcpServers = {};
|
||||
|
||||
const serverToolsMap = new Map();
|
||||
const cacheResults = await Promise.all(
|
||||
configuredServers.map(async (serverName) => {
|
||||
try {
|
||||
return {
|
||||
serverName,
|
||||
tools: await getMCPServerTools(userId, serverName, mcpConfig[serverName]),
|
||||
};
|
||||
} catch (error) {
|
||||
logger.error(`[getMCPTools] Error fetching cached tools for ${serverName}:`, error);
|
||||
return { serverName, tools: null };
|
||||
}
|
||||
}),
|
||||
);
|
||||
for (const { serverName, tools } of cacheResults) {
|
||||
if (tools) {
|
||||
serverToolsMap.set(serverName, tools);
|
||||
continue;
|
||||
}
|
||||
|
||||
let serverTools;
|
||||
try {
|
||||
serverTools = await mcpManager.getServerToolFunctions(userId, serverName);
|
||||
} catch (error) {
|
||||
logger.error(`[getMCPTools] Error fetching tools for server ${serverName}:`, error);
|
||||
continue;
|
||||
}
|
||||
if (!serverTools) {
|
||||
logger.debug(`[getMCPTools] No tools found for server ${serverName}`);
|
||||
continue;
|
||||
}
|
||||
serverToolsMap.set(serverName, serverTools);
|
||||
|
||||
if (Object.keys(serverTools).length > 0) {
|
||||
// Cache asynchronously without blocking
|
||||
cacheMCPServerTools({
|
||||
userId,
|
||||
serverName,
|
||||
serverTools,
|
||||
serverConfig: mcpConfig[serverName],
|
||||
}).catch((err) =>
|
||||
logger.error(`[getMCPTools] Failed to cache tools for ${serverName}:`, err),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Process each configured server
|
||||
for (const serverName of configuredServers) {
|
||||
try {
|
||||
const serverTools = serverToolsMap.get(serverName);
|
||||
|
||||
const serverConfig = mcpConfig[serverName];
|
||||
|
||||
const server = {
|
||||
name: serverName,
|
||||
icon: serverConfig?.iconPath || '',
|
||||
authenticated: true,
|
||||
authConfig: [],
|
||||
tools: [],
|
||||
};
|
||||
|
||||
// Set authentication config once for the server
|
||||
if (serverConfig?.customUserVars) {
|
||||
const customVarKeys = Object.keys(serverConfig.customUserVars);
|
||||
if (customVarKeys.length > 0) {
|
||||
server.authConfig = Object.entries(serverConfig.customUserVars).map(([key, value]) => ({
|
||||
authField: key,
|
||||
label: value.title || key,
|
||||
description: value.description || '',
|
||||
sensitive: value.sensitive,
|
||||
}));
|
||||
server.authenticated = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Process tools efficiently - no need for convertMCPToolToPlugin
|
||||
if (serverTools) {
|
||||
for (const [toolKey, toolData] of Object.entries(serverTools)) {
|
||||
if (!toolData.function || !toolKey.includes(Constants.mcp_delimiter)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const toolName = toolKey.split(Constants.mcp_delimiter)[0];
|
||||
server.tools.push({
|
||||
name: toolName,
|
||||
pluginKey: toolKey,
|
||||
description: toolData.function.description || '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Only add server if it has tools or is configured
|
||||
if (server.tools.length > 0 || serverConfig) {
|
||||
mcpServers[serverName] = server;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`[getMCPTools] Error loading tools for server ${serverName}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
res.status(200).json({ servers: mcpServers });
|
||||
} catch (error) {
|
||||
logger.error('[getMCPTools]', error);
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Mirrors canAccessResource's capability bypass plus per-resource ACL EDIT check.
|
||||
* `skipCapabilityWithoutDbIds` lets the list path skip the MANAGE_MCP_SERVERS probe
|
||||
* when no DB-backed server is present; no list consumer reads the edit-gated fields
|
||||
* the bypass would disclose. The detail route must not set it.
|
||||
*/
|
||||
async function computeCanEditByServer(req, serverConfigs, { skipCapabilityWithoutDbIds } = {}) {
|
||||
const canEditByServer = new Map();
|
||||
const dbIdsToCheck = [];
|
||||
const dbIdToServerName = new Map();
|
||||
for (const [name, config] of Object.entries(serverConfigs)) {
|
||||
if (config.dbId) {
|
||||
dbIdsToCheck.push(config.dbId);
|
||||
dbIdToServerName.set(String(config.dbId), name);
|
||||
continue;
|
||||
}
|
||||
canEditByServer.set(name, isUserSourced(config));
|
||||
}
|
||||
if (skipCapabilityWithoutDbIds === true && dbIdsToCheck.length === 0) {
|
||||
return canEditByServer;
|
||||
}
|
||||
let bypass = false;
|
||||
try {
|
||||
bypass = await hasCapability(req.user, SystemCapabilities.MANAGE_MCP_SERVERS);
|
||||
} catch (err) {
|
||||
logger.warn(`[computeCanEditByServer] Capability bypass check failed: ${err.message}`);
|
||||
}
|
||||
if (bypass) {
|
||||
for (const name of Object.keys(serverConfigs)) {
|
||||
canEditByServer.set(name, true);
|
||||
}
|
||||
return canEditByServer;
|
||||
}
|
||||
if (dbIdsToCheck.length > 0) {
|
||||
try {
|
||||
const permsMap = await getResourcePermissionsMap({
|
||||
userId: req.user.id,
|
||||
role: req.user.role,
|
||||
resourceType: ResourceType.MCPSERVER,
|
||||
resourceIds: dbIdsToCheck,
|
||||
});
|
||||
for (const [dbIdStr, name] of dbIdToServerName) {
|
||||
const bits = permsMap.get(dbIdStr) ?? 0;
|
||||
canEditByServer.set(name, (bits & PermissionBits.EDIT) !== 0);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`[computeCanEditByServer] ACL lookup failed, defaulting to no edit: ${err.message}`,
|
||||
);
|
||||
for (const name of dbIdToServerName.values()) {
|
||||
canEditByServer.set(name, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
return canEditByServer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all MCP servers with permissions
|
||||
* @route GET /api/mcp/servers
|
||||
*/
|
||||
const getMCPServersList = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
if (!userId) {
|
||||
return res.status(401).json({ message: 'Unauthorized' });
|
||||
}
|
||||
|
||||
const serverConfigs = await resolveAllMcpConfigs(userId, req.user);
|
||||
const canEditByServer = await computeCanEditByServer(req, serverConfigs, {
|
||||
skipCapabilityWithoutDbIds: true,
|
||||
});
|
||||
return res.json(redactAllServerSecrets(serverConfigs, { canEditByServer }));
|
||||
} catch (error) {
|
||||
logger.error('[getMCPServersList]', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns true when the request body's parsed config configures OBO. We block
|
||||
* non-permission holders from creating or updating any DB-stored MCP server
|
||||
* that mints per-user delegated tokens.
|
||||
*/
|
||||
function configHasObo(parsedConfig) {
|
||||
return (
|
||||
!!parsedConfig &&
|
||||
typeof parsedConfig === 'object' &&
|
||||
'obo' in parsedConfig &&
|
||||
parsedConfig.obo != null
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fields a user without `CONFIGURE_OBO` may modify on an OBO server (allowlist).
|
||||
* Any field not on this list is locked: changes to it (add, modify, or remove)
|
||||
* require the permission. Allowlisting is fail-closed — when upstream introduces
|
||||
* a new MCP server config field, it lands in the locked set by default until
|
||||
* explicitly opted in here. Anything that could redirect the OBO token flow
|
||||
* (`url`, `proxy`, `headers`), change scopes (`obo`), or reroute auth (`oauth`,
|
||||
* `apiKey`, `customUserVars`) MUST stay locked.
|
||||
*/
|
||||
const OBO_USER_EDITABLE_FIELDS = new Set(['title', 'description', 'iconPath']);
|
||||
|
||||
/**
|
||||
* Returns true when any non-allowlisted user-input field differs between the
|
||||
* existing server config and the new payload. Treats add, remove, and modify
|
||||
* as changes (stable JSON compare, with absence on either side counting as a
|
||||
* change unless both sides are absent). The comparison surface is
|
||||
* `MCP_USER_INPUT_FIELDS` (schema-derived from `MCPServerUserInputSchema`),
|
||||
* so new fields on the schema are picked up automatically and stay locked
|
||||
* by default until added to the allowlist above.
|
||||
*/
|
||||
function violatesOboLockdown(existingConfig, newConfig) {
|
||||
for (const field of MCP_USER_INPUT_FIELDS) {
|
||||
if (OBO_USER_EDITABLE_FIELDS.has(field)) continue;
|
||||
const existing = existingConfig?.[field];
|
||||
const next = newConfig?.[field];
|
||||
if (existing === undefined && next === undefined) continue;
|
||||
if (JSON.stringify(existing) !== JSON.stringify(next)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function callerCanConfigureObo(req) {
|
||||
return checkAccess({
|
||||
req,
|
||||
user: req.user,
|
||||
permissionType: PermissionTypes.MCP_SERVERS,
|
||||
permissions: [Permissions.CONFIGURE_OBO],
|
||||
getRoleByName: db.getRoleByName,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create MCP server
|
||||
* @route POST /api/mcp/servers
|
||||
*/
|
||||
const createMCPServerController = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
const { config } = req.body;
|
||||
|
||||
const validation = MCPServerUserInputSchema.safeParse(config);
|
||||
if (!validation.success) {
|
||||
return res.status(400).json({
|
||||
message: 'Invalid configuration',
|
||||
errors: validation.error.errors,
|
||||
});
|
||||
}
|
||||
if (configHasObo(validation.data) && !(await callerCanConfigureObo(req))) {
|
||||
logger.warn(
|
||||
`[createMCPServer] User ${userId} attempted to configure OBO without ${Permissions.CONFIGURE_OBO} permission`,
|
||||
);
|
||||
return res
|
||||
.status(403)
|
||||
.json({ message: 'Forbidden: Insufficient permissions to configure OBO' });
|
||||
}
|
||||
const reservedServerNames = await resolveMcpConfigNames(req);
|
||||
const result = await getMCPServersRegistry().addServer(
|
||||
'temp_server_name',
|
||||
validation.data,
|
||||
'DB',
|
||||
userId,
|
||||
reservedServerNames,
|
||||
);
|
||||
res.status(201).json({
|
||||
serverName: result.serverName,
|
||||
...redactServerSecrets(result.config, { canEdit: true }),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[createMCPServer]', error);
|
||||
const mcpErrorResponse = handleMCPError(error, res);
|
||||
if (mcpErrorResponse) {
|
||||
return mcpErrorResponse;
|
||||
}
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get MCP server by ID
|
||||
*/
|
||||
const getMCPServerById = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
const { serverName } = req.params;
|
||||
if (!serverName) {
|
||||
return res.status(400).json({ message: 'Server name is required' });
|
||||
}
|
||||
const configServers = await resolveConfigServers(req);
|
||||
const parsedConfig = await getMCPServersRegistry().getServerConfig(
|
||||
serverName,
|
||||
userId,
|
||||
configServers,
|
||||
);
|
||||
|
||||
if (!parsedConfig) {
|
||||
return res.status(404).json({ message: 'MCP server not found' });
|
||||
}
|
||||
|
||||
const canEditMap = await computeCanEditByServer(req, { [serverName]: parsedConfig });
|
||||
const canEdit = canEditMap.get(serverName) ?? false;
|
||||
res.status(200).json(redactServerSecrets(parsedConfig, { canEdit }));
|
||||
} catch (error) {
|
||||
logger.error('[getMCPServerById]', error);
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update MCP server
|
||||
* @route PATCH /api/mcp/servers/:serverName
|
||||
*/
|
||||
const updateMCPServerController = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
const { serverName } = req.params;
|
||||
const { config } = req.body;
|
||||
|
||||
const validation = MCPServerUserInputSchema.safeParse(config);
|
||||
if (!validation.success) {
|
||||
return res.status(400).json({
|
||||
message: 'Invalid configuration',
|
||||
errors: validation.error.errors,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* On an existing OBO server, lock down every user-input field except the
|
||||
* cosmetic allowlist (title, description, iconPath) for callers without
|
||||
* CONFIGURE_OBO. This closes the OBO redirect vector — without it, a user
|
||||
* with UPDATE could change `url` (or `proxy`/`headers`/`customUserVars`)
|
||||
* to point OBO-minted tokens at an attacker-controlled endpoint. Adds,
|
||||
* modifies, and removes are all caught.
|
||||
*/
|
||||
const existingConfig = await getMCPServersRegistry().getServerConfig(serverName, userId);
|
||||
if (configHasObo(existingConfig) && !(await callerCanConfigureObo(req))) {
|
||||
if (violatesOboLockdown(existingConfig, validation.data)) {
|
||||
logger.warn(
|
||||
`[updateMCPServer] User ${userId} attempted to modify a locked field on OBO server '${serverName}' without ${Permissions.CONFIGURE_OBO} permission`,
|
||||
);
|
||||
return res
|
||||
.status(403)
|
||||
.json({ message: 'Forbidden: Insufficient permissions to configure OBO' });
|
||||
}
|
||||
} else if (configHasObo(validation.data) && !(await callerCanConfigureObo(req))) {
|
||||
// Adding OBO to a non-OBO server (or first-time configuration) still
|
||||
// requires the permission, even if existing has no OBO.
|
||||
logger.warn(
|
||||
`[updateMCPServer] User ${userId} attempted to add OBO to '${serverName}' without ${Permissions.CONFIGURE_OBO} permission`,
|
||||
);
|
||||
return res
|
||||
.status(403)
|
||||
.json({ message: 'Forbidden: Insufficient permissions to configure OBO' });
|
||||
}
|
||||
|
||||
const parsedConfig = await getMCPServersRegistry().updateServer(
|
||||
serverName,
|
||||
validation.data,
|
||||
'DB',
|
||||
userId,
|
||||
);
|
||||
|
||||
res.status(200).json(redactServerSecrets(parsedConfig, { canEdit: true }));
|
||||
} catch (error) {
|
||||
logger.error('[updateMCPServer]', error);
|
||||
const mcpErrorResponse = handleMCPError(error, res);
|
||||
if (mcpErrorResponse) {
|
||||
return mcpErrorResponse;
|
||||
}
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete MCP server
|
||||
* @route DELETE /api/mcp/servers/:serverName
|
||||
*/
|
||||
const deleteMCPServerController = async (req, res) => {
|
||||
try {
|
||||
const userId = req.user?.id;
|
||||
const { serverName } = req.params;
|
||||
await getMCPServersRegistry().removeServer(serverName, 'DB', userId);
|
||||
res.status(200).json({ message: 'MCP server deleted successfully' });
|
||||
} catch (error) {
|
||||
logger.error('[deleteMCPServer]', error);
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getMCPTools,
|
||||
getMCPServersList,
|
||||
createMCPServerController,
|
||||
getMCPServerById,
|
||||
updateMCPServerController,
|
||||
deleteMCPServerController,
|
||||
};
|
||||
@@ -0,0 +1,259 @@
|
||||
const { nanoid } = require('nanoid');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { checkAccess, loadWebSearchAuth } = require('@librechat/api');
|
||||
const {
|
||||
Tools,
|
||||
AuthType,
|
||||
Permissions,
|
||||
ToolCallTypes,
|
||||
PermissionTypes,
|
||||
} = require('librechat-data-provider');
|
||||
const { getRoleByName, createToolCall, getToolCallsByConvo, getMessage } = require('~/models');
|
||||
const { processFileURL, uploadImageBuffer } = require('~/server/services/Files/process');
|
||||
const { getRetentionExpiry } = require('~/server/services/Files/retention');
|
||||
const { processCodeOutput, runPreviewFinalize } = require('~/server/services/Files/Code/process');
|
||||
const { loadAuthValues } = require('~/server/services/Tools/credentials');
|
||||
const { loadTools } = require('~/app/clients/tools/util');
|
||||
|
||||
/**
|
||||
* Tools that are callable directly via `POST /tools/:toolId/call`.
|
||||
* `execute_code` is the only entry today; the tool runs server-side via
|
||||
* the agents library / sandbox service without any per-user credential.
|
||||
*/
|
||||
const directCallableTools = new Set([Tools.execute_code]);
|
||||
|
||||
const toolAccessPermType = {
|
||||
[Tools.execute_code]: PermissionTypes.RUN_CODE,
|
||||
};
|
||||
|
||||
/**
|
||||
* Verifies web search authentication, ensuring each category has at least
|
||||
* one fully authenticated service.
|
||||
*
|
||||
* @param {ServerRequest} req - The request object
|
||||
* @param {ServerResponse} res - The response object
|
||||
* @returns {Promise<void>} A promise that resolves when the function has completed
|
||||
*/
|
||||
const verifyWebSearchAuth = async (req, res) => {
|
||||
try {
|
||||
const appConfig = req.config;
|
||||
const userId = req.user.id;
|
||||
/** @type {TCustomConfig['webSearch']} */
|
||||
const webSearchConfig = appConfig?.webSearch || {};
|
||||
const result = await loadWebSearchAuth({
|
||||
userId,
|
||||
loadAuthValues,
|
||||
webSearchConfig,
|
||||
throwError: false,
|
||||
});
|
||||
|
||||
return res.status(200).json({
|
||||
authenticated: result.authenticated,
|
||||
authTypes: result.authTypes,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in verifyWebSearchAuth:', error);
|
||||
return res.status(500).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {ServerRequest} req - The request object, containing information about the HTTP request.
|
||||
* @param {ServerResponse} res - The response object, used to send back the desired HTTP response.
|
||||
* @returns {Promise<void>} A promise that resolves when the function has completed.
|
||||
*/
|
||||
const verifyToolAuth = async (req, res) => {
|
||||
try {
|
||||
const { toolId } = req.params;
|
||||
if (toolId === Tools.web_search) {
|
||||
return await verifyWebSearchAuth(req, res);
|
||||
}
|
||||
if (!directCallableTools.has(toolId)) {
|
||||
res.status(404).json({ message: 'Tool not found' });
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* `execute_code` no longer requires a per-user credential — sandbox
|
||||
* auth is handled server-side by the agents library. Always report
|
||||
* system-authenticated so the client proceeds straight to the call
|
||||
* without a key-entry dialog.
|
||||
*
|
||||
* Deployment contract: reachability of the sandbox service is the
|
||||
* admin's responsibility. This endpoint does not probe the service
|
||||
* (a per-auth-check network hop would be too expensive for what is
|
||||
* a UI-gate query). If the sandbox is unreachable, the call path
|
||||
* surfaces the error at execution time instead of here.
|
||||
*/
|
||||
res.status(200).json({ authenticated: true, message: AuthType.SYSTEM_DEFINED });
|
||||
} catch (error) {
|
||||
res.status(500).json({ message: error.message });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {ServerRequest} req - The request object, containing information about the HTTP request.
|
||||
* @param {ServerResponse} res - The response object, used to send back the desired HTTP response.
|
||||
* @param {NextFunction} next - The next middleware function to call.
|
||||
* @returns {Promise<void>} A promise that resolves when the function has completed.
|
||||
*/
|
||||
const callTool = async (req, res) => {
|
||||
try {
|
||||
const appConfig = req.config;
|
||||
const { toolId = '' } = req.params;
|
||||
if (!directCallableTools.has(toolId)) {
|
||||
logger.warn(`[${toolId}/call] User ${req.user.id} attempted call to invalid tool`);
|
||||
res.status(404).json({ message: 'Tool not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { partIndex, blockIndex, messageId, conversationId, ...args } = req.body;
|
||||
if (!messageId) {
|
||||
logger.warn(`[${toolId}/call] User ${req.user.id} attempted call without message ID`);
|
||||
res.status(400).json({ message: 'Message ID required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const message = await getMessage({ user: req.user.id, messageId });
|
||||
if (!message) {
|
||||
logger.debug(`[${toolId}/call] User ${req.user.id} attempted call with invalid message ID`);
|
||||
res.status(404).json({ message: 'Message not found' });
|
||||
return;
|
||||
}
|
||||
logger.debug(`[${toolId}/call] User: ${req.user.id}`);
|
||||
let hasAccess = true;
|
||||
if (toolAccessPermType[toolId]) {
|
||||
hasAccess = await checkAccess({
|
||||
user: req.user,
|
||||
permissionType: toolAccessPermType[toolId],
|
||||
permissions: [Permissions.USE],
|
||||
getRoleByName,
|
||||
});
|
||||
}
|
||||
if (!hasAccess) {
|
||||
logger.warn(
|
||||
`[${toolAccessPermType[toolId]}] Forbidden: Insufficient permissions for User ${req.user.id}: ${Permissions.USE}`,
|
||||
);
|
||||
return res.status(403).json({ message: 'Forbidden: Insufficient permissions' });
|
||||
}
|
||||
const { loadedTools } = await loadTools({
|
||||
user: req.user.id,
|
||||
tools: [toolId],
|
||||
functions: true,
|
||||
options: {
|
||||
req,
|
||||
returnMetadata: true,
|
||||
processFileURL,
|
||||
uploadImageBuffer,
|
||||
},
|
||||
webSearch: appConfig.webSearch,
|
||||
fileStrategy: appConfig.fileStrategy,
|
||||
imageOutputType: appConfig.imageOutputType,
|
||||
});
|
||||
|
||||
const tool = loadedTools[0];
|
||||
const toolCallId = `${req.user.id}_${nanoid()}`;
|
||||
const result = await tool.invoke({
|
||||
args,
|
||||
name: toolId,
|
||||
id: toolCallId,
|
||||
type: ToolCallTypes.TOOL_CALL,
|
||||
});
|
||||
|
||||
const { content, artifact } = result;
|
||||
const toolCallData = {
|
||||
toolId,
|
||||
messageId,
|
||||
partIndex,
|
||||
blockIndex,
|
||||
conversationId,
|
||||
result: content,
|
||||
user: req.user.id,
|
||||
...(await getRetentionExpiry(req)),
|
||||
};
|
||||
|
||||
if (!artifact || !artifact.files || toolId !== Tools.execute_code) {
|
||||
createToolCall(toolCallData).catch((error) => {
|
||||
logger.error(`Error creating tool call: ${error.message}`);
|
||||
});
|
||||
return res.status(200).json({
|
||||
result: content,
|
||||
});
|
||||
}
|
||||
|
||||
const artifactPromises = [];
|
||||
for (const file of artifact.files) {
|
||||
/* Files flagged `inherited` by codeapi are unchanged passthroughs of
|
||||
* inputs the caller already owns (skill files, prior downloaded inputs,
|
||||
* inherited .dirkeep markers). Re-downloading them is wasted work and
|
||||
* 403s when the file is scoped to a different entity (e.g. skill
|
||||
* entity_id) than the user's session key. They remain available for
|
||||
* subsequent tool calls via primeInvokedSkills / session inheritance. */
|
||||
if (file.inherited) {
|
||||
continue;
|
||||
}
|
||||
const { id, name } = file;
|
||||
artifactPromises.push(
|
||||
(async () => {
|
||||
const result = await processCodeOutput({
|
||||
req,
|
||||
id,
|
||||
name,
|
||||
messageId,
|
||||
toolCallId,
|
||||
conversationId,
|
||||
session_id: artifact.session_id,
|
||||
});
|
||||
const fileMetadata = result?.file ?? null;
|
||||
const finalize = result?.finalize;
|
||||
if (!fileMetadata) {
|
||||
return null;
|
||||
}
|
||||
/* This endpoint is non-streaming and its contract is "give
|
||||
* me the artifacts" — return the persisted record immediately
|
||||
* (with `status: 'pending'` for office buckets) and run the
|
||||
* preview render in the background. The client polls
|
||||
* `/api/files/:file_id/preview` for the resolved record.
|
||||
* No `onResolved` — there's no live stream to write to here. */
|
||||
runPreviewFinalize({
|
||||
finalize,
|
||||
fileId: fileMetadata.file_id,
|
||||
previewRevision: result?.previewRevision,
|
||||
});
|
||||
return fileMetadata;
|
||||
})().catch((error) => {
|
||||
logger.error('Error processing code output:', error);
|
||||
return null;
|
||||
}),
|
||||
);
|
||||
}
|
||||
const attachments = await Promise.all(artifactPromises);
|
||||
toolCallData.attachments = attachments;
|
||||
createToolCall(toolCallData).catch((error) => {
|
||||
logger.error(`Error creating tool call: ${error.message}`);
|
||||
});
|
||||
res.status(200).json({
|
||||
result: content,
|
||||
attachments,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error calling tool', error);
|
||||
res.status(500).json({ message: 'Error calling tool' });
|
||||
}
|
||||
};
|
||||
|
||||
const getToolCalls = async (req, res) => {
|
||||
try {
|
||||
const { conversationId } = req.query;
|
||||
const toolCalls = await getToolCallsByConvo(conversationId, req.user.id);
|
||||
res.status(200).json(toolCalls);
|
||||
} catch (error) {
|
||||
logger.error('Error getting tool calls', error);
|
||||
res.status(500).json({ message: 'Error getting tool calls' });
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
callTool,
|
||||
getToolCalls,
|
||||
verifyToolAuth,
|
||||
};
|
||||
Reference in New Issue
Block a user