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.
460 lines
15 KiB
JavaScript
460 lines
15 KiB
JavaScript
const fs = require('fs').promises;
|
|
const express = require('express');
|
|
const { logger } = require('@hanzochat/data-schemas');
|
|
const {
|
|
Time,
|
|
isUUID,
|
|
CacheKeys,
|
|
FileSources,
|
|
SystemRoles,
|
|
ResourceType,
|
|
EModelEndpoint,
|
|
PermissionBits,
|
|
checkOpenAIStorage,
|
|
isAssistantsEndpoint,
|
|
} = require('@hanzochat/data-provider');
|
|
const {
|
|
filterFile,
|
|
processFileUpload,
|
|
processDeleteRequest,
|
|
processAgentFileUpload,
|
|
} = require('~/server/services/Files/process');
|
|
const { SANDBOX_ID_RE, ARTIFACT_NAME_RE } = require('@hanzochat/api');
|
|
const { fileAccess } = require('~/server/middleware/accessResources/fileAccess');
|
|
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
|
const { getOpenAIClient } = require('~/server/controllers/assistants/helpers');
|
|
const { checkPermission } = require('~/server/services/PermissionService');
|
|
const { loadAuthValues } = require('~/server/services/Tools/credentials');
|
|
const { refreshS3FileUrls } = require('~/server/services/Files/S3/crud');
|
|
const { hasAccessToFilesViaAgent } = require('~/server/services/Files');
|
|
const { getFiles, batchUpdateFiles } = require('~/models');
|
|
const { cleanFileName } = require('~/server/utils/files');
|
|
const { getAssistant } = require('~/models/Assistant');
|
|
const { getAgent } = require('~/models/Agent');
|
|
const { getLogStores } = require('~/cache');
|
|
const { Readable } = require('stream');
|
|
|
|
const router = express.Router();
|
|
|
|
router.get('/', async (req, res) => {
|
|
try {
|
|
const appConfig = req.config;
|
|
const files = await getFiles({ user: req.user.id });
|
|
if (appConfig.fileStrategy === FileSources.s3) {
|
|
try {
|
|
const cache = getLogStores(CacheKeys.S3_EXPIRY_INTERVAL);
|
|
const alreadyChecked = await cache.get(req.user.id);
|
|
if (!alreadyChecked) {
|
|
await refreshS3FileUrls(files, batchUpdateFiles);
|
|
await cache.set(req.user.id, true, Time.THIRTY_MINUTES);
|
|
}
|
|
} catch (error) {
|
|
logger.warn('[/files] Error refreshing S3 file URLs:', error);
|
|
}
|
|
}
|
|
res.status(200).send(files);
|
|
} catch (error) {
|
|
logger.error('[/files] Error getting files:', error);
|
|
res.status(400).json({ message: 'Error in request', error: error.message });
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Get files specific to an agent
|
|
* @route GET /files/agent/:agent_id
|
|
* @param {string} agent_id - The agent ID to get files for
|
|
* @returns {Promise<TFile[]>} Array of files attached to the agent
|
|
*/
|
|
router.get('/agent/:agent_id', async (req, res) => {
|
|
try {
|
|
const { agent_id } = req.params;
|
|
const userId = req.user.id;
|
|
|
|
if (!agent_id) {
|
|
return res.status(400).json({ error: 'Agent ID is required' });
|
|
}
|
|
|
|
const agent = await getAgent({ id: agent_id });
|
|
if (!agent) {
|
|
return res.status(200).json([]);
|
|
}
|
|
|
|
if (agent.author.toString() !== userId) {
|
|
const hasEditPermission = await checkPermission({
|
|
userId,
|
|
role: req.user.role,
|
|
resourceType: ResourceType.AGENT,
|
|
resourceId: agent._id,
|
|
requiredPermission: PermissionBits.EDIT,
|
|
});
|
|
|
|
if (!hasEditPermission) {
|
|
return res.status(200).json([]);
|
|
}
|
|
}
|
|
|
|
const agentFileIds = [];
|
|
if (agent.tool_resources) {
|
|
for (const [, resource] of Object.entries(agent.tool_resources)) {
|
|
if (resource?.file_ids && Array.isArray(resource.file_ids)) {
|
|
agentFileIds.push(...resource.file_ids);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (agentFileIds.length === 0) {
|
|
return res.status(200).json([]);
|
|
}
|
|
|
|
const files = await getFiles({ file_id: { $in: agentFileIds } }, null, { text: 0 });
|
|
|
|
res.status(200).json(files);
|
|
} catch (error) {
|
|
logger.error('[/files/agent/:agent_id] Error fetching agent files:', error);
|
|
res.status(500).json({ error: 'Failed to fetch agent files' });
|
|
}
|
|
});
|
|
|
|
router.get('/config', async (req, res) => {
|
|
try {
|
|
const appConfig = req.config;
|
|
res.status(200).json(appConfig.fileConfig);
|
|
} catch (error) {
|
|
logger.error('[/files] Error getting fileConfig', error);
|
|
res.status(400).json({ message: 'Error in request', error: error.message });
|
|
}
|
|
});
|
|
|
|
router.delete('/', async (req, res) => {
|
|
try {
|
|
const { files: _files } = req.body;
|
|
|
|
/** @type {MongoFile[]} */
|
|
const files = _files.filter((file) => {
|
|
if (!file.file_id) {
|
|
return false;
|
|
}
|
|
if (!file.filepath) {
|
|
return false;
|
|
}
|
|
|
|
if (/^(file|assistant)-/.test(file.file_id)) {
|
|
return true;
|
|
}
|
|
|
|
return isUUID.safeParse(file.file_id).success;
|
|
});
|
|
|
|
if (files.length === 0) {
|
|
res.status(204).json({ message: 'Nothing provided to delete' });
|
|
return;
|
|
}
|
|
|
|
const fileIds = files.map((file) => file.file_id);
|
|
const dbFiles = await getFiles({ file_id: { $in: fileIds } });
|
|
|
|
const ownedFiles = [];
|
|
const nonOwnedFiles = [];
|
|
|
|
for (const file of dbFiles) {
|
|
if (file.user.toString() === req.user.id.toString()) {
|
|
ownedFiles.push(file);
|
|
} else {
|
|
nonOwnedFiles.push(file);
|
|
}
|
|
}
|
|
|
|
if (nonOwnedFiles.length === 0) {
|
|
await processDeleteRequest({ req, files: ownedFiles });
|
|
logger.debug(
|
|
`[/files] Files deleted successfully: ${ownedFiles
|
|
.filter((f) => f.file_id)
|
|
.map((f) => f.file_id)
|
|
.join(', ')}`,
|
|
);
|
|
res.status(200).json({ message: 'Files deleted successfully' });
|
|
return;
|
|
}
|
|
|
|
let authorizedFiles = [...ownedFiles];
|
|
let unauthorizedFiles = [];
|
|
|
|
if (req.body.agent_id && nonOwnedFiles.length > 0) {
|
|
const nonOwnedFileIds = nonOwnedFiles.map((f) => f.file_id);
|
|
const accessMap = await hasAccessToFilesViaAgent({
|
|
userId: req.user.id,
|
|
role: req.user.role,
|
|
fileIds: nonOwnedFileIds,
|
|
agentId: req.body.agent_id,
|
|
isDelete: true,
|
|
});
|
|
|
|
for (const file of nonOwnedFiles) {
|
|
if (accessMap.get(file.file_id)) {
|
|
authorizedFiles.push(file);
|
|
} else {
|
|
unauthorizedFiles.push(file);
|
|
}
|
|
}
|
|
} else {
|
|
unauthorizedFiles = nonOwnedFiles;
|
|
}
|
|
|
|
if (unauthorizedFiles.length > 0) {
|
|
return res.status(403).json({
|
|
message: 'You can only delete files you have access to',
|
|
unauthorizedFiles: unauthorizedFiles.map((f) => f.file_id),
|
|
});
|
|
}
|
|
|
|
/* Handle agent unlinking even if no valid files to delete */
|
|
if (req.body.agent_id && req.body.tool_resource && dbFiles.length === 0) {
|
|
const agent = await getAgent({
|
|
id: req.body.agent_id,
|
|
});
|
|
|
|
const toolResourceFiles = agent.tool_resources?.[req.body.tool_resource]?.file_ids ?? [];
|
|
const agentFiles = files.filter((f) => toolResourceFiles.includes(f.file_id));
|
|
|
|
await processDeleteRequest({ req, files: agentFiles });
|
|
res.status(200).json({ message: 'File associations removed successfully from agent' });
|
|
return;
|
|
}
|
|
|
|
/* Handle assistant unlinking even if no valid files to delete */
|
|
if (req.body.assistant_id && req.body.tool_resource && dbFiles.length === 0) {
|
|
const assistant = await getAssistant({
|
|
id: req.body.assistant_id,
|
|
});
|
|
|
|
const toolResourceFiles = assistant.tool_resources?.[req.body.tool_resource]?.file_ids ?? [];
|
|
const assistantFiles = files.filter((f) => toolResourceFiles.includes(f.file_id));
|
|
|
|
await processDeleteRequest({ req, files: assistantFiles });
|
|
res.status(200).json({ message: 'File associations removed successfully from assistant' });
|
|
return;
|
|
} else if (
|
|
req.body.assistant_id &&
|
|
req.body.files?.[0]?.filepath === EModelEndpoint.azureAssistants
|
|
) {
|
|
await processDeleteRequest({ req, files: req.body.files });
|
|
return res
|
|
.status(200)
|
|
.json({ message: 'File associations removed successfully from Azure Assistant' });
|
|
}
|
|
|
|
await processDeleteRequest({ req, files: authorizedFiles });
|
|
|
|
logger.debug(
|
|
`[/files] Files deleted successfully: ${authorizedFiles
|
|
.filter((f) => f.file_id)
|
|
.map((f) => f.file_id)
|
|
.join(', ')}`,
|
|
);
|
|
res.status(200).json({ message: 'Files deleted successfully' });
|
|
} catch (error) {
|
|
logger.error('[/files] Error deleting files:', error);
|
|
res.status(400).json({ message: 'Error in request', error: error.message });
|
|
}
|
|
});
|
|
|
|
router.get('/code/download/:session_id/:fileId', async (req, res) => {
|
|
try {
|
|
const { session_id, fileId } = req.params;
|
|
const logPrefix = `Session ID: ${session_id} | File ID: ${fileId} | Code output download requested by user `;
|
|
logger.debug(logPrefix);
|
|
|
|
if (!session_id || !fileId) {
|
|
return res.status(400).send('Bad request');
|
|
}
|
|
|
|
/* A sandbox id (`m_` + 12 random bytes, hex) and an artifact's flat name.
|
|
* Both arrive from the URL and both are about to be used as one, so both are
|
|
* checked against the grammars cloud actually mints — not the nanoid shape
|
|
* the retired code service used, which no sandbox id or filename matches. */
|
|
if (!SANDBOX_ID_RE.test(session_id) || !ARTIFACT_NAME_RE.test(fileId)) {
|
|
logger.debug(`${logPrefix} invalid session_id or fileId`);
|
|
return res.status(400).send('Bad request');
|
|
}
|
|
|
|
const { getDownloadStream } = getStrategyFunctions(FileSources.execute_code);
|
|
if (!getDownloadStream) {
|
|
logger.warn(
|
|
`${logPrefix} has no stream method implemented for ${FileSources.execute_code} source`,
|
|
);
|
|
return res.status(501).send('Not Implemented');
|
|
}
|
|
|
|
const response = await getDownloadStream(`${session_id}/${fileId}`, req);
|
|
res.set(response.headers);
|
|
response.data.pipe(res);
|
|
} catch (error) {
|
|
logger.error('Error downloading file:', error);
|
|
res.status(500).send('Error downloading file');
|
|
}
|
|
});
|
|
|
|
router.get('/download/:userId/:file_id', fileAccess, async (req, res) => {
|
|
try {
|
|
const { userId, file_id } = req.params;
|
|
logger.debug(`File download requested by user ${userId}: ${file_id}`);
|
|
|
|
// Access already validated by fileAccess middleware
|
|
const file = req.fileAccess.file;
|
|
|
|
if (checkOpenAIStorage(file.source) && !file.model) {
|
|
logger.warn(`File download requested by user ${userId} has no associated model: ${file_id}`);
|
|
return res.status(400).send('The model used when creating this file is not available');
|
|
}
|
|
|
|
const { getDownloadStream } = getStrategyFunctions(file.source);
|
|
if (!getDownloadStream) {
|
|
logger.warn(
|
|
`File download requested by user ${userId} has no stream method implemented: ${file.source}`,
|
|
);
|
|
return res.status(501).send('Not Implemented');
|
|
}
|
|
|
|
const setHeaders = () => {
|
|
const cleanedFilename = cleanFileName(file.filename);
|
|
res.setHeader('Content-Disposition', `attachment; filename="${cleanedFilename}"`);
|
|
res.setHeader('Content-Type', 'application/octet-stream');
|
|
res.setHeader('X-File-Metadata', JSON.stringify(file));
|
|
};
|
|
|
|
if (checkOpenAIStorage(file.source)) {
|
|
req.body = { model: file.model };
|
|
const endpointMap = {
|
|
[FileSources.openai]: EModelEndpoint.assistants,
|
|
[FileSources.azure]: EModelEndpoint.azureAssistants,
|
|
};
|
|
const { openai } = await getOpenAIClient({
|
|
req,
|
|
res,
|
|
overrideEndpoint: endpointMap[file.source],
|
|
});
|
|
logger.debug(`Downloading file ${file_id} from OpenAI`);
|
|
const passThrough = await getDownloadStream(file_id, openai);
|
|
setHeaders();
|
|
logger.debug(`File ${file_id} downloaded from OpenAI`);
|
|
|
|
// Handle both Node.js and Web streams
|
|
const stream =
|
|
passThrough.body && typeof passThrough.body.getReader === 'function'
|
|
? Readable.fromWeb(passThrough.body)
|
|
: passThrough.body;
|
|
|
|
stream.pipe(res);
|
|
} else {
|
|
const fileStream = await getDownloadStream(req, file.filepath);
|
|
|
|
fileStream.on('error', (streamError) => {
|
|
logger.error('[DOWNLOAD ROUTE] Stream error:', streamError);
|
|
});
|
|
|
|
setHeaders();
|
|
fileStream.pipe(res);
|
|
}
|
|
} catch (error) {
|
|
logger.error('[DOWNLOAD ROUTE] Error downloading file:', error);
|
|
res.status(500).send('Error downloading file');
|
|
}
|
|
});
|
|
|
|
router.post('/', async (req, res) => {
|
|
const metadata = req.body;
|
|
let cleanup = true;
|
|
|
|
try {
|
|
filterFile({ req });
|
|
|
|
metadata.temp_file_id = metadata.file_id;
|
|
metadata.file_id = req.file_id;
|
|
|
|
if (isAssistantsEndpoint(metadata.endpoint)) {
|
|
return await processFileUpload({ req, res, metadata });
|
|
}
|
|
|
|
/**
|
|
* Check agent permissions for permanent agent file uploads (not message attachments).
|
|
* Message attachments (message_file=true) are temporary files for a single conversation
|
|
* and should be allowed for users who can chat with the agent.
|
|
* Permanent file uploads to tool_resources require EDIT permission.
|
|
*/
|
|
const isMessageAttachment = metadata.message_file === true || metadata.message_file === 'true';
|
|
if (metadata.agent_id && metadata.tool_resource && !isMessageAttachment) {
|
|
const userId = req.user.id;
|
|
|
|
/** Admin users bypass permission checks */
|
|
if (req.user.role !== SystemRoles.ADMIN) {
|
|
const agent = await getAgent({ id: metadata.agent_id });
|
|
|
|
if (!agent) {
|
|
return res.status(404).json({
|
|
error: 'Not Found',
|
|
message: 'Agent not found',
|
|
});
|
|
}
|
|
|
|
/** Check if user is the author or has edit permission */
|
|
if (agent.author.toString() !== userId) {
|
|
const hasEditPermission = await checkPermission({
|
|
userId,
|
|
role: req.user.role,
|
|
resourceType: ResourceType.AGENT,
|
|
resourceId: agent._id,
|
|
requiredPermission: PermissionBits.EDIT,
|
|
});
|
|
|
|
if (!hasEditPermission) {
|
|
logger.warn(
|
|
`[/files] User ${userId} denied upload to agent ${metadata.agent_id} (insufficient permissions)`,
|
|
);
|
|
return res.status(403).json({
|
|
error: 'Forbidden',
|
|
message: 'Insufficient permissions to upload files to this agent',
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return await processAgentFileUpload({ req, res, metadata });
|
|
} catch (error) {
|
|
let message = 'Error processing file';
|
|
logger.error('[/files] Error processing file:', error);
|
|
|
|
if (error.message?.includes('file_ids')) {
|
|
message += ': ' + error.message;
|
|
}
|
|
|
|
if (
|
|
error.message?.includes('Invalid file format') ||
|
|
error.message?.includes('No OCR result') ||
|
|
error.message?.includes('exceeds token limit')
|
|
) {
|
|
message = error.message;
|
|
}
|
|
|
|
try {
|
|
await fs.unlink(req.file.path);
|
|
cleanup = false;
|
|
} catch (error) {
|
|
logger.error('[/files] Error deleting file:', error);
|
|
}
|
|
res.status(500).json({ message });
|
|
} finally {
|
|
if (cleanup) {
|
|
try {
|
|
await fs.unlink(req.file.path);
|
|
} catch (error) {
|
|
logger.error('[/files] Error deleting file after file processing:', error);
|
|
}
|
|
} else {
|
|
logger.debug('[/files] File processing completed without cleanup');
|
|
}
|
|
}
|
|
});
|
|
|
|
module.exports = router;
|