Skip to content

Commit 1d76a7e

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 de934b9 commit 1d76a7e

7 files changed

Lines changed: 328 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
| '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: 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
@@ -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)