From 9e1e71aa4ed0859e42c750e94b846a8dc857a8ff Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 13 Jul 2026 17:18:19 -0700 Subject: [PATCH] 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. --- .../[id]/files/[fileId]/share/route.ts | 8 +- .../lib/copilot/generated/tool-catalog-v1.ts | 57 ++++++ .../lib/copilot/generated/tool-schemas-v1.ts | 56 ++++++ .../copilot/tools/server/files/share-file.ts | 176 ++++++++++++++++++ apps/sim/lib/copilot/tools/server/router.ts | 3 + apps/sim/lib/copilot/vfs/serializers.ts | 10 + apps/sim/lib/copilot/vfs/workspace-vfs.ts | 23 +++ 7 files changed, 332 insertions(+), 1 deletion(-) create mode 100644 apps/sim/lib/copilot/tools/server/files/share-file.ts diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts index d056dfa0223..0d6a09d6361 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts @@ -104,8 +104,14 @@ export const PUT = withRouteHandler( // master on/off and the per-auth-type allow-list); disabling is always // allowed so users can still un-share after the policy is turned on. if (isActive) { + // Validate the auth type that will ACTUALLY be persisted. upsertFileShare + // falls back to the existing share's authType when none is passed, so a bare + // re-enable must be checked against that stored mode — not 'public' — or a + // now-disallowed password/email/sso share could be silently reactivated. + const existingShare = await getShareForResource('file', fileId) + const effectiveAuthType = authType ?? existingShare?.authType ?? 'public' try { - await validatePublicFileSharing(session.user.id, workspaceId, authType ?? 'public') + await validatePublicFileSharing(session.user.id, workspaceId, effectiveAuthType) } catch (error) { if (error instanceof PublicFileSharingNotAllowedError) { logger.warn(`[${requestId}] Public file sharing disabled for workspace ${workspaceId}`) diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index cce8011cd3e..294fb951de0 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -94,6 +94,7 @@ export interface ToolCatalogEntry { | 'set_block_enabled' | 'set_environment_variables' | 'set_global_workflow_variables' + | 'share_file' | 'superagent' | 'table' | 'update_deployment_version' @@ -193,6 +194,7 @@ export interface ToolCatalogEntry { | 'set_block_enabled' | 'set_environment_variables' | 'set_global_workflow_variables' + | 'share_file' | 'superagent' | 'table' | 'update_deployment_version' @@ -3768,6 +3770,60 @@ export const SetGlobalWorkflowVariables: ToolCatalogEntry = { requiredPermission: 'write', } +export const ShareFile: ToolCatalogEntry = { + id: 'share_file', + name: 'share_file', + route: 'sim', + mode: 'async', + parameters: { + type: 'object', + properties: { + action: { + type: 'string', + description: 'Whether to create/update the share link or deactivate it.', + enum: ['share', 'unshare'], + default: 'share', + }, + allowedEmails: { + type: 'array', + description: + 'Allowed emails or "@domain" patterns for authType "email" or "sso". Ignored for other auth types.', + items: { type: 'string' }, + }, + authType: { + type: 'string', + description: 'How viewers authenticate to open the link. Ignored for unshare.', + enum: ['public', 'password', 'email', 'sso'], + default: 'public', + }, + password: { + type: 'string', + description: + '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.', + }, + path: { + type: 'string', + description: 'Canonical workspace file VFS path to share, e.g. "files/Reports/Q4.md".', + }, + }, + required: ['path'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: + 'Share state. Contains url (the {baseUrl}/f/{token} link), token, authType, hasPassword, and isActive.', + }, + message: { type: 'string', description: 'Human-readable outcome.' }, + success: { type: 'boolean', description: 'Whether the share action succeeded.' }, + }, + required: ['success', 'message'], + }, + requiredPermission: 'write', +} + export const Superagent: ToolCatalogEntry = { id: 'superagent', name: 'superagent', @@ -4767,6 +4823,7 @@ export const TOOL_CATALOG: Record = { [SetBlockEnabled.id]: SetBlockEnabled, [SetEnvironmentVariables.id]: SetEnvironmentVariables, [SetGlobalWorkflowVariables.id]: SetGlobalWorkflowVariables, + [ShareFile.id]: ShareFile, [Superagent.id]: Superagent, [Table.id]: Table, [UpdateDeploymentVersion.id]: UpdateDeploymentVersion, diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 2d9678086ff..7e095658759 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -3534,6 +3534,62 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, resultSchema: undefined, }, + share_file: { + parameters: { + type: 'object', + properties: { + action: { + type: 'string', + description: 'Whether to create/update the share link or deactivate it.', + enum: ['share', 'unshare'], + default: 'share', + }, + allowedEmails: { + type: 'array', + description: + 'Allowed emails or "@domain" patterns for authType "email" or "sso". Ignored for other auth types.', + items: { + type: 'string', + }, + }, + authType: { + type: 'string', + description: 'How viewers authenticate to open the link. Ignored for unshare.', + enum: ['public', 'password', 'email', 'sso'], + default: 'public', + }, + password: { + type: 'string', + description: + '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.', + }, + path: { + type: 'string', + description: 'Canonical workspace file VFS path to share, e.g. "files/Reports/Q4.md".', + }, + }, + required: ['path'], + }, + resultSchema: { + type: 'object', + properties: { + data: { + type: 'object', + description: + 'Share state. Contains url (the {baseUrl}/f/{token} link), token, authType, hasPassword, and isActive.', + }, + message: { + type: 'string', + description: 'Human-readable outcome.', + }, + success: { + type: 'boolean', + description: 'Whether the share action succeeded.', + }, + }, + required: ['success', 'message'], + }, + }, superagent: { parameters: { properties: { diff --git a/apps/sim/lib/copilot/tools/server/files/share-file.ts b/apps/sim/lib/copilot/tools/server/files/share-file.ts new file mode 100644 index 00000000000..618d5f0b810 --- /dev/null +++ b/apps/sim/lib/copilot/tools/server/files/share-file.ts @@ -0,0 +1,176 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import type { ShareAuthType } from '@/lib/api/contracts/public-shares' +import { ShareFile } from '@/lib/copilot/generated/tool-catalog-v1' +import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' +import { + assertServerToolNotAborted, + type BaseServerTool, + type ServerToolContext, +} from '@/lib/copilot/tools/server/base-tool' +import { + getShareForResource, + ShareValidationError, + upsertFileShare, +} from '@/lib/public-shares/share-manager' +import { + getWorkspaceFile, + resolveWorkspaceFileReference, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { + PublicFileSharingNotAllowedError, + validatePublicFileSharing, +} from '@/ee/access-control/utils/permission-check' + +const logger = createLogger('ShareFileServerTool') + +interface ShareFileArgs { + path?: string + fileId?: string + action?: 'share' | 'unshare' + authType?: ShareAuthType + password?: string + allowedEmails?: string[] + args?: Record +} + +interface ShareFileResult { + success: boolean + message: string + data?: { + url: string + token: string + authType: ShareAuthType + hasPassword: boolean + isActive: boolean + } +} + +export const shareFileServerTool: BaseServerTool = { + name: ShareFile.id, + async execute(params: ShareFileArgs, context?: ServerToolContext): Promise { + if (!context?.userId) { + throw new Error('Authentication required') + } + const workspaceId = context.workspaceId + if (!workspaceId) { + return { success: false, message: 'Workspace ID is required' } + } + await ensureWorkspaceAccess(workspaceId, context.userId, 'write') + + const nested = params.args + const path = params.path || (nested?.path as string) || '' + const legacyFileId = params.fileId || (nested?.fileId as string) || '' + const action = (params.action || (nested?.action as string) || 'share') as 'share' | 'unshare' + const authType = (params.authType || (nested?.authType as ShareAuthType | undefined)) as + | ShareAuthType + | undefined + const password = params.password || (nested?.password as string) || undefined + const allowedEmails = + params.allowedEmails || (nested?.allowedEmails as string[] | undefined) || undefined + + const targetRef = path || legacyFileId + if (!targetRef) return { success: false, message: 'path is required' } + + const existingFile = path + ? await resolveWorkspaceFileReference(workspaceId, path) + : await getWorkspaceFile(workspaceId, legacyFileId) + if (!existingFile) { + return { success: false, message: `File not found: ${targetRef}` } + } + const fileId = existingFile.id + const isActive = action !== 'unshare' + + // Enabling a share is gated by the org's access-control policy (both the + // master on/off and the per-auth-type allow-list); disabling is always + // allowed so users can still un-share after the policy is turned on. + if (isActive) { + // Validate the auth type that will ACTUALLY be persisted. upsertFileShare + // falls back to the existing share's authType when none is passed, so a bare + // re-enable must be checked against that stored mode — not 'public' — or a + // now-disallowed password/email/sso share could be silently reactivated. + const existingShare = await getShareForResource('file', fileId) + const effectiveAuthType = authType ?? existingShare?.authType ?? 'public' + try { + await validatePublicFileSharing(context.userId, workspaceId, effectiveAuthType) + } catch (error) { + if (error instanceof PublicFileSharingNotAllowedError) { + return { success: false, message: error.message } + } + throw error + } + } + + assertServerToolNotAborted(context) + + let share + try { + share = await upsertFileShare({ + workspaceId, + fileId, + userId: context.userId, + isActive, + authType, + password, + allowedEmails, + }) + } catch (error) { + if (error instanceof ShareValidationError) { + return { success: false, message: error.message } + } + throw error + } + + logger.info(`${isActive ? 'Enabled' : 'Disabled'} share for file via share_file`, { + fileId, + workspaceId, + authType: share.authType, + userId: context.userId, + }) + + recordAudit({ + workspaceId, + actorId: context.userId, + action: isActive ? AuditAction.FILE_SHARED : AuditAction.FILE_SHARE_DISABLED, + resourceType: AuditResourceType.FILE, + resourceId: fileId, + resourceName: existingFile.name, + description: `${isActive ? 'Enabled' : 'Disabled'} public share for "${existingFile.name}"`, + }) + + if (!isActive) { + return { + success: true, + message: `Stopped sharing "${existingFile.name}". The previous link no longer works.`, + data: { + url: share.url, + token: share.token, + authType: share.authType, + hasPassword: share.hasPassword, + isActive: share.isActive, + }, + } + } + + const authNote = + share.authType === 'password' + ? ' (password-protected — share the password separately)' + : share.authType === 'email' + ? ' (restricted to allowed emails via one-time code)' + : share.authType === 'sso' + ? ' (restricted to allowed emails via SSO)' + : '' + + return { + success: true, + message: `Shared "${existingFile.name}"${authNote}: ${share.url}`, + data: { + url: share.url, + token: share.token, + authType: share.authType, + hasPassword: share.hasPassword, + isActive: share.isActive, + }, + } + }, +} diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index 9dd8c737dde..bd23295fa3e 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -45,6 +45,7 @@ import { renameFileFolderServerTool, } from '@/lib/copilot/tools/server/files/file-folders' import { renameFileServerTool } from '@/lib/copilot/tools/server/files/rename-file' +import { shareFileServerTool } from '@/lib/copilot/tools/server/files/share-file' import { workspaceFileServerTool } from '@/lib/copilot/tools/server/files/workspace-file' import { validateGeneratedToolPayload } from '@/lib/copilot/tools/server/generated-schema' import { generateImageServerTool } from '@/lib/copilot/tools/server/image/generate-image' @@ -132,6 +133,7 @@ const WRITE_ACTIONS: Record = { [CreateFile.id]: ['*'], [RenameFile.id]: ['*'], [DeleteFile.id]: ['*'], + [shareFileServerTool.name]: ['*'], [MoveFile.id]: ['*'], [CreateFileFolder.id]: ['*'], [RenameFileFolder.id]: ['*'], @@ -177,6 +179,7 @@ const baseServerToolRegistry: Record = { [createFileServerTool.name]: createFileServerTool, [renameFileServerTool.name]: renameFileServerTool, [deleteFileServerTool.name]: deleteFileServerTool, + [shareFileServerTool.name]: shareFileServerTool, [moveFileServerTool.name]: moveFileServerTool, [listFileFoldersServerTool.name]: listFileFoldersServerTool, [createFileFolderServerTool.name]: createFileFolderServerTool, diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index 571768bad6a..f0550c57742 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -1,4 +1,5 @@ import { truncate } from '@sim/utils/string' +import type { ShareAuthType } from '@/lib/api/contracts/public-shares' import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions' import { isHosted } from '@/lib/core/config/env-flags' import { isSubBlockHidden } from '@/lib/workflows/subblocks/visibility' @@ -291,6 +292,12 @@ export function serializeFileMeta(file: { contentType: string size: number uploadedAt: Date + /** Whether the file has an active public share link. */ + shared?: boolean + /** Auth mode of the active share; only meaningful when `shared` is true. */ + shareAuthType?: ShareAuthType + /** Public share link (`{baseUrl}/f/{token}`); only meaningful when `shared` is true. */ + shareUrl?: string }): string { return JSON.stringify( { @@ -303,6 +310,9 @@ export function serializeFileMeta(file: { size: file.size, uploadedAt: file.uploadedAt.toISOString(), readContentWith: file.vfsPath ? `${file.vfsPath}/content` : undefined, + shared: Boolean(file.shared), + shareAuthType: file.shared ? file.shareAuthType : undefined, + shareUrl: file.shared ? file.shareUrl : undefined, note: 'This is file metadata only. To read the file text/bytes, read the readContentWith path (i.e. append /content).', }, null, diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index f40def73f7e..844a6117293 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -100,6 +100,7 @@ import { BINARY_DOC_TASKS, MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/executi import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task' import { getKnowledgeBases } from '@/lib/knowledge/service' import { validateMermaidSource } from '@/lib/mermaid/validate' +import { getSharesForResources } from '@/lib/public-shares/share-manager' import { listTables } from '@/lib/table/service' import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { @@ -1647,6 +1648,23 @@ export class WorkspaceVFS { folders, includeReservedSystemFiles: true, }) + // Batch-load public share state so each file's metadata carries an ambient + // `shared` flag (mirrors how the files-list UI enriches rows) — no N+1. + // Fail soft: share state is only metadata enrichment, so a lookup failure + // must not drop the whole file tree (the outer catch returns []) — fall back + // to no shares, and files still materialize with `shared: false`. + let shareByFileId: Awaited> = new Map() + try { + shareByFileId = await getSharesForResources( + 'file', + files.map((file) => file.id) + ) + } catch (error) { + logger.warn('Failed to load file share state; file metadata will show shared: false', { + workspaceId, + error: toError(error).message, + }) + } for (const folder of folders) { if ( !workflowArtifactsEnabled && @@ -1665,6 +1683,8 @@ export class WorkspaceVFS { if (!workflowArtifactsEnabled && isWorkflowAliasBackingPath(filePath)) { continue } + const share = shareByFileId.get(file.id) + const shared = share?.isActive ?? false this.files.set( filePath, serializeFileMeta({ @@ -1676,6 +1696,9 @@ export class WorkspaceVFS { contentType: file.type, size: file.size, uploadedAt: file.uploadedAt, + shared, + shareAuthType: shared ? share?.authType : undefined, + shareUrl: shared ? share?.url : undefined, }) ) }