mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 06:52:47 +00:00
🔐 feat: Mint Code API Auth Tokens (#13028)
* feat: Mint CodeAPI auth tokens * style: Format CodeAPI download route * fix: Prune CodeAPI token cache * fix: Propagate CodeAPI managed auth * test: Mock CodeAPI auth in traversal suite * fix: Pass auth context to invoked skill cache * feat: Mint CodeAPI plan context * chore: Refresh CodeAPI auth guidance * fix: Guard OpenID JWT fallback * fix: Default CodeAPI JWT tenant in single-tenant mode * chore: Update @librechat/agents to version 3.1.84 in package-lock.json and package.json files * chore: Standardize references to Code API in comments and tests
This commit is contained in:
parent
8a654dc8b1
commit
c67e2b54dc
23 changed files with 973 additions and 58 deletions
|
|
@ -13,17 +13,25 @@ const { getTenantId } = require('@librechat/data-schemas');
|
|||
// ── Mocks ──────────────────────────────────────────────────────────────
|
||||
|
||||
let mockPassportError = null;
|
||||
let mockRegisteredStrategies = new Set(['jwt']);
|
||||
|
||||
jest.mock('passport', () => ({
|
||||
authenticate: jest.fn(() => {
|
||||
return (req, _res, done) => {
|
||||
_strategy: jest.fn((strategy) => (mockRegisteredStrategies.has(strategy) ? {} : undefined)),
|
||||
authenticate: jest.fn((strategy, _options, callback) => {
|
||||
return (req, _res, _done) => {
|
||||
if (mockPassportError) {
|
||||
return done(mockPassportError);
|
||||
return callback(mockPassportError);
|
||||
}
|
||||
if (req._mockUser) {
|
||||
req.user = req._mockUser;
|
||||
const strategyResult = req._mockStrategies?.[strategy];
|
||||
if (strategyResult) {
|
||||
return callback(
|
||||
strategyResult.err ?? null,
|
||||
strategyResult.user ?? false,
|
||||
strategyResult.info,
|
||||
strategyResult.status,
|
||||
);
|
||||
}
|
||||
done();
|
||||
return callback(null, req._mockUser ?? false, { message: 'Unauthorized' }, 401);
|
||||
};
|
||||
}),
|
||||
}));
|
||||
|
|
@ -49,9 +57,11 @@ jest.mock('@librechat/api', () => {
|
|||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
const requireJwtAuth = require('../requireJwtAuth');
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const passport = require('passport');
|
||||
|
||||
function mockReq(user) {
|
||||
return { headers: {}, _mockUser: user };
|
||||
function mockReq(user, extra = {}) {
|
||||
return { headers: {}, _mockUser: user, ...extra };
|
||||
}
|
||||
|
||||
function mockRes() {
|
||||
|
|
@ -74,6 +84,10 @@ function runAuth(user) {
|
|||
describe('requireJwtAuth tenant context chaining', () => {
|
||||
afterEach(() => {
|
||||
mockPassportError = null;
|
||||
mockRegisteredStrategies = new Set(['jwt']);
|
||||
isEnabled.mockReturnValue(false);
|
||||
passport.authenticate.mockClear();
|
||||
passport._strategy.mockClear();
|
||||
});
|
||||
|
||||
it('forwards passport errors to next() without entering tenant middleware', async () => {
|
||||
|
|
@ -98,9 +112,61 @@ describe('requireJwtAuth tenant context chaining', () => {
|
|||
expect(tenantId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('ALS tenant context is NOT set when user is undefined', async () => {
|
||||
const tenantId = await runAuth(undefined);
|
||||
expect(tenantId).toBeUndefined();
|
||||
it('returns 401 when no strategy authenticates a user', async () => {
|
||||
const req = mockReq(undefined);
|
||||
const res = mockRes();
|
||||
const next = jest.fn();
|
||||
|
||||
requireJwtAuth(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(getTenantId()).toBeUndefined();
|
||||
});
|
||||
|
||||
it('falls back to OpenID JWT for bearer-only reuse requests', async () => {
|
||||
isEnabled.mockReturnValue(true);
|
||||
mockRegisteredStrategies.add('openidJwt');
|
||||
const req = mockReq(undefined, {
|
||||
_mockStrategies: {
|
||||
jwt: { user: false, info: { message: 'invalid signature' }, status: 401 },
|
||||
openidJwt: { user: { tenantId: 'tenant-openid', role: 'user' } },
|
||||
},
|
||||
});
|
||||
const res = mockRes();
|
||||
const tenantId = await new Promise((resolve) => {
|
||||
requireJwtAuth(req, res, () => {
|
||||
resolve(getTenantId());
|
||||
});
|
||||
});
|
||||
|
||||
expect(tenantId).toBe('tenant-openid');
|
||||
expect(req.authStrategy).toBe('openidJwt');
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips OpenID JWT fallback when the strategy was not registered', async () => {
|
||||
isEnabled.mockReturnValue(true);
|
||||
const req = mockReq(undefined, {
|
||||
_mockStrategies: {
|
||||
jwt: { user: false, info: { message: 'invalid signature' }, status: 401 },
|
||||
openidJwt: { user: { tenantId: 'tenant-openid', role: 'user' } },
|
||||
},
|
||||
});
|
||||
const res = mockRes();
|
||||
const next = jest.fn();
|
||||
|
||||
requireJwtAuth(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(401);
|
||||
expect(req.authStrategy).toBeUndefined();
|
||||
expect(passport.authenticate).toHaveBeenCalledTimes(1);
|
||||
expect(passport.authenticate).toHaveBeenCalledWith(
|
||||
'jwt',
|
||||
{ session: false },
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('concurrent requests get isolated tenant contexts', async () => {
|
||||
|
|
|
|||
|
|
@ -2,23 +2,31 @@ const cookies = require('cookie');
|
|||
const passport = require('passport');
|
||||
const { isEnabled, tenantContextMiddleware } = require('@librechat/api');
|
||||
|
||||
const hasPassportStrategy = (strategy) =>
|
||||
typeof passport._strategy === 'function' && passport._strategy(strategy) != null;
|
||||
|
||||
// This middleware does not require authentication,
|
||||
// but if the user is authenticated, it will set the user object
|
||||
// and establish tenant ALS context.
|
||||
const optionalJwtAuth = (req, res, next) => {
|
||||
const cookieHeader = req.headers.cookie;
|
||||
const tokenProvider = cookieHeader ? cookies.parse(cookieHeader).token_provider : null;
|
||||
const useOpenIdJwt =
|
||||
tokenProvider === 'openid' &&
|
||||
isEnabled(process.env.OPENID_REUSE_TOKENS) &&
|
||||
hasPassportStrategy('openidJwt');
|
||||
const callback = (err, user) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
if (user) {
|
||||
req.user = user;
|
||||
req.authStrategy = useOpenIdJwt ? 'openidJwt' : 'jwt';
|
||||
return tenantContextMiddleware(req, res, next);
|
||||
}
|
||||
next();
|
||||
};
|
||||
if (tokenProvider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS)) {
|
||||
if (useOpenIdJwt) {
|
||||
return passport.authenticate('openidJwt', { session: false }, callback)(req, res, next);
|
||||
}
|
||||
passport.authenticate('jwt', { session: false }, callback)(req, res, next);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@ const cookies = require('cookie');
|
|||
const passport = require('passport');
|
||||
const { isEnabled, tenantContextMiddleware } = require('@librechat/api');
|
||||
|
||||
const hasPassportStrategy = (strategy) =>
|
||||
typeof passport._strategy === 'function' && passport._strategy(strategy) != null;
|
||||
|
||||
/**
|
||||
* Custom Middleware to handle JWT authentication, with support for OpenID token reuse.
|
||||
* Switches between JWT and OpenID authentication based on cookies and environment settings.
|
||||
|
|
@ -13,17 +16,35 @@ const { isEnabled, tenantContextMiddleware } = require('@librechat/api');
|
|||
const requireJwtAuth = (req, res, next) => {
|
||||
const cookieHeader = req.headers.cookie;
|
||||
const tokenProvider = cookieHeader ? cookies.parse(cookieHeader).token_provider : null;
|
||||
const openidReuseEnabled = isEnabled(process.env.OPENID_REUSE_TOKENS);
|
||||
const openidJwtAvailable = openidReuseEnabled && hasPassportStrategy('openidJwt');
|
||||
const strategies =
|
||||
tokenProvider === 'openid' && openidJwtAvailable
|
||||
? ['openidJwt', 'jwt']
|
||||
: ['jwt', ...(openidJwtAvailable ? ['openidJwt'] : [])];
|
||||
|
||||
const strategy =
|
||||
tokenProvider === 'openid' && isEnabled(process.env.OPENID_REUSE_TOKENS) ? 'openidJwt' : 'jwt';
|
||||
const authenticateWithStrategy = (index) => {
|
||||
const strategy = strategies[index];
|
||||
passport.authenticate(strategy, { session: false }, (err, user, info, status) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
if (!user) {
|
||||
if (index + 1 < strategies.length) {
|
||||
return authenticateWithStrategy(index + 1);
|
||||
}
|
||||
return res.status(status || 401).json({
|
||||
message: info?.message || 'Unauthorized',
|
||||
});
|
||||
}
|
||||
req.user = user;
|
||||
req.authStrategy = strategy;
|
||||
// req.user is now populated by passport — set up tenant ALS context
|
||||
tenantContextMiddleware(req, res, next);
|
||||
})(req, res, next);
|
||||
};
|
||||
|
||||
passport.authenticate(strategy, { session: false })(req, res, (err) => {
|
||||
if (err) {
|
||||
return next(err);
|
||||
}
|
||||
// req.user is now populated by passport — set up tenant ALS context
|
||||
tenantContextMiddleware(req, res, next);
|
||||
});
|
||||
authenticateWithStrategy(0);
|
||||
};
|
||||
|
||||
module.exports = requireJwtAuth;
|
||||
|
|
|
|||
|
|
@ -318,10 +318,14 @@ router.get('/code/download/:session_id/:fileId', async (req, res) => {
|
|||
* sessionKey; without these query params it 400s with
|
||||
* "kind must be one of: skill, agent, user". */
|
||||
/** @type {AxiosResponse<ReadableStream> | undefined} */
|
||||
const response = await getDownloadStream(`${session_id}/${fileId}`, {
|
||||
kind: 'user',
|
||||
id: req.user.id,
|
||||
});
|
||||
const response = await getDownloadStream(
|
||||
`${session_id}/${fileId}`,
|
||||
{
|
||||
kind: 'user',
|
||||
id: req.user.id,
|
||||
},
|
||||
req,
|
||||
);
|
||||
res.set(response.headers);
|
||||
response.data.pipe(res);
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ jest.mock('@librechat/api', () => {
|
|||
sanitizeArtifactPath: mockSanitizeArtifactPath,
|
||||
flattenArtifactPath: mockFlattenArtifactPath,
|
||||
createAxiosInstance: jest.fn(() => mockAxios),
|
||||
getCodeApiAuthHeaders: jest.fn(async () => ({})),
|
||||
classifyCodeArtifact: jest.fn(() => 'other'),
|
||||
extractCodeArtifactText: jest.fn(async () => null),
|
||||
/* `processCodeOutput` calls this to derive the trust flag persisted
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ const {
|
|||
codeServerHttpsAgent,
|
||||
appendCodeEnvFileIdentity,
|
||||
buildCodeEnvDownloadQuery,
|
||||
getCodeApiAuthHeaders,
|
||||
} = require('@librechat/api');
|
||||
|
||||
const axios = createAxiosInstance();
|
||||
|
|
@ -26,10 +27,11 @@ const MAX_FILE_SIZE = 150 * 1024 * 1024;
|
|||
* @returns {Promise<AxiosResponse>} A promise that resolves to a readable stream of the file content.
|
||||
* @throws {Error} If there's an error during the download process.
|
||||
*/
|
||||
async function getCodeOutputDownloadStream(fileIdentifier, identity) {
|
||||
async function getCodeOutputDownloadStream(fileIdentifier, identity, req) {
|
||||
try {
|
||||
const baseURL = getCodeBaseURL();
|
||||
const query = buildCodeEnvDownloadQuery(identity);
|
||||
const authHeaders = await getCodeApiAuthHeaders(req);
|
||||
/** @type {import('axios').AxiosRequestConfig} */
|
||||
const options = {
|
||||
method: 'get',
|
||||
|
|
@ -37,6 +39,7 @@ async function getCodeOutputDownloadStream(fileIdentifier, identity) {
|
|||
responseType: 'stream',
|
||||
headers: {
|
||||
'User-Agent': 'LibreChat/1.0',
|
||||
...authHeaders,
|
||||
},
|
||||
httpAgent: codeServerHttpAgent,
|
||||
httpsAgent: codeServerHttpsAgent,
|
||||
|
|
@ -85,6 +88,7 @@ async function uploadCodeEnvFile({ req, stream, filename, kind, id, version }) {
|
|||
appendCodeEnvFile(form, stream, filename);
|
||||
|
||||
const baseURL = getCodeBaseURL();
|
||||
const authHeaders = await getCodeApiAuthHeaders(req);
|
||||
/** @type {import('axios').AxiosRequestConfig} */
|
||||
const options = {
|
||||
headers: {
|
||||
|
|
@ -92,6 +96,7 @@ async function uploadCodeEnvFile({ req, stream, filename, kind, id, version }) {
|
|||
'Content-Type': 'multipart/form-data',
|
||||
'User-Agent': 'LibreChat/1.0',
|
||||
'User-Id': req.user.id,
|
||||
...authHeaders,
|
||||
},
|
||||
httpAgent: codeServerHttpAgent,
|
||||
httpsAgent: codeServerHttpsAgent,
|
||||
|
|
@ -156,6 +161,7 @@ async function batchUploadCodeEnvFiles({ req, files, kind, id, version, read_onl
|
|||
}
|
||||
|
||||
const baseURL = getCodeBaseURL();
|
||||
const authHeaders = await getCodeApiAuthHeaders(req);
|
||||
/** @type {import('axios').AxiosRequestConfig} */
|
||||
const options = {
|
||||
headers: {
|
||||
|
|
@ -163,6 +169,7 @@ async function batchUploadCodeEnvFiles({ req, files, kind, id, version, read_onl
|
|||
'Content-Type': 'multipart/form-data',
|
||||
'User-Agent': 'LibreChat/1.0',
|
||||
'User-Id': req.user.id,
|
||||
...authHeaders,
|
||||
},
|
||||
httpAgent: codeServerHttpAgent,
|
||||
httpsAgent: codeServerHttpsAgent,
|
||||
|
|
|
|||
|
|
@ -49,18 +49,24 @@ jest.mock('@librechat/api', () => {
|
|||
return `?${params.toString()}`;
|
||||
}),
|
||||
logAxiosError: jest.fn(({ message }) => message),
|
||||
getCodeApiAuthHeaders: jest.fn(async () => ({})),
|
||||
createAxiosInstance: jest.fn(() => mockAxios),
|
||||
codeServerHttpAgent: new http.Agent({ keepAlive: false }),
|
||||
codeServerHttpsAgent: new https.Agent({ keepAlive: false }),
|
||||
};
|
||||
});
|
||||
|
||||
const { codeServerHttpAgent, codeServerHttpsAgent } = require('@librechat/api');
|
||||
const {
|
||||
codeServerHttpAgent,
|
||||
codeServerHttpsAgent,
|
||||
getCodeApiAuthHeaders,
|
||||
} = require('@librechat/api');
|
||||
const { getCodeOutputDownloadStream, uploadCodeEnvFile } = require('./crud');
|
||||
|
||||
describe('Code CRUD', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
getCodeApiAuthHeaders.mockResolvedValue({});
|
||||
});
|
||||
|
||||
describe('getCodeOutputDownloadStream', () => {
|
||||
|
|
@ -101,6 +107,18 @@ describe('Code CRUD', () => {
|
|||
expect(callConfig.timeout).toBe(15000);
|
||||
});
|
||||
|
||||
it('forwards Code API auth headers when a request is provided', async () => {
|
||||
const req = { user: { id: 'user-123' } };
|
||||
getCodeApiAuthHeaders.mockResolvedValue({ Authorization: 'Bearer codeapi-token' });
|
||||
mockAxios.mockResolvedValue({ data: Readable.from(['chunk']) });
|
||||
|
||||
await getCodeOutputDownloadStream('session-1/file-1', userIdentity, req);
|
||||
|
||||
const callConfig = mockAxios.mock.calls[0][0];
|
||||
expect(getCodeApiAuthHeaders).toHaveBeenCalledWith(req);
|
||||
expect(callConfig.headers.Authorization).toBe('Bearer codeapi-token');
|
||||
});
|
||||
|
||||
it('forwards skill identity (kind/id/version) when re-downloading a primed skill file', async () => {
|
||||
mockAxios.mockResolvedValue({ data: Readable.from(['chunk']) });
|
||||
|
||||
|
|
@ -194,6 +212,23 @@ describe('Code CRUD', () => {
|
|||
expect(result).toEqual({ storage_session_id: 'sess-1', file_id: 'fid-1' });
|
||||
});
|
||||
|
||||
it('forwards Code API auth headers on upload requests', async () => {
|
||||
getCodeApiAuthHeaders.mockResolvedValue({ Authorization: 'Bearer codeapi-token' });
|
||||
mockAxios.post.mockResolvedValue({
|
||||
data: {
|
||||
message: 'success',
|
||||
storage_session_id: 'sess-1',
|
||||
files: [{ fileId: 'fid-1', filename: 'data.csv' }],
|
||||
},
|
||||
});
|
||||
|
||||
await uploadCodeEnvFile(baseUploadParams);
|
||||
|
||||
const callConfig = mockAxios.post.mock.calls[0][2];
|
||||
expect(getCodeApiAuthHeaders).toHaveBeenCalledWith(baseUploadParams.req);
|
||||
expect(callConfig.headers.Authorization).toBe('Bearer codeapi-token');
|
||||
});
|
||||
|
||||
/* Phase C / option α (codeapi #1455): the upload wire carries the
|
||||
* resource identity codeapi uses for sessionKey derivation. Without
|
||||
* these on the form, codeapi falls back to user bucketing for every
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const {
|
|||
sanitizeArtifactPath,
|
||||
flattenArtifactPath,
|
||||
createAxiosInstance,
|
||||
getCodeApiAuthHeaders,
|
||||
classifyCodeArtifact,
|
||||
codeServerHttpAgent,
|
||||
codeServerHttpsAgent,
|
||||
|
|
@ -335,6 +336,7 @@ const processCodeOutput = async ({
|
|||
|
||||
try {
|
||||
const formattedDate = currentDate.toISOString();
|
||||
const authHeaders = await getCodeApiAuthHeaders(req);
|
||||
/* Code-output files are always user-private — no skill execution
|
||||
* produces a skill-scoped output bucket. The download URL must
|
||||
* carry `?kind=user&id=<userId>` so codeapi's `sessionAuth`
|
||||
|
|
@ -347,6 +349,7 @@ const processCodeOutput = async ({
|
|||
responseType: 'arraybuffer',
|
||||
headers: {
|
||||
'User-Agent': 'LibreChat/1.0',
|
||||
...authHeaders,
|
||||
},
|
||||
httpAgent: codeServerHttpAgent,
|
||||
httpsAgent: codeServerHttpsAgent,
|
||||
|
|
@ -669,14 +672,16 @@ function checkIfActive(dateString) {
|
|||
* @param {import('librechat-data-provider').CodeEnvRef} ref - Typed pointer
|
||||
* into codeapi storage. Carries kind/id/storage_session_id/file_id;
|
||||
* codeapi resolves the sessionKey from the request's auth context.
|
||||
* @param {ServerRequest} [req] - Current authenticated request, used to mint Code API auth.
|
||||
*
|
||||
* @returns {Promise<string|null>}
|
||||
* A promise that resolves to the `lastModified` time string of the file if successful, or null if there is an
|
||||
* error in initialization or fetching the info.
|
||||
*/
|
||||
async function getSessionInfo(ref) {
|
||||
async function getSessionInfo(ref, req) {
|
||||
try {
|
||||
const baseURL = getCodeBaseURL();
|
||||
const authHeaders = await getCodeApiAuthHeaders(req);
|
||||
/* `/sessions/.../objects/...` is gated by codeapi's `sessionAuth`
|
||||
* middleware (post-Phase C). The middleware reconstructs the
|
||||
* sessionKey from the URL query (`kind`/`id`/`version?`) plus the
|
||||
|
|
@ -693,6 +698,7 @@ async function getSessionInfo(ref) {
|
|||
url: `${baseURL}/sessions/${ref.storage_session_id}/objects/${ref.file_id}${query}`,
|
||||
headers: {
|
||||
'User-Agent': 'LibreChat/1.0',
|
||||
...authHeaders,
|
||||
},
|
||||
httpAgent: codeServerHttpAgent,
|
||||
httpsAgent: codeServerHttpsAgent,
|
||||
|
|
@ -925,7 +931,7 @@ const primeFiles = async (options) => {
|
|||
);
|
||||
}
|
||||
};
|
||||
const uploadTime = await getSessionInfo(ref);
|
||||
const uploadTime = await getSessionInfo(ref, req);
|
||||
if (!uploadTime) {
|
||||
logger.debug(
|
||||
`[primeCodeFiles] file=${file.file_id} path=reupload reason=no-uploadtime ` +
|
||||
|
|
@ -979,9 +985,10 @@ const primeFiles = async (options) => {
|
|||
* @param {string} params.file_path - Absolute path inside the sandbox (e.g. `/mnt/data/foo.txt`).
|
||||
* @param {string} [params.session_id] - Sandbox session id from the seeded context.
|
||||
* @param {Array<{id: string, name: string, session_id?: string}>} [params.files] - File refs to mount.
|
||||
* @param {ServerRequest} [params.req] - Current authenticated request, used to mint Code API auth.
|
||||
* @returns {Promise<{content: string} | null>}
|
||||
*/
|
||||
async function readSandboxFile({ file_path, session_id, files }) {
|
||||
async function readSandboxFile({ file_path, session_id, files, req }) {
|
||||
const baseURL = getCodeBaseURL();
|
||||
if (!baseURL) {
|
||||
return null;
|
||||
|
|
@ -1002,6 +1009,7 @@ async function readSandboxFile({ file_path, session_id, files }) {
|
|||
}
|
||||
|
||||
try {
|
||||
const authHeaders = await getCodeApiAuthHeaders(req);
|
||||
const response = await axios({
|
||||
method: 'post',
|
||||
url: `${baseURL}/exec`,
|
||||
|
|
@ -1009,6 +1017,7 @@ async function readSandboxFile({ file_path, session_id, files }) {
|
|||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'LibreChat/1.0',
|
||||
...authHeaders,
|
||||
},
|
||||
httpAgent: codeServerHttpAgent,
|
||||
httpsAgent: codeServerHttpsAgent,
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ jest.mock('@librechat/api', () => {
|
|||
sanitizeArtifactPath: jest.fn((name) => name),
|
||||
flattenArtifactPath: jest.fn((name) => name.replace(/\//g, '__')),
|
||||
createAxiosInstance: jest.fn(() => mockAxios),
|
||||
getCodeApiAuthHeaders: jest.fn(async () => ({})),
|
||||
withTimeout: (...args) => passthroughWithTimeout(...args),
|
||||
hasOfficeHtmlPath: (...args) => mockHasOfficeHtmlPath(...args),
|
||||
/**
|
||||
|
|
@ -148,7 +149,12 @@ const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
|||
const { convertImage } = require('~/server/services/Files/images/convert');
|
||||
const { determineFileType } = require('~/server/utils');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { codeServerHttpAgent, codeServerHttpsAgent, getStorageMetadata } = require('@librechat/api');
|
||||
const {
|
||||
codeServerHttpAgent,
|
||||
codeServerHttpsAgent,
|
||||
getCodeApiAuthHeaders,
|
||||
getStorageMetadata,
|
||||
} = require('@librechat/api');
|
||||
|
||||
const { processCodeOutput, getSessionInfo, readSandboxFile, primeFiles } = require('./process');
|
||||
|
||||
|
|
@ -231,6 +237,24 @@ describe('Code Process', () => {
|
|||
});
|
||||
|
||||
describe('processCodeOutput', () => {
|
||||
it('forwards Code API auth headers when downloading generated output', async () => {
|
||||
getCodeApiAuthHeaders.mockResolvedValueOnce({ Authorization: 'Bearer codeapi-token' });
|
||||
mockAxios.mockResolvedValue({ data: Buffer.alloc(100) });
|
||||
|
||||
await processCodeOutput(baseParams);
|
||||
|
||||
expect(getCodeApiAuthHeaders).toHaveBeenCalledWith(mockReq);
|
||||
expect(mockAxios).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'get',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer codeapi-token',
|
||||
'User-Agent': 'LibreChat/1.0',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
describe('image file processing', () => {
|
||||
it('should process image files using convertImage', async () => {
|
||||
const imageParams = { ...baseParams, name: 'chart.png' };
|
||||
|
|
@ -1008,6 +1032,34 @@ describe('Code Process', () => {
|
|||
expect(callConfig.httpAgent.keepAlive).toBe(false);
|
||||
expect(callConfig.httpsAgent.keepAlive).toBe(false);
|
||||
});
|
||||
|
||||
it('forwards Code API auth headers when checking session object freshness', async () => {
|
||||
getCodeApiAuthHeaders.mockResolvedValueOnce({ Authorization: 'Bearer freshness-token' });
|
||||
mockAxios.mockResolvedValue({
|
||||
data: { lastModified: '2024-01-01T00:00:00Z' },
|
||||
});
|
||||
|
||||
await getSessionInfo(
|
||||
{
|
||||
kind: 'user',
|
||||
id: 'user-123',
|
||||
storage_session_id: 'session-123',
|
||||
file_id: 'file-123',
|
||||
},
|
||||
mockReq,
|
||||
);
|
||||
|
||||
expect(getCodeApiAuthHeaders).toHaveBeenCalledWith(mockReq);
|
||||
expect(mockAxios).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'get',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer freshness-token',
|
||||
'User-Agent': 'LibreChat/1.0',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deferred-preview flow (office-bucket files)', () => {
|
||||
|
|
@ -1465,6 +1517,24 @@ describe('Code Process', () => {
|
|||
expect(call.httpAgent).toBe(codeServerHttpAgent);
|
||||
expect(call.httpsAgent).toBe(codeServerHttpsAgent);
|
||||
});
|
||||
|
||||
it('forwards Code API auth headers when reading from the sandbox', async () => {
|
||||
getCodeApiAuthHeaders.mockResolvedValueOnce({ Authorization: 'Bearer sandbox-token' });
|
||||
mockAxios.mockResolvedValueOnce({ data: { stdout: 'x', stderr: '' } });
|
||||
|
||||
await readSandboxFile({ file_path: '/mnt/data/x.txt', req: mockReq });
|
||||
|
||||
expect(getCodeApiAuthHeaders).toHaveBeenCalledWith(mockReq);
|
||||
expect(mockAxios).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
method: 'post',
|
||||
headers: expect.objectContaining({
|
||||
Authorization: 'Bearer sandbox-token',
|
||||
'User-Agent': 'LibreChat/1.0',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('response handling', () => {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ const {
|
|||
buildOAuthToolCallName,
|
||||
buildToolClassification,
|
||||
buildWebSearchDynamicContext,
|
||||
getCodeApiAuthHeaders,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
Time,
|
||||
|
|
@ -1009,6 +1010,7 @@ async function loadAgentTools({
|
|||
agentId: agent.id,
|
||||
agentToolOptions: agent.tool_options,
|
||||
deferredToolsEnabled,
|
||||
authHeaders: () => getCodeApiAuthHeaders(req),
|
||||
});
|
||||
|
||||
const agentTools = [];
|
||||
|
|
@ -1279,10 +1281,12 @@ async function loadToolsForExecution({
|
|||
configurable.toolRegistry = toolRegistry;
|
||||
try {
|
||||
/**
|
||||
* PTC auth is handled by the agents library / sandbox service
|
||||
* directly; LibreChat no longer threads a per-run credential.
|
||||
* LibreChat threads per-request Code API auth through the agents
|
||||
* library so PTC calls share the same managed auth context.
|
||||
*/
|
||||
const ptcTool = createProgrammaticToolCallingTool({});
|
||||
const ptcTool = createProgrammaticToolCallingTool({
|
||||
authHeaders: () => getCodeApiAuthHeaders(req),
|
||||
});
|
||||
allLoadedTools.push(ptcTool);
|
||||
} catch (error) {
|
||||
logger.error('[loadToolsForExecution] Error creating PTC tool:', error);
|
||||
|
|
@ -1292,7 +1296,9 @@ async function loadToolsForExecution({
|
|||
const isBashTool = toolNames.includes(AgentConstants.BASH_TOOL);
|
||||
if (isBashTool) {
|
||||
try {
|
||||
const bashTool = createBashExecutionTool({});
|
||||
const bashTool = createBashExecutionTool({
|
||||
authHeaders: () => getCodeApiAuthHeaders(req),
|
||||
});
|
||||
allLoadedTools.push(bashTool);
|
||||
} catch (error) {
|
||||
logger.error('[loadToolsForExecution] Failed to create bash_tool', error);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue