| 903 | * All requests go directly - internal routes use regular fetch, external use SSRF-protected fetch |
| 904 | */ |
| 905 | export async function executeTool( |
| 906 | toolId: string, |
| 907 | params: Record<string, any>, |
| 908 | options: ExecuteToolOptions = {} |
| 909 | ): Promise<ToolResponse> { |
| 910 | const { skipPostProcess = false, executionContext, signal } = options |
| 911 | // Fall back to the workflow execution's abort signal so plan-based execution timeouts |
| 912 | // and cancellation propagate to tool fetches when the caller passes no explicit signal. |
| 913 | const effectiveSignal = signal ?? executionContext?.abortSignal |
| 914 | // Capture start time for precise timing |
| 915 | const startTime = new Date() |
| 916 | const startTimeISO = startTime.toISOString() |
| 917 | const requestId = generateRequestId() |
| 918 | |
| 919 | // Hoisted so the outer catch can attribute a thrown failure to the chosen key. |
| 920 | let hostedKeyForMetrics: { provider: string; tool: string; key: string } | undefined |
| 921 | |
| 922 | try { |
| 923 | let tool: ToolConfig | undefined |
| 924 | |
| 925 | // Normalize tool ID to strip resource suffixes (e.g., workflow_executor_<uuid> -> workflow_executor) |
| 926 | const normalizedToolId = normalizeToolId(toolId) |
| 927 | |
| 928 | const scope = resolveToolScope(params, executionContext) |
| 929 | |
| 930 | const toolKind: 'skill' | 'custom' | 'mcp' | undefined = |
| 931 | normalizedToolId === 'load_skill' || normalizedToolId === 'load_user_skill' |
| 932 | ? 'skill' |
| 933 | : isCustomTool(normalizedToolId) |
| 934 | ? 'custom' |
| 935 | : isMcpTool(normalizedToolId) |
| 936 | ? 'mcp' |
| 937 | : undefined |
| 938 | |
| 939 | // Runs for ALL tools (not just kinded ones) so the per-tool `deniedTools` |
| 940 | // denylist is enforced alongside the existing mcp/custom/skill gates. |
| 941 | if (scope.userId && scope.workspaceId) { |
| 942 | await assertPermissionsAllowed({ |
| 943 | userId: scope.userId, |
| 944 | workspaceId: scope.workspaceId, |
| 945 | toolId: normalizedToolId, |
| 946 | toolKind, |
| 947 | ctx: executionContext, |
| 948 | }) |
| 949 | } |
| 950 | |
| 951 | if (normalizedToolId === 'load_skill' || normalizedToolId === 'load_user_skill') { |
| 952 | const skillName = params.skill_name |
| 953 | if (!skillName || !scope.workspaceId) { |
| 954 | return { |
| 955 | success: false, |
| 956 | output: { error: 'Missing skill_name or workspace context' }, |
| 957 | error: 'Missing skill_name or workspace context', |
| 958 | } |
| 959 | } |
| 960 | const content = await resolveSkillContent(skillName, scope.workspaceId) |
| 961 | if (!content) { |
| 962 | return { |