mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-11-01 03:26:18 +00:00
* 🗂️ fix: Resolve Code Interpreter Destination Collisions
Codeapi mounts each `/exec` input at a destination derived from its `name`
and rejects the whole request when two entries land on one. LibreChat never
enforced that: `primeFiles` sent `name: file.filename` verbatim, and the
graph seed deduped by `(storage_session_id, id)` — identity, not destination.
Two conversation files sharing a filename therefore killed code execution for
the rest of the conversation. The rejected request never reaches the sandbox,
so no result comes back to collapse the pair, and every later turn re-primes
both and fails identically. Only code-generated outputs are covered by the
`(filename, conversationId, context, tenantId)` partial unique index; uploads
carry `context: message_attachment`, so one conversation can hold several
records sharing a filename — two uploads of `image.png`, or an upload whose
name a later execution wrote back in place.
`packages/api/src/files/code/destinations.ts` now owns the name → destination
mapping for every contributor to an `/exec` file list, mirroring codeapi's
conflict rule including directory-prefix conflicts:
- `primeFiles` claims per pushed file, disambiguating with a `-2`, `-3`
counter and echoing the resolved path into the tool context, so the model
is told where each file actually mounts. Claim order comes from immutable
`createdAt`, not `getFiles`'s `updatedAt` sort, which usage accounting and
re-upload both bump — order-dependent destinations would silently repoint
a path an earlier turn had already handed the model. Newest wins the bare
name, matching `ToolNode.updateCodeSession` and giving an in-place rewrite
precedence over the original it superseded.
- `seedCodeFilesIntoSessions` drops a later file whose destination is already
claimed. Renaming there would invent a path nothing told the model about,
and a conflict at that layer means one logical file arrived twice under
different storage pointers.
Fixes #15443
* 🧹 chore: Tolerate Holes in the Destination Priority Sort
`primeFiles` sorts the raw `getFiles` result before its own per-entry guard
runs, so the comparator has to survive a null row rather than throw ahead of
the loop that was already defending against one.
Also tightens the tool-context assertion in the upload-vs-output case, which
was passing on a trailing space rather than on the path.
* 🔁 fix: Address Codex Review on Destination Assignment
Four findings from the review of `b0f61d0`.
**Identity-derived suffixes instead of a collision counter.** A counter is
assigned from whatever is in the set, so a third file arriving under the
literal name a displaced file had been given pushed that file along, and code
the model wrote in an earlier turn would silently start reading the newcomer.
The fallback now hashes the file's own `file_id`, so a displaced file keeps
its destination for the life of the conversation and only the winner of the
bare name can change. A trailing counter still guards a hash collision so the
search terminates on any input.
**Rank by last content write, not record creation.** `processCodeOutput`
claims one row per `(filename, conversationId)` and rewrites it in place, so
a repeatedly written output keeps its original `createdAt` while recording the
latest writer in `metadata.sourceDispatchedAt`. Ranking on `createdAt` alone
handed the bare path to an upload the output had since been rewritten over.
Recency is now `max(sourceDispatchedAt, createdAt)` — still never `updatedAt`,
which usage accounting bumps without any content changing.
**Advertise a displaced generated output.** Generated files are left out of
the tool context because the model already knows what it wrote — which stops
being true once a newer same-named file takes the bare path and the output
mounts somewhere the model has never seen. Displaced outputs are now listed
with the name they were written as, and displaced uploads name their original
too.
**Rank conversation files above an agent's private ones.** Each agent claims
destinations over its own candidate set — the conversation's files plus its
own — so a private file taking a bare name from a shared file in one agent but
not in another left two agents advertising different paths for the same file
into one shared mount namespace. Shared files now rank first, so the majority
that every agent sees resolves identically. The private-versus-private case
still drops at the merge and is documented there; closing it needs one
assignment across contributors, made before any agent renders its context.
* 📌 test: Pin Alias Ownership on an In-Place Rewrite
Codex round 2 flagged that the identity alias still falls back to a counter
when the alias itself is literally claimed, so a displaced file can move after
all. The mechanism is real and my JSDoc overstated the guarantee, which this
corrects.
The behaviour stays as it is. The realistic way a file arrives under another
file's alias is the model rewriting the displaced file in place: it was told
`<stem>-<hash>.ext`, wrote back to it, and `processCodeOutput` registered an
output under that literal name. Keeping the alias with the newest holder is
what makes the model's familiar path resolve to its own edit; reserving it
against the newcomer would hand that path back to the bytes it replaced. The
test pins that ordering so a later change cannot invert it silently.
1839 lines
67 KiB
JavaScript
1839 lines
67 KiB
JavaScript
const path = require('path');
|
|
const { v4 } = require('uuid');
|
|
const { logger } = require('@librechat/data-schemas');
|
|
const { getCodeBaseURL } = require('@librechat/agents');
|
|
const {
|
|
withTimeout,
|
|
getBasePath,
|
|
logAxiosError,
|
|
hasOfficeHtmlPath,
|
|
sanitizeArtifactPath,
|
|
flattenArtifactPath,
|
|
createAxiosInstance,
|
|
getCodeApiAuthHeaders,
|
|
classifyCodeArtifact,
|
|
codeServerHttpAgent,
|
|
codeServerHttpsAgent,
|
|
extractCodeArtifactText,
|
|
extractCodeArtifactRawText,
|
|
extractCodeArtifactInspectionText,
|
|
getBoundedCodeOutputByteLimit,
|
|
getExtractedTextFormat,
|
|
getStorageMetadata,
|
|
getCodeExecutionBaseUrl,
|
|
buildCodeEnvDownloadQuery,
|
|
claimCodeDestination,
|
|
createCodeDestinationSet,
|
|
CODE_API_EXPECTED_PROFILE_HEADER,
|
|
sortCodeFilesByDestinationPriority,
|
|
} = require('@librechat/api');
|
|
const {
|
|
Tools,
|
|
megabyte,
|
|
fileConfig,
|
|
FileContext,
|
|
FileSources,
|
|
imageExtRegex,
|
|
inferMimeType,
|
|
EToolResources,
|
|
EModelEndpoint,
|
|
ErrorTypes,
|
|
mergeFileConfig,
|
|
getCodeEnvRefs,
|
|
mergeCodeEnvRef,
|
|
getCodeEnvRefForProfile,
|
|
getEndpointFileConfig,
|
|
} = require('librechat-data-provider');
|
|
const { filterFilesByAgentAccess } = require('~/server/services/Files/permissions');
|
|
const { createFile, getFiles, updateFile, claimCodeFile } = require('~/models');
|
|
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
|
const { convertImage } = require('~/server/services/Files/images/convert');
|
|
const { getRetentionExpiry } = require('~/server/services/Files/retention');
|
|
const { determineFileType } = require('~/server/utils');
|
|
|
|
const axios = createAxiosInstance();
|
|
|
|
class CodeOutputDownloadLimitError extends Error {
|
|
constructor(maxBytes) {
|
|
super(`Generated file exceeds the ${maxBytes}-byte transport limit`);
|
|
this.name = 'CodeOutputDownloadLimitError';
|
|
this.code = 'CODE_OUTPUT_DOWNLOAD_LIMIT';
|
|
}
|
|
}
|
|
|
|
const getCodeOutputFileSettings = (req) => {
|
|
const mergedFileConfig = mergeFileConfig(req.config.fileConfig);
|
|
const endpointFileConfig = getEndpointFileConfig({
|
|
fileConfig: mergedFileConfig,
|
|
endpoint: EModelEndpoint.agents,
|
|
});
|
|
const configuredFileSizeLimit =
|
|
endpointFileConfig.fileSizeLimit ?? mergedFileConfig.serverFileSizeLimit;
|
|
return {
|
|
endpointFileConfig,
|
|
fileSizeLimit: getBoundedCodeOutputByteLimit(configuredFileSizeLimit),
|
|
};
|
|
};
|
|
|
|
const downloadCodeOutputBuffer = async ({
|
|
req,
|
|
id,
|
|
session_id,
|
|
maxBytes,
|
|
codeApiBaseUrl,
|
|
executionProfile = 'default',
|
|
}) => {
|
|
const baseURL = codeApiBaseUrl ?? getCodeExecutionBaseUrl(executionProfile);
|
|
const authHeaders = await getCodeApiAuthHeaders(req);
|
|
const downloadQuery = buildCodeEnvDownloadQuery({ kind: 'user', id: req.user.id });
|
|
let response;
|
|
try {
|
|
response = await axios({
|
|
method: 'get',
|
|
url: `${baseURL}/download/${session_id}/${id}${downloadQuery}`,
|
|
responseType: 'arraybuffer',
|
|
headers: {
|
|
'User-Agent': 'LibreChat/1.0',
|
|
...authHeaders,
|
|
[CODE_API_EXPECTED_PROFILE_HEADER]: executionProfile,
|
|
},
|
|
httpAgent: codeServerHttpAgent,
|
|
httpsAgent: codeServerHttpsAgent,
|
|
timeout: 15000,
|
|
...(Number.isFinite(maxBytes) && maxBytes >= 0
|
|
? {
|
|
maxContentLength: maxBytes,
|
|
maxBodyLength: maxBytes,
|
|
}
|
|
: {}),
|
|
});
|
|
} catch (error) {
|
|
if (
|
|
Number.isFinite(maxBytes) &&
|
|
maxBytes >= 0 &&
|
|
/maxContentLength|maxBodyLength/i.test(error?.message ?? '')
|
|
) {
|
|
throw new CodeOutputDownloadLimitError(maxBytes);
|
|
}
|
|
throw error;
|
|
}
|
|
const buffer = Buffer.from(response.data, 'binary');
|
|
if (Number.isFinite(maxBytes) && maxBytes >= 0 && buffer.length > maxBytes) {
|
|
throw new CodeOutputDownloadLimitError(maxBytes);
|
|
}
|
|
return buffer;
|
|
};
|
|
|
|
/**
|
|
* Downloads and derives inspectable text for a code artifact without writing
|
|
* file bytes or metadata. Direct tool calls use this to preflight every
|
|
* artifact before allowing any one artifact to persist.
|
|
* @param {Object} params
|
|
* @param {ServerRequest} params.req
|
|
* @param {string} params.id
|
|
* @param {string} params.name
|
|
* @param {string} params.session_id
|
|
* @param {number} [params.maxBytes] - Remaining aggregate inspection budget.
|
|
* @param {string} [params.codeApiBaseUrl] - Trusted per-agent Code API endpoint.
|
|
* @param {'default'|'stateful'} [params.executionProfile] - Trusted execution profile.
|
|
*/
|
|
const prepareCodeOutputForInspection = async ({
|
|
req,
|
|
id,
|
|
name,
|
|
session_id,
|
|
maxBytes,
|
|
inspectContent = true,
|
|
codeApiBaseUrl,
|
|
executionProfile = 'default',
|
|
}) => {
|
|
const { fileSizeLimit } = getCodeOutputFileSettings(req);
|
|
const transportLimit =
|
|
Number.isFinite(maxBytes) && maxBytes >= 0 ? Math.min(maxBytes, fileSizeLimit) : fileSizeLimit;
|
|
const buffer = await downloadCodeOutputBuffer({
|
|
req,
|
|
id,
|
|
session_id,
|
|
maxBytes: transportLimit,
|
|
codeApiBaseUrl,
|
|
executionProfile,
|
|
});
|
|
const safeName = sanitizeArtifactPath(name);
|
|
const fallbackType = inferMimeType(name, '') || 'application/octet-stream';
|
|
if (!inspectContent) {
|
|
return {
|
|
buffer,
|
|
file: {
|
|
name,
|
|
filename: safeName,
|
|
type: fallbackType,
|
|
},
|
|
};
|
|
}
|
|
if (buffer.length > fileSizeLimit) {
|
|
return {
|
|
buffer,
|
|
extractedTextComplete: false,
|
|
file: {
|
|
name,
|
|
filename: safeName,
|
|
type: fallbackType,
|
|
},
|
|
};
|
|
}
|
|
|
|
const detectedType = await determineFileType(buffer, true);
|
|
const detectedMimeType = detectedType?.mime?.toLowerCase();
|
|
if (detectedMimeType?.startsWith('image/')) {
|
|
return {
|
|
buffer,
|
|
extractedTextComplete: false,
|
|
file: {
|
|
name,
|
|
filename: safeName,
|
|
type: detectedMimeType,
|
|
},
|
|
};
|
|
}
|
|
|
|
const leafName = path.basename(safeName);
|
|
const unknownText = detectedType == null ? extractCodeArtifactRawText(buffer, 'utf8-text') : null;
|
|
const mimeType = unknownText != null ? 'text/plain' : (detectedMimeType ?? fallbackType);
|
|
const category = unknownText != null ? 'utf8-text' : classifyCodeArtifact(leafName, mimeType);
|
|
const content = unknownText ?? extractCodeArtifactRawText(buffer, category);
|
|
const extractedText = await extractCodeArtifactInspectionText(
|
|
buffer,
|
|
leafName,
|
|
mimeType,
|
|
category,
|
|
);
|
|
return {
|
|
buffer,
|
|
extractedTextComplete: extractedText.complete,
|
|
file: {
|
|
name,
|
|
filename: safeName,
|
|
type: mimeType,
|
|
content: content ?? undefined,
|
|
extractedText: extractedText.text ?? undefined,
|
|
},
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Creates a fallback download URL response when file cannot be processed locally.
|
|
* Used when: file exceeds size limit, storage strategy unavailable, or download error occurs.
|
|
* @param {Object} params - The parameters.
|
|
* @param {string} params.name - The filename.
|
|
* @param {string} params.session_id - The code execution session ID.
|
|
* @param {string} params.id - The file ID from the code environment.
|
|
* @param {string} params.conversationId - The current conversation ID.
|
|
* @param {string} params.toolCallId - The tool call ID that generated the file.
|
|
* @param {string} params.messageId - The current message ID.
|
|
* @param {number} params.expiresAt - Expiration timestamp (24 hours from creation).
|
|
* @param {'default'|'stateful'} [params.executionProfile] - Code API route for later fallback download.
|
|
* @param {string} [params.executionRouteKey] - Deployment-local route identity.
|
|
* @returns {Object} Fallback response with download URL.
|
|
*/
|
|
const createDownloadFallback = ({
|
|
id,
|
|
name,
|
|
agentId,
|
|
messageId,
|
|
expiresAt,
|
|
session_id,
|
|
toolCallId,
|
|
conversationId,
|
|
executionProfile,
|
|
executionRouteKey,
|
|
}) => {
|
|
const basePath = getBasePath();
|
|
const query = new URLSearchParams();
|
|
if (executionProfile === 'stateful') {
|
|
query.set('execution_profile', 'stateful');
|
|
}
|
|
if (executionRouteKey && executionRouteKey !== executionProfile) {
|
|
query.set('execution_route_key', executionRouteKey);
|
|
}
|
|
const routeQuery = query.size > 0 ? `?${query.toString()}` : '';
|
|
return {
|
|
filename: name,
|
|
filepath: `${basePath}/api/files/code/download/${session_id}/${id}${routeQuery}`,
|
|
expiresAt,
|
|
conversationId,
|
|
toolCallId,
|
|
messageId,
|
|
agentId,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Hard ceiling on the deferred preview rendering (HTML extraction + DB
|
|
* update). The inner office-render path already has its own 12s timeout
|
|
* and a concurrency-limited queue; this is the outer guard that catches
|
|
* pathological cases where queue wait + render + DB write would
|
|
* otherwise hang the file in `status: 'pending'` indefinitely.
|
|
*
|
|
* If the timeout fires the record is updated to `status: 'failed'`
|
|
* with `previewError: 'timeout'` and the UI shows download-only.
|
|
*/
|
|
const PREVIEW_FINALIZE_TIMEOUT_MS = 60_000;
|
|
|
|
/**
|
|
* Render the inline HTML preview for a code-execution file (or plain
|
|
* text for non-office buckets that still benefit from caching), then
|
|
* atomically transition the DB record to `status: 'ready'` (with
|
|
* `text`/`textFormat`) or `status: 'failed'` (with `previewError`).
|
|
*
|
|
* Decoupled from `processCodeOutput` so the agent's final response is
|
|
* not blocked on potentially slow office rendering. The caller fires
|
|
* this without awaiting; promises continue running after the HTTP
|
|
* response closes (Node doesn't kill them) and the frontend learns of
|
|
* completion via the `attachment` update SSE event (if the stream is
|
|
* still open) or via React Query polling otherwise. Process restart
|
|
* is the only thing that can lose progress — covered by the boot-time
|
|
* orphan sweep.
|
|
*
|
|
* @param {object} params
|
|
* @param {Buffer} params.buffer - The full downloaded file contents,
|
|
* bounded by the server's `fileSizeLimit` config (defaults far above
|
|
* the 1MB extractor cap). The buffer is captured by the closure
|
|
* returned in `{ finalize }`, so when many office files queue behind
|
|
* the inner concurrency limiter (cap 2), all queued buffers stay
|
|
* resident until each one's slot frees. For a tool result emitting
|
|
* N office files, peak heap usage from this path is up to
|
|
* `N * fileSizeLimit`. Acceptable for typical agent runs (a handful
|
|
* of files at a few hundred KB each); pathological cases are bounded
|
|
* by the inner per-file 12s timeout and the outer 60s render cap.
|
|
* @param {string} params.leafName - Basename for classification.
|
|
* @param {string} params.mimeType - Detected/inferred MIME.
|
|
* @param {string} params.category - Classifier output.
|
|
* @param {string} params.file_id - The DB record key for the update.
|
|
* @param {string} [params.previewRevision] - Generation marker stamped
|
|
* by the immediate persist step. The DB commit is conditional on
|
|
* this — if a newer emit (cross-turn filename reuse) has rotated
|
|
* the revision before this render finishes, `updateFile` returns
|
|
* null and the stale render is silently discarded rather than
|
|
* overwriting the newer record.
|
|
* @returns {Promise<MongoFile | null>} The post-update record on
|
|
* success; `null` if the DB update itself failed (extraction failure
|
|
* is reflected as `status: 'failed'`, not a thrown error) or if the
|
|
* `previewRevision` guard rejected the write.
|
|
*/
|
|
const finalizePreview = async ({
|
|
buffer,
|
|
leafName,
|
|
mimeType,
|
|
category,
|
|
file_id,
|
|
previewRevision,
|
|
}) => {
|
|
let text = null;
|
|
let previewError;
|
|
try {
|
|
text = await withTimeout(
|
|
extractCodeArtifactText(buffer, leafName, mimeType, category),
|
|
PREVIEW_FINALIZE_TIMEOUT_MS,
|
|
`Preview extraction exceeded ${PREVIEW_FINALIZE_TIMEOUT_MS}ms`,
|
|
);
|
|
} catch (_error) {
|
|
/* `extractCodeArtifactText` swallows its own errors and returns null,
|
|
* so the only way to reach here is a `withTimeout` rejection — i.e.
|
|
* the queue + render combined exceeded the outer 60s ceiling. */
|
|
previewError = 'timeout';
|
|
logger.warn(
|
|
`[finalizePreview] ${file_id}: extraction timed out after ${PREVIEW_FINALIZE_TIMEOUT_MS}ms`,
|
|
);
|
|
}
|
|
/* HTML-or-null contract (PR #12934): null result on an office file
|
|
* must NOT fall back to plain text — surface as failed. Caller gates
|
|
* on `hasOfficeHtmlPath`, so reaching here always means office. */
|
|
const textFormat = getExtractedTextFormat(leafName, mimeType, text);
|
|
const failed = text == null;
|
|
const status = failed ? 'failed' : 'ready';
|
|
if (failed && !previewError) {
|
|
previewError = 'parser-error';
|
|
}
|
|
try {
|
|
/* Conditional update: commit only if `previewRevision` still
|
|
* matches what the immediate persist step stamped. If a newer
|
|
* emit has rotated the revision (cross-turn filename reuse),
|
|
* `updateFile` returns null and the stale render is silently
|
|
* discarded. (Codex P1 review on PR #12957.) */
|
|
const updated = await updateFile(
|
|
{
|
|
file_id,
|
|
text,
|
|
textFormat,
|
|
status,
|
|
previewError: failed ? previewError : null,
|
|
},
|
|
previewRevision ? { previewRevision } : undefined,
|
|
);
|
|
if (!updated && previewRevision) {
|
|
logger.debug(
|
|
`[finalizePreview] ${file_id}: stale render skipped — newer emit has superseded revision ${previewRevision}`,
|
|
);
|
|
}
|
|
return updated;
|
|
} catch (error) {
|
|
logger.error(
|
|
`[finalizePreview] ${file_id}: failed to persist preview result: ${error?.message ?? error}`,
|
|
);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Run the background `finalize` thunk returned by `processCodeOutput`
|
|
* and route the resolved record to the caller's emit logic. Shared
|
|
* between `callbacks.js` (chat-completions + Open Responses) and
|
|
* `tools.js` (direct tool endpoint) so the fire-and-forget pattern
|
|
* doesn't drift across callsites.
|
|
*
|
|
* `onResolved` receives the post-update DB record and is the only piece
|
|
* that varies — chat-completions writes the legacy `attachment` SSE
|
|
* event, Open Responses writes the spec-shaped `librechat:attachment`
|
|
* event with a sequence number, and the direct tool endpoint has no
|
|
* stream to write to (caller passes a no-op).
|
|
*
|
|
* The catch path is the safety net for unexpected programming errors
|
|
* inside `finalizePreview` ONLY. The function is designed to never
|
|
* throw (extraction and DB failures are translated to `status: 'failed'`
|
|
* inside it), but a ref error or future regression would otherwise
|
|
* leave the DB record stuck at `'pending'` until the boot-time orphan
|
|
* sweep — potentially hours away on a stable server. We attempt a
|
|
* best-effort `updateFile` to mark the record `'failed'` with
|
|
* `previewError: 'unexpected'` so the UI stops polling and the
|
|
* next-turn LLM context surfaces the failure.
|
|
*
|
|
* `onResolved` errors are deliberately isolated in their own try/catch.
|
|
* Without that isolation, a transient transport-side failure (SSE write
|
|
* race after the stream closed, an emitter listener throwing) would
|
|
* propagate into the finalize catch and downgrade an *already-resolved*
|
|
* record to `failed` with `previewError: 'unexpected'` — surfacing
|
|
* "preview unavailable" in the UI even though extraction succeeded
|
|
* and the file is on disk. The emit failure is logged but the DB
|
|
* record stays at whatever `finalizePreview` wrote (typically
|
|
* `'ready'`), so the polling layer / next page load still sees the
|
|
* resolved preview.
|
|
*
|
|
* @param {object} params
|
|
* @param {(() => Promise<object | null>) | undefined} params.finalize - The
|
|
* thunk returned by `processCodeOutput`. No-op when undefined.
|
|
* @param {string | undefined} params.fileId - DB key for the failure
|
|
* marker; if absent the catch only logs.
|
|
* @param {string | undefined} [params.previewRevision] - Generation
|
|
* marker stamped by the immediate persist step. The defensive
|
|
* `updateFile` in the catch is conditional on this — if a newer
|
|
* emit has rotated the revision, the stale failure marker is
|
|
* silently discarded so a programming error from an older render
|
|
* doesn't override a newer turn's record.
|
|
* @param {(updated: object) => void} [params.onResolved] - Called once
|
|
* on success with the post-update record.
|
|
*/
|
|
const runPreviewFinalize = ({ finalize, fileId, previewRevision, onResolved }) => {
|
|
if (typeof finalize !== 'function') {
|
|
return;
|
|
}
|
|
finalize()
|
|
.then((updated) => {
|
|
if (!updated || !onResolved) {
|
|
return;
|
|
}
|
|
/* Isolated try/catch — a throw inside `onResolved` (transport-side
|
|
* SSE write race, emitter listener error) MUST NOT propagate to
|
|
* the outer `.catch`, which would downgrade an already-resolved
|
|
* record to `failed` with `previewError: 'unexpected'`.
|
|
* Extraction succeeded at this point and `finalizePreview` has
|
|
* already persisted the terminal status; the polling layer / next
|
|
* page load will surface the resolved preview even if this turn's
|
|
* SSE emit didn't land. */
|
|
try {
|
|
onResolved(updated);
|
|
} catch (emitError) {
|
|
logger.error(
|
|
`[runPreviewFinalize] onResolved threw for ${fileId}; record stays at the finalized status:`,
|
|
emitError,
|
|
);
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
logger.error('Error rendering deferred preview:', error);
|
|
if (!fileId) {
|
|
return;
|
|
}
|
|
updateFile(
|
|
{
|
|
file_id: fileId,
|
|
status: 'failed',
|
|
previewError: 'unexpected',
|
|
},
|
|
previewRevision ? { previewRevision } : undefined,
|
|
).catch((updateErr) => {
|
|
logger.error(
|
|
`[runPreviewFinalize] also failed to mark ${fileId} as failed after error:`,
|
|
updateErr,
|
|
);
|
|
});
|
|
});
|
|
};
|
|
|
|
/**
|
|
* Process code execution output files — downloads and saves both images
|
|
* and non-image files. All files are saved to local storage with
|
|
* `codeEnvRef` metadata for code env re-upload.
|
|
*
|
|
* Returns a two-part shape so callers can ship the attachment to the
|
|
* client immediately and run preview extraction in the background:
|
|
* - `file`: persisted metadata (file is on disk, downloadable, and
|
|
* has `status: 'pending'` if a preview is still being rendered).
|
|
* - `finalize` (optional): a thunk returning the deferred preview
|
|
* result promise. Present only when an inline HTML preview is
|
|
* expected (office buckets — DOCX/XLSX/XLS/ODS/CSV/PPTX). Caller
|
|
* decides whether to await or fire-and-forget.
|
|
*
|
|
* Existing fallback paths (size limit, missing storage strategy, error
|
|
* catch) return `{ file }` with no `finalize` — there's nothing to
|
|
* extract.
|
|
*
|
|
* @param {ServerRequest} params.req - The Express request object.
|
|
* @param {string} params.id - The file ID from the code environment.
|
|
* @param {string} params.name - The filename.
|
|
* @param {string} params.toolCallId - The tool call ID that generated the file.
|
|
* @param {string} params.session_id - The code execution session ID.
|
|
* @param {string} params.conversationId - The current conversation ID.
|
|
* @param {string} params.messageId - The current message ID.
|
|
* @param {string} [params.codeApiBaseUrl] - Trusted per-agent Code API endpoint.
|
|
* @param {'default'|'stateful'} [params.executionProfile] - Trusted execution profile.
|
|
* @param {string} [params.executionRouteKey] - Trusted deployment-local route identity.
|
|
* @param {Buffer} [params.preparedBuffer] - Bytes downloaded during a
|
|
* no-write content inspection preflight.
|
|
* @param {boolean} [params.downloadFallback] - Return the bounded download
|
|
* fallback without downloading the generated bytes again.
|
|
* @returns {Promise<{ file: MongoFile & { messageId: string, toolCallId: string }, finalize?: () => Promise<MongoFile | null> }>}
|
|
*/
|
|
const processCodeOutput = async ({
|
|
req,
|
|
id,
|
|
name,
|
|
toolCallId,
|
|
conversationId,
|
|
messageId,
|
|
session_id,
|
|
agentId,
|
|
freshClaimAfter,
|
|
codeApiBaseUrl,
|
|
executionProfile = 'default',
|
|
executionRouteKey = executionProfile,
|
|
preparedBuffer,
|
|
downloadFallback,
|
|
}) => {
|
|
const appConfig = req.config;
|
|
const currentDate = new Date();
|
|
const fileExt = path.extname(name).toLowerCase();
|
|
const isImage = fileExt && imageExtRegex.test(name);
|
|
|
|
const { endpointFileConfig, fileSizeLimit } = getCodeOutputFileSettings(req);
|
|
|
|
try {
|
|
const formattedDate = currentDate.toISOString();
|
|
if (downloadFallback === true) {
|
|
return {
|
|
file: createDownloadFallback({
|
|
id,
|
|
name,
|
|
agentId,
|
|
messageId,
|
|
toolCallId,
|
|
session_id,
|
|
conversationId,
|
|
executionProfile,
|
|
executionRouteKey,
|
|
expiresAt: currentDate.getTime() + 86400000,
|
|
}),
|
|
};
|
|
}
|
|
const buffer =
|
|
preparedBuffer ??
|
|
(await downloadCodeOutputBuffer({
|
|
req,
|
|
id,
|
|
session_id,
|
|
maxBytes: fileSizeLimit,
|
|
codeApiBaseUrl,
|
|
executionProfile,
|
|
}));
|
|
|
|
// Enforce file size limit
|
|
if (buffer.length > fileSizeLimit) {
|
|
logger.warn(
|
|
`[processCodeOutput] File "${name}" (${(buffer.length / megabyte).toFixed(2)} MB) exceeds size limit of ${(fileSizeLimit / megabyte).toFixed(2)} MB, falling back to download URL`,
|
|
);
|
|
return {
|
|
file: createDownloadFallback({
|
|
id,
|
|
name,
|
|
agentId,
|
|
messageId,
|
|
toolCallId,
|
|
session_id,
|
|
conversationId,
|
|
executionProfile,
|
|
executionRouteKey,
|
|
expiresAt: currentDate.getTime() + 86400000,
|
|
}),
|
|
};
|
|
}
|
|
|
|
/* Code-output files belong to the user who ran the execution.
|
|
* SessionKey on codeapi will be `<tenant>:user:<userId>` for these,
|
|
* so cache and access stay user-private. */
|
|
const codeEnvRef = {
|
|
kind: 'user',
|
|
id: req.user.id,
|
|
storage_session_id: session_id,
|
|
file_id: id,
|
|
executionProfile,
|
|
...(executionRouteKey !== executionProfile ? { executionRouteKey } : {}),
|
|
};
|
|
|
|
/* `safeName` keeps the directory structure (`a/b/file.txt` -> `a/b/file.txt`)
|
|
* so the next prime() can place the file at the same nested path in the
|
|
* sandbox; flattening would re-create the bug where every nested artifact
|
|
* collapsed into the root and read_file calls 404'd. The flat-form
|
|
* storage key is composed below once `file_id` is known so we can cap
|
|
* the total length at filesystem NAME_MAX. */
|
|
const safeName = sanitizeArtifactPath(name);
|
|
if (safeName !== name) {
|
|
logger.warn(
|
|
`[processCodeOutput] Filename sanitized: "${name}" -> "${safeName}" | conv=${conversationId}`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Atomically claim a file_id for this (filename, conversationId, context) tuple.
|
|
* Uses $setOnInsert so concurrent calls for the same filename converge on
|
|
* a single record instead of creating duplicates (TOCTOU race fix).
|
|
*
|
|
* Claim by `safeName` (not raw `name`) so the claim and the eventual
|
|
* `createFile` agree on the filename column — otherwise weird inputs
|
|
* (e.g. `"proj name/file@v1.txt"`) would claim under the raw name and
|
|
* then write under the sanitized one, leaving the claim row orphaned.
|
|
*/
|
|
/**
|
|
* Dispatch-order stamp persisted with every write AND every claim insert
|
|
* (foreground writes dispatch ≈ now): the out-of-order guard below
|
|
* compares WRITER dispatch order, not wall-clock write time — an older
|
|
* task writing late must not make a newer task's harvest look stale, and
|
|
* a freshly claimed row must carry its claimant's stamp before the
|
|
* content write lands.
|
|
*/
|
|
const sourceDispatchedAt = freshClaimAfter ?? Date.now();
|
|
|
|
const newFileId = v4();
|
|
const claimed = await claimCodeFile({
|
|
filename: safeName,
|
|
conversationId,
|
|
file_id: newFileId,
|
|
user: req.user.id,
|
|
tenantId: req.user.tenantId,
|
|
sourceDispatchedAt,
|
|
});
|
|
const file_id = claimed.file_id;
|
|
const isUpdate = file_id !== newFileId;
|
|
|
|
/**
|
|
* Out-of-order guard for detached (background) harvests: when the claimed
|
|
* row's last writer was dispatched AFTER this task (`freshClaimAfter` =
|
|
* this task's dispatch time), a newer run owns this filename slot. The
|
|
* `(filename, conversationId)` unique index means the stale bytes have
|
|
* nowhere else to live, so skip this file rather than overwrite fresh
|
|
* content — the harvest's stdout patch still lands, only the superseded
|
|
* attachment is omitted. Falls back to `updatedAt` for rows written
|
|
* before the stamp existed (the claim itself is timestamp-neutral).
|
|
*/
|
|
const lastWriterDispatchedAt =
|
|
claimed.metadata?.sourceDispatchedAt ??
|
|
(claimed.updatedAt != null ? new Date(claimed.updatedAt).getTime() : null);
|
|
if (isUpdate && freshClaimAfter != null && lastWriterDispatchedAt > freshClaimAfter) {
|
|
logger.warn(
|
|
`[processCodeOutput] Skipping stale background output "${safeName}" (${file_id}): a newer run owns this filename`,
|
|
);
|
|
return null;
|
|
}
|
|
|
|
if (isUpdate) {
|
|
logger.debug(
|
|
`[processCodeOutput] Updating existing file "${safeName}" (${file_id}) instead of creating duplicate`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Background harvests commit through a CONDITIONAL write: the ownership
|
|
* predicate (last writer's dispatch stamp not newer than ours) is part of
|
|
* the update's filter, so check and write are one atomic operation — a
|
|
* stale harvest's commit simply misses and its attachment is skipped.
|
|
* The row always exists here (the claim inserted it), so the non-upsert
|
|
* `updateFile` matches `createFile(data, true)` semantics ($set + TTL
|
|
* unset). Bytes a loser may have already uploaded to the shared storage
|
|
* key are a narrow residual that per-file locking would be needed to
|
|
* close. Foreground writes keep the unconditional `createFile` path.
|
|
*/
|
|
const commitCodeFile = async (fileData) => {
|
|
if (freshClaimAfter == null) {
|
|
await createFile(fileData, true);
|
|
return true;
|
|
}
|
|
const committed = await updateFile(fileData, {
|
|
$or: [
|
|
{ 'metadata.sourceDispatchedAt': { $exists: false } },
|
|
{ 'metadata.sourceDispatchedAt': { $lte: sourceDispatchedAt } },
|
|
],
|
|
});
|
|
if (!committed) {
|
|
logger.warn(
|
|
`[processCodeOutput] Skipping stale background output "${safeName}" (${file_id}): a newer run owns this filename`,
|
|
);
|
|
return false;
|
|
}
|
|
return true;
|
|
};
|
|
|
|
/**
|
|
* Preserve the original `messageId` on update. Each `processCodeOutput`
|
|
* call would otherwise overwrite it with the current run's run id, which
|
|
* decouples the file from the assistant message that originally created
|
|
* it. `getCodeGeneratedFiles` filters by `messageId IN <thread>`, so a
|
|
* stale id (e.g. from a later regeneration / failed re-read attempt)
|
|
* silently excludes the file from priming on subsequent turns.
|
|
*/
|
|
const persistedMessageId = isUpdate ? (claimed.messageId ?? messageId) : messageId;
|
|
/* A generated-output write replaces the file's bytes, so pointers to
|
|
* earlier content in another profile must not survive as reusable refs. */
|
|
const codeEnvReferenceSet = mergeCodeEnvRef(undefined, codeEnvRef);
|
|
const codeEnvMetadata = {
|
|
...claimed.metadata,
|
|
...codeEnvReferenceSet,
|
|
sourceDispatchedAt,
|
|
};
|
|
|
|
if (isImage) {
|
|
const usage = isUpdate ? (claimed.usage ?? 0) + 1 : 1;
|
|
const _file = await convertImage(req, buffer, 'high', `${file_id}${fileExt}`);
|
|
const filepath = usage > 1 ? `${_file.filepath}?v=${Date.now()}` : _file.filepath;
|
|
const storageMetadata = getStorageMetadata({
|
|
filepath: _file.filepath,
|
|
source: appConfig.fileStrategy,
|
|
storageKey: _file.storageKey,
|
|
storageRegion: _file.storageRegion,
|
|
});
|
|
const file = {
|
|
..._file,
|
|
filepath,
|
|
...storageMetadata,
|
|
file_id,
|
|
messageId: persistedMessageId,
|
|
usage,
|
|
filename: safeName,
|
|
conversationId,
|
|
executionProfile,
|
|
user: req.user.id,
|
|
tenantId: req.user.tenantId,
|
|
type: `image/${appConfig.imageOutputType}`,
|
|
createdAt: isUpdate ? claimed.createdAt : formattedDate,
|
|
updatedAt: formattedDate,
|
|
source: appConfig.fileStrategy,
|
|
context: FileContext.execute_code,
|
|
metadata: codeEnvMetadata,
|
|
...(await getRetentionExpiry(req)),
|
|
};
|
|
if (!(await commitCodeFile(file))) {
|
|
return null;
|
|
}
|
|
return { file: Object.assign(file, { messageId, toolCallId, agentId }) };
|
|
}
|
|
|
|
const { saveBuffer } = getStrategyFunctions(appConfig.fileStrategy);
|
|
if (!saveBuffer) {
|
|
logger.warn(
|
|
`[processCodeOutput] saveBuffer not available for strategy ${appConfig.fileStrategy}, falling back to download URL`,
|
|
);
|
|
return {
|
|
file: createDownloadFallback({
|
|
id,
|
|
name,
|
|
agentId,
|
|
messageId,
|
|
toolCallId,
|
|
session_id,
|
|
conversationId,
|
|
executionProfile,
|
|
executionRouteKey,
|
|
expiresAt: currentDate.getTime() + 86400000,
|
|
}),
|
|
};
|
|
}
|
|
|
|
const detectedType = await determineFileType(buffer, true);
|
|
const mimeType = detectedType?.mime || inferMimeType(name, '') || 'application/octet-stream';
|
|
|
|
/** Check MIME type support - for code-generated files, we're lenient but log unsupported types */
|
|
const isSupportedMimeType = fileConfig.checkType(
|
|
mimeType,
|
|
endpointFileConfig.supportedMimeTypes,
|
|
);
|
|
if (!isSupportedMimeType) {
|
|
logger.warn(
|
|
`[processCodeOutput] File "${name}" has unsupported MIME type "${mimeType}", proceeding with storage but may not be usable as tool resource`,
|
|
);
|
|
}
|
|
|
|
/* Compose the storage key here, after `file_id` is known, so the
|
|
* `flattenArtifactPath` cap budget can be calculated against the
|
|
* actual prefix length. The full key has to fit in one filesystem
|
|
* path component (NAME_MAX = 255 on most filesystems); without this
|
|
* cap, deeply-nested artifact paths whose individual segments were
|
|
* within bounds can still produce a flat form that overflows once
|
|
* `${file_id}__` is prepended, causing `ENAMETOOLONG` inside
|
|
* saveBuffer and falling back to a download URL. The 255 figure is
|
|
* the conservative cross-platform NAME_MAX (Linux ext4, NTFS, APFS).
|
|
*/
|
|
const NAME_MAX = 255;
|
|
const flatName = flattenArtifactPath(safeName, NAME_MAX - file_id.length - 2);
|
|
const fileName = `${file_id}__${flatName}`;
|
|
const filepath = await saveBuffer({
|
|
userId: req.user.id,
|
|
buffer,
|
|
fileName,
|
|
basePath: 'uploads',
|
|
tenantId: req.user.tenantId,
|
|
});
|
|
const storageMetadata = getStorageMetadata({
|
|
filepath,
|
|
source: appConfig.fileStrategy,
|
|
});
|
|
|
|
/* `classifyCodeArtifact` and `extractCodeArtifactText` make
|
|
* extension/bare-name decisions on the input string. With the
|
|
* path-preserving sanitizer they can now receive a nested path like
|
|
* `reports.v1/Makefile`, which the classifier's `extensionOf` reads
|
|
* as `v1/Makefile` (the slice after the dot in the directory name)
|
|
* and the bare-name branch rejects because it sees a `.` anywhere in
|
|
* the string. Result: extensionless artifacts under dotted folders
|
|
* (Makefile, Dockerfile, etc.) get misclassified as `other` and
|
|
* skip text extraction. Pass the basename so classification matches
|
|
* what it would have gotten with the old flat-name flow. */
|
|
const leafName = path.basename(safeName);
|
|
const category = classifyCodeArtifact(leafName, mimeType);
|
|
|
|
/* Office-bucket files (DOCX/XLSX/XLS/ODS/CSV/PPTX) route through
|
|
* `bufferToOfficeHtml` which is CPU-heavy. Persist the record now
|
|
* with `status: 'pending'` and `text: null` so the agent's response
|
|
* isn't blocked, then return a `finalize` thunk the caller can run
|
|
* in the background. Non-office files have cheap or no extraction
|
|
* — run it inline so the caller gets a fully-resolved record
|
|
* without juggling a finalize step. */
|
|
const expectsPreview = hasOfficeHtmlPath(leafName, mimeType);
|
|
|
|
const baseFile = {
|
|
file_id,
|
|
filepath,
|
|
...storageMetadata,
|
|
messageId: persistedMessageId,
|
|
object: 'file',
|
|
filename: safeName,
|
|
type: mimeType,
|
|
conversationId,
|
|
user: req.user.id,
|
|
tenantId: req.user.tenantId,
|
|
bytes: buffer.length,
|
|
updatedAt: formattedDate,
|
|
metadata: codeEnvMetadata,
|
|
source: appConfig.fileStrategy,
|
|
context: FileContext.execute_code,
|
|
usage: isUpdate ? (claimed.usage ?? 0) + 1 : 1,
|
|
createdAt: isUpdate ? claimed.createdAt : formattedDate,
|
|
...(await getRetentionExpiry(req)),
|
|
};
|
|
|
|
if (expectsPreview) {
|
|
/* Persist with `status: 'pending'` and explicit
|
|
* `text: null` / `textFormat: null` so an update that previously
|
|
* had cached text gets cleared. The deferred finalize transitions
|
|
* to 'ready' (with text/textFormat) or 'failed' (with
|
|
* previewError).
|
|
*
|
|
* `previewRevision` is a fresh UUID stamped on every emit. The
|
|
* deferred finalize's `updateFile` is conditional on this — if
|
|
* a newer turn (cross-turn filename reuse) has rotated the
|
|
* revision before this render finishes, the stale render is
|
|
* silently discarded rather than overwriting the newer record.
|
|
* (Codex P1 review on PR #12957.) */
|
|
const previewRevision = v4();
|
|
const file = {
|
|
...baseFile,
|
|
text: null,
|
|
textFormat: null,
|
|
status: 'pending',
|
|
previewError: null,
|
|
previewRevision,
|
|
};
|
|
if (!(await commitCodeFile(file))) {
|
|
return null;
|
|
}
|
|
return {
|
|
file: Object.assign(file, { messageId, toolCallId, agentId }),
|
|
finalize: () =>
|
|
finalizePreview({ buffer, leafName, mimeType, category, file_id, previewRevision }),
|
|
previewRevision,
|
|
};
|
|
}
|
|
|
|
/* Non-office path: extraction is cheap (utf8 decode, parseDocument
|
|
* for PDF/ODT, or null for binaries). Run inline and return a
|
|
* fully-resolved record — no `finalize` needed. */
|
|
const text = await extractCodeArtifactText(buffer, leafName, mimeType, category);
|
|
/* `textFormat` accompanies `text` so the client can gate
|
|
* office-HTML-bucket routing on a trusted signal — clients MUST
|
|
* NOT inject `text` into the iframe as HTML unless `textFormat ===
|
|
* 'html'`. RAG-uploaded `.docx` etc. arrive with plain text from
|
|
* mammoth.extractRawText and would otherwise be hijacked by the
|
|
* extension-based office routing into the HTML-injection path
|
|
* (Codex P1 review on PR #12934). null on extract failure — the
|
|
* client treats absence as 'text' for safety. */
|
|
const textFormat = getExtractedTextFormat(leafName, mimeType, text);
|
|
const file = {
|
|
...baseFile,
|
|
// Always set explicitly so an update which produces a binary or
|
|
// oversized artifact clears any previously cached text — createFile
|
|
// uses findOneAndUpdate with $set semantics.
|
|
text: text ?? null,
|
|
textFormat: textFormat ?? null,
|
|
// Clear deferred-preview lifecycle fields in case the prior emit
|
|
// at this (filename, conversationId) was an office file —
|
|
// otherwise stale `pending`/`failed` would persist and the client
|
|
// would render the wrong state for the now non-office artifact.
|
|
status: null,
|
|
previewError: null,
|
|
previewRevision: null,
|
|
};
|
|
|
|
if (!(await commitCodeFile(file))) {
|
|
return null;
|
|
}
|
|
return { file: Object.assign(file, { messageId, toolCallId, agentId }) };
|
|
} catch (error) {
|
|
if (error?.code === 'CODE_OUTPUT_DOWNLOAD_LIMIT') {
|
|
logger.warn(
|
|
`[processCodeOutput] Generated file exceeds size limit of ${(fileSizeLimit / megabyte).toFixed(2)} MB, falling back to download URL`,
|
|
);
|
|
}
|
|
if (error?.message === 'Path traversal detected in filename') {
|
|
logger.warn(
|
|
`[processCodeOutput] Path traversal blocked for file "${name}" | conv=${conversationId}`,
|
|
);
|
|
}
|
|
logAxiosError({
|
|
message: 'Error downloading/processing code environment file',
|
|
error,
|
|
});
|
|
logger.warn(
|
|
`[processCodeOutput] Falling back to Code API download URL for strategy ${appConfig.fileStrategy}`,
|
|
);
|
|
|
|
// Fallback for download errors - return download URL so user can still manually download
|
|
return {
|
|
file: createDownloadFallback({
|
|
id,
|
|
name,
|
|
agentId,
|
|
messageId,
|
|
toolCallId,
|
|
session_id,
|
|
conversationId,
|
|
executionProfile,
|
|
executionRouteKey,
|
|
expiresAt: currentDate.getTime() + 86400000,
|
|
}),
|
|
};
|
|
}
|
|
};
|
|
|
|
function checkIfActive(dateString) {
|
|
const givenDate = new Date(dateString);
|
|
const currentDate = new Date();
|
|
const timeDifference = currentDate - givenDate;
|
|
const hoursPassed = timeDifference / (1000 * 60 * 60);
|
|
return hoursPassed < 23;
|
|
}
|
|
|
|
/**
|
|
* Retrieves the `lastModified` time string for a specified file from Code Execution Server.
|
|
*
|
|
* @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.
|
|
* @param {{baseUrl?: string, executionProfile?: 'default'|'stateful'}} [route]
|
|
* Trusted host-selected Code API route.
|
|
*
|
|
* @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, req, route = {}) {
|
|
try {
|
|
const baseURL = route.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
|
|
* requester's auth context, then matches it against the cached
|
|
* sessionKey on the storage bucket. We have the full `codeEnvRef`
|
|
* here, so pass kind+id (+version when skill) directly. */
|
|
const query = buildCodeEnvDownloadQuery({
|
|
kind: ref.kind,
|
|
id: ref.id,
|
|
...(ref.kind === 'skill' ? { version: ref.version } : {}),
|
|
});
|
|
const response = await axios({
|
|
method: 'get',
|
|
url: `${baseURL}/sessions/${ref.storage_session_id}/objects/${ref.file_id}${query}`,
|
|
headers: {
|
|
'User-Agent': 'LibreChat/1.0',
|
|
...authHeaders,
|
|
...(route.executionProfile
|
|
? { [CODE_API_EXPECTED_PROFILE_HEADER]: route.executionProfile }
|
|
: {}),
|
|
},
|
|
httpAgent: codeServerHttpAgent,
|
|
httpsAgent: codeServerHttpsAgent,
|
|
timeout: 5000,
|
|
});
|
|
|
|
return response.data?.lastModified;
|
|
} catch (_error) {
|
|
logger.debug('[getSessionInfo] session lookup failed (treating as cache miss)');
|
|
return null;
|
|
}
|
|
}
|
|
|
|
const getPreviewContextSuffix = (file) => {
|
|
if (file.status === 'pending') {
|
|
return ' (preview not yet generated)';
|
|
}
|
|
|
|
if (file.status !== 'failed') {
|
|
return '';
|
|
}
|
|
|
|
return file.previewError
|
|
? ` (preview unavailable: ${file.previewError})`
|
|
: ' (preview unavailable)';
|
|
};
|
|
|
|
/**
|
|
* A generated output is normally left out — the model already knows what it
|
|
* wrote. That only holds while the file is still where it wrote it: once a
|
|
* newer same-named file takes the bare path, the output mounts under a
|
|
* suffixed name the model has never seen, and silence would leave it reading
|
|
* the newcomer or failing to find its own artifact.
|
|
*/
|
|
const getVisibleCodeFileContextLine = (file, agentResourceIds, destination) => {
|
|
const displaced = destination !== file.filename;
|
|
if (file.context === FileContext.execute_code && !displaced) {
|
|
return '';
|
|
}
|
|
|
|
const origin =
|
|
file.context === FileContext.execute_code
|
|
? ` (written earlier as ${file.filename})`
|
|
: `${agentResourceIds.has(file.file_id) ? '' : ' (attached by user)'}${
|
|
displaced ? ` (uploaded as ${file.filename})` : ''
|
|
}`;
|
|
return `\n\t- /mnt/data/${destination}${origin}${getPreviewContextSuffix(file)}`;
|
|
};
|
|
|
|
const appendVisibleCodeFileContext = (toolContext, contextLine) => {
|
|
if (!contextLine) {
|
|
return toolContext;
|
|
}
|
|
|
|
if (toolContext) {
|
|
return `${toolContext}${contextLine}`;
|
|
}
|
|
|
|
return `- Note: The following files are available in the "${Tools.execute_code}" tool environment:${contextLine}`;
|
|
};
|
|
|
|
class CodeResourceRecoveryError extends Error {
|
|
constructor({ required, primed, failed }) {
|
|
super(JSON.stringify({ type: ErrorTypes.RESOURCE_RECOVERY_REQUIRED }));
|
|
this.name = 'CodeResourceRecoveryError';
|
|
this.code = ErrorTypes.RESOURCE_RECOVERY_REQUIRED;
|
|
this.status = 409;
|
|
this.statusCode = 409;
|
|
this.details = { required, primed, failed };
|
|
this.required = required;
|
|
this.primed = primed;
|
|
this.failed = failed;
|
|
}
|
|
}
|
|
|
|
const getPrimingCorrelation = (req) => ({
|
|
requestId: req?.requestId ?? req?.id ?? 'unknown',
|
|
runId: req?.body?.messageId ?? req?.body?.conversationId ?? 'unknown',
|
|
});
|
|
|
|
const getReuploadFailureCategory = (error) => {
|
|
const status =
|
|
error?.response?.status ??
|
|
error?.statusCode ??
|
|
error?.status ??
|
|
error?.$metadata?.httpStatusCode;
|
|
const code = error?.code ?? error?.name;
|
|
if (
|
|
status === 404 ||
|
|
code === 'NoSuchKey' ||
|
|
code === 'NotFound' ||
|
|
code === 'BlobNotFound' ||
|
|
code === 'ResourceNotFound'
|
|
) {
|
|
return 'missing_backing_object';
|
|
}
|
|
if (
|
|
status === 401 ||
|
|
status === 403 ||
|
|
code === 'AccessDenied' ||
|
|
code === 'AccessDeniedException' ||
|
|
code === 'Forbidden'
|
|
) {
|
|
return 'resource_access_denied';
|
|
}
|
|
return 'reupload_failed';
|
|
};
|
|
|
|
/**
|
|
*
|
|
* @param {Object} options
|
|
* @param {ServerRequest} options.req
|
|
* @param {Agent['tool_resources']} options.tool_resources
|
|
* @param {string} [options.agentId] - The agent ID for file access control
|
|
* @param {string} [options.agentResourceType] - Permission resource type for the authorized agent route
|
|
* @returns {Promise<{
|
|
* files: Array<{ id: string; session_id: string; name: string }>,
|
|
* toolContext: string,
|
|
* }>}
|
|
*/
|
|
const primeFiles = async (options) => {
|
|
const {
|
|
tool_resources,
|
|
req,
|
|
agentId,
|
|
agentResourceType,
|
|
codeApiBaseUrl,
|
|
executionProfile = 'default',
|
|
executionRouteKey = executionProfile,
|
|
} = options;
|
|
const codeApiRoute = { baseUrl: codeApiBaseUrl, executionProfile };
|
|
const file_ids = tool_resources?.[EToolResources.execute_code]?.file_ids ?? [];
|
|
const agentResourceIds = new Set(file_ids);
|
|
const resourceFiles = tool_resources?.[EToolResources.execute_code]?.files ?? [];
|
|
/** Runtime entries identify candidates only; database records remain authoritative for storage metadata. */
|
|
const candidateFileIds = new Set(file_ids);
|
|
for (const file of resourceFiles) {
|
|
if (typeof file?.file_id === 'string') {
|
|
candidateFileIds.add(file.file_id);
|
|
}
|
|
}
|
|
|
|
/* Step 1 of the priming trace: input volume. Pair with the
|
|
* per-file `[primeCodeFiles] file=...` lines and the final
|
|
* `[primeCodeFiles] returned=...` line below to locate which
|
|
* layer drops a file the sandbox doesn't end up seeing. */
|
|
logger.debug(
|
|
`[primeCodeFiles] in: file_ids=${file_ids.length} resourceFiles=${resourceFiles.length}`,
|
|
{ agentId, file_ids, resourceFileIds: resourceFiles.map((f) => f?.file_id) },
|
|
);
|
|
|
|
// Get all files first
|
|
const allFiles =
|
|
(await getFiles({ file_id: { $in: Array.from(candidateFileIds) } }, null, { text: 0 })) ?? [];
|
|
|
|
// Filter by access if user and agent are provided
|
|
let dbFiles;
|
|
if (req?.user?.id && agentId) {
|
|
dbFiles = await filterFilesByAgentAccess({
|
|
files: allFiles,
|
|
userId: req.user.id,
|
|
role: req.user.role,
|
|
agentId,
|
|
resourceType: agentResourceType,
|
|
});
|
|
} else {
|
|
dbFiles = allFiles;
|
|
}
|
|
|
|
const files = [];
|
|
const sessions = new Map();
|
|
let toolContext = '';
|
|
|
|
/* Claim order decides which record keeps the bare `/mnt/data/<name>` path
|
|
* when several share a filename, so it is fixed here rather than inherited
|
|
* from `getFiles`'s `updatedAt` sort — usage accounting and re-upload both
|
|
* bump `updatedAt`, which would repoint paths between turns. `file_ids` are
|
|
* this agent's own resources; every other candidate came from the
|
|
* conversation and is therefore seen by every agent in the run, so shared
|
|
* files rank first and land on the same destination whichever agent primes
|
|
* them. */
|
|
const orderedFiles = sortCodeFilesByDestinationPriority(dbFiles, agentResourceIds);
|
|
const destinations = createCodeDestinationSet();
|
|
|
|
/* Per-file path counters — emitted at the bottom so a single
|
|
* grep on `[primeCodeFiles]` shows the input volume, the per-file
|
|
* paths taken, and the final dispatch summary in one trace. */
|
|
let skippedNoRef = 0;
|
|
let reuploadFailures = 0;
|
|
let requiredCodeFiles = 0;
|
|
const reuploadFailureCategories = new Set();
|
|
|
|
for (let i = 0; i < orderedFiles.length; i++) {
|
|
const file = orderedFiles[i];
|
|
if (!file) {
|
|
continue;
|
|
}
|
|
|
|
const ref = getCodeEnvRefForProfile(file.metadata, executionRouteKey);
|
|
const sourceRef = ref ?? getCodeEnvRefs(file.metadata)[0]?.[1];
|
|
if (!sourceRef) {
|
|
skippedNoRef += 1;
|
|
logger.debug(`[primeCodeFiles] file=${file.file_id} path=skip reason=no-codeenvref`);
|
|
continue;
|
|
}
|
|
requiredCodeFiles += 1;
|
|
const session_id = sourceRef.storage_session_id;
|
|
const id = sourceRef.file_id;
|
|
|
|
/**
|
|
* `pushFile` accepts optional overrides so the reupload path can
|
|
* push the FRESH `(storage_session_id, file_id)` from the new
|
|
* `codeEnvRef`. Without these overrides, the closure would
|
|
* capture the stale pre-reupload refs from the outer loop and
|
|
* the in-memory `files` array (now consumed by
|
|
* `buildInitialToolSessions` to seed `Graph.sessions`) would
|
|
* point at a sandbox object that no longer exists. The DB record
|
|
* gets the new ref via `updateFile`, but the seed would still
|
|
* inject the old one — bash_tool / read_file would 404 trying to
|
|
* mount the file until the next turn re-reads metadata.
|
|
*
|
|
* `kind`, `id`, `version` are preserved on the in-memory ref so
|
|
* codeapi can resolve sessionKey per-file (kind switch +
|
|
* tenant prefix from auth context).
|
|
*/
|
|
const pushFile = (overrideSessionId, overrideId) => {
|
|
/* Claimed here rather than up front so files that never reach the
|
|
* sandbox — no code-env ref, or a failed re-upload — do not reserve a
|
|
* name and push a file that does reach it onto a counter. */
|
|
const destination = claimCodeDestination(destinations, file.filename, file.file_id);
|
|
if (destination !== file.filename) {
|
|
logger.debug(
|
|
`[primeCodeFiles] file=${file.file_id} destination=${destination} ` +
|
|
`reason=name-collision filename=${file.filename}`,
|
|
);
|
|
}
|
|
toolContext = appendVisibleCodeFileContext(
|
|
toolContext,
|
|
getVisibleCodeFileContextLine(file, agentResourceIds, destination),
|
|
);
|
|
/* `id` is the storage file_id (drives codeapi's upload-key
|
|
* existence check), `resource_id` is the entity that owns
|
|
* the storage session (drives sessionKey re-derivation). For
|
|
* code-output files this is `kind: 'user'` and `resource_id`
|
|
* is informational (codeapi ignores it for user kind), but
|
|
* we still send it for shape uniformity with shared kinds. */
|
|
files.push({
|
|
id: overrideId ?? id,
|
|
resource_id: sourceRef.id,
|
|
storage_session_id: overrideSessionId ?? session_id,
|
|
name: destination,
|
|
kind: sourceRef.kind,
|
|
...(sourceRef.kind === 'skill' ? { version: sourceRef.version } : {}),
|
|
});
|
|
};
|
|
|
|
const reuploadFile = async () => {
|
|
try {
|
|
const { getDownloadStream } = getStrategyFunctions(file.source);
|
|
const { handleFileUpload: uploadCodeEnvFile } = getStrategyFunctions(
|
|
FileSources.execute_code,
|
|
);
|
|
const stream = await getDownloadStream(options.req, file.filepath);
|
|
/* Reupload preserves the resource identity from the existing
|
|
* ref so codeapi re-buckets under the same sessionKey shape
|
|
* (skill stays skill, user stays user). Without this, a
|
|
* skill-cache-miss reupload would land in the user bucket
|
|
* and never re-shareable cross-user. */
|
|
const uploaded = await uploadCodeEnvFile({
|
|
req: options.req,
|
|
stream,
|
|
filename: file.filename,
|
|
kind: sourceRef.kind,
|
|
id: sourceRef.id,
|
|
...(sourceRef.kind === 'skill' ? { version: sourceRef.version } : {}),
|
|
codeApiBaseUrl,
|
|
executionProfile,
|
|
});
|
|
|
|
/**
|
|
* Use the FRESH `(storage_session_id, file_id)` from the
|
|
* reupload response and route it through the dedupe Map, the
|
|
* persisted record, and the in-memory `files` list. The
|
|
* original ref captured at the top of this iteration refers
|
|
* to the old, expired/missing sandbox object — using it here
|
|
* would silently re-introduce the bug `Graph.sessions`
|
|
* seeding is supposed to fix.
|
|
*
|
|
* `kind`, `id`, `version` survive the round-trip: the
|
|
* upload preserves the resource identity, only the storage
|
|
* pointer changes.
|
|
*/
|
|
const newRef = {
|
|
kind: sourceRef.kind,
|
|
id: sourceRef.id,
|
|
storage_session_id: uploaded.storage_session_id,
|
|
file_id: uploaded.file_id,
|
|
executionProfile,
|
|
...(executionRouteKey !== executionProfile ? { executionRouteKey } : {}),
|
|
...(sourceRef.kind === 'skill' ? { version: sourceRef.version } : {}),
|
|
};
|
|
|
|
const updatedRefs = mergeCodeEnvRef(file.metadata, newRef);
|
|
|
|
await updateFile({
|
|
file_id: file.file_id,
|
|
'metadata.codeEnvRef': updatedRefs.codeEnvRef,
|
|
[`metadata.codeEnvRefs.${executionRouteKey}`]: newRef,
|
|
});
|
|
sessions.set(newRef.storage_session_id, true);
|
|
pushFile(newRef.storage_session_id, newRef.file_id);
|
|
logger.debug(
|
|
`[primeCodeFiles] file=${file.file_id} path=reupload-success ` +
|
|
`oldSession=${session_id} newSession=${newRef.storage_session_id} newFileId=${newRef.file_id}`,
|
|
);
|
|
} catch (error) {
|
|
reuploadFailures += 1;
|
|
const failureCategory = getReuploadFailureCategory(error);
|
|
reuploadFailureCategories.add(failureCategory);
|
|
const { requestId, runId } = getPrimingCorrelation(req);
|
|
logger.error(
|
|
`[primeCodeFiles] reupload-failed requestId=${requestId} runId=${runId} ` +
|
|
`category=${failureCategory}`,
|
|
);
|
|
}
|
|
};
|
|
if (!ref) {
|
|
logger.debug(
|
|
`[primeCodeFiles] file=${file.file_id} path=reupload reason=profile-missing ` +
|
|
`requestedProfile=${executionProfile}`,
|
|
);
|
|
await reuploadFile();
|
|
continue;
|
|
}
|
|
if (sessions.has(session_id)) {
|
|
logger.debug(
|
|
`[primeCodeFiles] file=${file.file_id} path=cache-hit-by-session storage_session_id=${session_id}`,
|
|
);
|
|
pushFile();
|
|
continue;
|
|
}
|
|
const uploadTime = await getSessionInfo(ref, req, codeApiRoute);
|
|
if (!uploadTime) {
|
|
logger.debug(
|
|
`[primeCodeFiles] file=${file.file_id} path=reupload reason=no-uploadtime ` +
|
|
`storage_session_id=${session_id}`,
|
|
);
|
|
await reuploadFile();
|
|
continue;
|
|
}
|
|
if (!checkIfActive(uploadTime)) {
|
|
logger.debug(
|
|
`[primeCodeFiles] file=${file.file_id} path=reupload reason=stale ` +
|
|
`uploadTime=${uploadTime} storage_session_id=${session_id}`,
|
|
);
|
|
await reuploadFile();
|
|
continue;
|
|
}
|
|
sessions.set(session_id, true);
|
|
logger.debug(
|
|
`[primeCodeFiles] file=${file.file_id} path=fresh-active storage_session_id=${session_id}`,
|
|
);
|
|
pushFile();
|
|
}
|
|
|
|
/* Dispatch summary — emitted unconditionally so a single grep on
|
|
* `[primeCodeFiles] out` always shows the final state, not only
|
|
* the per-path trail leading up to it. */
|
|
const primedCodeFiles = files.length;
|
|
const allRequiredResourcesFailed =
|
|
requiredCodeFiles > 0 && primedCodeFiles === 0 && reuploadFailures === requiredCodeFiles;
|
|
const { requestId, runId } = getPrimingCorrelation(req);
|
|
logger.debug(
|
|
`[primeCodeFiles] out: returned=${files.length} ` +
|
|
`required=${requiredCodeFiles} skippedNoRef=${skippedNoRef} reuploadFailures=${reuploadFailures}`,
|
|
);
|
|
|
|
if (allRequiredResourcesFailed) {
|
|
const failureCategory =
|
|
reuploadFailureCategories.size === 1
|
|
? Array.from(reuploadFailureCategories)[0]
|
|
: 'mixed_reupload_failure';
|
|
logger.warn(
|
|
`[primeCodeFiles] resource-recovery-required requestId=${requestId} runId=${runId} ` +
|
|
`required=${requiredCodeFiles} primed=${primedCodeFiles} failed=${reuploadFailures} ` +
|
|
`category=${failureCategory}`,
|
|
);
|
|
throw new CodeResourceRecoveryError({
|
|
required: requiredCodeFiles,
|
|
primed: primedCodeFiles,
|
|
failed: reuploadFailures,
|
|
});
|
|
}
|
|
|
|
return { files, toolContext };
|
|
};
|
|
|
|
/**
|
|
* Reads a single file from the code-execution sandbox by shelling `cat`
|
|
* through the sandbox `/exec` endpoint. Used by the `read_file` host
|
|
* handler when the requested path is a code-env path (`/mnt/data/...`)
|
|
* or otherwise not resolvable as a skill file. Resolves to
|
|
* `{ content }` from stdout on success, or `null` when the codeapi base
|
|
* URL isn't configured / the read returns no content (caller turns that
|
|
* into a model-visible error). Throws axios-style errors on transport
|
|
* failure so the caller can surface a meaningful error message.
|
|
*
|
|
* `session_id` and `files` come from the seeded `tc.codeSessionContext`
|
|
* (emitted by the agents-side `ToolNode` for `read_file` calls in
|
|
* v3.1.72+) so the read lands in the same sandbox session that holds
|
|
* the agent's prior-turn artifacts.
|
|
*
|
|
* @param {Object} params
|
|
* @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,
|
|
runtime_session_hint,
|
|
codeApiBaseUrl,
|
|
executionProfile,
|
|
req,
|
|
}) {
|
|
const baseURL = codeApiBaseUrl ?? getCodeBaseURL();
|
|
if (!baseURL) {
|
|
return null;
|
|
}
|
|
|
|
/** Single-quote `file_path` with embedded-quote escaping so a malicious
|
|
* filename can't break out of the `cat` command. The handler upstream
|
|
* has already established this is a code-env path the model
|
|
* legitimately asked to read; this just keeps the shell quoting safe. */
|
|
const safePath = `'${file_path.replace(/'/g, `'\\''`)}'`;
|
|
/** @type {Record<string, unknown>} */
|
|
const postData = { lang: 'bash', code: `cat ${safePath}` };
|
|
if (session_id) {
|
|
postData.session_id = session_id;
|
|
}
|
|
if (runtime_session_hint) {
|
|
postData.runtime_session_hint = runtime_session_hint;
|
|
}
|
|
if (files && files.length > 0) {
|
|
postData.files = files;
|
|
}
|
|
|
|
try {
|
|
const authHeaders = await getCodeApiAuthHeaders(req);
|
|
const response = await axios({
|
|
method: 'post',
|
|
url: `${baseURL}/exec`,
|
|
data: postData,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'User-Agent': 'LibreChat/1.0',
|
|
...authHeaders,
|
|
...(executionProfile ? { [CODE_API_EXPECTED_PROFILE_HEADER]: executionProfile } : {}),
|
|
},
|
|
httpAgent: codeServerHttpAgent,
|
|
httpsAgent: codeServerHttpsAgent,
|
|
timeout: 15000,
|
|
});
|
|
const result = response?.data ?? {};
|
|
if (result.stderr && (result.stdout == null || result.stdout === '')) {
|
|
throw new Error(String(result.stderr).trim());
|
|
}
|
|
if (result.stdout == null) {
|
|
return null;
|
|
}
|
|
return { content: String(result.stdout) };
|
|
} catch (error) {
|
|
logAxiosError({
|
|
message: `Error reading sandbox file "${file_path}"`,
|
|
error,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reads a small image file out of the code-execution sandbox as base64 so
|
|
* `read_file` can surface it to vision-capable models. `readSandboxFile`'s
|
|
* `cat` round-trips stdout through codeapi's JSON transport, which lossily
|
|
* replaces non-UTF-8 bytes and corrupts image data. Here a tiny Python
|
|
* reader stats the file, refuses (without transferring) anything over
|
|
* `maxBytes`, and otherwise base64-encodes the bytes IN the sandbox so the
|
|
* payload stays ASCII-safe across the JSON `/exec` transport. Session
|
|
* forwarding mirrors `readSandboxFile` so the read lands in the same
|
|
* sandbox session that holds the agent's prior-turn artifacts.
|
|
*
|
|
* @param {Object} params
|
|
* @param {string} params.file_path - Path inside the sandbox (e.g. `/mnt/data/chart.png`).
|
|
* @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 {string} [params.runtime_session_hint] - Per-conversation stateful runtime-session hint.
|
|
* @param {number} [params.maxBytes] - In-sandbox size cap; larger files return `{ tooLarge, bytes }`.
|
|
* @param {ServerRequest} [params.req] - Current authenticated request, used to mint Code API auth.
|
|
* @returns {Promise<{base64: string, bytes: number} | {tooLarge: true, bytes: number} | null>}
|
|
* `null` when codeapi is unavailable; throws on transport / read errors.
|
|
*/
|
|
async function readSandboxImage({
|
|
file_path,
|
|
session_id,
|
|
files,
|
|
runtime_session_hint,
|
|
codeApiBaseUrl,
|
|
executionProfile,
|
|
maxBytes,
|
|
req,
|
|
}) {
|
|
const baseURL = codeApiBaseUrl ?? getCodeBaseURL();
|
|
if (!baseURL) {
|
|
return null;
|
|
}
|
|
|
|
const limit = typeof maxBytes === 'number' && maxBytes > 0 ? maxBytes : 5 * megabyte;
|
|
const chunkBytes = getImageChunkBytes();
|
|
const maxChunks = Math.ceil(limit / chunkBytes) + 1;
|
|
|
|
/** @type {Buffer[]} */
|
|
const parts = [];
|
|
let offset = 0;
|
|
let total = null;
|
|
|
|
for (let i = 0; i < maxChunks; i++) {
|
|
const payload = Buffer.from(
|
|
JSON.stringify({ file_path, limit, offset, chunk: chunkBytes }),
|
|
'utf8',
|
|
).toString('base64');
|
|
const code = [
|
|
"python3 - <<'PY'",
|
|
'import base64, json, os, stat',
|
|
`payload = ${JSON.stringify(payload)}`,
|
|
"data = json.loads(base64.b64decode(payload).decode('utf-8'))",
|
|
"p = data['file_path']",
|
|
"limit = data['limit']",
|
|
"offset = data['offset']",
|
|
"chunk = data['chunk']",
|
|
'try:',
|
|
' st = os.stat(p)',
|
|
'except OSError as e:',
|
|
' print(json.dumps({"error": str(e)}))',
|
|
' raise SystemExit(0)',
|
|
// Reject FIFOs, sockets, and device files (e.g. a symlink to /dev/zero):
|
|
// os.stat can report a small/zero size while an unbounded read blocks or
|
|
// streams forever until the request times out.
|
|
'if not stat.S_ISREG(st.st_mode):',
|
|
' print(json.dumps({"error": "not a regular file"}))',
|
|
' raise SystemExit(0)',
|
|
'if st.st_size > limit:',
|
|
' print(json.dumps({"too_large": True, "bytes": st.st_size}))',
|
|
' raise SystemExit(0)',
|
|
// Read only this window. The whole base64 payload cannot be emitted in
|
|
// one shot: the runner caps stdout at SANDBOX_OUTPUT_MAX_SIZE (1024
|
|
// bytes by default) and SIGKILLs the job on overflow, which truncates
|
|
// the JSON mid-string. Windowing keeps every response under that cap.
|
|
"with open(p, 'rb') as f:",
|
|
' f.seek(offset)',
|
|
' raw = f.read(chunk)',
|
|
'print(json.dumps({"total": st.st_size, "n": len(raw), "b64": base64.b64encode(raw).decode("ascii")}))',
|
|
'PY',
|
|
].join('\n');
|
|
|
|
const parsed = await execSandboxImageChunk({
|
|
baseURL,
|
|
code,
|
|
file_path,
|
|
session_id,
|
|
runtime_session_hint,
|
|
executionProfile,
|
|
files,
|
|
req,
|
|
chunkBytes,
|
|
});
|
|
|
|
if (parsed.error) {
|
|
throw new Error(String(parsed.error));
|
|
}
|
|
if (parsed.too_large === true) {
|
|
return { tooLarge: true, bytes: Number(parsed.bytes) || 0 };
|
|
}
|
|
if (typeof parsed.b64 !== 'string' || typeof parsed.n !== 'number') {
|
|
return null;
|
|
}
|
|
|
|
if (total == null) {
|
|
total = Number(parsed.total) || 0;
|
|
if (total > limit) {
|
|
return { tooLarge: true, bytes: total };
|
|
}
|
|
} else if (Number(parsed.total) !== total) {
|
|
/* The file changed underneath us; a spliced-together buffer would be
|
|
* a mix of two versions rather than any real image. */
|
|
throw new Error(`"${file_path}" changed while being read from the sandbox`);
|
|
}
|
|
|
|
parts.push(Buffer.from(parsed.b64, 'base64'));
|
|
offset += parsed.n;
|
|
|
|
if (parsed.n === 0 || offset >= total) {
|
|
break;
|
|
}
|
|
}
|
|
|
|
const buffer = Buffer.concat(parts);
|
|
if (total == null) {
|
|
return null;
|
|
}
|
|
if (buffer.length !== total) {
|
|
/* Ran out of chunk budget (or short reads); returning a partial image
|
|
* would render as a corrupt file, so surface it as unreadable-inline. */
|
|
return { tooLarge: true, bytes: total };
|
|
}
|
|
return { base64: buffer.toString('base64'), bytes: buffer.length };
|
|
}
|
|
|
|
/**
|
|
* Raw bytes pulled per `/exec` round-trip when inlining a sandbox image.
|
|
* Each chunk is base64-encoded (~1.33x) into the response's stdout, which
|
|
* the runner truncates + SIGKILLs past `SANDBOX_OUTPUT_MAX_SIZE`. The
|
|
* default leaves headroom for a runner configured at 64KB; deployments
|
|
* with a smaller cap must lower this, and a larger cap can raise it to cut
|
|
* round-trips.
|
|
* @returns {number}
|
|
*/
|
|
function getImageChunkBytes() {
|
|
const parsed = Number(process.env.LIBRECHAT_CODE_IMAGE_CHUNK_BYTES);
|
|
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 32 * 1024;
|
|
}
|
|
|
|
/**
|
|
* Runs one image-chunk read over `/exec` and parses its JSON line.
|
|
* @returns {Promise<Record<string, unknown>>}
|
|
*/
|
|
async function execSandboxImageChunk({
|
|
baseURL,
|
|
code,
|
|
file_path,
|
|
session_id,
|
|
runtime_session_hint,
|
|
executionProfile,
|
|
files,
|
|
req,
|
|
chunkBytes,
|
|
}) {
|
|
/** @type {Record<string, unknown>} */
|
|
const postData = { lang: 'bash', code };
|
|
if (session_id) {
|
|
postData.session_id = session_id;
|
|
}
|
|
if (runtime_session_hint) {
|
|
postData.runtime_session_hint = runtime_session_hint;
|
|
}
|
|
if (files && files.length > 0) {
|
|
postData.files = files;
|
|
}
|
|
|
|
try {
|
|
const authHeaders = await getCodeApiAuthHeaders(req);
|
|
const response = await axios({
|
|
method: 'post',
|
|
url: `${baseURL}/exec`,
|
|
data: postData,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'User-Agent': 'LibreChat/1.0',
|
|
...authHeaders,
|
|
...(executionProfile ? { [CODE_API_EXPECTED_PROFILE_HEADER]: executionProfile } : {}),
|
|
},
|
|
httpAgent: codeServerHttpAgent,
|
|
httpsAgent: codeServerHttpsAgent,
|
|
timeout: 15000,
|
|
});
|
|
const result = response?.data ?? {};
|
|
/* The runner truncates stdout at SANDBOX_OUTPUT_MAX_SIZE and SIGKILLs the
|
|
* job (status `OL`). Detect that explicitly: the surviving stdout is a
|
|
* base64 string cut mid-flight, so parsing it yields a misleading
|
|
* "unexpected output" instead of naming the real, fixable cause. */
|
|
if (result.status === 'OL') {
|
|
throw new Error(
|
|
`Reading "${file_path}" exceeded the sandbox stdout limit (chunk ${chunkBytes} bytes). ` +
|
|
'Lower LIBRECHAT_CODE_IMAGE_CHUNK_BYTES or raise SANDBOX_OUTPUT_MAX_SIZE on the runner.',
|
|
);
|
|
}
|
|
if (result.stderr && (result.stdout == null || result.stdout === '')) {
|
|
throw new Error(String(result.stderr).trim());
|
|
}
|
|
if (result.stdout == null || String(result.stdout).trim() === '') {
|
|
return {};
|
|
}
|
|
/* Parse the LAST non-empty line: the reader's JSON is the final thing it
|
|
* prints, so anything a shell profile or library emitted ahead of it
|
|
* (banners, warnings) must not break the read. */
|
|
const lines = String(result.stdout)
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
try {
|
|
return JSON.parse(lines[lines.length - 1]);
|
|
} catch {
|
|
throw new Error(
|
|
`Unexpected output while reading image bytes from the sandbox: ${String(result.stdout).slice(0, 120)}`,
|
|
);
|
|
}
|
|
} catch (error) {
|
|
logAxiosError({
|
|
message: `Error reading sandbox image "${file_path}"`,
|
|
error,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Writes a UTF-8 text file into the code-execution sandbox by running a
|
|
* small Python writer through the sandbox `/exec` endpoint. The payload is
|
|
* base64-encoded JSON so neither the file path nor the content is
|
|
* interpolated into shell syntax.
|
|
*
|
|
* @param {Object} params
|
|
* @param {string} params.file_path - Path inside the sandbox (prefer `/mnt/data/...`).
|
|
* @param {string} params.content - Complete UTF-8 text content to write.
|
|
* @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<{stdout?: string, stderr?: string, session_id?: string, files?: Array<Object>} | null>}
|
|
*/
|
|
async function writeSandboxFile({
|
|
file_path,
|
|
content,
|
|
session_id,
|
|
files,
|
|
runtime_session_hint,
|
|
codeApiBaseUrl,
|
|
executionProfile,
|
|
req,
|
|
}) {
|
|
const baseURL = codeApiBaseUrl ?? getCodeBaseURL();
|
|
if (!baseURL) {
|
|
return null;
|
|
}
|
|
|
|
const payload = Buffer.from(
|
|
JSON.stringify({
|
|
file_path,
|
|
content_b64: Buffer.from(content, 'utf8').toString('base64'),
|
|
}),
|
|
'utf8',
|
|
).toString('base64');
|
|
const code = [
|
|
"python3 - <<'PY'",
|
|
'import base64, json, os',
|
|
`payload = ${JSON.stringify(payload)}`,
|
|
"data = json.loads(base64.b64decode(payload).decode('utf-8'))",
|
|
"path = data['file_path']",
|
|
"content = base64.b64decode(data['content_b64'])",
|
|
'parent = os.path.dirname(path)',
|
|
'if parent:',
|
|
' os.makedirs(parent, exist_ok=True)',
|
|
"with open(path, 'wb') as f:",
|
|
' f.write(content)',
|
|
'print(f"WROTE {len(content)} bytes to {path}")',
|
|
'PY',
|
|
].join('\n');
|
|
|
|
/** @type {Record<string, unknown>} */
|
|
const postData = { lang: 'bash', code };
|
|
if (session_id) {
|
|
postData.session_id = session_id;
|
|
}
|
|
if (runtime_session_hint) {
|
|
postData.runtime_session_hint = runtime_session_hint;
|
|
}
|
|
if (files && files.length > 0) {
|
|
postData.files = files;
|
|
}
|
|
|
|
try {
|
|
const authHeaders = await getCodeApiAuthHeaders(req);
|
|
const response = await axios({
|
|
method: 'post',
|
|
url: `${baseURL}/exec`,
|
|
data: postData,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'User-Agent': 'LibreChat/1.0',
|
|
...authHeaders,
|
|
...(executionProfile ? { [CODE_API_EXPECTED_PROFILE_HEADER]: executionProfile } : {}),
|
|
},
|
|
httpAgent: codeServerHttpAgent,
|
|
httpsAgent: codeServerHttpsAgent,
|
|
timeout: 15000,
|
|
});
|
|
const result = response?.data ?? {};
|
|
if (result.stderr && (result.stdout == null || result.stdout === '')) {
|
|
throw new Error(String(result.stderr).trim());
|
|
}
|
|
if (result.stdout == null && result.session_id == null) {
|
|
return null;
|
|
}
|
|
return {
|
|
stdout: result.stdout == null ? undefined : String(result.stdout),
|
|
stderr: result.stderr == null ? undefined : String(result.stderr),
|
|
session_id: result.session_id,
|
|
files: result.files,
|
|
};
|
|
} catch (error) {
|
|
logAxiosError({
|
|
message: `Error writing sandbox file "${file_path}"`,
|
|
error,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
CodeResourceRecoveryError,
|
|
primeFiles,
|
|
checkIfActive,
|
|
getSessionInfo,
|
|
processCodeOutput,
|
|
prepareCodeOutputForInspection,
|
|
readSandboxFile,
|
|
readSandboxImage,
|
|
writeSandboxFile,
|
|
runPreviewFinalize,
|
|
};
|