mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-05-13 16:07:30 +00:00
* 🛂 fix: Skip Re-Download of Inherited Code-Env Files (No More 403 Storms) When a bash/code-interpreter call lists or operates on inputs the user already owns (skill files primed via primeInvokedSkills, files inherited from a prior session), codeapi echoes those files back in the tool result with `inherited: true`. We were treating every entry as a generated artifact and calling processCodeOutput on each, which: 1. Hit `/api/files/code/download/<session_id>/<file_id>` with the user's session key. Skill files are uploaded under the skill's entity_id, so every download 403'd — producing dozens of "Unauthorized download" log lines per turn. 2. Surfaced those inputs as ghost file chips in the UI even though they were never generated by the run. 3. Wasted a download round-trip even when no auth boundary was crossed — the file is already persisted at its origin. Fix: skip files where `file.inherited === true` in all three artifact-files loops (`tools.js`, `createToolEndCallback`, and `createResponsesToolEndCallback`). Skill files remain available to subsequent calls via primeInvokedSkills / session inheritance — we just don't redundantly re-download them. Pairs with codeapi-side change that adds the `inherited` flag. * 🔒 feat: Mark Skill Files as `read_only` During Code-Env Priming Pairs with codeapi `read_only` upload flag (ClickHouse/ai#1345). When LibreChat primes a skill into the code-env, every file in the batch (SKILL.md plus all bundled scripts/schemas/docs) is now uploaded with `read_only: true`. Codeapi seals these inputs at the filesystem layer (chmod 444) and the walker echoes the original refs as `inherited: true` regardless of whether sandboxed code modified the bytes on disk. Without this, the previous PR's `inherited` skip handled only the unchanged case. A modified skill file (pip writing pyc near a .py, a script accidentally truncating LICENSE.txt, etc.) still flowed through the modified-input branch on codeapi, got a fresh user-owned file_id, uploaded as a "generated" artifact, and surfaced in the UI as a chip the user couldn't actually authorize a download for. Changes: - `api/server/services/Files/Code/crud.js`: `batchUploadCodeEnvFiles({ ..., read_only })` forwards the flag as a multipart form field. Default `false` preserves existing behavior for user-attached files and prior-session inheritance. - `packages/api/src/agents/skillFiles.ts`: type signature gains `read_only?: boolean`; `primeSkillFiles` passes `true`. - `packages/api/src/agents/skillFiles.spec.ts`: assert the upload call carries `read_only: true`. The flag is intentionally not skill-specific. Any future infrastructure-input flow (system fixtures, cached datasets, etc.) can opt in the same way.
246 lines
8.1 KiB
JavaScript
246 lines
8.1 KiB
JavaScript
const { nanoid } = require('nanoid');
|
|
const { logger } = require('@librechat/data-schemas');
|
|
const { checkAccess, loadWebSearchAuth } = require('@librechat/api');
|
|
const {
|
|
Tools,
|
|
AuthType,
|
|
Permissions,
|
|
ToolCallTypes,
|
|
PermissionTypes,
|
|
} = require('librechat-data-provider');
|
|
const { getRoleByName, createToolCall, getToolCallsByConvo, getMessage } = require('~/models');
|
|
const { processFileURL, uploadImageBuffer } = require('~/server/services/Files/process');
|
|
const { processCodeOutput } = require('~/server/services/Files/Code/process');
|
|
const { loadAuthValues } = require('~/server/services/Tools/credentials');
|
|
const { loadTools } = require('~/app/clients/tools/util');
|
|
|
|
/**
|
|
* Tools that are callable directly via `POST /tools/:toolId/call`.
|
|
* `execute_code` is the only entry today; the tool runs server-side via
|
|
* the agents library / sandbox service without any per-user credential.
|
|
*/
|
|
const directCallableTools = new Set([Tools.execute_code]);
|
|
|
|
const toolAccessPermType = {
|
|
[Tools.execute_code]: PermissionTypes.RUN_CODE,
|
|
};
|
|
|
|
/**
|
|
* Verifies web search authentication, ensuring each category has at least
|
|
* one fully authenticated service.
|
|
*
|
|
* @param {ServerRequest} req - The request object
|
|
* @param {ServerResponse} res - The response object
|
|
* @returns {Promise<void>} A promise that resolves when the function has completed
|
|
*/
|
|
const verifyWebSearchAuth = async (req, res) => {
|
|
try {
|
|
const appConfig = req.config;
|
|
const userId = req.user.id;
|
|
/** @type {TCustomConfig['webSearch']} */
|
|
const webSearchConfig = appConfig?.webSearch || {};
|
|
const result = await loadWebSearchAuth({
|
|
userId,
|
|
loadAuthValues,
|
|
webSearchConfig,
|
|
throwError: false,
|
|
});
|
|
|
|
return res.status(200).json({
|
|
authenticated: result.authenticated,
|
|
authTypes: result.authTypes,
|
|
});
|
|
} catch (error) {
|
|
console.error('Error in verifyWebSearchAuth:', error);
|
|
return res.status(500).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* @param {ServerRequest} req - The request object, containing information about the HTTP request.
|
|
* @param {ServerResponse} res - The response object, used to send back the desired HTTP response.
|
|
* @returns {Promise<void>} A promise that resolves when the function has completed.
|
|
*/
|
|
const verifyToolAuth = async (req, res) => {
|
|
try {
|
|
const { toolId } = req.params;
|
|
if (toolId === Tools.web_search) {
|
|
return await verifyWebSearchAuth(req, res);
|
|
}
|
|
if (!directCallableTools.has(toolId)) {
|
|
res.status(404).json({ message: 'Tool not found' });
|
|
return;
|
|
}
|
|
/**
|
|
* `execute_code` no longer requires a per-user credential — sandbox
|
|
* auth is handled server-side by the agents library. Always report
|
|
* system-authenticated so the client proceeds straight to the call
|
|
* without a key-entry dialog.
|
|
*
|
|
* Deployment contract: reachability of the sandbox service is the
|
|
* admin's responsibility. This endpoint does not probe the service
|
|
* (a per-auth-check network hop would be too expensive for what is
|
|
* a UI-gate query). If the sandbox is unreachable, the call path
|
|
* surfaces the error at execution time instead of here.
|
|
*/
|
|
res.status(200).json({ authenticated: true, message: AuthType.SYSTEM_DEFINED });
|
|
} catch (error) {
|
|
res.status(500).json({ message: error.message });
|
|
}
|
|
};
|
|
|
|
/**
|
|
* @param {ServerRequest} req - The request object, containing information about the HTTP request.
|
|
* @param {ServerResponse} res - The response object, used to send back the desired HTTP response.
|
|
* @param {NextFunction} next - The next middleware function to call.
|
|
* @returns {Promise<void>} A promise that resolves when the function has completed.
|
|
*/
|
|
const callTool = async (req, res) => {
|
|
try {
|
|
const appConfig = req.config;
|
|
const { toolId = '' } = req.params;
|
|
if (!directCallableTools.has(toolId)) {
|
|
logger.warn(`[${toolId}/call] User ${req.user.id} attempted call to invalid tool`);
|
|
res.status(404).json({ message: 'Tool not found' });
|
|
return;
|
|
}
|
|
|
|
const { partIndex, blockIndex, messageId, conversationId, ...args } = req.body;
|
|
if (!messageId) {
|
|
logger.warn(`[${toolId}/call] User ${req.user.id} attempted call without message ID`);
|
|
res.status(400).json({ message: 'Message ID required' });
|
|
return;
|
|
}
|
|
|
|
const message = await getMessage({ user: req.user.id, messageId });
|
|
if (!message) {
|
|
logger.debug(`[${toolId}/call] User ${req.user.id} attempted call with invalid message ID`);
|
|
res.status(404).json({ message: 'Message not found' });
|
|
return;
|
|
}
|
|
logger.debug(`[${toolId}/call] User: ${req.user.id}`);
|
|
let hasAccess = true;
|
|
if (toolAccessPermType[toolId]) {
|
|
hasAccess = await checkAccess({
|
|
user: req.user,
|
|
permissionType: toolAccessPermType[toolId],
|
|
permissions: [Permissions.USE],
|
|
getRoleByName,
|
|
});
|
|
}
|
|
if (!hasAccess) {
|
|
logger.warn(
|
|
`[${toolAccessPermType[toolId]}] Forbidden: Insufficient permissions for User ${req.user.id}: ${Permissions.USE}`,
|
|
);
|
|
return res.status(403).json({ message: 'Forbidden: Insufficient permissions' });
|
|
}
|
|
const { loadedTools } = await loadTools({
|
|
user: req.user.id,
|
|
tools: [toolId],
|
|
functions: true,
|
|
options: {
|
|
req,
|
|
returnMetadata: true,
|
|
processFileURL,
|
|
uploadImageBuffer,
|
|
},
|
|
webSearch: appConfig.webSearch,
|
|
fileStrategy: appConfig.fileStrategy,
|
|
imageOutputType: appConfig.imageOutputType,
|
|
});
|
|
|
|
const tool = loadedTools[0];
|
|
const toolCallId = `${req.user.id}_${nanoid()}`;
|
|
const result = await tool.invoke({
|
|
args,
|
|
name: toolId,
|
|
id: toolCallId,
|
|
type: ToolCallTypes.TOOL_CALL,
|
|
});
|
|
|
|
const { content, artifact } = result;
|
|
const toolCallData = {
|
|
toolId,
|
|
messageId,
|
|
partIndex,
|
|
blockIndex,
|
|
conversationId,
|
|
result: content,
|
|
user: req.user.id,
|
|
};
|
|
|
|
if (!artifact || !artifact.files || toolId !== Tools.execute_code) {
|
|
createToolCall(toolCallData).catch((error) => {
|
|
logger.error(`Error creating tool call: ${error.message}`);
|
|
});
|
|
return res.status(200).json({
|
|
result: content,
|
|
});
|
|
}
|
|
|
|
const artifactPromises = [];
|
|
for (const file of artifact.files) {
|
|
/* Files flagged `inherited` by codeapi are unchanged passthroughs of
|
|
* inputs the caller already owns (skill files, prior downloaded inputs,
|
|
* inherited .dirkeep markers). Re-downloading them is wasted work and
|
|
* 403s when the file is scoped to a different entity (e.g. skill
|
|
* entity_id) than the user's session key. They remain available for
|
|
* subsequent tool calls via primeInvokedSkills / session inheritance. */
|
|
if (file.inherited) {
|
|
continue;
|
|
}
|
|
const { id, name } = file;
|
|
artifactPromises.push(
|
|
(async () => {
|
|
const fileMetadata = await processCodeOutput({
|
|
req,
|
|
id,
|
|
name,
|
|
messageId,
|
|
toolCallId,
|
|
conversationId,
|
|
session_id: artifact.session_id,
|
|
});
|
|
|
|
if (!fileMetadata) {
|
|
return null;
|
|
}
|
|
|
|
return fileMetadata;
|
|
})().catch((error) => {
|
|
logger.error('Error processing code output:', error);
|
|
return null;
|
|
}),
|
|
);
|
|
}
|
|
const attachments = await Promise.all(artifactPromises);
|
|
toolCallData.attachments = attachments;
|
|
createToolCall(toolCallData).catch((error) => {
|
|
logger.error(`Error creating tool call: ${error.message}`);
|
|
});
|
|
res.status(200).json({
|
|
result: content,
|
|
attachments,
|
|
});
|
|
} catch (error) {
|
|
logger.error('Error calling tool', error);
|
|
res.status(500).json({ message: 'Error calling tool' });
|
|
}
|
|
};
|
|
|
|
const getToolCalls = async (req, res) => {
|
|
try {
|
|
const { conversationId } = req.query;
|
|
const toolCalls = await getToolCallsByConvo(conversationId, req.user.id);
|
|
res.status(200).json(toolCalls);
|
|
} catch (error) {
|
|
logger.error('Error getting tool calls', error);
|
|
res.status(500).json({ message: 'Error getting tool calls' });
|
|
}
|
|
};
|
|
|
|
module.exports = {
|
|
callTool,
|
|
getToolCalls,
|
|
verifyToolAuth,
|
|
};
|