feat: fix custom tool headers auth and add test endpoint

This commit is contained in:
Rodrigo Borba 2026-01-10 18:43:48 -03:00
parent 4141980f72
commit 3c3ba175c5
9 changed files with 296 additions and 12 deletions

View File

@ -31,6 +31,12 @@ class CaptainCustomTools extends ApiClient {
delete(id) {
return axios.delete(`${this.url}/${id}`);
}
test(id, data = {}) {
return axios.post(`${this.url}/${id}/test`, {
tool_params: data,
});
}
}
export default new CaptainCustomTools();

View File

@ -43,6 +43,12 @@ const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const menuItems = computed(() => [
{
label: t('CAPTAIN.CUSTOM_TOOLS.OPTIONS.TEST_TOOL'),
value: 'test',
action: 'test',
icon: 'i-lucide-play',
},
{
label: t('CAPTAIN.CUSTOM_TOOLS.OPTIONS.EDIT_TOOL'),
value: 'edit',

View File

@ -0,0 +1,141 @@
<script setup>
import { ref, computed } from 'vue';
import { useAlert } from 'dashboard/composables';
import { useI18n } from 'vue-i18n';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Label from 'dashboard/components-next/input/Label.vue';
import Spinner from 'shared/components/Spinner.vue';
import CaptainCustomToolsAPI from 'dashboard/api/captain/customTools';
const props = defineProps({
tool: {
type: Object,
required: true,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const dialogRef = ref(null);
const isLoading = ref(false);
const testResult = ref(null);
const testParams = ref({});
// Initialize params based on schema
if (props.tool.param_schema) {
props.tool.param_schema.forEach(param => {
testParams.value[param.name] = '';
});
}
const hasParams = computed(() => props.tool.param_schema && props.tool.param_schema.length > 0);
const handleClose = () => {
emit('close');
};
const runTest = async () => {
isLoading.value = true;
testResult.value = null;
try {
const { data } = await CaptainCustomToolsAPI.test(props.tool.id, testParams.value);
testResult.value = data;
} catch (error) {
useAlert(t('CAPTAIN.CUSTOM_TOOLS.TEST.ERROR_MESSAGE'));
console.error(error);
} finally {
isLoading.value = false;
}
};
defineExpose({ dialogRef });
</script>
<template>
<Dialog
ref="dialogRef"
width="3xl"
:title="$t('CAPTAIN.CUSTOM_TOOLS.TEST.TITLE', { name: tool.title })"
:show-cancel-button="false"
:show-confirm-button="false"
@close="handleClose"
>
<div class="flex flex-col gap-6">
<!-- Params Section -->
<div v-if="hasParams" class="flex flex-col gap-4 p-4 border rounded-lg border-n-slate-3 bg-n-slate-1">
<h4 class="text-sm font-medium text-n-slate-12">
{{ $t('CAPTAIN.CUSTOM_TOOLS.TEST.PARAMETERS') }}
</h4>
<div class="grid grid-cols-2 gap-4">
<div v-for="param in tool.param_schema" :key="param.name" class="flex flex-col gap-1">
<Label :label="param.name" :required="param.required" />
<Input
v-model="testParams[param.name]"
:placeholder="param.description"
type="text"
/>
</div>
</div>
</div>
<!-- Action Button -->
<div class="flex justify-end">
<Button
:label="isLoading ? $t('CAPTAIN.CUSTOM_TOOLS.TEST.TESTING') : $t('CAPTAIN.CUSTOM_TOOLS.TEST.RUN_TEST')"
:icon="isLoading ? '' : 'i-lucide-play'"
color="blue"
:disabled="isLoading"
@click="runTest"
>
<template v-if="isLoading" #prefix>
<Spinner size="sm" class="text-white" />
</template>
</Button>
</div>
<!-- Results Section -->
<div v-if="testResult" class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<span
class="px-2 py-0.5 text-xs font-medium rounded"
:class="
testResult.success
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'
"
>
{{ testResult.status }} {{ testResult.success ? 'OK' : 'Error' }}
</span>
<span class="text-xs text-n-slate-11">
{{ $t('CAPTAIN.CUSTOM_TOOLS.TEST.RESPONSE_TIME', { ms: 'N/A' }) }}
</span>
</div>
<!-- Headers -->
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-n-slate-11">
{{ $t('CAPTAIN.CUSTOM_TOOLS.TEST.RESPONSE_HEADERS') }}
</span>
<pre
class="p-3 overflow-x-auto text-xs rounded bg-n-slate-2 text-n-slate-11 font-mono max-h-32"
>{{ JSON.stringify(testResult.headers, null, 2) }}</pre>
</div>
<!-- Body -->
<div class="flex flex-col gap-1">
<span class="text-xs font-medium text-n-slate-11">
{{ $t('CAPTAIN.CUSTOM_TOOLS.TEST.RESPONSE_BODY') }}
</span>
<pre
class="p-3 overflow-x-auto text-xs rounded bg-n-slate-2 text-n-slate-12 font-mono max-h-96"
>{{ testResult.body }}</pre>
</div>
</div>
</div>
<template #footer />
</Dialog>
</template>

View File

@ -5,6 +5,7 @@ import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import CustomToolsPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/CustomToolsPageEmptyState.vue';
import CreateCustomToolDialog from 'dashboard/components-next/captain/pageComponents/customTool/CreateCustomToolDialog.vue';
import ToolTestDialog from 'dashboard/components-next/captain/pageComponents/customTool/ToolTestDialog.vue';
import CustomToolCard from 'dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
@ -17,6 +18,7 @@ const customToolsMeta = useMapGetter('captainCustomTools/getMeta');
const createDialogRef = ref(null);
const deleteDialogRef = ref(null);
const toolTestDialogRef = ref(null);
const selectedTool = ref(null);
const dialogType = ref('');
@ -38,6 +40,12 @@ const handleEdit = tool => {
nextTick(() => createDialogRef.value.dialogRef.open());
};
const handleTest = tool => {
dialogType.value = 'test';
selectedTool.value = tool;
nextTick(() => toolTestDialogRef.value.dialogRef.open());
};
const handleDelete = tool => {
selectedTool.value = tool;
nextTick(() => deleteDialogRef.value.dialogRef.open());
@ -49,6 +57,8 @@ const handleAction = ({ action, id }) => {
handleEdit(tool);
} else if (action === 'delete') {
handleDelete(tool);
} else if (action === 'test') {
handleTest(tool);
}
};
@ -117,13 +127,20 @@ onMounted(() => {
</PageLayout>
<CreateCustomToolDialog
v-if="dialogType"
v-if="['create', 'edit'].includes(dialogType)"
ref="createDialogRef"
:type="dialogType"
:selected-tool="selectedTool"
@close="handleDialogClose"
/>
<ToolTestDialog
v-if="dialogType === 'test'"
ref="toolTestDialogRef"
:tool="selectedTool"
@close="handleDialogClose"
/>
<DeleteDialog
v-if="selectedTool"
ref="deleteDialogRef"

View File

@ -71,7 +71,9 @@ Rails.application.routes.draw do
resources :copilot_threads, only: [:index, :create] do
resources :copilot_messages, only: [:index, :create]
end
resources :custom_tools
resources :custom_tools do
post :test, on: :member
end
resources :documents, only: [:index, :show, :create, :destroy]
end
# Jasmine AI Routes (SDR Agent)

View File

@ -0,0 +1,56 @@
# frozen_string_literal: true
class FixStatusSuitesHeaders < ActiveRecord::Migration[7.1]
def up
# Find all Custom Tools that look like 'status Suites' or use the specific endpoint
tools = Captain::CustomTool.where('endpoint_url ILIKE ? OR title ILIKE ?', '%/api/PlugPlay/api/SuitesStatus%', '%Status Suites%')
tools.each do |tool|
puts "Processing tool: #{tool.title} (ID: #{tool.id})"
updated = false
new_auth_config = tool.auth_config || {}
new_auth_config['headers'] ||= {}
# Try to extract from Endpoint URL if they were query params
begin
uri = URI.parse(tool.endpoint_url)
query_params = URI.decode_www_form(uri.query || '').to_h
%w[PLUG-PLAY-ID PLUG-PLAY-TOKEN].each do |header_key|
next unless query_params.key?(header_key)
puts " Found #{header_key} in URL query params. Moving to headers."
new_auth_config['headers'][header_key] = query_params[header_key]
query_params.delete(header_key)
updated = true
end
if updated
# Rebuild URL without these params
uri.query = URI.encode_www_form(query_params).presence
tool.endpoint_url = uri.to_s
tool.auth_config = new_auth_config
# Remove from param_schema if present
if tool.param_schema.is_a?(Array)
original_size = tool.param_schema.size
tool.param_schema.reject! { |p| %w[PLUG-PLAY-ID PLUG-PLAY-TOKEN].include?(p['name']) }
puts ' Removed params from param_schema.' if tool.param_schema.size < original_size
end
tool.save!
puts ' Tool updated successfully.'
else
puts ' No keys found in URL query params. Manual update might be required for values.'
end
rescue URI::InvalidURIError => e
puts " Skipping invalid URI: #{tool.endpoint_url}"
end
end
end
def down
# Irreversible automatically without data loss risk
end
end

View File

@ -1,7 +1,7 @@
class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::BaseController
before_action :current_account
before_action -> { check_authorization(Captain::CustomTool) }
before_action :set_custom_tool, only: [:show, :update, :destroy]
before_action :set_custom_tool, only: [:show, :update, :destroy, :test]
def index
@custom_tools = account_custom_tools.enabled
@ -22,6 +22,14 @@ class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::Bas
head :no_content
end
def test
raise Pundit::NotAuthorizedError unless Current.user.administrator?
tool_instance = @custom_tool.tool(nil)
result = tool_instance.test_perform(nil, **params.fetch(:tool_params, {}).permit!)
render json: result
end
private
def set_custom_tool

View File

@ -44,20 +44,28 @@ module Concerns::Toolable
end
def build_auth_headers
return {} if auth_none?
headers = {}
# 1. Base Auth Headers (if any)
case auth_type
when 'bearer'
{ 'Authorization' => "Bearer #{auth_config['token']}" }
headers['Authorization'] = "Bearer #{auth_config['token']}"
when 'api_key'
if auth_config['location'] == 'header'
{ auth_config['name'] => auth_config['key'] }
else
{}
end
else
{}
headers[auth_config['name']] = auth_config['key'] if auth_config['location'] == 'header'
end
# 2. Custom Mixin Headers (from auth_config['headers'])
# Priority: Custom headers overwrite generated headers if conflict (though rare)
# Normalization: We trust the keys as provided or we could downcase them,
# but Net::HTTP handles headers case-insensitively usually.
# To avoid duplicates like 'Authorization' and 'authorization', we could normalize keys.
if auth_config['headers'].is_a?(Hash)
auth_config['headers'].each do |key, value|
headers[key] = value
end
end
headers
end
def build_basic_auth_credentials

View File

@ -22,8 +22,41 @@ class Captain::Tools::HttpTool < Agents::Tool
'An error occurred while executing the request'
end
def test_perform(tool_context, **params)
url = @custom_tool.build_request_url(params)
body = @custom_tool.build_request_body(params)
# Execute request
response = execute_http_request(url, body, tool_context)
# Return structured data for test UI
{
success: response.is_a?(Net::HTTPSuccess),
status: response.code.to_i,
headers: mask_sensitive_headers(response.each_header.to_h),
body: response.body # Frontend should truncated if too large
}
rescue StandardError => e
{
success: false,
error: e.message,
status: 500
}
end
private
def mask_sensitive_headers(headers)
sensitive_keys = %w[authorization plug-play-token plug-play-id x-api-key]
headers.each_with_object({}) do |(k, v), h|
h[k] = if sensitive_keys.include?(k.downcase)
'********'
else
v
end
end
end
PRIVATE_IP_RANGES = [
IPAddr.new('127.0.0.0/8'), # IPv4 Loopback
IPAddr.new('10.0.0.0/8'), # IPv4 Private network
@ -55,8 +88,15 @@ class Captain::Tools::HttpTool < Agents::Tool
apply_authentication(request)
apply_metadata_headers(request, tool_context)
Rails.logger.info "[HttpTool] Requesting #{request.method} #{uri}"
Rails.logger.info "[HttpTool] Headers: #{request.each_header.to_h}"
Rails.logger.info "[HttpTool] Body: #{request.body}" if request.body
response = http.request(request)
Rails.logger.info "[HttpTool] Response Status: #{response.code}"
Rails.logger.info "[HttpTool] Response Body: #{response.body}"
raise "HTTP request failed with status #{response.code}" unless response.is_a?(Net::HTTPSuccess)
validate_response!(response)