Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
1ecd4d7
🧱 refactor: typed CodeEnvRef + kind discriminator + tenant-aware sand…
danny-avila May 7, 2026
133e72d
🩹 fix: persist execute_code uploads under codeEnvRef metadata key
danny-avila May 7, 2026
7da6c0d
🩹 fix: read storage_session_id on primed file refs (Codex P1)
danny-avila May 7, 2026
18233ae
πŸ“¦ chore: Bump `@librechat/agents` to version 3.1.80-dev.0 in package-…
danny-avila May 7, 2026
e72989b
πŸͺͺ fix: thread kind/id/version through codeapi /download URLs (Phase C Ξ±)
danny-avila May 7, 2026
c5b0f4c
♻️ refactor: extract codeEnv identity helpers into packages/api
danny-avila May 7, 2026
73d2cb6
πŸͺͺ fix: emit resource_id alongside id on _injected_files (skill 403 fix)
danny-avila May 7, 2026
5dcdb60
fix(skill-tool-call): carry resource_id through primeSkillFiles β†’ art…
danny-avila May 8, 2026
94552b0
fix(handlers.spec): align session_id β†’ storage_session_id rename + ki…
danny-avila May 8, 2026
60ae8a0
chore: fix `@librechat/agents`, correct version to 3.1.80-dev.0 in pa…
danny-avila May 8, 2026
34027dd
chore: bump `@librechat/agents` to version 3.1.80-dev.1 in package.js…
danny-avila May 8, 2026
ad64c1f
chore: bump `@librechat/agents` to version 3.1.80-dev.2
danny-avila May 8, 2026
555aff6
feat(observability): trace file priming chain from primeCodeFiles to …
danny-avila May 8, 2026
3d344f5
chore: add CONSOLE_JSON_STRING_LENGTH to .env.example for JSON log st…
danny-avila May 8, 2026
e483017
fix(files): align codeapi upload filename with LC's sanitized DB file…
danny-avila May 8, 2026
8c60200
chore: bump `@librechat/agents` to version 3.1.80-dev.3 in package.js…
danny-avila May 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ TRUST_PROXY=1
# Use when process console logs in cloud deployment like GCP/AWS
CONSOLE_JSON=false

# The maximum length of a string in a JSON log object.
# Default: 255
# CONSOLE_JSON_STRING_LENGTH=255

#===============#
# Debug Logging #
#===============#
Expand Down
6 changes: 5 additions & 1 deletion api/app/clients/BaseClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -1234,7 +1234,11 @@ class BaseClient {
allFiles.push(file);
continue;
}
if (file.embedded === true || file.metadata?.fileIdentifier != null) {
if (
file.embedded === true ||
file.metadata?.codeEnvRef != null ||
file.metadata?.fileIdentifier != null
) {
allFiles.push(file);
continue;
}
Expand Down
2 changes: 1 addition & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@
"@azure/storage-blob": "^12.30.0",
"@google/genai": "^1.19.0",
"@keyv/redis": "^4.3.3",
"@librechat/agents": "^3.1.79",
"@librechat/agents": "^3.1.80-dev.3",
"@librechat/api": "*",
"@librechat/data-schemas": "*",
"@microsoft/microsoft-graph-client": "^3.0.7",
Expand Down
30 changes: 16 additions & 14 deletions api/server/controllers/agents/callbacks.js
Original file line number Diff line number Diff line change
Expand Up @@ -616,22 +616,23 @@ function createToolEndCallback({ req, res, artifactPromises, streamId = null })
toolCallId,
conversationId: metadata.thread_id,
/**
* Use the FILE's session_id (storage session), not the
* top-level artifact session_id (exec session). The codeapi
* worker reports two distinct ids on a tool result:
* Use the FILE's `storage_session_id` (storage session),
* not the top-level artifact `session_id` (exec session).
* The codeapi worker reports two distinct ids on a tool
* result:
* - `artifact.session_id` is the EXEC session β€” the
* sandbox VM that ran the bash command. Files don't
* live there; it's torn down post-execution.
* - `file.session_id` is the STORAGE session β€” the
* file-server bucket prefix where artifacts actually
* live and are served from.
* - `file.storage_session_id` is the STORAGE session β€”
* the file-server bucket prefix where artifacts
* actually live and are served from.
* `processCodeOutput` builds `/download/{session_id}/{id}`,
* so passing the exec id resolves to a path the file-server
* doesn't know about and 404s. Fall back to artifact-level
* for older worker payloads that may not populate per-file
* ids.
*/
session_id: file.session_id ?? output.artifact.session_id,
session_id: file.storage_session_id ?? output.artifact.session_id,
});
const fileMetadata = result?.file ?? null;
const finalize = result?.finalize;
Expand Down Expand Up @@ -882,22 +883,23 @@ function createResponsesToolEndCallback({ req, res, tracker, artifactPromises })
toolCallId,
conversationId: metadata.thread_id,
/**
* Use the FILE's session_id (storage session), not the
* top-level artifact session_id (exec session). The codeapi
* worker reports two distinct ids on a tool result:
* Use the FILE's `storage_session_id` (storage session),
* not the top-level artifact `session_id` (exec session).
* The codeapi worker reports two distinct ids on a tool
* result:
* - `artifact.session_id` is the EXEC session β€” the
* sandbox VM that ran the bash command. Files don't
* live there; it's torn down post-execution.
* - `file.session_id` is the STORAGE session β€” the
* file-server bucket prefix where artifacts actually
* live and are served from.
* - `file.storage_session_id` is the STORAGE session β€”
* the file-server bucket prefix where artifacts
* actually live and are served from.
* `processCodeOutput` builds `/download/{session_id}/{id}`,
* so passing the exec id resolves to a path the file-server
* doesn't know about and 404s. Fall back to artifact-level
* for older worker payloads that may not populate per-file
* ids.
*/
session_id: file.session_id ?? output.artifact.session_id,
session_id: file.storage_session_id ?? output.artifact.session_id,
});
const fileMetadata = result?.file ?? null;
const finalize = result?.finalize;
Expand Down
2 changes: 1 addition & 1 deletion api/server/controllers/agents/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -331,7 +331,7 @@ class AgentClient extends BaseClient {
this.contextHandlers?.processFile(file);
continue;
}
if (file.metadata?.fileIdentifier) {
if (file.metadata?.codeEnvRef) {
continue;
}
}
Expand Down
19 changes: 17 additions & 2 deletions api/server/routes/files/files.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const fs = require('fs').promises;
const express = require('express');
const { logger, SystemCapabilities } = require('@librechat/data-schemas');
const {
logAxiosError,
refreshS3FileUrls,
resolveUploadErrorMessage,
verifyAgentUploadPermission,
Expand Down Expand Up @@ -309,12 +310,26 @@ router.get('/code/download/:session_id/:fileId', async (req, res) => {
return res.status(501).send('Not Implemented');
}

/* Code-output downloads are always user-private β€” `processCodeOutput`
* persists every code-execution artifact under
* `metadata.codeEnvRef.kind === 'user'` regardless of which skill
* the run invoked. Pass `kind: 'user'` + `id: <userId>` so codeapi's
* `sessionAuth` resolves the matching `<tenant>:user:<userId>`
* 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}`);
const response = await getDownloadStream(`${session_id}/${fileId}`, {
kind: 'user',
id: req.user.id,
});
res.set(response.headers);
response.data.pipe(res);
} catch (error) {
logger.error('Error downloading file:', error);
/* `logAxiosError` redacts buffer/stream response bodies β€” without
* it, a stream-typed axios failure dumps the entire `Readable`'s
* internal state (megabytes of socket + readableState) into the
* log line. Plain `logger.error(error)` would do that here. */
logAxiosError({ message: 'Error downloading code-output file', error });
res.status(500).send('Error downloading file');
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,15 @@ jest.mock('@librechat/api', () => {
* inline (non-finalize) path so existing assertions on a single
* createFile call hold. */
hasOfficeHtmlPath: jest.fn(() => false),
/* Identity-helper stub mirroring `packages/api/src/files/code/identity.ts`.
* `processCodeOutput` calls this for every output download URL;
* traversal cases don't care about the query shape, just that it
* returns something concatable. */
buildCodeEnvDownloadQuery: jest.fn(({ kind, id, version }) => {
const params = new URLSearchParams({ kind, id });
if (version != null) params.set('version', String(version));
return `?${params.toString()}`;
}),
codeServerHttpAgent: new http.Agent({ keepAlive: false }),
codeServerHttpsAgent: new https.Agent({ keepAlive: false }),
};
Expand Down
74 changes: 49 additions & 25 deletions api/server/services/Files/Code/crud.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ const {
createAxiosInstance,
codeServerHttpAgent,
codeServerHttpsAgent,
appendCodeEnvFileIdentity,
buildCodeEnvDownloadQuery,
} = require('@librechat/api');

const axios = createAxiosInstance();
Expand All @@ -16,16 +18,22 @@ 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").
* @param {{ kind: 'skill' | 'agent' | 'user'; id: string; version?: number }} identity
* Resource identity required by codeapi's `sessionAuth` to derive the
* matching sessionKey. For code-output downloads this is always
* `kind: 'user', id: <userId>`; for skill/agent re-downloads pass
* the kind+id (+version for skill) from the file's `metadata.codeEnvRef`.
* @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) {
async function getCodeOutputDownloadStream(fileIdentifier, identity) {
try {
const baseURL = getCodeBaseURL();
const query = buildCodeEnvDownloadQuery(identity);
/** @type {import('axios').AxiosRequestConfig} */
const options = {
method: 'get',
url: `${baseURL}/download/${fileIdentifier}`,
url: `${baseURL}/download/${fileIdentifier}${query}`,
responseType: 'stream',
headers: {
'User-Agent': 'LibreChat/1.0',
Expand All @@ -49,20 +57,31 @@ async function getCodeOutputDownloadStream(fileIdentifier) {

/**
* Uploads a file to the Code Environment server.
*
* `kind`/`id`/`version?` are required so codeapi can route the upload to
* the correct sessionKey bucket β€” `<tenant>:<kind>:<id>[:v:<version>]`
* for shared kinds, `<tenant>:user:<authContext.userId>` for `user`.
* Without these, codeapi falls back to user-scoped bucketing regardless
* of the resource the file belongs to, so skill-cache invalidation
* (driven by the version bump on edit) never fires. See codeapi #1455.
*
* @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>}
* @param {'skill' | 'agent' | 'user'} params.kind - Resource kind that owns this file's storage session.
* @param {string} params.id - Resource id (skillId / agentId / userId). Codeapi
* ignores this for `kind: 'user'` (auth context provides userId), but it's
* sent uniformly for shape symmetry with the discriminated union.
* @param {number} [params.version] - Required when `kind === 'skill'`; absent otherwise.
* @returns {Promise<{ storage_session_id: string; file_id: string }>}
* The codeapi storage location of the uploaded file.
* @throws {Error} If there's an error during the upload process.
*/
async function uploadCodeEnvFile({ req, stream, filename, entity_id = '' }) {
async function uploadCodeEnvFile({ req, stream, filename, kind, id, version }) {
try {
const form = new FormData();
if (entity_id.length > 0) {
form.append('entity_id', entity_id);
}
appendCodeEnvFileIdentity(form, { kind, id, version });
appendCodeEnvFile(form, stream, filename);

const baseURL = getCodeBaseURL();
Expand All @@ -83,18 +102,16 @@ async function uploadCodeEnvFile({ req, stream, filename, entity_id = '' }) {

const response = await axios.post(`${baseURL}/upload`, form, options);

/** @type {{ message: string; session_id: string; files: Array<{ fileId: string; filename: string }> }} */
/** @type {{ message: string; storage_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}`;
return {
storage_session_id: result.storage_session_id,
file_id: result.files[0].fileId,
};
} catch (error) {
throw new Error(
logAxiosError({
Expand All @@ -109,25 +126,28 @@ async function uploadCodeEnvFile({ req, stream, filename, entity_id = '' }) {
* 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.
*
* `kind`/`id`/`version?` carry the resource identity for codeapi's sessionKey
* derivation β€” see `uploadCodeEnvFile` for the full motivation.
*
* @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 {'skill' | 'agent' | 'user'} params.kind - Resource kind that owns the batch's storage session.
* @param {string} params.id - Resource id (skillId / agentId / userId).
* @param {number} [params.version] - Required when `kind === 'skill'`; absent otherwise.
* @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 }> }>}
* @returns {Promise<{ storage_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 }) {
async function batchUploadCodeEnvFiles({ req, files, kind, id, version, read_only = false }) {
try {
const form = new FormData();
if (entity_id.length > 0) {
form.append('entity_id', entity_id);
}
appendCodeEnvFileIdentity(form, { kind, id, version });
if (read_only) {
form.append('read_only', 'true');
}
Expand All @@ -153,12 +173,12 @@ async function batchUploadCodeEnvFiles({ req, files, entity_id = '', read_only =

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 }} */
/** @type {{ message: string; storage_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 ||
!result.storage_session_id ||
!Array.isArray(result.files)
) {
throw new Error(`Unexpected batch upload response: ${JSON.stringify(result).slice(0, 200)}`);
Expand All @@ -179,7 +199,7 @@ async function batchUploadCodeEnvFiles({ req, files, entity_id = '', read_only =
.filter((f) => f.status === 'success' && f.fileId)
.map((f) => ({ fileId: f.fileId, filename: f.filename }));

return { session_id: result.session_id, files: successFiles };
return { storage_session_id: result.storage_session_id, files: successFiles };
} catch (error) {
throw new Error(
logAxiosError({
Expand All @@ -190,4 +210,8 @@ async function batchUploadCodeEnvFiles({ req, files, entity_id = '', read_only =
}
}

module.exports = { getCodeOutputDownloadStream, uploadCodeEnvFile, batchUploadCodeEnvFiles };
module.exports = {
getCodeOutputDownloadStream,
uploadCodeEnvFile,
batchUploadCodeEnvFiles,
};
Loading
Loading