Skip to content

Commit 472a037

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'.
1 parent de934b9 commit 472a037

6 files changed

Lines changed: 300 additions & 0 deletions

File tree

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
| 'superagent'
9899
| 'table'
99100
| 'update_deployment_version'
@@ -193,6 +194,7 @@ export interface ToolCatalogEntry {
193194
| 'set_block_enabled'
194195
| 'set_environment_variables'
195196
| 'set_global_workflow_variables'
197+
| 'share_file'
196198
| 'superagent'
197199
| 'table'
198200
| 'update_deployment_version'
@@ -3768,6 +3770,60 @@ export const SetGlobalWorkflowVariables: ToolCatalogEntry = {
37683770
requiredPermission: 'write',
37693771
}
37703772

3773+
export const ShareFile: ToolCatalogEntry = {
3774+
id: 'share_file',
3775+
name: 'share_file',
3776+
route: 'sim',
3777+
mode: 'async',
3778+
parameters: {
3779+
type: 'object',
3780+
properties: {
3781+
action: {
3782+
type: 'string',
3783+
description: 'Whether to create/update the share link or deactivate it.',
3784+
enum: ['share', 'unshare'],
3785+
default: 'share',
3786+
},
3787+
allowedEmails: {
3788+
type: 'array',
3789+
description:
3790+
'Allowed emails or "@domain" patterns for authType "email" or "sso". Ignored for other auth types.',
3791+
items: { type: 'string' },
3792+
},
3793+
authType: {
3794+
type: 'string',
3795+
description: 'How viewers authenticate to open the link. Ignored for unshare.',
3796+
enum: ['public', 'password', 'email', 'sso'],
3797+
default: 'public',
3798+
},
3799+
password: {
3800+
type: 'string',
3801+
description:
3802+
'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.',
3803+
},
3804+
path: {
3805+
type: 'string',
3806+
description: 'Canonical workspace file VFS path to share, e.g. "files/Reports/Q4.md".',
3807+
},
3808+
},
3809+
required: ['path'],
3810+
},
3811+
resultSchema: {
3812+
type: 'object',
3813+
properties: {
3814+
data: {
3815+
type: 'object',
3816+
description:
3817+
'Share state. Contains url (the {baseUrl}/f/{token} link), token, authType, hasPassword, and isActive.',
3818+
},
3819+
message: { type: 'string', description: 'Human-readable outcome.' },
3820+
success: { type: 'boolean', description: 'Whether the share action succeeded.' },
3821+
},
3822+
required: ['success', 'message'],
3823+
},
3824+
requiredPermission: 'write',
3825+
}
3826+
37713827
export const Superagent: ToolCatalogEntry = {
37723828
id: 'superagent',
37733829
name: 'superagent',
@@ -4767,6 +4823,7 @@ export const TOOL_CATALOG: Record<string, ToolCatalogEntry> = {
47674823
[SetBlockEnabled.id]: SetBlockEnabled,
47684824
[SetEnvironmentVariables.id]: SetEnvironmentVariables,
47694825
[SetGlobalWorkflowVariables.id]: SetGlobalWorkflowVariables,
4826+
[ShareFile.id]: ShareFile,
47704827
[Superagent.id]: Superagent,
47714828
[Table.id]: Table,
47724829
[UpdateDeploymentVersion.id]: UpdateDeploymentVersion,

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

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3534,6 +3534,62 @@ export const TOOL_RUNTIME_SCHEMAS: Record<string, ToolRuntimeSchemaEntry> = {
35343534
},
35353535
resultSchema: undefined,
35363536
},
3537+
share_file: {
3538+
parameters: {
3539+
type: 'object',
3540+
properties: {
3541+
action: {
3542+
type: 'string',
3543+
description: 'Whether to create/update the share link or deactivate it.',
3544+
enum: ['share', 'unshare'],
3545+
default: 'share',
3546+
},
3547+
allowedEmails: {
3548+
type: 'array',
3549+
description:
3550+
'Allowed emails or "@domain" patterns for authType "email" or "sso". Ignored for other auth types.',
3551+
items: {
3552+
type: 'string',
3553+
},
3554+
},
3555+
authType: {
3556+
type: 'string',
3557+
description: 'How viewers authenticate to open the link. Ignored for unshare.',
3558+
enum: ['public', 'password', 'email', 'sso'],
3559+
default: 'public',
3560+
},
3561+
password: {
3562+
type: 'string',
3563+
description:
3564+
'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.',
3565+
},
3566+
path: {
3567+
type: 'string',
3568+
description: 'Canonical workspace file VFS path to share, e.g. "files/Reports/Q4.md".',
3569+
},
3570+
},
3571+
required: ['path'],
3572+
},
3573+
resultSchema: {
3574+
type: 'object',
3575+
properties: {
3576+
data: {
3577+
type: 'object',
3578+
description:
3579+
'Share state. Contains url (the {baseUrl}/f/{token} link), token, authType, hasPassword, and isActive.',
3580+
},
3581+
message: {
3582+
type: 'string',
3583+
description: 'Human-readable outcome.',
3584+
},
3585+
success: {
3586+
type: 'boolean',
3587+
description: 'Whether the share action succeeded.',
3588+
},
3589+
},
3590+
required: ['success', 'message'],
3591+
},
3592+
},
35373593
superagent: {
35383594
parameters: {
35393595
properties: {
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
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 { ShareValidationError, upsertFileShare } from '@/lib/public-shares/share-manager'
12+
import {
13+
getWorkspaceFile,
14+
resolveWorkspaceFileReference,
15+
} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
16+
import {
17+
PublicFileSharingNotAllowedError,
18+
validatePublicFileSharing,
19+
} from '@/ee/access-control/utils/permission-check'
20+
21+
const logger = createLogger('ShareFileServerTool')
22+
23+
interface ShareFileArgs {
24+
path?: string
25+
fileId?: string
26+
action?: 'share' | 'unshare'
27+
authType?: ShareAuthType
28+
password?: string
29+
allowedEmails?: string[]
30+
args?: Record<string, unknown>
31+
}
32+
33+
interface ShareFileResult {
34+
success: boolean
35+
message: string
36+
data?: {
37+
url: string
38+
token: string
39+
authType: ShareAuthType
40+
hasPassword: boolean
41+
isActive: boolean
42+
}
43+
}
44+
45+
export const shareFileServerTool: BaseServerTool<ShareFileArgs, ShareFileResult> = {
46+
name: ShareFile.id,
47+
async execute(params: ShareFileArgs, context?: ServerToolContext): Promise<ShareFileResult> {
48+
if (!context?.userId) {
49+
throw new Error('Authentication required')
50+
}
51+
const workspaceId = context.workspaceId
52+
if (!workspaceId) {
53+
return { success: false, message: 'Workspace ID is required' }
54+
}
55+
await ensureWorkspaceAccess(workspaceId, context.userId, 'write')
56+
57+
const nested = params.args
58+
const path = params.path || (nested?.path as string) || ''
59+
const legacyFileId = params.fileId || (nested?.fileId as string) || ''
60+
const action = (params.action || (nested?.action as string) || 'share') as 'share' | 'unshare'
61+
const authType = (params.authType || (nested?.authType as ShareAuthType | undefined)) as
62+
| ShareAuthType
63+
| undefined
64+
const password = params.password || (nested?.password as string) || undefined
65+
const allowedEmails =
66+
params.allowedEmails || (nested?.allowedEmails as string[] | undefined) || undefined
67+
68+
const targetRef = path || legacyFileId
69+
if (!targetRef) return { success: false, message: 'path is required' }
70+
71+
const existingFile = path
72+
? await resolveWorkspaceFileReference(workspaceId, path)
73+
: await getWorkspaceFile(workspaceId, legacyFileId)
74+
if (!existingFile) {
75+
return { success: false, message: `File not found: ${targetRef}` }
76+
}
77+
const fileId = existingFile.id
78+
const isActive = action !== 'unshare'
79+
80+
// Enabling a share is gated by the org's access-control policy (both the
81+
// master on/off and the per-auth-type allow-list); disabling is always
82+
// allowed so users can still un-share after the policy is turned on.
83+
if (isActive) {
84+
try {
85+
await validatePublicFileSharing(context.userId, workspaceId, authType ?? 'public')
86+
} catch (error) {
87+
if (error instanceof PublicFileSharingNotAllowedError) {
88+
return { success: false, message: error.message }
89+
}
90+
throw error
91+
}
92+
}
93+
94+
assertServerToolNotAborted(context)
95+
96+
let share
97+
try {
98+
share = await upsertFileShare({
99+
workspaceId,
100+
fileId,
101+
userId: context.userId,
102+
isActive,
103+
authType,
104+
password,
105+
allowedEmails,
106+
})
107+
} catch (error) {
108+
if (error instanceof ShareValidationError) {
109+
return { success: false, message: error.message }
110+
}
111+
throw error
112+
}
113+
114+
logger.info(`${isActive ? 'Enabled' : 'Disabled'} share for file via share_file`, {
115+
fileId,
116+
workspaceId,
117+
authType: share.authType,
118+
userId: context.userId,
119+
})
120+
121+
recordAudit({
122+
workspaceId,
123+
actorId: context.userId,
124+
action: isActive ? AuditAction.FILE_SHARED : AuditAction.FILE_SHARE_DISABLED,
125+
resourceType: AuditResourceType.FILE,
126+
resourceId: fileId,
127+
resourceName: existingFile.name,
128+
description: `${isActive ? 'Enabled' : 'Disabled'} public share for "${existingFile.name}"`,
129+
})
130+
131+
if (!isActive) {
132+
return {
133+
success: true,
134+
message: `Stopped sharing "${existingFile.name}". The previous link no longer works.`,
135+
data: {
136+
url: share.url,
137+
token: share.token,
138+
authType: share.authType,
139+
hasPassword: share.hasPassword,
140+
isActive: share.isActive,
141+
},
142+
}
143+
}
144+
145+
const authNote =
146+
share.authType === 'password'
147+
? ' (password-protected — share the password separately)'
148+
: share.authType === 'email'
149+
? ' (restricted to allowed emails via one-time code)'
150+
: share.authType === 'sso'
151+
? ' (restricted to allowed emails via SSO)'
152+
: ''
153+
154+
return {
155+
success: true,
156+
message: `Shared "${existingFile.name}"${authNote}: ${share.url}`,
157+
data: {
158+
url: share.url,
159+
token: share.token,
160+
authType: share.authType,
161+
hasPassword: share.hasPassword,
162+
isActive: share.isActive,
163+
},
164+
}
165+
},
166+
}

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ import {
4545
renameFileFolderServerTool,
4646
} from '@/lib/copilot/tools/server/files/file-folders'
4747
import { renameFileServerTool } from '@/lib/copilot/tools/server/files/rename-file'
48+
import { shareFileServerTool } from '@/lib/copilot/tools/server/files/share-file'
4849
import { workspaceFileServerTool } from '@/lib/copilot/tools/server/files/workspace-file'
4950
import { validateGeneratedToolPayload } from '@/lib/copilot/tools/server/generated-schema'
5051
import { generateImageServerTool } from '@/lib/copilot/tools/server/image/generate-image'
@@ -132,6 +133,7 @@ const WRITE_ACTIONS: Record<string, string[]> = {
132133
[CreateFile.id]: ['*'],
133134
[RenameFile.id]: ['*'],
134135
[DeleteFile.id]: ['*'],
136+
[shareFileServerTool.name]: ['*'],
135137
[MoveFile.id]: ['*'],
136138
[CreateFileFolder.id]: ['*'],
137139
[RenameFileFolder.id]: ['*'],
@@ -177,6 +179,7 @@ const baseServerToolRegistry: Record<string, BaseServerTool> = {
177179
[createFileServerTool.name]: createFileServerTool,
178180
[renameFileServerTool.name]: renameFileServerTool,
179181
[deleteFileServerTool.name]: deleteFileServerTool,
182+
[shareFileServerTool.name]: shareFileServerTool,
180183
[moveFileServerTool.name]: moveFileServerTool,
181184
[listFileFoldersServerTool.name]: listFileFoldersServerTool,
182185
[createFileFolderServerTool.name]: createFileFolderServerTool,

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { truncate } from '@sim/utils/string'
2+
import type { ShareAuthType } from '@/lib/api/contracts/public-shares'
23
import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions'
34
import { isHosted } from '@/lib/core/config/env-flags'
45
import { isSubBlockHidden } from '@/lib/workflows/subblocks/visibility'
@@ -291,6 +292,10 @@ export function serializeFileMeta(file: {
291292
contentType: string
292293
size: number
293294
uploadedAt: Date
295+
/** Whether the file has an active public share link. */
296+
shared?: boolean
297+
/** Auth mode of the active share; only meaningful when `shared` is true. */
298+
shareAuthType?: ShareAuthType
294299
}): string {
295300
return JSON.stringify(
296301
{
@@ -303,6 +308,8 @@ export function serializeFileMeta(file: {
303308
size: file.size,
304309
uploadedAt: file.uploadedAt.toISOString(),
305310
readContentWith: file.vfsPath ? `${file.vfsPath}/content` : undefined,
311+
shared: Boolean(file.shared),
312+
shareAuthType: file.shared ? file.shareAuthType : undefined,
306313
note: 'This is file metadata only. To read the file text/bytes, read the readContentWith path (i.e. append /content).',
307314
},
308315
null,

0 commit comments

Comments
 (0)