mirror of
https://github.com/hanzoai/chat.git
synced 2026-09-30 20:51:17 +00:00
execute_code no longer POSTs /v1/exec behind a shared service key. It leases a Hanzo sandbox (cloud /v1/sandboxes) with the CALLER's own hanzo.id bearer, runs the program there, and reports what it printed and what it wrote. The session IS the sandbox. `session_id` is the sandbox id, the artifact directory is its workdir, an expired session is a lease the reaper ended. There is no session table here because there is no second object to keep in sync. Why the key had to be deleted rather than fixed ----------------------------------------------- A shared service key IS custom auth, and it is also why that surface could not bill: it carries no tenant, so cloud could not tell whose org a run belonged to — apps/exec's own header says so. With the user's bearer the run lands on the account that ran it, because the account is the only thing the call carries. It was never even wired: `EnvVar.CODE_API_KEY` does not exist in the shipped @hanzochat/agents, so `authFields: [undefined]` at every call site and setting the env var was a silent no-op. Deleted at all ~20 sites, plus the guard in loadAuthValues that existed only to survive the `undefined`, plus the client's key-entry path in the tool-auth contract (`fieldsMap[execute_code]` is now `[]` and verifyToolAuth never consults the credential store for it). What the tool does now ---------------------- One round trip. The program rides the exec channel's stdin, so the source is never quoted into a command line. Its stdout/stderr are redirected to files, so the command's own stdout carries only four framed lines — exit code, then base64 of out, err and the new-file list. That is unforgeable by construction: a delimiter would let a program that printed the delimiter name files it never wrote. Scratch lives in /tmp, never in /mnt/data, so the artifact scan cannot mistake this tool's own source for a user's output. The language menu is measured against the sandbox image rather than inherited: `d`, `f90` and `r` are dropped (no compiler), `rb` added, `ts` runs on bun. Files in and out ride the same three verbs: upload is POST /:id/fs, download is GET /:id/fs, and "is this session still alive?" is GET /:id — one question where there used to be a `lastModified` fetch and a 23-hour freshness rule that only together approximated it. PTC is declared and not executable: it is a different protocol (it suspends the run and resumes from a continuation token) and cloud answers it 501, so offering a tool that fails mid-run is worse than not offering one. De-branding: user-facing prose first, wire second. The `LIBRECHAT_TEST_RUN_HOOK` env var and the `@librechat/*` requires are renamed because BOTH ends live in this repo. The `application/vnd.librechat.*` artifact MIMEs are NOT renamed — they are identifiers on the wire, no user reads them, and a rename needs a migration story it would not buy anything. NOTICE and LICENSE are untouched.
399 lines
13 KiB
JavaScript
399 lines
13 KiB
JavaScript
const path = require('path');
|
|
const { v4 } = require('uuid');
|
|
const { logger } = require('@hanzochat/data-schemas');
|
|
const {
|
|
getBasePath,
|
|
logAxiosError,
|
|
sandboxIsLive,
|
|
sandboxForRequest,
|
|
} = require('@hanzochat/api');
|
|
const {
|
|
Tools,
|
|
megabyte,
|
|
fileConfig,
|
|
FileContext,
|
|
FileSources,
|
|
imageExtRegex,
|
|
inferMimeType,
|
|
EToolResources,
|
|
EModelEndpoint,
|
|
mergeFileConfig,
|
|
getEndpointFileConfig,
|
|
} = require('@hanzochat/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 { determineFileType } = require('~/server/utils');
|
|
|
|
/**
|
|
* 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).
|
|
* @returns {Object} Fallback response with download URL.
|
|
*/
|
|
const createDownloadFallback = ({
|
|
id,
|
|
name,
|
|
messageId,
|
|
expiresAt,
|
|
session_id,
|
|
toolCallId,
|
|
conversationId,
|
|
}) => {
|
|
const basePath = getBasePath();
|
|
return {
|
|
filename: name,
|
|
filepath: `${basePath}/v1/chat/files/code/download/${session_id}/${id}`,
|
|
expiresAt,
|
|
conversationId,
|
|
toolCallId,
|
|
messageId,
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Process code execution output files - downloads and saves both images and non-image files.
|
|
* All files are saved to local storage with fileIdentifier metadata for code env re-upload.
|
|
* @param {ServerRequest} params.req - The Express request object.
|
|
* @param {string} params.id - The artifact's name in the sandbox.
|
|
* @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.
|
|
* @returns {Promise<MongoFile & { messageId: string, toolCallId: string } | undefined>} The file metadata or undefined if an error occurs.
|
|
*/
|
|
const processCodeOutput = async ({
|
|
req,
|
|
id,
|
|
name,
|
|
toolCallId,
|
|
conversationId,
|
|
messageId,
|
|
session_id,
|
|
}) => {
|
|
const appConfig = req.config;
|
|
const currentDate = new Date();
|
|
const fileExt = path.extname(name).toLowerCase();
|
|
const isImage = fileExt && imageExtRegex.test(name);
|
|
|
|
const mergedFileConfig = mergeFileConfig(appConfig.fileConfig);
|
|
const endpointFileConfig = getEndpointFileConfig({
|
|
fileConfig: mergedFileConfig,
|
|
endpoint: EModelEndpoint.agents,
|
|
});
|
|
const fileSizeLimit = endpointFileConfig.fileSizeLimit ?? mergedFileConfig.serverFileSizeLimit;
|
|
|
|
try {
|
|
const formattedDate = currentDate.toISOString();
|
|
/* Read the artifact out of the sandbox that produced it, under the caller's
|
|
* own bearer. `sandboxForRequest` resumes `session_id` when its lease is still
|
|
* good; a different id back means the lease is gone and the bytes with it. */
|
|
const sandbox = await sandboxForRequest(req, session_id);
|
|
if (sandbox.id !== session_id) {
|
|
throw new Error(`code environment session ${session_id} has expired`);
|
|
}
|
|
const buffer = await sandbox.readBuffer(id);
|
|
if (buffer == null) {
|
|
throw new Error(`no such artifact in session ${session_id}: ${id}`);
|
|
}
|
|
|
|
// 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 createDownloadFallback({
|
|
id,
|
|
name,
|
|
messageId,
|
|
toolCallId,
|
|
session_id,
|
|
conversationId,
|
|
expiresAt: currentDate.getTime() + 86400000,
|
|
});
|
|
}
|
|
|
|
const fileIdentifier = `${session_id}/${id}`;
|
|
|
|
/**
|
|
* 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).
|
|
*/
|
|
const newFileId = v4();
|
|
const claimed = await claimCodeFile({
|
|
filename: name,
|
|
conversationId,
|
|
file_id: newFileId,
|
|
user: req.user.id,
|
|
});
|
|
const file_id = claimed.file_id;
|
|
const isUpdate = file_id !== newFileId;
|
|
|
|
if (isUpdate) {
|
|
logger.debug(
|
|
`[processCodeOutput] Updating existing file "${name}" (${file_id}) instead of creating duplicate`,
|
|
);
|
|
}
|
|
|
|
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 file = {
|
|
..._file,
|
|
filepath,
|
|
file_id,
|
|
messageId,
|
|
usage,
|
|
filename: name,
|
|
conversationId,
|
|
user: req.user.id,
|
|
type: `image/${appConfig.imageOutputType}`,
|
|
createdAt: isUpdate ? claimed.createdAt : formattedDate,
|
|
updatedAt: formattedDate,
|
|
source: appConfig.fileStrategy,
|
|
context: FileContext.execute_code,
|
|
metadata: { fileIdentifier },
|
|
};
|
|
await createFile(file, true);
|
|
return Object.assign(file, { messageId, toolCallId });
|
|
}
|
|
|
|
const { saveBuffer } = getStrategyFunctions(appConfig.fileStrategy);
|
|
if (!saveBuffer) {
|
|
logger.warn(
|
|
`[processCodeOutput] saveBuffer not available for strategy ${appConfig.fileStrategy}, falling back to download URL`,
|
|
);
|
|
return createDownloadFallback({
|
|
id,
|
|
name,
|
|
messageId,
|
|
toolCallId,
|
|
session_id,
|
|
conversationId,
|
|
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`,
|
|
);
|
|
}
|
|
|
|
const fileName = `${file_id}__${name}`;
|
|
const filepath = await saveBuffer({
|
|
userId: req.user.id,
|
|
buffer,
|
|
fileName,
|
|
basePath: 'uploads',
|
|
});
|
|
|
|
const file = {
|
|
file_id,
|
|
filepath,
|
|
messageId,
|
|
object: 'file',
|
|
filename: name,
|
|
type: mimeType,
|
|
conversationId,
|
|
user: req.user.id,
|
|
bytes: buffer.length,
|
|
updatedAt: formattedDate,
|
|
metadata: { fileIdentifier },
|
|
source: appConfig.fileStrategy,
|
|
context: FileContext.execute_code,
|
|
usage: isUpdate ? (claimed.usage ?? 0) + 1 : 1,
|
|
createdAt: isUpdate ? claimed.createdAt : formattedDate,
|
|
};
|
|
|
|
await createFile(file, true);
|
|
return Object.assign(file, { messageId, toolCallId });
|
|
} catch (error) {
|
|
logAxiosError({
|
|
message: 'Error downloading/processing code environment file',
|
|
error,
|
|
});
|
|
|
|
// Fallback for download errors - return download URL so user can still manually download
|
|
return createDownloadFallback({
|
|
id,
|
|
name,
|
|
messageId,
|
|
toolCallId,
|
|
session_id,
|
|
conversationId,
|
|
expiresAt: currentDate.getTime() + 86400000,
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Is the sandbox that holds this file still leased?
|
|
*
|
|
* ONE question, where there used to be two that only together answered it — a
|
|
* `lastModified` fetched from a file listing and a 23-hour freshness rule applied
|
|
* to it. Both were proxies for "can I still read this?", and a sandbox answers
|
|
* that directly: the lease is either running or it is not. A dead session's files
|
|
* are gone whatever their timestamps say, and a live one's are there.
|
|
*
|
|
* @param {string} fileIdentifier - `"<sandbox id>/<name>"`.
|
|
* @param {ServerRequest} req
|
|
* @returns {Promise<boolean>}
|
|
*/
|
|
async function sessionIsLive(fileIdentifier, req) {
|
|
const [pathPart] = String(fileIdentifier ?? '').split('?');
|
|
const session_id = pathPart.split('/')[0];
|
|
return session_id ? sandboxIsLive(req, session_id) : false;
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @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
|
|
* @returns {Promise<{
|
|
* files: Array<{ id: string; session_id: string; name: string }>,
|
|
* toolContext: string,
|
|
* }>}
|
|
*/
|
|
const primeFiles = async (options) => {
|
|
const { tool_resources, req, agentId } = options;
|
|
const file_ids = tool_resources?.[EToolResources.execute_code]?.file_ids ?? [];
|
|
const agentResourceIds = new Set(file_ids);
|
|
const resourceFiles = tool_resources?.[EToolResources.execute_code]?.files ?? [];
|
|
|
|
// Get all files first
|
|
const allFiles = (await getFiles({ file_id: { $in: file_ids } }, 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,
|
|
});
|
|
} else {
|
|
dbFiles = allFiles;
|
|
}
|
|
|
|
dbFiles = dbFiles.concat(resourceFiles);
|
|
|
|
const files = [];
|
|
const sessions = new Map();
|
|
let toolContext = '';
|
|
|
|
for (let i = 0; i < dbFiles.length; i++) {
|
|
const file = dbFiles[i];
|
|
if (!file) {
|
|
continue;
|
|
}
|
|
|
|
if (file.metadata.fileIdentifier) {
|
|
const [path, queryString] = file.metadata.fileIdentifier.split('?');
|
|
const [session_id, id] = path.split('/');
|
|
|
|
const pushFile = () => {
|
|
if (!toolContext) {
|
|
toolContext = `- Note: The following files are available in the "${Tools.execute_code}" tool environment:`;
|
|
}
|
|
|
|
let fileSuffix = '';
|
|
if (!agentResourceIds.has(file.file_id)) {
|
|
fileSuffix =
|
|
file.context === FileContext.execute_code
|
|
? ' (from previous code execution)'
|
|
: ' (attached by user)';
|
|
}
|
|
|
|
toolContext += `\n\t- /mnt/data/${file.filename}${fileSuffix}`;
|
|
files.push({
|
|
id,
|
|
session_id,
|
|
name: file.filename,
|
|
});
|
|
};
|
|
|
|
if (sessions.has(session_id)) {
|
|
pushFile();
|
|
continue;
|
|
}
|
|
|
|
let queryParams = {};
|
|
if (queryString) {
|
|
queryParams = Object.fromEntries(new URLSearchParams(queryString).entries());
|
|
}
|
|
|
|
const reuploadFile = async () => {
|
|
try {
|
|
const { getDownloadStream } = getStrategyFunctions(file.source);
|
|
const { handleFileUpload: uploadCodeEnvFile } = getStrategyFunctions(
|
|
FileSources.execute_code,
|
|
);
|
|
const stream = await getDownloadStream(options.req, file.filepath);
|
|
const fileIdentifier = await uploadCodeEnvFile({
|
|
req: options.req,
|
|
stream,
|
|
filename: file.filename,
|
|
entity_id: queryParams.entity_id,
|
|
});
|
|
|
|
// Preserve existing metadata when adding fileIdentifier
|
|
const updatedMetadata = {
|
|
...file.metadata, // Preserve existing metadata (like S3 storage info)
|
|
fileIdentifier, // Add fileIdentifier
|
|
};
|
|
|
|
await updateFile({
|
|
file_id: file.file_id,
|
|
metadata: updatedMetadata,
|
|
});
|
|
sessions.set(session_id, true);
|
|
pushFile();
|
|
} catch (error) {
|
|
logger.error(
|
|
`Error re-uploading file ${id} in session ${session_id}: ${error.message}`,
|
|
error,
|
|
);
|
|
}
|
|
};
|
|
if (!(await sessionIsLive(file.metadata.fileIdentifier, options.req))) {
|
|
logger.debug(`[primeFiles] session ${session_id} is no longer leased; re-uploading ${id}`);
|
|
await reuploadFile();
|
|
continue;
|
|
}
|
|
sessions.set(session_id, true);
|
|
pushFile();
|
|
}
|
|
}
|
|
|
|
return { files, toolContext };
|
|
};
|
|
|
|
module.exports = {
|
|
primeFiles,
|
|
sessionIsLive,
|
|
processCodeOutput,
|
|
};
|