mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-01 03:27:01 +00:00
🛂 feat: Filter Model-Bound Content by Source (#14425)
* feat: introduce optional content protection seam * feat: enforce source-aware content filters * feat: complete source-aware content enforcement * test: activate skill file-text fail-close fixtures * fix: harden source-aware content filters * fix: harden model-bound content filtering * fix: preserve legacy filters and generated files * fix: inspect shared scalar metadata * test: align mocks with current dev dependencies * feat: add persisted content filter safeguards * feat: complete source-aware content filter enforcement * fix: move resume content preflight into TypeScript * fix: close content inspection edge cases * fix: harden content protection boundaries * fix: complete content protection safeguards * test: align persisted memory filter coverage * fix: reconcile content protection with current dev * fix: reconcile content protection with latest dev * fix: close content protection review gaps * fix: enforce source-aware provider boundaries * fix: preserve legacy PII preflight semantics * test: stabilize stored branch preflight fixture * fix: defer agent writes until protected model admission * perf: harden source-aware model-bound filtering * fix: canonicalize provider lineage before validation * fix: satisfy model-bound callback type checks * perf: Bound content protection filtering work * fix: Bound submission array traversal * fix: Stabilize bounded content snapshots * fix: Scope model-bound traversal overflows * fix: Preserve scoped content inspection * fix: Accumulate aggregate traversal scopes * fix: centralize content policy boundaries * test: align deferred tool policy context * test: align controller policy mocks * style: normalize content protection imports * fix: close content policy review gaps * fix: narrow active skill policy config * fix: address content protection review boundaries * fix: retain exact provenance overflow sentinel * fix: preserve literal and scoped provenance updates * fix: narrow persisted edit provenance * fix: isolate exact overflow attribution * fix: centralize stored prompt protection * fix: fail closed on incomplete transcript evidence * fix: align canonical transcript routing * refactor: centralize content policy preflights * fix: isolate upload policy error typing * style: sort policy preflight imports * refactor: centralize content policy boundaries
This commit is contained in:
parent
10f95c0ce9
commit
67b7b441b2
303 changed files with 75546 additions and 2327 deletions
|
|
@ -70,6 +70,17 @@ jest.mock('~/server/services/Files/Code/process', () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Files/Code/preflight', () => ({
|
||||
preflightCodeOutputBatch: jest.fn(async ({ artifact }) =>
|
||||
(artifact.files ?? [])
|
||||
.filter((file) => file.inherited !== true)
|
||||
.map((file) => ({
|
||||
file,
|
||||
sessionId: file.storage_session_id ?? artifact.session_id,
|
||||
})),
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Tools/credentials', () => ({
|
||||
loadAuthValues: jest.fn(),
|
||||
}));
|
||||
|
|
@ -436,6 +447,7 @@ describe('createToolEndCallback', () => {
|
|||
* message slot, leaving the current turn's pending chip stuck. */
|
||||
|
||||
const { processCodeOutput } = require('~/server/services/Files/Code/process');
|
||||
const { preflightCodeOutputBatch } = require('~/server/services/Files/Code/preflight');
|
||||
|
||||
function makeCodeExecutionEvent({
|
||||
runId,
|
||||
|
|
@ -867,6 +879,50 @@ describe('createToolEndCallback', () => {
|
|||
expect(processCodeOutput).not.toHaveBeenCalled();
|
||||
expect(res.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects blocked generated bytes before queuing any persistence', async () => {
|
||||
const blocked = new Error('Generated file content blocked');
|
||||
preflightCodeOutputBatch.mockRejectedValueOnce(blocked);
|
||||
const toolEndCallback = createToolEndCallback({ req, res, artifactPromises });
|
||||
const event = makeCodeExecutionEvent({
|
||||
runId: 'run-blocked',
|
||||
threadId: 'thread-1',
|
||||
toolCallId: 'tool-blocked',
|
||||
fileId: 'fid-blocked',
|
||||
name: 'blocked.txt',
|
||||
});
|
||||
|
||||
await expect(toolEndCallback({ output: event.output }, event.metadata)).rejects.toBe(blocked);
|
||||
|
||||
expect(processCodeOutput).not.toHaveBeenCalled();
|
||||
expect(artifactPromises).toHaveLength(0);
|
||||
expect(res.write).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects blocked generated bytes in the Responses callback before persistence', async () => {
|
||||
const blocked = new Error('Generated file content blocked');
|
||||
preflightCodeOutputBatch.mockRejectedValueOnce(blocked);
|
||||
const { createResponsesToolEndCallback } = require('../callbacks');
|
||||
const callback = createResponsesToolEndCallback({
|
||||
req,
|
||||
res,
|
||||
tracker: { nextSequence: jest.fn(() => 1) },
|
||||
artifactPromises,
|
||||
});
|
||||
const event = makeCodeExecutionEvent({
|
||||
runId: 'run-responses-blocked',
|
||||
threadId: 'thread-1',
|
||||
toolCallId: 'tool-responses-blocked',
|
||||
fileId: 'fid-responses-blocked',
|
||||
name: 'blocked.txt',
|
||||
});
|
||||
|
||||
await expect(callback({ output: event.output }, event.metadata)).rejects.toBe(blocked);
|
||||
|
||||
expect(processCodeOutput).not.toHaveBeenCalled();
|
||||
expect(artifactPromises).toHaveLength(0);
|
||||
expect(res.write).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,43 @@ const mockBuildAgentContextAttachmentsByAgentId = jest.fn().mockReturnValue(new
|
|||
const mockBuildInlineMemoryContext = jest.fn().mockResolvedValue('');
|
||||
const mockApplyContextToAgent = jest.fn().mockResolvedValue(undefined);
|
||||
const mockInitialSessions = new Map([['execute_code', { session_id: 'seeded' }]]);
|
||||
const mockGetSafeErrorMetadata = jest.fn((error) => {
|
||||
const status = error?.status ?? error?.statusCode ?? error?.response?.status;
|
||||
return {
|
||||
type: error instanceof Error ? 'Error' : 'UnknownError',
|
||||
...(Number.isInteger(status) && status >= 100 && status <= 599 && { status }),
|
||||
};
|
||||
});
|
||||
const mockHasActivePiiPatterns = (config) =>
|
||||
config != null &&
|
||||
(config.starterPatterns == null ||
|
||||
config.starterPatterns.length > 0 ||
|
||||
(config.customPatterns?.length ?? 0) > 0);
|
||||
const mockHasModelBoundContentProtection = (filters, legacyPii) => {
|
||||
const sourcePolicies = [
|
||||
legacyPii,
|
||||
filters?.messages?.pii,
|
||||
filters?.agentInstructions?.pii,
|
||||
filters?.conversationStarters?.pii,
|
||||
filters?.skills?.pii,
|
||||
filters?.memories?.pii,
|
||||
filters?.files?.pii,
|
||||
filters?.toolArguments?.pii,
|
||||
filters?.modelParameters?.pii,
|
||||
filters?.actionMetadata?.pii,
|
||||
];
|
||||
if (sourcePolicies.some(mockHasActivePiiPatterns)) {
|
||||
return true;
|
||||
}
|
||||
const filePolicy = filters?.files?.pii;
|
||||
return (
|
||||
filePolicy?.uninspectable === 'block' &&
|
||||
(filePolicy.fields == null ||
|
||||
filePolicy.fields.some((field) =>
|
||||
['content', 'extracted_text', 'transcript'].includes(field),
|
||||
))
|
||||
);
|
||||
};
|
||||
class MockAgentRunEnvelopeError extends TypeError {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
|
|
@ -107,6 +144,21 @@ jest.mock('@librechat/agents', () => ({
|
|||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
collectReachableAgents: (roots) => {
|
||||
const agents = [];
|
||||
const pending = [...roots];
|
||||
const visited = new Set();
|
||||
for (let index = 0; index < pending.length; index++) {
|
||||
const agent = pending[index];
|
||||
if (!agent || visited.has(agent)) {
|
||||
continue;
|
||||
}
|
||||
visited.add(agent);
|
||||
agents.push(agent);
|
||||
pending.push(...(agent.subagentAgentConfigs ?? []));
|
||||
}
|
||||
return agents;
|
||||
},
|
||||
/** Pass-through: the controller strips UI-only activity-label parts
|
||||
* before SDK formatting; the mock must expose it like any other used
|
||||
* export or the call throws before the assertions run. */
|
||||
|
|
@ -184,7 +236,28 @@ jest.mock('@librechat/api', () => ({
|
|||
resolveRecursionLimit: jest.fn().mockReturnValue(50),
|
||||
createToolExecuteHandler: jest.fn().mockReturnValue({ handle: jest.fn() }),
|
||||
isChatCompletionValidationFailure: jest.fn().mockReturnValue(false),
|
||||
findPiiMatchInMessages: jest.fn().mockReturnValue(null),
|
||||
inspectContent: jest.fn().mockReturnValue(null),
|
||||
extractMessageContent: jest.fn().mockReturnValue([]),
|
||||
extractModelParameterContent: jest.fn().mockReturnValue([]),
|
||||
extractSkillContent: jest.fn().mockReturnValue([]),
|
||||
getBlockedOpaqueFileField: jest.fn().mockReturnValue(null),
|
||||
getContentTraversalFragments: jest.fn().mockReturnValue([]),
|
||||
isContentTraversalProtected: jest.fn().mockReturnValue(true),
|
||||
isContentTraversalLimitError: jest.fn((error) => error?.code === 'content_filter_uninspectable'),
|
||||
assertModelBoundContent: jest.fn(),
|
||||
hasModelBoundContentProtection: mockHasModelBoundContentProtection,
|
||||
isContentFilterError: jest.fn((error) => error?.code === 'content_filter_block'),
|
||||
getSafeErrorMetadata: mockGetSafeErrorMetadata,
|
||||
contentFilterBlockResponse: jest.fn().mockReturnValue({
|
||||
error: 'content_filter_block',
|
||||
message: 'Submitted content was blocked.',
|
||||
}),
|
||||
contentFilterUninspectableResponse: jest.fn().mockReturnValue({
|
||||
error: 'content_filter_uninspectable',
|
||||
message: 'Submitted file content could not be inspected before processing.',
|
||||
source: 'file',
|
||||
field: 'content',
|
||||
}),
|
||||
discoverConnectedAgents: jest.fn().mockResolvedValue({
|
||||
agentConfigs: new Map(),
|
||||
edges: [],
|
||||
|
|
@ -400,6 +473,507 @@ describe('OpenAIChatCompletionController', () => {
|
|||
expect(aggregator.usage.completionTokens).toBe(initialCompletionTokens + 10);
|
||||
});
|
||||
|
||||
describe('content filtering', () => {
|
||||
it('blocks opaque inline media before text inspection or agent loading', async () => {
|
||||
const api = require('@librechat/api');
|
||||
const db = require('~/models');
|
||||
const messages = [
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'data:image/png;base64,do-not-echo' },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
api.validateRequest.mockReturnValueOnce({
|
||||
request: { model: 'agent-123', messages, stream: false },
|
||||
});
|
||||
api.getBlockedOpaqueFileField.mockReturnValueOnce('content');
|
||||
|
||||
await OpenAIChatCompletionController(req, res);
|
||||
|
||||
expect(api.getBlockedOpaqueFileField).toHaveBeenCalledWith(req.config.filters, messages);
|
||||
expect(api.extractMessageContent).not.toHaveBeenCalled();
|
||||
expect(db.getAgent).not.toHaveBeenCalled();
|
||||
expect(api.createErrorResponse).toHaveBeenCalledWith(
|
||||
'Submitted file content could not be inspected before processing.',
|
||||
'invalid_request_error',
|
||||
'content_filter_uninspectable',
|
||||
);
|
||||
expect(JSON.stringify(api.createErrorResponse.mock.calls)).not.toContain('do-not-echo');
|
||||
});
|
||||
|
||||
it('returns a raw-free error when nested message inspection exhausts its budget', async () => {
|
||||
const api = require('@librechat/api');
|
||||
const db = require('~/models');
|
||||
req.config.filters = { messages: { pii: { starterPatterns: [] } } };
|
||||
api.extractMessageContent.mockImplementationOnce(() => {
|
||||
throw {
|
||||
code: 'content_filter_uninspectable',
|
||||
statusCode: 400,
|
||||
body: {
|
||||
error: 'content_filter_uninspectable',
|
||||
message: 'Submitted content could not be completely inspected before processing.',
|
||||
source: 'message',
|
||||
field: 'content_part',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await OpenAIChatCompletionController(req, res);
|
||||
|
||||
expect(db.getAgent).not.toHaveBeenCalled();
|
||||
expect(api.createErrorResponse).toHaveBeenCalledWith(
|
||||
'Submitted content could not be completely inspected before processing.',
|
||||
'invalid_request_error',
|
||||
'content_filter_uninspectable',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves field granularity when the exhausted nested field is not selected', async () => {
|
||||
const api = require('@librechat/api');
|
||||
const db = require('~/models');
|
||||
req.config.filters = {
|
||||
messages: { pii: { fields: ['text'], starterPatterns: [] } },
|
||||
};
|
||||
api.extractMessageContent.mockImplementationOnce(() => {
|
||||
throw {
|
||||
code: 'content_filter_uninspectable',
|
||||
statusCode: 400,
|
||||
body: {
|
||||
error: 'content_filter_uninspectable',
|
||||
message: 'Submitted content could not be completely inspected before processing.',
|
||||
source: 'message',
|
||||
field: 'content_part',
|
||||
},
|
||||
};
|
||||
});
|
||||
api.isContentTraversalProtected.mockReturnValueOnce(false);
|
||||
|
||||
await OpenAIChatCompletionController(req, res);
|
||||
|
||||
expect(db.getAgent).toHaveBeenCalled();
|
||||
expect(api.createErrorResponse).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'invalid_request_error',
|
||||
'content_filter_uninspectable',
|
||||
);
|
||||
});
|
||||
|
||||
it('continues when exhausted model parameters are outside the active policy', async () => {
|
||||
const api = require('@librechat/api');
|
||||
const db = require('~/models');
|
||||
api.extractModelParameterContent.mockImplementationOnce(() => {
|
||||
throw {
|
||||
code: 'content_filter_uninspectable',
|
||||
statusCode: 400,
|
||||
body: {
|
||||
error: 'content_filter_uninspectable',
|
||||
message: 'Submitted content could not be completely inspected before processing.',
|
||||
source: 'model_parameter',
|
||||
field: 'request_fields',
|
||||
},
|
||||
};
|
||||
});
|
||||
api.isContentTraversalProtected.mockReturnValueOnce(false);
|
||||
|
||||
await OpenAIChatCompletionController(req, res);
|
||||
|
||||
expect(db.getAgent).toHaveBeenCalled();
|
||||
expect(api.createErrorResponse).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
'invalid_request_error',
|
||||
'content_filter_uninspectable',
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks submitted messages and model parameters before loading the agent', async () => {
|
||||
const api = require('@librechat/api');
|
||||
const db = require('~/models');
|
||||
api.validateRequest.mockReturnValueOnce({
|
||||
request: {
|
||||
model: 'agent-123',
|
||||
messages: [],
|
||||
stream: false,
|
||||
stop: ['submitted stop sequence'],
|
||||
},
|
||||
});
|
||||
api.inspectContent.mockReturnValueOnce({
|
||||
detectorId: 'pii-pattern',
|
||||
ruleId: 'sk_prefix',
|
||||
label: 'sk- prefix token',
|
||||
source: 'model_parameter',
|
||||
field: 'stop',
|
||||
});
|
||||
|
||||
await OpenAIChatCompletionController(req, res);
|
||||
|
||||
expect(api.extractMessageContent).toHaveBeenCalled();
|
||||
expect(api.extractModelParameterContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ stop: ['submitted stop sequence'] }),
|
||||
);
|
||||
expect(db.getAgent).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(api.createErrorResponse).toHaveBeenCalledWith(
|
||||
'Submitted content was blocked.',
|
||||
'invalid_request_error',
|
||||
'content_filter_block',
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks manually selected skill names before resolving the skill', async () => {
|
||||
const api = require('@librechat/api');
|
||||
const db = require('~/models');
|
||||
req.body.manualSkills = ['PRIVATE-SKILL'];
|
||||
api.extractManualSkills.mockReturnValueOnce(['PRIVATE-SKILL']);
|
||||
api.inspectContent.mockReturnValueOnce({
|
||||
detectorId: 'pii-pattern',
|
||||
ruleId: 'private',
|
||||
label: 'private value',
|
||||
source: 'skill',
|
||||
field: 'name',
|
||||
});
|
||||
|
||||
await OpenAIChatCompletionController(req, res);
|
||||
|
||||
expect(api.extractSkillContent).toHaveBeenCalledWith({ name: 'PRIVATE-SKILL' });
|
||||
expect(db.getAgent).not.toHaveBeenCalled();
|
||||
expect(api.createErrorResponse).toHaveBeenCalledWith(
|
||||
'Submitted content was blocked.',
|
||||
'invalid_request_error',
|
||||
'content_filter_block',
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects filtered model-bound context before starting a streaming response', async () => {
|
||||
const api = require('@librechat/api');
|
||||
api.validateRequest.mockReturnValueOnce({
|
||||
request: {
|
||||
model: 'agent-123',
|
||||
messages: [],
|
||||
stream: true,
|
||||
},
|
||||
});
|
||||
api.assertModelBoundContent.mockImplementationOnce(() => {
|
||||
throw Object.assign(new Error('Submitted content contains a private value.'), {
|
||||
code: 'content_filter_block',
|
||||
statusCode: 400,
|
||||
body: {
|
||||
error: 'content_filter_block',
|
||||
message: 'Submitted content contains a private value. Remove it and try again.',
|
||||
source: 'agent_instruction',
|
||||
field: 'instructions',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await OpenAIChatCompletionController(req, res);
|
||||
|
||||
expect(res.status).toHaveBeenCalledWith(400);
|
||||
expect(api.createErrorResponse).toHaveBeenCalledWith(
|
||||
'Submitted content contains a private value. Remove it and try again.',
|
||||
'invalid_request_error',
|
||||
'content_filter_block',
|
||||
);
|
||||
expect(res.setHeader).not.toHaveBeenCalled();
|
||||
expect(res.flushHeaders).not.toHaveBeenCalled();
|
||||
expect(api.writeSSE).not.toHaveBeenCalled();
|
||||
expect(api.createRun).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('preflights file-derived content from every reachable agent under a files-only policy', async () => {
|
||||
const api = require('@librechat/api');
|
||||
const primaryRequestFile = { filename: 'primary-request.txt', content: 'primary request' };
|
||||
const primaryContextFile = { filename: 'primary-context.txt', content: 'primary context' };
|
||||
const handoffRequestFile = { filename: 'handoff-request.txt', content: 'handoff request' };
|
||||
const handoffContextFile = {
|
||||
filename: 'handoff-context.txt',
|
||||
content: 'sk-handoff-context',
|
||||
};
|
||||
const nestedRequestFile = { filename: 'nested-request.txt', content: 'nested request' };
|
||||
const nestedPureSubagent = {
|
||||
id: 'agent-nested-pure',
|
||||
model: 'gpt-4',
|
||||
model_parameters: {},
|
||||
requestAttachments: [nestedRequestFile],
|
||||
dynamicToolContextMap: { nested_lookup: 'nested dynamic context' },
|
||||
};
|
||||
const pureSubagent = {
|
||||
id: 'agent-pure',
|
||||
model: 'gpt-4',
|
||||
model_parameters: {},
|
||||
subagentAgentConfigs: [nestedPureSubagent],
|
||||
};
|
||||
const blockedError = Object.assign(new Error('Submitted file content was blocked.'), {
|
||||
code: 'content_filter_block',
|
||||
statusCode: 400,
|
||||
body: {
|
||||
error: 'content_filter_block',
|
||||
message: 'Submitted file content was blocked.',
|
||||
source: 'file',
|
||||
field: 'content',
|
||||
},
|
||||
});
|
||||
req.config.filters = {
|
||||
files: { pii: { fields: ['content'], starterPatterns: ['sk-'] } },
|
||||
};
|
||||
api.validateRequest.mockReturnValueOnce({
|
||||
request: { model: 'agent-123', messages: [], stream: true },
|
||||
});
|
||||
api.initializeAgent.mockResolvedValueOnce({
|
||||
id: 'agent-123',
|
||||
model: 'gpt-4',
|
||||
model_parameters: {},
|
||||
toolRegistry: {},
|
||||
edges: [{ source: 'agent-123', target: 'agent-handoff' }],
|
||||
requestAttachments: [primaryRequestFile],
|
||||
agentContextAttachments: [primaryContextFile],
|
||||
dynamicToolContextMap: { execute_code: 'primary dynamic context' },
|
||||
subagentAgentConfigs: [pureSubagent],
|
||||
});
|
||||
api.discoverConnectedAgents.mockResolvedValueOnce({
|
||||
agentConfigs: new Map([
|
||||
[
|
||||
'agent-handoff',
|
||||
{
|
||||
id: 'agent-handoff',
|
||||
model: 'gpt-4',
|
||||
model_parameters: {},
|
||||
requestAttachments: [handoffRequestFile],
|
||||
agentContextAttachments: [handoffContextFile],
|
||||
dynamicToolContextMap: { file_search: 'handoff dynamic context' },
|
||||
},
|
||||
],
|
||||
]),
|
||||
edges: [],
|
||||
skippedAgentIds: new Set(),
|
||||
userMCPAuthMap: undefined,
|
||||
});
|
||||
api.assertModelBoundContent.mockImplementationOnce(({ filters, agents, files }) => {
|
||||
expect(filters).toEqual(req.config.filters);
|
||||
expect(agents.map(({ id }) => id)).toEqual([
|
||||
'agent-123',
|
||||
'agent-handoff',
|
||||
'agent-pure',
|
||||
'agent-nested-pure',
|
||||
]);
|
||||
expect(files).toEqual([
|
||||
primaryRequestFile,
|
||||
primaryContextFile,
|
||||
{ content: 'primary dynamic context' },
|
||||
handoffRequestFile,
|
||||
handoffContextFile,
|
||||
{ content: 'handoff dynamic context' },
|
||||
nestedRequestFile,
|
||||
{ content: 'nested dynamic context' },
|
||||
]);
|
||||
throw blockedError;
|
||||
});
|
||||
|
||||
await OpenAIChatCompletionController(req, res);
|
||||
|
||||
expect(api.createRun).not.toHaveBeenCalled();
|
||||
expect(res.setHeader).not.toHaveBeenCalled();
|
||||
expect(res.flushHeaders).not.toHaveBeenCalled();
|
||||
expect(api.createErrorResponse).toHaveBeenCalledWith(
|
||||
blockedError.body.message,
|
||||
'invalid_request_error',
|
||||
'content_filter_block',
|
||||
);
|
||||
});
|
||||
|
||||
it('preflights the exact synthesized dynamic tool context as file content', async () => {
|
||||
const api = require('@librechat/api');
|
||||
const blockedError = Object.assign(new Error('Submitted file content was blocked.'), {
|
||||
code: 'content_filter_block',
|
||||
statusCode: 400,
|
||||
body: {
|
||||
error: 'content_filter_block',
|
||||
message: 'Submitted file content was blocked.',
|
||||
source: 'file',
|
||||
field: 'content',
|
||||
},
|
||||
});
|
||||
req.config.filters = {
|
||||
files: { pii: { fields: ['content'], starterPatterns: ['sk-'] } },
|
||||
};
|
||||
api.validateRequest.mockReturnValueOnce({
|
||||
request: { model: 'agent-123', messages: [], stream: true },
|
||||
});
|
||||
api.initializeAgent.mockResolvedValueOnce({
|
||||
id: 'agent-123',
|
||||
model: 'gpt-4',
|
||||
model_parameters: {},
|
||||
toolRegistry: {},
|
||||
edges: [],
|
||||
dynamicToolContextMap: {
|
||||
execute_code: ' safe context',
|
||||
ignored_empty: '',
|
||||
file_search: 'sk-dynamic-file-context ',
|
||||
ignored_non_string: 42,
|
||||
},
|
||||
});
|
||||
api.assertModelBoundContent.mockImplementationOnce(({ filters, files }) => {
|
||||
expect(filters).toEqual(req.config.filters);
|
||||
expect(files).toEqual([{ content: 'safe context\nsk-dynamic-file-context' }]);
|
||||
throw blockedError;
|
||||
});
|
||||
|
||||
await OpenAIChatCompletionController(req, res);
|
||||
|
||||
expect(api.createRun).not.toHaveBeenCalled();
|
||||
expect(res.setHeader).not.toHaveBeenCalled();
|
||||
expect(res.flushHeaders).not.toHaveBeenCalled();
|
||||
expect(api.createErrorResponse).toHaveBeenCalledWith(
|
||||
blockedError.body.message,
|
||||
'invalid_request_error',
|
||||
'content_filter_block',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('safe error logging', () => {
|
||||
it('logs bounded metadata and returns a raw-free provider error', async () => {
|
||||
const api = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const rawValue = 'PRIVATE-OPENAI-PROVIDER-PAYLOAD';
|
||||
const providerError = Object.assign(new Error(`Provider echoed ${rawValue}`), {
|
||||
code: 'ERR_REMOTE',
|
||||
response: {
|
||||
status: 502,
|
||||
headers: { authorization: rawValue },
|
||||
data: { prompt: rawValue },
|
||||
},
|
||||
});
|
||||
req.config.filters = { messages: { pii: {} } };
|
||||
mockProcessStream.mockRejectedValueOnce(providerError);
|
||||
|
||||
await OpenAIChatCompletionController(req, res);
|
||||
|
||||
expect(mockGetSafeErrorMetadata).toHaveBeenCalledWith(providerError);
|
||||
const errorLog = logger.error.mock.calls.find(
|
||||
([message]) => message === '[OpenAI API] Error:',
|
||||
);
|
||||
expect(errorLog).toEqual(['[OpenAI API] Error:', { type: 'Error', status: 502 }]);
|
||||
expect(JSON.stringify(errorLog)).not.toContain(rawValue);
|
||||
expect(api.createErrorResponse).toHaveBeenCalledWith(
|
||||
'An error occurred while processing the request',
|
||||
'server_error',
|
||||
null,
|
||||
);
|
||||
expect(JSON.stringify(api.createErrorResponse.mock.calls)).not.toContain(rawValue);
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
});
|
||||
|
||||
it('streams a raw-free provider error after headers are sent', async () => {
|
||||
const api = require('@librechat/api');
|
||||
const rawValue = 'PRIVATE-OPENAI-STREAM-PAYLOAD';
|
||||
api.validateRequest.mockReturnValueOnce({
|
||||
request: {
|
||||
model: 'agent-123',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
stream: true,
|
||||
},
|
||||
});
|
||||
req.config.filters = { messages: { pii: {} } };
|
||||
res.flushHeaders.mockImplementationOnce(() => {
|
||||
res.headersSent = true;
|
||||
});
|
||||
mockProcessStream.mockRejectedValueOnce(new Error(`Provider echoed ${rawValue}`));
|
||||
|
||||
await OpenAIChatCompletionController(req, res);
|
||||
|
||||
expect(api.createChunk).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
{ content: '\n\nError: An error occurred while processing the request' },
|
||||
'stop',
|
||||
);
|
||||
expect(JSON.stringify(api.createChunk.mock.calls)).not.toContain(rawValue);
|
||||
expect(JSON.stringify(api.writeSSE.mock.calls)).not.toContain(rawValue);
|
||||
});
|
||||
|
||||
it('preserves the legacy provider error when protection is inactive', async () => {
|
||||
const api = require('@librechat/api');
|
||||
const rawValue = 'LEGACY-OPENAI-PROVIDER-ERROR';
|
||||
mockProcessStream.mockRejectedValueOnce(
|
||||
Object.assign(new Error(rawValue), { code: 'ERR_LEGACY_REMOTE' }),
|
||||
);
|
||||
|
||||
await OpenAIChatCompletionController(req, res);
|
||||
|
||||
expect(api.createErrorResponse).toHaveBeenCalledWith(
|
||||
rawValue,
|
||||
'server_error',
|
||||
'ERR_LEGACY_REMOTE',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a management-only prompt', { prompts: { pii: {} } }],
|
||||
['an inert message', { messages: { pii: { starterPatterns: [] } } }],
|
||||
])('preserves the legacy provider error for %s policy', async (_policy, filters) => {
|
||||
const api = require('@librechat/api');
|
||||
const rawValue = 'LEGACY-OPENAI-CONFIGURED-PROVIDER-ERROR';
|
||||
req.config.filters = filters;
|
||||
mockProcessStream.mockRejectedValueOnce(new Error(rawValue));
|
||||
|
||||
await OpenAIChatCompletionController(req, res);
|
||||
|
||||
expect(api.createErrorResponse).toHaveBeenCalledWith(rawValue, 'server_error', null);
|
||||
});
|
||||
|
||||
it('preserves the legacy streamed provider error when protection is inactive', async () => {
|
||||
const api = require('@librechat/api');
|
||||
const rawValue = 'LEGACY-OPENAI-STREAM-ERROR';
|
||||
api.validateRequest.mockReturnValueOnce({
|
||||
request: {
|
||||
model: 'agent-123',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
stream: true,
|
||||
},
|
||||
});
|
||||
res.flushHeaders.mockImplementationOnce(() => {
|
||||
res.headersSent = true;
|
||||
});
|
||||
mockProcessStream.mockRejectedValueOnce(new Error(rawValue));
|
||||
|
||||
await OpenAIChatCompletionController(req, res);
|
||||
|
||||
expect(api.createChunk).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
{ content: `\n\nError: ${rawValue}` },
|
||||
'stop',
|
||||
);
|
||||
});
|
||||
|
||||
it('logs bounded metadata for tool callback failures', async () => {
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const rawValue = 'PRIVATE-OPENAI-TOOL-PAYLOAD';
|
||||
const toolError = Object.assign(new Error(`Tool echoed ${rawValue}`), {
|
||||
code: 'ERR_TOOL',
|
||||
response: { status: 422, data: { output: rawValue } },
|
||||
});
|
||||
mockProcessStream.mockImplementationOnce(async (_input, _config, options) => {
|
||||
options.callbacks.TOOL_ERROR({}, toolError, 'execute_code');
|
||||
});
|
||||
|
||||
await OpenAIChatCompletionController(req, res);
|
||||
|
||||
expect(mockGetSafeErrorMetadata).toHaveBeenCalledWith(toolError);
|
||||
const errorLog = logger.error.mock.calls.find(([message]) =>
|
||||
message.includes('Tool Error "execute_code"'),
|
||||
);
|
||||
expect(errorLog).toEqual([
|
||||
'[OpenAI API] Tool Error "execute_code"',
|
||||
{ type: 'Error', status: 422 },
|
||||
]);
|
||||
expect(JSON.stringify(errorLog)).not.toContain(rawValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe('conversation ownership validation', () => {
|
||||
it('should skip ownership check when conversation_id is not provided', async () => {
|
||||
const { getConvo } = require('~/models');
|
||||
|
|
|
|||
|
|
@ -14,6 +14,54 @@ const mockGetBalanceConfig = jest.fn().mockReturnValue({ enabled: true });
|
|||
const mockGetTransactionsConfig = jest.fn().mockReturnValue({ enabled: true });
|
||||
const mockResolveMemoryAvailability = jest.fn().mockResolvedValue(true);
|
||||
const mockInitialSessions = new Map([['execute_code', { session_id: 'seeded' }]]);
|
||||
const mockInspectContent = jest.fn().mockReturnValue(null);
|
||||
const mockResolveConversationTitle = jest.fn(({ filters, candidate, fallback = 'New Chat' }) => {
|
||||
const resolveAllowedTitle = (value) => {
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
return null;
|
||||
}
|
||||
const finding = mockInspectContent(
|
||||
[{ source: 'conversation_title', field: 'title', text: value }],
|
||||
{ filters },
|
||||
);
|
||||
return finding == null ? value : null;
|
||||
};
|
||||
|
||||
return (
|
||||
resolveAllowedTitle(candidate) ??
|
||||
(fallback === candidate ? null : resolveAllowedTitle(fallback))
|
||||
);
|
||||
});
|
||||
const mockHasActivePiiPatterns = (config) =>
|
||||
config != null &&
|
||||
(config.starterPatterns == null ||
|
||||
config.starterPatterns.length > 0 ||
|
||||
(config.customPatterns?.length ?? 0) > 0);
|
||||
const mockHasModelBoundContentProtection = (filters, legacyPii) => {
|
||||
const sourcePolicies = [
|
||||
legacyPii,
|
||||
filters?.messages?.pii,
|
||||
filters?.agentInstructions?.pii,
|
||||
filters?.conversationStarters?.pii,
|
||||
filters?.skills?.pii,
|
||||
filters?.memories?.pii,
|
||||
filters?.files?.pii,
|
||||
filters?.toolArguments?.pii,
|
||||
filters?.modelParameters?.pii,
|
||||
filters?.actionMetadata?.pii,
|
||||
];
|
||||
if (sourcePolicies.some(mockHasActivePiiPatterns)) {
|
||||
return true;
|
||||
}
|
||||
const filePolicy = filters?.files?.pii;
|
||||
return (
|
||||
filePolicy?.uninspectable === 'block' &&
|
||||
(filePolicy.fields == null ||
|
||||
filePolicy.fields.some((field) =>
|
||||
['content', 'extracted_text', 'transcript'].includes(field),
|
||||
))
|
||||
);
|
||||
};
|
||||
class MockAgentRunEnvelopeError extends TypeError {
|
||||
constructor(message) {
|
||||
super(message);
|
||||
|
|
@ -34,6 +82,13 @@ const mockCreateAgentRunEnvelope = jest.fn(
|
|||
payload: JSON.parse(JSON.stringify(payload)),
|
||||
}),
|
||||
);
|
||||
const mockGetSafeErrorMetadata = jest.fn((error) => {
|
||||
const status = error?.status ?? error?.statusCode ?? error?.response?.status;
|
||||
return {
|
||||
type: error instanceof Error ? 'Error' : 'UnknownError',
|
||||
...(Number.isInteger(status) && status >= 100 && status <= 599 && { status }),
|
||||
};
|
||||
});
|
||||
const mockBuildSkillPrimedIdsByName = jest.fn((manualSkillPrimes, alwaysApplySkillPrimes) => {
|
||||
const primed = {};
|
||||
for (const skill of alwaysApplySkillPrimes ?? []) {
|
||||
|
|
@ -110,10 +165,27 @@ jest.mock('@librechat/agents', () => ({
|
|||
}));
|
||||
|
||||
jest.mock('@librechat/api', () => ({
|
||||
SAFE_CONVERSATION_TITLE: 'New Chat',
|
||||
resolveConversationTitle: (...args) => mockResolveConversationTitle(...args),
|
||||
/** Pass-through: the controller strips UI-only activity-label parts
|
||||
* before SDK formatting; the mock must expose it like any other used
|
||||
* export or the call throws before the assertions run. */
|
||||
stripActivityLabelParts: jest.fn((payload) => payload),
|
||||
collectReachableAgents: (roots) => {
|
||||
const agents = [];
|
||||
const pending = [...roots];
|
||||
const visited = new Set();
|
||||
for (let index = 0; index < pending.length; index++) {
|
||||
const agent = pending[index];
|
||||
if (!agent || visited.has(agent)) {
|
||||
continue;
|
||||
}
|
||||
visited.add(agent);
|
||||
agents.push(agent);
|
||||
pending.push(...(agent.subagentAgentConfigs ?? []));
|
||||
}
|
||||
return agents;
|
||||
},
|
||||
createRun: jest.fn().mockResolvedValue({
|
||||
processStream: jest.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
|
|
@ -190,7 +262,35 @@ jest.mock('@librechat/api', () => ({
|
|||
buildResponse: jest.fn().mockReturnValue({ id: 'resp_123', output: [] }),
|
||||
generateResponseId: jest.fn().mockReturnValue('resp_mock-123'),
|
||||
isValidationFailure: jest.fn().mockReturnValue(false),
|
||||
findPiiMatchInMessages: jest.fn().mockReturnValue(null),
|
||||
inspectContent: mockInspectContent,
|
||||
extractConversationTitleContent: jest.fn(({ title }) => [
|
||||
{ source: 'conversation_title', field: 'title', text: title },
|
||||
]),
|
||||
extractAgentContent: jest.fn().mockReturnValue([]),
|
||||
extractFileContent: jest.fn().mockReturnValue([]),
|
||||
extractMessageContent: jest.fn().mockReturnValue([]),
|
||||
extractModelParameterContent: jest.fn().mockReturnValue([]),
|
||||
extractSkillContent: jest.fn().mockReturnValue([]),
|
||||
extractToolArgumentContent: jest.fn().mockReturnValue([]),
|
||||
getBlockedOpaqueFileField: jest.fn().mockReturnValue(null),
|
||||
getContentTraversalFragments: jest.fn().mockReturnValue([]),
|
||||
isContentTraversalProtected: jest.fn().mockReturnValue(true),
|
||||
isContentTraversalLimitError: jest.fn((error) => error?.code === 'content_filter_uninspectable'),
|
||||
prependContentTraversalFragments: jest.fn(),
|
||||
assertModelBoundContent: jest.fn(),
|
||||
hasModelBoundContentProtection: mockHasModelBoundContentProtection,
|
||||
isContentFilterError: jest.fn((error) => error?.code === 'content_filter_block'),
|
||||
getSafeErrorMetadata: mockGetSafeErrorMetadata,
|
||||
contentFilterBlockResponse: jest.fn().mockReturnValue({
|
||||
error: 'content_filter_block',
|
||||
message: 'Submitted content was blocked.',
|
||||
}),
|
||||
contentFilterUninspectableResponse: jest.fn().mockReturnValue({
|
||||
error: 'content_filter_uninspectable',
|
||||
message: 'Submitted file content could not be inspected before processing.',
|
||||
source: 'file',
|
||||
field: 'content',
|
||||
}),
|
||||
emitResponseCreated: jest.fn(),
|
||||
createResponseContext: jest.fn().mockReturnValue({ responseId: 'resp_123' }),
|
||||
createResponseTracker: jest.fn().mockReturnValue({
|
||||
|
|
@ -341,6 +441,7 @@ describe('createResponse controller', () => {
|
|||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGlobalDiscoveredAgentConfigs = null;
|
||||
require('@librechat/api').inspectContent.mockReset().mockReturnValue(null);
|
||||
|
||||
const controller = require('../responses');
|
||||
createResponse = controller.createResponse;
|
||||
|
|
@ -584,6 +685,813 @@ describe('createResponse controller', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('content filtering', () => {
|
||||
it('replaces a blocked agent-derived conversation title before persistence', async () => {
|
||||
const api = require('@librechat/api');
|
||||
const db = require('~/models');
|
||||
api.validateResponseRequest.mockReturnValueOnce({
|
||||
request: {
|
||||
model: 'agent-123',
|
||||
input: 'Hello',
|
||||
stream: false,
|
||||
store: true,
|
||||
},
|
||||
});
|
||||
api.inspectContent.mockImplementation((fragments) =>
|
||||
fragments[0]?.source === 'conversation_title' && fragments[0]?.text === 'BLOCKED-AGENT'
|
||||
? { detectorId: 'pii-pattern' }
|
||||
: null,
|
||||
);
|
||||
db.getAgent.mockResolvedValueOnce({
|
||||
id: 'agent-123',
|
||||
name: 'BLOCKED-AGENT',
|
||||
model: 'claude-3',
|
||||
provider: 'anthropic',
|
||||
});
|
||||
req.config.filters = {
|
||||
conversationTitles: {
|
||||
pii: {
|
||||
starterPatterns: [],
|
||||
customPatterns: [{ id: 'blocked', label: 'blocked', regex: 'BLOCKED' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await createResponse(req, res);
|
||||
|
||||
expect(db.saveConvo).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({
|
||||
title: 'New Chat',
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks opaque response input before conversion or agent loading', async () => {
|
||||
const api = require('@librechat/api');
|
||||
const db = require('~/models');
|
||||
const input = [
|
||||
{
|
||||
type: 'message',
|
||||
role: 'user',
|
||||
content: [{ type: 'input_file', file_data: 'do-not-echo' }],
|
||||
},
|
||||
];
|
||||
api.validateResponseRequest.mockReturnValueOnce({
|
||||
request: { model: 'agent-123', input, stream: false },
|
||||
});
|
||||
api.getBlockedOpaqueFileField.mockReturnValueOnce('extracted_text');
|
||||
api.contentFilterUninspectableResponse.mockReturnValueOnce({
|
||||
error: 'content_filter_uninspectable',
|
||||
message: 'Submitted file content could not be inspected before processing.',
|
||||
source: 'file',
|
||||
field: 'extracted_text',
|
||||
});
|
||||
|
||||
await createResponse(req, res);
|
||||
|
||||
expect(api.getBlockedOpaqueFileField).toHaveBeenCalledWith(req.config.filters, input);
|
||||
expect(api.convertInputToMessages).not.toHaveBeenCalled();
|
||||
expect(db.getAgent).not.toHaveBeenCalled();
|
||||
expect(api.sendResponsesErrorResponse).toHaveBeenCalledWith(
|
||||
res,
|
||||
400,
|
||||
'Submitted file content could not be inspected before processing.',
|
||||
'invalid_request',
|
||||
'content_filter_uninspectable',
|
||||
);
|
||||
expect(JSON.stringify(api.sendResponsesErrorResponse.mock.calls)).not.toContain(
|
||||
'do-not-echo',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns a raw-free error when nested response input exhausts its budget', async () => {
|
||||
const api = require('@librechat/api');
|
||||
const db = require('~/models');
|
||||
req.config.filters = { messages: { pii: { starterPatterns: [] } } };
|
||||
api.extractMessageContent.mockImplementationOnce(() => {
|
||||
throw {
|
||||
code: 'content_filter_uninspectable',
|
||||
statusCode: 400,
|
||||
body: {
|
||||
error: 'content_filter_uninspectable',
|
||||
message: 'Submitted content could not be completely inspected before processing.',
|
||||
source: 'message',
|
||||
field: 'content_part',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
await createResponse(req, res);
|
||||
|
||||
expect(db.getAgent).not.toHaveBeenCalled();
|
||||
expect(api.sendResponsesErrorResponse).toHaveBeenCalledWith(
|
||||
res,
|
||||
400,
|
||||
'Submitted content could not be completely inspected before processing.',
|
||||
'invalid_request',
|
||||
'content_filter_uninspectable',
|
||||
);
|
||||
});
|
||||
|
||||
it('continues when exhausted response parameters are outside the active policy', async () => {
|
||||
const api = require('@librechat/api');
|
||||
const db = require('~/models');
|
||||
api.extractModelParameterContent.mockImplementationOnce(() => {
|
||||
throw {
|
||||
code: 'content_filter_uninspectable',
|
||||
statusCode: 400,
|
||||
body: {
|
||||
error: 'content_filter_uninspectable',
|
||||
message: 'Submitted content could not be completely inspected before processing.',
|
||||
source: 'model_parameter',
|
||||
field: 'request_fields',
|
||||
},
|
||||
};
|
||||
});
|
||||
api.isContentTraversalProtected.mockReturnValueOnce(false);
|
||||
|
||||
await createResponse(req, res);
|
||||
|
||||
expect(db.getAgent).toHaveBeenCalled();
|
||||
expect(api.sendResponsesErrorResponse).not.toHaveBeenCalledWith(
|
||||
res,
|
||||
400,
|
||||
expect.anything(),
|
||||
'invalid_request',
|
||||
'content_filter_uninspectable',
|
||||
);
|
||||
});
|
||||
|
||||
it('retains earlier request fragments when a function schema exhausts traversal', async () => {
|
||||
const api = require('@librechat/api');
|
||||
const db = require('~/models');
|
||||
const instructionFragment = {
|
||||
id: 'agent.instructions',
|
||||
path: '/instructions',
|
||||
text: 'PRIVATE-INSTRUCTION',
|
||||
source: 'agent_instruction',
|
||||
field: 'instructions',
|
||||
};
|
||||
const partialToolFragment = {
|
||||
id: 'tool.arguments.partial',
|
||||
path: '/arguments/safe',
|
||||
text: 'safe',
|
||||
source: 'tool_argument',
|
||||
field: 'arguments',
|
||||
};
|
||||
const traversalError = Object.assign(new Error('Traversal limit exceeded'), {
|
||||
code: 'content_filter_uninspectable',
|
||||
statusCode: 400,
|
||||
body: {
|
||||
error: 'content_filter_uninspectable',
|
||||
message: 'Submitted content could not be completely inspected before processing.',
|
||||
source: 'tool_argument',
|
||||
field: 'arguments',
|
||||
},
|
||||
});
|
||||
api.validateResponseRequest.mockReturnValueOnce({
|
||||
request: {
|
||||
model: 'agent-123',
|
||||
input: 'Hello',
|
||||
instructions: 'PRIVATE-INSTRUCTION',
|
||||
tools: [{ type: 'function', name: 'lookup', parameters: { safe: true } }],
|
||||
stream: false,
|
||||
},
|
||||
});
|
||||
api.extractAgentContent.mockReturnValueOnce([instructionFragment]);
|
||||
api.extractToolArgumentContent.mockImplementationOnce(() => {
|
||||
throw traversalError;
|
||||
});
|
||||
api.getContentTraversalFragments.mockReturnValueOnce([
|
||||
instructionFragment,
|
||||
partialToolFragment,
|
||||
]);
|
||||
api.inspectContent.mockReturnValueOnce({
|
||||
detectorId: 'pii-pattern',
|
||||
ruleId: 'private',
|
||||
label: 'private value',
|
||||
source: 'agent_instruction',
|
||||
field: 'instructions',
|
||||
});
|
||||
req.config.filters = {
|
||||
agentInstructions: {
|
||||
pii: {
|
||||
fields: ['instructions'],
|
||||
starterPatterns: [],
|
||||
customPatterns: [{ id: 'private', label: 'private value', regex: 'PRIVATE' }],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await createResponse(req, res);
|
||||
|
||||
expect(api.prependContentTraversalFragments).toHaveBeenCalledWith(
|
||||
traversalError,
|
||||
expect.arrayContaining([instructionFragment]),
|
||||
);
|
||||
expect(api.inspectContent).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([instructionFragment, partialToolFragment]),
|
||||
expect.anything(),
|
||||
);
|
||||
expect(db.getAgent).not.toHaveBeenCalled();
|
||||
expect(api.sendResponsesErrorResponse).toHaveBeenCalledWith(
|
||||
res,
|
||||
400,
|
||||
'Submitted content was blocked.',
|
||||
'invalid_request',
|
||||
'content_filter_block',
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks instructions and input before loading the agent', async () => {
|
||||
const api = require('@librechat/api');
|
||||
const db = require('~/models');
|
||||
api.validateResponseRequest.mockReturnValueOnce({
|
||||
request: {
|
||||
model: 'agent-123',
|
||||
input: 'Hello',
|
||||
stream: false,
|
||||
metadata: { label: 'submitted metadata' },
|
||||
text: {
|
||||
format: {
|
||||
type: 'json_schema',
|
||||
json_schema: { description: 'submitted response schema' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
api.inspectContent.mockReturnValueOnce({
|
||||
detectorId: 'pii-pattern',
|
||||
ruleId: 'sk_prefix',
|
||||
label: 'sk- prefix token',
|
||||
source: 'agent_instruction',
|
||||
field: 'instructions',
|
||||
});
|
||||
|
||||
await createResponse(req, res);
|
||||
|
||||
expect(api.extractAgentContent).toHaveBeenCalled();
|
||||
expect(api.extractMessageContent).toHaveBeenCalled();
|
||||
expect(api.extractModelParameterContent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
metadata: { label: 'submitted metadata' },
|
||||
response_format: {
|
||||
type: 'json_schema',
|
||||
json_schema: { description: 'submitted response schema' },
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(db.getAgent).not.toHaveBeenCalled();
|
||||
expect(api.sendResponsesErrorResponse).toHaveBeenCalledWith(
|
||||
res,
|
||||
400,
|
||||
'Submitted content was blocked.',
|
||||
'invalid_request',
|
||||
'content_filter_block',
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks manually selected skill names before resolving the skill', async () => {
|
||||
const api = require('@librechat/api');
|
||||
const db = require('~/models');
|
||||
req.body.manualSkills = ['PRIVATE-SKILL'];
|
||||
api.extractManualSkills.mockReturnValueOnce(['PRIVATE-SKILL']);
|
||||
api.inspectContent.mockReturnValueOnce({
|
||||
detectorId: 'pii-pattern',
|
||||
ruleId: 'private',
|
||||
label: 'private value',
|
||||
source: 'skill',
|
||||
field: 'name',
|
||||
});
|
||||
|
||||
await createResponse(req, res);
|
||||
|
||||
expect(api.extractSkillContent).toHaveBeenCalledWith({ name: 'PRIVATE-SKILL' });
|
||||
expect(db.getAgent).not.toHaveBeenCalled();
|
||||
expect(api.sendResponsesErrorResponse).toHaveBeenCalledWith(
|
||||
res,
|
||||
400,
|
||||
'Submitted content was blocked.',
|
||||
'invalid_request',
|
||||
'content_filter_block',
|
||||
);
|
||||
});
|
||||
|
||||
it('blocks previously stored model-bound content before provider invocation', async () => {
|
||||
const api = require('@librechat/api');
|
||||
const blockedError = Object.assign(
|
||||
new Error('Submitted content contains a private value. Remove it and try again.'),
|
||||
{
|
||||
code: 'content_filter_block',
|
||||
statusCode: 400,
|
||||
body: {
|
||||
error: 'content_filter_block',
|
||||
message: 'Submitted content contains a private value. Remove it and try again.',
|
||||
source: 'message',
|
||||
field: 'text',
|
||||
},
|
||||
},
|
||||
);
|
||||
api.assertModelBoundContent.mockImplementationOnce(() => {
|
||||
throw blockedError;
|
||||
});
|
||||
|
||||
await createResponse(req, res);
|
||||
|
||||
expect(api.assertModelBoundContent).toHaveBeenCalled();
|
||||
expect(api.createRun).not.toHaveBeenCalled();
|
||||
expect(api.sendResponsesErrorResponse).toHaveBeenCalledWith(
|
||||
res,
|
||||
400,
|
||||
blockedError.body.message,
|
||||
'invalid_request',
|
||||
'content_filter_block',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves imported whole-assistant provenance and blocks its stored text', async () => {
|
||||
const api = require('@librechat/api');
|
||||
const db = require('~/models');
|
||||
const storedMessage = {
|
||||
messageId: 'imported-assistant',
|
||||
isCreatedByUser: false,
|
||||
isUserSubmitted: true,
|
||||
text: 'sk-imported-secret',
|
||||
};
|
||||
const blockedError = Object.assign(new Error('Submitted content was blocked.'), {
|
||||
code: 'content_filter_block',
|
||||
statusCode: 400,
|
||||
body: {
|
||||
error: 'content_filter_block',
|
||||
message: 'Submitted content was blocked.',
|
||||
source: 'message',
|
||||
field: 'text',
|
||||
},
|
||||
});
|
||||
api.validateResponseRequest.mockReturnValueOnce({
|
||||
request: {
|
||||
model: 'agent-123',
|
||||
input: 'Hello',
|
||||
stream: false,
|
||||
previous_response_id: 'resp_imported',
|
||||
},
|
||||
});
|
||||
db.getConvo.mockResolvedValueOnce({ conversationId: 'resp_imported', user: 'user-123' });
|
||||
db.getMessages.mockResolvedValueOnce([storedMessage]);
|
||||
api.assertModelBoundContent.mockImplementationOnce(({ storedMessages }) => {
|
||||
expect(storedMessages).toEqual([
|
||||
expect.objectContaining({
|
||||
messageId: 'imported-assistant',
|
||||
isCreatedByUser: false,
|
||||
isUserSubmitted: true,
|
||||
text: 'sk-imported-secret',
|
||||
content: 'sk-imported-secret',
|
||||
}),
|
||||
]);
|
||||
throw blockedError;
|
||||
});
|
||||
|
||||
await createResponse(req, res);
|
||||
|
||||
expect(api.initializeAgent).not.toHaveBeenCalled();
|
||||
expect(api.discoverConnectedAgents).not.toHaveBeenCalled();
|
||||
expect(mockBuildAgentScopedContext).not.toHaveBeenCalled();
|
||||
expect(mockApplyContextToAgent).not.toHaveBeenCalled();
|
||||
expect(db.updateFilesUsage).not.toHaveBeenCalled();
|
||||
expect(api.createRun).not.toHaveBeenCalled();
|
||||
expect(api.sendResponsesErrorResponse).toHaveBeenCalledWith(
|
||||
res,
|
||||
400,
|
||||
blockedError.body.message,
|
||||
'invalid_request',
|
||||
'content_filter_block',
|
||||
);
|
||||
});
|
||||
|
||||
it('preserves path-marked assistant content and blocks only the submitted block', async () => {
|
||||
const api = require('@librechat/api');
|
||||
const db = require('~/models');
|
||||
const content = [
|
||||
{ type: 'text', text: 'neighboring model prose' },
|
||||
{ type: 'text', text: 'sk-path-secret' },
|
||||
];
|
||||
const blockedError = Object.assign(new Error('Submitted content was blocked.'), {
|
||||
code: 'content_filter_block',
|
||||
statusCode: 400,
|
||||
body: {
|
||||
error: 'content_filter_block',
|
||||
message: 'Submitted content was blocked.',
|
||||
source: 'message',
|
||||
field: 'content_part',
|
||||
},
|
||||
});
|
||||
api.validateResponseRequest.mockReturnValueOnce({
|
||||
request: {
|
||||
model: 'agent-123',
|
||||
input: 'Hello',
|
||||
stream: false,
|
||||
previous_response_id: 'resp_path_marked',
|
||||
},
|
||||
});
|
||||
db.getConvo.mockResolvedValueOnce({
|
||||
conversationId: 'resp_path_marked',
|
||||
user: 'user-123',
|
||||
});
|
||||
db.getMessages.mockResolvedValueOnce([
|
||||
{
|
||||
messageId: 'mixed-assistant',
|
||||
isCreatedByUser: false,
|
||||
text: 'assistant summary text',
|
||||
content,
|
||||
userSubmittedPaths: ['/content/1/text'],
|
||||
userSubmittedMessageFieldPaths: [{ path: '/content/1/text', field: 'decision_response' }],
|
||||
},
|
||||
]);
|
||||
api.assertModelBoundContent.mockImplementationOnce(({ storedMessages }) => {
|
||||
expect(storedMessages).toEqual([
|
||||
expect.objectContaining({
|
||||
text: 'assistant summary text',
|
||||
content,
|
||||
userSubmittedPaths: ['/content/1/text'],
|
||||
userSubmittedMessageFieldPaths: [
|
||||
{ path: '/content/1/text', field: 'decision_response' },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
throw blockedError;
|
||||
});
|
||||
|
||||
await createResponse(req, res);
|
||||
|
||||
expect(api.initializeAgent).not.toHaveBeenCalled();
|
||||
expect(api.createRun).not.toHaveBeenCalled();
|
||||
expect(api.sendResponsesErrorResponse).toHaveBeenCalledWith(
|
||||
res,
|
||||
400,
|
||||
blockedError.body.message,
|
||||
'invalid_request',
|
||||
'content_filter_block',
|
||||
);
|
||||
});
|
||||
|
||||
it('allows neighboring unmarked assistant prose when marked content is safe', async () => {
|
||||
const api = require('@librechat/api');
|
||||
const db = require('~/models');
|
||||
const content = [
|
||||
{ type: 'text', text: 'sk-model-generated-prose' },
|
||||
{ type: 'text', text: 'safe submitted correction' },
|
||||
];
|
||||
api.validateResponseRequest.mockReturnValueOnce({
|
||||
request: {
|
||||
model: 'agent-123',
|
||||
input: 'Hello',
|
||||
stream: false,
|
||||
previous_response_id: 'resp_neighboring_model_output',
|
||||
},
|
||||
});
|
||||
db.getConvo.mockResolvedValueOnce({
|
||||
conversationId: 'resp_neighboring_model_output',
|
||||
user: 'user-123',
|
||||
});
|
||||
db.getMessages.mockResolvedValueOnce([
|
||||
{
|
||||
messageId: 'mixed-assistant',
|
||||
isCreatedByUser: false,
|
||||
content,
|
||||
userSubmittedPaths: ['/content/1/text'],
|
||||
},
|
||||
]);
|
||||
api.assertModelBoundContent.mockImplementationOnce(({ storedMessages }) => {
|
||||
const [message] = storedMessages;
|
||||
expect(message.content).toEqual(content);
|
||||
expect(message.userSubmittedPaths).toEqual(['/content/1/text']);
|
||||
expect(message.content[1].text).toBe('safe submitted correction');
|
||||
});
|
||||
|
||||
await createResponse(req, res);
|
||||
|
||||
expect(api.createRun).toHaveBeenCalledTimes(1);
|
||||
expect(api.sendResponsesErrorResponse).not.toHaveBeenCalledWith(
|
||||
res,
|
||||
400,
|
||||
expect.anything(),
|
||||
'invalid_request',
|
||||
'content_filter_block',
|
||||
);
|
||||
});
|
||||
|
||||
it('preflights request and context attachments from every run agent under a files-only policy', async () => {
|
||||
const api = require('@librechat/api');
|
||||
const primaryRequestFile = { filename: 'primary-request.txt', content: 'primary request' };
|
||||
const primaryContextFile = { filename: 'primary-context.txt', content: 'primary context' };
|
||||
const handoffRequestFile = { filename: 'handoff-request.txt', content: 'handoff request' };
|
||||
const handoffContextFile = {
|
||||
filename: 'handoff-context.txt',
|
||||
content: 'sk-handoff-context',
|
||||
};
|
||||
const blockedError = Object.assign(new Error('Submitted file content was blocked.'), {
|
||||
code: 'content_filter_block',
|
||||
statusCode: 400,
|
||||
body: {
|
||||
error: 'content_filter_block',
|
||||
message: 'Submitted file content was blocked.',
|
||||
source: 'file',
|
||||
field: 'content',
|
||||
},
|
||||
});
|
||||
req.config.filters = {
|
||||
files: { pii: { fields: ['content'], starterPatterns: ['sk-'] } },
|
||||
};
|
||||
api.validateResponseRequest.mockReturnValueOnce({
|
||||
request: { model: 'agent-123', input: 'Hello', stream: true },
|
||||
});
|
||||
api.initializeAgent.mockResolvedValueOnce({
|
||||
id: 'agent-123',
|
||||
model: 'claude-3',
|
||||
model_parameters: {},
|
||||
toolRegistry: {},
|
||||
edges: [{ source: 'agent-123', target: 'agent-handoff' }],
|
||||
requestAttachments: [primaryRequestFile],
|
||||
agentContextAttachments: [primaryContextFile],
|
||||
});
|
||||
mockGlobalDiscoveredAgentConfigs = new Map([
|
||||
[
|
||||
'agent-handoff',
|
||||
{
|
||||
id: 'agent-handoff',
|
||||
model: 'claude-3',
|
||||
model_parameters: {},
|
||||
requestAttachments: [handoffRequestFile],
|
||||
agentContextAttachments: [handoffContextFile],
|
||||
},
|
||||
],
|
||||
]);
|
||||
api.assertModelBoundContent
|
||||
.mockImplementationOnce(() => undefined)
|
||||
.mockImplementationOnce(({ filters, files }) => {
|
||||
expect(filters).toEqual(req.config.filters);
|
||||
expect(files).toEqual([
|
||||
primaryRequestFile,
|
||||
primaryContextFile,
|
||||
handoffRequestFile,
|
||||
handoffContextFile,
|
||||
]);
|
||||
throw blockedError;
|
||||
});
|
||||
|
||||
await createResponse(req, res);
|
||||
|
||||
expect(api.createRun).not.toHaveBeenCalled();
|
||||
expect(api.setupStreamingResponse).not.toHaveBeenCalled();
|
||||
expect(api.sendResponsesErrorResponse).toHaveBeenCalledWith(
|
||||
res,
|
||||
400,
|
||||
blockedError.body.message,
|
||||
'invalid_request',
|
||||
'content_filter_block',
|
||||
);
|
||||
});
|
||||
|
||||
it('preflights each exact synthesized dynamic tool context as file content', async () => {
|
||||
const api = require('@librechat/api');
|
||||
const nestedPureSubagent = {
|
||||
id: 'agent-nested-pure',
|
||||
model: 'claude-3',
|
||||
model_parameters: {},
|
||||
toolDefinitions: [
|
||||
{
|
||||
name: 'nested_lookup',
|
||||
description: 'late-loaded nested tool definition',
|
||||
parameters: { type: 'object' },
|
||||
},
|
||||
],
|
||||
dynamicToolContextMap: {
|
||||
nested_lookup: 'sk-nested-dynamic-context',
|
||||
},
|
||||
};
|
||||
const blockedError = Object.assign(new Error('Submitted file content was blocked.'), {
|
||||
code: 'content_filter_block',
|
||||
statusCode: 400,
|
||||
body: {
|
||||
error: 'content_filter_block',
|
||||
message: 'Submitted file content was blocked.',
|
||||
source: 'file',
|
||||
field: 'content',
|
||||
},
|
||||
});
|
||||
req.config.filters = {
|
||||
files: { pii: { fields: ['content'], starterPatterns: ['sk-'] } },
|
||||
};
|
||||
api.validateResponseRequest.mockReturnValueOnce({
|
||||
request: { model: 'agent-123', input: 'Hello', stream: true },
|
||||
});
|
||||
api.initializeAgent.mockResolvedValueOnce({
|
||||
id: 'agent-123',
|
||||
model: 'claude-3',
|
||||
model_parameters: {},
|
||||
toolRegistry: {},
|
||||
edges: [{ source: 'agent-123', target: 'agent-handoff' }],
|
||||
dynamicToolContextMap: {
|
||||
execute_code: ' primary safe context',
|
||||
ignored_empty: '',
|
||||
file_search: 'primary context ',
|
||||
ignored_non_string: 42,
|
||||
},
|
||||
subagentAgentConfigs: [
|
||||
{
|
||||
id: 'agent-pure',
|
||||
model: 'claude-3',
|
||||
model_parameters: {},
|
||||
subagentAgentConfigs: [nestedPureSubagent],
|
||||
},
|
||||
],
|
||||
});
|
||||
mockGlobalDiscoveredAgentConfigs = new Map([
|
||||
[
|
||||
'agent-handoff',
|
||||
{
|
||||
id: 'agent-handoff',
|
||||
model: 'claude-3',
|
||||
model_parameters: {},
|
||||
dynamicToolContextMap: {
|
||||
execute_code: 'handoff safe context',
|
||||
file_search: 'sk-handoff-dynamic-context',
|
||||
},
|
||||
},
|
||||
],
|
||||
]);
|
||||
api.assertModelBoundContent
|
||||
.mockImplementationOnce(() => undefined)
|
||||
.mockImplementationOnce(({ filters, agents, files }) => {
|
||||
expect(filters).toEqual(req.config.filters);
|
||||
expect(agents.map(({ id }) => id)).toEqual([
|
||||
'agent-123',
|
||||
'agent-handoff',
|
||||
'agent-pure',
|
||||
'agent-nested-pure',
|
||||
]);
|
||||
expect(files).toEqual([
|
||||
{ content: 'primary safe context\nprimary context' },
|
||||
{ content: 'handoff safe context\nsk-handoff-dynamic-context' },
|
||||
{ content: 'sk-nested-dynamic-context' },
|
||||
]);
|
||||
throw blockedError;
|
||||
});
|
||||
|
||||
await createResponse(req, res);
|
||||
|
||||
expect(api.createRun).not.toHaveBeenCalled();
|
||||
expect(api.setupStreamingResponse).not.toHaveBeenCalled();
|
||||
expect(api.sendResponsesErrorResponse).toHaveBeenCalledWith(
|
||||
res,
|
||||
400,
|
||||
blockedError.body.message,
|
||||
'invalid_request',
|
||||
'content_filter_block',
|
||||
);
|
||||
});
|
||||
|
||||
it('re-inspects agents after dynamic context is applied', async () => {
|
||||
const api = require('@librechat/api');
|
||||
const blockedError = Object.assign(new Error('Submitted content was blocked.'), {
|
||||
code: 'content_filter_block',
|
||||
statusCode: 400,
|
||||
body: {
|
||||
error: 'content_filter_block',
|
||||
message: 'Submitted content was blocked.',
|
||||
source: 'agent_instruction',
|
||||
field: 'instructions',
|
||||
},
|
||||
});
|
||||
mockApplyContextToAgent.mockImplementationOnce(async ({ agent }) => {
|
||||
agent.instructions = 'PRIVATE-DYNAMIC-INSTRUCTION';
|
||||
});
|
||||
api.assertModelBoundContent
|
||||
.mockImplementationOnce(() => undefined)
|
||||
.mockImplementationOnce(({ agents }) => {
|
||||
if (agents?.some((agent) => agent.instructions === 'PRIVATE-DYNAMIC-INSTRUCTION')) {
|
||||
throw blockedError;
|
||||
}
|
||||
});
|
||||
|
||||
await createResponse(req, res);
|
||||
|
||||
expect(api.assertModelBoundContent).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({
|
||||
agents: [expect.objectContaining({ instructions: 'PRIVATE-DYNAMIC-INSTRUCTION' })],
|
||||
}),
|
||||
);
|
||||
expect(api.createRun).not.toHaveBeenCalled();
|
||||
expect(api.sendResponsesErrorResponse).toHaveBeenCalledWith(
|
||||
res,
|
||||
400,
|
||||
blockedError.body.message,
|
||||
'invalid_request',
|
||||
'content_filter_block',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('safe error logging', () => {
|
||||
it('logs bounded metadata and returns a raw-free provider error', async () => {
|
||||
const api = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const rawValue = 'PRIVATE-RESPONSES-PROVIDER-PAYLOAD';
|
||||
const providerError = Object.assign(new Error(`Provider echoed ${rawValue}`), {
|
||||
code: 'ERR_REMOTE',
|
||||
response: {
|
||||
status: 502,
|
||||
headers: { authorization: rawValue },
|
||||
data: { prompt: rawValue },
|
||||
},
|
||||
});
|
||||
req.config.filters = { messages: { pii: {} } };
|
||||
api.createRun.mockRejectedValueOnce(providerError);
|
||||
|
||||
await createResponse(req, res);
|
||||
|
||||
expect(mockGetSafeErrorMetadata).toHaveBeenCalledWith(providerError);
|
||||
const errorLog = logger.error.mock.calls.find(
|
||||
([message]) => message === '[Responses API] Error:',
|
||||
);
|
||||
expect(errorLog).toEqual(['[Responses API] Error:', { type: 'Error', status: 502 }]);
|
||||
expect(JSON.stringify(errorLog)).not.toContain(rawValue);
|
||||
expect(api.sendResponsesErrorResponse).toHaveBeenCalledWith(
|
||||
res,
|
||||
500,
|
||||
'An error occurred while processing the request',
|
||||
'server_error',
|
||||
);
|
||||
expect(JSON.stringify(api.sendResponsesErrorResponse.mock.calls)).not.toContain(rawValue);
|
||||
});
|
||||
|
||||
it('preserves the legacy provider error when protection is inactive', async () => {
|
||||
const api = require('@librechat/api');
|
||||
const rawValue = 'LEGACY-RESPONSES-PROVIDER-ERROR';
|
||||
api.createRun.mockRejectedValueOnce(
|
||||
Object.assign(new Error(rawValue), { code: 'ERR_LEGACY_REMOTE' }),
|
||||
);
|
||||
|
||||
await createResponse(req, res);
|
||||
|
||||
expect(api.sendResponsesErrorResponse).toHaveBeenCalledWith(
|
||||
res,
|
||||
500,
|
||||
rawValue,
|
||||
'server_error',
|
||||
'ERR_LEGACY_REMOTE',
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['a management-only prompt', { prompts: { pii: {} } }],
|
||||
['an inert message', { messages: { pii: { starterPatterns: [] } } }],
|
||||
])('preserves the legacy provider error for %s policy', async (_policy, filters) => {
|
||||
const api = require('@librechat/api');
|
||||
const rawValue = 'LEGACY-RESPONSES-CONFIGURED-PROVIDER-ERROR';
|
||||
req.config.filters = filters;
|
||||
api.createRun.mockRejectedValueOnce(new Error(rawValue));
|
||||
|
||||
await createResponse(req, res);
|
||||
|
||||
expect(api.sendResponsesErrorResponse).toHaveBeenCalledWith(
|
||||
res,
|
||||
500,
|
||||
rawValue,
|
||||
'server_error',
|
||||
);
|
||||
});
|
||||
|
||||
it('logs bounded metadata for tool callback failures', async () => {
|
||||
const api = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const rawValue = 'PRIVATE-RESPONSES-TOOL-PAYLOAD';
|
||||
const toolError = Object.assign(new Error(`Tool echoed ${rawValue}`), {
|
||||
code: 'ERR_TOOL',
|
||||
response: { status: 422, data: { output: rawValue } },
|
||||
});
|
||||
api.createRun.mockResolvedValueOnce({
|
||||
processStream: jest.fn().mockImplementation(async (_input, _config, options) => {
|
||||
options.callbacks.TOOL_ERROR({}, toolError, 'file_search');
|
||||
}),
|
||||
});
|
||||
|
||||
await createResponse(req, res);
|
||||
|
||||
expect(mockGetSafeErrorMetadata).toHaveBeenCalledWith(toolError);
|
||||
const errorLog = logger.error.mock.calls.find(([message]) =>
|
||||
message.includes('Tool Error "file_search"'),
|
||||
);
|
||||
expect(errorLog).toEqual([
|
||||
'[Responses API] Tool Error "file_search"',
|
||||
{ type: 'Error', status: 422 },
|
||||
]);
|
||||
expect(JSON.stringify(errorLog)).not.toContain(rawValue);
|
||||
});
|
||||
});
|
||||
|
||||
describe('conversation ownership validation', () => {
|
||||
it('should skip ownership check when previous_response_id is not provided', async () => {
|
||||
const { getConvo } = require('~/models');
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue