mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-10 08:31:37 +00:00
Merge branch 'main' into fix/feature/obo
This commit is contained in:
commit
b49e7b4ae3
55 changed files with 1633 additions and 117 deletions
|
|
@ -18,7 +18,11 @@ async function contextProjectionController(req, res) {
|
|||
return;
|
||||
}
|
||||
const projection = await resolveContextProjection(
|
||||
{ userId: req.user?.id, getMessages: db.getMessages },
|
||||
{
|
||||
userId: req.user?.id,
|
||||
getMessages: db.getMessages,
|
||||
getMessageTextStats: db.getMessageTextStats,
|
||||
},
|
||||
params,
|
||||
);
|
||||
res.json(projection ?? null);
|
||||
|
|
|
|||
|
|
@ -1450,11 +1450,12 @@ class AgentClient extends BaseClient {
|
|||
});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
'[api/server/controllers/agents/client.js #sendCompletion] Operation aborted',
|
||||
err,
|
||||
);
|
||||
if (!abortController.signal.aborted) {
|
||||
if (abortController.signal.aborted) {
|
||||
logger.debug(
|
||||
'[api/server/controllers/agents/client.js #sendCompletion] Operation aborted by user',
|
||||
{ conversationId: this.conversationId, name: err?.name, code: err?.code },
|
||||
);
|
||||
} else {
|
||||
logger.error(
|
||||
'[api/server/controllers/agents/client.js #sendCompletion] Unhandled error type',
|
||||
err,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,36 @@ const { sendError } = require('~/server/middleware/error');
|
|||
const { abortRun } = require('./abortRun');
|
||||
const db = require('~/models');
|
||||
|
||||
/**
|
||||
* @param {Error | unknown} error
|
||||
* @returns {boolean}
|
||||
*/
|
||||
const isAbortError = (error) => {
|
||||
const visited = new Set();
|
||||
let current = error;
|
||||
|
||||
while (current && typeof current === 'object' && !visited.has(current)) {
|
||||
visited.add(current);
|
||||
|
||||
const errorName = current.name;
|
||||
const errorCode = current.code;
|
||||
const errorMessage = typeof current.message === 'string' ? current.message : '';
|
||||
|
||||
if (
|
||||
errorName === 'AbortError' ||
|
||||
errorCode === 'ABORT_ERR' ||
|
||||
errorMessage.includes('AbortError') ||
|
||||
/(?:operation|request|stream) was aborted/i.test(errorMessage)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
current = current.cause;
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Spend tokens for all models from collected usage.
|
||||
* This handles both sequential and parallel agent execution.
|
||||
|
|
@ -200,18 +230,26 @@ const handleAbort = function () {
|
|||
* @returns {Promise<void>}
|
||||
*/
|
||||
const handleAbortError = async (res, req, error, data) => {
|
||||
const { sender, conversationId, messageId, parentMessageId, userMessageId, partialText } = data;
|
||||
|
||||
if (error?.message?.includes('base64')) {
|
||||
logger.error('[handleAbortError] Error in base64 encoding', {
|
||||
...error,
|
||||
stack: smartTruncateText(error?.stack, 1000),
|
||||
message: truncateText(error.message, 350),
|
||||
});
|
||||
} else if (isAbortError(error)) {
|
||||
logger.debug('[handleAbortError] AI response aborted by user', {
|
||||
conversationId,
|
||||
code: error?.code,
|
||||
name: error?.name,
|
||||
message: truncateText(error?.message ?? 'AbortError', 350),
|
||||
});
|
||||
} else {
|
||||
logger.error('[handleAbortError] AI response error; aborting request:', error);
|
||||
}
|
||||
const { sender, conversationId, messageId, parentMessageId, userMessageId, partialText } = data;
|
||||
|
||||
if (error.stack && error.stack.includes('google')) {
|
||||
if (error?.stack && error.stack.includes('google')) {
|
||||
logger.warn(
|
||||
`AI Response error for conversation ${conversationId} likely caused by Google censor/filter`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -73,7 +73,18 @@ jest.mock('./abortRun', () => ({
|
|||
abortRun: jest.fn(),
|
||||
}));
|
||||
|
||||
const { spendCollectedUsage } = require('./abortMiddleware');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { sendError } = require('~/server/middleware/error');
|
||||
const { handleAbortError, spendCollectedUsage } = require('./abortMiddleware');
|
||||
|
||||
const buildAbortRequest = () => ({
|
||||
body: {
|
||||
model: 'gpt-4',
|
||||
},
|
||||
user: {
|
||||
id: 'user-123',
|
||||
},
|
||||
});
|
||||
|
||||
describe('abortMiddleware - spendCollectedUsage', () => {
|
||||
beforeEach(() => {
|
||||
|
|
@ -237,3 +248,65 @@ describe('abortMiddleware - spendCollectedUsage', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('abortMiddleware - handleAbortError', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
'native DOMException AbortError',
|
||||
new DOMException('The operation was aborted', 'AbortError'),
|
||||
'AbortError',
|
||||
],
|
||||
[
|
||||
'wrapped AbortError message',
|
||||
new Error('SSE stream disconnected: AbortError: The operation was aborted'),
|
||||
'Error',
|
||||
],
|
||||
[
|
||||
'cause-nested AbortError',
|
||||
new Error('Request failed', {
|
||||
cause: new DOMException('The operation was aborted', 'AbortError'),
|
||||
}),
|
||||
'Error',
|
||||
],
|
||||
])('logs a %s as a debug event instead of an error', async (_label, error, name) => {
|
||||
await handleAbortError({}, buildAbortRequest(), error, {
|
||||
sender: 'AI',
|
||||
conversationId: 'convo-123',
|
||||
messageId: 'message-123',
|
||||
parentMessageId: 'parent-123',
|
||||
userMessageId: 'user-message-123',
|
||||
});
|
||||
|
||||
expect(logger.error).not.toHaveBeenCalled();
|
||||
expect(logger.debug).toHaveBeenCalledWith('[handleAbortError] AI response aborted by user', {
|
||||
conversationId: 'convo-123',
|
||||
code: error.code,
|
||||
name,
|
||||
message: error.message,
|
||||
});
|
||||
expect(sendError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('keeps unexpected generation errors classified as errors', async () => {
|
||||
const error = new Error('Provider failed');
|
||||
|
||||
await handleAbortError({}, buildAbortRequest(), error, {
|
||||
sender: 'AI',
|
||||
conversationId: 'convo-123',
|
||||
messageId: 'message-123',
|
||||
parentMessageId: 'parent-123',
|
||||
userMessageId: 'user-message-123',
|
||||
});
|
||||
|
||||
expect(logger.error).toHaveBeenCalledWith(
|
||||
'[handleAbortError] AI response error; aborting request:',
|
||||
error,
|
||||
);
|
||||
expect(logger.debug).not.toHaveBeenCalled();
|
||||
expect(sendError).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
19
api/server/middleware/limiters/contextProjectionLimiter.js
Normal file
19
api/server/middleware/limiters/contextProjectionLimiter.js
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
const rateLimit = require('express-rate-limit');
|
||||
const { limiterCache } = require('@librechat/api');
|
||||
|
||||
const { CONTEXT_PROJECTION_WINDOW = 1, CONTEXT_PROJECTION_MAX = 20 } = process.env;
|
||||
|
||||
const windowMs = (parseInt(CONTEXT_PROJECTION_WINDOW, 10) || 1) * 60 * 1000;
|
||||
const max = parseInt(CONTEXT_PROJECTION_MAX, 10) || 20;
|
||||
|
||||
const contextProjectionLimiter = rateLimit({
|
||||
windowMs,
|
||||
max,
|
||||
handler: (_req, res) => {
|
||||
res.status(429).json({ message: 'Too many context projection requests. Try again later' });
|
||||
},
|
||||
keyGenerator: (req) => req.user?.id,
|
||||
store: limiterCache('context_projection_limiter'),
|
||||
});
|
||||
|
||||
module.exports = contextProjectionLimiter;
|
||||
|
|
@ -9,6 +9,7 @@ const registerLimiter = require('./registerLimiter');
|
|||
const toolCallLimiter = require('./toolCallLimiter');
|
||||
const messageLimiters = require('./messageLimiters');
|
||||
const promptUsageLimiter = require('./promptUsageLimiter');
|
||||
const contextProjectionLimiter = require('./contextProjectionLimiter');
|
||||
const verifyEmailLimiter = require('./verifyEmailLimiter');
|
||||
const resetPasswordLimiter = require('./resetPasswordLimiter');
|
||||
const twoFactorTempLimiter = require('./twoFactorTempLimiter');
|
||||
|
|
@ -22,6 +23,7 @@ module.exports = {
|
|||
loginLimiter,
|
||||
registerLimiter,
|
||||
toolCallLimiter,
|
||||
contextProjectionLimiter,
|
||||
createTTSLimiters,
|
||||
createSTTLimiters,
|
||||
verifyEmailLimiter,
|
||||
|
|
|
|||
|
|
@ -3,9 +3,9 @@ const jwt = require('jsonwebtoken');
|
|||
const { isEnabled } = require('@librechat/api');
|
||||
const { logger, runAsSystem } = require('@librechat/data-schemas');
|
||||
const { SystemRoles } = require('librechat-data-provider');
|
||||
const { getUserById } = require('~/models');
|
||||
const { getUserById, findSession } = require('~/models');
|
||||
|
||||
const verifyRefreshToken = (token) => {
|
||||
const verifySignedUserId = (token) => {
|
||||
try {
|
||||
const payload = jwt.verify(token, process.env.JWT_REFRESH_SECRET);
|
||||
return typeof payload?.id === 'string' ? payload.id : null;
|
||||
|
|
@ -14,13 +14,36 @@ const verifyRefreshToken = (token) => {
|
|||
}
|
||||
};
|
||||
|
||||
const getRefreshTokenUserId = async (token) => {
|
||||
const userId = verifySignedUserId(token);
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const session = await runAsSystem(() => findSession({ userId, refreshToken: token }));
|
||||
return session ? userId : null;
|
||||
};
|
||||
|
||||
const getOpenIdUserId = (parsed, req) => {
|
||||
if (parsed.token_provider !== 'openid' || !isEnabled(process.env.OPENID_REUSE_TOKENS)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sessionRefreshToken = req.session?.openidTokens?.refreshToken;
|
||||
if (!parsed.refreshToken || parsed.refreshToken !== sessionRefreshToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return verifySignedUserId(parsed.openid_user_id);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fallback auth for share file routes that are hit by `<img>`/anchor requests,
|
||||
* which can't carry the bearer access token. Resolves the viewer from the
|
||||
* `refreshToken` cookie (or the signed `openid_user_id` cookie) — the same
|
||||
* mechanism secure image links use — so non-public shared links can authorize
|
||||
* the viewer's ACL. Never blocks: on any failure it leaves `req.user` unset and
|
||||
* lets `canAccessSharedLink` decide (public access, 401, or 403).
|
||||
* `refreshToken` cookie (or an active OpenID session plus signed `openid_user_id`
|
||||
* cookie) so non-public shared links can authorize the viewer's ACL. Never
|
||||
* blocks: on any failure it leaves `req.user` unset and lets
|
||||
* `canAccessSharedLink` decide (public access, 401, or 403).
|
||||
*/
|
||||
const optionalShareFileAuth = async (req, res, next) => {
|
||||
if (req.user) {
|
||||
|
|
@ -34,22 +57,17 @@ const optionalShareFileAuth = async (req, res, next) => {
|
|||
}
|
||||
|
||||
const parsed = cookie.parse(cookieHeader);
|
||||
const useOpenId =
|
||||
parsed.token_provider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS);
|
||||
const token = useOpenId ? parsed.openid_user_id : parsed.refreshToken;
|
||||
if (!token) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const userId = verifyRefreshToken(token);
|
||||
const userId =
|
||||
getOpenIdUserId(parsed, req) ||
|
||||
(parsed.refreshToken ? await getRefreshTokenUserId(parsed.refreshToken) : null);
|
||||
if (!userId) {
|
||||
return next();
|
||||
}
|
||||
|
||||
// Resolve in system context: this runs before canAccessSharedLink establishes
|
||||
// the share tenant, so under strict tenant isolation a tenant-scoped User
|
||||
// query would otherwise throw. The viewer's id comes from their own verified
|
||||
// refresh token; the share's tenant-scoped ACL check still gates access.
|
||||
// query would otherwise throw. The viewer's id comes from verified, active
|
||||
// cookie auth; the share's tenant-scoped ACL check still gates access.
|
||||
const user = await runAsSystem(() =>
|
||||
getUserById(userId, '-password -__v -totpSecret -backupCodes'),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,14 +1,27 @@
|
|||
const mockVerify = jest.fn();
|
||||
const mockGetUserById = jest.fn();
|
||||
const mockFindSession = jest.fn();
|
||||
const mockRunAsSystem = jest.fn((fn) => fn());
|
||||
|
||||
jest.mock('jsonwebtoken', () => ({ verify: (...args) => mockVerify(...args) }));
|
||||
jest.mock('@librechat/api', () => ({ isEnabled: (v) => v === 'true' || v === true }));
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { warn: jest.fn(), error: jest.fn() },
|
||||
runAsSystem: (fn) => fn(),
|
||||
jest.mock('@librechat/api', () => ({ isEnabled: (v) => v === 'true' || v === true }), {
|
||||
virtual: true,
|
||||
});
|
||||
jest.mock(
|
||||
'@librechat/data-schemas',
|
||||
() => ({
|
||||
logger: { warn: jest.fn(), error: jest.fn() },
|
||||
runAsSystem: (...args) => mockRunAsSystem(...args),
|
||||
}),
|
||||
{ virtual: true },
|
||||
);
|
||||
jest.mock('librechat-data-provider', () => ({ SystemRoles: { USER: 'USER' } }), {
|
||||
virtual: true,
|
||||
});
|
||||
jest.mock('~/models', () => ({
|
||||
getUserById: (...args) => mockGetUserById(...args),
|
||||
findSession: (...args) => mockFindSession(...args),
|
||||
}));
|
||||
jest.mock('librechat-data-provider', () => ({ SystemRoles: { USER: 'USER' } }));
|
||||
jest.mock('~/models', () => ({ getUserById: (...args) => mockGetUserById(...args) }));
|
||||
|
||||
const optionalShareFileAuth = require('./optionalShareFileAuth');
|
||||
|
||||
|
|
@ -30,20 +43,25 @@ describe('optionalShareFileAuth', () => {
|
|||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(mockVerify).not.toHaveBeenCalled();
|
||||
expect(mockGetUserById).not.toHaveBeenCalled();
|
||||
expect(mockFindSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('resolves the viewer from a valid refreshToken cookie', async () => {
|
||||
it('resolves the viewer from a valid refreshToken cookie with a live session', async () => {
|
||||
mockVerify.mockReturnValue({ id: 'viewer-1' });
|
||||
mockFindSession.mockResolvedValue({ _id: 'session-1' });
|
||||
mockGetUserById.mockResolvedValue({ _id: 'viewer-1', role: 'USER' });
|
||||
const req = { headers: { cookie: 'refreshToken=good.jwt' } };
|
||||
const next = await run(req);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(mockVerify).toHaveBeenCalledWith('good.jwt', 'test-secret');
|
||||
expect(mockFindSession).toHaveBeenCalledWith({ userId: 'viewer-1', refreshToken: 'good.jwt' });
|
||||
expect(mockRunAsSystem).toHaveBeenCalledTimes(2);
|
||||
expect(req.user).toMatchObject({ id: 'viewer-1', role: 'USER' });
|
||||
});
|
||||
|
||||
it('defaults the role to USER when the record has none', async () => {
|
||||
mockVerify.mockReturnValue({ id: 'viewer-2' });
|
||||
mockFindSession.mockResolvedValue({ _id: 'session-2' });
|
||||
mockGetUserById.mockResolvedValue({ _id: 'viewer-2' });
|
||||
const req = { headers: { cookie: 'refreshToken=good.jwt' } };
|
||||
await run(req);
|
||||
|
|
@ -58,6 +76,21 @@ describe('optionalShareFileAuth', () => {
|
|||
expect(mockGetUserById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves req.user unset when the refresh token has no live session', async () => {
|
||||
mockVerify.mockReturnValue({ id: 'viewer-3' });
|
||||
mockFindSession.mockResolvedValue(null);
|
||||
const req = { headers: { cookie: 'refreshToken=revoked.jwt' } };
|
||||
const next = await run(req);
|
||||
expect(next).toHaveBeenCalledTimes(1);
|
||||
expect(req.user).toBeUndefined();
|
||||
expect(mockFindSession).toHaveBeenCalledWith({
|
||||
userId: 'viewer-3',
|
||||
refreshToken: 'revoked.jwt',
|
||||
});
|
||||
expect(mockRunAsSystem).toHaveBeenCalledTimes(1);
|
||||
expect(mockGetUserById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves req.user unset when the token is invalid', async () => {
|
||||
mockVerify.mockImplementation(() => {
|
||||
throw new Error('bad token');
|
||||
|
|
@ -69,16 +102,35 @@ describe('optionalShareFileAuth', () => {
|
|||
expect(mockGetUserById).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the signed openid_user_id cookie for OpenID-reuse sessions', async () => {
|
||||
it('uses the signed openid_user_id cookie only for active OpenID-reuse sessions', async () => {
|
||||
process.env.OPENID_REUSE_TOKENS = 'true';
|
||||
mockVerify.mockReturnValue({ id: 'oidc-1' });
|
||||
mockGetUserById.mockResolvedValue({ _id: 'oidc-1', role: 'USER' });
|
||||
const req = {
|
||||
headers: { cookie: 'token_provider=openid; openid_user_id=signed.jwt' },
|
||||
headers: {
|
||||
cookie: 'token_provider=openid; refreshToken=stored-refresh; openid_user_id=signed.jwt',
|
||||
},
|
||||
session: { openidTokens: { refreshToken: 'stored-refresh' } },
|
||||
};
|
||||
await run(req);
|
||||
expect(mockVerify).toHaveBeenCalledWith('signed.jwt', 'test-secret');
|
||||
expect(mockFindSession).not.toHaveBeenCalled();
|
||||
expect(req.user).toMatchObject({ id: 'oidc-1' });
|
||||
delete process.env.OPENID_REUSE_TOKENS;
|
||||
});
|
||||
|
||||
it('leaves req.user unset for OpenID-reuse cookies without an active matching session', async () => {
|
||||
process.env.OPENID_REUSE_TOKENS = 'true';
|
||||
mockVerify.mockReturnValue({ id: 'oidc-2' });
|
||||
const req = {
|
||||
headers: {
|
||||
cookie: 'token_provider=openid; refreshToken=stale-refresh; openid_user_id=signed.jwt',
|
||||
},
|
||||
session: { openidTokens: { refreshToken: 'current-refresh' } },
|
||||
};
|
||||
await run(req);
|
||||
expect(req.user).toBeUndefined();
|
||||
expect(mockGetUserById).not.toHaveBeenCalled();
|
||||
delete process.env.OPENID_REUSE_TOKENS;
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -185,18 +185,22 @@ describe('GET /api/config', () => {
|
|||
expect(response.body).not.toHaveProperty('customFooter');
|
||||
});
|
||||
|
||||
it('should include public share footer fields when share context is requested', async () => {
|
||||
it('should not include share-only fields when share context is requested', async () => {
|
||||
process.env.ANALYTICS_GTM_ID = 'GTM-XYZ';
|
||||
process.env.CUSTOM_FOOTER = 'public footer text';
|
||||
process.env.HELP_AND_FAQ_URL = 'https://internal.example.com/faq';
|
||||
process.env.SANDPACK_BUNDLER_URL = 'https://bundler.test';
|
||||
process.env.SANDPACK_STATIC_BUNDLER_URL = 'https://static-bundler.test';
|
||||
mockGetAppConfig.mockResolvedValue(baseAppConfig);
|
||||
const app = createApp(null);
|
||||
|
||||
const response = await request(app).get('/api/config?context=share');
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body.analyticsGtmId).toBe('GTM-XYZ');
|
||||
expect(response.body.customFooter).toBe('public footer text');
|
||||
expect(response.body).not.toHaveProperty('analyticsGtmId');
|
||||
expect(response.body).not.toHaveProperty('customFooter');
|
||||
expect(response.body).not.toHaveProperty('bundlerURL');
|
||||
expect(response.body).not.toHaveProperty('staticBundlerURL');
|
||||
expect(response.body).not.toHaveProperty('helpAndFaqURL');
|
||||
expect(response.body).not.toHaveProperty('allowAccountDeletion');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ const mockGetSharedLinkExpiration = jest.fn();
|
|||
const mockGrantCreationPermissions = jest.fn();
|
||||
const mockUpdateSharedLinkPermissionsExpiration = jest.fn();
|
||||
const mockSharedLinksAccess = jest.fn((_req, _res, next) => next());
|
||||
const mockBuildSharedLinkStartupPayload = jest.fn();
|
||||
const mockCanAccessSharedLink = jest.fn((_req, _res, next) => next());
|
||||
const mockGetAppConfig = jest.fn();
|
||||
const mockGetTenantId = jest.fn(() => undefined);
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
isEnabled: jest.fn(() => true),
|
||||
|
|
@ -16,6 +20,7 @@ jest.mock('@librechat/api', () => ({
|
|||
ensureLinkPermissions: jest.fn(),
|
||||
isFileSnapshotEnabled: jest.fn(() => true),
|
||||
isFileSnapshotKillSwitchActive: jest.fn(() => false),
|
||||
buildSharedLinkStartupPayload: (...args) => mockBuildSharedLinkStartupPayload(...args),
|
||||
deleteSharedLinkWithCleanup: jest.fn(),
|
||||
getSharedLinkExpiration: (...args) => mockGetSharedLinkExpiration(...args),
|
||||
isActiveExpirationDate: jest.fn((expiredAt) => expiredAt > new Date()),
|
||||
|
|
@ -23,9 +28,11 @@ jest.mock('@librechat/api', () => ({
|
|||
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
logger: { error: jest.fn(), warn: jest.fn() },
|
||||
getTenantId: (...args) => mockGetTenantId(...args),
|
||||
createTempChatExpirationDate: jest.fn(() => new Date('2030-01-01T00:00:00.000Z')),
|
||||
runAsSystem: jest.fn((fn) => fn()),
|
||||
tenantStorage: { run: jest.fn((_ctx, fn) => fn()) },
|
||||
SYSTEM_TENANT_ID: '__SYSTEM__',
|
||||
}));
|
||||
|
||||
jest.mock('librechat-data-provider', () => ({
|
||||
|
|
@ -84,11 +91,19 @@ jest.mock('~/server/utils/files', () => ({
|
|||
getContentDisposition: jest.fn((name, disposition = 'attachment') => `${disposition}; ${name}`),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/middleware/canAccessSharedLink', () => (_req, _res, next) => next());
|
||||
jest.mock(
|
||||
'~/server/middleware/canAccessSharedLink',
|
||||
() =>
|
||||
(...args) =>
|
||||
mockCanAccessSharedLink(...args),
|
||||
);
|
||||
jest.mock('~/server/middleware/optionalShareFileAuth', () => (_req, _res, next) => next());
|
||||
jest.mock('~/server/middleware/optionalJwtAuth', () => (req, _res, next) => next());
|
||||
jest.mock('~/server/middleware/requireJwtAuth', () => (req, res, next) => next());
|
||||
jest.mock('~/server/middleware/config/app', () => (_req, _res, next) => next());
|
||||
jest.mock('~/server/services/Config/app', () => ({
|
||||
getAppConfig: (...args) => mockGetAppConfig(...args),
|
||||
}));
|
||||
|
||||
const { Readable } = require('stream');
|
||||
const { RetentionMode } = require('librechat-data-provider');
|
||||
|
|
@ -129,9 +144,22 @@ const buildApp = ({ retentionMode = RetentionMode.TEMPORARY } = {}) => {
|
|||
return app;
|
||||
};
|
||||
|
||||
describe('share routes retention', () => {
|
||||
describe('share routes', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGetTenantId.mockReturnValue(undefined);
|
||||
mockGetAppConfig.mockResolvedValue({
|
||||
interfaceConfig: {
|
||||
privacyPolicy: { externalUrl: 'https://example.com/privacy' },
|
||||
},
|
||||
});
|
||||
mockBuildSharedLinkStartupPayload.mockReturnValue({
|
||||
appTitle: 'Shared Chat',
|
||||
bundlerURL: 'https://bundler.example.com',
|
||||
interface: {
|
||||
privacyPolicy: { externalUrl: 'https://example.com/privacy' },
|
||||
},
|
||||
});
|
||||
getRoleByName.mockResolvedValue({
|
||||
permissions: {
|
||||
SHARED_LINKS: {
|
||||
|
|
@ -142,6 +170,45 @@ describe('share routes retention', () => {
|
|||
mockGrantCreationPermissions.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it('serves shared startup config after shared-link access is granted', async () => {
|
||||
const response = await request(buildApp()).get('/api/share/share-123/config');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers['cache-control']).toBe('private, no-store');
|
||||
expect(mockCanAccessSharedLink).toHaveBeenCalled();
|
||||
expect(mockGetAppConfig).toHaveBeenCalledWith({ baseOnly: true });
|
||||
expect(mockBuildSharedLinkStartupPayload).toHaveBeenCalledWith({
|
||||
interfaceConfig: {
|
||||
privacyPolicy: { externalUrl: 'https://example.com/privacy' },
|
||||
},
|
||||
});
|
||||
expect(response.body).toEqual({
|
||||
appTitle: 'Shared Chat',
|
||||
bundlerURL: 'https://bundler.example.com',
|
||||
interface: {
|
||||
privacyPolicy: { externalUrl: 'https://example.com/privacy' },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('uses tenant-scoped app config for shared startup config when tenant context is present', async () => {
|
||||
mockGetTenantId.mockReturnValue('tenant-abc');
|
||||
|
||||
const response = await request(buildApp()).get('/api/share/share-123/config');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockGetAppConfig).toHaveBeenCalledWith({ tenantId: 'tenant-abc' });
|
||||
});
|
||||
|
||||
it('uses base app config for shared startup config in system context', async () => {
|
||||
mockGetTenantId.mockReturnValue('__SYSTEM__');
|
||||
|
||||
const response = await request(buildApp()).get('/api/share/share-123/config');
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mockGetAppConfig).toHaveBeenCalledWith({ baseOnly: true });
|
||||
});
|
||||
|
||||
it('prevents successful shared message responses from being cached', async () => {
|
||||
getSharedMessages.mockResolvedValue({ shareId: 'share-123', messages: [] });
|
||||
|
||||
|
|
|
|||
|
|
@ -107,9 +107,9 @@ function buildPreLoginPayload() {
|
|||
}
|
||||
|
||||
/**
|
||||
* Public share fields rendered by `client/src/components/Share/ShareView.tsx`.
|
||||
* They remain off the default anonymous config used by login screens, and are
|
||||
* exposed to anonymous callers only when the client asks for share context.
|
||||
* Fields shared by authenticated chat and share-view config. Anonymous share
|
||||
* views receive these through `/api/share/:shareId/config` after share access
|
||||
* checks, not through the generic startup config endpoint.
|
||||
*/
|
||||
function buildPublicSharePayload() {
|
||||
/** @type {Partial<TStartupConfig>} */
|
||||
|
|
@ -215,7 +215,6 @@ router.get('/', async function (req, res) {
|
|||
/** @type {Partial<TStartupConfig>} */
|
||||
const payload = {
|
||||
...preLoginPayload,
|
||||
...(req.query.context === 'share' ? publicSharePayload : {}),
|
||||
socialLogins: baseConfig?.registration?.socialLogins ?? defaultSocialLogins,
|
||||
turnstile: baseConfig?.turnstileConfig,
|
||||
...(rum ? { rum } : {}),
|
||||
|
|
|
|||
|
|
@ -4,11 +4,18 @@ const configMiddleware = require('~/server/middleware/config/app');
|
|||
const endpointController = require('~/server/controllers/EndpointController');
|
||||
const tokenConfigController = require('~/server/controllers/TokenConfigController');
|
||||
const contextProjectionController = require('~/server/controllers/ContextProjectionController');
|
||||
const { contextProjectionLimiter } = require('~/server/middleware/limiters');
|
||||
|
||||
const router = express.Router();
|
||||
/** Auth required for role/tenant-scoped endpoint config resolution. */
|
||||
router.get('/', requireJwtAuth, endpointController);
|
||||
router.get('/token-config', requireJwtAuth, configMiddleware, tokenConfigController);
|
||||
router.post('/context-projection', requireJwtAuth, configMiddleware, contextProjectionController);
|
||||
router.post(
|
||||
'/context-projection',
|
||||
requireJwtAuth,
|
||||
contextProjectionLimiter,
|
||||
configMiddleware,
|
||||
contextProjectionController,
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ const {
|
|||
ensureLinkPermissions,
|
||||
isFileSnapshotEnabled,
|
||||
isFileSnapshotKillSwitchActive,
|
||||
buildSharedLinkStartupPayload,
|
||||
deleteSharedLinkWithCleanup,
|
||||
updateSharedLinkPermissionsExpiration,
|
||||
isActiveExpirationDate,
|
||||
|
|
@ -14,8 +15,10 @@ const {
|
|||
} = require('@librechat/api');
|
||||
const {
|
||||
logger,
|
||||
getTenantId,
|
||||
runAsSystem,
|
||||
tenantStorage,
|
||||
SYSTEM_TENANT_ID,
|
||||
createTempChatExpirationDate,
|
||||
} = require('@librechat/data-schemas');
|
||||
const { FileSources, PermissionTypes, Permissions } = require('librechat-data-provider');
|
||||
|
|
@ -38,6 +41,7 @@ const optionalShareFileAuth = require('~/server/middleware/optionalShareFileAuth
|
|||
const optionalJwtAuth = require('~/server/middleware/optionalJwtAuth');
|
||||
const requireJwtAuth = require('~/server/middleware/requireJwtAuth');
|
||||
const configMiddleware = require('~/server/middleware/config/app');
|
||||
const { getAppConfig } = require('~/server/services/Config/app');
|
||||
const router = express.Router();
|
||||
|
||||
const checkSharedLinksAccess = generateCheckAccess({
|
||||
|
|
@ -76,6 +80,14 @@ const runWithTenant = (tenantId, fn) =>
|
|||
* 'failed' on the next poll so the client poller terminates. */
|
||||
const PREVIEW_LAZY_SWEEP_CUTOFF_MS = 2 * 60 * 1000;
|
||||
|
||||
const getShareStartupPayload = async () => {
|
||||
const tenantId = getTenantId();
|
||||
const appConfig = await getAppConfig(
|
||||
tenantId && tenantId !== SYSTEM_TENANT_ID ? { tenantId } : { baseOnly: true },
|
||||
);
|
||||
return buildSharedLinkStartupPayload(appConfig);
|
||||
};
|
||||
|
||||
/**
|
||||
* MIME types that are safe to render inline. Everything else (text/html, SVG,
|
||||
* and other active content) is served as an `attachment` so a public viewer
|
||||
|
|
@ -212,6 +224,17 @@ const streamSharedFile = async (req, res, file, requestedDisposition) => {
|
|||
};
|
||||
|
||||
if (allowSharedLinks) {
|
||||
router.get('/:shareId/config', optionalJwtAuth, canAccessSharedLink, async (_req, res) => {
|
||||
try {
|
||||
const payload = await getShareStartupPayload();
|
||||
res.set('Cache-Control', 'private, no-store');
|
||||
res.status(200).json(payload);
|
||||
} catch (error) {
|
||||
logger.error('Error getting shared startup config:', error);
|
||||
res.status(500).json({ message: 'Error getting shared startup config' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get(
|
||||
'/:shareId',
|
||||
optionalJwtAuth,
|
||||
|
|
|
|||
|
|
@ -1466,20 +1466,25 @@ async function loadToolsForExecution({
|
|||
AgentConstants.PROGRAMMATIC_TOOL_CALLING,
|
||||
].filter((name) => toolNames.includes(name));
|
||||
const isPTCRequested = ptcToolNames.length > 0;
|
||||
const isBashToolRequested = toolNames.includes(AgentConstants.BASH_TOOL);
|
||||
const isLegacyExecuteCodeRequested = toolNames.includes(Tools.execute_code);
|
||||
const isCodeExecutionToolRequested = isBashToolRequested || isLegacyExecuteCodeRequested;
|
||||
|
||||
let enabledCapabilities;
|
||||
if (actionsEnabled === undefined || isPTCRequested) {
|
||||
if (actionsEnabled === undefined || isPTCRequested || isCodeExecutionToolRequested) {
|
||||
enabledCapabilities = await resolveAgentCapabilities(req, appConfig, agent?.id);
|
||||
}
|
||||
if (actionsEnabled === undefined) {
|
||||
actionsEnabled = enabledCapabilities.has(AgentCapabilities.actions);
|
||||
}
|
||||
const codeExecutionEnabled =
|
||||
enabledCapabilities?.has(AgentCapabilities.execute_code) === true &&
|
||||
agent?.tools?.includes(Tools.execute_code) === true;
|
||||
|
||||
const isPTC =
|
||||
isPTCRequested &&
|
||||
enabledCapabilities.has(AgentCapabilities.programmatic_tools) &&
|
||||
enabledCapabilities.has(AgentCapabilities.execute_code) &&
|
||||
agent?.tools?.includes(Tools.execute_code) === true;
|
||||
codeExecutionEnabled;
|
||||
|
||||
logger.debug(
|
||||
`[loadToolsForExecution] isToolSearch: ${isToolSearch}, toolRegistry: ${toolRegistry?.size ?? 'undefined'}`,
|
||||
|
|
@ -1513,7 +1518,16 @@ async function loadToolsForExecution({
|
|||
}
|
||||
}
|
||||
|
||||
const isBashTool = toolNames.includes(AgentConstants.BASH_TOOL);
|
||||
const isBashTool =
|
||||
isBashToolRequested &&
|
||||
codeExecutionEnabled &&
|
||||
toolRegistry?.has(AgentConstants.BASH_TOOL) === true;
|
||||
if (isBashToolRequested && !isBashTool) {
|
||||
logger.warn(
|
||||
`[loadToolsForExecution] Skipping unregistered or unauthorized ${AgentConstants.BASH_TOOL}. ` +
|
||||
`User: ${req.user.id} | Agent: ${agent?.id ?? 'unknown'}`,
|
||||
);
|
||||
}
|
||||
if (isBashTool) {
|
||||
try {
|
||||
const bashTool = createBashExecutionTool({
|
||||
|
|
@ -1550,9 +1564,22 @@ async function loadToolsForExecution({
|
|||
}
|
||||
|
||||
const requestedNonSpecialToolNames = toolNames.filter((name) => !specialToolNames.has(name));
|
||||
const allowedNonSpecialToolNames = requestedNonSpecialToolNames.filter((name) => {
|
||||
if (name !== Tools.execute_code) {
|
||||
return true;
|
||||
}
|
||||
const allowed = codeExecutionEnabled && toolRegistry?.has(Tools.execute_code) === true;
|
||||
if (!allowed) {
|
||||
logger.warn(
|
||||
`[loadToolsForExecution] Skipping unregistered or unauthorized ${Tools.execute_code}. ` +
|
||||
`User: ${req.user.id} | Agent: ${agent?.id ?? 'unknown'}`,
|
||||
);
|
||||
}
|
||||
return allowed;
|
||||
});
|
||||
const allToolNamesToLoad = isPTC
|
||||
? [...new Set([...requestedNonSpecialToolNames, ...ptcOrchestratedToolNames])]
|
||||
: requestedNonSpecialToolNames;
|
||||
? [...new Set([...allowedNonSpecialToolNames, ...ptcOrchestratedToolNames])]
|
||||
: allowedNonSpecialToolNames;
|
||||
|
||||
const actionToolNames = [];
|
||||
const regularToolNames = [];
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
const { Constants: AgentConstants } = require('@librechat/agents');
|
||||
const {
|
||||
Tools,
|
||||
Constants,
|
||||
|
|
@ -964,6 +965,29 @@ describe('ToolService - Action Capability Gating', () => {
|
|||
const actionToolName = `get_weather${actionDelimiter}api_example_com`;
|
||||
const regularTool = Tools.web_search;
|
||||
|
||||
it('does not load code execution tools that were not registered for the agent', async () => {
|
||||
const capabilities = [
|
||||
AgentCapabilities.tools,
|
||||
AgentCapabilities.web_search,
|
||||
AgentCapabilities.execute_code,
|
||||
];
|
||||
const req = createMockReq(capabilities);
|
||||
const toolRegistry = new Map([[Tools.web_search, { name: Tools.web_search }]]);
|
||||
mockGetEndpointsConfig.mockResolvedValue(createEndpointsConfig(capabilities));
|
||||
|
||||
const result = await loadToolsForExecution({
|
||||
req,
|
||||
res: {},
|
||||
agent: { id: 'agent_without_code', tools: [Tools.web_search] },
|
||||
toolNames: [AgentConstants.BASH_TOOL, Tools.execute_code],
|
||||
toolRegistry,
|
||||
actionsEnabled: false,
|
||||
});
|
||||
|
||||
expect(result.loadedTools.map((tool) => tool.name)).toEqual([]);
|
||||
expect(mockLoadToolsUtil).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('loads bash PTC under the legacy programmatic tool name when code capabilities are enabled', async () => {
|
||||
const capabilities = [
|
||||
AgentCapabilities.tools,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue