diff --git a/api/app/clients/tools/util/fileSearch.js b/api/app/clients/tools/util/fileSearch.js index 050a0fd896..27686e0480 100644 --- a/api/app/clients/tools/util/fileSearch.js +++ b/api/app/clients/tools/util/fileSearch.js @@ -11,17 +11,25 @@ const { getFiles } = require('~/models/File'); * @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<{ file_id: string; filename: string }>, * toolContext: string * }>} */ const primeFiles = async (options) => { - const { tool_resources } = options; + const { tool_resources, req, agentId } = options; const file_ids = tool_resources?.[EToolResources.file_search]?.file_ids ?? []; const agentResourceIds = new Set(file_ids); const resourceFiles = tool_resources?.[EToolResources.file_search]?.files ?? []; - const dbFiles = ((await getFiles({ file_id: { $in: file_ids } })) ?? []).concat(resourceFiles); + const dbFiles = ( + (await getFiles( + { file_id: { $in: file_ids } }, + null, + { text: 0 }, + { userId: req?.user?.id, agentId }, + )) ?? [] + ).concat(resourceFiles); let toolContext = `- Note: Semantic search is available through the ${Tools.file_search} tool but no files are currently loaded. Request the user to upload documents to search through.`; diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index c233c0f762..5dcefd6df3 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -245,7 +245,13 @@ const loadTools = async ({ authFields: [EnvVar.CODE_API_KEY], }); const codeApiKey = authValues[EnvVar.CODE_API_KEY]; - const { files, toolContext } = await primeCodeFiles(options, codeApiKey); + const { files, toolContext } = await primeCodeFiles( + { + ...options, + agentId: agent?.id, + }, + codeApiKey, + ); if (toolContext) { toolContextMap[tool] = toolContext; } @@ -260,7 +266,10 @@ const loadTools = async ({ continue; } else if (tool === Tools.file_search) { requestedTools[tool] = async () => { - const { files, toolContext } = await primeSearchFiles(options); + const { files, toolContext } = await primeSearchFiles({ + ...options, + agentId: agent?.id, + }); if (toolContext) { toolContextMap[tool] = toolContext; } diff --git a/api/models/File.js b/api/models/File.js index 1ee943131d..6d1d77a220 100644 --- a/api/models/File.js +++ b/api/models/File.js @@ -1,5 +1,7 @@ const { logger } = require('@librechat/data-schemas'); -const { EToolResources, FileContext } = require('librechat-data-provider'); +const { EToolResources, FileContext, Constants } = require('librechat-data-provider'); +const { getProjectByName } = require('./Project'); +const { getAgent } = require('./Agent'); const { File } = require('~/db/models'); /** @@ -12,17 +14,119 @@ const findFileById = async (file_id, options = {}) => { return await File.findOne({ file_id, ...options }).lean(); }; +/** + * Checks if a user has access to multiple files through a shared agent (batch operation) + * @param {string} userId - The user ID to check access for + * @param {string[]} fileIds - Array of file IDs to check + * @param {string} agentId - The agent ID that might grant access + * @returns {Promise>} Map of fileId to access status + */ +const hasAccessToFilesViaAgent = async (userId, fileIds, agentId) => { + const accessMap = new Map(); + + // Initialize all files as no access + fileIds.forEach((fileId) => accessMap.set(fileId, false)); + + try { + const agent = await getAgent({ id: agentId }); + + if (!agent) { + return accessMap; + } + + // Check if user is the author - if so, grant access to all files + if (agent.author.toString() === userId) { + fileIds.forEach((fileId) => accessMap.set(fileId, true)); + return accessMap; + } + + // Check if agent is shared with the user via projects + if (!agent.projectIds || agent.projectIds.length === 0) { + return accessMap; + } + + // Check if agent is in global project + const globalProject = await getProjectByName(Constants.GLOBAL_PROJECT_NAME, '_id'); + if ( + !globalProject || + !agent.projectIds.some((pid) => pid.toString() === globalProject._id.toString()) + ) { + return accessMap; + } + + // Agent is globally shared - check if it's collaborative + if (!agent.isCollaborative) { + return accessMap; + } + + // Agent is globally shared and collaborative - check which files are actually attached + const attachedFileIds = new Set(); + if (agent.tool_resources) { + for (const [_resourceType, resource] of Object.entries(agent.tool_resources)) { + if (resource?.file_ids && Array.isArray(resource.file_ids)) { + resource.file_ids.forEach((fileId) => attachedFileIds.add(fileId)); + } + } + } + + // Grant access only to files that are attached to this agent + fileIds.forEach((fileId) => { + if (attachedFileIds.has(fileId)) { + accessMap.set(fileId, true); + } + }); + + return accessMap; + } catch (error) { + logger.error('[hasAccessToFilesViaAgent] Error checking file access:', error); + return accessMap; + } +}; + /** * Retrieves files matching a given filter, sorted by the most recently updated. * @param {Object} filter - The filter criteria to apply. * @param {Object} [_sortOptions] - Optional sort parameters. * @param {Object|String} [selectFields={ text: 0 }] - Fields to include/exclude in the query results. * Default excludes the 'text' field. + * @param {Object} [options] - Additional options + * @param {string} [options.userId] - User ID for access control + * @param {string} [options.agentId] - Agent ID that might grant access to files * @returns {Promise>} A promise that resolves to an array of file documents. */ -const getFiles = async (filter, _sortOptions, selectFields = { text: 0 }) => { +const getFiles = async (filter, _sortOptions, selectFields = { text: 0 }, options = {}) => { const sortOptions = { updatedAt: -1, ..._sortOptions }; - return await File.find(filter).select(selectFields).sort(sortOptions).lean(); + const files = await File.find(filter).select(selectFields).sort(sortOptions).lean(); + + // If userId and agentId are provided, filter files based on access + if (options.userId && options.agentId) { + // Collect file IDs that need access check + const filesToCheck = []; + const ownedFiles = []; + + for (const file of files) { + if (file.user && file.user.toString() === options.userId) { + ownedFiles.push(file); + } else { + filesToCheck.push(file); + } + } + + if (filesToCheck.length === 0) { + return ownedFiles; + } + + // Batch check access for all non-owned files + const fileIds = filesToCheck.map((f) => f.file_id); + const accessMap = await hasAccessToFilesViaAgent(options.userId, fileIds, options.agentId); + + // Filter files based on access + const accessibleFiles = filesToCheck.filter((file) => accessMap.get(file.file_id)); + + return [...ownedFiles, ...accessibleFiles]; + } + + return files; }; /** @@ -176,4 +280,5 @@ module.exports = { deleteFiles, deleteFileByFilter, batchUpdateFiles, + hasAccessToFilesViaAgent, }; diff --git a/api/models/File.spec.js b/api/models/File.spec.js new file mode 100644 index 0000000000..9861571e33 --- /dev/null +++ b/api/models/File.spec.js @@ -0,0 +1,264 @@ +const mongoose = require('mongoose'); +const { v4: uuidv4 } = require('uuid'); +const { fileSchema } = require('@librechat/data-schemas'); +const { agentSchema } = require('@librechat/data-schemas'); +const { projectSchema } = require('@librechat/data-schemas'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants; +const { getFiles, createFile } = require('./File'); +const { getProjectByName } = require('./Project'); +const { createAgent } = require('./Agent'); + +let File; +let Agent; +let Project; + +describe('File Access Control', () => { + let mongoServer; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + const mongoUri = mongoServer.getUri(); + File = mongoose.models.File || mongoose.model('File', fileSchema); + Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema); + Project = mongoose.models.Project || mongoose.model('Project', projectSchema); + await mongoose.connect(mongoUri); + }); + + afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + beforeEach(async () => { + await File.deleteMany({}); + await Agent.deleteMany({}); + await Project.deleteMany({}); + }); + + describe('hasAccessToFilesViaAgent', () => { + it('should efficiently check access for multiple files at once', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const authorId = new mongoose.Types.ObjectId().toString(); + const agentId = uuidv4(); + const fileIds = [uuidv4(), uuidv4(), uuidv4(), uuidv4()]; + + // Create files + for (const fileId of fileIds) { + await createFile({ + user: authorId, + file_id: fileId, + filename: `file-${fileId}.txt`, + filepath: `/uploads/${fileId}`, + }); + } + + // Create agent with only first two files attached + await createAgent({ + id: agentId, + name: 'Test Agent', + author: authorId, + model: 'gpt-4', + provider: 'openai', + isCollaborative: true, + tool_resources: { + file_search: { + file_ids: [fileIds[0], fileIds[1]], + }, + }, + }); + + // Get or create global project + const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id'); + + // Share agent globally + await Agent.updateOne({ id: agentId }, { $push: { projectIds: globalProject._id } }); + + // Check access for all files + const { hasAccessToFilesViaAgent } = require('./File'); + const accessMap = await hasAccessToFilesViaAgent(userId, fileIds, agentId); + + // Should have access only to the first two files + expect(accessMap.get(fileIds[0])).toBe(true); + expect(accessMap.get(fileIds[1])).toBe(true); + expect(accessMap.get(fileIds[2])).toBe(false); + expect(accessMap.get(fileIds[3])).toBe(false); + }); + + it('should grant access to all files when user is the agent author', async () => { + const authorId = new mongoose.Types.ObjectId().toString(); + const agentId = uuidv4(); + const fileIds = [uuidv4(), uuidv4(), uuidv4()]; + + // Create agent + await createAgent({ + id: agentId, + name: 'Test Agent', + author: authorId, + model: 'gpt-4', + provider: 'openai', + tool_resources: { + file_search: { + file_ids: [fileIds[0]], // Only one file attached + }, + }, + }); + + // Check access as the author + const { hasAccessToFilesViaAgent } = require('./File'); + const accessMap = await hasAccessToFilesViaAgent(authorId, fileIds, agentId); + + // Author should have access to all files + expect(accessMap.get(fileIds[0])).toBe(true); + expect(accessMap.get(fileIds[1])).toBe(true); + expect(accessMap.get(fileIds[2])).toBe(true); + }); + + it('should handle non-existent agent gracefully', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const fileIds = [uuidv4(), uuidv4()]; + + const { hasAccessToFilesViaAgent } = require('./File'); + const accessMap = await hasAccessToFilesViaAgent(userId, fileIds, 'non-existent-agent'); + + // Should have no access to any files + expect(accessMap.get(fileIds[0])).toBe(false); + expect(accessMap.get(fileIds[1])).toBe(false); + }); + + it('should deny access when agent is not collaborative', async () => { + const userId = new mongoose.Types.ObjectId().toString(); + const authorId = new mongoose.Types.ObjectId().toString(); + const agentId = uuidv4(); + const fileIds = [uuidv4(), uuidv4()]; + + // Create agent with files but isCollaborative: false + await createAgent({ + id: agentId, + name: 'Non-Collaborative Agent', + author: authorId, + model: 'gpt-4', + provider: 'openai', + isCollaborative: false, + tool_resources: { + file_search: { + file_ids: fileIds, + }, + }, + }); + + // Get or create global project + const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id'); + + // Share agent globally + await Agent.updateOne({ id: agentId }, { $push: { projectIds: globalProject._id } }); + + // Check access for files + const { hasAccessToFilesViaAgent } = require('./File'); + const accessMap = await hasAccessToFilesViaAgent(userId, fileIds, agentId); + + // Should have no access to any files when isCollaborative is false + expect(accessMap.get(fileIds[0])).toBe(false); + expect(accessMap.get(fileIds[1])).toBe(false); + }); + }); + + describe('getFiles with agent access control', () => { + test('should return files owned by user and files accessible through agent', async () => { + const authorId = new mongoose.Types.ObjectId(); + const userId = new mongoose.Types.ObjectId(); + const agentId = `agent_${uuidv4()}`; + const ownedFileId = `file_${uuidv4()}`; + const sharedFileId = `file_${uuidv4()}`; + const inaccessibleFileId = `file_${uuidv4()}`; + + // Create/get global project using getProjectByName which will upsert + const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME); + + // Create agent with shared file + await createAgent({ + id: agentId, + name: 'Shared Agent', + provider: 'test', + model: 'test-model', + author: authorId, + projectIds: [globalProject._id], + isCollaborative: true, + tool_resources: { + file_search: { + file_ids: [sharedFileId], + }, + }, + }); + + // Create files + await createFile({ + file_id: ownedFileId, + user: userId, + filename: 'owned.txt', + filepath: '/uploads/owned.txt', + type: 'text/plain', + bytes: 100, + }); + + await createFile({ + file_id: sharedFileId, + user: authorId, + filename: 'shared.txt', + filepath: '/uploads/shared.txt', + type: 'text/plain', + bytes: 200, + embedded: true, + }); + + await createFile({ + file_id: inaccessibleFileId, + user: authorId, + filename: 'inaccessible.txt', + filepath: '/uploads/inaccessible.txt', + type: 'text/plain', + bytes: 300, + }); + + // Get files with access control + const files = await getFiles( + { file_id: { $in: [ownedFileId, sharedFileId, inaccessibleFileId] } }, + null, + { text: 0 }, + { userId: userId.toString(), agentId }, + ); + + expect(files).toHaveLength(2); + expect(files.map((f) => f.file_id)).toContain(ownedFileId); + expect(files.map((f) => f.file_id)).toContain(sharedFileId); + expect(files.map((f) => f.file_id)).not.toContain(inaccessibleFileId); + }); + + test('should return all files when no userId/agentId provided', async () => { + const userId = new mongoose.Types.ObjectId(); + const fileId1 = `file_${uuidv4()}`; + const fileId2 = `file_${uuidv4()}`; + + await createFile({ + file_id: fileId1, + user: userId, + filename: 'file1.txt', + filepath: '/uploads/file1.txt', + type: 'text/plain', + bytes: 100, + }); + + await createFile({ + file_id: fileId2, + user: new mongoose.Types.ObjectId(), + filename: 'file2.txt', + filepath: '/uploads/file2.txt', + type: 'text/plain', + bytes: 200, + }); + + const files = await getFiles({ file_id: { $in: [fileId1, fileId2] } }); + expect(files).toHaveLength(2); + }); + }); +}); diff --git a/api/server/routes/files/files.agents.test.js b/api/server/routes/files/files.agents.test.js new file mode 100644 index 0000000000..a669559982 --- /dev/null +++ b/api/server/routes/files/files.agents.test.js @@ -0,0 +1,282 @@ +const express = require('express'); +const request = require('supertest'); +const mongoose = require('mongoose'); +const { v4: uuidv4 } = require('uuid'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants; + +// Mock dependencies +jest.mock('~/server/services/Files/process', () => ({ + processDeleteRequest: jest.fn().mockResolvedValue({}), + filterFile: jest.fn(), + processFileUpload: jest.fn(), + processAgentFileUpload: jest.fn(), +})); + +jest.mock('~/server/services/Files/strategies', () => ({ + getStrategyFunctions: jest.fn(() => ({})), +})); + +jest.mock('~/server/controllers/assistants/helpers', () => ({ + getOpenAIClient: jest.fn(), +})); + +jest.mock('~/server/services/Tools/credentials', () => ({ + loadAuthValues: jest.fn(), +})); + +jest.mock('~/server/services/Files/S3/crud', () => ({ + refreshS3FileUrls: jest.fn(), +})); + +jest.mock('~/cache', () => ({ + getLogStores: jest.fn(() => ({ + get: jest.fn(), + set: jest.fn(), + })), +})); + +jest.mock('~/config', () => ({ + logger: { + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, +})); + +const { createFile } = require('~/models/File'); +const { createAgent } = require('~/models/Agent'); +const { getProjectByName } = require('~/models/Project'); + +// Import the router after mocks +const router = require('./files'); + +describe('File Routes - Agent Files Endpoint', () => { + let app; + let mongoServer; + let authorId; + let otherUserId; + let agentId; + let fileId1; + let fileId2; + let fileId3; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + + // Initialize models + require('~/db/models'); + + app = express(); + app.use(express.json()); + + // Mock authentication middleware + app.use((req, res, next) => { + req.user = { id: otherUserId || 'default-user' }; + req.app = { locals: {} }; + next(); + }); + + app.use('/files', router); + }); + + afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + + // Clear database + const collections = mongoose.connection.collections; + for (const key in collections) { + await collections[key].deleteMany({}); + } + + authorId = new mongoose.Types.ObjectId().toString(); + otherUserId = new mongoose.Types.ObjectId().toString(); + agentId = uuidv4(); + fileId1 = uuidv4(); + fileId2 = uuidv4(); + fileId3 = uuidv4(); + + // Create files + await createFile({ + user: authorId, + file_id: fileId1, + filename: 'agent-file1.txt', + filepath: `/uploads/${authorId}/${fileId1}`, + bytes: 1024, + type: 'text/plain', + }); + + await createFile({ + user: authorId, + file_id: fileId2, + filename: 'agent-file2.txt', + filepath: `/uploads/${authorId}/${fileId2}`, + bytes: 2048, + type: 'text/plain', + }); + + await createFile({ + user: otherUserId, + file_id: fileId3, + filename: 'user-file.txt', + filepath: `/uploads/${otherUserId}/${fileId3}`, + bytes: 512, + type: 'text/plain', + }); + + // Create an agent with files attached + await createAgent({ + id: agentId, + name: 'Test Agent', + author: authorId, + model: 'gpt-4', + provider: 'openai', + isCollaborative: true, + tool_resources: { + file_search: { + file_ids: [fileId1, fileId2], + }, + }, + }); + + // Share the agent globally + const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id'); + if (globalProject) { + const { updateAgent } = require('~/models/Agent'); + await updateAgent({ id: agentId }, { projectIds: [globalProject._id] }); + } + }); + + describe('GET /files/agent/:agent_id', () => { + it('should return files accessible through the agent for non-author', async () => { + const response = await request(app).get(`/files/agent/${agentId}`); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(2); // Only agent files, not user-owned files + + const fileIds = response.body.map((f) => f.file_id); + expect(fileIds).toContain(fileId1); + expect(fileIds).toContain(fileId2); + expect(fileIds).not.toContain(fileId3); // User's own file not included + }); + + it('should return 400 when agent_id is not provided', async () => { + const response = await request(app).get('/files/agent/'); + + expect(response.status).toBe(404); // Express returns 404 for missing route parameter + }); + + it('should return empty array for non-existent agent', async () => { + const response = await request(app).get('/files/agent/non-existent-agent'); + + expect(response.status).toBe(200); + expect(response.body).toEqual([]); // Empty array for non-existent agent + }); + + it('should return empty array when agent is not collaborative', async () => { + // Create a non-collaborative agent + const nonCollabAgentId = uuidv4(); + await createAgent({ + id: nonCollabAgentId, + name: 'Non-Collaborative Agent', + author: authorId, + model: 'gpt-4', + provider: 'openai', + isCollaborative: false, + tool_resources: { + file_search: { + file_ids: [fileId1], + }, + }, + }); + + // Share it globally + const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id'); + if (globalProject) { + const { updateAgent } = require('~/models/Agent'); + await updateAgent({ id: nonCollabAgentId }, { projectIds: [globalProject._id] }); + } + + const response = await request(app).get(`/files/agent/${nonCollabAgentId}`); + + expect(response.status).toBe(200); + expect(response.body).toEqual([]); // Empty array when not collaborative + }); + + it('should return agent files for agent author', async () => { + // Create a new app instance with author authentication + const authorApp = express(); + authorApp.use(express.json()); + authorApp.use((req, res, next) => { + req.user = { id: authorId }; + req.app = { locals: {} }; + next(); + }); + authorApp.use('/files', router); + + const response = await request(authorApp).get(`/files/agent/${agentId}`); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(2); // Agent files for author + + const fileIds = response.body.map((f) => f.file_id); + expect(fileIds).toContain(fileId1); + expect(fileIds).toContain(fileId2); + expect(fileIds).not.toContain(fileId3); // User's own file not included + }); + + it('should return files uploaded by other users to shared agent for author', async () => { + // Create a file uploaded by another user + const otherUserFileId = uuidv4(); + const anotherUserId = new mongoose.Types.ObjectId().toString(); + + await createFile({ + user: anotherUserId, + file_id: otherUserFileId, + filename: 'other-user-file.txt', + filepath: `/uploads/${anotherUserId}/${otherUserFileId}`, + bytes: 4096, + type: 'text/plain', + }); + + // Update agent to include the file uploaded by another user + const { updateAgent } = require('~/models/Agent'); + await updateAgent( + { id: agentId }, + { + tool_resources: { + file_search: { + file_ids: [fileId1, fileId2, otherUserFileId], + }, + }, + }, + ); + + // Create app instance with author authentication + const authorApp = express(); + authorApp.use(express.json()); + authorApp.use((req, res, next) => { + req.user = { id: authorId }; + req.app = { locals: {} }; + next(); + }); + authorApp.use('/files', router); + + const response = await request(authorApp).get(`/files/agent/${agentId}`); + + expect(response.status).toBe(200); + expect(response.body).toHaveLength(3); // Including file from another user + + const fileIds = response.body.map((f) => f.file_id); + expect(fileIds).toContain(fileId1); + expect(fileIds).toContain(fileId2); + expect(fileIds).toContain(otherUserFileId); // File uploaded by another user + }); + }); +}); diff --git a/api/server/routes/files/files.js b/api/server/routes/files/files.js index bdfdca65cf..e0b354ef6e 100644 --- a/api/server/routes/files/files.js +++ b/api/server/routes/files/files.js @@ -5,6 +5,7 @@ const { Time, isUUID, CacheKeys, + Constants, FileSources, EModelEndpoint, isAgentsEndpoint, @@ -16,11 +17,12 @@ const { processDeleteRequest, processAgentFileUpload, } = require('~/server/services/Files/process'); +const { getFiles, batchUpdateFiles, hasAccessToFilesViaAgent } = require('~/models/File'); const { getStrategyFunctions } = require('~/server/services/Files/strategies'); const { getOpenAIClient } = require('~/server/controllers/assistants/helpers'); const { loadAuthValues } = require('~/server/services/Tools/credentials'); const { refreshS3FileUrls } = require('~/server/services/Files/S3/crud'); -const { getFiles, batchUpdateFiles } = require('~/models/File'); +const { getProjectByName } = require('~/models/Project'); const { getAssistant } = require('~/models/Assistant'); const { getAgent } = require('~/models/Agent'); const { getLogStores } = require('~/cache'); @@ -50,6 +52,68 @@ router.get('/', async (req, res) => { } }); +/** + * 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} 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' }); + } + + // Get the agent to check ownership and attached files + const agent = await getAgent({ id: agent_id }); + + if (!agent) { + // No agent found, return empty array + return res.status(200).json([]); + } + + // Check if user has access to the agent + if (agent.author.toString() !== userId) { + // Non-authors need the agent to be globally shared and collaborative + const globalProject = await getProjectByName(Constants.GLOBAL_PROJECT_NAME, '_id'); + + if ( + !globalProject || + !agent.projectIds.some((pid) => pid.toString() === globalProject._id.toString()) || + !agent.isCollaborative + ) { + return res.status(200).json([]); + } + } + + // Collect all file IDs from agent's tool resources + 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 no files attached to agent, return empty array + if (agentFileIds.length === 0) { + return res.status(200).json([]); + } + + // Get only the files attached to this agent + 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 { res.status(200).json(req.app.locals.fileConfig); @@ -86,11 +150,62 @@ router.delete('/', async (req, res) => { const fileIds = files.map((file) => file.file_id); const dbFiles = await getFiles({ file_id: { $in: fileIds } }); - const unauthorizedFiles = dbFiles.filter((file) => file.user.toString() !== req.user.id); + + const ownedFiles = []; + const nonOwnedFiles = []; + const fileMap = new Map(); + + for (const file of dbFiles) { + fileMap.set(file.file_id, file); + if (file.user.toString() === req.user.id) { + ownedFiles.push(file); + } else { + nonOwnedFiles.push(file); + } + } + + // If all files are owned by the user, no need for further checks + 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; + } + + // Check access for non-owned files + let authorizedFiles = [...ownedFiles]; + let unauthorizedFiles = []; + + if (req.body.agent_id && nonOwnedFiles.length > 0) { + // Batch check access for all non-owned files + const nonOwnedFileIds = nonOwnedFiles.map((f) => f.file_id); + const accessMap = await hasAccessToFilesViaAgent( + req.user.id, + nonOwnedFileIds, + req.body.agent_id, + ); + + // Separate authorized and unauthorized files + for (const file of nonOwnedFiles) { + if (accessMap.get(file.file_id)) { + authorizedFiles.push(file); + } else { + unauthorizedFiles.push(file); + } + } + } else { + // No agent context, all non-owned files are unauthorized + unauthorizedFiles = nonOwnedFiles; + } if (unauthorizedFiles.length > 0) { return res.status(403).json({ - message: 'You can only delete your own files', + message: 'You can only delete files you have access to', unauthorizedFiles: unauthorizedFiles.map((f) => f.file_id), }); } @@ -131,10 +246,10 @@ router.delete('/', async (req, res) => { .json({ message: 'File associations removed successfully from Azure Assistant' }); } - await processDeleteRequest({ req, files: dbFiles }); + await processDeleteRequest({ req, files: authorizedFiles }); logger.debug( - `[/files] Files deleted successfully: ${files + `[/files] Files deleted successfully: ${authorizedFiles .filter((f) => f.file_id) .map((f) => f.file_id) .join(', ')}`, diff --git a/api/server/routes/files/files.test.js b/api/server/routes/files/files.test.js new file mode 100644 index 0000000000..3f5ae9fc41 --- /dev/null +++ b/api/server/routes/files/files.test.js @@ -0,0 +1,302 @@ +const express = require('express'); +const request = require('supertest'); +const mongoose = require('mongoose'); +const { v4: uuidv4 } = require('uuid'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants; + +// Mock dependencies +jest.mock('~/server/services/Files/process', () => ({ + processDeleteRequest: jest.fn().mockResolvedValue({}), + filterFile: jest.fn(), + processFileUpload: jest.fn(), + processAgentFileUpload: jest.fn(), +})); + +jest.mock('~/server/services/Files/strategies', () => ({ + getStrategyFunctions: jest.fn(() => ({})), +})); + +jest.mock('~/server/controllers/assistants/helpers', () => ({ + getOpenAIClient: jest.fn(), +})); + +jest.mock('~/server/services/Tools/credentials', () => ({ + loadAuthValues: jest.fn(), +})); + +jest.mock('~/server/services/Files/S3/crud', () => ({ + refreshS3FileUrls: jest.fn(), +})); + +jest.mock('~/cache', () => ({ + getLogStores: jest.fn(() => ({ + get: jest.fn(), + set: jest.fn(), + })), +})); + +jest.mock('~/config', () => ({ + logger: { + error: jest.fn(), + warn: jest.fn(), + debug: jest.fn(), + }, +})); + +const { createFile } = require('~/models/File'); +const { createAgent } = require('~/models/Agent'); +const { getProjectByName } = require('~/models/Project'); +const { processDeleteRequest } = require('~/server/services/Files/process'); + +// Import the router after mocks +const router = require('./files'); + +describe('File Routes - Delete with Agent Access', () => { + let app; + let mongoServer; + let authorId; + let otherUserId; + let agentId; + let fileId; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + + // Initialize models + require('~/db/models'); + + app = express(); + app.use(express.json()); + + // Mock authentication middleware + app.use((req, res, next) => { + req.user = { id: otherUserId || 'default-user' }; + req.app = { locals: {} }; + next(); + }); + + app.use('/files', router); + }); + + afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + + // Clear database + const collections = mongoose.connection.collections; + for (const key in collections) { + await collections[key].deleteMany({}); + } + + authorId = new mongoose.Types.ObjectId().toString(); + otherUserId = new mongoose.Types.ObjectId().toString(); + fileId = uuidv4(); + + // Create a file owned by the author + await createFile({ + user: authorId, + file_id: fileId, + filename: 'test.txt', + filepath: `/uploads/${authorId}/${fileId}`, + bytes: 1024, + type: 'text/plain', + }); + + // Create an agent with the file attached + const agent = await createAgent({ + id: uuidv4(), + name: 'Test Agent', + author: authorId, + model: 'gpt-4', + provider: 'openai', + isCollaborative: true, + tool_resources: { + file_search: { + file_ids: [fileId], + }, + }, + }); + agentId = agent.id; + + // Share the agent globally + const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, '_id'); + if (globalProject) { + const { updateAgent } = require('~/models/Agent'); + await updateAgent({ id: agentId }, { projectIds: [globalProject._id] }); + } + }); + + describe('DELETE /files', () => { + it('should allow deleting files owned by the user', async () => { + // Create a file owned by the current user + const userFileId = uuidv4(); + await createFile({ + user: otherUserId, + file_id: userFileId, + filename: 'user-file.txt', + filepath: `/uploads/${otherUserId}/${userFileId}`, + bytes: 1024, + type: 'text/plain', + }); + + const response = await request(app) + .delete('/files') + .send({ + files: [ + { + file_id: userFileId, + filepath: `/uploads/${otherUserId}/${userFileId}`, + }, + ], + }); + + expect(response.status).toBe(200); + expect(response.body.message).toBe('Files deleted successfully'); + expect(processDeleteRequest).toHaveBeenCalled(); + }); + + it('should prevent deleting files not owned by user without agent context', async () => { + const response = await request(app) + .delete('/files') + .send({ + files: [ + { + file_id: fileId, + filepath: `/uploads/${authorId}/${fileId}`, + }, + ], + }); + + expect(response.status).toBe(403); + expect(response.body.message).toBe('You can only delete files you have access to'); + expect(response.body.unauthorizedFiles).toContain(fileId); + expect(processDeleteRequest).not.toHaveBeenCalled(); + }); + + it('should allow deleting files accessible through shared agent', async () => { + const response = await request(app) + .delete('/files') + .send({ + agent_id: agentId, + files: [ + { + file_id: fileId, + filepath: `/uploads/${authorId}/${fileId}`, + }, + ], + }); + + expect(response.status).toBe(200); + expect(response.body.message).toBe('Files deleted successfully'); + expect(processDeleteRequest).toHaveBeenCalled(); + }); + + it('should prevent deleting files not attached to the specified agent', async () => { + // Create another file not attached to the agent + const unattachedFileId = uuidv4(); + await createFile({ + user: authorId, + file_id: unattachedFileId, + filename: 'unattached.txt', + filepath: `/uploads/${authorId}/${unattachedFileId}`, + bytes: 1024, + type: 'text/plain', + }); + + const response = await request(app) + .delete('/files') + .send({ + agent_id: agentId, + files: [ + { + file_id: unattachedFileId, + filepath: `/uploads/${authorId}/${unattachedFileId}`, + }, + ], + }); + + expect(response.status).toBe(403); + expect(response.body.message).toBe('You can only delete files you have access to'); + expect(response.body.unauthorizedFiles).toContain(unattachedFileId); + }); + + it('should handle mixed authorized and unauthorized files', async () => { + // Create a file owned by the current user + const userFileId = uuidv4(); + await createFile({ + user: otherUserId, + file_id: userFileId, + filename: 'user-file.txt', + filepath: `/uploads/${otherUserId}/${userFileId}`, + bytes: 1024, + type: 'text/plain', + }); + + // Create an unauthorized file + const unauthorizedFileId = uuidv4(); + await createFile({ + user: authorId, + file_id: unauthorizedFileId, + filename: 'unauthorized.txt', + filepath: `/uploads/${authorId}/${unauthorizedFileId}`, + bytes: 1024, + type: 'text/plain', + }); + + const response = await request(app) + .delete('/files') + .send({ + agent_id: agentId, + files: [ + { + file_id: fileId, // Authorized through agent + filepath: `/uploads/${authorId}/${fileId}`, + }, + { + file_id: userFileId, // Owned by user + filepath: `/uploads/${otherUserId}/${userFileId}`, + }, + { + file_id: unauthorizedFileId, // Not authorized + filepath: `/uploads/${authorId}/${unauthorizedFileId}`, + }, + ], + }); + + expect(response.status).toBe(403); + expect(response.body.message).toBe('You can only delete files you have access to'); + expect(response.body.unauthorizedFiles).toContain(unauthorizedFileId); + expect(response.body.unauthorizedFiles).not.toContain(fileId); + expect(response.body.unauthorizedFiles).not.toContain(userFileId); + }); + + it('should prevent deleting files when agent is not collaborative', async () => { + // Update the agent to be non-collaborative + const { updateAgent } = require('~/models/Agent'); + await updateAgent({ id: agentId }, { isCollaborative: false }); + + const response = await request(app) + .delete('/files') + .send({ + agent_id: agentId, + files: [ + { + file_id: fileId, + filepath: `/uploads/${authorId}/${fileId}`, + }, + ], + }); + + expect(response.status).toBe(403); + expect(response.body.message).toBe('You can only delete files you have access to'); + expect(response.body.unauthorizedFiles).toContain(fileId); + expect(processDeleteRequest).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/api/server/services/Endpoints/agents/agent.js b/api/server/services/Endpoints/agents/agent.js index 6fde14b366..149c626cc1 100644 --- a/api/server/services/Endpoints/agents/agent.js +++ b/api/server/services/Endpoints/agents/agent.js @@ -11,8 +11,8 @@ const { replaceSpecialVars, providerEndpointMap, } = require('librechat-data-provider'); -const { getProviderConfig } = require('~/server/services/Endpoints'); const generateArtifactsPrompt = require('~/app/clients/prompts/artifacts'); +const { getProviderConfig } = require('~/server/services/Endpoints'); const { processFiles } = require('~/server/services/Files/process'); const { getFiles, getToolFilesByIds } = require('~/models/File'); const { getConvoFiles } = require('~/models/Conversation'); @@ -82,6 +82,7 @@ const initializeAgent = async ({ attachments: currentFiles, tool_resources: agent.tool_resources, requestFileSet: new Set(requestFiles?.map((file) => file.file_id)), + agentId: agent.id, }); const provider = agent.provider; diff --git a/api/server/services/Files/Code/process.js b/api/server/services/Files/Code/process.js index cf65154983..22947379c5 100644 --- a/api/server/services/Files/Code/process.js +++ b/api/server/services/Files/Code/process.js @@ -152,6 +152,7 @@ async function getSessionInfo(fileIdentifier, apiKey) { * @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} apiKey * @returns {Promise<{ * files: Array<{ id: string; session_id: string; name: string }>, @@ -159,11 +160,18 @@ async function getSessionInfo(fileIdentifier, apiKey) { * }>} */ const primeFiles = async (options, apiKey) => { - const { tool_resources } = 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 ?? []; - const dbFiles = ((await getFiles({ file_id: { $in: file_ids } })) ?? []).concat(resourceFiles); + const dbFiles = ( + (await getFiles( + { file_id: { $in: file_ids } }, + null, + { text: 0 }, + { userId: req?.user?.id, agentId }, + )) ?? [] + ).concat(resourceFiles); const files = []; const sessions = new Map(); diff --git a/client/src/components/Chat/Input/Files/FileRow.tsx b/client/src/components/Chat/Input/Files/FileRow.tsx index 7919e2e875..67d687eefd 100644 --- a/client/src/components/Chat/Input/Files/FileRow.tsx +++ b/client/src/components/Chat/Input/Files/FileRow.tsx @@ -2,6 +2,8 @@ import { useEffect } from 'react'; import { EToolResources } from 'librechat-data-provider'; import type { ExtendedFile } from '~/common'; import { useDeleteFilesMutation } from '~/data-provider'; +import { useToastContext } from '~/Providers'; +import { useLocalize } from '~/hooks'; import { useFileDeletion } from '~/hooks/Files'; import FileContainer from './FileContainer'; import { logger } from '~/utils'; @@ -30,6 +32,8 @@ export default function FileRow({ isRTL?: boolean; Wrapper?: React.FC<{ children: React.ReactNode }>; }) { + const localize = useLocalize(); + const { showToast } = useToastContext(); const files = Array.from(_files?.values() ?? []).filter((file) => fileFilter ? fileFilter(file) : true, ); @@ -105,6 +109,10 @@ export default function FileRow({ ) .uniqueFiles.map((file: ExtendedFile, index: number) => { const handleDelete = () => { + showToast({ + message: localize('com_ui_deleting_file'), + status: 'info', + }); if (abortUpload && file.progress < 1) { abortUpload(); } diff --git a/client/src/components/SidePanel/Agents/AgentConfig.tsx b/client/src/components/SidePanel/Agents/AgentConfig.tsx index c2b621e35b..64c038c518 100644 --- a/client/src/components/SidePanel/Agents/AgentConfig.tsx +++ b/client/src/components/SidePanel/Agents/AgentConfig.tsx @@ -7,6 +7,7 @@ import { useToastContext, useFileMapContext, useAgentPanelContext } from '~/Prov import useAgentCapabilities from '~/hooks/Agents/useAgentCapabilities'; import Action from '~/components/SidePanel/Builder/Action'; import { ToolSelectDialog } from '~/components/Tools'; +import { useGetAgentFiles } from '~/data-provider'; import { icons } from '~/hooks/Endpoint/Icons'; import { processAgentOption } from '~/utils'; import Instructions from './Instructions'; @@ -49,6 +50,18 @@ export default function AgentConfig({ createMutation }: Pick { + const newFileMap = { ...fileMap }; + agentFiles.forEach((file) => { + if (file.file_id) { + newFileMap[file.file_id] = file; + } + }); + return newFileMap; + }, [fileMap, agentFiles]); + const { ocrEnabled, codeEnabled, @@ -74,10 +87,10 @@ export default function AgentConfig({ createMutation }: Pick { if (typeof agent === 'string') { @@ -94,10 +107,10 @@ export default function AgentConfig({ createMutation }: Pick { if (typeof agent === 'string') { @@ -114,10 +127,10 @@ export default function AgentConfig({ createMutation }: Pick { if (!agent_id) { diff --git a/client/src/components/SidePanel/Agents/ShareAgent.tsx b/client/src/components/SidePanel/Agents/ShareAgent.tsx index 43aba233d2..7f025ef248 100644 --- a/client/src/components/SidePanel/Agents/ShareAgent.tsx +++ b/client/src/components/SidePanel/Agents/ShareAgent.tsx @@ -135,10 +135,9 @@ export default function ShareAgent({ 'btn btn-neutral border-token-border-light relative h-9 rounded-lg font-medium', removeFocusOutlines, )} - aria-label={localize( - 'com_ui_share_var', - { 0: agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent') }, - )} + aria-label={localize('com_ui_share_var', { + 0: agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent'), + })} type="button" >
@@ -148,10 +147,9 @@ export default function ShareAgent({ - {localize( - 'com_ui_share_var', - { 0: agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent') }, - )} + {localize('com_ui_share_var', { + 0: agentName != null && agentName !== '' ? `"${agentName}"` : localize('com_ui_agent'), + })}
=> { const queryClient = useQueryClient(); - const { onSuccess, ...options } = _options || {}; + const { showToast } = useToastContext(); + const localize = useLocalize(); + const { onSuccess, onError, ...options } = _options || {}; return useMutation([MutationKeys.fileDelete], { mutationFn: (body: t.DeleteFilesBody) => dataService.deleteFiles(body), ...options, + onError: (error, vars, context) => { + if (error && typeof error === 'object' && 'response' in error) { + const errorWithResponse = error as { response?: { status?: number } }; + if (errorWithResponse.response?.status === 403) { + showToast({ + message: localize('com_ui_delete_not_allowed'), + status: 'error', + }); + } + } + onError?.(error, vars, context); + }, onSuccess: (data, vars, context) => { queryClient.setQueryData([QueryKeys.files], (cachefiles) => { const { files: filesDeleted } = vars; @@ -160,6 +176,12 @@ export const useDeleteFilesMutation = ( return (cachefiles ?? []).filter((file) => !fileMap.has(file.file_id)); }); + + showToast({ + message: localize('com_ui_delete_success'), + status: 'success', + }); + onSuccess?.(data, vars, context); if (vars.agent_id != null && vars.agent_id) { queryClient.refetchQueries([QueryKeys.agent, vars.agent_id]); diff --git a/client/src/data-provider/Files/queries.ts b/client/src/data-provider/Files/queries.ts index 4f5805b269..019474263d 100644 --- a/client/src/data-provider/Files/queries.ts +++ b/client/src/data-provider/Files/queries.ts @@ -1,6 +1,6 @@ import { useRecoilValue } from 'recoil'; -import { QueryKeys, dataService } from 'librechat-data-provider'; import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { QueryKeys, DynamicQueryKeys, dataService } from 'librechat-data-provider'; import type { QueryObserverResult, UseQueryOptions } from '@tanstack/react-query'; import type t from 'librechat-data-provider'; import { addFileToCache } from '~/utils'; @@ -19,6 +19,24 @@ export const useGetFiles = ( }); }; +export const useGetAgentFiles = ( + agentId: string | undefined, + config?: UseQueryOptions, +): QueryObserverResult => { + const queriesEnabled = useRecoilValue(store.queriesEnabled); + return useQuery( + DynamicQueryKeys.agentFiles(agentId ?? ''), + () => (agentId ? dataService.getAgentFiles(agentId) : Promise.resolve([])), + { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + ...config, + enabled: (config?.enabled ?? true) === true && queriesEnabled && !!agentId, + }, + ); +}; + export const useGetFileConfig = ( config?: UseQueryOptions, ): QueryObserverResult => { diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index fd271b9923..69a3f29539 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -703,8 +703,11 @@ "com_ui_delete_mcp_error": "Failed to delete MCP server", "com_ui_delete_mcp_success": "MCP server deleted successfully", "com_ui_delete_memory": "Delete Memory", + "com_ui_delete_not_allowed": "Delete operation is not allowed", "com_ui_delete_prompt": "Delete Prompt?", + "com_ui_delete_success": "Successfully deleted", "com_ui_delete_shared_link": "Delete shared link?", + "com_ui_deleting_file": "Deleting file...", "com_ui_delete_tool": "Delete Tool", "com_ui_delete_tool_confirm": "Are you sure you want to delete this tool?", "com_ui_deleted": "Deleted", @@ -1085,4 +1088,4 @@ "com_ui_yes": "Yes", "com_ui_zoom": "Zoom", "com_user_message": "You" -} \ No newline at end of file +} diff --git a/client/src/utils/map.ts b/client/src/utils/map.ts index 1fc8dd073a..73a60687f4 100644 --- a/client/src/utils/map.ts +++ b/client/src/utils/map.ts @@ -18,7 +18,7 @@ export function mapAttachments(attachments: Array { tool_resources, }); - expect(mockGetFiles).toHaveBeenCalledWith({ file_id: { $in: ['ocr-file-1'] } }, {}, {}); + expect(mockGetFiles).toHaveBeenCalledWith( + { file_id: { $in: ['ocr-file-1'] } }, + {}, + {}, + { userId: undefined, agentId: undefined }, + ); expect(result.attachments).toEqual(mockOcrFiles); expect(result.tool_resources).toEqual(tool_resources); }); diff --git a/packages/api/src/agents/resources.ts b/packages/api/src/agents/resources.ts index e7af7927f5..40ed95370e 100644 --- a/packages/api/src/agents/resources.ts +++ b/packages/api/src/agents/resources.ts @@ -10,12 +10,14 @@ import type { Request as ServerRequest } from 'express'; * @param filter - MongoDB filter query for files * @param _sortOptions - Sorting options (currently unused) * @param selectFields - Field selection options + * @param options - Additional options including userId and agentId for access control * @returns Promise resolving to array of files */ export type TGetFiles = ( filter: FilterQuery, _sortOptions: ProjectionType | null | undefined, selectFields: QueryOptions | null | undefined, + options?: { userId?: string; agentId?: string }, ) => Promise>; /** @@ -145,12 +147,14 @@ export const primeResources = async ({ requestFileSet, attachments: _attachments, tool_resources: _tool_resources, + agentId, }: { req: ServerRequest; requestFileSet: Set; attachments: Promise> | undefined; tool_resources: AgentToolResources | undefined; getFiles: TGetFiles; + agentId?: string; }): Promise<{ attachments: Array | undefined; tool_resources: AgentToolResources | undefined; @@ -205,6 +209,7 @@ export const primeResources = async ({ }, {}, {}, + { userId: req.user?.id, agentId }, ); for (const file of context) { diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index 3d396b0ba0..adb0e634a1 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -188,6 +188,12 @@ export const agents = ({ path = '', options }: { path?: string; options?: object export const revertAgentVersion = (agent_id: string) => `${agents({ path: `${agent_id}/revert` })}`; export const files = () => '/api/files'; +export const fileUpload = () => '/api/files'; +export const fileDelete = () => '/api/files'; +export const fileDownload = (userId: string, fileId: string) => + `/api/files/download/${userId}/${fileId}`; +export const fileConfig = () => '/api/files/config'; +export const agentFiles = (agentId: string) => `/api/files/agent/${agentId}`; export const images = () => `${files()}/images`; diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index c76efbac87..dde248e2ad 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -318,6 +318,10 @@ export const getFiles = (): Promise => { return request.get(endpoints.files()); }; +export const getAgentFiles = (agentId: string): Promise => { + return request.get(endpoints.agentFiles(agentId)); +}; + export const getFileConfig = (): Promise => { return request.get(`${endpoints.files()}/config`); }; diff --git a/packages/data-provider/src/keys.ts b/packages/data-provider/src/keys.ts index 93e21c7e45..ec94c0f0ff 100644 --- a/packages/data-provider/src/keys.ts +++ b/packages/data-provider/src/keys.ts @@ -50,6 +50,11 @@ export enum QueryKeys { memories = 'memories', } +// Dynamic query keys that require parameters +export const DynamicQueryKeys = { + agentFiles: (agentId: string) => ['agentFiles', agentId] as const, +} as const; + export enum MutationKeys { fileUpload = 'fileUpload', fileDelete = 'fileDelete',