Skip to content

Commit 8783ba8

Browse files
committed
fix(files): let Mothership tool runs write workspace files
`tools execute` authorizes the chat's delegated principal for `sim:tool-execution` and hands it to the tool layer unchanged, but every workspace-file use case admits only `sim:workspace-files`. Every File tool run directly from a Mothership chat therefore failed with a 403 "Delegated workspace access is no longer valid", which the agent relayed to the user as lost workspace access. The File tool handler now rebinds a delegated caller to the workspace-files audience (keeping subject, workspace, expiry ceiling and resource scope), as function-execute and payload materialization already do; the file use cases still check the subject's current workspace role. `image_generate` outside a workflow run returned only a URL for its copilot-storage upload, so the file-output processor downloaded and re-uploaded it, storing every image twice under two keys and returning `imageUrl` and `image` that disagreed. It now returns the stored file.
1 parent 3d5b37f commit 8783ba8

3 files changed

Lines changed: 194 additions & 2 deletions

File tree

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,153 @@
1+
/**
2+
* Mothership runs a file tool through `tools execute`, whose use case authorizes the chat's
3+
* delegated principal for `sim:tool-execution`. The File write that follows must still reach the
4+
* workspace-file use cases, which admit only `sim:workspace-files`, and must still refuse a
5+
* subject or workspace the original delegation does not cover. Exercises the real handler,
6+
* application authorization, metadata and local storage against Postgres.
7+
*/
8+
import { mkdtempSync } from 'node:fs'
9+
import { rm } from 'node:fs/promises'
10+
import { tmpdir } from 'node:os'
11+
import path from 'node:path'
12+
import type { SubjectDelegatedPrincipal } from '@sim/auth/principal'
13+
import { db } from '@sim/db'
14+
import { auditLog, organization, user, workspace, workspaceFiles } from '@sim/db/schema'
15+
import { deleteWorkspaceFixture } from '@sim/db/testing/workspace-fixtures'
16+
import { generateId } from '@sim/utils/id'
17+
import { and, eq, inArray } from 'drizzle-orm'
18+
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
19+
20+
const fixtureStorage = vi.hoisted(() => ({ root: '' }))
21+
vi.mock('@/lib/uploads/core/setup.server', () => ({
22+
get UPLOAD_DIR_SERVER() {
23+
return fixtureStorage.root
24+
},
25+
}))
26+
27+
import { executeFileTool } from '@/lib/internal/file/execute-tool'
28+
import {
29+
createKnowledgeAclFixtureIds,
30+
seedKnowledgeAclFixture,
31+
} from '@/lib/knowledge/__integration__/seed-source-access-fixture'
32+
import { uploadCopilotFile } from '@/lib/uploads/contexts/copilot/file-manager'
33+
34+
const fixtures: ReturnType<typeof createKnowledgeAclFixtureIds>[] = []
35+
36+
async function seed() {
37+
const ids = createKnowledgeAclFixtureIds()
38+
fixtures.push(ids)
39+
await seedKnowledgeAclFixture(ids)
40+
return ids
41+
}
42+
43+
type Fixture = Awaited<ReturnType<typeof seed>>
44+
45+
/** The chat principal `tools execute` hands to the tool layer. */
46+
function toolExecutionPrincipal(subjectUserId: string, workspaceId: string) {
47+
const issuedAt = new Date()
48+
return {
49+
kind: 'delegated',
50+
serviceId: 'copilot',
51+
subjectUserId,
52+
workspaceId,
53+
delegationId: generateId(),
54+
audience: 'sim:tool-execution',
55+
issuedAt,
56+
expiresAt: new Date(issuedAt.getTime() + 60_000),
57+
resourceScope: { chatId: generateId() },
58+
} satisfies SubjectDelegatedPrincipal
59+
}
60+
61+
/** A generated image as `image_generate` stores it outside a workflow run. */
62+
async function generatedImage(ownerId: string) {
63+
return uploadCopilotFile({
64+
buffer: Buffer.from(`image bytes ${generateId()}`),
65+
fileName: `${generateId()}.webp`,
66+
contentType: 'image/webp',
67+
userId: ownerId,
68+
})
69+
}
70+
71+
async function writeCopy(
72+
ids: Fixture,
73+
principal: SubjectDelegatedPrincipal,
74+
fileInput: Awaited<ReturnType<typeof generatedImage>>
75+
) {
76+
const fileName = `saved-${generateId()}.webp`
77+
const response = await executeFileTool({
78+
toolId: 'file_write',
79+
input: { operation: 'write', workspaceId: ids.workspaceId, fileName, fileInput },
80+
headers: new Headers(),
81+
requestId: generateId(),
82+
context: {
83+
workflowId: '',
84+
workspaceId: ids.workspaceId,
85+
userId: principal.subjectUserId,
86+
callerPrincipal: principal,
87+
},
88+
})
89+
const [saved] = await db
90+
.select({ id: workspaceFiles.id, sizeBytes: workspaceFiles.sizeBytes })
91+
.from(workspaceFiles)
92+
.where(
93+
and(
94+
eq(workspaceFiles.workspaceId, ids.workspaceId),
95+
eq(workspaceFiles.originalName, fileName),
96+
eq(workspaceFiles.context, 'workspace')
97+
)
98+
)
99+
return { status: response.status, saved }
100+
}
101+
102+
beforeAll(() => {
103+
fixtureStorage.root = mkdtempSync(path.join(tmpdir(), 'sim-delegated-caller-write-'))
104+
})
105+
106+
afterAll(async () => {
107+
try {
108+
for (const ids of fixtures) {
109+
await db.delete(auditLog).where(eq(auditLog.workspaceId, ids.workspaceId))
110+
await db
111+
.delete(workspaceFiles)
112+
.where(inArray(workspaceFiles.userId, [ids.aliceId, ids.bobId]))
113+
await deleteWorkspaceFixture(db, eq(workspace.id, ids.workspaceId))
114+
await db.delete(organization).where(eq(organization.id, ids.organizationId))
115+
await db.delete(user).where(inArray(user.id, [ids.aliceId, ids.bobId]))
116+
}
117+
await rm(fixtureStorage.root, { recursive: true, force: true })
118+
} finally {
119+
await db.$client.end()
120+
}
121+
})
122+
123+
describe('a File write run directly from a Mothership chat', () => {
124+
it('saves a generated image into Workspace Files', async () => {
125+
const ids = await seed()
126+
const image = await generatedImage(ids.aliceId)
127+
128+
const result = await writeCopy(ids, toolExecutionPrincipal(ids.aliceId, ids.workspaceId), image)
129+
130+
expect(result.status).toBe(200)
131+
expect(result.saved?.sizeBytes).toBe(image.size)
132+
})
133+
134+
it('still refuses a member whose role cannot write', async () => {
135+
const ids = await seed()
136+
const image = await generatedImage(ids.bobId)
137+
138+
const result = await writeCopy(ids, toolExecutionPrincipal(ids.bobId, ids.workspaceId), image)
139+
140+
expect(result.status).toBeGreaterThanOrEqual(400)
141+
expect(result.saved).toBeUndefined()
142+
})
143+
144+
it('still refuses a delegation minted for another workspace', async () => {
145+
const ids = await seed()
146+
const image = await generatedImage(ids.aliceId)
147+
148+
const result = await writeCopy(ids, toolExecutionPrincipal(ids.aliceId, generateId()), image)
149+
150+
expect(result.status).toBeGreaterThanOrEqual(400)
151+
expect(result.saved).toBeUndefined()
152+
})
153+
})

‎apps/sim/lib/internal/file/execute-tool.ts‎

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { resolvePrincipalAttribution, resolvePrincipalSubject } from '@sim/auth/principal'
1+
import {
2+
type Principal,
3+
resolvePrincipalAttribution,
4+
resolvePrincipalSubject,
5+
} from '@sim/auth/principal'
26
import { createLogger } from '@sim/logger'
37
import { getErrorMessage, toError } from '@sim/utils/errors'
48
import { z } from 'zod'
@@ -25,6 +29,7 @@ import {
2529
import { parseInternalToolInput } from '@/lib/internal/tool-operations/parse-input'
2630
import type { InternalToolOperationHandler } from '@/lib/internal/tool-operations/types'
2731
import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization'
32+
import { rebindWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal'
2833
import { searchWorkspaceFileContent } from '@/lib/workspace-files/application/search-workspace-file-content'
2934
import {
3035
FILE_SEARCH_DEFAULT_MAX_RESULTS,
@@ -79,6 +84,35 @@ const fileSearchInputSchema = z
7984
})
8085
.strict()
8186

87+
/**
88+
* A delegated caller reaches a file tool through a use case that authorized it for
89+
* its own audience (`sim:tool-execution` for a direct tool call), while every
90+
* workspace-file use case admits only `sim:workspace-files`. Rebinding keeps the
91+
* subject, workspace, expiry ceiling, and resource scope, so the file use cases
92+
* still check the subject's current workspace access.
93+
*/
94+
function bindWorkspaceFilePrincipal(
95+
principal: Principal,
96+
workspaceId: string,
97+
requestId: string
98+
): Principal {
99+
if (
100+
principal.kind !== 'delegated' ||
101+
principal.audience === WORKSPACE_FILES_DELEGATION_AUDIENCE
102+
) {
103+
return principal
104+
}
105+
const scope = principal.resourceScope
106+
return rebindWorkspaceFileDelegatedPrincipal({
107+
principal,
108+
workspaceId,
109+
delegationId: `file-tool:${requestId}`,
110+
...(scope?.fileId ? { fileId: scope.fileId } : {}),
111+
...(scope?.chatId ? { chatId: scope.chatId } : {}),
112+
...(scope?.executionId ? { executionId: scope.executionId } : {}),
113+
})
114+
}
115+
82116
export const executeFileTool: InternalToolOperationHandler = async (request) => {
83117
request.signal?.throwIfAborted()
84118
if (!FILE_MANAGE_TOOL_IDS.has(request.toolId)) {
@@ -117,7 +151,11 @@ export const executeFileTool: InternalToolOperationHandler = async (request) =>
117151
try {
118152
const principal =
119153
request.context.callerPrincipal && !request.context.executorDelegationOrigin
120-
? request.context.callerPrincipal
154+
? bindWorkspaceFilePrincipal(
155+
request.context.callerPrincipal,
156+
workspaceId,
157+
request.requestId
158+
)
121159
: await createExecutorPrincipalFromExecutionContext({
122160
context: request.context,
123161
audience: WORKSPACE_FILES_DELEGATION_AUDIENCE,

‎apps/sim/lib/internal/image/operations.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,7 @@ async function storeGeneratedImage(
654654
return {
655655
content: imageUrl,
656656
imageUrl,
657+
imageFile: fileInfo,
657658
fileName: safeFileName,
658659
contentType: imageResult.contentType,
659660
provider: imageResult.provider,

0 commit comments

Comments
 (0)