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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions apps/sim/lib/internal/file/delegated-caller-write.integration.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
/**
* Mothership runs a file tool through `tools execute`, whose use case authorizes the chat's
* delegated principal for `sim:tool-execution`. The File write that follows must still reach the
* workspace-file use cases, which admit only `sim:workspace-files`, and must still refuse a
* subject or workspace the original delegation does not cover. Exercises the real handler,
* application authorization, metadata and local storage against Postgres.
*/
import { mkdtempSync } from 'node:fs'
import { rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import type { SubjectDelegatedPrincipal } from '@sim/auth/principal'
import { db } from '@sim/db'
import { auditLog, organization, user, workspace, workspaceFiles } from '@sim/db/schema'
import { deleteWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
import { createDelegatedPrincipal } from '@sim/testing/factories/principal.factory'
import { setUploadDirServer, uploadsSetupMock } from '@sim/testing/mocks/uploads-setup.mock'
import { generateId } from '@sim/utils/id'
import { and, eq, inArray } from 'drizzle-orm'
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'

vi.mock('@/lib/uploads/core/setup.server', () => uploadsSetupMock)

import { executeFileTool } from '@/lib/internal/file/execute-tool'
import {
createKnowledgeAclFixtureIds,
seedKnowledgeAclFixture,
} from '@/lib/knowledge/__integration__/seed-source-access-fixture'
import { uploadCopilotFile } from '@/lib/uploads/contexts/copilot/file-manager'

const fixtures: ReturnType<typeof createKnowledgeAclFixtureIds>[] = []

async function seed() {
const ids = createKnowledgeAclFixtureIds()
fixtures.push(ids)
await seedKnowledgeAclFixture(ids)
return ids
}

type Fixture = Awaited<ReturnType<typeof seed>>

/** The chat principal `tools execute` hands to the tool layer. */
function toolExecutionPrincipal(subjectUserId: string, workspaceId: string) {
return createDelegatedPrincipal({
subjectUserId,
workspaceId,
audience: 'sim:tool-execution',
resourceScope: { chatId: generateId() },
})
}

/** A generated image as `image_generate` stores it outside a workflow run. */
async function generatedImage(ownerId: string) {
return uploadCopilotFile({
buffer: Buffer.from(`image bytes ${generateId()}`),
fileName: `${generateId()}.webp`,
contentType: 'image/webp',
userId: ownerId,
})
}

async function writeCopy(
ids: Fixture,
principal: SubjectDelegatedPrincipal,
fileInput: Awaited<ReturnType<typeof generatedImage>>
) {
const fileName = `saved-${generateId()}.webp`
const response = await executeFileTool({
toolId: 'file_write',
input: { operation: 'write', workspaceId: ids.workspaceId, fileName, fileInput },
headers: new Headers(),
requestId: generateId(),
context: {
workflowId: '',
workspaceId: ids.workspaceId,
userId: principal.subjectUserId,
callerPrincipal: principal,
},
})
const [saved] = await db
.select({ id: workspaceFiles.id, sizeBytes: workspaceFiles.sizeBytes })
.from(workspaceFiles)
.where(
and(
eq(workspaceFiles.workspaceId, ids.workspaceId),
eq(workspaceFiles.originalName, fileName),
eq(workspaceFiles.context, 'workspace')
)
)
return { status: response.status, saved }
}

const uploadRoot = mkdtempSync(path.join(tmpdir(), 'sim-delegated-caller-write-'))

beforeAll(() => {
setUploadDirServer(uploadRoot)
})

afterAll(async () => {
try {
for (const ids of fixtures) {
await db.delete(auditLog).where(eq(auditLog.workspaceId, ids.workspaceId))
await db
.delete(workspaceFiles)
.where(inArray(workspaceFiles.userId, [ids.aliceId, ids.bobId]))
await deleteWorkspaceFixture(db, eq(workspace.id, ids.workspaceId))
await db.delete(organization).where(eq(organization.id, ids.organizationId))
await db.delete(user).where(inArray(user.id, [ids.aliceId, ids.bobId]))
}
await rm(uploadRoot, { recursive: true, force: true })
} finally {
await db.$client.end()
}
})

describe('a File write run directly from a Mothership chat', () => {
it('saves a generated image into Workspace Files', async () => {
const ids = await seed()
const image = await generatedImage(ids.aliceId)

const result = await writeCopy(ids, toolExecutionPrincipal(ids.aliceId, ids.workspaceId), image)

expect(result.status).toBe(200)
expect(result.saved?.sizeBytes).toBe(image.size)
})

it('still refuses a member whose role cannot write', async () => {
const ids = await seed()
const image = await generatedImage(ids.bobId)

const result = await writeCopy(ids, toolExecutionPrincipal(ids.bobId, ids.workspaceId), image)

expect(result.status).toBe(403)
expect(result.saved).toBeUndefined()
})

it('still refuses a delegation minted for another workspace', async () => {
const ids = await seed()
const image = await generatedImage(ids.aliceId)

const result = await writeCopy(ids, toolExecutionPrincipal(ids.aliceId, generateId()), image)

expect(result.status).toBe(403)
expect(result.saved).toBeUndefined()
})
})
41 changes: 39 additions & 2 deletions apps/sim/lib/internal/file/execute-tool.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { resolvePrincipalAttribution, resolvePrincipalSubject } from '@sim/auth/principal'
import {
type Principal,
resolvePrincipalAttribution,
resolvePrincipalSubject,
} from '@sim/auth/principal'
import { createLogger } from '@sim/logger'
import { getErrorMessage, toError } from '@sim/utils/errors'
import { z } from 'zod'
Expand All @@ -25,6 +29,7 @@ import {
import { parseInternalToolInput } from '@/lib/internal/tool-operations/parse-input'
import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types'
import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization'
import { rebindWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal'
import { searchWorkspaceFileContent } from '@/lib/workspace-files/application/search-workspace-file-content'
import {
FILE_SEARCH_DEFAULT_MAX_RESULTS,
Expand Down Expand Up @@ -79,6 +84,34 @@ const fileSearchInputSchema = z
})
.strict()

/**
* A delegated caller arrives with its calling use case's audience (`sim:tool-execution`),
* but workspace-file use cases admit only `sim:workspace-files`; they still check the
* subject's access. A delegation for another workspace is left for them to refuse.
*/
function bindWorkspaceFilePrincipal(
principal: Principal,
workspaceId: string,
requestId: string
): Principal {
if (
principal.kind !== 'delegated' ||
principal.audience === WORKSPACE_FILES_DELEGATION_AUDIENCE ||
principal.workspaceId !== workspaceId
) {
return principal
}
const scope = principal.resourceScope
return rebindWorkspaceFileDelegatedPrincipal({
principal,
workspaceId,
delegationId: `file-tool:${requestId}`,
fileId: scope?.fileId,
chatId: scope?.chatId,
executionId: scope?.executionId,
})
}

export const executeFileTool: InternalToolOperationHandler = async (request) => {
request.signal?.throwIfAborted()
if (!FILE_MANAGE_TOOL_IDS.has(request.toolId)) {
Expand Down Expand Up @@ -117,7 +150,11 @@ export const executeFileTool: InternalToolOperationHandler = async (request) =>
try {
const principal =
request.context.callerPrincipal && !request.context.executorDelegationOrigin
? request.context.callerPrincipal
? bindWorkspaceFilePrincipal(
request.context.callerPrincipal,
workspaceId,
request.requestId
)
: await createExecutorPrincipalFromExecutionContext({
context: request.context,
audience: WORKSPACE_FILES_DELEGATION_AUDIENCE,
Expand Down
82 changes: 31 additions & 51 deletions apps/sim/lib/internal/image/operations.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { interruptibleSleep } from '@sim/utils/helpers'
import { isRecordLike } from '@sim/utils/object'
import { isRecordLike, omit } from '@sim/utils/object'
import { getUserFacingErrorMessage } from '@/lib/core/errors/database-query-error'
import { getMaxExecutionTimeout } from '@/lib/core/execution-limits'
import {
Expand All @@ -23,6 +23,7 @@ import { recordServiceMeteringFailure } from '@/lib/mothership/billing/service-o
import { type FalAICostMetadata, getFalAICostMetadata } from '@/lib/tools/falai-pricing'
import { uploadCopilotFile } from '@/lib/uploads/contexts/copilot'
import { uploadExecutionFile } from '@/lib/uploads/contexts/execution'
import { resolveStoredFileMetadata } from '@/lib/uploads/utils/stored-file-metadata'

const logger = createLogger('ImageOperations')
const MAX_IMAGE_BYTES = MAX_REMOTE_IMAGE_BYTES
Expand Down Expand Up @@ -595,8 +596,11 @@ async function storeGeneratedImage(
context: ImageOperationContext
): Promise<StoredImageResponse> {
context.signal?.throwIfAborted()
const timestamp = Date.now()
const safeFileName = imageResult.fileName || `image-${imageResult.provider}-${timestamp}.png`
const { fileName, mimeType: contentType } = resolveStoredFileMetadata(
imageResult.fileName || `image-${imageResult.provider}-${Date.now()}.png`,
imageResult.contentType,
imageResult.buffer
)
const executionContext =
context.workspaceId && context.workflowId && context.executionId
? {
Expand All @@ -606,56 +610,32 @@ async function storeGeneratedImage(
}
: null

if (executionContext) {
const imageFile = await uploadExecutionFile(
executionContext,
imageResult.buffer,
safeFileName,
imageResult.contentType,
context.userId
)
context.signal?.throwIfAborted()

return {
content: imageFile.url,
imageUrl: imageFile.url,
imageFile,
fileName: safeFileName,
contentType: imageResult.contentType,
provider: imageResult.provider,
model: imageResult.model,
metadata: {
provider: imageResult.provider,
model: imageResult.model,
description: imageResult.description,
revisedPrompt: imageResult.revisedPrompt,
seed: imageResult.seed,
jobId: imageResult.jobId,
contentType: imageResult.contentType,
},
__falaiCostDollars: imageResult.falaiCost?.costDollars,
__falaiBilling: imageResult.falaiCost,
}
}

const fileInfo = await uploadCopilotFile({
buffer: imageResult.buffer,
fileName: safeFileName,
contentType: imageResult.contentType,
userId: context.userId,
})
// A stored file output is kept as is; a bare URL would be downloaded and stored again.
const imageFile = executionContext
? await uploadExecutionFile(
executionContext,
imageResult.buffer,
fileName,
contentType,
context.userId
)
: omit(
await uploadCopilotFile({
buffer: imageResult.buffer,
fileName,
contentType,
userId: context.userId,
}),
['mimeType']
)
context.signal?.throwIfAborted()
const imageUrl = fileInfo.url
logger.info(`[${context.requestId}] Stored generated image fallback`, {
fileName: safeFileName,
size: imageResult.buffer.length,
})

return {
content: imageUrl,
imageUrl,
fileName: safeFileName,
contentType: imageResult.contentType,
content: imageFile.url,
imageUrl: imageFile.url,
imageFile,
fileName,
contentType,
provider: imageResult.provider,
model: imageResult.model,
metadata: {
Expand All @@ -665,7 +645,7 @@ async function storeGeneratedImage(
revisedPrompt: imageResult.revisedPrompt,
seed: imageResult.seed,
jobId: imageResult.jobId,
contentType: imageResult.contentType,
contentType,
},
__falaiCostDollars: imageResult.falaiCost?.costDollars,
__falaiBilling: imageResult.falaiCost,
Expand Down
5 changes: 4 additions & 1 deletion apps/sim/lib/internal/tool-operations/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ export interface InternalToolOperationContext {
executorDelegationOrigin?: ExecutorDelegationOrigin
/** Trusted source block for saved MCP operation restrictions. */
mcpBlockId?: string
/** Authenticated direct caller, supplied by an authorized use case, never tool input. */
/**
* Authenticated direct caller, supplied by an authorized use case, never tool input. A
* delegated principal carries that use case's audience; rebind it before another use case.
*/
callerPrincipal?: Principal
copilotToolExecution?: boolean
copilotInteractionMode?: 'interactive' | 'headless'
Expand Down
Loading