Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions api/app/clients/tools/structured/DALLE3.js
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ class DALLE3 extends Tool {
this.returnMetadata = fields.returnMetadata ?? false;

this.userId = fields.userId;
this.tenantId = fields.req?.user?.tenantId;
this.fileStrategy = fields.fileStrategy;
/** @type {boolean} */
this.isAgent = fields.isAgent;
Expand Down Expand Up @@ -228,6 +229,7 @@ Error Message: ${error.message}`);
fileName: imageName,
fileStrategy: this.fileStrategy,
context: FileContext.image_generation,
tenantId: this.tenantId,
});

if (this.returnMetadata) {
Expand Down
3 changes: 3 additions & 0 deletions api/app/clients/tools/structured/FluxAPI.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ class FluxAPI extends Tool {
this.override = fields.override ?? false;

this.userId = fields.userId;
this.tenantId = fields.req?.user?.tenantId;
this.fileStrategy = fields.fileStrategy;

/** @type {boolean} **/
Expand Down Expand Up @@ -341,6 +342,7 @@ class FluxAPI extends Tool {
fileName: imageName,
basePath: 'images',
context: FileContext.image_generation,
tenantId: this.tenantId,
});

logger.debug('[FluxAPI] Image saved to path:', result.filepath);
Expand Down Expand Up @@ -571,6 +573,7 @@ class FluxAPI extends Tool {
fileName: imageName,
basePath: 'images',
context: FileContext.image_generation,
tenantId: this.tenantId,
});

logger.debug('[FluxAPI] Finetuned image saved to path:', result.filepath);
Expand Down
16 changes: 16 additions & 0 deletions api/app/clients/tools/structured/specs/imageTools-agent.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,14 @@ describe('image tools - agent mode ToolMessage format', () => {
expect(dalle.responseFormat).not.toBe('content_and_artifact');
});

it('keeps tenant context without retaining the request object', () => {
const req = { user: { tenantId: 'tenant-a' }, socket: {} };
const dalle = new DALLE3({ isAgent: false, processFileURL: jest.fn(), req });

expect(dalle.tenantId).toBe('tenant-a');
expect(dalle.req).toBeUndefined();
});

it('invoke() returns ToolMessage with base64 in artifact, not serialized in content', async () => {
const dalle = new DALLE3({ isAgent: true });
const result = await dalle.invoke(
Expand Down Expand Up @@ -172,6 +180,14 @@ describe('image tools - agent mode ToolMessage format', () => {
expect(flux.responseFormat).not.toBe('content_and_artifact');
});

it('keeps tenant context without retaining the request object', () => {
const req = { user: { tenantId: 'tenant-a' }, socket: {} };
const flux = new FluxAPI({ isAgent: false, processFileURL: jest.fn(), req });

expect(flux.tenantId).toBe('tenant-a');
expect(flux.req).toBeUndefined();
});

it('invoke() returns ToolMessage with base64 in artifact, not serialized in content', async () => {
const flux = new FluxAPI({ isAgent: true });
const invokePromise = flux.invoke(
Expand Down
10 changes: 7 additions & 3 deletions api/server/controllers/AuthController.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,11 @@ const refreshController = async (req, res) => {
);
}

const token = setOpenIDAuthTokens(tokenset, req, res, user._id.toString(), refreshToken);
const token = setOpenIDAuthTokens(tokenset, req, res, {
userId: user._id.toString(),
existingRefreshToken: refreshToken,
tenantId: user.tenantId,
});

const { password: _pw, __v: _v, totpSecret: _ts, backupCodes: _bc, ...safeUser } = user;
return res.status(200).send({ token, user: safeUser });
Expand All @@ -146,7 +150,7 @@ const refreshController = async (req, res) => {
const userId = payload.id;

if (process.env.NODE_ENV === 'CI') {
const token = await setAuthTokens(userId, res);
const token = await setAuthTokens(userId, res, null, req);
return res.status(200).send({ token, user });
}

Expand All @@ -160,7 +164,7 @@ const refreshController = async (req, res) => {
);

if (session && session.expiration > new Date()) {
const token = await setAuthTokens(userId, res, session);
const token = await setAuthTokens(userId, res, session, req);

res.status(200).send({ token, user });
} else if (req?.query?.retry) {
Expand Down
122 changes: 87 additions & 35 deletions api/server/controllers/agents/v1.js
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,49 @@ const filterAuthorizedTools = async ({
return filteredTools;
};

/**
* Removes file IDs from tool resources unless the referenced file is owned by
* the agent owner.
* @param {object} params
* @param {object} params.tool_resources
* @param {string | object} params.ownerId
* @param {string} params.logPrefix
* @returns {Promise<number>} Count of removed file references.
*/
const pruneToolResourceFileIdsForOwner = async ({ tool_resources, ownerId, logPrefix }) => {
const referencedFileIds = collectToolResourceFileIds(tool_resources);
if (referencedFileIds.length === 0) {
return 0;
}
if (!ownerId) {
return stripFileIdsFromToolResources(tool_resources, referencedFileIds).removedCount;
}
const ownerIdStr = ownerId.toString();

try {
const ownerFiles = await db.getFiles({ file_id: { $in: referencedFileIds } }, null, {
file_id: 1,
user: 1,
});
const allowedIds = new Set(
(ownerFiles ?? [])
.filter((file) => file.user && file.user.toString() === ownerIdStr)
.map((file) => file.file_id),
);
const disallowedIds = referencedFileIds.filter((id) => !allowedIds.has(id));
if (disallowedIds.length > 0) {
logger.warn(`${logPrefix} Pruning ${disallowedIds.length} invalid file reference(s)`);
return stripFileIdsFromToolResources(tool_resources, disallowedIds).removedCount;
}
return 0;
} catch (fileCheckError) {
logger.warn(`${logPrefix} File ownership check failed, pruning incoming file references`, {
error: fileCheckError?.message,
});
return stripFileIdsFromToolResources(tool_resources, referencedFileIds).removedCount;
}
};

/**
* Creates an Agent.
* @route POST /Agents
Expand All @@ -239,6 +282,14 @@ const createAgentHandler = async (req, res) => {

const { id: userId, role: userRole } = req.user;

if (agentData.tool_resources) {
await pruneToolResourceFileIdsForOwner({
tool_resources: agentData.tool_resources,
ownerId: userId,
logPrefix: '[/Agents]',
});
}

if (agentData.edges?.length) {
const unauthorized = await validateEdgeAgentAccess(agentData.edges, userId, userRole);
if (unauthorized.length > 0) {
Expand Down Expand Up @@ -509,36 +560,12 @@ const updateAgentHandler = async (req, res) => {
updateData.tools = ocrConversion.tools;
}

/*
* Strip orphaned file_id stubs from the incoming payload (see issue #12776).
* Scoped to updates that actually touch tool_resources: if the save does not
* modify that field, the delete-time cleanup in processDeleteRequest and the
* one-off migration already cover pre-existing corruption, so there's no
* reason to pay an extra DB round-trip here. Wrapped in try/catch so a
* transient failure in this integrity check never turns a good save into 500.
*/
if (updateData.tool_resources) {
try {
const referencedFileIds = collectToolResourceFileIds(updateData.tool_resources);
if (referencedFileIds.length > 0) {
const existingFiles = await db.getFiles({ file_id: { $in: referencedFileIds } }, null, {
file_id: 1,
});
const existingIds = new Set((existingFiles ?? []).map((f) => f.file_id));
const orphans = referencedFileIds.filter((id) => !existingIds.has(id));
if (orphans.length > 0) {
logger.warn(
`[/Agents/:id] Pruning ${orphans.length} orphaned file reference(s) from agent ${id}`,
);
stripFileIdsFromToolResources(updateData.tool_resources, orphans);
}
}
} catch (orphanCheckError) {
logger.warn(
'[/Agents/:id] Orphan file check failed, skipping cleanup for this request',
orphanCheckError,
);
}
await pruneToolResourceFileIdsForOwner({
tool_resources: updateData.tool_resources,
ownerId: existingAgent.author,
logPrefix: `[/Agents/:id] Agent ${id}`,
});
}

if (updateData.tools) {
Expand Down Expand Up @@ -722,6 +749,14 @@ const duplicateAgentHandler = async (req, res) => {
});
}

if (newAgentData.tool_resources) {
await pruneToolResourceFileIdsForOwner({
tool_resources: newAgentData.tool_resources,
ownerId: userId,
logPrefix: '[/Agents/:id/duplicate]',
});
}

const newAgent = await db.createAgent(newAgentData);

try {
Expand Down Expand Up @@ -960,6 +995,7 @@ const uploadAgentAvatarHandler = async (req, res) => {
userId: req.user.id,
manual: 'false',
agentId: agent_id,
tenantId: req.user.tenantId,
});

const image = {
Expand All @@ -972,7 +1008,11 @@ const uploadAgentAvatarHandler = async (req, res) => {
if (_avatar && _avatar.source) {
const { deleteFile } = getStrategyFunctions(_avatar.source);
try {
await deleteFile(req, { filepath: _avatar.filepath });
await deleteFile(req, {
filepath: _avatar.filepath,
user: req.user.id,
tenantId: req.user.tenantId,
});
await db.deleteFileByFilter({ user: req.user.id, filepath: _avatar.filepath });
} catch (error) {
logger.error('[/:agent_id/avatar] Error deleting old avatar', error);
Expand Down Expand Up @@ -1051,6 +1091,7 @@ const revertAgentVersionHandler = async (req, res) => {
// Permissions are enforced via route middleware (ACL EDIT)

let updatedAgent = await db.revertAgentVersion({ id }, version_index);
const revertUpdates = {};

if (updatedAgent.tools?.length) {
const [availableTools, configServers] = await Promise.all([
Expand All @@ -1065,14 +1106,25 @@ const revertAgentVersionHandler = async (req, res) => {
configServers,
});
if (filteredTools.length !== updatedAgent.tools.length) {
updatedAgent = await db.updateAgent(
{ id },
{ tools: filteredTools },
{ updatingUserId: req.user.id },
);
revertUpdates.tools = filteredTools;
}
}

if (updatedAgent.tool_resources) {
const removedCount = await pruneToolResourceFileIdsForOwner({
tool_resources: updatedAgent.tool_resources,
ownerId: existingAgent.author,
logPrefix: '[/Agents/:id/revert]',
});
if (removedCount > 0) {
revertUpdates.tool_resources = updatedAgent.tool_resources;
}
}

if (Object.keys(revertUpdates).length > 0) {
updatedAgent = await db.updateAgent({ id }, revertUpdates, { updatingUserId: req.user.id });
}

if (updatedAgent.author) {
updatedAgent.author = updatedAgent.author.toString();
}
Expand Down
Loading
Loading