Skip to content

Commit aa55758

Browse files
feat(copilot): add share_file tool handler and surface file share state to the agent
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 aa55758

7 files changed

Lines changed: 332 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: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
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+
84+
// Enabling a share is gated by the org's access-control policy (both the
85+
// master on/off and the per-auth-type allow-list); disabling is always
86+
// allowed so users can still un-share after the policy is turned on.
87+
if (isActive) {
88+
// Validate the auth type that will ACTUALLY be persisted. upsertFileShare
89+
// falls back to the existing share's authType when none is passed, so a bare
90+
// re-enable must be checked against that stored mode — not 'public' — or a
91+
// now-disallowed password/email/sso share could be silently reactivated.
92+
const existingShare = await getShareForResource('file', fileId)
93+
const effectiveAuthType = authType ?? existingShare?.authType ?? 'public'
94+
try {
95+
await validatePublicFileSharing(context.userId, workspaceId, effectiveAuthType)
96+
} catch (error) {
97+
if (error instanceof PublicFileSharingNotAllowedError) {
98+
return { success: false, message: error.message }
99+
}
100+
throw error
101+
}
102+
}
103+
104+
assertServerToolNotAborted(context)
105+
106+
let share
107+
try {
108+
share = await upsertFileShare({
109+
workspaceId,
110+
fileId,
111+
userId: context.userId,
112+
isActive,
113+
authType,
114+
password,
115+
allowedEmails,
116+
})
117+
} catch (error) {
118+
if (error instanceof ShareValidationError) {
119+
return { success: false, message: error.message }
120+
}
121+
throw error
122+
}
123+
124+
logger.info(`${isActive ? 'Enabled' : 'Disabled'} share for file via share_file`, {
125+
fileId,
126+
workspaceId,
127+
authType: share.authType,
128+
userId: context.userId,
129+
})
130+
131+
recordAudit({
132+
workspaceId,
133+
actorId: context.userId,
134+
action: isActive ? AuditAction.FILE_SHARED : AuditAction.FILE_SHARE_DISABLED,
135+
resourceType: AuditResourceType.FILE,
136+
resourceId: fileId,
137+
resourceName: existingFile.name,
138+
description: `${isActive ? 'Enabled' : 'Disabled'} public share for "${existingFile.name}"`,
139+
})
140+
141+
if (!isActive) {
142+
return {
143+
success: true,
144+
message: `Stopped sharing "${existingFile.name}". The previous link no longer works.`,
145+
data: {
146+
url: share.url,
147+
token: share.token,
148+
authType: share.authType,
149+
hasPassword: share.hasPassword,
150+
isActive: share.isActive,
151+
},
152+
}
153+
}
154+
155+
const authNote =
156+
share.authType === 'password'
157+
? ' (password-protected — share the password separately)'
158+
: share.authType === 'email'
159+
? ' (restricted to allowed emails via one-time code)'
160+
: share.authType === 'sso'
161+
? ' (restricted to allowed emails via SSO)'
162+
: ''
163+
164+
return {
165+
success: true,
166+
message: `Shared "${existingFile.name}"${authNote}: ${share.url}`,
167+
data: {
168+
url: share.url,
169+
token: share.token,
170+
authType: share.authType,
171+
hasPassword: share.hasPassword,
172+
isActive: share.isActive,
173+
},
174+
}
175+
},
176+
}

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,

apps/sim/lib/copilot/vfs/serializers.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { ShareAuthType } from '@/lib/api/contracts/public-shares'
12
import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions'
23
import { isHosted } from '@/lib/core/config/env-flags'
34
import { type FilterFieldType, getOperatorsForFieldType } from '@/lib/knowledge/filters/types'
@@ -366,6 +367,12 @@ export function serializeFileMeta(file: {
366367
size: number
367368
uploadedAt: Date
368369
updatedAt: Date
370+
/** Whether the file has an active public share link. */
371+
shared?: boolean
372+
/** Auth mode of the active share; only meaningful when `shared` is true. */
373+
shareAuthType?: ShareAuthType
374+
/** Public share link (`{baseUrl}/f/{token}`); only meaningful when `shared` is true. */
375+
shareUrl?: string
369376
}): string {
370377
return JSON.stringify(
371378
{
@@ -379,6 +386,9 @@ export function serializeFileMeta(file: {
379386
uploadedAt: file.uploadedAt.toISOString(),
380387
updatedAt: file.updatedAt.toISOString(),
381388
readContentWith: file.vfsPath ? `${file.vfsPath}/content` : undefined,
389+
shared: Boolean(file.shared),
390+
shareAuthType: file.shared ? file.shareAuthType : undefined,
391+
shareUrl: file.shared ? file.shareUrl : undefined,
382392
note: 'This is file metadata only. To read the file text/bytes, read the readContentWith path (i.e. append /content).',
383393
},
384394
null,

0 commit comments

Comments
 (0)