mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-04 14:57:42 +00:00
🪦 fix: Add Durable MCP Config Tombstones (#13534)
* fix: add durable MCP config tombstones * fix: preserve scoped config tombstones * fix: clean up config tombstone lint * fix: handle empty model spec skill allowlist * fix: preserve inactive config tombstones
This commit is contained in:
parent
5118a566df
commit
aeb5adff34
10 changed files with 506 additions and 6 deletions
|
|
@ -49,6 +49,9 @@ function createHandlers(overrides = {}) {
|
|||
patchConfigFields: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ _id: 'c1', overrides: { registration: { enabled: false } } }),
|
||||
tombstoneConfigField: jest
|
||||
.fn()
|
||||
.mockResolvedValue({ _id: 'c1', tombstones: ['mcpServers.github'] }),
|
||||
unsetConfigField: jest.fn().mockResolvedValue({ _id: 'c1', overrides: {} }),
|
||||
deleteConfig: jest.fn().mockResolvedValue({ _id: 'c1' }),
|
||||
toggleConfigActive: jest.fn().mockResolvedValue({ _id: 'c1', isActive: false }),
|
||||
|
|
@ -377,6 +380,78 @@ describe('createAdminConfigHandlers', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('tombstoneConfigField', () => {
|
||||
it('writes an explicit tombstone for a valid field path', async () => {
|
||||
const { handlers, deps } = createHandlers();
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
body: { fieldPath: 'mcpServers.github' },
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.tombstoneConfigField(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(deps.tombstoneConfigField).toHaveBeenCalledWith(
|
||||
'role',
|
||||
'admin',
|
||||
expect.anything(),
|
||||
'mcpServers.github',
|
||||
10,
|
||||
);
|
||||
});
|
||||
|
||||
it('uses the existing config priority when priority is omitted', async () => {
|
||||
const { handlers, deps } = createHandlers({
|
||||
findConfigByPrincipal: jest.fn().mockResolvedValue({ _id: 'c1', priority: 42 }),
|
||||
});
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
body: { fieldPath: 'mcpServers.github' },
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.tombstoneConfigField(req, res);
|
||||
|
||||
expect(deps.tombstoneConfigField).toHaveBeenCalledWith(
|
||||
'role',
|
||||
'admin',
|
||||
expect.anything(),
|
||||
'mcpServers.github',
|
||||
42,
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks interface permission paths', async () => {
|
||||
const { handlers, deps } = createHandlers();
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
body: { fieldPath: 'interface.mcpServers.use' },
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.tombstoneConfigField(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body!.message).toBeDefined();
|
||||
expect(deps.tombstoneConfigField).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects unsafe field paths', async () => {
|
||||
const { handlers, deps } = createHandlers();
|
||||
const req = mockReq({
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
body: { fieldPath: '__proto__.polluted' },
|
||||
});
|
||||
const res = mockRes();
|
||||
|
||||
await handlers.tombstoneConfigField(req, res);
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(deps.tombstoneConfigField).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('patchConfigField', () => {
|
||||
it('returns 403 when user lacks capability for section', async () => {
|
||||
const { handlers } = createHandlers({
|
||||
|
|
@ -641,6 +716,13 @@ describe('createAdminConfigHandlers', () => {
|
|||
query: { fieldPath: 'interface.modelSelect' },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'tombstoneConfigField',
|
||||
reqOverrides: {
|
||||
params: { principalType: 'role', principalId: 'admin' },
|
||||
body: { fieldPath: 'mcpServers.github' },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'deleteConfigOverrides',
|
||||
reqOverrides: {
|
||||
|
|
|
|||
|
|
@ -82,6 +82,14 @@ export interface AdminConfigDeps {
|
|||
priority: number,
|
||||
session?: ClientSession,
|
||||
) => Promise<IConfig | null>;
|
||||
tombstoneConfigField: (
|
||||
principalType: PrincipalType,
|
||||
principalId: string | Types.ObjectId,
|
||||
principalModel: PrincipalModel,
|
||||
fieldPath: string,
|
||||
priority: number,
|
||||
session?: ClientSession,
|
||||
) => Promise<IConfig | null>;
|
||||
unsetConfigField: (
|
||||
principalType: PrincipalType,
|
||||
principalId: string | Types.ObjectId,
|
||||
|
|
@ -163,6 +171,7 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps) {
|
|||
findConfigByPrincipal,
|
||||
upsertConfig,
|
||||
patchConfigFields,
|
||||
tombstoneConfigField: writeConfigTombstone,
|
||||
unsetConfigField,
|
||||
deleteConfig,
|
||||
toggleConfigActive,
|
||||
|
|
@ -479,6 +488,80 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps) {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /:principalType/:principalId/fields/tombstone — Suppress an inherited config path.
|
||||
*/
|
||||
async function tombstoneConfigField(req: ServerRequest, res: Response) {
|
||||
try {
|
||||
const { principalType, principalId } = req.params as {
|
||||
principalType: string;
|
||||
principalId: string;
|
||||
};
|
||||
|
||||
if (!validatePrincipalType(principalType)) {
|
||||
return res.status(400).json({ error: `Invalid principalType: ${principalType}` });
|
||||
}
|
||||
|
||||
const { fieldPath, priority } = req.body as {
|
||||
fieldPath?: string;
|
||||
priority?: number;
|
||||
};
|
||||
|
||||
if (!fieldPath || typeof fieldPath !== 'string') {
|
||||
return res.status(400).json({ error: 'fieldPath is required' });
|
||||
}
|
||||
|
||||
if (priority != null && (typeof priority !== 'number' || priority < 0)) {
|
||||
return res.status(400).json({ error: 'priority must be a non-negative number' });
|
||||
}
|
||||
|
||||
if (!isValidFieldPath(fieldPath)) {
|
||||
return res.status(400).json({ error: `Invalid or unsafe field path: ${fieldPath}` });
|
||||
}
|
||||
|
||||
const user = getCapabilityUser(req);
|
||||
if (!user) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
|
||||
const section = getTopLevelSection(fieldPath);
|
||||
|
||||
if (!(await hasConfigCapability(user, section as ConfigSection, 'manage'))) {
|
||||
return res.status(403).json({
|
||||
error: `Insufficient permissions for config section: ${section}`,
|
||||
});
|
||||
}
|
||||
|
||||
if (isInterfacePermissionPath(fieldPath)) {
|
||||
logger.warn(
|
||||
`[adminConfig] Ignoring tombstone for interface permission field "${fieldPath}" — use role permissions instead`,
|
||||
);
|
||||
return res.status(200).json({ message: 'No actionable field path provided' });
|
||||
}
|
||||
|
||||
const existing =
|
||||
priority == null
|
||||
? await findConfigByPrincipal(principalType, principalId, { includeInactive: true })
|
||||
: null;
|
||||
|
||||
const config = await writeConfigTombstone(
|
||||
principalType,
|
||||
principalId,
|
||||
principalModel(principalType),
|
||||
fieldPath,
|
||||
priority ?? existing?.priority ?? DEFAULT_PRIORITY,
|
||||
);
|
||||
|
||||
invalidateConfigCaches?.(user.tenantId)?.catch((err) =>
|
||||
logger.error('[adminConfig] Cache invalidation failed after field tombstone:', err),
|
||||
);
|
||||
return res.status(200).json({ config });
|
||||
} catch (error) {
|
||||
logger.error('[adminConfig] tombstoneConfigField error:', error);
|
||||
return res.status(500).json({ error: 'Failed to tombstone config field' });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE /:principalType/:principalId/fields?fieldPath=dotted.path
|
||||
*/
|
||||
|
|
@ -624,6 +707,7 @@ export function createAdminConfigHandlers(deps: AdminConfigDeps) {
|
|||
getConfig,
|
||||
upsertConfigOverrides,
|
||||
patchConfigField,
|
||||
tombstoneConfigField,
|
||||
deleteConfigField,
|
||||
deleteConfigOverrides,
|
||||
toggleConfig,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue