feat: fix custom tool headers auth and add test endpoint
This commit is contained in:
parent
4141980f72
commit
3c3ba175c5
@ -31,6 +31,12 @@ class CaptainCustomTools extends ApiClient {
|
|||||||
delete(id) {
|
delete(id) {
|
||||||
return axios.delete(`${this.url}/${id}`);
|
return axios.delete(`${this.url}/${id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test(id, data = {}) {
|
||||||
|
return axios.post(`${this.url}/${id}/test`, {
|
||||||
|
tool_params: data,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default new CaptainCustomTools();
|
export default new CaptainCustomTools();
|
||||||
|
|||||||
@ -43,6 +43,12 @@ const { t } = useI18n();
|
|||||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||||
|
|
||||||
const menuItems = computed(() => [
|
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'),
|
label: t('CAPTAIN.CUSTOM_TOOLS.OPTIONS.EDIT_TOOL'),
|
||||||
value: 'edit',
|
value: 'edit',
|
||||||
|
|||||||
@ -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>
|
||||||
@ -5,6 +5,7 @@ import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
|||||||
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
|
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
|
||||||
import CustomToolsPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/CustomToolsPageEmptyState.vue';
|
import CustomToolsPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/CustomToolsPageEmptyState.vue';
|
||||||
import CreateCustomToolDialog from 'dashboard/components-next/captain/pageComponents/customTool/CreateCustomToolDialog.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 CustomToolCard from 'dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue';
|
||||||
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.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 createDialogRef = ref(null);
|
||||||
const deleteDialogRef = ref(null);
|
const deleteDialogRef = ref(null);
|
||||||
|
const toolTestDialogRef = ref(null);
|
||||||
const selectedTool = ref(null);
|
const selectedTool = ref(null);
|
||||||
const dialogType = ref('');
|
const dialogType = ref('');
|
||||||
|
|
||||||
@ -38,6 +40,12 @@ const handleEdit = tool => {
|
|||||||
nextTick(() => createDialogRef.value.dialogRef.open());
|
nextTick(() => createDialogRef.value.dialogRef.open());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleTest = tool => {
|
||||||
|
dialogType.value = 'test';
|
||||||
|
selectedTool.value = tool;
|
||||||
|
nextTick(() => toolTestDialogRef.value.dialogRef.open());
|
||||||
|
};
|
||||||
|
|
||||||
const handleDelete = tool => {
|
const handleDelete = tool => {
|
||||||
selectedTool.value = tool;
|
selectedTool.value = tool;
|
||||||
nextTick(() => deleteDialogRef.value.dialogRef.open());
|
nextTick(() => deleteDialogRef.value.dialogRef.open());
|
||||||
@ -49,6 +57,8 @@ const handleAction = ({ action, id }) => {
|
|||||||
handleEdit(tool);
|
handleEdit(tool);
|
||||||
} else if (action === 'delete') {
|
} else if (action === 'delete') {
|
||||||
handleDelete(tool);
|
handleDelete(tool);
|
||||||
|
} else if (action === 'test') {
|
||||||
|
handleTest(tool);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -117,13 +127,20 @@ onMounted(() => {
|
|||||||
</PageLayout>
|
</PageLayout>
|
||||||
|
|
||||||
<CreateCustomToolDialog
|
<CreateCustomToolDialog
|
||||||
v-if="dialogType"
|
v-if="['create', 'edit'].includes(dialogType)"
|
||||||
ref="createDialogRef"
|
ref="createDialogRef"
|
||||||
:type="dialogType"
|
:type="dialogType"
|
||||||
:selected-tool="selectedTool"
|
:selected-tool="selectedTool"
|
||||||
@close="handleDialogClose"
|
@close="handleDialogClose"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ToolTestDialog
|
||||||
|
v-if="dialogType === 'test'"
|
||||||
|
ref="toolTestDialogRef"
|
||||||
|
:tool="selectedTool"
|
||||||
|
@close="handleDialogClose"
|
||||||
|
/>
|
||||||
|
|
||||||
<DeleteDialog
|
<DeleteDialog
|
||||||
v-if="selectedTool"
|
v-if="selectedTool"
|
||||||
ref="deleteDialogRef"
|
ref="deleteDialogRef"
|
||||||
|
|||||||
@ -71,7 +71,9 @@ Rails.application.routes.draw do
|
|||||||
resources :copilot_threads, only: [:index, :create] do
|
resources :copilot_threads, only: [:index, :create] do
|
||||||
resources :copilot_messages, only: [:index, :create]
|
resources :copilot_messages, only: [:index, :create]
|
||||||
end
|
end
|
||||||
resources :custom_tools
|
resources :custom_tools do
|
||||||
|
post :test, on: :member
|
||||||
|
end
|
||||||
resources :documents, only: [:index, :show, :create, :destroy]
|
resources :documents, only: [:index, :show, :create, :destroy]
|
||||||
end
|
end
|
||||||
# Jasmine AI Routes (SDR Agent)
|
# Jasmine AI Routes (SDR Agent)
|
||||||
|
|||||||
56
db/migrate/20260110193000_fix_status_suites_headers.rb
Normal file
56
db/migrate/20260110193000_fix_status_suites_headers.rb
Normal 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
|
||||||
@ -1,7 +1,7 @@
|
|||||||
class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::BaseController
|
||||||
before_action :current_account
|
before_action :current_account
|
||||||
before_action -> { check_authorization(Captain::CustomTool) }
|
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
|
def index
|
||||||
@custom_tools = account_custom_tools.enabled
|
@custom_tools = account_custom_tools.enabled
|
||||||
@ -22,6 +22,14 @@ class Api::V1::Accounts::Captain::CustomToolsController < Api::V1::Accounts::Bas
|
|||||||
head :no_content
|
head :no_content
|
||||||
end
|
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
|
private
|
||||||
|
|
||||||
def set_custom_tool
|
def set_custom_tool
|
||||||
|
|||||||
@ -44,20 +44,28 @@ module Concerns::Toolable
|
|||||||
end
|
end
|
||||||
|
|
||||||
def build_auth_headers
|
def build_auth_headers
|
||||||
return {} if auth_none?
|
headers = {}
|
||||||
|
|
||||||
|
# 1. Base Auth Headers (if any)
|
||||||
case auth_type
|
case auth_type
|
||||||
when 'bearer'
|
when 'bearer'
|
||||||
{ 'Authorization' => "Bearer #{auth_config['token']}" }
|
headers['Authorization'] = "Bearer #{auth_config['token']}"
|
||||||
when 'api_key'
|
when 'api_key'
|
||||||
if auth_config['location'] == 'header'
|
headers[auth_config['name']] = auth_config['key'] if auth_config['location'] == 'header'
|
||||||
{ auth_config['name'] => auth_config['key'] }
|
|
||||||
else
|
|
||||||
{}
|
|
||||||
end
|
|
||||||
else
|
|
||||||
{}
|
|
||||||
end
|
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
|
end
|
||||||
|
|
||||||
def build_basic_auth_credentials
|
def build_basic_auth_credentials
|
||||||
|
|||||||
@ -22,8 +22,41 @@ class Captain::Tools::HttpTool < Agents::Tool
|
|||||||
'An error occurred while executing the request'
|
'An error occurred while executing the request'
|
||||||
end
|
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
|
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 = [
|
PRIVATE_IP_RANGES = [
|
||||||
IPAddr.new('127.0.0.0/8'), # IPv4 Loopback
|
IPAddr.new('127.0.0.0/8'), # IPv4 Loopback
|
||||||
IPAddr.new('10.0.0.0/8'), # IPv4 Private network
|
IPAddr.new('10.0.0.0/8'), # IPv4 Private network
|
||||||
@ -55,8 +88,15 @@ class Captain::Tools::HttpTool < Agents::Tool
|
|||||||
apply_authentication(request)
|
apply_authentication(request)
|
||||||
apply_metadata_headers(request, tool_context)
|
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)
|
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)
|
raise "HTTP request failed with status #{response.code}" unless response.is_a?(Net::HTTPSuccess)
|
||||||
|
|
||||||
validate_response!(response)
|
validate_response!(response)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user