Skip to content

Commit 58c87b2

Browse files
feat(copilot): add share_file tool handler and surface file share state to the agent (#5656)
Implement the sim-side share_file server tool (resolves VFS path to file, keeps the existing org-policy + permission + audit checks, delegates to upsertFileShare). Register it in the tool router and generated catalog. Stamp an ambient 'shared'/'shareAuthType' flag onto file metadata via one batched share lookup, mirroring how workflows expose 'isDeployed'. Validate the EFFECTIVE auth type when re-enabling a share: upsertFileShare preserves an existing share's authType when none is passed, so validate the stored mode (not 'public') or a re-share could reactivate a now-disallowed password/email/sso share. Same fix applied to the share PUT route.
1 parent de4acb3 commit 58c87b2

8 files changed

Lines changed: 350 additions & 1 deletion

File tree

apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,8 +104,14 @@ export const PUT = withRouteHandler(
104104
// master on/off and the per-auth-type allow-list); disabling is always
105105
// allowed so users can still un-share after the policy is turned on.
106106
if (isActive) {
107+
// Validate the auth type that will ACTUALLY be persisted. upsertFileShare
108+
// falls back to the existing share's authType when none is passed, so a bare
109+
// re-enable must be checked against that stored mode — not 'public' — or a
110+
// now-disallowed password/email/sso share could be silently reactivated.
111+
const existingShare = await getShareForResource('file', fileId)
112+
const effectiveAuthType = authType ?? existingShare?.authType ?? 'public'
107113
try {
108-
await validatePublicFileSharing(session.user.id, workspaceId, authType ?? 'public')
114+
await validatePublicFileSharing(session.user.id, workspaceId, effectiveAuthType)
109115
} catch (error) {
110116
if (error instanceof PublicFileSharingNotAllowedError) {
111117
logger.warn(`[${requestId}] Public file sharing disabled for workspace ${workspaceId}`)

apps/sim/lib/copilot/generated/tool-catalog-v1.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ export interface ToolCatalogEntry {
9494
| 'set_block_enabled'
9595
| 'set_environment_variables'
9696
| 'set_global_workflow_variables'
97+
| 'share_file'
9798
| 'table'
9899
| 'update_deployment_version'
99100
| 'update_scheduled_task_history'
@@ -191,6 +192,7 @@ export interface ToolCatalogEntry {
191192
| 'set_block_enabled'
192193
| 'set_environment_variables'
193194
| 'set_global_workflow_variables'
195+
| 'share_file'
194196
| 'table'
195197
| 'update_deployment_version'
196198
| 'update_scheduled_task_history'
@@ -3925,6 +3927,60 @@ export const SetGlobalWorkflowVariables: ToolCatalogEntry = {
39253927
requiredPermission: 'write',
39263928
}
39273929

3930+
export const ShareFile: ToolCatalogEntry = {
3931+
id: 'share_file',
3932+
name: 'share_file',
3933+
route: 'sim',
3934+
mode: 'async',
3935+
parameters: {
3936+
type: 'object',
3937+
properties: {
3938+
action: {
3939+
type: 'string',
3940+
description: 'Whether to create/update the share link or deactivate it.',
3941+
enum: ['share', 'unshare'],
3942+
default: 'share',
3943+
},
3944+
allowedEmails: {
3945+
type: 'array',
3946+
description:
3947+
'Allowed emails or "@domain" patterns for authType "email" or "sso". Ignored for other auth types.',
3948+
items: { type: 'string' },
3949+
},
3950+
authType: {
3951+
type: 'string',
3952+
description: 'How viewers authenticate to open the link. Ignored for unshare.',
3953+
enum: ['public', 'password', 'email', 'sso'],
3954+
default: 'public',
3955+
},
3956+
password: {
3957+
type: 'string',
3958+
description:
3959+
'Password for authType "password". Leave empty to keep the file\'s existing password when re-sharing an already password-protected file. Ignored for other auth types.',
3960+
},
3961+
path: {
3962+
type: 'string',
3963+
description: 'Canonical workspace file VFS path to share, e.g. "files/Reports/Q4.md".',
3964+
},
3965+
},
3966+
required: ['path'],
3967+
},
3968+
resultSchema: {
3969+
type: 'object',
3970+
properties: {
3971+
data: {
3972+
type: 'object',
3973+
description:
3974+
'Share state. Contains url (the {baseUrl}/f/{token} link), token, authType, hasPassword, and isActive.',
3975+
},
3976+
message: { type: 'string', description: 'Human-readable outcome.' },
3977+
success: { type: 'boolean', description: 'Whether the share action succeeded.' },
3978+
},
3979+
required: ['success', 'message'],
3980+
},
3981+
requiredPermission: 'write',
3982+
}
3983+
39283984
export const Table: ToolCatalogEntry = {
39293985
id: 'table',
39303986
name: 'table',
@@ -4879,6 +4935,7 @@ export const TOOL_CATALOG: Record<string, ToolCatalogEntry> = {
48794935
[SetBlockEnabled.id]: SetBlockEnabled,
48804936
[SetEnvironmentVariables.id]: SetEnvironmentVariables,
48814937
[SetGlobalWorkflowVariables.id]: SetGlobalWorkflowVariables,
4938+
[ShareFile.id]: ShareFile,
48824939
[Table.id]: Table,
48834940
[UpdateDeploymentVersion.id]: UpdateDeploymentVersion,
48844941
[UpdateScheduledTaskHistory.id]: UpdateScheduledTaskHistory,

apps/sim/lib/copilot/generated/tool-schemas-v1.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3715,6 +3715,62 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
37153715
},
37163716
resultSchema: undefined,
37173717
},
3718+
share_file: {
3719+
parameters: {
3720+
type: 'object',
3721+
properties: {
3722+
action: {
3723+
type: 'string',
3724+
description: 'Whether to create/update the share link or deactivate it.',
3725+
enum: ['share', 'unshare'],
3726+
default: 'share',
3727+
},
3728+
allowedEmails: {
3729+
type: 'array',
3730+
description:
3731+
'Allowed emails or "@domain" patterns for authType "email" or "sso". Ignored for other auth types.',
3732+
items: {
3733+
type: 'string',
3734+
},
3735+
},
3736+
authType: {
3737+
type: 'string',
3738+
description: 'How viewers authenticate to open the link. Ignored for unshare.',
3739+
enum: ['public', 'password', 'email', 'sso'],
3740+
default: 'public',
3741+
},
3742+
password: {
3743+
type: 'string',
3744+
description:
3745+
'Password for authType "password". Leave empty to keep the file\'s existing password when re-sharing an already password-protected file. Ignored for other auth types.',
3746+
},
3747+
path: {
3748+
type: 'string',
3749+
description: 'Canonical workspace file VFS path to share, e.g. "files/Reports/Q4.md".',
3750+
},
3751+
},
3752+
required: ['path'],
3753+
},
3754+
resultSchema: {
3755+
type: 'object',
3756+
properties: {
3757+
data: {
3758+
type: 'object',
3759+
description:
3760+
'Share state. Contains url (the {baseUrl}/f/{token} link), token, authType, hasPassword, and isActive.',
3761+
},
3762+
message: {
3763+
type: 'string',
3764+
description: 'Human-readable outcome.',
3765+
},
3766+
success: {
3767+
type: 'boolean',
3768+
description: 'Whether the share action succeeded.',
3769+
},
3770+
},
3771+
required: ['success', 'message'],
3772+
},
3773+
},
37183774
table: {
37193775
parameters: {
37203776
properties: {
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit'
2+
import { createLogger } from '@sim/logger'
3+
import type { ShareAuthType } from '@/lib/api/contracts/public-shares'
4+
import { ShareFile } from '@/lib/copilot/generated/tool-catalog-v1'
5+
import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access'
6+
import {
7+
assertServerToolNotAborted,
8+
type BaseServerTool,
9+
type ServerToolContext,
10+
} from '@/lib/copilot/tools/server/base-tool'
11+
import {
12+
getShareForResource,
13+
ShareValidationError,
14+
upsertFileShare,
15+
} from '@/lib/public-shares/share-manager'
16+
import {
17+
getWorkspaceFile,
18+
resolveWorkspaceFileReference,
19+
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
20+
import {
21+
PublicFileSharingNotAllowedError,
22+
validatePublicFileSharing,
23+
} from '@/ee/access-control/utils/permission-check'
24+
25+
const logger = createLogger('ShareFileServerTool')
26+
27+
interface ShareFileArgs {
28+
path?: string
29+
fileId?: string
30+
action?: 'share' | 'unshare'
31+
authType?: ShareAuthType
32+
password?: string
33+
allowedEmails?: string[]
34+
args?: Record<string, unknown>
35+
}
36+
37+
interface ShareFileResult {
38+
success: boolean
39+
message: string
40+
data?: {
41+
url: string
42+
token: string
43+
authType: ShareAuthType
44+
hasPassword: boolean
45+
isActive: boolean
46+
}
47+
}
48+
49+
export const shareFileServerTool: BaseServerTool<ShareFileArgs, ShareFileResult> = {
50+
name: ShareFile.id,
51+
async execute(params: ShareFileArgs, context?: ServerToolContext): Promise<ShareFileResult> {
52+
if (!context?.userId) {
53+
throw new Error('Authentication required')
54+
}
55+
const workspaceId = context.workspaceId
56+
if (!workspaceId) {
57+
return { success: false, message: 'Workspace ID is required' }
58+
}
59+
await ensureWorkspaceAccess(workspaceId, context.userId, 'write')
60+
61+
const nested = params.args
62+
const path = params.path || (nested?.path as string) || ''
63+
const legacyFileId = params.fileId || (nested?.fileId as string) || ''
64+
const action = (params.action || (nested?.action as string) || 'share') as 'share' | 'unshare'
65+
const authType = (params.authType || (nested?.authType as ShareAuthType | undefined)) as
66+
| ShareAuthType
67+
| undefined
68+
const password = params.password || (nested?.password as string) || undefined
69+
const allowedEmails =
70+
params.allowedEmails || (nested?.allowedEmails as string[] | undefined) || undefined
71+
72+
const targetRef = path || legacyFileId
73+
if (!targetRef) return { success: false, message: 'path is required' }
74+
75+
const existingFile = path
76+
? await resolveWorkspaceFileReference(workspaceId, path)
77+
: await getWorkspaceFile(workspaceId, legacyFileId)
78+
if (!existingFile) {
79+
return { success: false, message: `File not found: ${targetRef}` }
80+
}
81+
const fileId = existingFile.id
82+
const isActive = action !== 'unshare'
83+
const existingShare = await getShareForResource('file', fileId)
84+
85+
// Unsharing a file that was never shared (or is already disabled) is a no-op:
86+
// never insert an inactive row, emit a FILE_SHARE_DISABLED audit, or return a
87+
// link claiming a share was revoked when none existed.
88+
if (!isActive && !existingShare?.isActive) {
89+
return {
90+
success: true,
91+
message: `"${existingFile.name}" isn't shared — nothing to unshare.`,
92+
}
93+
}
94+
95+
// Enabling a share is gated by the org's access-control policy (both the
96+
// master on/off and the per-auth-type allow-list); disabling is always
97+
// allowed so users can still un-share after the policy is turned on.
98+
if (isActive) {
99+
// Validate the auth type that will ACTUALLY be persisted. upsertFileShare
100+
// falls back to the existing share's authType when none is passed, so a bare
101+
// re-enable must be checked against that stored mode — not 'public' — or a
102+
// now-disallowed password/email/sso share could be silently reactivated.
103+
const effectiveAuthType = authType ?? existingShare?.authType ?? 'public'
104+
try {
105+
await validatePublicFileSharing(context.userId, workspaceId, effectiveAuthType)
106+
} catch (error) {
107+
if (error instanceof PublicFileSharingNotAllowedError) {
108+
return { success: false, message: error.message }
109+
}
110+
throw error
111+
}
112+
}
113+
114+
assertServerToolNotAborted(context)
115+
116+
let share
117+
try {
118+
share = await upsertFileShare({
119+
workspaceId,
120+
fileId,
121+
userId: context.userId,
122+
isActive,
123+
authType,
124+
password,
125+
allowedEmails,
126+
})
127+
} catch (error) {
128+
if (error instanceof ShareValidationError) {
129+
return { success: false, message: error.message }
130+
}
131+
throw error
132+
}
133+
134+
logger.info(`${isActive ? 'Enabled' : 'Disabled'} share for file via share_file`, {
135+
fileId,
136+
workspaceId,
137+
authType: share.authType,
138+
userId: context.userId,
139+
})
140+
141+
recordAudit({
142+
workspaceId,
143+
actorId: context.userId,
144+
action: isActive ? AuditAction.FILE_SHARED : AuditAction.FILE_SHARE_DISABLED,
145+
resourceType: AuditResourceType.FILE,
146+
resourceId: fileId,
147+
resourceName: existingFile.name,
148+
description: `${isActive ? 'Enabled' : 'Disabled'} public share for "${existingFile.name}"`,
149+
})
150+
151+
if (!isActive) {
152+
return {
153+
success: true,
154+
message: `Stopped sharing "${existingFile.name}". The previous link no longer works.`,
155+
data: {
156+
url: share.url,
157+
token: share.token,
158+
authType: share.authType,
159+
hasPassword: share.hasPassword,
160+
isActive: share.isActive,
161+
},
162+
}
163+
}
164+
165+
const authNote =
166+
share.authType === 'password'
167+
? ' (password-protected — share the password separately)'
168+
: share.authType === 'email'
169+
? ' (restricted to allowed emails via one-time code)'
170+
: share.authType === 'sso'
171+
? ' (restricted to allowed emails via SSO)'
172+
: ''
173+
174+
return {
175+
success: true,
176+
message: `Shared "${existingFile.name}"${authNote}: ${share.url}`,
177+
data: {
178+
url: share.url,
179+
token: share.token,
180+
authType: share.authType,
181+
hasPassword: share.hasPassword,
182+
isActive: share.isActive,
183+
},
184+
}
185+
},
186+
}

apps/sim/lib/copilot/tools/server/router.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ import {
4040
renameFileFolderServerTool,
4141
} from '@/lib/copilot/tools/server/files/file-folders'
4242
import { renameFileServerTool } from '@/lib/copilot/tools/server/files/rename-file'
43+
import { shareFileServerTool } from '@/lib/copilot/tools/server/files/share-file'
4344
import { workspaceFileServerTool } from '@/lib/copilot/tools/server/files/workspace-file'
4445
import { validateGeneratedToolPayload } from '@/lib/copilot/tools/server/generated-schema'
4546
import { generateImageServerTool } from '@/lib/copilot/tools/server/image/generate-image'
@@ -129,6 +130,7 @@ const WRITE_ACTIONS: Record<string, string[]> = {
129130
[CreateFile.id]: ['*'],
130131
rename_file: ['*'],
131132
[DeleteFile.id]: ['*'],
133+
[shareFileServerTool.name]: ['*'],
132134
move_file: ['*'],
133135
create_file_folder: ['*'],
134136
rename_file_folder: ['*'],
@@ -176,6 +178,7 @@ const baseServerToolRegistry: Record<string, BaseServerTool> = {
176178
[createFileServerTool.name]: createFileServerTool,
177179
[renameFileServerTool.name]: renameFileServerTool,
178180
[deleteFileServerTool.name]: deleteFileServerTool,
181+
[shareFileServerTool.name]: shareFileServerTool,
179182
[moveFileServerTool.name]: moveFileServerTool,
180183
[listFileFoldersServerTool.name]: listFileFoldersServerTool,
181184
[createFileFolderServerTool.name]: createFileFolderServerTool,

0 commit comments

Comments
 (0)