Skip to content

Commit cf2f0b3

Browse files
fix(content): preserve canonical provenance across reads (#9036)
1 parent 7c1dd09 commit cf2f0b3

6 files changed

Lines changed: 134 additions & 26 deletions

File tree

‎apps/sim/lib/knowledge/__integration__/content-delivery.integration.ts‎

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from '@sim/db/schema'
1717
import { deleteWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
1818
import { sha256Hex } from '@sim/security/hash'
19+
import { createExecutorPrincipal } from '@sim/testing/factories/principal.factory'
1920
import { generateId } from '@sim/utils/id'
2021
import { isRecordLike } from '@sim/utils/object'
2122
import { eq, inArray } from 'drizzle-orm'
@@ -33,6 +34,12 @@ vi.mock('@/lib/uploads/core/setup.server', () => ({
3334
import { observeContentDelivery } from '@/lib/core/application'
3435
import { encryptSecret } from '@/lib/core/security/encryption'
3536
import type { DurableSecretProvenance } from '@/lib/execution/durable-secret-provenance'
37+
import {
38+
PRIVATE_TOOL_METADATA_REQUEST_HEADER,
39+
RESOLVED_SECRET_PROVENANCE_FIELD,
40+
RESOLVED_SECRET_PROVENANCE_METADATA_V1,
41+
} from '@/lib/execution/private-tool-metadata'
42+
import { listDocumentsOperation } from '@/lib/internal/knowledge/operations'
3643
import {
3744
createKnowledgeAclFixtureIds,
3845
seedKnowledgeAclFixture,
@@ -51,6 +58,7 @@ import {
5158
readDetailedKnowledgeTagUsage,
5259
readKnowledgeTagUsage,
5360
} from '@/lib/knowledge/application/tags'
61+
import * as documentService from '@/lib/knowledge/documents/service'
5462
import {
5563
bindKnowledgeDocumentFieldSecretProvenance,
5664
classifyKnowledgeDocumentMetadata,
@@ -63,6 +71,7 @@ import { embeddingVectorValues } from '@/lib/knowledge/vector-columns'
6371
import { createCopilotChatPrincipal } from '@/lib/mothership/auth/application-delegation'
6472
import { createRegistryContentGate } from '@/lib/mothership/content-delivery/registry-gate'
6573
import { uploadFile } from '@/lib/uploads/core/storage-service'
74+
import { projectResolvedSecretModelContent } from '@/executor/utils/resolved-secret-content-projection'
6675
import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry'
6776

6877
const SECRET = 'fixture-knowledge-token-41875'
@@ -95,6 +104,7 @@ async function seedDocument(
95104
content?: string
96105
provenance?: DurableSecretProvenance
97106
original?: boolean
107+
contentHash?: string | null
98108
} = {}
99109
) {
100110
const content = options.content ?? 'page public content'
@@ -105,6 +115,7 @@ async function seedDocument(
105115
id,
106116
knowledgeBaseId: ids.knowledgeBaseId,
107117
filename: options.name ?? 'page.txt',
118+
contentHash: options.contentHash ?? null,
108119
fileUrl:
109120
options.original === false
110121
? ''
@@ -229,6 +240,81 @@ afterAll(async () => {
229240
})
230241

231242
describe('knowledge content delivery', () => {
243+
it.each([null, sha256Hex('stable connector source')])(
244+
'keeps workflow list provenance complete for an unchanged document with content hash %s',
245+
async (contentHash) => {
246+
const doc = await seedDocument({
247+
name: `${SECRET}.txt`,
248+
contentHash,
249+
provenance: bindKnowledgeDocumentFieldSecretProvenance(exact, 'filename', `${SECRET}.txt`),
250+
})
251+
const context = {
252+
principal: createExecutorPrincipal({
253+
subjectUserId: ids.aliceId,
254+
workspaceId: ids.workspaceId,
255+
audience: KNOWLEDGE_DELEGATION_AUDIENCE,
256+
delegationContext: { kind: 'workflow_execution' as const, workflowId: generateId() },
257+
}),
258+
headers: new Headers({
259+
[PRIVATE_TOOL_METADATA_REQUEST_HEADER]: RESOLVED_SECRET_PROVENANCE_METADATA_V1,
260+
}),
261+
}
262+
const result = await listDocumentsOperation(
263+
ids.knowledgeBaseId,
264+
{ limit: 50, offset: 0 },
265+
context
266+
)
267+
const provenance = result.bodyFields?.[RESOLVED_SECRET_PROVENANCE_FIELD]
268+
expect(provenance).toMatchObject({ complete: true })
269+
expect(result.body).toMatchObject({
270+
data: { documents: [{ id: doc.id, filename: `${SECRET}.txt` }] },
271+
})
272+
expect(JSON.stringify(result.body)).not.toContain('contentHash')
273+
const registry = new ResolvedSecretTraceRegistry([], {
274+
userId: ids.aliceId,
275+
workspaceId: ids.workspaceId,
276+
})
277+
expect(await registry.importProvenance(provenance, { trusted: true })).toBe(true)
278+
const projected = projectResolvedSecretModelContent(result.body, registry)
279+
expect(projected.safe).toBe(true)
280+
expect(JSON.stringify(projected)).toContain('{{FIXTURE_TOKEN}}.txt')
281+
expect(JSON.stringify(projected)).not.toContain(SECRET)
282+
283+
const read = documentService.getDocuments
284+
const replace = vi
285+
.spyOn(documentService, 'getDocuments')
286+
.mockImplementation(async (...args) => {
287+
const snapshot = await read(...args)
288+
await db.transaction(async (tx) => {
289+
const [replacement] = await tx
290+
.update(document)
291+
.set({ contentHash: sha256Hex('replacement connector source') })
292+
.where(eq(document.id, doc.id))
293+
.returning()
294+
await replaceKnowledgeDocumentSecretProvenanceInTx(
295+
tx,
296+
doc.id,
297+
createKnowledgeDocumentSourceValue(replacement),
298+
bindKnowledgeDocumentFieldSecretProvenance(exact, 'filename', `${SECRET}.txt`)
299+
)
300+
})
301+
return snapshot
302+
})
303+
try {
304+
const changed = await listDocumentsOperation(
305+
ids.knowledgeBaseId,
306+
{ limit: 50, offset: 0 },
307+
context
308+
)
309+
expect(changed.bodyFields?.[RESOLVED_SECRET_PROVENANCE_FIELD]).toMatchObject({
310+
complete: false,
311+
})
312+
} finally {
313+
replace.mockRestore()
314+
}
315+
}
316+
)
317+
232318
it('classifies a mixed metadata page without materializing multiple inline originals together', async () => {
233319
const content = 'inline source content '.repeat(8192)
234320
const legacy = await seedDocument({ content })

‎apps/sim/lib/knowledge/documents/service.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ const logger = createLogger('DocumentService')
217217
* and a machine-readable name would only invite a client to read resource
218218
* existence into it.
219219
*/
220-
export class KnowledgeBaseFileOwnershipError extends OrchestrationError {
220+
class KnowledgeBaseFileOwnershipError extends OrchestrationError {
221221
constructor(public readonly storageKey: string) {
222222
super('forbidden', 'Document file is not owned by this knowledge base')
223223
this.name = 'KnowledgeBaseFileOwnershipError'
@@ -2861,6 +2861,7 @@ export async function getDocuments(
28612861
knowledgeBaseId: string
28622862
filename: string
28632863
fileUrl: string
2864+
contentHash: string | null
28642865
fileSize: number
28652866
mimeType: string
28662867
chunkCount: number
@@ -2968,6 +2969,7 @@ export async function getDocuments(
29682969
knowledgeBaseId: document.knowledgeBaseId,
29692970
filename: document.filename,
29702971
fileUrl: document.fileUrl,
2972+
contentHash: document.contentHash,
29712973
fileSize: document.fileSize,
29722974
mimeType: document.mimeType,
29732975
chunkCount: document.chunkCount,
@@ -3084,6 +3086,7 @@ export async function getDocuments(
30843086
knowledgeBaseId: doc.knowledgeBaseId,
30853087
filename: doc.filename,
30863088
fileUrl: doc.fileUrl,
3089+
contentHash: doc.contentHash,
30873090
fileSize: doc.fileSize,
30883091
mimeType: doc.mimeType,
30893092
chunkCount: doc.chunkCount,

‎apps/sim/lib/mothership/__integration__/content-delivery.integration.ts‎

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,7 @@ import { storeCompiledDoc } from '@/lib/uploads/documents/compiled-store'
152152
import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization'
153153
import { downloadWorkspaceFileItems } from '@/lib/workspace-files/application/download-workspace-file-items'
154154
import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content'
155+
import { readWorkspaceFileSecretProvenance } from '@/lib/workspace-files/application/read-workspace-file-secret-provenance'
155156
import {
156157
getWorkspaceFileShares,
157158
updateWorkspaceFileShare,
@@ -1130,21 +1131,42 @@ describe('content reaching Chat is admitted per item', () => {
11301131
userId: fixture.ids.aliceId,
11311132
workspaceId: ws(),
11321133
})
1133-
const result = await observeContentDelivery(
1134-
createRegistryContentGate({
1135-
registry,
1136-
actorUserId: fixture.ids.aliceId,
1137-
workspaceId: ws(),
1138-
}),
1139-
() =>
1140-
readWorkspaceFileContent.execute({
1141-
principal,
1142-
input: { fileId: created.fileId, includeSecretProvenance: true },
1143-
})
1134+
const gate = createRegistryContentGate({
1135+
registry,
1136+
actorUserId: fixture.ids.aliceId,
1137+
workspaceId: ws(),
1138+
})
1139+
const result = await observeContentDelivery(gate, () =>
1140+
readWorkspaceFileContent.execute({
1141+
principal,
1142+
input: { fileId: created.fileId, includeSecretProvenance: true },
1143+
})
11441144
)
11451145
expect(result.content.toString()).toBe(content)
11461146
expect(result.file.vfsNamespace).toBe(context === 'test' ? 'tests' : 'changelog')
11471147
expect(result.secretProvenance).toEqual({ status: 'exact', entries: [] })
1148+
const evidence = await observeContentDelivery(gate, () =>
1149+
readWorkspaceFileSecretProvenance.execute({
1150+
principal,
1151+
input: { fileId: created.fileId },
1152+
})
1153+
)
1154+
expect.soft(evidence.provenance).toEqual({ status: 'exact', entries: [] })
1155+
const staleEvidence = await observeContentDelivery(gate, () =>
1156+
readWorkspaceFileSecretProvenance.execute({
1157+
principal,
1158+
input: { fileId: created.fileId, expectedContentUpdatedAt: new Date(0) },
1159+
})
1160+
)
1161+
expect(staleEvidence.provenance).toEqual({ status: 'unknown' })
1162+
if (context === 'changelog') {
1163+
const version = await cli(
1164+
'GET',
1165+
`/api/v2/files/${created.fileId}/versions/1/text?workspaceId=${ws()}`
1166+
)
1167+
expect.soft(version.status).toBe(200)
1168+
expect.soft(modelView(version)).toContain(content)
1169+
}
11481170
featureFlagsMockFns.mockIsFeatureEnabled.mockReset()
11491171
}
11501172
)

‎apps/sim/lib/mothership/request/tools/client.ts‎

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -347,10 +347,7 @@ export async function waitForWorkflowToolCompletion({
347347

348348
try {
349349
completion = await waitForToolCompletion(toolCallId, timeoutMs, abortSignal)
350-
if (!completion) {
351-
toolRegistry?.markIncomplete('client-tool-completion-missing')
352-
return null
353-
}
350+
if (!completion) return null
354351

355352
const executionId = getWorkflowToolCompletionExecutionId(completion.data)
356353
const launchError = getWorkflowToolLaunchError(completion.data)

‎apps/sim/lib/workspace-files/application/file-versions.ts‎

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,10 @@ import {
3030
import { hasObjectNotFoundCause } from '@/lib/uploads/core/errors'
3131
import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types'
3232
import { formatFileSize } from '@/lib/uploads/utils/file-utils'
33-
import { requireAdmittedWorkspaceFile } from '@/lib/workspace-files/application/admit-file-content'
33+
import {
34+
requireAdmittedWorkspaceFile,
35+
workspaceFileProvenanceIdentity,
36+
} from '@/lib/workspace-files/application/admit-file-content'
3437
import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case'
3538
import {
3639
type DownloadWorkspaceFileStreamResult,
@@ -128,12 +131,10 @@ async function readVersionSecretProvenance(
128131
): Promise<WorkspaceFileSecretProvenance | undefined> {
129132
if (!mustClassifyContent(principal)) return undefined
130133
if (version.isCurrent) {
131-
return getBoundWorkspaceFileSecretProvenance(file.workspaceId, {
132-
fileId: file.id,
133-
key: file.key,
134-
context: file.storageContext ?? 'workspace',
135-
contentUpdatedAt: file.contentUpdatedAt ?? undefined,
136-
})
134+
return getBoundWorkspaceFileSecretProvenance(
135+
file.workspaceId,
136+
workspaceFileProvenanceIdentity(file)
137+
)
137138
}
138139
const snapshot = await getWorkspaceFileVersionProvenance(file.id, version.version, version.key)
139140
return snapshot ? workspaceFileSecretProvenanceFromSnapshot(snapshot) : { status: 'unknown' }

‎apps/sim/lib/workspace-files/application/read-workspace-file-secret-provenance.ts‎

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
getBoundWorkspaceFileSecretProvenance,
77
type WorkspaceFileSecretProvenance,
88
} from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance'
9+
import { workspaceFileProvenanceIdentity } from '@/lib/workspace-files/application/admit-file-content'
910
import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case'
1011
import { fileOperations } from '@/lib/workspace-files/application/operations'
1112
import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context'
@@ -39,9 +40,7 @@ export const readWorkspaceFileSecretProvenance = defineAuthorizedWorkspaceFileUs
3940
await admitContent(principal, [])
4041
return {
4142
provenance: await getBoundWorkspaceFileSecretProvenance(context.workspaceId, {
42-
fileId: file.id,
43-
key: file.key,
44-
context: 'workspace',
43+
...workspaceFileProvenanceIdentity(file),
4544
contentUpdatedAt: input.expectedContentUpdatedAt,
4645
}),
4746
ownerUserId: file.uploadedBy,

0 commit comments

Comments
 (0)