mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-06-16 07:51:32 +03: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:
@@ -6,6 +6,7 @@ const {
|
||||
createSafeUser,
|
||||
mcpToolPattern,
|
||||
loadWebSearchAuth,
|
||||
getCodeApiAuthHeaders,
|
||||
buildImageToolContext,
|
||||
buildWebSearchContext,
|
||||
buildWebSearchDynamicContext,
|
||||
@@ -282,7 +283,11 @@ const loadTools = async ({
|
||||
if (files?.length) {
|
||||
primedCodeFiles = files;
|
||||
}
|
||||
return createCodeExecutionTool({ user_id: user, files });
|
||||
return createCodeExecutionTool({
|
||||
user_id: user,
|
||||
files,
|
||||
authHeaders: () => getCodeApiAuthHeaders(options.req),
|
||||
});
|
||||
};
|
||||
continue;
|
||||
} else if (tool === Tools.file_search) {
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
"@azure/storage-blob": "^12.30.0",
|
||||
"@google/genai": "^1.19.0",
|
||||
"@keyv/redis": "^4.3.3",
|
||||
"@librechat/agents": "^3.1.83",
|
||||
"@librechat/agents": "^3.1.84",
|
||||
"@librechat/api": "*",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
|
||||
@@ -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);
|
||||
|
||||
10
package-lock.json
generated
10
package-lock.json
generated
@@ -60,7 +60,7 @@
|
||||
"@azure/storage-blob": "^12.30.0",
|
||||
"@google/genai": "^1.19.0",
|
||||
"@keyv/redis": "^4.3.3",
|
||||
"@librechat/agents": "^3.1.83",
|
||||
"@librechat/agents": "^3.1.84",
|
||||
"@librechat/api": "*",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
@@ -12088,9 +12088,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@librechat/agents": {
|
||||
"version": "3.1.83",
|
||||
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.83.tgz",
|
||||
"integrity": "sha512-6d+GOrR9ORe0a+ofwcJLWXbEP5VIurKxu4bWnOPpLMj5+rPDeTPgfGrmGwPrREQPTkFWpMb8VkEb7iP2ve3XzA==",
|
||||
"version": "3.1.84",
|
||||
"resolved": "https://registry.npmjs.org/@librechat/agents/-/agents-3.1.84.tgz",
|
||||
"integrity": "sha512-scAAdh11aHJlvVVACl6FaiGdz+UZDVkSUb99PZjNAql6BQ//I/pgzBrZ10Q4VNp65cMRYPI6GqAAGQb2MFCA/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.92.0",
|
||||
@@ -44658,7 +44658,7 @@
|
||||
"@azure/storage-blob": "^12.30.0",
|
||||
"@google/genai": "^1.19.0",
|
||||
"@keyv/redis": "^4.3.3",
|
||||
"@librechat/agents": "^3.1.83",
|
||||
"@librechat/agents": "^3.1.84",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@smithy/node-http-handler": "^4.4.5",
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
"@azure/storage-blob": "^12.30.0",
|
||||
"@google/genai": "^1.19.0",
|
||||
"@keyv/redis": "^4.3.3",
|
||||
"@librechat/agents": "^3.1.83",
|
||||
"@librechat/agents": "^3.1.84",
|
||||
"@librechat/data-schemas": "*",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@smithy/node-http-handler": "^4.4.5",
|
||||
|
||||
@@ -104,7 +104,7 @@ export interface ToolExecuteOptions {
|
||||
files: Array<{ fileId: string; filename: string }>;
|
||||
}>;
|
||||
/** Checks if a code env file is still active. Returns lastModified or null. */
|
||||
getSessionInfo?: (ref: CodeEnvRef) => Promise<string | null>;
|
||||
getSessionInfo?: (ref: CodeEnvRef, req?: ServerRequest) => Promise<string | null>;
|
||||
/** 23-hour freshness check */
|
||||
checkIfActive?: (dateString: string) => boolean;
|
||||
/** Persists `codeEnvRef` on skill files after upload */
|
||||
@@ -147,6 +147,7 @@ export interface ToolExecuteOptions {
|
||||
file_path: string;
|
||||
session_id?: string;
|
||||
files?: Array<{ id: string; name: string; session_id?: string }>;
|
||||
req?: ServerRequest;
|
||||
}) => Promise<{ content: string } | null>;
|
||||
}
|
||||
|
||||
@@ -332,6 +333,7 @@ async function handleSandboxFileFallback(
|
||||
tc: ToolCallRequest,
|
||||
filePath: string,
|
||||
options: ToolExecuteOptions,
|
||||
req?: ServerRequest,
|
||||
): Promise<ToolExecuteResult> {
|
||||
const ext = lowercaseExtension(filePath);
|
||||
if (BINARY_EXTENSIONS_NEVER_READABLE.has(ext)) {
|
||||
@@ -361,6 +363,7 @@ async function handleSandboxFileFallback(
|
||||
file_path: filePath,
|
||||
session_id: ctx?.session_id,
|
||||
files: ctx?.files,
|
||||
...(req ? { req } : {}),
|
||||
});
|
||||
if (!result || result.content == null) {
|
||||
return {
|
||||
@@ -450,7 +453,7 @@ async function handleReadFileCall(
|
||||
*/
|
||||
if (args.file_path.startsWith('/mnt/data/')) {
|
||||
if (codeEnvAvailable) {
|
||||
return handleSandboxFileFallback(tc, args.file_path, options);
|
||||
return handleSandboxFileFallback(tc, args.file_path, options, req);
|
||||
}
|
||||
return {
|
||||
toolCallId: tc.id,
|
||||
@@ -463,7 +466,7 @@ async function handleReadFileCall(
|
||||
const slashIdx = args.file_path.indexOf('/');
|
||||
if (slashIdx < 1) {
|
||||
if (codeEnvAvailable) {
|
||||
return handleSandboxFileFallback(tc, args.file_path, options);
|
||||
return handleSandboxFileFallback(tc, args.file_path, options, req);
|
||||
}
|
||||
return {
|
||||
toolCallId: tc.id,
|
||||
@@ -483,7 +486,7 @@ async function handleReadFileCall(
|
||||
* dead-ending with a skill-centric error message.
|
||||
*/
|
||||
if (codeEnvAvailable) {
|
||||
return handleSandboxFileFallback(tc, args.file_path, options);
|
||||
return handleSandboxFileFallback(tc, args.file_path, options, req);
|
||||
}
|
||||
return {
|
||||
toolCallId: tc.id,
|
||||
@@ -502,7 +505,7 @@ async function handleReadFileCall(
|
||||
*/
|
||||
if (!skillsEffectivelyEnabled) {
|
||||
if (codeEnvAvailable) {
|
||||
return handleSandboxFileFallback(tc, args.file_path, options);
|
||||
return handleSandboxFileFallback(tc, args.file_path, options, req);
|
||||
}
|
||||
return {
|
||||
toolCallId: tc.id,
|
||||
@@ -544,7 +547,7 @@ async function handleReadFileCall(
|
||||
const activeSkillNames = mergedConfigurable?.activeSkillNames as Set<string> | undefined;
|
||||
if (activeSkillNames && !activeSkillNames.has(skillName) && !isPrimedThisTurn) {
|
||||
if (codeEnvAvailable) {
|
||||
return handleSandboxFileFallback(tc, args.file_path, options);
|
||||
return handleSandboxFileFallback(tc, args.file_path, options, req);
|
||||
}
|
||||
return {
|
||||
toolCallId: tc.id,
|
||||
|
||||
@@ -274,17 +274,28 @@ describe('primeInvokedSkills — execute_code capability gate', () => {
|
||||
},
|
||||
]);
|
||||
const batchUploadCodeEnvFiles = jest.fn();
|
||||
const getSessionInfo = jest.fn().mockResolvedValue('2026-05-06T00:00:00Z');
|
||||
const deps = makeDeps({
|
||||
codeEnvAvailable: true,
|
||||
listSkillFiles,
|
||||
batchUploadCodeEnvFiles,
|
||||
getSessionInfo: jest.fn().mockResolvedValue('2026-05-06T00:00:00Z'),
|
||||
getSessionInfo,
|
||||
checkIfActive: jest.fn().mockReturnValue(true),
|
||||
});
|
||||
|
||||
const result = await primeInvokedSkills(deps);
|
||||
|
||||
expect(batchUploadCodeEnvFiles).not.toHaveBeenCalled();
|
||||
expect(getSessionInfo).toHaveBeenCalledWith(
|
||||
{
|
||||
kind: 'skill',
|
||||
id: SKILL_ID.toString(),
|
||||
storage_session_id: 'session-cached',
|
||||
file_id: 'file-cached',
|
||||
version: SKILL_VERSION,
|
||||
},
|
||||
deps.req,
|
||||
);
|
||||
const codeSession = result.initialSessions?.get('execute_code');
|
||||
expect(codeSession?.files).toEqual([
|
||||
{
|
||||
|
||||
@@ -51,7 +51,7 @@ export interface PrimeSkillFilesParams {
|
||||
files: Array<{ fileId: string; filename: string }>;
|
||||
}>;
|
||||
/** Checks if a code env file is still active. Returns lastModified timestamp or null. */
|
||||
getSessionInfo?: (ref: CodeEnvRef) => Promise<string | null>;
|
||||
getSessionInfo?: (ref: CodeEnvRef, req?: ServerRequest) => Promise<string | null>;
|
||||
/** 23-hour freshness check */
|
||||
checkIfActive?: (dateString: string) => boolean;
|
||||
/** Persists `codeEnvRef` on skill files after upload. Implementations
|
||||
@@ -128,7 +128,7 @@ export async function primeSkillFiles(
|
||||
try {
|
||||
const checkResults = await Promise.all(
|
||||
Array.from(refsBySession.values()).map(async (ref) => {
|
||||
const lastModified = await getSessionInfo(ref);
|
||||
const lastModified = await getSessionInfo(ref, req);
|
||||
return !!(lastModified && checkIfActive(lastModified));
|
||||
}),
|
||||
);
|
||||
@@ -421,7 +421,7 @@ export async function primeInvokedSkills(
|
||||
const checkResults = await Promise.all(
|
||||
Array.from(refsBySession.values()).map(async (ref) => {
|
||||
try {
|
||||
const lastModified = await deps.getSessionInfo?.(ref);
|
||||
const lastModified = await deps.getSessionInfo?.(ref, deps.req);
|
||||
return !!(lastModified && deps.checkIfActive?.(lastModified));
|
||||
} catch {
|
||||
return false;
|
||||
@@ -470,7 +470,7 @@ export async function primeInvokedSkills(
|
||||
// Per-skill upload: each skill gets its own storage session keyed
|
||||
// by `(kind: 'skill', id: skillId, version: skill.version)`.
|
||||
// primeSkillFiles handles freshness caching per-skill, so only
|
||||
// expired skills re-upload. CodeAPI handles mixed
|
||||
// expired skills re-upload. Code API handles mixed
|
||||
// storage_session_ids natively.
|
||||
const allPrimedFiles: Array<{
|
||||
id: string;
|
||||
|
||||
281
packages/api/src/auth/codeapi.spec.ts
Normal file
281
packages/api/src/auth/codeapi.spec.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { createHash, generateKeyPairSync, verify as cryptoVerify } from 'crypto';
|
||||
import type { KeyObject } from 'crypto';
|
||||
import type { ServerRequest } from '~/types';
|
||||
import { getCodeApiAuthHeaders, mintCodeApiToken } from './codeapi';
|
||||
|
||||
jest.mock(
|
||||
'@librechat/data-schemas',
|
||||
() => ({
|
||||
getTenantId: jest.fn(),
|
||||
}),
|
||||
{ virtual: true },
|
||||
);
|
||||
|
||||
jest.mock('~/utils', () => ({
|
||||
isEnabled: (value?: string) => value === 'true' || value === '1',
|
||||
}));
|
||||
|
||||
const mockGetTenantId = jest.requireMock('@librechat/data-schemas').getTenantId as jest.Mock;
|
||||
|
||||
const ENV_KEYS = [
|
||||
'CODEAPI_AUTH_PROVIDER',
|
||||
'CODEAPI_JWT_ENABLED',
|
||||
'CODEAPI_JWT_PRIVATE_KEY',
|
||||
'CODEAPI_JWT_PRIVATE_KEY_BASE64',
|
||||
'CODEAPI_JWT_PRIVATE_JWK_JSON',
|
||||
'CODEAPI_JWT_ALGORITHM',
|
||||
'CODEAPI_JWT_KID',
|
||||
'CODEAPI_JWT_KEY_ID',
|
||||
'CODEAPI_JWT_ISSUER',
|
||||
'CODEAPI_JWT_AUDIENCE',
|
||||
'CODEAPI_JWT_TTL_SECONDS',
|
||||
'CODEAPI_JWT_MINT_CACHE_SECONDS',
|
||||
'CODEAPI_JWT_SINGLE_TENANT_ID',
|
||||
'TENANT_ISOLATION_STRICT',
|
||||
'OPENID_REUSE_TOKENS',
|
||||
] as const;
|
||||
|
||||
type Claims = Record<string, unknown>;
|
||||
|
||||
function baseRequest(overrides: Record<string, unknown> = {}): ServerRequest {
|
||||
return {
|
||||
user: {
|
||||
_id: { toString: () => 'user_123' },
|
||||
tenantId: 'tenant_abc',
|
||||
role: 'USER',
|
||||
provider: 'local',
|
||||
...overrides,
|
||||
},
|
||||
body: { tenant_id: 'spoofed_tenant' },
|
||||
headers: { 'x-tenant-id': 'spoofed_header_tenant' },
|
||||
} as unknown as ServerRequest;
|
||||
}
|
||||
|
||||
function decodeToken(token: string): {
|
||||
header: Claims;
|
||||
claims: Claims;
|
||||
signingInput: string;
|
||||
signature: Buffer;
|
||||
} {
|
||||
const [header, payload, signature] = token.split('.') as [string, string, string];
|
||||
return {
|
||||
header: JSON.parse(Buffer.from(header, 'base64url').toString('utf8')) as Claims,
|
||||
claims: JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')) as Claims,
|
||||
signingInput: `${header}.${payload}`,
|
||||
signature: Buffer.from(signature, 'base64url'),
|
||||
};
|
||||
}
|
||||
|
||||
function expectedContextHash(input: {
|
||||
userId: string;
|
||||
tenantId: string;
|
||||
role: string;
|
||||
principalSource: string;
|
||||
orgId?: string;
|
||||
serviceId?: string;
|
||||
chcUserId?: string;
|
||||
}): string {
|
||||
return createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify({
|
||||
chc_user_id: input.chcUserId ?? '',
|
||||
org_id: input.orgId ?? '',
|
||||
principal_source: input.principalSource,
|
||||
role: input.role,
|
||||
service_id: input.serviceId ?? '',
|
||||
sub: input.userId,
|
||||
tenant_id: input.tenantId,
|
||||
}),
|
||||
)
|
||||
.digest('hex');
|
||||
}
|
||||
|
||||
describe('Code API JWT minting', () => {
|
||||
const originalEnv = new Map<string, string | undefined>();
|
||||
let publicKey: KeyObject;
|
||||
|
||||
beforeAll(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
originalEnv.set(key, process.env[key]);
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
const keyPair = generateKeyPairSync('ed25519');
|
||||
publicKey = keyPair.publicKey;
|
||||
process.env.CODEAPI_AUTH_PROVIDER = 'librechat-jwt';
|
||||
process.env.CODEAPI_JWT_PRIVATE_JWK_JSON = JSON.stringify(
|
||||
keyPair.privateKey.export({ format: 'jwk' }),
|
||||
);
|
||||
process.env.CODEAPI_JWT_ALGORITHM = 'EdDSA';
|
||||
process.env.CODEAPI_JWT_KID = 'test-kid';
|
||||
process.env.CODEAPI_JWT_ISSUER = 'librechat';
|
||||
process.env.CODEAPI_JWT_AUDIENCE = 'codeapi';
|
||||
process.env.CODEAPI_JWT_TTL_SECONDS = '300';
|
||||
process.env.CODEAPI_JWT_MINT_CACHE_SECONDS = '30';
|
||||
delete process.env.CODEAPI_JWT_PRIVATE_KEY;
|
||||
delete process.env.CODEAPI_JWT_PRIVATE_KEY_BASE64;
|
||||
delete process.env.CODEAPI_JWT_SINGLE_TENANT_ID;
|
||||
delete process.env.TENANT_ISOLATION_STRICT;
|
||||
delete process.env.OPENID_REUSE_TOKENS;
|
||||
mockGetTenantId.mockReset();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
for (const key of ENV_KEYS) {
|
||||
const value = originalEnv.get(key);
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('mints a Code API-scoped token from canonical LibreChat JWT context', async () => {
|
||||
jest.spyOn(Date, 'now').mockReturnValue(1_778_250_000_000);
|
||||
|
||||
const token = await mintCodeApiToken(baseRequest());
|
||||
const decoded = decodeToken(token);
|
||||
|
||||
expect(decoded.header).toEqual({
|
||||
alg: 'EdDSA',
|
||||
typ: 'JWT',
|
||||
kid: 'test-kid',
|
||||
});
|
||||
expect(
|
||||
cryptoVerify(null, Buffer.from(decoded.signingInput), publicKey, decoded.signature),
|
||||
).toBe(true);
|
||||
expect(decoded.claims).toMatchObject({
|
||||
iss: 'librechat',
|
||||
aud: 'codeapi',
|
||||
sub: 'user_123',
|
||||
iat: 1_778_250_000,
|
||||
nbf: 1_778_250_000,
|
||||
exp: 1_778_250_300,
|
||||
tenant_id: 'tenant_abc',
|
||||
role: 'USER',
|
||||
principal_source: 'librechat_jwt',
|
||||
});
|
||||
expect(decoded.claims.auth_context_hash).toBe(
|
||||
expectedContextHash({
|
||||
userId: 'user_123',
|
||||
tenantId: 'tenant_abc',
|
||||
role: 'USER',
|
||||
principalSource: 'librechat_jwt',
|
||||
}),
|
||||
);
|
||||
expect(decoded.claims).not.toHaveProperty('refresh_token');
|
||||
expect(decoded.claims).not.toHaveProperty('openid_token');
|
||||
});
|
||||
|
||||
it('marks OpenID reuse callers without forwarding upstream credentials', async () => {
|
||||
process.env.OPENID_REUSE_TOKENS = 'true';
|
||||
const req = baseRequest({
|
||||
provider: 'openid',
|
||||
idOnTheSource: 'chc_user_123',
|
||||
refreshToken: 'do-not-forward',
|
||||
accessToken: 'do-not-forward',
|
||||
});
|
||||
req.authStrategy = 'openidJwt';
|
||||
|
||||
const token = await mintCodeApiToken(req);
|
||||
const { claims } = decodeToken(token);
|
||||
|
||||
expect(claims).toMatchObject({
|
||||
principal_source: 'openid_reuse',
|
||||
chc_user_id: 'chc_user_123',
|
||||
});
|
||||
expect(JSON.stringify(claims)).not.toContain('do-not-forward');
|
||||
});
|
||||
|
||||
it('marks OpenID users authenticated by LibreChat JWT as LibreChat JWT callers', async () => {
|
||||
process.env.OPENID_REUSE_TOKENS = 'true';
|
||||
|
||||
const token = await mintCodeApiToken(baseRequest({ provider: 'openid' }));
|
||||
const { claims } = decodeToken(token);
|
||||
|
||||
expect(claims.principal_source).toBe('librechat_jwt');
|
||||
});
|
||||
|
||||
it('includes optional plan context when present without trusting caller input', async () => {
|
||||
const token = await mintCodeApiToken(
|
||||
baseRequest({
|
||||
subscription: { planId: 'prod_plan_123' },
|
||||
}),
|
||||
);
|
||||
const { claims } = decodeToken(token);
|
||||
|
||||
expect(claims.plan_id).toBe('prod_plan_123');
|
||||
expect(claims).not.toHaveProperty('planId');
|
||||
});
|
||||
|
||||
it('uses the single-tenant namespace when tenant context is absent outside strict mode', async () => {
|
||||
mockGetTenantId.mockReturnValue(undefined);
|
||||
|
||||
const token = await mintCodeApiToken(baseRequest({ tenantId: undefined }));
|
||||
const { claims } = decodeToken(token);
|
||||
|
||||
expect(claims.tenant_id).toBe('legacy');
|
||||
expect(claims.auth_context_hash).toBe(
|
||||
expectedContextHash({
|
||||
userId: 'user_123',
|
||||
tenantId: 'legacy',
|
||||
role: 'USER',
|
||||
principalSource: 'librechat_jwt',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('supports overriding the single-tenant namespace for local deployments', async () => {
|
||||
process.env.CODEAPI_JWT_SINGLE_TENANT_ID = 'local-single-tenant';
|
||||
mockGetTenantId.mockReturnValue(undefined);
|
||||
|
||||
const token = await mintCodeApiToken(baseRequest({ tenantId: undefined }));
|
||||
const { claims } = decodeToken(token);
|
||||
|
||||
expect(claims.tenant_id).toBe('local-single-tenant');
|
||||
});
|
||||
|
||||
it('rejects minting without tenant context in strict tenant mode', async () => {
|
||||
process.env.TENANT_ISOLATION_STRICT = 'true';
|
||||
mockGetTenantId.mockReturnValue(undefined);
|
||||
|
||||
await expect(mintCodeApiToken(baseRequest({ tenantId: undefined }))).rejects.toThrow(
|
||||
'Code API JWT auth requires tenant context',
|
||||
);
|
||||
});
|
||||
|
||||
it('ignores caller-supplied tenant spoofing fields', async () => {
|
||||
const token = await mintCodeApiToken(baseRequest({ tenantId: 'tenant_canonical' }));
|
||||
const { claims } = decodeToken(token);
|
||||
|
||||
expect(claims.tenant_id).toBe('tenant_canonical');
|
||||
expect(JSON.stringify(claims)).not.toContain('spoofed_tenant');
|
||||
expect(JSON.stringify(claims)).not.toContain('spoofed_header_tenant');
|
||||
});
|
||||
|
||||
it('caches minted tokens for at most the configured 30 second window', async () => {
|
||||
const now = jest.spyOn(Date, 'now').mockReturnValue(1_778_250_000_000);
|
||||
const req = baseRequest();
|
||||
|
||||
const first = await mintCodeApiToken(req);
|
||||
const second = await mintCodeApiToken(req);
|
||||
now.mockReturnValue(1_778_250_031_000);
|
||||
const afterCacheWindow = await mintCodeApiToken(req);
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(afterCacheWindow).not.toBe(first);
|
||||
});
|
||||
|
||||
it('returns Authorization headers only when a request and managed auth are present', async () => {
|
||||
await expect(getCodeApiAuthHeaders()).resolves.toEqual({});
|
||||
await expect(getCodeApiAuthHeaders(baseRequest())).resolves.toEqual({
|
||||
Authorization: expect.stringMatching(/^Bearer /),
|
||||
});
|
||||
|
||||
process.env.CODEAPI_AUTH_PROVIDER = 'legacy-api-key';
|
||||
delete process.env.CODEAPI_JWT_ENABLED;
|
||||
await expect(getCodeApiAuthHeaders(baseRequest())).resolves.toEqual({});
|
||||
});
|
||||
});
|
||||
380
packages/api/src/auth/codeapi.ts
Normal file
380
packages/api/src/auth/codeapi.ts
Normal file
@@ -0,0 +1,380 @@
|
||||
import { getTenantId } from '@librechat/data-schemas';
|
||||
import { createHash, createPrivateKey, randomUUID, sign as cryptoSign } from 'crypto';
|
||||
import type { KeyObject, JsonWebKey } from 'crypto';
|
||||
import type { ServerRequest } from '~/types';
|
||||
import { isEnabled } from '~/utils';
|
||||
|
||||
type CodeApiJwtAlg = 'EdDSA' | 'RS256';
|
||||
type PrincipalSource = 'librechat_jwt' | 'openid_reuse';
|
||||
|
||||
interface CodeApiUserContext {
|
||||
id?: string;
|
||||
_id?: { toString(): string };
|
||||
role?: string;
|
||||
tenantId?: string | { toString(): string };
|
||||
provider?: string;
|
||||
orgId?: string;
|
||||
serviceId?: string;
|
||||
chcUserId?: string;
|
||||
idOnTheSource?: string;
|
||||
planId?: string;
|
||||
subscription?: {
|
||||
planId?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface CodeApiClaims {
|
||||
iss: string;
|
||||
aud: string;
|
||||
sub: string;
|
||||
iat: number;
|
||||
nbf: number;
|
||||
exp: number;
|
||||
jti: string;
|
||||
tenant_id: string;
|
||||
role: string;
|
||||
principal_source: PrincipalSource;
|
||||
org_id?: string;
|
||||
service_id?: string;
|
||||
chc_user_id?: string;
|
||||
plan_id?: string;
|
||||
auth_context_hash: string;
|
||||
}
|
||||
|
||||
interface SigningConfig {
|
||||
alg: CodeApiJwtAlg;
|
||||
kid: string;
|
||||
issuer: string;
|
||||
audience: string;
|
||||
ttlSeconds: number;
|
||||
cacheSeconds: number;
|
||||
key: KeyObject;
|
||||
rawKey: string;
|
||||
}
|
||||
|
||||
interface CachedToken {
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
cachedUntil: number;
|
||||
}
|
||||
|
||||
const DEFAULT_ISSUER = 'librechat';
|
||||
const DEFAULT_AUDIENCE = 'codeapi';
|
||||
const DEFAULT_KID = 'lc-codeapi-2026-05';
|
||||
const DEFAULT_SINGLE_TENANT_ID = 'legacy';
|
||||
const DEFAULT_TTL_SECONDS = 300;
|
||||
const DEFAULT_CACHE_SECONDS = 30;
|
||||
const MAX_TTL_SECONDS = 300;
|
||||
const MAX_CACHE_SECONDS = 30;
|
||||
const TOKEN_REUSE_SAFETY_WINDOW_SECONDS = 30;
|
||||
const TOKEN_CACHE_PRUNE_INTERVAL_SECONDS = 30;
|
||||
|
||||
let signingConfigCache: SigningConfig | null = null;
|
||||
const tokenCache = new Map<string, CachedToken>();
|
||||
let tokenCacheLastPrunedAt = 0;
|
||||
|
||||
function base64Url(value: Buffer | string): string {
|
||||
return Buffer.from(value).toString('base64url');
|
||||
}
|
||||
|
||||
function normalizePem(value: string): string {
|
||||
return value.replace(/\\n/g, '\n').trim();
|
||||
}
|
||||
|
||||
function getPrivateKeyRaw(): string {
|
||||
const inlineKey = process.env.CODEAPI_JWT_PRIVATE_KEY;
|
||||
if (inlineKey != null && inlineKey.trim() !== '') {
|
||||
return normalizePem(inlineKey);
|
||||
}
|
||||
const base64Key = process.env.CODEAPI_JWT_PRIVATE_KEY_BASE64;
|
||||
if (base64Key != null && base64Key.trim() !== '') {
|
||||
return normalizePem(Buffer.from(base64Key, 'base64').toString('utf8'));
|
||||
}
|
||||
const jwkJson = process.env.CODEAPI_JWT_PRIVATE_JWK_JSON;
|
||||
if (jwkJson != null && jwkJson.trim() !== '') {
|
||||
return jwkJson.trim();
|
||||
}
|
||||
throw new Error('Code API JWT signing key is not configured');
|
||||
}
|
||||
|
||||
function parseAlg(value: string | undefined): CodeApiJwtAlg {
|
||||
if (value === 'RS256') {
|
||||
return 'RS256';
|
||||
}
|
||||
if (value === undefined || value === '' || value === 'EdDSA') {
|
||||
return 'EdDSA';
|
||||
}
|
||||
throw new Error(`Unsupported Code API JWT algorithm: ${value}`);
|
||||
}
|
||||
|
||||
function parseCappedSeconds(value: string | undefined, fallback: number, max: number): number {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return fallback;
|
||||
}
|
||||
return Math.min(Math.floor(parsed), max);
|
||||
}
|
||||
|
||||
function createSigningKey(rawKey: string): KeyObject {
|
||||
if (rawKey.startsWith('{')) {
|
||||
return createPrivateKey({ key: JSON.parse(rawKey) as JsonWebKey, format: 'jwk' });
|
||||
}
|
||||
return createPrivateKey(rawKey);
|
||||
}
|
||||
|
||||
function getSigningConfig(): SigningConfig {
|
||||
const rawKey = getPrivateKeyRaw();
|
||||
const alg = parseAlg(process.env.CODEAPI_JWT_ALGORITHM);
|
||||
const kid = process.env.CODEAPI_JWT_KID ?? process.env.CODEAPI_JWT_KEY_ID ?? DEFAULT_KID;
|
||||
const issuer = process.env.CODEAPI_JWT_ISSUER ?? DEFAULT_ISSUER;
|
||||
const audience = process.env.CODEAPI_JWT_AUDIENCE ?? DEFAULT_AUDIENCE;
|
||||
const ttlSeconds = parseCappedSeconds(
|
||||
process.env.CODEAPI_JWT_TTL_SECONDS,
|
||||
DEFAULT_TTL_SECONDS,
|
||||
MAX_TTL_SECONDS,
|
||||
);
|
||||
const cacheSeconds = parseCappedSeconds(
|
||||
process.env.CODEAPI_JWT_MINT_CACHE_SECONDS,
|
||||
DEFAULT_CACHE_SECONDS,
|
||||
MAX_CACHE_SECONDS,
|
||||
);
|
||||
|
||||
if (
|
||||
signingConfigCache &&
|
||||
signingConfigCache.rawKey === rawKey &&
|
||||
signingConfigCache.alg === alg &&
|
||||
signingConfigCache.kid === kid &&
|
||||
signingConfigCache.issuer === issuer &&
|
||||
signingConfigCache.audience === audience &&
|
||||
signingConfigCache.ttlSeconds === ttlSeconds &&
|
||||
signingConfigCache.cacheSeconds === cacheSeconds
|
||||
) {
|
||||
return signingConfigCache;
|
||||
}
|
||||
|
||||
signingConfigCache = {
|
||||
alg,
|
||||
kid,
|
||||
issuer,
|
||||
audience,
|
||||
ttlSeconds,
|
||||
cacheSeconds,
|
||||
rawKey,
|
||||
key: createSigningKey(rawKey),
|
||||
};
|
||||
tokenCache.clear();
|
||||
tokenCacheLastPrunedAt = 0;
|
||||
return signingConfigCache;
|
||||
}
|
||||
|
||||
function stringifyClaimValue(value: unknown): string | undefined {
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
return value;
|
||||
}
|
||||
if (value && typeof value === 'object' && 'toString' in value) {
|
||||
const stringValue = value.toString();
|
||||
return stringValue.trim() === '' ? undefined : stringValue;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveUser(req: ServerRequest): CodeApiUserContext {
|
||||
const user = req.user as CodeApiUserContext | undefined;
|
||||
if (!user) {
|
||||
throw new Error('Code API token minting requires an authenticated user');
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
function resolveUserId(user: CodeApiUserContext): string {
|
||||
const userId = stringifyClaimValue(user.id) ?? stringifyClaimValue(user._id);
|
||||
if (!userId) {
|
||||
throw new Error('Code API token minting requires a canonical user id');
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
function resolveSingleTenantId(): string {
|
||||
const configured = process.env.CODEAPI_JWT_SINGLE_TENANT_ID;
|
||||
if (configured != null && configured.trim() !== '') {
|
||||
return configured.trim();
|
||||
}
|
||||
return DEFAULT_SINGLE_TENANT_ID;
|
||||
}
|
||||
|
||||
function resolveTenantId(user: CodeApiUserContext): string | undefined {
|
||||
const tenantId = stringifyClaimValue(user.tenantId) ?? getTenantId();
|
||||
if (tenantId) {
|
||||
return tenantId;
|
||||
}
|
||||
if (isEnabled(process.env.TENANT_ISOLATION_STRICT)) {
|
||||
return undefined;
|
||||
}
|
||||
return resolveSingleTenantId();
|
||||
}
|
||||
|
||||
function isManagedCodeApiJwtMode(): boolean {
|
||||
const provider = process.env.CODEAPI_AUTH_PROVIDER;
|
||||
return provider === 'librechat-jwt' || provider === 'both';
|
||||
}
|
||||
|
||||
export function isCodeApiJwtAuthEnabled(): boolean {
|
||||
return isManagedCodeApiJwtMode() || isEnabled(process.env.CODEAPI_JWT_ENABLED);
|
||||
}
|
||||
|
||||
function resolvePrincipalSource(req: ServerRequest): PrincipalSource {
|
||||
if (req.authStrategy === 'openidJwt') {
|
||||
return 'openid_reuse';
|
||||
}
|
||||
return 'librechat_jwt';
|
||||
}
|
||||
|
||||
function canonicalContextHash(input: {
|
||||
userId: string;
|
||||
tenantId: string;
|
||||
role: string;
|
||||
principalSource: PrincipalSource;
|
||||
orgId?: string;
|
||||
serviceId?: string;
|
||||
chcUserId?: string;
|
||||
}): string {
|
||||
const canonical = {
|
||||
chc_user_id: input.chcUserId ?? '',
|
||||
org_id: input.orgId ?? '',
|
||||
principal_source: input.principalSource,
|
||||
role: input.role,
|
||||
service_id: input.serviceId ?? '',
|
||||
sub: input.userId,
|
||||
tenant_id: input.tenantId,
|
||||
};
|
||||
return createHash('sha256').update(JSON.stringify(canonical)).digest('hex');
|
||||
}
|
||||
|
||||
function buildClaims(req: ServerRequest, config: SigningConfig, now: number): CodeApiClaims {
|
||||
const user = resolveUser(req);
|
||||
const userId = resolveUserId(user);
|
||||
const tenantId = resolveTenantId(user);
|
||||
if (!tenantId) {
|
||||
throw new Error('Code API JWT auth requires tenant context');
|
||||
}
|
||||
|
||||
const role = user.role ?? 'USER';
|
||||
const principalSource = resolvePrincipalSource(req);
|
||||
const orgId = stringifyClaimValue(user.orgId);
|
||||
const serviceId = stringifyClaimValue(user.serviceId);
|
||||
const chcUserId = stringifyClaimValue(user.chcUserId) ?? stringifyClaimValue(user.idOnTheSource);
|
||||
const planId = stringifyClaimValue(user.planId) ?? stringifyClaimValue(user.subscription?.planId);
|
||||
const authContextHash = canonicalContextHash({
|
||||
userId,
|
||||
tenantId,
|
||||
role,
|
||||
principalSource,
|
||||
orgId,
|
||||
serviceId,
|
||||
chcUserId,
|
||||
});
|
||||
|
||||
return {
|
||||
iss: config.issuer,
|
||||
aud: config.audience,
|
||||
sub: userId,
|
||||
iat: now,
|
||||
nbf: now,
|
||||
exp: now + config.ttlSeconds,
|
||||
jti: randomUUID(),
|
||||
tenant_id: tenantId,
|
||||
role,
|
||||
principal_source: principalSource,
|
||||
...(orgId ? { org_id: orgId } : {}),
|
||||
...(serviceId ? { service_id: serviceId } : {}),
|
||||
...(chcUserId ? { chc_user_id: chcUserId } : {}),
|
||||
...(planId ? { plan_id: planId } : {}),
|
||||
auth_context_hash: authContextHash,
|
||||
};
|
||||
}
|
||||
|
||||
function signJwt(config: SigningConfig, claims: CodeApiClaims): string {
|
||||
const header = {
|
||||
alg: config.alg,
|
||||
typ: 'JWT',
|
||||
kid: config.kid,
|
||||
};
|
||||
const signingInput = `${base64Url(JSON.stringify(header))}.${base64Url(JSON.stringify(claims))}`;
|
||||
const signature = cryptoSign(
|
||||
config.alg === 'RS256' ? 'RSA-SHA256' : null,
|
||||
Buffer.from(signingInput),
|
||||
config.key,
|
||||
);
|
||||
return `${signingInput}.${base64Url(signature)}`;
|
||||
}
|
||||
|
||||
function cacheKey(config: SigningConfig, claims: CodeApiClaims): string {
|
||||
return [
|
||||
config.alg,
|
||||
config.kid,
|
||||
claims.sub,
|
||||
claims.tenant_id,
|
||||
claims.role,
|
||||
claims.principal_source,
|
||||
claims.org_id ?? '',
|
||||
claims.service_id ?? '',
|
||||
claims.chc_user_id ?? '',
|
||||
claims.plan_id ?? '',
|
||||
claims.auth_context_hash,
|
||||
].join(':');
|
||||
}
|
||||
|
||||
function pruneTokenCache(now: number): void {
|
||||
if (tokenCache.size === 0) {
|
||||
return;
|
||||
}
|
||||
if (now - tokenCacheLastPrunedAt < TOKEN_CACHE_PRUNE_INTERVAL_SECONDS) {
|
||||
return;
|
||||
}
|
||||
|
||||
tokenCacheLastPrunedAt = now;
|
||||
for (const [key, cached] of tokenCache) {
|
||||
if (cached.cachedUntil <= now || cached.expiresAt <= now + TOKEN_REUSE_SAFETY_WINDOW_SECONDS) {
|
||||
tokenCache.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function mintCodeApiToken(req: ServerRequest): Promise<string> {
|
||||
if (!isCodeApiJwtAuthEnabled()) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const config = getSigningConfig();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
pruneTokenCache(now);
|
||||
const claims = buildClaims(req, config, now);
|
||||
const key = cacheKey(config, claims);
|
||||
const cached = tokenCache.get(key);
|
||||
if (
|
||||
cached &&
|
||||
cached.cachedUntil > now &&
|
||||
cached.expiresAt > now + TOKEN_REUSE_SAFETY_WINDOW_SECONDS
|
||||
) {
|
||||
return cached.token;
|
||||
}
|
||||
|
||||
const token = signJwt(config, claims);
|
||||
tokenCache.set(key, {
|
||||
token,
|
||||
expiresAt: claims.exp,
|
||||
cachedUntil: Math.min(
|
||||
now + config.cacheSeconds,
|
||||
claims.exp - TOKEN_REUSE_SAFETY_WINDOW_SECONDS,
|
||||
),
|
||||
});
|
||||
return token;
|
||||
}
|
||||
|
||||
export async function getCodeApiAuthHeaders(req?: ServerRequest): Promise<Record<string, string>> {
|
||||
if (!req || !isCodeApiJwtAuthEnabled()) {
|
||||
return {};
|
||||
}
|
||||
const token = await mintCodeApiToken(req);
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
@@ -5,3 +5,4 @@ export * from './refresh';
|
||||
export * from './agent';
|
||||
export * from './password';
|
||||
export * from './invite';
|
||||
export * from './codeapi';
|
||||
|
||||
@@ -187,6 +187,8 @@ export interface BuildToolClassificationParams {
|
||||
deferredToolsEnabled?: boolean;
|
||||
/** When true, skip creating tool instances (for event-driven mode) */
|
||||
definitionsOnly?: boolean;
|
||||
/** Optional host-supplied Code API auth headers for remote programmatic execution. */
|
||||
authHeaders?: () => Promise<Record<string, string>> | Record<string, string>;
|
||||
}
|
||||
|
||||
/** Result from building tool classification */
|
||||
@@ -251,6 +253,7 @@ export async function buildToolClassification(
|
||||
agentToolOptions,
|
||||
definitionsOnly = false,
|
||||
deferredToolsEnabled = true,
|
||||
authHeaders,
|
||||
} = params;
|
||||
const additionalTools: GenericTool[] = [];
|
||||
|
||||
@@ -345,7 +348,9 @@ export async function buildToolClassification(
|
||||
}
|
||||
|
||||
try {
|
||||
const ptcTool = createProgrammaticToolCallingTool({});
|
||||
const ptcTool = createProgrammaticToolCallingTool({ authHeaders } as Parameters<
|
||||
typeof createProgrammaticToolCallingTool
|
||||
>[0] & { authHeaders?: BuildToolClassificationParams['authHeaders'] });
|
||||
additionalTools.push(ptcTool);
|
||||
|
||||
/** Add PTC definition for event-driven mode */
|
||||
|
||||
@@ -25,4 +25,6 @@ export type ServerRequest = Request<unknown, unknown, RequestBody> & {
|
||||
conversationCreatedAt?: string;
|
||||
/** Conversation loaded while resolving the prompt timestamp anchor, reused by save logic. */
|
||||
resolvedConversation?: Partial<TConversation> | null;
|
||||
/** Passport strategy that populated req.user for this request. */
|
||||
authStrategy?: string;
|
||||
};
|
||||
|
||||
@@ -5,14 +5,14 @@
|
||||
* both at once.
|
||||
*
|
||||
* - `skill`: shared per skill identity. Cross-user-within-tenant
|
||||
* sharing. CodeAPI sessionKey omits the user dimension.
|
||||
* sharing. Code API sessionKey omits the user dimension.
|
||||
* `version` is required (the skill's monotonic counter scopes the
|
||||
* cache per revision so any edit invalidates the prior cache
|
||||
* entry naturally).
|
||||
* - `agent`: shared per agent identity. Same sharing semantic as
|
||||
* skills (agents are addressable resources accessible to a
|
||||
* permission-defined audience).
|
||||
* - `user`: user-private. CodeAPI sessionKey is keyed by the
|
||||
* - `user`: user-private. Code API sessionKey is keyed by the
|
||||
* requesting user from auth context. Used for chat attachments
|
||||
* and code-output artifacts.
|
||||
*/
|
||||
@@ -30,7 +30,7 @@ export type CodeEnvKind = (typeof CODE_ENV_KINDS)[number];
|
||||
* sandbox-run session.
|
||||
*
|
||||
* `kind` and `id` together name the resource that owns this file's
|
||||
* storage session. CodeAPI uses them (plus the auth-context tenant
|
||||
* storage session. Code API uses them (plus the auth-context tenant
|
||||
* id) to derive the sessionKey, which determines who shares the
|
||||
* cache. Cross-user sharing for shared resources (skills, agents) is
|
||||
* a designed property of the kind switch, not an emergent side
|
||||
|
||||
Reference in New Issue
Block a user