mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-05-14 00:19:40 +00:00
* 🛂 fix: Skip Re-Download of Inherited Code-Env Files (No More 403 Storms) When a bash/code-interpreter call lists or operates on inputs the user already owns (skill files primed via primeInvokedSkills, files inherited from a prior session), codeapi echoes those files back in the tool result with `inherited: true`. We were treating every entry as a generated artifact and calling processCodeOutput on each, which: 1. Hit `/api/files/code/download/<session_id>/<file_id>` with the user's session key. Skill files are uploaded under the skill's entity_id, so every download 403'd — producing dozens of "Unauthorized download" log lines per turn. 2. Surfaced those inputs as ghost file chips in the UI even though they were never generated by the run. 3. Wasted a download round-trip even when no auth boundary was crossed — the file is already persisted at its origin. Fix: skip files where `file.inherited === true` in all three artifact-files loops (`tools.js`, `createToolEndCallback`, and `createResponsesToolEndCallback`). Skill files remain available to subsequent calls via primeInvokedSkills / session inheritance — we just don't redundantly re-download them. Pairs with codeapi-side change that adds the `inherited` flag. * 🔒 feat: Mark Skill Files as `read_only` During Code-Env Priming Pairs with codeapi `read_only` upload flag (ClickHouse/ai#1345). When LibreChat primes a skill into the code-env, every file in the batch (SKILL.md plus all bundled scripts/schemas/docs) is now uploaded with `read_only: true`. Codeapi seals these inputs at the filesystem layer (chmod 444) and the walker echoes the original refs as `inherited: true` regardless of whether sandboxed code modified the bytes on disk. Without this, the previous PR's `inherited` skip handled only the unchanged case. A modified skill file (pip writing pyc near a .py, a script accidentally truncating LICENSE.txt, etc.) still flowed through the modified-input branch on codeapi, got a fresh user-owned file_id, uploaded as a "generated" artifact, and surfaced in the UI as a chip the user couldn't actually authorize a download for. Changes: - `api/server/services/Files/Code/crud.js`: `batchUploadCodeEnvFiles({ ..., read_only })` forwards the flag as a multipart form field. Default `false` preserves existing behavior for user-attached files and prior-session inheritance. - `packages/api/src/agents/skillFiles.ts`: type signature gains `read_only?: boolean`; `primeSkillFiles` passes `true`. - `packages/api/src/agents/skillFiles.spec.ts`: assert the upload call carries `read_only: true`. The flag is intentionally not skill-specific. Any future infrastructure-input flow (system fixtures, cached datasets, etc.) can opt in the same way.
192 lines
6.6 KiB
JavaScript
192 lines
6.6 KiB
JavaScript
const FormData = require('form-data');
|
|
const { logger } = require('@librechat/data-schemas');
|
|
const { getCodeBaseURL } = require('@librechat/agents');
|
|
const {
|
|
logAxiosError,
|
|
createAxiosInstance,
|
|
codeServerHttpAgent,
|
|
codeServerHttpsAgent,
|
|
} = require('@librechat/api');
|
|
|
|
const axios = createAxiosInstance();
|
|
|
|
const MAX_FILE_SIZE = 150 * 1024 * 1024;
|
|
|
|
/**
|
|
* Retrieves a download stream for a specified file.
|
|
* @param {string} fileIdentifier - The identifier for the file (e.g., "session_id/fileId").
|
|
* @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) {
|
|
try {
|
|
const baseURL = getCodeBaseURL();
|
|
/** @type {import('axios').AxiosRequestConfig} */
|
|
const options = {
|
|
method: 'get',
|
|
url: `${baseURL}/download/${fileIdentifier}`,
|
|
responseType: 'stream',
|
|
headers: {
|
|
'User-Agent': 'LibreChat/1.0',
|
|
},
|
|
httpAgent: codeServerHttpAgent,
|
|
httpsAgent: codeServerHttpsAgent,
|
|
timeout: 15000,
|
|
};
|
|
|
|
const response = await axios(options);
|
|
return response;
|
|
} catch (error) {
|
|
throw new Error(
|
|
logAxiosError({
|
|
message: `Error downloading code environment file stream: ${error.message}`,
|
|
error,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Uploads a file to the Code Environment server.
|
|
* @param {Object} params - The params object.
|
|
* @param {ServerRequest} params.req - The request object from Express. It should have a `user` property with an `id` representing the user
|
|
* @param {import('fs').ReadStream | import('stream').Readable} params.stream - The read stream for the file.
|
|
* @param {string} params.filename - The name of the file.
|
|
* @param {string} [params.entity_id] - Optional entity ID for the file.
|
|
* @returns {Promise<string>}
|
|
* @throws {Error} If there's an error during the upload process.
|
|
*/
|
|
async function uploadCodeEnvFile({ req, stream, filename, entity_id = '' }) {
|
|
try {
|
|
const form = new FormData();
|
|
if (entity_id.length > 0) {
|
|
form.append('entity_id', entity_id);
|
|
}
|
|
form.append('file', stream, filename);
|
|
|
|
const baseURL = getCodeBaseURL();
|
|
/** @type {import('axios').AxiosRequestConfig} */
|
|
const options = {
|
|
headers: {
|
|
...form.getHeaders(),
|
|
'Content-Type': 'multipart/form-data',
|
|
'User-Agent': 'LibreChat/1.0',
|
|
'User-Id': req.user.id,
|
|
},
|
|
httpAgent: codeServerHttpAgent,
|
|
httpsAgent: codeServerHttpsAgent,
|
|
timeout: 120000,
|
|
maxContentLength: MAX_FILE_SIZE,
|
|
maxBodyLength: MAX_FILE_SIZE,
|
|
};
|
|
|
|
const response = await axios.post(`${baseURL}/upload`, form, options);
|
|
|
|
/** @type {{ message: string; session_id: string; files: Array<{ fileId: string; filename: string }> }} */
|
|
const result = response.data;
|
|
if (result.message !== 'success') {
|
|
throw new Error(`Error uploading file: ${result.message}`);
|
|
}
|
|
|
|
const fileIdentifier = `${result.session_id}/${result.files[0].fileId}`;
|
|
if (entity_id.length === 0) {
|
|
return fileIdentifier;
|
|
}
|
|
|
|
return `${fileIdentifier}?entity_id=${entity_id}`;
|
|
} catch (error) {
|
|
throw new Error(
|
|
logAxiosError({
|
|
message: `Error uploading code environment file: ${error.message}`,
|
|
error,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Uploads multiple files to the code execution environment in a single request.
|
|
* Uses the /upload/batch endpoint which shares one session_id across all files.
|
|
*
|
|
* @param {object} params
|
|
* @param {import('express').Request & { user: { id: string } }} params.req - The request object.
|
|
* @param {Array<{ stream: NodeJS.ReadableStream; filename: string }>} params.files - Files to upload.
|
|
* @param {string} [params.entity_id] - Optional entity ID.
|
|
* @param {boolean} [params.read_only] - When true, codeapi tags every file in
|
|
* the batch as infrastructure (e.g. skill files). The flag is persisted as
|
|
* MinIO object metadata (`X-Amz-Meta-Read-Only`) and travels with the file
|
|
* through subsequent download/walk passes — sandboxed-code modifications
|
|
* are dropped on the floor and the original ref is echoed back as
|
|
* `inherited: true`, never as a generated artifact.
|
|
* @returns {Promise<{ session_id: string; files: Array<{ fileId: string; filename: string }> }>}
|
|
* @throws {Error} If the batch upload fails entirely.
|
|
*/
|
|
async function batchUploadCodeEnvFiles({ req, files, entity_id = '', read_only = false }) {
|
|
try {
|
|
const form = new FormData();
|
|
if (entity_id.length > 0) {
|
|
form.append('entity_id', entity_id);
|
|
}
|
|
if (read_only) {
|
|
form.append('read_only', 'true');
|
|
}
|
|
for (const file of files) {
|
|
form.append('file', file.stream, file.filename);
|
|
}
|
|
|
|
const baseURL = getCodeBaseURL();
|
|
/** @type {import('axios').AxiosRequestConfig} */
|
|
const options = {
|
|
headers: {
|
|
...form.getHeaders(),
|
|
'Content-Type': 'multipart/form-data',
|
|
'User-Agent': 'LibreChat/1.0',
|
|
'User-Id': req.user.id,
|
|
},
|
|
httpAgent: codeServerHttpAgent,
|
|
httpsAgent: codeServerHttpsAgent,
|
|
timeout: 120000,
|
|
maxContentLength: MAX_FILE_SIZE,
|
|
maxBodyLength: MAX_FILE_SIZE,
|
|
};
|
|
|
|
const response = await axios.post(`${baseURL}/upload/batch`, form, options);
|
|
|
|
/** @type {{ message: string; session_id: string; files: Array<{ status: string; fileId?: string; filename: string; error?: string }>; succeeded: number; failed: number }} */
|
|
const result = response.data;
|
|
if (
|
|
!result ||
|
|
typeof result !== 'object' ||
|
|
!result.session_id ||
|
|
!Array.isArray(result.files)
|
|
) {
|
|
throw new Error(`Unexpected batch upload response: ${JSON.stringify(result).slice(0, 200)}`);
|
|
}
|
|
if (result.message === 'error') {
|
|
throw new Error('All files in batch upload failed');
|
|
}
|
|
|
|
if (result.failed > 0) {
|
|
const failedNames = result.files
|
|
.filter((f) => f.status === 'error')
|
|
.map((f) => `${f.filename}: ${f.error || 'unknown'}`)
|
|
.join(', ');
|
|
logger.warn(`[batchUploadCodeEnvFiles] ${result.failed} file(s) failed: ${failedNames}`);
|
|
}
|
|
|
|
const successFiles = result.files
|
|
.filter((f) => f.status === 'success' && f.fileId)
|
|
.map((f) => ({ fileId: f.fileId, filename: f.filename }));
|
|
|
|
return { session_id: result.session_id, files: successFiles };
|
|
} catch (error) {
|
|
throw new Error(
|
|
logAxiosError({
|
|
message: `Error in batch upload to code environment: ${error instanceof Error ? error.message : String(error)}`,
|
|
error,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
module.exports = { getCodeOutputDownloadStream, uploadCodeEnvFile, batchUploadCodeEnvFiles };
|