@@ -25,6 +25,7 @@ import {
2525 setEnvFlags ,
2626} from '@sim/testing'
2727import { sleep } from '@sim/utils/helpers'
28+ import { DrizzleQueryError } from 'drizzle-orm/errors'
2829import { afterAll , afterEach , beforeAll , beforeEach , describe , expect , it , vi } from 'vitest'
2930import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution'
3031import { projectToolResultForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result'
@@ -54,6 +55,7 @@ const {
5455 mockGenerateInternalDelegationToken,
5556 mockGenerateInternalToken,
5657 mockResolveWorkspaceFileReference,
58+ mockAssertPermissionsAllowed,
5759} = vi . hoisted ( ( ) => ( {
5860 mockGetBYOKKey : vi . fn ( ) ,
5961 mockGetToolAsync : vi . fn ( ) ,
@@ -71,6 +73,7 @@ const {
7173 mockGenerateInternalDelegationToken : vi . fn ( ) ,
7274 mockGenerateInternalToken : vi . fn ( ) ,
7375 mockResolveWorkspaceFileReference : vi . fn ( ) ,
76+ mockAssertPermissionsAllowed : vi . fn ( ) ,
7477} ) )
7578
7679const mockSecureFetchWithPinnedIP = inputValidationMockFns . mockSecureFetchWithPinnedIP
@@ -94,7 +97,7 @@ vi.mock('@/lib/core/security/encryption', () => ({
9497} ) )
9598
9699vi . mock ( '@/ee/access-control/utils/permission-check' , ( ) => ( {
97- assertPermissionsAllowed : vi . fn ( ) . mockResolvedValue ( undefined ) ,
100+ assertPermissionsAllowed : mockAssertPermissionsAllowed ,
98101 validateBlockType : vi . fn ( ) . mockResolvedValue ( undefined ) ,
99102 validateMcpToolsAllowed : vi . fn ( ) . mockResolvedValue ( undefined ) ,
100103 validateCustomToolsAllowed : vi . fn ( ) . mockResolvedValue ( undefined ) ,
@@ -460,6 +463,7 @@ vi.spyOn(getQueryClientModule, 'getQueryClient').mockImplementation(createMockQu
460463
461464beforeEach ( ( ) => {
462465 vi . spyOn ( getQueryClientModule , 'getQueryClient' ) . mockImplementation ( createMockQueryClient )
466+ mockAssertPermissionsAllowed . mockResolvedValue ( undefined )
463467 mockGenerateInternalDelegationToken . mockResolvedValue ( 'executor-token' )
464468 mockRunWorkflowTool . mockResolvedValue ( { success : true , output : { } } )
465469 // Suites below call vi.resetAllMocks(), which wipes the shared env/urls mock
@@ -692,6 +696,138 @@ describe('executeTool Function', () => {
692696 tools . function_execute = originalFunctionTool
693697 } )
694698
699+ it ( 'retries transient database failures during permission preflight' , async ( ) => {
700+ const driverError = Object . assign ( new Error ( 'read ECONNRESET' ) , {
701+ code : 'ECONNRESET' ,
702+ errno : 'ECONNRESET' ,
703+ syscall : 'read' ,
704+ } )
705+ const databaseError = new DrizzleQueryError (
706+ 'select "id" from "workspace" where "workspace"."id" = $1 limit $2' ,
707+ [ 'workspace-secret-id' , 1 ] ,
708+ driverError
709+ )
710+ mockAssertPermissionsAllowed . mockRejectedValueOnce ( databaseError )
711+ mockToolsLogger . warn . mockClear ( )
712+
713+ const result = await executeTool (
714+ 'function_execute' ,
715+ { code : 'return 1' } ,
716+ { executionContext : createToolExecutionContext ( { userId : 'user-123' } ) }
717+ )
718+
719+ expect ( result . success ) . toBe ( true )
720+ expect ( mockAssertPermissionsAllowed ) . toHaveBeenCalledTimes ( 2 )
721+ expect ( global . fetch ) . toHaveBeenCalledTimes ( 1 )
722+ expect ( mockToolsLogger . warn ) . toHaveBeenCalledWith (
723+ expect . stringContaining ( 'Retrying tool permission preflight after database error' ) ,
724+ expect . objectContaining ( {
725+ attempt : 1 ,
726+ maxAttempts : 3 ,
727+ cause : expect . objectContaining ( { code : 'ECONNRESET' } ) ,
728+ } )
729+ )
730+ } )
731+
732+ it ( 'logs exhausted database retries without exposing query details to the caller' , async ( ) => {
733+ const driverError = Object . assign ( new Error ( 'read ECONNRESET' ) , {
734+ code : 'ECONNRESET' ,
735+ errno : 'ECONNRESET' ,
736+ syscall : 'read' ,
737+ } )
738+ const databaseError = new DrizzleQueryError (
739+ 'select "id" from "workspace" where "workspace"."id" = $1 limit $2' ,
740+ [ 'workspace-secret-id' , 1 ] ,
741+ driverError
742+ )
743+ mockAssertPermissionsAllowed . mockRejectedValue ( databaseError )
744+ mockToolsLogger . error . mockClear ( )
745+
746+ const result = await executeTool (
747+ 'http_request' ,
748+ { url : 'https://example.com' } ,
749+ { executionContext : createToolExecutionContext ( { userId : 'user-123' } ) }
750+ )
751+
752+ expect ( result . success ) . toBe ( false )
753+ expect ( result . error ) . toBe (
754+ 'An internal error occurred while executing the tool. Please try again.'
755+ )
756+ expect ( JSON . stringify ( result ) ) . not . toContain ( 'Failed query' )
757+ expect ( JSON . stringify ( result ) ) . not . toContain ( 'workspace-secret-id' )
758+ expect ( mockAssertPermissionsAllowed ) . toHaveBeenCalledTimes ( 3 )
759+ expect ( global . fetch ) . not . toHaveBeenCalled ( )
760+
761+ const loggedError = mockToolsLogger . error . mock . calls . at ( - 1 ) ?. [ 1 ]
762+ expect ( loggedError ) . toEqual (
763+ expect . objectContaining ( {
764+ cause : expect . objectContaining ( {
765+ name : 'Error' ,
766+ message : 'read ECONNRESET' ,
767+ code : 'ECONNRESET' ,
768+ errno : 'ECONNRESET' ,
769+ syscall : 'read' ,
770+ causeChain : expect . arrayContaining ( [
771+ expect . stringContaining ( 'params: [redacted]' ) ,
772+ 'Error: read ECONNRESET' ,
773+ ] ) ,
774+ } ) ,
775+ } )
776+ )
777+ expect ( loggedError ) . not . toHaveProperty ( 'stack' )
778+ expect ( JSON . stringify ( loggedError ) ) . not . toContain ( 'workspace-secret-id' )
779+ } )
780+
781+ it ( 'does not retry non-transient database failures during permission preflight' , async ( ) => {
782+ const databaseError = new DrizzleQueryError (
783+ 'select "missing_column" from "workspace"' ,
784+ [ ] ,
785+ Object . assign ( new Error ( 'column does not exist' ) , { code : '42703' } )
786+ )
787+ mockAssertPermissionsAllowed . mockRejectedValue ( databaseError )
788+
789+ const result = await executeTool (
790+ 'function_execute' ,
791+ { code : 'return 1' } ,
792+ { executionContext : createToolExecutionContext ( { userId : 'user-123' } ) }
793+ )
794+
795+ expect ( result . success ) . toBe ( false )
796+ expect ( result . error ) . toBe (
797+ 'An internal error occurred while executing the tool. Please try again.'
798+ )
799+ expect ( mockAssertPermissionsAllowed ) . toHaveBeenCalledTimes ( 1 )
800+ expect ( global . fetch ) . not . toHaveBeenCalled ( )
801+ } )
802+
803+ it ( 'surfaces cancellation instead of a concurrent permission database failure' , async ( ) => {
804+ const controller = new AbortController ( )
805+ const abortReason = new Error ( 'Execution cancelled' )
806+ const databaseError = new DrizzleQueryError (
807+ 'select "id" from "workspace" where "workspace"."id" = $1' ,
808+ [ 'workspace-secret-id' ] ,
809+ Object . assign ( new Error ( 'read ECONNRESET' ) , { code : 'ECONNRESET' } )
810+ )
811+ mockAssertPermissionsAllowed . mockImplementationOnce ( async ( ) => {
812+ controller . abort ( abortReason )
813+ throw databaseError
814+ } )
815+
816+ const result = await executeTool (
817+ 'function_execute' ,
818+ { code : 'return 1' } ,
819+ {
820+ executionContext : createToolExecutionContext ( { userId : 'user-123' } ) ,
821+ signal : controller . signal ,
822+ }
823+ )
824+
825+ expect ( result . success ) . toBe ( false )
826+ expect ( result . error ) . toBe ( 'Execution cancelled' )
827+ expect ( mockAssertPermissionsAllowed ) . toHaveBeenCalledTimes ( 1 )
828+ expect ( global . fetch ) . not . toHaveBeenCalled ( )
829+ } )
830+
695831 it ( 'should call internal routes directly' , async ( ) => {
696832 const originalFunctionTool = { ...tools . function_execute }
697833 tools . function_execute = {
0 commit comments