mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-28 04:37:37 +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
|
|
@ -14,6 +14,10 @@ const {
|
|||
getTransactionsConfig,
|
||||
encodeAndFormatDocuments,
|
||||
getLangfuseTraceMessageFields,
|
||||
isContentFilterError,
|
||||
assertModelBoundProviderContent,
|
||||
collectModelBoundHistoricalFileIdState,
|
||||
projectModelBoundSourceFiles,
|
||||
} = require('@librechat/api');
|
||||
const {
|
||||
Constants,
|
||||
|
|
@ -28,6 +32,7 @@ const {
|
|||
isEphemeralAgentId,
|
||||
supportsBalanceCheck,
|
||||
isBedrockDocumentType,
|
||||
HITL_MESSAGE_FILTER_FIELDS,
|
||||
getEndpointFileConfig,
|
||||
stripReasoningLabelMetadata,
|
||||
} = require('librechat-data-provider');
|
||||
|
|
@ -36,37 +41,57 @@ const { logViolation } = require('~/cache');
|
|||
const TextStream = require('./TextStream');
|
||||
const db = require('~/models');
|
||||
|
||||
const collectHistoricalFileRefs = (message) => {
|
||||
const refs = [];
|
||||
if (Array.isArray(message.files)) {
|
||||
refs.push(...message.files);
|
||||
}
|
||||
if (Array.isArray(message.attachments)) {
|
||||
refs.push(...message.attachments);
|
||||
}
|
||||
/** Steer parts carry their own attachment refs inside assistant content;
|
||||
* collecting them here folds the steer replay stamp's lookup into this
|
||||
* single per-turn query (see `stampSteerPartMedia`). */
|
||||
if (Array.isArray(message.content)) {
|
||||
for (const part of message.content) {
|
||||
if (part?.type === ContentTypes.STEER && Array.isArray(part.files)) {
|
||||
refs.push(...part.files);
|
||||
}
|
||||
}
|
||||
}
|
||||
return refs;
|
||||
};
|
||||
const omitUnreplayedHistoricalFiles = (messages) =>
|
||||
messages.map(({ files: _files, attachments: _attachments, ...message }) => ({
|
||||
...message,
|
||||
...(Array.isArray(message.content)
|
||||
? {
|
||||
content: message.content.map((part) => {
|
||||
if (part == null || typeof part !== 'object') {
|
||||
return part;
|
||||
}
|
||||
const {
|
||||
file: _partFile,
|
||||
files: _partFiles,
|
||||
image_file: _imageFile,
|
||||
file_id: _fileId,
|
||||
...rest
|
||||
} = part;
|
||||
return rest;
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
|
||||
const collectHistoricalFileIds = (messages) => {
|
||||
const fileIds = new Set();
|
||||
for (const message of messages) {
|
||||
for (const ref of collectHistoricalFileRefs(message)) {
|
||||
if (ref?.file_id) {
|
||||
fileIds.add(ref.file_id);
|
||||
}
|
||||
const mergeUserSubmittedPaths = (...pathLists) => [
|
||||
...new Set(
|
||||
pathLists
|
||||
.flat()
|
||||
.filter((path) => typeof path === 'string' && path.startsWith('/') && path.length <= 2048),
|
||||
),
|
||||
];
|
||||
const hitlMessageFilterFields = new Set(HITL_MESSAGE_FILTER_FIELDS);
|
||||
const mergeUserSubmittedMessageFieldPaths = (...entryLists) => {
|
||||
const entries = [];
|
||||
const seen = new Set();
|
||||
for (const entry of entryLists.flat()) {
|
||||
if (
|
||||
entry == null ||
|
||||
typeof entry.path !== 'string' ||
|
||||
!entry.path.startsWith('/') ||
|
||||
entry.path.length > 2048 ||
|
||||
!hitlMessageFilterFields.has(entry.field)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const key = `${entry.field}:${entry.path}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
entries.push(entry);
|
||||
}
|
||||
return Array.from(fileIds);
|
||||
return entries;
|
||||
};
|
||||
|
||||
const buildOwnerFileFilter = (fileIds, user) => {
|
||||
|
|
@ -84,6 +109,14 @@ const buildOwnerFileFilter = (fileIds, user) => {
|
|||
return filter;
|
||||
};
|
||||
|
||||
const getOwnerHistoricalFiles = async (fileIds, user) => {
|
||||
const fileFilter = buildOwnerFileFilter(fileIds, user);
|
||||
if (!fileFilter) {
|
||||
return [];
|
||||
}
|
||||
return (await db.getFiles(fileFilter, {}, {})) ?? [];
|
||||
};
|
||||
|
||||
const TOOL_ATTACHMENT_KEYS = [
|
||||
Tools.file_search,
|
||||
Tools.web_search,
|
||||
|
|
@ -208,6 +241,71 @@ class BaseClient {
|
|||
throw new Error("Method 'setOptions' must be implemented.");
|
||||
}
|
||||
|
||||
getModelBoundStoredMessages(messages) {
|
||||
return this.options.resendFiles === false ? omitUnreplayedHistoricalFiles(messages) : messages;
|
||||
}
|
||||
|
||||
/** @param {TMessage[]} messages */
|
||||
setModelBoundStoredMessages(messages) {
|
||||
this.modelBoundStoredMessages = [...(messages ?? [])];
|
||||
}
|
||||
|
||||
getModelBoundFileProjection() {
|
||||
return projectModelBoundSourceFiles({
|
||||
messageFilesBySourceMessageId: this.message_file_map,
|
||||
sourceMessages: this.modelBoundStoredMessages,
|
||||
steerFileIdsBySourceMessageId: this.modelBoundSteerFileIdsBySourceMessageId,
|
||||
replayHistoricalFiles: this.options.resendFiles !== false,
|
||||
historicalFiles: this.authorizedHistoricalFiles,
|
||||
processedCurrentFiles: Array.isArray(this.options.attachments)
|
||||
? this.options.attachments
|
||||
: [],
|
||||
canonicalCurrentFiles: Array.isArray(this.modelBoundCurrentFiles)
|
||||
? this.modelBoundCurrentFiles
|
||||
: [],
|
||||
initiallyOverflowed: this.modelBoundHistoricalFileIdsOverflowed === true,
|
||||
});
|
||||
}
|
||||
|
||||
/** Optional pre-build guard for policies that cover restored history
|
||||
* independently of the final provider selection. */
|
||||
assertStoredModelBoundContent() {}
|
||||
|
||||
/** Agent runs can defer the parent write until their first exact model
|
||||
* boundary is admitted. Generic clients preserve the historical eager
|
||||
* persistence behavior. */
|
||||
shouldDeferUserMessagePersistence() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Returns the request-scoped deferred parent-write controller, when any. */
|
||||
getModelBoundUserMessagePersistence() {
|
||||
return this.modelBoundUserMessagePersistence;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic clients return their selected model payload from `buildMessages`.
|
||||
* AgentClient overrides this because its SDK performs pruning later and
|
||||
* enforces the same projection at the actual chat-model callback instead.
|
||||
*
|
||||
* @param {string | Array<Record<string, unknown>>} payload
|
||||
*/
|
||||
assertBuiltModelBoundContent(payload) {
|
||||
const messages = Array.isArray(payload)
|
||||
? payload
|
||||
: [{ role: 'user', content: payload, isCreatedByUser: true, isUserSubmitted: true }];
|
||||
const fileProjection = this.getModelBoundFileProjection();
|
||||
assertModelBoundProviderContent({
|
||||
filters: this.options.req?.config?.filters,
|
||||
legacyPii: this.options.req?.config?.messageFilter?.pii,
|
||||
providerMessages: messages,
|
||||
storedMessages: this.modelBoundStoredMessages,
|
||||
fileIdsBySourceMessageId: fileProjection.fileIdsBySourceMessageId,
|
||||
resolvedFiles: fileProjection.resolvedFiles,
|
||||
sourceFileProjectionOverflowed: fileProjection.overflowed,
|
||||
});
|
||||
}
|
||||
|
||||
async getCompletion() {
|
||||
throw new Error("Method 'getCompletion' must be implemented.");
|
||||
}
|
||||
|
|
@ -565,6 +663,9 @@ class BaseClient {
|
|||
const appConfig = this.options.req?.config;
|
||||
/** @type {Promise<TMessage>} */
|
||||
let userMessagePromise;
|
||||
/** @type {{ promise: Promise<unknown>, isPending: () => boolean, start: () => Promise<unknown>, cancel: () => Promise<unknown> } | undefined} */
|
||||
let userMessagePersistence;
|
||||
this.modelBoundUserMessagePersistence = undefined;
|
||||
const { user, head, isEdited, conversationId, responseMessageId, saveOptions, userMessage } =
|
||||
await this.handleStartMethods(message, opts);
|
||||
|
||||
|
|
@ -600,8 +701,10 @@ class BaseClient {
|
|||
const text = editedContent[type];
|
||||
if (index >= 0 && index < latestMessage.content.length) {
|
||||
const contentPart = latestMessage.content[index];
|
||||
let didApplyEdit = false;
|
||||
if (type === ContentTypes.THINK && contentPart.type === ContentTypes.THINK) {
|
||||
contentPart[ContentTypes.THINK] = text;
|
||||
didApplyEdit = true;
|
||||
delete contentPart.reasoning_label;
|
||||
delete contentPart.reasoning_label_step_id;
|
||||
delete contentPart.reasoning_label_attempts;
|
||||
|
|
@ -610,6 +713,13 @@ class BaseClient {
|
|||
delete contentPart.reasoning_label_status;
|
||||
} else if (type === ContentTypes.TEXT && contentPart.type === ContentTypes.TEXT) {
|
||||
contentPart[ContentTypes.TEXT] = text;
|
||||
didApplyEdit = true;
|
||||
}
|
||||
if (didApplyEdit) {
|
||||
latestMessage.userSubmittedPaths = mergeUserSubmittedPaths(
|
||||
latestMessage.userSubmittedPaths,
|
||||
[`/content/${index}/${type}`],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -625,16 +735,36 @@ class BaseClient {
|
|||
*/
|
||||
const parentMessageId = isEdited ? head : userMessage.messageId;
|
||||
this.parentMessageId = parentMessageId;
|
||||
const modelBoundStoredMessages = this.getModelBoundStoredMessages(this.currentMessages);
|
||||
this.setModelBoundStoredMessages(modelBoundStoredMessages);
|
||||
this.assertStoredModelBoundContent();
|
||||
this.modelBoundCurrentFiles = Array.isArray(this.options.attachments)
|
||||
? [...this.options.attachments]
|
||||
: [];
|
||||
if (this.options.resendFiles !== false && this.authorizedHistoricalFiles == null) {
|
||||
const historicalFileState = collectModelBoundHistoricalFileIdState(modelBoundStoredMessages);
|
||||
this.modelBoundHistoricalFileIdsOverflowed ||= historicalFileState.overflowed;
|
||||
const files = await getOwnerHistoricalFiles(
|
||||
historicalFileState.fileIds,
|
||||
this.options.req?.user,
|
||||
);
|
||||
this.authorizedHistoricalFiles = new Map(
|
||||
files
|
||||
.filter((file) => typeof file?.file_id === 'string' && file.file_id.length > 0)
|
||||
.map((file) => [file.file_id, file]),
|
||||
);
|
||||
}
|
||||
let {
|
||||
prompt: payload,
|
||||
tokenCountMap,
|
||||
promptTokens,
|
||||
} = await this.buildMessages(
|
||||
this.currentMessages,
|
||||
modelBoundStoredMessages,
|
||||
parentMessageId,
|
||||
this.getBuildMessagesOptions(opts),
|
||||
opts,
|
||||
);
|
||||
this.assertBuiltModelBoundContent(payload);
|
||||
this.options.startupTelemetry?.mark('messages_built');
|
||||
|
||||
if (tokenCountMap && tokenCountMap[userMessage.messageId]) {
|
||||
|
|
@ -688,13 +818,74 @@ class BaseClient {
|
|||
userMessage.alwaysAppliedSkills = names;
|
||||
}
|
||||
}
|
||||
userMessagePromise = this.saveMessageToDatabase(userMessage, saveOptions, user).catch(
|
||||
(err) => {
|
||||
const startUserMessagePersistence = () => {
|
||||
this.savedMessageIds.add(userMessage.messageId);
|
||||
return this.saveMessageToDatabase(userMessage, saveOptions, user).catch((err) => {
|
||||
logger.error('[BaseClient] Failed to save user message:', err);
|
||||
return {};
|
||||
},
|
||||
);
|
||||
this.savedMessageIds.add(userMessage.messageId);
|
||||
});
|
||||
};
|
||||
if (this.shouldDeferUserMessagePersistence()) {
|
||||
let state = 'pending';
|
||||
let startPersistence = startUserMessagePersistence;
|
||||
let resolvePersistence;
|
||||
let removeAbortListener = () => {};
|
||||
const persistencePromise = new Promise((resolve) => {
|
||||
resolvePersistence = resolve;
|
||||
});
|
||||
const start = () => {
|
||||
if (state !== 'pending') {
|
||||
return persistencePromise;
|
||||
}
|
||||
state = 'started';
|
||||
removeAbortListener();
|
||||
const startDeferredPersistence = startPersistence;
|
||||
startPersistence = undefined;
|
||||
try {
|
||||
Promise.resolve(startDeferredPersistence?.()).then(resolvePersistence, () =>
|
||||
resolvePersistence({}),
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('[BaseClient] Failed to start deferred user-message persistence:', error);
|
||||
resolvePersistence({});
|
||||
}
|
||||
return persistencePromise;
|
||||
};
|
||||
const cancel = () => {
|
||||
if (state !== 'pending') {
|
||||
return persistencePromise;
|
||||
}
|
||||
state = 'cancelled';
|
||||
removeAbortListener();
|
||||
startPersistence = undefined;
|
||||
/** Resolve with a non-persisted sentinel. The subagent task store
|
||||
* validates the result and fails child creation closed, while the
|
||||
* request's policy error remains the only surfaced rejection. */
|
||||
resolvePersistence({});
|
||||
return persistencePromise;
|
||||
};
|
||||
userMessagePersistence = Object.freeze({
|
||||
promise: persistencePromise,
|
||||
isPending: () => state === 'pending',
|
||||
start,
|
||||
cancel,
|
||||
});
|
||||
const requestAbortSignal = this.abortController?.signal;
|
||||
if (requestAbortSignal?.aborted) {
|
||||
/** Preserve the historical durability contract for Stop: abort
|
||||
* persistence may publish the partial assistant response before the
|
||||
* provider unwinds, so its parent write must already be underway. */
|
||||
start();
|
||||
} else if (requestAbortSignal != null) {
|
||||
const startOnAbort = () => start();
|
||||
requestAbortSignal.addEventListener('abort', startOnAbort, { once: true });
|
||||
removeAbortListener = () => requestAbortSignal.removeEventListener('abort', startOnAbort);
|
||||
}
|
||||
this.modelBoundUserMessagePersistence = userMessagePersistence;
|
||||
userMessagePromise = persistencePromise;
|
||||
} else {
|
||||
userMessagePromise = startUserMessagePersistence();
|
||||
}
|
||||
if (typeof opts?.getReqData === 'function') {
|
||||
opts.getReqData({
|
||||
userMessagePromise,
|
||||
|
|
@ -704,35 +895,51 @@ class BaseClient {
|
|||
|
||||
const balanceConfig = getBalanceConfig(appConfig);
|
||||
const transactionsConfig = getTransactionsConfig(appConfig);
|
||||
if (
|
||||
balanceConfig?.enabled &&
|
||||
supportsBalanceCheck[this.options.endpointType ?? this.options.endpoint]
|
||||
) {
|
||||
await checkBalance(
|
||||
{
|
||||
req: this.options.req,
|
||||
res: this.options.res,
|
||||
txData: {
|
||||
user: this.user,
|
||||
tokenType: 'prompt',
|
||||
amount: promptTokens,
|
||||
endpoint: this.options.endpoint,
|
||||
model: this.modelOptions?.model ?? this.model,
|
||||
endpointTokenConfig: this.options.endpointTokenConfig,
|
||||
let completionResult;
|
||||
try {
|
||||
if (
|
||||
balanceConfig?.enabled &&
|
||||
supportsBalanceCheck[this.options.endpointType ?? this.options.endpoint]
|
||||
) {
|
||||
await checkBalance(
|
||||
{
|
||||
req: this.options.req,
|
||||
res: this.options.res,
|
||||
txData: {
|
||||
user: this.user,
|
||||
tokenType: 'prompt',
|
||||
amount: promptTokens,
|
||||
endpoint: this.options.endpoint,
|
||||
model: this.modelOptions?.model ?? this.model,
|
||||
endpointTokenConfig: this.options.endpointTokenConfig,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
logViolation,
|
||||
getMultiplier: db.getMultiplier,
|
||||
findBalanceByUser: db.findBalanceByUser,
|
||||
createAutoRefillTransaction: db.createAutoRefillTransaction,
|
||||
balanceConfig,
|
||||
upsertBalanceFields: db.upsertBalanceFields,
|
||||
},
|
||||
);
|
||||
}
|
||||
{
|
||||
logViolation,
|
||||
getMultiplier: db.getMultiplier,
|
||||
findBalanceByUser: db.findBalanceByUser,
|
||||
createAutoRefillTransaction: db.createAutoRefillTransaction,
|
||||
balanceConfig,
|
||||
upsertBalanceFields: db.upsertBalanceFields,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
const { completion, metadata } = await this.sendCompletion(payload, opts);
|
||||
completionResult = await this.sendCompletion(payload, opts);
|
||||
} catch (error) {
|
||||
if (userMessagePersistence?.isPending()) {
|
||||
if (isContentFilterError(error)) {
|
||||
userMessagePersistence.cancel();
|
||||
} else {
|
||||
userMessagePersistence.start();
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
/** A safe no-model completion (or a runtime that cannot expose the
|
||||
* admission callback) must not leave the parent-write gate pending. */
|
||||
userMessagePersistence?.start();
|
||||
const { completion, metadata } = completionResult;
|
||||
if (this.abortController) {
|
||||
this.abortController.requestCompleted = true;
|
||||
}
|
||||
|
|
@ -759,6 +966,8 @@ class BaseClient {
|
|||
...(this.metadata ?? {}),
|
||||
metadata: Object.keys(metadata ?? {}).length > 0 ? metadata : undefined,
|
||||
};
|
||||
let editedSourceMessage;
|
||||
let editedSourceContentLength = 0;
|
||||
|
||||
if (typeof completion === 'string') {
|
||||
responseMessage.text = completion;
|
||||
|
|
@ -776,6 +985,8 @@ class BaseClient {
|
|||
if (!latestMessage?.content) {
|
||||
responseMessage.content = completion;
|
||||
} else {
|
||||
editedSourceMessage = latestMessage;
|
||||
editedSourceContentLength = latestMessage.content.length;
|
||||
const existingContent = [...latestMessage.content];
|
||||
const { type: editedType } = opts.editedContent;
|
||||
responseMessage.content = this.mergeEditedContent(
|
||||
|
|
@ -789,6 +1000,53 @@ class BaseClient {
|
|||
responseMessage.text = completion.join('');
|
||||
}
|
||||
|
||||
if (Array.isArray(responseMessage.content)) {
|
||||
const userSubmittedPaths = [];
|
||||
const userSubmittedMessageFieldPaths = [];
|
||||
for (let index = 0; index < responseMessage.content.length; index++) {
|
||||
if (responseMessage.content[index]?.type === ContentTypes.STEER) {
|
||||
userSubmittedPaths.push(`/content/${index}`);
|
||||
}
|
||||
}
|
||||
if (editedSourceMessage != null) {
|
||||
userSubmittedPaths.push(
|
||||
...(editedSourceMessage.userSubmittedPaths ?? []).filter((path) => {
|
||||
const match = /^\/content\/(\d+)(?:\/|$)/.exec(path);
|
||||
return match != null && Number(match[1]) < editedSourceContentLength;
|
||||
}),
|
||||
);
|
||||
userSubmittedMessageFieldPaths.push(
|
||||
...(editedSourceMessage.userSubmittedMessageFieldPaths ?? []).filter((entry) => {
|
||||
const match = /^\/content\/(\d+)(?:\/|$)/.exec(entry?.path);
|
||||
return match != null && Number(match[1]) < editedSourceContentLength;
|
||||
}),
|
||||
);
|
||||
if (editedSourceMessage.isUserSubmitted === true) {
|
||||
for (let index = 0; index < editedSourceContentLength; index++) {
|
||||
userSubmittedPaths.push(`/content/${index}`);
|
||||
}
|
||||
}
|
||||
const editedIndex = opts.editedContent?.index;
|
||||
const editedType = opts.editedContent?.type;
|
||||
if (
|
||||
Number.isInteger(editedIndex) &&
|
||||
editedIndex >= 0 &&
|
||||
editedIndex < editedSourceContentLength &&
|
||||
(editedType === ContentTypes.TEXT || editedType === ContentTypes.THINK)
|
||||
) {
|
||||
userSubmittedPaths.push(`/content/${editedIndex}/${editedType}`);
|
||||
}
|
||||
}
|
||||
if (userSubmittedPaths.length > 0) {
|
||||
responseMessage.userSubmittedPaths = mergeUserSubmittedPaths(userSubmittedPaths);
|
||||
}
|
||||
if (userSubmittedMessageFieldPaths.length > 0) {
|
||||
responseMessage.userSubmittedMessageFieldPaths = mergeUserSubmittedMessageFieldPaths(
|
||||
userSubmittedMessageFieldPaths,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (tokenCountMap && this.recordTokenUsage && this.getTokenCountForResponse) {
|
||||
let completionTokens;
|
||||
|
||||
|
|
@ -1549,15 +1807,16 @@ class BaseClient {
|
|||
}
|
||||
}
|
||||
|
||||
const historicalFileIds = collectHistoricalFileIds(_messages);
|
||||
const fileFilter = buildOwnerFileFilter(historicalFileIds, this.options.req?.user);
|
||||
const historicalFileState = collectModelBoundHistoricalFileIdState(_messages);
|
||||
this.modelBoundHistoricalFileIdsOverflowed ||= historicalFileState.overflowed;
|
||||
const authorizedFilesById = new Map();
|
||||
if (fileFilter) {
|
||||
const files = (await db.getFiles(fileFilter, {}, {})) ?? [];
|
||||
for (const file of files) {
|
||||
if (file?.file_id) {
|
||||
authorizedFilesById.set(file.file_id, file);
|
||||
}
|
||||
const files = await getOwnerHistoricalFiles(
|
||||
historicalFileState.fileIds,
|
||||
this.options.req?.user,
|
||||
);
|
||||
for (const file of files) {
|
||||
if (file?.file_id) {
|
||||
authorizedFilesById.set(file.file_id, file);
|
||||
}
|
||||
}
|
||||
/** Owner-scoped docs for THIS turn, including steer-part refs — the steer
|
||||
|
|
|
|||
|
|
@ -1,12 +1,15 @@
|
|||
const { Constants, ContentTypes } = require('librechat-data-provider');
|
||||
const { ContentFilterError } = require('@librechat/api');
|
||||
const { FakeClient, initializeFakeClient } = require('./FakeClient');
|
||||
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((resolvePromise) => {
|
||||
let reject;
|
||||
const promise = new Promise((resolvePromise, rejectPromise) => {
|
||||
resolve = resolvePromise;
|
||||
reject = rejectPromise;
|
||||
});
|
||||
return { promise, resolve };
|
||||
return { promise, reject, resolve };
|
||||
}
|
||||
|
||||
jest.mock('~/db/connect');
|
||||
|
|
@ -551,6 +554,86 @@ describe('BaseClient', () => {
|
|||
expect(response).toEqual(expectedResult);
|
||||
});
|
||||
|
||||
test('persists exact provenance paths for edited and steered assistant content', async () => {
|
||||
const history = [
|
||||
{
|
||||
role: 'user',
|
||||
isCreatedByUser: true,
|
||||
text: 'Original question',
|
||||
messageId: 'user-message',
|
||||
parentMessageId: Constants.NO_PARENT,
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
isCreatedByUser: false,
|
||||
messageId: 'assistant-message',
|
||||
parentMessageId: 'user-message',
|
||||
userSubmittedPaths: ['/content/1/think'],
|
||||
userSubmittedMessageFieldPaths: [
|
||||
{ path: '/content/0/tool_call/output', field: 'answer' },
|
||||
],
|
||||
content: [
|
||||
{
|
||||
type: ContentTypes.TOOL_CALL,
|
||||
tool_call: { name: 'ask_user_question', output: 'Prior answer' },
|
||||
},
|
||||
{ type: ContentTypes.THINK, [ContentTypes.THINK]: 'Prior user-edited reasoning' },
|
||||
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: 'Original model response' },
|
||||
],
|
||||
},
|
||||
];
|
||||
TestClient = initializeFakeClient(apiKey, options, history);
|
||||
TestClient.clientName = 'agents';
|
||||
TestClient.sendCompletion.mockResolvedValue({
|
||||
completion: [
|
||||
{ type: ContentTypes.TEXT, [ContentTypes.TEXT]: ' model continuation' },
|
||||
{ type: ContentTypes.STEER, [ContentTypes.STEER]: 'User steer' },
|
||||
],
|
||||
metadata: undefined,
|
||||
});
|
||||
|
||||
const response = await TestClient.sendMessage('ignored during edit', {
|
||||
conversationId: 'conversation-1',
|
||||
parentMessageId: 'assistant-message',
|
||||
responseMessageId: 'assistant-message',
|
||||
isEdited: true,
|
||||
isContinued: true,
|
||||
editedContent: {
|
||||
index: 2,
|
||||
text: 'User replacement',
|
||||
type: ContentTypes.TEXT,
|
||||
},
|
||||
});
|
||||
|
||||
const modelBoundEditedMessage = TestClient.buildMessages.mock.calls[0][0].at(-1);
|
||||
expect(modelBoundEditedMessage.userSubmittedPaths).toEqual([
|
||||
'/content/1/think',
|
||||
'/content/2/text',
|
||||
]);
|
||||
|
||||
expect(response.content).toEqual([
|
||||
{
|
||||
type: ContentTypes.TOOL_CALL,
|
||||
tool_call: { name: 'ask_user_question', output: 'Prior answer' },
|
||||
},
|
||||
{ type: ContentTypes.THINK, [ContentTypes.THINK]: 'Prior user-edited reasoning' },
|
||||
{
|
||||
type: ContentTypes.TEXT,
|
||||
[ContentTypes.TEXT]: 'User replacement model continuation',
|
||||
},
|
||||
{ type: ContentTypes.STEER, [ContentTypes.STEER]: 'User steer' },
|
||||
]);
|
||||
expect(response.userSubmittedPaths).toEqual([
|
||||
'/content/3',
|
||||
'/content/1/think',
|
||||
'/content/2/text',
|
||||
]);
|
||||
expect(response.userSubmittedMessageFieldPaths).toEqual([
|
||||
{ path: '/content/0/tool_call/output', field: 'answer' },
|
||||
]);
|
||||
expect(response).not.toHaveProperty('isUserSubmitted');
|
||||
});
|
||||
|
||||
test('should replace responseMessageId with new UUID when isRegenerate is true and messageId ends with underscore', async () => {
|
||||
const mockCrypto = require('crypto');
|
||||
const newUUID = 'new-uuid-1234';
|
||||
|
|
@ -619,6 +702,577 @@ describe('BaseClient', () => {
|
|||
expect(TestClient.getSaveOptions).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('runs the restored-history guard before building model input', async () => {
|
||||
const policyError = Object.assign(new Error('Blocked restored history'), {
|
||||
code: 'content_filter_block',
|
||||
});
|
||||
TestClient.assertStoredModelBoundContent = jest.fn(() => {
|
||||
throw policyError;
|
||||
});
|
||||
|
||||
await expect(TestClient.sendMessage('Safe new message')).rejects.toBe(policyError);
|
||||
|
||||
expect(TestClient.assertStoredModelBoundContent).toHaveBeenCalledTimes(1);
|
||||
expect(TestClient.buildMessages).not.toHaveBeenCalled();
|
||||
expect(TestClient.sendCompletion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('cancels a deferred user-message write when the model boundary rejects content', async () => {
|
||||
saveMessage.mockClear();
|
||||
saveConvo.mockClear();
|
||||
const policyError = new ContentFilterError({ source: 'message', field: 'text' });
|
||||
const getReqData = jest.fn();
|
||||
const abortController = new AbortController();
|
||||
TestClient.shouldDeferUserMessagePersistence = jest.fn(() => true);
|
||||
TestClient.sendCompletion.mockRejectedValue(policyError);
|
||||
|
||||
await expect(
|
||||
TestClient.sendMessage('Safe new message', { abortController, getReqData }),
|
||||
).rejects.toBe(policyError);
|
||||
|
||||
/** Policy cancellation removes the Stop listener and remains final. */
|
||||
abortController.abort();
|
||||
|
||||
expect(saveMessage).not.toHaveBeenCalled();
|
||||
expect(saveConvo).not.toHaveBeenCalled();
|
||||
const persistenceCall = getReqData.mock.calls.find(([data]) => data.userMessagePromise);
|
||||
await expect(persistenceCall[0].userMessagePromise).resolves.toEqual({});
|
||||
});
|
||||
|
||||
test('starts a deferred user-message write after a safe no-model completion', async () => {
|
||||
saveMessage.mockClear();
|
||||
saveConvo.mockClear();
|
||||
TestClient.shouldDeferUserMessagePersistence = jest.fn(() => true);
|
||||
TestClient.sendCompletion.mockImplementation(async () => {
|
||||
expect(saveMessage).not.toHaveBeenCalled();
|
||||
return { completion: 'Safe response', metadata: undefined };
|
||||
});
|
||||
|
||||
await TestClient.sendMessage('Safe new message');
|
||||
|
||||
expect(saveMessage.mock.calls.some(([, message]) => message.isCreatedByUser === true)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves eager user-message persistence for non-policy provider failures', async () => {
|
||||
saveMessage.mockClear();
|
||||
saveConvo.mockClear();
|
||||
const providerError = new Error('Provider unavailable');
|
||||
TestClient.shouldDeferUserMessagePersistence = jest.fn(() => true);
|
||||
TestClient.sendCompletion.mockRejectedValue(providerError);
|
||||
|
||||
await expect(TestClient.sendMessage('Safe new message')).rejects.toBe(providerError);
|
||||
|
||||
expect(saveMessage.mock.calls.some(([, message]) => message.isCreatedByUser === true)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('starts a deferred user-message write when Stop aborts an in-flight completion', async () => {
|
||||
saveMessage.mockClear();
|
||||
saveConvo.mockClear();
|
||||
const completionStarted = deferred();
|
||||
const completionResult = deferred();
|
||||
const abortController = new AbortController();
|
||||
TestClient.shouldDeferUserMessagePersistence = jest.fn(() => true);
|
||||
TestClient.sendCompletion.mockImplementation(() => {
|
||||
completionStarted.resolve();
|
||||
return completionResult.promise;
|
||||
});
|
||||
|
||||
const sendPromise = TestClient.sendMessage('Safe new message', { abortController });
|
||||
await completionStarted.promise;
|
||||
expect(saveMessage).not.toHaveBeenCalled();
|
||||
|
||||
abortController.abort();
|
||||
expect(saveMessage.mock.calls.some(([, message]) => message.isCreatedByUser === true)).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
completionResult.resolve({ completion: 'Partial response', metadata: undefined });
|
||||
await sendPromise;
|
||||
});
|
||||
|
||||
test('keeps an abort-started write when a late policy error loses the settlement race', async () => {
|
||||
saveMessage.mockClear();
|
||||
saveConvo.mockClear();
|
||||
const completionStarted = deferred();
|
||||
const completionResult = deferred();
|
||||
const policyError = new ContentFilterError({ source: 'message', field: 'text' });
|
||||
const abortController = new AbortController();
|
||||
TestClient.shouldDeferUserMessagePersistence = jest.fn(() => true);
|
||||
TestClient.sendCompletion.mockImplementation(() => {
|
||||
completionStarted.resolve();
|
||||
return completionResult.promise;
|
||||
});
|
||||
|
||||
const sendPromise = TestClient.sendMessage('Safe new message', { abortController });
|
||||
await completionStarted.promise;
|
||||
abortController.abort();
|
||||
completionResult.reject(policyError);
|
||||
|
||||
await expect(sendPromise).rejects.toBe(policyError);
|
||||
expect(saveMessage.mock.calls.some(([, message]) => message.isCreatedByUser === true)).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('blocks persisted user text selected by the built model payload', async () => {
|
||||
const secret = 'PRIVATE-HISTORICAL-VALUE';
|
||||
const history = [
|
||||
{
|
||||
role: 'user',
|
||||
isCreatedByUser: true,
|
||||
text: `Previously stored ${secret}`,
|
||||
messageId: 'persisted-user',
|
||||
parentMessageId: Constants.NO_PARENT,
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
isCreatedByUser: false,
|
||||
text: 'Safe model response',
|
||||
messageId: 'persisted-assistant',
|
||||
parentMessageId: 'persisted-user',
|
||||
},
|
||||
];
|
||||
TestClient = initializeFakeClient(
|
||||
apiKey,
|
||||
{
|
||||
...options,
|
||||
req: {
|
||||
config: {
|
||||
filters: {
|
||||
messages: {
|
||||
pii: {
|
||||
fields: ['text'],
|
||||
starterPatterns: [],
|
||||
customPatterns: [
|
||||
{
|
||||
id: 'historical-private',
|
||||
label: 'historical private value',
|
||||
regex: 'PRIVATE-HISTORICAL-[A-Z]+',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
history,
|
||||
);
|
||||
|
||||
let error;
|
||||
try {
|
||||
await TestClient.sendMessage('Safe new message', {
|
||||
conversationId: 'persisted-conversation',
|
||||
parentMessageId: 'persisted-assistant',
|
||||
});
|
||||
} catch (caughtError) {
|
||||
error = caughtError;
|
||||
}
|
||||
|
||||
expect(error).toMatchObject({
|
||||
code: 'content_filter_block',
|
||||
body: {
|
||||
error: 'content_filter_block',
|
||||
source: 'message',
|
||||
field: 'text',
|
||||
},
|
||||
});
|
||||
expect(JSON.stringify({ message: error.message, body: error.body })).not.toContain(secret);
|
||||
expect(TestClient.buildMessages).toHaveBeenCalledTimes(1);
|
||||
expect(TestClient.sendCompletion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('allows persisted user text that the built model payload prunes out', async () => {
|
||||
const secret = 'PRIVATE-PRUNED-HISTORICAL-VALUE';
|
||||
TestClient = initializeFakeClient(
|
||||
apiKey,
|
||||
{
|
||||
...options,
|
||||
req: {
|
||||
config: {
|
||||
filters: {
|
||||
messages: {
|
||||
pii: {
|
||||
fields: ['text'],
|
||||
starterPatterns: [],
|
||||
customPatterns: [
|
||||
{
|
||||
id: 'pruned-private',
|
||||
label: 'pruned private value',
|
||||
regex: 'PRIVATE-PRUNED-HISTORICAL-[A-Z]+',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
[
|
||||
{
|
||||
role: 'user',
|
||||
isCreatedByUser: true,
|
||||
text: `Old ${secret}`,
|
||||
messageId: 'pruned-user',
|
||||
parentMessageId: Constants.NO_PARENT,
|
||||
},
|
||||
{
|
||||
role: 'assistant',
|
||||
isCreatedByUser: false,
|
||||
text: 'Safe response',
|
||||
messageId: 'safe-assistant',
|
||||
parentMessageId: 'pruned-user',
|
||||
},
|
||||
],
|
||||
);
|
||||
TestClient.buildMessages.mockResolvedValue({
|
||||
prompt: [{ role: 'user', content: 'Safe new message' }],
|
||||
tokenCountMap: null,
|
||||
});
|
||||
|
||||
await expect(
|
||||
TestClient.sendMessage('Safe new message', {
|
||||
conversationId: 'pruned-conversation',
|
||||
parentMessageId: 'safe-assistant',
|
||||
}),
|
||||
).resolves.toEqual(expect.objectContaining({ isCreatedByUser: false }));
|
||||
|
||||
expect(TestClient.buildMessages).toHaveBeenCalledTimes(1);
|
||||
expect(TestClient.sendCompletion).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('blocks historical tool arguments without classifying assistant prose as user input', async () => {
|
||||
const filters = {
|
||||
messages: {
|
||||
pii: {
|
||||
fields: ['text'],
|
||||
starterPatterns: [],
|
||||
customPatterns: [
|
||||
{
|
||||
id: 'assistant-prose',
|
||||
label: 'assistant prose value',
|
||||
regex: 'PRIVATE-PROSE',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
toolArguments: {
|
||||
pii: {
|
||||
fields: ['arguments'],
|
||||
starterPatterns: [],
|
||||
customPatterns: [
|
||||
{
|
||||
id: 'historical-tool',
|
||||
label: 'historical tool value',
|
||||
regex: 'PRIVATE-TOOL',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const safeUserMessage = {
|
||||
role: 'user',
|
||||
isCreatedByUser: true,
|
||||
text: 'Safe historical question',
|
||||
messageId: 'safe-user',
|
||||
parentMessageId: Constants.NO_PARENT,
|
||||
};
|
||||
const assistantMessage = {
|
||||
role: 'assistant',
|
||||
isCreatedByUser: false,
|
||||
text: 'Model generated PRIVATE-PROSE',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_call',
|
||||
tool_call: {
|
||||
name: 'lookup',
|
||||
args: { query: 'PRIVATE-TOOL' },
|
||||
},
|
||||
},
|
||||
],
|
||||
messageId: 'assistant-with-tool',
|
||||
parentMessageId: 'safe-user',
|
||||
};
|
||||
const clientOptions = {
|
||||
...options,
|
||||
req: { config: { filters } },
|
||||
};
|
||||
TestClient = initializeFakeClient(apiKey, clientOptions, [safeUserMessage, assistantMessage]);
|
||||
|
||||
await expect(
|
||||
TestClient.sendMessage('Safe new message', {
|
||||
conversationId: 'tool-conversation',
|
||||
parentMessageId: 'assistant-with-tool',
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: 'content_filter_block',
|
||||
body: {
|
||||
source: 'tool_argument',
|
||||
field: 'arguments',
|
||||
},
|
||||
});
|
||||
expect(TestClient.buildMessages).toHaveBeenCalledTimes(1);
|
||||
expect(TestClient.sendCompletion).not.toHaveBeenCalled();
|
||||
|
||||
const proseOnlyClient = initializeFakeClient(apiKey, clientOptions, [
|
||||
safeUserMessage,
|
||||
{
|
||||
...assistantMessage,
|
||||
content: undefined,
|
||||
},
|
||||
]);
|
||||
await expect(
|
||||
proseOnlyClient.sendMessage('Safe new message', {
|
||||
conversationId: 'prose-conversation',
|
||||
parentMessageId: 'assistant-with-tool',
|
||||
}),
|
||||
).resolves.toEqual(expect.objectContaining({ isCreatedByUser: false }));
|
||||
expect(proseOnlyClient.buildMessages).toHaveBeenCalledTimes(1);
|
||||
expect(proseOnlyClient.sendCompletion).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('resolves and inspects owner-scoped historical files before building messages', async () => {
|
||||
const historicalMessage = {
|
||||
role: 'user',
|
||||
isCreatedByUser: true,
|
||||
text: 'Use my file',
|
||||
files: [{ file_id: 'owned-file' }],
|
||||
messageId: 'historical-file-message',
|
||||
parentMessageId: Constants.NO_PARENT,
|
||||
};
|
||||
getFiles.mockReset();
|
||||
getFiles.mockResolvedValueOnce([
|
||||
{
|
||||
file_id: 'owned-file',
|
||||
filename: 'owned.txt',
|
||||
filepath: '/uploads/owned.txt',
|
||||
text: 'safe canonical file content',
|
||||
user: 'user-1',
|
||||
},
|
||||
]);
|
||||
TestClient = initializeFakeClient(
|
||||
apiKey,
|
||||
{
|
||||
...options,
|
||||
req: {
|
||||
user: { id: 'user-1', tenantId: 'tenant-a' },
|
||||
config: {
|
||||
filters: {
|
||||
files: {
|
||||
pii: {
|
||||
fields: ['extracted_text'],
|
||||
starterPatterns: [],
|
||||
uninspectable: 'block',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
[historicalMessage],
|
||||
);
|
||||
|
||||
await expect(
|
||||
TestClient.sendMessage('Safe new message', {
|
||||
conversationId: 'historical-file-conversation',
|
||||
parentMessageId: 'historical-file-message',
|
||||
}),
|
||||
).resolves.toBeDefined();
|
||||
|
||||
expect(getFiles).toHaveBeenCalledWith(
|
||||
{
|
||||
file_id: { $in: ['owned-file'] },
|
||||
user: 'user-1',
|
||||
tenantId: 'tenant-a',
|
||||
},
|
||||
{},
|
||||
{},
|
||||
);
|
||||
expect(TestClient.buildMessages).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('does not block a missing historical file omitted from the final payload', async () => {
|
||||
getFiles.mockReset();
|
||||
getFiles.mockResolvedValueOnce([]);
|
||||
TestClient = initializeFakeClient(
|
||||
apiKey,
|
||||
{
|
||||
...options,
|
||||
req: {
|
||||
user: { id: 'user-1', tenantId: 'tenant-a' },
|
||||
config: {
|
||||
filters: {
|
||||
files: {
|
||||
pii: {
|
||||
fields: ['extracted_text'],
|
||||
starterPatterns: [],
|
||||
uninspectable: 'block',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
[
|
||||
{
|
||||
role: 'user',
|
||||
isCreatedByUser: true,
|
||||
text: 'Use a foreign file',
|
||||
files: [{ file_id: 'foreign-file' }],
|
||||
messageId: 'foreign-file-message',
|
||||
parentMessageId: Constants.NO_PARENT,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
await expect(
|
||||
TestClient.sendMessage('Safe new message', {
|
||||
conversationId: 'foreign-file-conversation',
|
||||
parentMessageId: 'foreign-file-message',
|
||||
}),
|
||||
).resolves.toEqual(expect.objectContaining({ isCreatedByUser: false }));
|
||||
expect(TestClient.buildMessages).toHaveBeenCalledTimes(1);
|
||||
expect(TestClient.sendCompletion).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('surfaces historical file lookup failures instead of silently dropping context', async () => {
|
||||
getFiles.mockReset();
|
||||
getFiles.mockRejectedValueOnce(new Error('historical file lookup unavailable'));
|
||||
TestClient = initializeFakeClient(
|
||||
apiKey,
|
||||
{
|
||||
...options,
|
||||
req: { user: { id: 'user-1', tenantId: 'tenant-a' }, config: {} },
|
||||
},
|
||||
[
|
||||
{
|
||||
role: 'user',
|
||||
isCreatedByUser: true,
|
||||
text: 'Use my historical file',
|
||||
files: [{ file_id: 'historical-file' }],
|
||||
messageId: 'historical-file-message',
|
||||
parentMessageId: Constants.NO_PARENT,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
await expect(
|
||||
TestClient.sendMessage('Safe new message', {
|
||||
conversationId: 'historical-file-error-conversation',
|
||||
parentMessageId: 'historical-file-message',
|
||||
}),
|
||||
).rejects.toThrow('historical file lookup unavailable');
|
||||
expect(TestClient.buildMessages).not.toHaveBeenCalled();
|
||||
expect(TestClient.sendCompletion).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('ignores historical file refs when the endpoint does not resend files', async () => {
|
||||
getFiles.mockReset();
|
||||
TestClient = initializeFakeClient(
|
||||
apiKey,
|
||||
{
|
||||
...options,
|
||||
resendFiles: false,
|
||||
req: {
|
||||
user: { id: 'user-1', tenantId: 'tenant-a' },
|
||||
config: {
|
||||
filters: {
|
||||
files: {
|
||||
pii: {
|
||||
fields: ['extracted_text'],
|
||||
starterPatterns: [],
|
||||
uninspectable: 'block',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
[
|
||||
{
|
||||
role: 'user',
|
||||
isCreatedByUser: true,
|
||||
text: 'A prior turn referenced a file.',
|
||||
files: [{ file_id: 'deleted-historical-file' }],
|
||||
content: [
|
||||
{
|
||||
type: 'input_file',
|
||||
files: [{ file_id: 'part-file' }],
|
||||
image_file: { file_id: 'image-file' },
|
||||
file_id: 'direct-file',
|
||||
file: { file_id: 'nested-file' },
|
||||
},
|
||||
],
|
||||
messageId: 'historical-file-message',
|
||||
parentMessageId: Constants.NO_PARENT,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
await expect(
|
||||
TestClient.sendMessage('Safe text-only continuation', {
|
||||
conversationId: 'no-file-replay-conversation',
|
||||
parentMessageId: 'historical-file-message',
|
||||
}),
|
||||
).resolves.toBeDefined();
|
||||
|
||||
expect(getFiles).not.toHaveBeenCalled();
|
||||
expect(TestClient.buildMessages).toHaveBeenCalled();
|
||||
const [modelMessages] = TestClient.buildMessages.mock.calls[0];
|
||||
expect(modelMessages[0]).not.toHaveProperty('files');
|
||||
expect(modelMessages[0].content[0]).toEqual({ type: 'input_file' });
|
||||
expect(TestClient.sendCompletion).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('keeps a materialized current attachment inspectable when historical replay is disabled', () => {
|
||||
const currentFile = {
|
||||
file_id: 'current-file',
|
||||
filename: 'safe.txt',
|
||||
text: 'Safe current attachment content',
|
||||
};
|
||||
TestClient = initializeFakeClient(apiKey, {
|
||||
...options,
|
||||
resendFiles: false,
|
||||
attachments: [currentFile],
|
||||
req: {
|
||||
config: {
|
||||
filters: {
|
||||
files: {
|
||||
pii: {
|
||||
fields: ['extracted_text'],
|
||||
starterPatterns: [],
|
||||
uninspectable: 'block',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
TestClient.message_file_map = { 'current-source': [currentFile] };
|
||||
TestClient.setModelBoundStoredMessages([
|
||||
{
|
||||
messageId: 'current-source',
|
||||
role: 'user',
|
||||
isCreatedByUser: true,
|
||||
text: 'Use the current file',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(() =>
|
||||
TestClient.assertBuiltModelBoundContent([
|
||||
{
|
||||
role: 'user',
|
||||
content: 'Use the current file',
|
||||
additional_kwargs: { sourceMessageId: 'current-source' },
|
||||
},
|
||||
]),
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
test('should return chat history', async () => {
|
||||
TestClient = initializeFakeClient(apiKey, options, messageHistory);
|
||||
const chatMessages = await TestClient.loadHistory(conversationId, '2');
|
||||
|
|
@ -1754,6 +2408,129 @@ describe('BaseClient', () => {
|
|||
expect(JSON.stringify(message)).not.toContain('forged owner text');
|
||||
});
|
||||
|
||||
test('hydrates files referenced by non-steer provider content parts', async () => {
|
||||
getFiles.mockResolvedValueOnce([ownerFile]);
|
||||
|
||||
const [message] = await TestClient.addPreviousAttachments([
|
||||
{
|
||||
messageId: 'msg-content-file',
|
||||
isCreatedByUser: true,
|
||||
content: [
|
||||
{
|
||||
type: 'input_file',
|
||||
files: [{ file_id: 'owner-file' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(getFiles).toHaveBeenCalledWith(
|
||||
{
|
||||
file_id: { $in: ['owner-file'] },
|
||||
user: 'user-1',
|
||||
tenantId: 'tenant-a',
|
||||
},
|
||||
{},
|
||||
{},
|
||||
);
|
||||
expect(TestClient.authorizedHistoricalFiles.get('owner-file')).toEqual(ownerFile);
|
||||
expect(message.content[0].files).toEqual([{ file_id: 'owner-file' }]);
|
||||
});
|
||||
|
||||
test('hydrates nested provider file references', async () => {
|
||||
getFiles.mockResolvedValueOnce([ownerFile]);
|
||||
|
||||
const [message] = await TestClient.addPreviousAttachments([
|
||||
{
|
||||
messageId: 'msg-nested-content-file',
|
||||
isCreatedByUser: true,
|
||||
content: [
|
||||
{
|
||||
type: 'input_file',
|
||||
file: { file_id: 'owner-file' },
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(getFiles).toHaveBeenCalledWith(
|
||||
{
|
||||
file_id: { $in: ['owner-file'] },
|
||||
user: 'user-1',
|
||||
tenantId: 'tenant-a',
|
||||
},
|
||||
{},
|
||||
{},
|
||||
);
|
||||
expect(TestClient.authorizedHistoricalFiles.get('owner-file')).toEqual(ownerFile);
|
||||
expect(message.content[0].file).toEqual({ file_id: 'owner-file' });
|
||||
});
|
||||
|
||||
test('preserves owner-scoped historical attachments when file patterns are inactive', async () => {
|
||||
TestClient.options.req.config = {
|
||||
filters: {
|
||||
files: {
|
||||
pii: {
|
||||
starterPatterns: [],
|
||||
customPatterns: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
getFiles.mockResolvedValueOnce([ownerFile]);
|
||||
|
||||
const [message] = await TestClient.addPreviousAttachments([
|
||||
{
|
||||
messageId: 'msg-inactive-file-policy',
|
||||
files: [{ file_id: 'owner-file', filename: 'forged-input.txt' }],
|
||||
attachments: [{ file_id: 'owner-file', filename: 'forged-output.txt' }],
|
||||
},
|
||||
]);
|
||||
|
||||
expect(getFiles).toHaveBeenCalledWith(
|
||||
{
|
||||
file_id: { $in: ['owner-file'] },
|
||||
user: 'user-1',
|
||||
tenantId: 'tenant-a',
|
||||
},
|
||||
{},
|
||||
{},
|
||||
);
|
||||
expect(message.files).toEqual([
|
||||
expect.objectContaining({ file_id: 'owner-file', filename: 'owner.txt' }),
|
||||
]);
|
||||
expect(message.attachments).toEqual([
|
||||
expect.objectContaining({ file_id: 'owner-file', filename: 'owner.txt' }),
|
||||
]);
|
||||
});
|
||||
|
||||
test('strips an unresolved historical file reference without pre-pruning enforcement', async () => {
|
||||
TestClient.options.req.config = {
|
||||
filters: {
|
||||
files: {
|
||||
pii: {
|
||||
fields: ['extracted_text'],
|
||||
starterPatterns: [],
|
||||
uninspectable: 'block',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
getFiles.mockResolvedValueOnce([]);
|
||||
|
||||
const [message] = await TestClient.addPreviousAttachments([
|
||||
{
|
||||
messageId: 'msg-unresolved',
|
||||
isCreatedByUser: true,
|
||||
files: [{ file_id: 'foreign-file' }],
|
||||
},
|
||||
]);
|
||||
expect(message).toEqual(expect.objectContaining({ messageId: 'msg-unresolved' }));
|
||||
expect(message).not.toHaveProperty('files');
|
||||
expect(TestClient.addFileContextToMessage).not.toHaveBeenCalled();
|
||||
expect(TestClient.processAttachments).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('strips historical file context when no authenticated owner scope is available', async () => {
|
||||
TestClient.options.req = {};
|
||||
|
||||
|
|
|
|||
|
|
@ -108,7 +108,7 @@ const initializeFakeClient = (apiKey, options, fakeMessages) => {
|
|||
const formattedMessages = orderedMessages.map((message) => {
|
||||
let { role: _role, sender, text } = message;
|
||||
const role = _role ?? sender;
|
||||
const content = text ?? '';
|
||||
const content = Array.isArray(message.content) ? message.content : (text ?? '');
|
||||
return {
|
||||
role: role?.toLowerCase() === 'user' ? 'user' : 'assistant',
|
||||
content,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue