feat(plugins): add AI tools and consent-gated data access
This commit is contained in:
71 files changed
+4613
-129
No files matched your search
@@ -5,9 +5,8 @@ import { Check, ChevronDown, ChevronRight, Folder, Search } from "@lucide/vue";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
import ConnectionIcon from "@/components/icons/ConnectionIcon.vue";
|
||||||
import { buildConnectionPickerRows, connectionPickerSelectableRows } from "@/lib/connection/connectionPickerTree";
|
import { buildConnectionPickerRows, connectionPickerSelectableRows } from "@/lib/connection/connectionPickerTree";
|
||||||
import { connectionIconType } from "@/lib/connection/connectionPresentation";
|
|
||||||
import { cn } from "@/lib/common/utils";
|
import { cn } from "@/lib/common/utils";
|
||||||
import type { ConnectionConfig, SidebarLayout } from "@/types/database";
|
import type { ConnectionConfig, SidebarLayout } from "@/types/database";
|
||||||
|
|
||||||
@@ -184,7 +183,7 @@ function handleKeydown(event: KeyboardEvent) {
|
|||||||
@click="selectConnection(row.id)"
|
@click="selectConnection(row.id)"
|
||||||
>
|
>
|
||||||
<Check :class="cn('h-3.5 w-3.5 shrink-0', row.id === modelValue ? 'opacity-100' : 'opacity-0')" />
|
<Check :class="cn('h-3.5 w-3.5 shrink-0', row.id === modelValue ? 'opacity-100' : 'opacity-0')" />
|
||||||
<DatabaseIcon :db-type="connectionIconType(connectionById.get(row.id))" class="h-3.5 w-3.5 shrink-0" />
|
<ConnectionIcon :connection="connectionById.get(row.id)" class="h-3.5 w-3.5 shrink-0" />
|
||||||
<span class="min-w-0 flex-1 truncate">{{ row.label }}</span>
|
<span class="min-w-0 flex-1 truncate">{{ row.label }}</span>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Vendored
+16
-2
@@ -26,6 +26,13 @@ vi.mock("@/components/icons/DatabaseIcon.vue", () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/plugins/PluginIcon.vue", () => ({
|
||||||
|
default: defineComponent({
|
||||||
|
props: ["pluginId", "contributionId"],
|
||||||
|
setup: (props) => () => h("span", { "data-plugin-id": props.pluginId, "data-contribution-id": props.contributionId }),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
const mountedApps: App[] = [];
|
const mountedApps: App[] = [];
|
||||||
|
|
||||||
const connections: ConnectionConfig[] = [
|
const connections: ConnectionConfig[] = [
|
||||||
@@ -45,12 +52,12 @@ const layout: SidebarLayout = {
|
|||||||
order: [{ type: "group", id: "g1", children: [{ type: "connection", id: "c1" }] }],
|
order: [{ type: "group", id: "g1", children: [{ type: "connection", id: "c1" }] }],
|
||||||
};
|
};
|
||||||
|
|
||||||
async function mountPicker() {
|
async function mountPicker(extraConnections: ConnectionConfig[] = []) {
|
||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
document.body.append(container);
|
document.body.append(container);
|
||||||
const app = createApp(ConnectionTreeSelect, {
|
const app = createApp(ConnectionTreeSelect, {
|
||||||
modelValue: "",
|
modelValue: "",
|
||||||
connections,
|
connections: [...connections, ...extraConnections],
|
||||||
layout,
|
layout,
|
||||||
placeholder: "Select connection",
|
placeholder: "Select connection",
|
||||||
searchPlaceholder: "Search connections",
|
searchPlaceholder: "Search connections",
|
||||||
@@ -82,6 +89,13 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("ConnectionTreeSelect search collapse behavior", () => {
|
describe("ConnectionTreeSelect search collapse behavior", () => {
|
||||||
|
it("passes plugin identity and provider logo metadata to the shared icon", async () => {
|
||||||
|
await mountPicker([{ ...connections[0], id: "plugin", name: "Plugin", db_type: "plugin", plugin_id: "sample.plugin", plugin_connection_provider: "sample.connection" }]);
|
||||||
|
const icon = document.body.querySelector('[data-picker-connection="plugin"] [data-plugin-id]');
|
||||||
|
expect(icon?.getAttribute("data-plugin-id")).toBe("sample.plugin");
|
||||||
|
expect(icon?.getAttribute("data-contribution-id")).toBe("sample.connection");
|
||||||
|
});
|
||||||
|
|
||||||
it("ignores group clicks during search without changing the post-search state", async () => {
|
it("ignores group clicks during search without changing the post-search state", async () => {
|
||||||
await mountPicker();
|
await mountPicker();
|
||||||
await setSearchText("Primary");
|
await setSearchText("Primary");
|
||||||
|
|||||||
@@ -66,8 +66,8 @@ import { usePromptTemplateStore } from "@/stores/promptTemplateStore";
|
|||||||
import { useUserSkillStore } from "@/stores/userSkillStore";
|
import { useUserSkillStore } from "@/stores/userSkillStore";
|
||||||
import { buildSelectedSkillChips, capSkillsToCharLimit, removeSkillIds, userSkillSourceOfId } from "@/lib/ai/userSkillSelection";
|
import { buildSelectedSkillChips, capSkillsToCharLimit, removeSkillIds, userSkillSourceOfId } from "@/lib/ai/userSkillSelection";
|
||||||
import { ACTIVE_SKILLS_TOTAL_MAX, type ReadUserSkill, type ReadUserSkillFailure, type UserSkillFailureReason, type UserSkillRootSettings } from "@/types/userSkills";
|
import { ACTIVE_SKILLS_TOTAL_MAX, type ReadUserSkill, type ReadUserSkillFailure, type UserSkillFailureReason, type UserSkillRootSettings } from "@/types/userSkills";
|
||||||
import { connectionIconType } from "@/lib/connection/connectionPresentation";
|
import { supportsAiAssistantContext } from "@/lib/database/databaseFeatureSupport";
|
||||||
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
import ConnectionIcon from "@/components/icons/ConnectionIcon.vue";
|
||||||
import ConnectionTreeSelect from "@/components/connection/ConnectionTreeSelect.vue";
|
import ConnectionTreeSelect from "@/components/connection/ConnectionTreeSelect.vue";
|
||||||
import SearchableSelect from "@/components/ui/searchable-select/SearchableSelect.vue";
|
import SearchableSelect from "@/components/ui/searchable-select/SearchableSelect.vue";
|
||||||
import { useQueryStore } from "@/stores/queryStore";
|
import { useQueryStore } from "@/stores/queryStore";
|
||||||
@@ -154,11 +154,11 @@ import { buildAiAgentPlan } from "@/lib/ai/aiAgentPlan";
|
|||||||
import { extractFirstSqlCodeBlock, extractSingleSqlCodeBlock } from "@/lib/ai/aiSqlExecutionPolicy";
|
import { extractFirstSqlCodeBlock, extractSingleSqlCodeBlock } from "@/lib/ai/aiSqlExecutionPolicy";
|
||||||
import { productionContextForDatabase } from "@/lib/database/productionSafety";
|
import { productionContextForDatabase } from "@/lib/database/productionSafety";
|
||||||
import ProductionContextBadge from "@/components/common/ProductionContextBadge.vue";
|
import ProductionContextBadge from "@/components/common/ProductionContextBadge.vue";
|
||||||
import { buildAiAgentStepItems, formatToolDurationMs, toolCallStepKey, upsertAgentStep, type AiAgentStepItem, type AiAgentStepTone } from "@/lib/ai/aiAgentStepPresentation";
|
import { buildAiAgentStepItems, formatAgentToolName, formatToolDurationMs, toolCallStepKey, updateAgentStepApproval, upsertAgentStep, type AiAgentStepItem, type AiAgentStepTone } from "@/lib/ai/aiAgentStepPresentation";
|
||||||
import { createAiShikiCodeHighlighter, type AiCodeHighlighter } from "@/lib/ai/aiCodeHighlighter";
|
import { createAiShikiCodeHighlighter, type AiCodeHighlighter } from "@/lib/ai/aiCodeHighlighter";
|
||||||
import { createAiMessageRenderer } from "@/lib/ai/aiMessageRender";
|
import { createAiMessageRenderer } from "@/lib/ai/aiMessageRender";
|
||||||
import { formatAiInlineMarkdown, handleAiMarkdownLinkClick } from "@/lib/ai/aiMarkdown";
|
import { formatAiInlineMarkdown, handleAiMarkdownLinkClick } from "@/lib/ai/aiMarkdown";
|
||||||
import { aiStream, aiCancelStream, saveAiConversation, saveAiRun, saveAiRunState, loadAiConversations, loadAiRuns, deleteAiConversation, listSchemas, listTables, readUserSkills, type AiConversation, type AiRun, type AiRunStatus } from "@/lib/backend/api";
|
import { aiStream, aiCancelStream, resolveAiToolApproval, saveAiConversation, saveAiRun, saveAiRunState, loadAiConversations, loadAiRuns, deleteAiConversation, listSchemas, listTables, readUserSkills, type AiConversation, type AiRun, type AiRunStatus } from "@/lib/backend/api";
|
||||||
import type { AiMessage } from "@/lib/backend/api";
|
import type { AiMessage } from "@/lib/backend/api";
|
||||||
import type { AiConfigItem, AiEffortCapability, AiEffortOption, AiEffortSelection } from "@/types/ai";
|
import type { AiConfigItem, AiEffortCapability, AiEffortOption, AiEffortSelection } from "@/types/ai";
|
||||||
import type { ConnectionConfig, QueryTab, SavedSqlFile, TableInfo } from "@/types/database";
|
import type { ConnectionConfig, QueryTab, SavedSqlFile, TableInfo } from "@/types/database";
|
||||||
@@ -184,6 +184,7 @@ import { buildAiAnalysisExport } from "@/lib/export/aiAnalysisExport";
|
|||||||
import { buildAiConversationExport, type AiConversationExportFormat } from "@/lib/export/aiConversationExport";
|
import { buildAiConversationExport, type AiConversationExportFormat } from "@/lib/export/aiConversationExport";
|
||||||
import { buildAiConversationSearchIndex, filterAiConversationSearchIndex } from "@/lib/ai/aiConversationSearch";
|
import { buildAiConversationSearchIndex, filterAiConversationSearchIndex } from "@/lib/ai/aiConversationSearch";
|
||||||
import AiAttachmentCard from "@/components/editor/AiAttachmentCard.vue";
|
import AiAttachmentCard from "@/components/editor/AiAttachmentCard.vue";
|
||||||
|
import AiToolApprovalCard from "@/components/editor/AiToolApprovalCard.vue";
|
||||||
import { resolveAiMessageCopyText } from "@/lib/ai/aiMessageCopy";
|
import { resolveAiMessageCopyText } from "@/lib/ai/aiMessageCopy";
|
||||||
import { buildPluginAiRequest, pluginContextFromMessages, pluginContextText, streamPluginAiConversation, type AiPluginContext, type AiPluginConversationRequest } from "@/lib/ai/aiPluginConversation";
|
import { buildPluginAiRequest, pluginContextFromMessages, pluginContextText, streamPluginAiConversation, type AiPluginContext, type AiPluginConversationRequest } from "@/lib/ai/aiPluginConversation";
|
||||||
|
|
||||||
@@ -1780,9 +1781,11 @@ watch(
|
|||||||
);
|
);
|
||||||
watch([dbSelectOptions, selectedNamespace], syncSelectedDatabases, { immediate: true });
|
watch([dbSelectOptions, selectedNamespace], syncSelectedDatabases, { immediate: true });
|
||||||
|
|
||||||
|
const showAiDatabaseSelector = computed(() => !!boundConnection.value && supportsAiAssistantContext(boundConnection.value.db_type));
|
||||||
|
|
||||||
const showAiSchemaSelector = computed(() => {
|
const showAiSchemaSelector = computed(() => {
|
||||||
const connection = boundConnection.value;
|
const connection = boundConnection.value;
|
||||||
return !!connection && connection.db_type !== "dameng" && aiSchemaSelectionSupported(connection);
|
return showAiDatabaseSelector.value && !!connection && connection.db_type !== "dameng" && aiSchemaSelectionSupported(connection);
|
||||||
});
|
});
|
||||||
|
|
||||||
const aiSchemaDatabaseKey = computed(() => {
|
const aiSchemaDatabaseKey = computed(() => {
|
||||||
@@ -1804,7 +1807,7 @@ async function loadAiSchemas() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadDatabases(connection = boundConnection.value): Promise<string[]> {
|
async function loadDatabases(connection = boundConnection.value): Promise<string[]> {
|
||||||
if (!connection) return [];
|
if (!connection || !supportsAiAssistantContext(connection.db_type)) return [];
|
||||||
if (connection.db_type !== "dameng") {
|
if (connection.db_type !== "dameng") {
|
||||||
await loadDatabaseOptions(connection.id);
|
await loadDatabaseOptions(connection.id);
|
||||||
return databaseOptions.value[connection.id] || [];
|
return databaseOptions.value[connection.id] || [];
|
||||||
@@ -2103,6 +2106,21 @@ function parseExplainFromData(explainData: unknown, dbType: string): ParsedExpla
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Sends the user's answer for a pending plugin tool approval back to the run that asked. */
|
||||||
|
async function resolveToolApproval(step: AiAgentStepItem, approved: boolean) {
|
||||||
|
const approval = step.approval;
|
||||||
|
if (!approval || approval.status !== "pending") return;
|
||||||
|
approval.status = "submitting";
|
||||||
|
try {
|
||||||
|
// The final status arrives with `tool_approval_resolved`; `false` means
|
||||||
|
// the run stopped waiting (timed out, cancelled, or app restarted).
|
||||||
|
if (!(await resolveAiToolApproval(approval.sessionId, approval.approvalId, approved))) approval.status = "expired";
|
||||||
|
} catch (error) {
|
||||||
|
approval.status = "pending";
|
||||||
|
toast(t("ai.toolApproval.answerFailed", { message: error instanceof Error ? error.message : String(error) }), 5000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function agentEventToStep(event: AgentEvent, index: number, now: number): AiAgentStepItem | undefined {
|
function agentEventToStep(event: AgentEvent, index: number, now: number): AiAgentStepItem | undefined {
|
||||||
if (event.type === "context_compacted") {
|
if (event.type === "context_compacted") {
|
||||||
return {
|
return {
|
||||||
@@ -2343,7 +2361,7 @@ function mentionTargetDatabase(): string {
|
|||||||
async function loadMentionCandidates(query: string) {
|
async function loadMentionCandidates(query: string) {
|
||||||
const connection = boundConnection.value;
|
const connection = boundConnection.value;
|
||||||
const mentionDatabase = mentionTargetDatabase();
|
const mentionDatabase = mentionTargetDatabase();
|
||||||
if (pluginContext.value || !connection || !boundConnectionId.value || !mentionDatabase) return;
|
if (pluginContext.value || !connection || !supportsAiAssistantContext(connection.db_type) || !boundConnectionId.value || !mentionDatabase) return;
|
||||||
|
|
||||||
const key = mentionCacheKey(boundConnectionId.value, mentionDatabase, query);
|
const key = mentionCacheKey(boundConnectionId.value, mentionDatabase, query);
|
||||||
if (mentionCache.value[key]) {
|
if (mentionCache.value[key]) {
|
||||||
@@ -3693,6 +3711,28 @@ async function send() {
|
|||||||
if (step) upsertAgentStep(msg.agentSteps, step);
|
if (step) upsertAgentStep(msg.agentSteps, step);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A plugin tool call waits for (or got) the user's approval: keep the
|
||||||
|
// state on the tool card so the answer buttons live next to the call.
|
||||||
|
if (event.type === "tool_approval_required" || event.type === "tool_approval_resolved") {
|
||||||
|
const msg = runMessages[assistantIdx];
|
||||||
|
if (msg?.agentSteps) {
|
||||||
|
const key = toolCallStepKey(event.tool_call_id, agentEvents.length - 1, "tool_call_start");
|
||||||
|
if (event.type === "tool_approval_required") {
|
||||||
|
updateAgentStepApproval(msg.agentSteps, key, () => ({
|
||||||
|
approvalId: event.approval_id,
|
||||||
|
sessionId,
|
||||||
|
pluginName: event.plugin_name,
|
||||||
|
pluginTool: event.plugin_tool,
|
||||||
|
connectionName: event.connection_name,
|
||||||
|
args: event.args ?? {},
|
||||||
|
expiresAtMs: Date.now() + event.timeout_secs * 1000,
|
||||||
|
status: "pending",
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
updateAgentStepApproval(msg.agentSteps, key, (approval) => (approval && approval.approvalId === event.approval_id ? { ...approval, status: event.outcome } : approval));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if (runIsVisible()) scrollToBottom();
|
if (runIsVisible()) scrollToBottom();
|
||||||
};
|
};
|
||||||
if (runPluginContext) {
|
if (runPluginContext) {
|
||||||
@@ -5530,16 +5570,17 @@ async function openExternalUrl(url: string) {
|
|||||||
<Loader2 v-if="step.tone === 'active' && step.toolName" class="h-3 w-3 shrink-0 animate-spin" />
|
<Loader2 v-if="step.tone === 'active' && step.toolName" class="h-3 w-3 shrink-0 animate-spin" />
|
||||||
<component :is="agentStepIcon(step.tone)" v-else class="h-3 w-3 shrink-0" />
|
<component :is="agentStepIcon(step.tone)" v-else class="h-3 w-3 shrink-0" />
|
||||||
<span class="font-medium">{{ t(step.labelKey) }}</span>
|
<span class="font-medium">{{ t(step.labelKey) }}</span>
|
||||||
<span v-if="step.toolName" class="text-muted-foreground">: {{ step.toolName }}</span>
|
<span v-if="step.toolName" class="text-muted-foreground">: {{ formatAgentToolName(step.toolName) }}</span>
|
||||||
<template v-if="step.tone === 'active' && step.toolName">
|
<template v-if="step.tone === 'active' && step.toolName">
|
||||||
<span class="ml-auto flex shrink-0 items-center gap-1">
|
<span class="ml-auto flex shrink-0 items-center gap-1">
|
||||||
<Loader2 class="h-3 w-3 animate-spin" />
|
<Loader2 class="h-3 w-3 animate-spin" />
|
||||||
<span>{{ t("ai.agentSteps.executing") }}</span>
|
<span>{{ step.approval?.status === "pending" || step.approval?.status === "submitting" ? t("ai.toolApproval.waiting") : t("ai.agentSteps.executing") }}</span>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
<span v-else-if="step.durationMs !== undefined" class="ml-auto shrink-0 tabular-nums" :class="step.tone === 'danger' ? 'text-red-600 dark:text-red-400' : 'text-chart-2'">{{ formatToolDurationMs(step.durationMs) }}</span>
|
<span v-else-if="step.durationMs !== undefined" class="ml-auto shrink-0 tabular-nums" :class="step.tone === 'danger' ? 'text-red-600 dark:text-red-400' : 'text-chart-2'">{{ formatToolDurationMs(step.durationMs) }}</span>
|
||||||
<ChevronRight v-if="step.toolResult || step.toolArgs?.sql" class="h-3 w-3 shrink-0 transition-transform duration-150" :class="[{ 'rotate-90': expandedSteps.has(step.key) }, !agentStepHasTail(step) ? 'ml-auto' : '']" />
|
<ChevronRight v-if="step.toolResult || step.toolArgs?.sql" class="h-3 w-3 shrink-0 transition-transform duration-150" :class="[{ 'rotate-90': expandedSteps.has(step.key) }, !agentStepHasTail(step) ? 'ml-auto' : '']" />
|
||||||
</button>
|
</button>
|
||||||
|
<AiToolApprovalCard v-if="step.approval" :approval="step.approval" @resolve="resolveToolApproval(step, $event)" />
|
||||||
<div v-if="expandedSteps.has(step.key)" class="border-t border-current/10 px-2 pb-2 pt-1">
|
<div v-if="expandedSteps.has(step.key)" class="border-t border-current/10 px-2 pb-2 pt-1">
|
||||||
<div v-if="step.toolArgs?.sql" class="mb-1 rounded bg-background/50 px-2 py-1 font-mono text-[10px] text-foreground/80 whitespace-pre-wrap">{{ step.toolArgs.sql }}</div>
|
<div v-if="step.toolArgs?.sql" class="mb-1 rounded bg-background/50 px-2 py-1 font-mono text-[10px] text-foreground/80 whitespace-pre-wrap">{{ step.toolArgs.sql }}</div>
|
||||||
<Button v-if="step.toolName === 'explain_query' && step.toolArgs?.sql" size="sm" variant="outline" class="mb-1 h-6 gap-1 text-[10px]" @click="emit('openExplainPlan', step.toolArgs.sql as string, conversationBinding)">
|
<Button v-if="step.toolName === 'explain_query' && step.toolArgs?.sql" size="sm" variant="outline" class="mb-1 h-6 gap-1 text-[10px]" @click="emit('openExplainPlan', step.toolArgs.sql as string, conversationBinding)">
|
||||||
@@ -5725,7 +5766,7 @@ async function openExternalUrl(url: string) {
|
|||||||
<pre class="max-h-56 overflow-auto whitespace-pre-wrap break-all p-2 text-[11px]">{{ pluginContextText(pluginContext) }}</pre>
|
<pre class="max-h-56 overflow-auto whitespace-pre-wrap break-all p-2 text-[11px]">{{ pluginContextText(pluginContext) }}</pre>
|
||||||
</details>
|
</details>
|
||||||
<template v-else-if="connectionStore.connections.length">
|
<template v-else-if="connectionStore.connections.length">
|
||||||
<DatabaseIcon v-if="boundConnection" :db-type="connectionIconType(boundConnection)" class="h-3 w-3 shrink-0" />
|
<ConnectionIcon v-if="boundConnection" :connection="boundConnection" class="h-3 w-3 shrink-0" />
|
||||||
<Server v-else class="h-3 w-3 shrink-0" />
|
<Server v-else class="h-3 w-3 shrink-0" />
|
||||||
<ConnectionTreeSelect
|
<ConnectionTreeSelect
|
||||||
:model-value="boundConnectionId"
|
:model-value="boundConnectionId"
|
||||||
@@ -5739,7 +5780,7 @@ async function openExternalUrl(url: string) {
|
|||||||
list-class="w-72 max-w-[calc(100vw-2rem)]"
|
list-class="w-72 max-w-[calc(100vw-2rem)]"
|
||||||
@update:model-value="(v) => changeConnection(v)"
|
@update:model-value="(v) => changeConnection(v)"
|
||||||
/>
|
/>
|
||||||
<template v-if="boundConnection">
|
<template v-if="boundConnection && showAiDatabaseSelector">
|
||||||
<Database class="h-3 w-3 shrink-0 text-foreground/40" />
|
<Database class="h-3 w-3 shrink-0 text-foreground/40" />
|
||||||
<Popover
|
<Popover
|
||||||
@update:open="
|
@update:open="
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { Check, Loader2, ShieldAlert, X } from "@lucide/vue";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import type { AiAgentStepApproval } from "@/lib/ai/aiAgentStepPresentation";
|
||||||
|
|
||||||
|
/** Inline approval for a plugin tool call that may change state. */
|
||||||
|
const props = defineProps<{ approval: AiAgentStepApproval }>();
|
||||||
|
const emit = defineEmits<{ resolve: [approved: boolean] }>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const expired = ref(false);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.approval.status, props.approval.expiresAtMs] as const,
|
||||||
|
([status, expiresAtMs], _previous, onCleanup) => {
|
||||||
|
expired.value = expiresAtMs <= Date.now();
|
||||||
|
if (expired.value || (status !== "pending" && status !== "submitting")) return;
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
expired.value = true;
|
||||||
|
}, expiresAtMs - Date.now());
|
||||||
|
onCleanup(() => clearTimeout(timeout));
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const argumentsText = computed(() => JSON.stringify(props.approval.args ?? {}, null, 2));
|
||||||
|
// A pending card restored from history after its deadline (or an app restart)
|
||||||
|
// has nobody waiting for the answer anymore.
|
||||||
|
const waiting = computed(() => (props.approval.status === "pending" || props.approval.status === "submitting") && !expired.value);
|
||||||
|
const displayStatus = computed(() => (props.approval.status === "pending" || props.approval.status === "submitting" ? "expired" : props.approval.status));
|
||||||
|
const expiresAt = computed(() => new Date(props.approval.expiresAtMs).toLocaleTimeString());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="space-y-1.5 border-t border-current/10 px-2 pb-2 pt-1.5 text-[10px]" data-ai-tool-approval>
|
||||||
|
<div class="flex items-start gap-1.5 text-foreground">
|
||||||
|
<ShieldAlert class="mt-px h-3 w-3 shrink-0 text-amber-600 dark:text-amber-400" />
|
||||||
|
<span class="min-w-0 wrap-anywhere">{{ t("ai.toolApproval.prompt", { plugin: approval.pluginName, tool: approval.pluginTool, connection: approval.connectionName }) }}</span>
|
||||||
|
</div>
|
||||||
|
<pre class="max-h-40 overflow-auto rounded bg-background/60 px-2 py-1 font-mono text-[10px] text-foreground/80 whitespace-pre-wrap wrap-anywhere">{{ argumentsText }}</pre>
|
||||||
|
<div v-if="waiting" class="flex flex-wrap items-center gap-1.5">
|
||||||
|
<Button size="sm" class="h-6 gap-1 px-2 text-[10px]" :disabled="approval.status === 'submitting'" @click="emit('resolve', true)">
|
||||||
|
<Loader2 v-if="approval.status === 'submitting'" class="h-3 w-3 animate-spin" />
|
||||||
|
<Check v-else class="h-3 w-3" />
|
||||||
|
{{ t("ai.toolApproval.approve") }}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="outline" class="h-6 gap-1 px-2 text-[10px]" :disabled="approval.status === 'submitting'" @click="emit('resolve', false)">
|
||||||
|
<X class="h-3 w-3" />
|
||||||
|
{{ t("ai.toolApproval.deny") }}
|
||||||
|
</Button>
|
||||||
|
<span class="text-muted-foreground">{{ t("ai.toolApproval.expiresAt", { time: expiresAt }) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="text-muted-foreground">{{ t(`ai.toolApproval.status.${displayStatus}`) }}</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -12,6 +12,8 @@ import i18n from "@/i18n";
|
|||||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||||
import AiAssistant from "@/components/editor/AiAssistant.vue";
|
import AiAssistant from "@/components/editor/AiAssistant.vue";
|
||||||
import { useSettingsStore } from "@/stores/settingsStore";
|
import { useSettingsStore } from "@/stores/settingsStore";
|
||||||
|
import { useConnectionStore } from "@/stores/connectionStore";
|
||||||
|
import type { ConnectionConfig } from "@/types/database";
|
||||||
|
|
||||||
vi.mock("@/lib/backend/api", async (importOriginal) => {
|
vi.mock("@/lib/backend/api", async (importOriginal) => {
|
||||||
const actual = await importOriginal<Record<string, unknown>>();
|
const actual = await importOriginal<Record<string, unknown>>();
|
||||||
@@ -22,6 +24,7 @@ vi.mock("@/lib/backend/api", async (importOriginal) => {
|
|||||||
loadAiRuns: empty,
|
loadAiRuns: empty,
|
||||||
readUserSkills: empty,
|
readUserSkills: empty,
|
||||||
loadAiConfigs: empty,
|
loadAiConfigs: empty,
|
||||||
|
listPlugins: empty,
|
||||||
loadPromptTemplates: empty,
|
loadPromptTemplates: empty,
|
||||||
getAiGlobalCustomInstructions: () => Promise.resolve(""),
|
getAiGlobalCustomInstructions: () => Promise.resolve(""),
|
||||||
saveAiChatSelection: () => Promise.resolve(),
|
saveAiChatSelection: () => Promise.resolve(),
|
||||||
@@ -35,15 +38,16 @@ afterEach(() => {
|
|||||||
while (cleanups.length) cleanups.pop()?.();
|
while (cleanups.length) cleanups.pop()?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
async function mountPanel(aiConfigLoaded: boolean): Promise<unknown[]> {
|
async function mountPanel(aiConfigLoaded: boolean, connection?: ConnectionConfig) {
|
||||||
const pinia = createPinia();
|
const pinia = createPinia();
|
||||||
const errors: unknown[] = [];
|
const errors: unknown[] = [];
|
||||||
const app = createApp({ render: () => h(TooltipProvider, () => h(AiAssistant)) });
|
const app = createApp({ render: () => h(TooltipProvider, () => h(AiAssistant, { connection })) });
|
||||||
app.use(pinia);
|
app.use(pinia);
|
||||||
app.use(i18n);
|
app.use(i18n);
|
||||||
app.config.errorHandler = (error) => errors.push(error);
|
app.config.errorHandler = (error) => errors.push(error);
|
||||||
app.config.warnHandler = () => {};
|
app.config.warnHandler = () => {};
|
||||||
useSettingsStore(pinia).isAiConfigLoaded = aiConfigLoaded;
|
useSettingsStore(pinia).isAiConfigLoaded = aiConfigLoaded;
|
||||||
|
if (connection) useConnectionStore(pinia).connections = [connection];
|
||||||
const container = document.createElement("div");
|
const container = document.createElement("div");
|
||||||
document.body.append(container);
|
document.body.append(container);
|
||||||
app.mount(container);
|
app.mount(container);
|
||||||
@@ -53,15 +57,32 @@ async function mountPanel(aiConfigLoaded: boolean): Promise<unknown[]> {
|
|||||||
});
|
});
|
||||||
// Let mount-time loads settle so their failures surface here too.
|
// Let mount-time loads settle so their failures surface here too.
|
||||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||||
return errors;
|
return { errors, container };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("AiAssistant mount", () => {
|
describe("AiAssistant mount", () => {
|
||||||
it("opens when the AI config was loaded before the panel mounted", async () => {
|
it("opens when the AI config was loaded before the panel mounted", async () => {
|
||||||
expect((await mountPanel(true)).map(String)).toEqual([]);
|
expect((await mountPanel(true)).errors.map(String)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("opens while the AI config is still loading", async () => {
|
it("opens while the AI config is still loading", async () => {
|
||||||
expect((await mountPanel(false)).map(String)).toEqual([]);
|
expect((await mountPanel(false)).errors.map(String)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["plugin", "etcd"] as const)("hides database and schema selectors for %s connections", async (dbType) => {
|
||||||
|
const { errors, container } = await mountPanel(true, { id: "connection", name: "Connection", db_type: dbType, plugin_id: dbType === "plugin" ? "sample.plugin" : undefined, host: "localhost", port: 22, username: "", password: "" });
|
||||||
|
expect(errors.map(String)).toEqual([]);
|
||||||
|
const row = container.querySelector("[data-ai-composer-context-row]");
|
||||||
|
expect(row?.textContent).toContain("Connection");
|
||||||
|
expect(row?.textContent).not.toContain(i18n.global.t("editor.selectDatabase"));
|
||||||
|
expect(row?.classList.contains("ai-prompt-context-row--schema")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps database and schema selectors for PostgreSQL", async () => {
|
||||||
|
const { errors, container } = await mountPanel(true, { id: "postgres", name: "PostgreSQL", db_type: "postgres", host: "localhost", port: 5432, username: "", password: "" });
|
||||||
|
expect(errors.map(String)).toEqual([]);
|
||||||
|
const row = container.querySelector("[data-ai-composer-context-row]");
|
||||||
|
expect(row?.textContent).toContain(i18n.global.t("editor.selectDatabase"));
|
||||||
|
expect(row?.classList.contains("ai-prompt-context-row--schema")).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { createApp, nextTick, reactive } from "vue";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import i18n from "@/i18n";
|
||||||
|
import AiToolApprovalCard from "../AiToolApprovalCard.vue";
|
||||||
|
import type { AiAgentStepApproval } from "@/lib/ai/aiAgentStepPresentation";
|
||||||
|
|
||||||
|
const cleanups: Array<() => void> = [];
|
||||||
|
|
||||||
|
function mountCard() {
|
||||||
|
const approval = reactive<AiAgentStepApproval>({ approvalId: "approval", sessionId: "session", pluginName: "Plugin", pluginTool: "write", connectionName: "Connection", args: { target: "orders" }, expiresAtMs: Date.now() + 1000, status: "pending" });
|
||||||
|
const onResolve = vi.fn();
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.append(container);
|
||||||
|
const app = createApp(AiToolApprovalCard, { approval, onResolve });
|
||||||
|
app.use(i18n);
|
||||||
|
app.mount(container);
|
||||||
|
cleanups.push(() => {
|
||||||
|
app.unmount();
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
return { approval, onResolve, container };
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (cleanups.length) cleanups.pop()?.();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("AiToolApprovalCard", () => {
|
||||||
|
it("expires without another backend event and removes its answer buttons", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const { container, onResolve } = mountCard();
|
||||||
|
expect(container.querySelectorAll("button")).toHaveLength(2);
|
||||||
|
await vi.advanceTimersByTimeAsync(1001);
|
||||||
|
await nextTick();
|
||||||
|
expect(container.querySelectorAll("button")).toHaveLength(0);
|
||||||
|
expect(container.textContent).toContain(i18n.global.t("ai.toolApproval.status.expired"));
|
||||||
|
expect(onResolve).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards one-time approval and disables both buttons while submitting", async () => {
|
||||||
|
const { container, approval, onResolve } = mountCard();
|
||||||
|
container.querySelector("button")?.click();
|
||||||
|
expect(onResolve).toHaveBeenCalledWith(true);
|
||||||
|
approval.status = "submitting";
|
||||||
|
await nextTick();
|
||||||
|
expect([...container.querySelectorAll("button")].every((button) => button.disabled)).toBe(true);
|
||||||
|
approval.status = "denied";
|
||||||
|
await nextTick();
|
||||||
|
expect(container.querySelectorAll("button")).toHaveLength(0);
|
||||||
|
expect(container.textContent).toContain(i18n.global.t("ai.toolApproval.status.denied"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { createApp, nextTick } from "vue";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import ConnectionIcon from "./ConnectionIcon.vue";
|
||||||
|
import { clearPluginIconCache } from "@/lib/plugins/pluginIconResolver";
|
||||||
|
import type { ConnectionConfig } from "@/types/database";
|
||||||
|
|
||||||
|
const { listPlugins, readPluginAsset } = vi.hoisted(() => ({ listPlugins: vi.fn(), readPluginAsset: vi.fn() }));
|
||||||
|
vi.mock("@/lib/backend/api", () => ({ listPlugins, readPluginAsset }));
|
||||||
|
|
||||||
|
const cleanups: Array<() => void> = [];
|
||||||
|
const connection: ConnectionConfig = { id: "c1", name: "Connection", db_type: "mysql", host: "localhost", port: 3306, username: "", password: "" };
|
||||||
|
|
||||||
|
async function mountIcon(config?: ConnectionConfig) {
|
||||||
|
const container = document.createElement("div");
|
||||||
|
document.body.append(container);
|
||||||
|
const app = createApp(ConnectionIcon, { connection: config, class: "h-3 w-3" });
|
||||||
|
app.mount(container);
|
||||||
|
cleanups.push(() => {
|
||||||
|
app.unmount();
|
||||||
|
container.remove();
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||||
|
await nextTick();
|
||||||
|
return container;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
clearPluginIconCache();
|
||||||
|
listPlugins.mockResolvedValue([
|
||||||
|
{
|
||||||
|
manifest: {
|
||||||
|
id: "sample.plugin",
|
||||||
|
icon: "assets/plugin.svg",
|
||||||
|
contributions: [{ type: "connection-provider", id: "sample.connection", icon: "assets/connection.svg" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
readPluginAsset.mockResolvedValue({ contentType: "image/svg+xml", dataBase64: "PHN2Zy8+" });
|
||||||
|
vi.stubGlobal("URL", { ...URL, createObjectURL: () => "blob:connection-icon", revokeObjectURL: vi.fn() });
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
while (cleanups.length) cleanups.pop()?.();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ConnectionIcon", () => {
|
||||||
|
it("uses the plugin connection-provider logo and preserves sizing", async () => {
|
||||||
|
const container = await mountIcon({ ...connection, db_type: "plugin", plugin_id: "sample.plugin", plugin_connection_provider: "sample.connection" });
|
||||||
|
expect(readPluginAsset).toHaveBeenCalledWith("sample.plugin", "assets/connection.svg");
|
||||||
|
expect(container.querySelector("img")?.getAttribute("src")).toBe("blob:connection-icon");
|
||||||
|
expect(container.firstElementChild?.classList.contains("h-3")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the plugin logo without a provider logo", async () => {
|
||||||
|
await mountIcon({ ...connection, db_type: "plugin", plugin_id: "sample.plugin" });
|
||||||
|
expect(readPluginAsset).toHaveBeenCalledWith("sample.plugin", "assets/plugin.svg");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps native database logos unchanged", async () => {
|
||||||
|
const container = await mountIcon(connection);
|
||||||
|
expect(container.querySelector("img")?.getAttribute("src")).toBe("/icons/database/mysql.svg");
|
||||||
|
expect(listPlugins).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a safe fallback for missing connection metadata", async () => {
|
||||||
|
const container = await mountIcon();
|
||||||
|
expect(container.querySelector("img")?.getAttribute("src")).toBe("/icons/database/postgres.svg");
|
||||||
|
expect(listPlugins).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
|
||||||
|
import PluginIcon from "@/components/plugins/PluginIcon.vue";
|
||||||
|
import { connectionIconType } from "@/lib/connection/connectionPresentation";
|
||||||
|
import type { ConnectionConfig } from "@/types/database";
|
||||||
|
|
||||||
|
defineProps<{ connection?: ConnectionConfig }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<PluginIcon v-if="connection?.db_type === 'plugin' && connection.plugin_id" :plugin-id="connection.plugin_id" :contribution-id="connection.plugin_connection_provider" />
|
||||||
|
<DatabaseIcon v-else :db-type="connectionIconType(connection)" />
|
||||||
|
</template>
|
||||||
@@ -1185,8 +1185,8 @@ function openPluginResultView(pluginId: string, contributionId: string, label: s
|
|||||||
if (!result) return;
|
if (!result) return;
|
||||||
// The tab carries the result-view contribution id, not a workbench id: the
|
// The tab carries the result-view contribution id, not a workbench id: the
|
||||||
// plugin UI is told which declared surface the user picked, and it receives a
|
// plugin UI is told which declared surface the user picked, and it receives a
|
||||||
// bounded snapshot — plugins re-query through their backend when they need the
|
// bounded snapshot — plugins that need more rows re-run the statement through
|
||||||
// full or streamed result set.
|
// `host.queryData` (host.data:read, with the user's consent).
|
||||||
const cappedRows = result.rows.slice(0, 500);
|
const cappedRows = result.rows.slice(0, 500);
|
||||||
queryStore.openPluginWorkbench(pluginId, contributionId, {
|
queryStore.openPluginWorkbench(pluginId, contributionId, {
|
||||||
title: label,
|
title: label,
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from "vue";
|
||||||
|
import { useI18n } from "vue-i18n";
|
||||||
|
import { Bot, Database, Loader2, ShieldCheck, TriangleAlert, X } from "@lucide/vue";
|
||||||
|
import * as api from "@/lib/backend/api";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Switch } from "@/components/ui/switch";
|
||||||
|
import { useToast } from "@/composables/useToast";
|
||||||
|
import type { InstalledPlugin } from "@/types/database";
|
||||||
|
import type { PluginDataGrant } from "@/types/pluginData";
|
||||||
|
import { PLUGIN_DATA_READ_PERMISSION } from "@/types/pluginData";
|
||||||
|
import type { PluginToolPreview } from "@/types/pluginAiTools";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plugin Center controls for what a plugin may reach beyond its own UI:
|
||||||
|
* built-in AI access to its MCP tools, and the connections it may read
|
||||||
|
* through `host.data:read`. Both are off until the user turns them on.
|
||||||
|
*/
|
||||||
|
const props = defineProps<{ plugin: InstalledPlugin }>();
|
||||||
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { toast } = useToast();
|
||||||
|
|
||||||
|
const pluginId = computed(() => props.plugin.manifest.id);
|
||||||
|
const hasBackend = computed(() => !!props.plugin.manifest.entrypoints?.backend);
|
||||||
|
const declaresDataRead = computed(() => (props.plugin.manifest.permissions || []).includes(PLUGIN_DATA_READ_PERMISSION));
|
||||||
|
|
||||||
|
const aiEnabled = ref(false);
|
||||||
|
const aiSaving = ref(false);
|
||||||
|
const preview = ref<PluginToolPreview | null>(null);
|
||||||
|
const previewLoading = ref(false);
|
||||||
|
const previewError = ref("");
|
||||||
|
const grants = ref<PluginDataGrant[]>([]);
|
||||||
|
const revoking = ref<string | null>(null);
|
||||||
|
|
||||||
|
function errorText(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
preview.value = null;
|
||||||
|
previewError.value = "";
|
||||||
|
grants.value = [];
|
||||||
|
const id = pluginId.value;
|
||||||
|
const [enabledIds, currentGrants] = await Promise.all([
|
||||||
|
hasBackend.value ? api.getAiPluginToolPlugins().catch(() => [] as string[]) : Promise.resolve([] as string[]),
|
||||||
|
declaresDataRead.value ? api.getPluginDataGrants(id).catch(() => [] as PluginDataGrant[]) : Promise.resolve([] as PluginDataGrant[]),
|
||||||
|
]);
|
||||||
|
// The selection may have moved on while the requests were in flight.
|
||||||
|
if (id !== pluginId.value) return;
|
||||||
|
aiEnabled.value = enabledIds.includes(id);
|
||||||
|
grants.value = currentGrants;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setAiEnabled(enabled: boolean) {
|
||||||
|
if (aiSaving.value) return;
|
||||||
|
aiSaving.value = true;
|
||||||
|
try {
|
||||||
|
const enabledIds = await api.setAiPluginToolPluginEnabled(pluginId.value, enabled);
|
||||||
|
aiEnabled.value = enabledIds.includes(pluginId.value);
|
||||||
|
} catch (error) {
|
||||||
|
toast(t("pluginPlatform.aiTools.saveFailed", { message: errorText(error) }), 5000);
|
||||||
|
} finally {
|
||||||
|
aiSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPreview() {
|
||||||
|
previewLoading.value = true;
|
||||||
|
previewError.value = "";
|
||||||
|
const id = pluginId.value;
|
||||||
|
try {
|
||||||
|
const result = await api.previewPluginAiTools(id);
|
||||||
|
if (id === pluginId.value) preview.value = result;
|
||||||
|
} catch (error) {
|
||||||
|
if (id === pluginId.value) previewError.value = errorText(error);
|
||||||
|
} finally {
|
||||||
|
previewLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function revokeGrant(connectionId: string) {
|
||||||
|
revoking.value = connectionId;
|
||||||
|
try {
|
||||||
|
grants.value = await api.setPluginDataGrant(pluginId.value, connectionId, false);
|
||||||
|
} catch (error) {
|
||||||
|
toast(t("pluginPlatform.dataAccess.revokeFailed", { message: errorText(error) }), 5000);
|
||||||
|
} finally {
|
||||||
|
revoking.value = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(pluginId, () => void load(), { immediate: true });
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="hasBackend || declaresDataRead" class="space-y-3">
|
||||||
|
<section v-if="hasBackend" class="space-y-3 rounded-lg border p-4" data-plugin-ai-tools>
|
||||||
|
<div class="flex items-start justify-between gap-3">
|
||||||
|
<div class="flex min-w-0 items-start gap-2">
|
||||||
|
<Bot class="mt-0.5 size-4 shrink-0 text-primary" />
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-sm font-medium">{{ t("pluginPlatform.aiTools.title") }}</div>
|
||||||
|
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("pluginPlatform.aiTools.description") }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Switch :model-value="aiEnabled" :disabled="aiSaving" :aria-label="t('pluginPlatform.aiTools.title')" @update:model-value="setAiEnabled($event === true)" />
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Button size="sm" variant="outline" class="h-7 gap-1.5 text-xs" :disabled="previewLoading" @click="loadPreview">
|
||||||
|
<Loader2 v-if="previewLoading" class="size-3.5 animate-spin" />
|
||||||
|
{{ t("pluginPlatform.aiTools.preview") }}
|
||||||
|
</Button>
|
||||||
|
<span class="text-[11px] text-muted-foreground">{{ t("pluginPlatform.aiTools.previewHint") }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="previewError" class="flex items-start gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-xs text-amber-800 dark:text-amber-200">
|
||||||
|
<TriangleAlert class="mt-0.5 size-3.5 shrink-0" />
|
||||||
|
<span class="min-w-0 break-words">{{ t("pluginPlatform.aiTools.previewFailed", { message: previewError }) }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="preview" class="space-y-2">
|
||||||
|
<div class="text-[11px] text-muted-foreground">
|
||||||
|
{{ preview.openConnections.length ? t("pluginPlatform.aiTools.openConnections", { names: preview.openConnections.map((connection) => connection.name).join(", ") }) : t("pluginPlatform.aiTools.noOpenConnections") }}
|
||||||
|
</div>
|
||||||
|
<div v-if="!preview.tools.length" class="rounded-md border border-dashed px-3 py-2 text-xs text-muted-foreground">{{ t("pluginPlatform.aiTools.noTools") }}</div>
|
||||||
|
<ul v-else class="max-h-64 divide-y overflow-y-auto rounded-md border">
|
||||||
|
<li v-for="tool in preview.tools" :key="tool.name" class="flex items-start gap-2 px-3 py-2">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="font-mono text-xs">{{ tool.name }}</div>
|
||||||
|
<div v-if="tool.description" class="mt-0.5 text-[11px] leading-4 text-muted-foreground">{{ tool.description }}</div>
|
||||||
|
</div>
|
||||||
|
<Badge v-if="tool.readOnly" variant="secondary" class="h-5 shrink-0 gap-1 px-1.5 text-[10px]"><ShieldCheck class="size-3" />{{ t("pluginPlatform.aiTools.readOnly") }}</Badge>
|
||||||
|
<Badge v-else variant="outline" class="h-5 shrink-0 border-amber-500/40 px-1.5 text-[10px] text-amber-700 dark:text-amber-300">{{ t("pluginPlatform.aiTools.needsApproval") }}</Badge>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="declaresDataRead" class="space-y-3 rounded-lg border p-4" data-plugin-data-access>
|
||||||
|
<div class="flex min-w-0 items-start gap-2">
|
||||||
|
<Database class="mt-0.5 size-4 shrink-0 text-primary" />
|
||||||
|
<div class="min-w-0">
|
||||||
|
<div class="text-sm font-medium">{{ t("pluginPlatform.dataAccess.title") }}</div>
|
||||||
|
<p class="mt-1 text-xs leading-5 text-muted-foreground">{{ t("pluginPlatform.dataAccess.description") }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-if="!grants.length" class="rounded-md border border-dashed px-3 py-2 text-xs text-muted-foreground">{{ t("pluginPlatform.dataAccess.none") }}</div>
|
||||||
|
<ul v-else class="divide-y rounded-md border">
|
||||||
|
<li v-for="grant in grants" :key="grant.connectionId" class="flex items-center gap-2 px-3 py-2">
|
||||||
|
<div class="min-w-0 flex-1 truncate text-xs">
|
||||||
|
<span v-if="grant.connectionName">{{ grant.connectionName }}</span>
|
||||||
|
<span v-else class="text-muted-foreground">{{ t("pluginPlatform.dataAccess.missingConnection", { id: grant.connectionId }) }}</span>
|
||||||
|
</div>
|
||||||
|
<Button size="sm" variant="ghost" class="h-6 gap-1 px-2 text-xs text-destructive" :disabled="revoking === grant.connectionId" @click="revokeGrant(grant.connectionId)">
|
||||||
|
<Loader2 v-if="revoking === grant.connectionId" class="size-3 animate-spin" />
|
||||||
|
<X v-else class="size-3" />
|
||||||
|
{{ t("pluginPlatform.dataAccess.revoke") }}
|
||||||
|
</Button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -14,6 +14,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip
|
|||||||
import { useToast } from "@/composables/useToast";
|
import { useToast } from "@/composables/useToast";
|
||||||
import PluginShortcutSettings from "./PluginShortcutSettings.vue";
|
import PluginShortcutSettings from "./PluginShortcutSettings.vue";
|
||||||
import PluginIcon from "@/components/plugins/PluginIcon.vue";
|
import PluginIcon from "@/components/plugins/PluginIcon.vue";
|
||||||
|
import PluginAiAccessSection from "@/components/plugins/PluginAiAccessSection.vue";
|
||||||
import * as api from "@/lib/backend/api";
|
import * as api from "@/lib/backend/api";
|
||||||
import { clearPluginIconCache } from "@/lib/plugins/pluginIconResolver";
|
import { clearPluginIconCache } from "@/lib/plugins/pluginIconResolver";
|
||||||
import { loadPinnedPluginIds, savePinnedPluginIds, sortPluginsPinnedFirst } from "@/lib/plugins/pluginPinning";
|
import { loadPinnedPluginIds, savePinnedPluginIds, sortPluginsPinnedFirst } from "@/lib/plugins/pluginPinning";
|
||||||
@@ -1354,6 +1355,8 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<PluginAiAccessSection :key="selectedDefinition.plugin.manifest.id" :plugin="selectedDefinition.plugin" />
|
||||||
|
|
||||||
<div v-if="connectionProviders.some((entry) => entry.plugin.manifest.id === selectedPluginId)" class="space-y-3">
|
<div v-if="connectionProviders.some((entry) => entry.plugin.manifest.id === selectedPluginId)" class="space-y-3">
|
||||||
<div class="flex flex-wrap gap-2">
|
<div class="flex flex-wrap gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -403,6 +403,14 @@ function createBridge() {
|
|||||||
getPlanCapabilities: (connectionId) => api.getPluginPlanCapabilities(connectionId),
|
getPlanCapabilities: (connectionId) => api.getPluginPlanCapabilities(connectionId),
|
||||||
explainPlan: (request) => api.getPluginEstimatedPlan(request),
|
explainPlan: (request) => api.getPluginEstimatedPlan(request),
|
||||||
getTableMetadata: (context) => api.getPluginTableMetadata(context),
|
getTableMetadata: (context) => api.getPluginTableMetadata(context),
|
||||||
|
// host.data:read — the bridge asks for consent per connection before the
|
||||||
|
// first query; the backend enforces the persisted grant on every call.
|
||||||
|
queryData: (pluginId, request) => api.queryPluginData(pluginId, request),
|
||||||
|
hasDataGrant: async (pluginId, connectionId) => (await api.getPluginDataGrants(pluginId)).some((grant) => grant.connectionId === connectionId),
|
||||||
|
confirmDataAccess: (_pluginId, pluginName, connectionId) => confirmPluginDataAccess(pluginName, connectionId),
|
||||||
|
grantDataAccess: async (pluginId, connectionId) => {
|
||||||
|
await api.setPluginDataGrant(pluginId, connectionId, true);
|
||||||
|
},
|
||||||
closeTab: () => emit("closeTab"),
|
closeTab: () => emit("closeTab"),
|
||||||
saveFile: (_pluginId, request, data) => savePluginFile(request, data),
|
saveFile: (_pluginId, request, data) => savePluginFile(request, data),
|
||||||
downloadFile: isTauriRuntime() ? downloadPluginFile : undefined,
|
downloadFile: isTauriRuntime() ? downloadPluginFile : undefined,
|
||||||
@@ -442,6 +450,22 @@ function createBridge() {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consent for `host.data:read`: names the plugin and the connection so the
|
||||||
|
* user sees exactly what is shared. An allow is persisted as a grant the
|
||||||
|
* Plugin Center can revoke; the web host asks through the browser dialog.
|
||||||
|
*/
|
||||||
|
async function confirmPluginDataAccess(pluginName: string, connectionId: string): Promise<boolean> {
|
||||||
|
const connectionName = useConnectionStore().getConfig(connectionId)?.name || connectionId;
|
||||||
|
const message = t("pluginPlatform.dataAccessConsent", { name: pluginName, connection: connectionName });
|
||||||
|
const title = t("pluginPlatform.dataAccessConsentTitle");
|
||||||
|
if (isTauriRuntime()) {
|
||||||
|
const { ask } = await import("@tauri-apps/plugin-dialog");
|
||||||
|
return (await ask(message, { title, kind: "warning" })) === true;
|
||||||
|
}
|
||||||
|
return window.confirm(`${title}\n\n${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
/** Keep a plugin-supplied name from smuggling path separators or traversal into the save dialog. */
|
/** Keep a plugin-supplied name from smuggling path separators or traversal into the save dialog. */
|
||||||
function safeFileName(value: string | undefined): string {
|
function safeFileName(value: string | undefined): string {
|
||||||
const base = (value || "").split(/[\\/]/).pop() || "";
|
const base = (value || "").split(/[\\/]/).pop() || "";
|
||||||
|
|||||||
@@ -73,6 +73,11 @@ const FEATURE_MESSAGES: FeatureMessages[] = [
|
|||||||
{ feature: "PostgreSQL legacy TLS", keys: ["connection.postgresLegacyTls", "connection.postgresLegacyTlsHint"] },
|
{ feature: "PostgreSQL legacy TLS", keys: ["connection.postgresLegacyTls", "connection.postgresLegacyTlsHint"] },
|
||||||
{ feature: "settings search sections", keys: ["settings.syncWebDavWebDescription", "settings.performanceSection"] },
|
{ feature: "settings search sections", keys: ["settings.syncWebDavWebDescription", "settings.performanceSection"] },
|
||||||
{ feature: "Redis batch expiration", keys: under("redis", ["batchExpiry", "batchExpiryTitle", "batchExpirySelected", "batchExpiryApply", "batchExpirySuccess", "batchExpiryPartial"]) },
|
{ feature: "Redis batch expiration", keys: under("redis", ["batchExpiry", "batchExpiryTitle", "batchExpirySelected", "batchExpiryApply", "batchExpirySuccess", "batchExpiryPartial"]) },
|
||||||
|
{
|
||||||
|
feature: "plugin AI tools and data access",
|
||||||
|
keys: ["pluginPlatform.dataAccessConsent", "pluginPlatform.dataAccessConsentTitle", "pluginPlatform.aiTools.*", "pluginPlatform.dataAccess.*", "ai.toolApproval.*"],
|
||||||
|
translated: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function lookup(messages: unknown, path: string): unknown {
|
function lookup(messages: unknown, path: string): unknown {
|
||||||
|
|||||||
@@ -82,6 +82,29 @@ const consul = {
|
|||||||
|
|
||||||
export default withEnglishFallback({
|
export default withEnglishFallback({
|
||||||
pluginPlatform: {
|
pluginPlatform: {
|
||||||
|
dataAccessConsent: '"{name}" "{connection}" bağlantısındakı məlumatları oxumaq istəyir. Bu bağlantı açıq olduqca plagin, Plagin Mərkəzində girişi ləğv edənə qədər orada yalnız oxuma sorğuları icra edə bilər. İcazə verilsin?',
|
||||||
|
dataAccessConsentTitle: "Məlumat girişi sorğusu",
|
||||||
|
aiTools: {
|
||||||
|
title: "Daxili süni intellekt alətləri",
|
||||||
|
description: "DBX süni intellekt köməkçisinin (Agent rejimi) açıq olan plagin bağlantılarında bu plaginin alətlərini çağırmasına icazə verir. Yalnız oxuma kimi elan edilməyən alətlər hər çağırışdan əvvəl təsdiqinizi istəyir və alət nəticəsi süni intellekt modelinizə göndərilir.",
|
||||||
|
preview: "Alətləri göstər",
|
||||||
|
previewHint: "Plagin işləmirsə, onu başladır.",
|
||||||
|
previewFailed: "Alətlərin siyahısı alınmadı: {message}",
|
||||||
|
openConnections: "Süni intellekt bu açıq bağlantılardan istifadə edəcək: {names}",
|
||||||
|
noOpenConnections: "Bu plaginin açıq bağlantısı yoxdur. Süni intellektdən bu alətləri istifadə etməsini istəməzdən əvvəl bağlantı açın.",
|
||||||
|
noTools: "Bu plagin süni intellekt alətləri təqdim etmir.",
|
||||||
|
readOnly: "Yalnız oxuma",
|
||||||
|
needsApproval: "Təsdiq tələb olunur",
|
||||||
|
saveFailed: "Ayar saxlanılmadı: {message}",
|
||||||
|
},
|
||||||
|
dataAccess: {
|
||||||
|
title: "Məlumat girişi",
|
||||||
|
description: "Bu plaginin yalnız oxuma sorğuları ilə oxuya biləcəyi bağlantılar. Plagin bağlantıya ilk dəfə ehtiyac duyanda sizdən soruşur; girişi burada istənilən vaxt ləğv edə bilərsiniz.",
|
||||||
|
none: "Hələ heç bir bağlantıya icazə verilməyib.",
|
||||||
|
revoke: "Ləğv et",
|
||||||
|
missingConnection: "Silinmiş bağlantı ({id})",
|
||||||
|
revokeFailed: "Giriş ləğv edilmədi: {message}",
|
||||||
|
},
|
||||||
shortcutsPluginCenter: "Plagin mərkəzinin yanında (açılan menyu)",
|
shortcutsPluginCenter: "Plagin mərkəzinin yanında (açılan menyu)",
|
||||||
shortcutsToolbar: "Yeniləmə düyməsinin solunda (üzən)",
|
shortcutsToolbar: "Yeniləmə düyməsinin solunda (üzən)",
|
||||||
shortcutsToolbarCount: "Görünən nişanların sayı",
|
shortcutsToolbarCount: "Görünən nişanların sayı",
|
||||||
@@ -3029,6 +3052,21 @@ export default withEnglishFallback({
|
|||||||
proposalConfirmYes: "Bəli, davam et",
|
proposalConfirmYes: "Bəli, davam et",
|
||||||
proposalConfirmNo: "Xeyr, təşəkkürlər",
|
proposalConfirmNo: "Xeyr, təşəkkürlər",
|
||||||
proposalConfirmTitle: "Əməliyyatın təsdiqi tələb olunur",
|
proposalConfirmTitle: "Əməliyyatın təsdiqi tələb olunur",
|
||||||
|
toolApproval: {
|
||||||
|
prompt: "{plugin} {connection} üzərində {tool} icra etmək istəyir. Aşağıdakı arqumentləri yoxlayın.",
|
||||||
|
approve: "Bir dəfə icazə ver",
|
||||||
|
deny: "Rədd et",
|
||||||
|
expiresAt: "Cavab verilməsə, {time} vaxtı avtomatik rədd ediləcək",
|
||||||
|
waiting: "Təsdiq gözlənilir",
|
||||||
|
answerFailed: "Cavabınız göndərilmədi: {message}",
|
||||||
|
status: {
|
||||||
|
approved: "Təsdiqləndi",
|
||||||
|
denied: "Rədd edildi",
|
||||||
|
timed_out: "Vaxtında cavab verilmədi; çağırış icra edilmədi",
|
||||||
|
cancelled: "Ləğv edildi",
|
||||||
|
expired: "Bu təsdiq artıq gözlənilmir",
|
||||||
|
},
|
||||||
|
},
|
||||||
agentSteps: {
|
agentSteps: {
|
||||||
generated: "SQL yaradıldı",
|
generated: "SQL yaradıldı",
|
||||||
noSql: "SQL tapılmadı",
|
noSql: "SQL tapılmadı",
|
||||||
|
|||||||
@@ -168,6 +168,29 @@ export default {
|
|||||||
docs,
|
docs,
|
||||||
dataDictionary,
|
dataDictionary,
|
||||||
pluginPlatform: {
|
pluginPlatform: {
|
||||||
|
dataAccessConsent: '"{name}" wants to read data from the connection "{connection}". While that connection is open, the plugin can run read-only queries on it until you revoke access in the Plugin Center. Allow?',
|
||||||
|
dataAccessConsentTitle: "Data Access Request",
|
||||||
|
aiTools: {
|
||||||
|
title: "Built-in AI tools",
|
||||||
|
description: "Let the DBX AI assistant (Agent mode) call this plugin's tools on the plugin connections you have open. Tools that are not declared read-only ask for your approval before every call, and tool output is sent to your AI model.",
|
||||||
|
preview: "Show tools",
|
||||||
|
previewHint: "Starts the plugin if it is not running.",
|
||||||
|
previewFailed: "Could not list tools: {message}",
|
||||||
|
openConnections: "The AI would use these open connections: {names}",
|
||||||
|
noOpenConnections: "No connection of this plugin is open. Open one before asking the AI to use these tools.",
|
||||||
|
noTools: "This plugin does not provide AI tools.",
|
||||||
|
readOnly: "Read-only",
|
||||||
|
needsApproval: "Needs approval",
|
||||||
|
saveFailed: "Could not save the setting: {message}",
|
||||||
|
},
|
||||||
|
dataAccess: {
|
||||||
|
title: "Data access",
|
||||||
|
description: "Connections this plugin may read with read-only queries. The plugin asks you the first time it needs a connection; you can revoke access here at any time.",
|
||||||
|
none: "No connection has been granted yet.",
|
||||||
|
revoke: "Revoke",
|
||||||
|
missingConnection: "Deleted connection ({id})",
|
||||||
|
revokeFailed: "Could not revoke access: {message}",
|
||||||
|
},
|
||||||
shortcutsPluginCenter: "Beside Plugin Center (dropdown)",
|
shortcutsPluginCenter: "Beside Plugin Center (dropdown)",
|
||||||
shortcutsToolbar: "Before Update (floating)",
|
shortcutsToolbar: "Before Update (floating)",
|
||||||
shortcutsToolbarCount: "Visible toolbar icons",
|
shortcutsToolbarCount: "Visible toolbar icons",
|
||||||
@@ -3385,6 +3408,21 @@ export default {
|
|||||||
proposalConfirmYes: "Yes, proceed",
|
proposalConfirmYes: "Yes, proceed",
|
||||||
proposalConfirmNo: "No thanks",
|
proposalConfirmNo: "No thanks",
|
||||||
proposalConfirmTitle: "Action confirmation required",
|
proposalConfirmTitle: "Action confirmation required",
|
||||||
|
toolApproval: {
|
||||||
|
prompt: "{plugin} wants to run {tool} on {connection}. Review the arguments below.",
|
||||||
|
approve: "Allow once",
|
||||||
|
deny: "Deny",
|
||||||
|
expiresAt: "Denied automatically at {time} if unanswered",
|
||||||
|
waiting: "Waiting for approval",
|
||||||
|
answerFailed: "Could not send your answer: {message}",
|
||||||
|
status: {
|
||||||
|
approved: "Approved",
|
||||||
|
denied: "Denied",
|
||||||
|
timed_out: "Not answered in time; the call did not run",
|
||||||
|
cancelled: "Cancelled",
|
||||||
|
expired: "This approval is no longer pending",
|
||||||
|
},
|
||||||
|
},
|
||||||
agentSteps: {
|
agentSteps: {
|
||||||
generated: "SQL generated",
|
generated: "SQL generated",
|
||||||
noSql: "No SQL found",
|
noSql: "No SQL found",
|
||||||
|
|||||||
@@ -171,6 +171,29 @@ export default withEnglishFallback({
|
|||||||
docs,
|
docs,
|
||||||
dataDictionary,
|
dataDictionary,
|
||||||
pluginPlatform: {
|
pluginPlatform: {
|
||||||
|
dataAccessConsent: '"{name}" quiere leer datos de la conexión "{connection}". Mientras esa conexión esté abierta, el plugin podrá ejecutar consultas de solo lectura en ella hasta que revoques el acceso en el Centro de plugins. ¿Permitir?',
|
||||||
|
dataAccessConsentTitle: "Solicitud de acceso a datos",
|
||||||
|
aiTools: {
|
||||||
|
title: "Herramientas de IA integradas",
|
||||||
|
description: "Permite que el asistente de IA de DBX (modo Agent) use las herramientas de este plugin en las conexiones del plugin que tengas abiertas. Las herramientas no declaradas de solo lectura piden tu aprobación antes de cada llamada, y su resultado se envía a tu modelo de IA.",
|
||||||
|
preview: "Ver herramientas",
|
||||||
|
previewHint: "Inicia el plugin si no está en ejecución.",
|
||||||
|
previewFailed: "No se pudieron listar las herramientas: {message}",
|
||||||
|
openConnections: "La IA usaría estas conexiones abiertas: {names}",
|
||||||
|
noOpenConnections: "No hay ninguna conexión de este plugin abierta. Abre una antes de pedir a la IA que use estas herramientas.",
|
||||||
|
noTools: "Este plugin no ofrece herramientas de IA.",
|
||||||
|
readOnly: "Solo lectura",
|
||||||
|
needsApproval: "Requiere aprobación",
|
||||||
|
saveFailed: "No se pudo guardar la configuración: {message}",
|
||||||
|
},
|
||||||
|
dataAccess: {
|
||||||
|
title: "Acceso a datos",
|
||||||
|
description: "Conexiones que este plugin puede leer con consultas de solo lectura. El plugin te pregunta la primera vez que necesita una conexión; puedes revocar el acceso aquí en cualquier momento.",
|
||||||
|
none: "Todavía no se ha concedido ninguna conexión.",
|
||||||
|
revoke: "Revocar",
|
||||||
|
missingConnection: "Conexión eliminada ({id})",
|
||||||
|
revokeFailed: "No se pudo revocar el acceso: {message}",
|
||||||
|
},
|
||||||
shortcutsPluginCenter: "Junto al centro de plugins (desplegable)",
|
shortcutsPluginCenter: "Junto al centro de plugins (desplegable)",
|
||||||
shortcutsToolbar: "Antes de Actualizar (flotante)",
|
shortcutsToolbar: "Antes de Actualizar (flotante)",
|
||||||
shortcutsToolbarCount: "Iconos visibles",
|
shortcutsToolbarCount: "Iconos visibles",
|
||||||
@@ -3479,6 +3502,21 @@ export default withEnglishFallback({
|
|||||||
clearDatabaseSelection: "Clear selection",
|
clearDatabaseSelection: "Clear selection",
|
||||||
searchDatabases: "Buscar bases de datos…",
|
searchDatabases: "Buscar bases de datos…",
|
||||||
noDatabasesFound: "No se encontraron bases de datos",
|
noDatabasesFound: "No se encontraron bases de datos",
|
||||||
|
toolApproval: {
|
||||||
|
prompt: "{plugin} quiere ejecutar {tool} en {connection}. Revisa los argumentos a continuación.",
|
||||||
|
approve: "Permitir una vez",
|
||||||
|
deny: "Denegar",
|
||||||
|
expiresAt: "Se denegará automáticamente a las {time} si no respondes",
|
||||||
|
waiting: "Esperando aprobación",
|
||||||
|
answerFailed: "No se pudo enviar tu respuesta: {message}",
|
||||||
|
status: {
|
||||||
|
approved: "Aprobado",
|
||||||
|
denied: "Denegado",
|
||||||
|
timed_out: "Sin respuesta a tiempo; la llamada no se ejecutó",
|
||||||
|
cancelled: "Cancelado",
|
||||||
|
expired: "Esta aprobación ya no está pendiente",
|
||||||
|
},
|
||||||
|
},
|
||||||
agentSteps: {
|
agentSteps: {
|
||||||
generated: "SQL generado",
|
generated: "SQL generado",
|
||||||
noSql: "No se encontró SQL",
|
noSql: "No se encontró SQL",
|
||||||
|
|||||||
@@ -170,6 +170,30 @@ export default withEnglishFallback({
|
|||||||
docs,
|
docs,
|
||||||
dataDictionary,
|
dataDictionary,
|
||||||
pluginPlatform: {
|
pluginPlatform: {
|
||||||
|
dataAccessConsent: '"{name}" vuole leggere i dati della connessione "{connection}". Finché la connessione è aperta, il plugin potrà eseguire query di sola lettura su di essa finché non revochi l\'accesso nel Centro plugin. Consentire?',
|
||||||
|
dataAccessConsentTitle: "Richiesta di accesso ai dati",
|
||||||
|
aiTools: {
|
||||||
|
title: "Strumenti IA integrati",
|
||||||
|
description:
|
||||||
|
"Consente all'assistente IA di DBX (modalità Agent) di usare gli strumenti di questo plugin sulle connessioni del plugin che hai aperto. Gli strumenti non dichiarati di sola lettura chiedono la tua approvazione prima di ogni chiamata e il loro output viene inviato al tuo modello IA.",
|
||||||
|
preview: "Mostra strumenti",
|
||||||
|
previewHint: "Avvia il plugin se non è in esecuzione.",
|
||||||
|
previewFailed: "Impossibile elencare gli strumenti: {message}",
|
||||||
|
openConnections: "L'IA userebbe queste connessioni aperte: {names}",
|
||||||
|
noOpenConnections: "Nessuna connessione di questo plugin è aperta. Aprine una prima di chiedere all'IA di usare questi strumenti.",
|
||||||
|
noTools: "Questo plugin non fornisce strumenti IA.",
|
||||||
|
readOnly: "Sola lettura",
|
||||||
|
needsApproval: "Richiede approvazione",
|
||||||
|
saveFailed: "Impossibile salvare l'impostazione: {message}",
|
||||||
|
},
|
||||||
|
dataAccess: {
|
||||||
|
title: "Accesso ai dati",
|
||||||
|
description: "Connessioni che questo plugin può leggere con query di sola lettura. Il plugin te lo chiede la prima volta che gli serve una connessione; puoi revocare l'accesso qui in qualsiasi momento.",
|
||||||
|
none: "Nessuna connessione è stata ancora concessa.",
|
||||||
|
revoke: "Revoca",
|
||||||
|
missingConnection: "Connessione eliminata ({id})",
|
||||||
|
revokeFailed: "Impossibile revocare l'accesso: {message}",
|
||||||
|
},
|
||||||
shortcutsPluginCenter: "Accanto al centro plugin (menu a discesa)",
|
shortcutsPluginCenter: "Accanto al centro plugin (menu a discesa)",
|
||||||
shortcutsToolbar: "Prima di Aggiorna (flottante)",
|
shortcutsToolbar: "Prima di Aggiorna (flottante)",
|
||||||
shortcutsToolbarCount: "Icone visibili",
|
shortcutsToolbarCount: "Icone visibili",
|
||||||
@@ -3297,6 +3321,21 @@ export default withEnglishFallback({
|
|||||||
clearDatabaseSelection: "Clear selection",
|
clearDatabaseSelection: "Clear selection",
|
||||||
searchDatabases: "Cerca database…",
|
searchDatabases: "Cerca database…",
|
||||||
noDatabasesFound: "Nessun database trovato",
|
noDatabasesFound: "Nessun database trovato",
|
||||||
|
toolApproval: {
|
||||||
|
prompt: "{plugin} vuole eseguire {tool} su {connection}. Controlla gli argomenti qui sotto.",
|
||||||
|
approve: "Consenti una volta",
|
||||||
|
deny: "Nega",
|
||||||
|
expiresAt: "Verrà negato automaticamente alle {time} senza risposta",
|
||||||
|
waiting: "In attesa di approvazione",
|
||||||
|
answerFailed: "Impossibile inviare la risposta: {message}",
|
||||||
|
status: {
|
||||||
|
approved: "Approvato",
|
||||||
|
denied: "Negato",
|
||||||
|
timed_out: "Nessuna risposta in tempo; la chiamata non è stata eseguita",
|
||||||
|
cancelled: "Annullato",
|
||||||
|
expired: "Questa approvazione non è più in attesa",
|
||||||
|
},
|
||||||
|
},
|
||||||
agentSteps: {
|
agentSteps: {
|
||||||
generated: "SQL generato",
|
generated: "SQL generato",
|
||||||
noSql: "Nessun SQL trovato",
|
noSql: "Nessun SQL trovato",
|
||||||
|
|||||||
@@ -171,6 +171,29 @@ export default withEnglishFallback({
|
|||||||
docs,
|
docs,
|
||||||
dataDictionary,
|
dataDictionary,
|
||||||
pluginPlatform: {
|
pluginPlatform: {
|
||||||
|
dataAccessConsent: "「{name}」が接続「{connection}」のデータの読み取りを要求しています。許可すると、プラグインセンターで取り消すまで、この接続が開いている間はプラグインが読み取り専用クエリを実行できます。許可しますか?",
|
||||||
|
dataAccessConsentTitle: "データアクセスの要求",
|
||||||
|
aiTools: {
|
||||||
|
title: "組み込み AI ツール",
|
||||||
|
description: "DBX AI アシスタント(Agent モード)が、開いているこのプラグインの接続でプラグインのツールを呼び出せるようにします。読み取り専用と宣言されていないツールは呼び出しのたびに承認を求め、ツールの出力は設定した AI モデルに送信されます。",
|
||||||
|
preview: "ツールを表示",
|
||||||
|
previewHint: "プラグインが実行されていない場合は起動します。",
|
||||||
|
previewFailed: "ツール一覧を取得できませんでした: {message}",
|
||||||
|
openConnections: "AI は次の開いている接続を使用します: {names}",
|
||||||
|
noOpenConnections: "このプラグインの接続は開かれていません。AI にこれらのツールを使わせる前に接続を開いてください。",
|
||||||
|
noTools: "このプラグインは AI ツールを提供していません。",
|
||||||
|
readOnly: "読み取り専用",
|
||||||
|
needsApproval: "承認が必要",
|
||||||
|
saveFailed: "設定を保存できませんでした: {message}",
|
||||||
|
},
|
||||||
|
dataAccess: {
|
||||||
|
title: "データアクセス",
|
||||||
|
description: "このプラグインが読み取り専用クエリを実行できる接続です。プラグインが初めて接続を必要とするときに確認を求めます。ここでいつでも取り消せます。",
|
||||||
|
none: "まだ許可された接続はありません。",
|
||||||
|
revoke: "取り消す",
|
||||||
|
missingConnection: "削除された接続({id})",
|
||||||
|
revokeFailed: "アクセスを取り消せませんでした: {message}",
|
||||||
|
},
|
||||||
shortcutsPluginCenter: "プラグインセンターの右側(ドロップダウン)",
|
shortcutsPluginCenter: "プラグインセンターの右側(ドロップダウン)",
|
||||||
shortcutsToolbar: "更新ボタンの左側(フローティング)",
|
shortcutsToolbar: "更新ボタンの左側(フローティング)",
|
||||||
shortcutsToolbarCount: "表示するアイコン数",
|
shortcutsToolbarCount: "表示するアイコン数",
|
||||||
@@ -3438,6 +3461,21 @@ export default withEnglishFallback({
|
|||||||
clearDatabaseSelection: "Clear selection",
|
clearDatabaseSelection: "Clear selection",
|
||||||
searchDatabases: "データベースを検索…",
|
searchDatabases: "データベースを検索…",
|
||||||
noDatabasesFound: "データベースが見つかりません",
|
noDatabasesFound: "データベースが見つかりません",
|
||||||
|
toolApproval: {
|
||||||
|
prompt: "「{plugin}」が「{connection}」で {tool} を実行しようとしています。以下の引数を確認してください。",
|
||||||
|
approve: "今回のみ許可",
|
||||||
|
deny: "拒否",
|
||||||
|
expiresAt: "{time} までに応答がない場合は自動的に拒否されます",
|
||||||
|
waiting: "承認待ち",
|
||||||
|
answerFailed: "応答を送信できませんでした: {message}",
|
||||||
|
status: {
|
||||||
|
approved: "許可済み",
|
||||||
|
denied: "拒否済み",
|
||||||
|
timed_out: "時間内に応答がなかったため実行されませんでした",
|
||||||
|
cancelled: "キャンセル済み",
|
||||||
|
expired: "この承認はすでに無効です",
|
||||||
|
},
|
||||||
|
},
|
||||||
agentSteps: {
|
agentSteps: {
|
||||||
generated: "SQL生成完了",
|
generated: "SQL生成完了",
|
||||||
noSql: "SQLが見つかりません",
|
noSql: "SQLが見つかりません",
|
||||||
|
|||||||
@@ -170,6 +170,29 @@ export default withEnglishFallback({
|
|||||||
docs,
|
docs,
|
||||||
dataDictionary,
|
dataDictionary,
|
||||||
pluginPlatform: {
|
pluginPlatform: {
|
||||||
|
dataAccessConsent: '"{name}" 플러그인이 "{connection}" 연결의 데이터 읽기를 요청합니다. 허용하면 플러그인 센터에서 취소할 때까지 이 연결이 열려 있는 동안 플러그인이 읽기 전용 쿼리를 실행할 수 있습니다. 허용하시겠습니까?',
|
||||||
|
dataAccessConsentTitle: "데이터 액세스 요청",
|
||||||
|
aiTools: {
|
||||||
|
title: "내장 AI 도구",
|
||||||
|
description: "DBX AI 어시스턴트(Agent 모드)가 열려 있는 이 플러그인의 연결에서 플러그인 도구를 호출하도록 허용합니다. 읽기 전용으로 선언되지 않은 도구는 호출할 때마다 승인을 요청하며, 도구 출력은 설정한 AI 모델로 전송됩니다.",
|
||||||
|
preview: "도구 보기",
|
||||||
|
previewHint: "플러그인이 실행 중이 아니면 시작합니다.",
|
||||||
|
previewFailed: "도구 목록을 가져올 수 없습니다: {message}",
|
||||||
|
openConnections: "AI가 사용할 열린 연결: {names}",
|
||||||
|
noOpenConnections: "이 플러그인의 열린 연결이 없습니다. AI에게 이 도구를 사용하게 하려면 먼저 연결을 여세요.",
|
||||||
|
noTools: "이 플러그인은 AI 도구를 제공하지 않습니다.",
|
||||||
|
readOnly: "읽기 전용",
|
||||||
|
needsApproval: "승인 필요",
|
||||||
|
saveFailed: "설정을 저장할 수 없습니다: {message}",
|
||||||
|
},
|
||||||
|
dataAccess: {
|
||||||
|
title: "데이터 액세스",
|
||||||
|
description: "이 플러그인이 읽기 전용 쿼리로 읽을 수 있는 연결입니다. 플러그인이 처음 연결이 필요할 때 동의를 요청하며, 여기에서 언제든지 취소할 수 있습니다.",
|
||||||
|
none: "아직 허용된 연결이 없습니다.",
|
||||||
|
revoke: "취소",
|
||||||
|
missingConnection: "삭제된 연결({id})",
|
||||||
|
revokeFailed: "액세스를 취소할 수 없습니다: {message}",
|
||||||
|
},
|
||||||
shortcutsPluginCenter: "플러그인 센터 오른쪽 (드롭다운)",
|
shortcutsPluginCenter: "플러그인 센터 오른쪽 (드롭다운)",
|
||||||
shortcutsToolbar: "업데이트 버튼 왼쪽 (플로팅)",
|
shortcutsToolbar: "업데이트 버튼 왼쪽 (플로팅)",
|
||||||
shortcutsToolbarCount: "표시할 아이콘 수",
|
shortcutsToolbarCount: "표시할 아이콘 수",
|
||||||
@@ -3107,6 +3130,21 @@ export default withEnglishFallback({
|
|||||||
proposalConfirmYes: "예, 진행",
|
proposalConfirmYes: "예, 진행",
|
||||||
proposalConfirmNo: "아니오",
|
proposalConfirmNo: "아니오",
|
||||||
proposalConfirmTitle: "작업 확인이 필요합니다",
|
proposalConfirmTitle: "작업 확인이 필요합니다",
|
||||||
|
toolApproval: {
|
||||||
|
prompt: "{plugin}이(가) {connection}에서 {tool}을(를) 실행하려고 합니다. 아래 인수를 확인하세요.",
|
||||||
|
approve: "이번만 허용",
|
||||||
|
deny: "거부",
|
||||||
|
expiresAt: "응답이 없으면 {time}에 자동으로 거부됩니다",
|
||||||
|
waiting: "승인 대기 중",
|
||||||
|
answerFailed: "응답을 보낼 수 없습니다: {message}",
|
||||||
|
status: {
|
||||||
|
approved: "승인됨",
|
||||||
|
denied: "거부됨",
|
||||||
|
timed_out: "제시간에 응답하지 않아 실행되지 않았습니다",
|
||||||
|
cancelled: "취소됨",
|
||||||
|
expired: "이 승인 요청은 더 이상 유효하지 않습니다",
|
||||||
|
},
|
||||||
|
},
|
||||||
agentSteps: {
|
agentSteps: {
|
||||||
generated: "SQL 생성됨",
|
generated: "SQL 생성됨",
|
||||||
noSql: "SQL을 찾을 수 없음",
|
noSql: "SQL을 찾을 수 없음",
|
||||||
|
|||||||
@@ -171,6 +171,29 @@ export default withEnglishFallback({
|
|||||||
docs,
|
docs,
|
||||||
dataDictionary,
|
dataDictionary,
|
||||||
pluginPlatform: {
|
pluginPlatform: {
|
||||||
|
dataAccessConsent: '"{name}" quer ler dados da conexão "{connection}". Enquanto essa conexão estiver aberta, o plugin poderá executar consultas somente leitura nela até você revogar o acesso na Central de plugins. Permitir?',
|
||||||
|
dataAccessConsentTitle: "Solicitação de acesso a dados",
|
||||||
|
aiTools: {
|
||||||
|
title: "Ferramentas de IA integradas",
|
||||||
|
description: "Permite que o assistente de IA do DBX (modo Agent) use as ferramentas deste plugin nas conexões do plugin que você tem abertas. Ferramentas não declaradas como somente leitura pedem sua aprovação antes de cada chamada, e a saída delas é enviada ao seu modelo de IA.",
|
||||||
|
preview: "Ver ferramentas",
|
||||||
|
previewHint: "Inicia o plugin se ele não estiver em execução.",
|
||||||
|
previewFailed: "Não foi possível listar as ferramentas: {message}",
|
||||||
|
openConnections: "A IA usaria estas conexões abertas: {names}",
|
||||||
|
noOpenConnections: "Nenhuma conexão deste plugin está aberta. Abra uma antes de pedir à IA que use estas ferramentas.",
|
||||||
|
noTools: "Este plugin não oferece ferramentas de IA.",
|
||||||
|
readOnly: "Somente leitura",
|
||||||
|
needsApproval: "Requer aprovação",
|
||||||
|
saveFailed: "Não foi possível salvar a configuração: {message}",
|
||||||
|
},
|
||||||
|
dataAccess: {
|
||||||
|
title: "Acesso a dados",
|
||||||
|
description: "Conexões que este plugin pode ler com consultas somente leitura. O plugin pergunta a você na primeira vez que precisa de uma conexão; você pode revogar o acesso aqui a qualquer momento.",
|
||||||
|
none: "Nenhuma conexão foi concedida ainda.",
|
||||||
|
revoke: "Revogar",
|
||||||
|
missingConnection: "Conexão excluída ({id})",
|
||||||
|
revokeFailed: "Não foi possível revogar o acesso: {message}",
|
||||||
|
},
|
||||||
shortcutsPluginCenter: "Ao lado da central de plugins (menu suspenso)",
|
shortcutsPluginCenter: "Ao lado da central de plugins (menu suspenso)",
|
||||||
shortcutsToolbar: "Antes de Atualizar (flutuante)",
|
shortcutsToolbar: "Antes de Atualizar (flutuante)",
|
||||||
shortcutsToolbarCount: "Ícones visíveis",
|
shortcutsToolbarCount: "Ícones visíveis",
|
||||||
@@ -3405,6 +3428,21 @@ export default withEnglishFallback({
|
|||||||
clearDatabaseSelection: "Clear selection",
|
clearDatabaseSelection: "Clear selection",
|
||||||
searchDatabases: "Pesquisar bancos de dados…",
|
searchDatabases: "Pesquisar bancos de dados…",
|
||||||
noDatabasesFound: "Nenhum banco de dados encontrado",
|
noDatabasesFound: "Nenhum banco de dados encontrado",
|
||||||
|
toolApproval: {
|
||||||
|
prompt: "{plugin} quer executar {tool} em {connection}. Revise os argumentos abaixo.",
|
||||||
|
approve: "Permitir uma vez",
|
||||||
|
deny: "Negar",
|
||||||
|
expiresAt: "Negado automaticamente às {time} se não houver resposta",
|
||||||
|
waiting: "Aguardando aprovação",
|
||||||
|
answerFailed: "Não foi possível enviar sua resposta: {message}",
|
||||||
|
status: {
|
||||||
|
approved: "Aprovado",
|
||||||
|
denied: "Negado",
|
||||||
|
timed_out: "Sem resposta a tempo; a chamada não foi executada",
|
||||||
|
cancelled: "Cancelado",
|
||||||
|
expired: "Esta aprovação não está mais pendente",
|
||||||
|
},
|
||||||
|
},
|
||||||
agentSteps: {
|
agentSteps: {
|
||||||
generated: "SQL gerado",
|
generated: "SQL gerado",
|
||||||
noSql: "Nenhum SQL encontrado",
|
noSql: "Nenhum SQL encontrado",
|
||||||
|
|||||||
@@ -118,6 +118,29 @@ export default withEnglishFallback({
|
|||||||
name: "DBX",
|
name: "DBX",
|
||||||
},
|
},
|
||||||
pluginPlatform: {
|
pluginPlatform: {
|
||||||
|
dataAccessConsent: "«{name}» запрашивает чтение данных подключения «{connection}». Пока подключение открыто, плагин сможет выполнять в нём запросы только на чтение, пока вы не отзовёте доступ в Центре плагинов. Разрешить?",
|
||||||
|
dataAccessConsentTitle: "Запрос доступа к данным",
|
||||||
|
aiTools: {
|
||||||
|
title: "Встроенные инструменты ИИ",
|
||||||
|
description: "Разрешает ИИ-ассистенту DBX (режим Agent) вызывать инструменты этого плагина на открытых подключениях плагина. Инструменты, не объявленные как только для чтения, запрашивают ваше подтверждение перед каждым вызовом, а их вывод отправляется вашей модели ИИ.",
|
||||||
|
preview: "Показать инструменты",
|
||||||
|
previewHint: "Запускает плагин, если он не работает.",
|
||||||
|
previewFailed: "Не удалось получить список инструментов: {message}",
|
||||||
|
openConnections: "ИИ будет использовать эти открытые подключения: {names}",
|
||||||
|
noOpenConnections: "Нет открытых подключений этого плагина. Откройте подключение, прежде чем просить ИИ использовать эти инструменты.",
|
||||||
|
noTools: "Этот плагин не предоставляет инструментов ИИ.",
|
||||||
|
readOnly: "Только чтение",
|
||||||
|
needsApproval: "Нужно подтверждение",
|
||||||
|
saveFailed: "Не удалось сохранить настройку: {message}",
|
||||||
|
},
|
||||||
|
dataAccess: {
|
||||||
|
title: "Доступ к данным",
|
||||||
|
description: "Подключения, которые этот плагин может читать запросами только на чтение. Плагин спрашивает разрешение, когда подключение понадобится ему впервые; отозвать доступ можно здесь в любой момент.",
|
||||||
|
none: "Доступ ещё не предоставлен ни к одному подключению.",
|
||||||
|
revoke: "Отозвать",
|
||||||
|
missingConnection: "Удалённое подключение ({id})",
|
||||||
|
revokeFailed: "Не удалось отозвать доступ: {message}",
|
||||||
|
},
|
||||||
shortcutsPluginCenter: "Справа от центра плагинов (меню)",
|
shortcutsPluginCenter: "Справа от центра плагинов (меню)",
|
||||||
shortcutsToolbar: "Слева от обновления (плавающая)",
|
shortcutsToolbar: "Слева от обновления (плавающая)",
|
||||||
shortcutsToolbarCount: "Число видимых значков",
|
shortcutsToolbarCount: "Число видимых значков",
|
||||||
@@ -3252,6 +3275,21 @@ export default withEnglishFallback({
|
|||||||
proposalConfirmYes: "Да, продолжить",
|
proposalConfirmYes: "Да, продолжить",
|
||||||
proposalConfirmNo: "Нет, спасибо",
|
proposalConfirmNo: "Нет, спасибо",
|
||||||
proposalConfirmTitle: "Требуется подтверждение действия",
|
proposalConfirmTitle: "Требуется подтверждение действия",
|
||||||
|
toolApproval: {
|
||||||
|
prompt: "{plugin} хочет выполнить {tool} на {connection}. Проверьте аргументы ниже.",
|
||||||
|
approve: "Разрешить один раз",
|
||||||
|
deny: "Отклонить",
|
||||||
|
expiresAt: "Без ответа будет автоматически отклонено в {time}",
|
||||||
|
waiting: "Ожидает подтверждения",
|
||||||
|
answerFailed: "Не удалось отправить ответ: {message}",
|
||||||
|
status: {
|
||||||
|
approved: "Разрешено",
|
||||||
|
denied: "Отклонено",
|
||||||
|
timed_out: "Нет ответа вовремя; вызов не выполнен",
|
||||||
|
cancelled: "Отменено",
|
||||||
|
expired: "Это подтверждение больше не ожидается",
|
||||||
|
},
|
||||||
|
},
|
||||||
agentSteps: {
|
agentSteps: {
|
||||||
generated: "SQL сгенерирован",
|
generated: "SQL сгенерирован",
|
||||||
noSql: "SQL не найден",
|
noSql: "SQL не найден",
|
||||||
|
|||||||
@@ -84,6 +84,29 @@ const consul = {
|
|||||||
|
|
||||||
export default withEnglishFallback({
|
export default withEnglishFallback({
|
||||||
pluginPlatform: {
|
pluginPlatform: {
|
||||||
|
dataAccessConsent: '"{name}", "{connection}" bağlantısındaki verileri okumak istiyor. Bu bağlantı açık olduğu sürece eklenti, Eklenti Merkezi\'nden erişimi iptal edene kadar bağlantıda salt okunur sorgular çalıştırabilir. İzin verilsin mi?',
|
||||||
|
dataAccessConsentTitle: "Veri Erişim İsteği",
|
||||||
|
aiTools: {
|
||||||
|
title: "Yerleşik yapay zekâ araçları",
|
||||||
|
description: "DBX yapay zekâ asistanının (Agent modu) açık olan eklenti bağlantılarında bu eklentinin araçlarını çağırmasına izin verir. Salt okunur olarak bildirilmeyen araçlar her çağrıdan önce onayınızı ister ve araç çıktısı yapay zekâ modelinize gönderilir.",
|
||||||
|
preview: "Araçları göster",
|
||||||
|
previewHint: "Eklenti çalışmıyorsa başlatılır.",
|
||||||
|
previewFailed: "Araçlar listelenemedi: {message}",
|
||||||
|
openConnections: "Yapay zekâ şu açık bağlantıları kullanır: {names}",
|
||||||
|
noOpenConnections: "Bu eklentinin açık bağlantısı yok. Yapay zekâdan bu araçları kullanmasını istemeden önce bir bağlantı açın.",
|
||||||
|
noTools: "Bu eklenti yapay zekâ aracı sağlamıyor.",
|
||||||
|
readOnly: "Salt okunur",
|
||||||
|
needsApproval: "Onay gerekir",
|
||||||
|
saveFailed: "Ayar kaydedilemedi: {message}",
|
||||||
|
},
|
||||||
|
dataAccess: {
|
||||||
|
title: "Veri erişimi",
|
||||||
|
description: "Bu eklentinin salt okunur sorgularla okuyabileceği bağlantılar. Eklenti bir bağlantıya ilk kez ihtiyaç duyduğunda size sorar; erişimi burada istediğiniz zaman iptal edebilirsiniz.",
|
||||||
|
none: "Henüz hiçbir bağlantıya izin verilmedi.",
|
||||||
|
revoke: "İptal et",
|
||||||
|
missingConnection: "Silinmiş bağlantı ({id})",
|
||||||
|
revokeFailed: "Erişim iptal edilemedi: {message}",
|
||||||
|
},
|
||||||
shortcutsPluginCenter: "Eklenti merkezinin yanında (açılır menü)",
|
shortcutsPluginCenter: "Eklenti merkezinin yanında (açılır menü)",
|
||||||
shortcutsToolbar: "Güncelle düğmesinin solunda (kayan)",
|
shortcutsToolbar: "Güncelle düğmesinin solunda (kayan)",
|
||||||
shortcutsToolbarCount: "Görünür simge sayısı",
|
shortcutsToolbarCount: "Görünür simge sayısı",
|
||||||
@@ -3005,6 +3028,21 @@ export default withEnglishFallback({
|
|||||||
proposalConfirmYes: "Evet, devam et",
|
proposalConfirmYes: "Evet, devam et",
|
||||||
proposalConfirmNo: "Hayır, teşekkürler",
|
proposalConfirmNo: "Hayır, teşekkürler",
|
||||||
proposalConfirmTitle: "Eylem onayı gerekli",
|
proposalConfirmTitle: "Eylem onayı gerekli",
|
||||||
|
toolApproval: {
|
||||||
|
prompt: "{plugin}, {connection} üzerinde {tool} çalıştırmak istiyor. Aşağıdaki argümanları inceleyin.",
|
||||||
|
approve: "Bir kez izin ver",
|
||||||
|
deny: "Reddet",
|
||||||
|
expiresAt: "Yanıt verilmezse {time} saatinde otomatik olarak reddedilir",
|
||||||
|
waiting: "Onay bekleniyor",
|
||||||
|
answerFailed: "Yanıtınız gönderilemedi: {message}",
|
||||||
|
status: {
|
||||||
|
approved: "Onaylandı",
|
||||||
|
denied: "Reddedildi",
|
||||||
|
timed_out: "Zamanında yanıt verilmedi; çağrı çalıştırılmadı",
|
||||||
|
cancelled: "İptal edildi",
|
||||||
|
expired: "Bu onay artık beklenmiyor",
|
||||||
|
},
|
||||||
|
},
|
||||||
agentSteps: {
|
agentSteps: {
|
||||||
generated: "SQL üretildi",
|
generated: "SQL üretildi",
|
||||||
noSql: "SQL bulunamadı",
|
noSql: "SQL bulunamadı",
|
||||||
|
|||||||
@@ -93,6 +93,29 @@ export default withEnglishFallback({
|
|||||||
docs,
|
docs,
|
||||||
dataDictionary,
|
dataDictionary,
|
||||||
pluginPlatform: {
|
pluginPlatform: {
|
||||||
|
dataAccessConsent: "「{name}」请求读取连接「{connection}」中的数据。在该连接打开期间,插件可以对它执行只读查询,直到你在插件中心撤销授权。是否允许?",
|
||||||
|
dataAccessConsentTitle: "数据访问请求",
|
||||||
|
aiTools: {
|
||||||
|
title: "内置 AI 工具",
|
||||||
|
description: "允许 DBX AI 助手(Agent 模式)在你已打开的该插件连接上调用插件工具。未声明为只读的工具每次调用前都会请求你确认,工具输出会发送给你配置的 AI 模型。",
|
||||||
|
preview: "查看工具",
|
||||||
|
previewHint: "插件未运行时会先启动它。",
|
||||||
|
previewFailed: "无法获取工具列表:{message}",
|
||||||
|
openConnections: "AI 将使用这些已打开的连接:{names}",
|
||||||
|
noOpenConnections: "该插件当前没有打开的连接。请先打开连接,再让 AI 使用这些工具。",
|
||||||
|
noTools: "该插件未提供 AI 工具。",
|
||||||
|
readOnly: "只读",
|
||||||
|
needsApproval: "需确认",
|
||||||
|
saveFailed: "保存设置失败:{message}",
|
||||||
|
},
|
||||||
|
dataAccess: {
|
||||||
|
title: "数据访问",
|
||||||
|
description: "该插件可以执行只读查询的连接。插件首次需要某个连接时会征求你的同意,你可以随时在这里撤销。",
|
||||||
|
none: "尚未授权任何连接。",
|
||||||
|
revoke: "撤销",
|
||||||
|
missingConnection: "已删除的连接({id})",
|
||||||
|
revokeFailed: "撤销授权失败:{message}",
|
||||||
|
},
|
||||||
shortcutsPluginCenter: "插件中心右侧(下拉)",
|
shortcutsPluginCenter: "插件中心右侧(下拉)",
|
||||||
shortcutsToolbar: "更新按钮左侧(浮动)",
|
shortcutsToolbar: "更新按钮左侧(浮动)",
|
||||||
shortcutsToolbarCount: "主栏显示数量",
|
shortcutsToolbarCount: "主栏显示数量",
|
||||||
@@ -3297,6 +3320,21 @@ export default withEnglishFallback({
|
|||||||
proposalConfirmYes: "是,请继续",
|
proposalConfirmYes: "是,请继续",
|
||||||
proposalConfirmNo: "不用了",
|
proposalConfirmNo: "不用了",
|
||||||
proposalConfirmTitle: "需要确认是否执行以下操作",
|
proposalConfirmTitle: "需要确认是否执行以下操作",
|
||||||
|
toolApproval: {
|
||||||
|
prompt: "「{plugin}」请求在「{connection}」上运行 {tool},请核对下面的参数。",
|
||||||
|
approve: "允许本次",
|
||||||
|
deny: "拒绝",
|
||||||
|
expiresAt: "{time} 前未确认将自动拒绝",
|
||||||
|
waiting: "等待确认",
|
||||||
|
answerFailed: "发送确认结果失败:{message}",
|
||||||
|
status: {
|
||||||
|
approved: "已允许",
|
||||||
|
denied: "已拒绝",
|
||||||
|
timed_out: "未及时确认,未执行",
|
||||||
|
cancelled: "已取消",
|
||||||
|
expired: "该确认已失效",
|
||||||
|
},
|
||||||
|
},
|
||||||
agentSteps: {
|
agentSteps: {
|
||||||
generated: "已生成 SQL",
|
generated: "已生成 SQL",
|
||||||
noSql: "未找到 SQL",
|
noSql: "未找到 SQL",
|
||||||
|
|||||||
@@ -171,6 +171,29 @@ export default withEnglishFallback({
|
|||||||
docs,
|
docs,
|
||||||
dataDictionary,
|
dataDictionary,
|
||||||
pluginPlatform: {
|
pluginPlatform: {
|
||||||
|
dataAccessConsent: "「{name}」請求讀取連線「{connection}」中的資料。在該連線開啟期間,外掛可以對它執行唯讀查詢,直到你在外掛中心撤銷授權。是否允許?",
|
||||||
|
dataAccessConsentTitle: "資料存取請求",
|
||||||
|
aiTools: {
|
||||||
|
title: "內建 AI 工具",
|
||||||
|
description: "允許 DBX AI 助理(Agent 模式)在你已開啟的此外掛連線上呼叫外掛工具。未宣告為唯讀的工具每次呼叫前都會請求你確認,工具輸出會傳送給你設定的 AI 模型。",
|
||||||
|
preview: "檢視工具",
|
||||||
|
previewHint: "外掛未執行時會先啟動它。",
|
||||||
|
previewFailed: "無法取得工具清單:{message}",
|
||||||
|
openConnections: "AI 將使用這些已開啟的連線:{names}",
|
||||||
|
noOpenConnections: "此外掛目前沒有開啟的連線。請先開啟連線,再讓 AI 使用這些工具。",
|
||||||
|
noTools: "此外掛未提供 AI 工具。",
|
||||||
|
readOnly: "唯讀",
|
||||||
|
needsApproval: "需確認",
|
||||||
|
saveFailed: "儲存設定失敗:{message}",
|
||||||
|
},
|
||||||
|
dataAccess: {
|
||||||
|
title: "資料存取",
|
||||||
|
description: "此外掛可以執行唯讀查詢的連線。外掛首次需要某個連線時會徵求你的同意,你可以隨時在這裡撤銷。",
|
||||||
|
none: "尚未授權任何連線。",
|
||||||
|
revoke: "撤銷",
|
||||||
|
missingConnection: "已刪除的連線({id})",
|
||||||
|
revokeFailed: "撤銷授權失敗:{message}",
|
||||||
|
},
|
||||||
shortcutsPluginCenter: "外掛中心右側(下拉)",
|
shortcutsPluginCenter: "外掛中心右側(下拉)",
|
||||||
shortcutsToolbar: "更新按鈕左側(浮動)",
|
shortcutsToolbar: "更新按鈕左側(浮動)",
|
||||||
shortcutsToolbarCount: "主列顯示數量",
|
shortcutsToolbarCount: "主列顯示數量",
|
||||||
@@ -3271,6 +3294,21 @@ export default withEnglishFallback({
|
|||||||
templateDefaultBadgeTitle: "{type} 的預設範本",
|
templateDefaultBadgeTitle: "{type} 的預設範本",
|
||||||
exportMarkdown: "匯出為 Markdown",
|
exportMarkdown: "匯出為 Markdown",
|
||||||
analysis: "AI 分析",
|
analysis: "AI 分析",
|
||||||
|
toolApproval: {
|
||||||
|
prompt: "「{plugin}」請求在「{connection}」上執行 {tool},請核對下方的參數。",
|
||||||
|
approve: "允許本次",
|
||||||
|
deny: "拒絕",
|
||||||
|
expiresAt: "{time} 前未確認將自動拒絕",
|
||||||
|
waiting: "等待確認",
|
||||||
|
answerFailed: "傳送確認結果失敗:{message}",
|
||||||
|
status: {
|
||||||
|
approved: "已允許",
|
||||||
|
denied: "已拒絕",
|
||||||
|
timed_out: "未及時確認,未執行",
|
||||||
|
cancelled: "已取消",
|
||||||
|
expired: "此確認已失效",
|
||||||
|
},
|
||||||
|
},
|
||||||
agentSteps: {
|
agentSteps: {
|
||||||
generated: "已產生 SQL",
|
generated: "已產生 SQL",
|
||||||
noSql: "未找到 SQL",
|
noSql: "未找到 SQL",
|
||||||
|
|||||||
@@ -217,3 +217,22 @@ describe("AI schema selector visibility", () => {
|
|||||||
expect(resolveAiDatabaseTarget(queryTab("app", "public"), postgres)).toEqual({ database: "app", schema: "public" });
|
expect(resolveAiDatabaseTarget(queryTab("app", "public"), postgres)).toEqual({ database: "app", schema: "public" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("Plugin AI context", () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it.each([false, true])("skips database metadata even with stale table context: %s", async (withTable) => {
|
||||||
|
const connection: ConnectionConfig = { ...postgresConnection(), db_type: "plugin", plugin_id: "sample.plugin" };
|
||||||
|
const tab = { ...queryTab("stale-db", "public"), connectionId: connection.id, tableMeta: withTable ? { schema: "public", tableName: "stale_table", columns: [] } : undefined };
|
||||||
|
const context = await buildAiContext(tab, connection, { mentionedTables: [{ schema: "public", table: "stale_mention" }], sqlFiles: [{ name: "notes.sql", content: "SELECT 1" }] });
|
||||||
|
expect(context.databaseType).toBe("plugin");
|
||||||
|
expect(context.connectionName).toBe(connection.name);
|
||||||
|
expect(context.tables).toEqual([]);
|
||||||
|
expect(context.truncated).toBe(false);
|
||||||
|
expect(context.sqlFiles).toEqual([{ name: "notes.sql", content: "SELECT 1" }]);
|
||||||
|
expect(apiMock.listTables).not.toHaveBeenCalled();
|
||||||
|
expect(apiMock.getColumns).not.toHaveBeenCalled();
|
||||||
|
expect(apiMock.listIndexes).not.toHaveBeenCalled();
|
||||||
|
expect(apiMock.listForeignKeys).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { formatToolDurationMs, toolCallStepKey, upsertAgentStep, type AiAgentStepItem } from "@/lib/ai/aiAgentStepPresentation";
|
import { formatAgentToolName, formatToolDurationMs, toolCallStepKey, updateAgentStepApproval, upsertAgentStep, type AiAgentStepApproval, type AiAgentStepItem } from "@/lib/ai/aiAgentStepPresentation";
|
||||||
|
|
||||||
const startStep: AiAgentStepItem = {
|
const startStep: AiAgentStepItem = {
|
||||||
key: "tool-call-1",
|
key: "tool-call-1",
|
||||||
@@ -76,6 +76,38 @@ describe("aiAgentStepPresentation", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("plugin tool approvals", () => {
|
||||||
|
const approval: AiAgentStepApproval = {
|
||||||
|
approvalId: "a1",
|
||||||
|
sessionId: "s1",
|
||||||
|
pluginName: "SSH",
|
||||||
|
pluginTool: "ssh_exec",
|
||||||
|
connectionName: "prod",
|
||||||
|
args: { command: "uptime" },
|
||||||
|
expiresAtMs: 5_000,
|
||||||
|
status: "pending",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("attaches the approval to the tool card and keeps it when the call ends", () => {
|
||||||
|
const steps: AiAgentStepItem[] = [];
|
||||||
|
upsertAgentStep(steps, startStep);
|
||||||
|
expect(updateAgentStepApproval(steps, "tool-call-1", () => approval)).toBe(true);
|
||||||
|
expect(updateAgentStepApproval(steps, "tool-missing", () => approval)).toBe(false);
|
||||||
|
updateAgentStepApproval(steps, "tool-call-1", (current) => (current ? { ...current, status: "approved" } : current));
|
||||||
|
upsertAgentStep(steps, { key: "tool-call-1", labelKey: "ai.agentSteps.toolDone", tone: "success", toolName: "list_tables", toolResult: "ok", endedAtMs: 2_000 });
|
||||||
|
expect(steps).toHaveLength(1);
|
||||||
|
expect(steps[0].approval).toEqual({ ...approval, status: "approved" });
|
||||||
|
expect(steps[0].toolResult).toBe("ok");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("formats plugin tool names with their provenance and leaves built-in names alone", () => {
|
||||||
|
expect(formatAgentToolName("kafka__kafka_topics_list")).toBe("kafka › kafka_topics_list");
|
||||||
|
expect(formatAgentToolName("execute_query")).toBe("execute_query");
|
||||||
|
expect(formatAgentToolName("__odd")).toBe("__odd");
|
||||||
|
expect(formatAgentToolName("odd__")).toBe("odd__");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("formatToolDurationMs", () => {
|
describe("formatToolDurationMs", () => {
|
||||||
it("formats sub-second durations with one decimal", () => {
|
it("formats sub-second durations with one decimal", () => {
|
||||||
expect(formatToolDurationMs(799)).toBe("0.8s");
|
expect(formatToolDurationMs(799)).toBe("0.8s");
|
||||||
|
|||||||
@@ -401,6 +401,38 @@ describe("aiGenerationStatus", () => {
|
|||||||
it("falls back to the raw snake_case name for unknown tools", () => {
|
it("falls back to the raw snake_case name for unknown tools", () => {
|
||||||
expect(toolLabel("mystery_tool", t)).toBe("mystery_tool");
|
expect(toolLabel("mystery_tool", t)).toBe("mystery_tool");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("shows plugin tools with their plugin prefix as provenance", () => {
|
||||||
|
expect(toolLabel("ssh__ssh_exec", t)).toBe("ssh › ssh_exec");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("plugin tool approvals", () => {
|
||||||
|
it("keep the approving tool active without changing the phase", () => {
|
||||||
|
let status = applyStatusEvent(createGenerationStatus(T0), { type: "tool_call_start", tool_call_id: "c1", tool_name: "ssh__ssh_exec", args: {} }, T0 + 1);
|
||||||
|
status = applyStatusEvent(
|
||||||
|
status,
|
||||||
|
{
|
||||||
|
type: "tool_approval_required",
|
||||||
|
approval_id: "a1",
|
||||||
|
tool_call_id: "c1",
|
||||||
|
tool_name: "ssh__ssh_exec",
|
||||||
|
plugin_id: "io.dbx.ssh",
|
||||||
|
plugin_name: "SSH",
|
||||||
|
plugin_tool: "ssh_exec",
|
||||||
|
connection_id: "s1",
|
||||||
|
connection_name: "prod",
|
||||||
|
args: { command: "uptime" },
|
||||||
|
timeout_secs: 300,
|
||||||
|
},
|
||||||
|
T0 + 2,
|
||||||
|
);
|
||||||
|
expect(status.phase).toBe("running_tool");
|
||||||
|
expect(status.activeTool?.name).toBe("ssh__ssh_exec");
|
||||||
|
status = applyStatusEvent(status, { type: "tool_approval_resolved", approval_id: "a1", tool_call_id: "c1", outcome: "approved" }, T0 + 3);
|
||||||
|
expect(status.phase).toBe("running_tool");
|
||||||
|
expect(status.lastEventAt).toBe(T0 + 3);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("status ticker (createStatusTicker / nextStatusTickDelay)", () => {
|
describe("status ticker (createStatusTicker / nextStatusTickDelay)", () => {
|
||||||
|
|||||||
@@ -784,13 +784,14 @@ export async function buildAiContext(
|
|||||||
const maxFksPerTable = options.maxFksPerTable ?? 10;
|
const maxFksPerTable = options.maxFksPerTable ?? 10;
|
||||||
const databaseType = aiDatabaseTypeForConnection(connection);
|
const databaseType = aiDatabaseTypeForConnection(connection);
|
||||||
const { database, schema } = resolveAiDatabaseTarget(tab, connection);
|
const { database, schema } = resolveAiDatabaseTarget(tab, connection);
|
||||||
|
const supportsMetadata = connection.db_type !== "plugin";
|
||||||
const tables: AiSchemaTable[] = [];
|
const tables: AiSchemaTable[] = [];
|
||||||
const tableKeys = new Set<string>();
|
const tableKeys = new Set<string>();
|
||||||
let truncated = false;
|
let truncated = false;
|
||||||
let schemaScope: AiContext["schemaScope"] = "database";
|
let schemaScope: AiContext["schemaScope"] = "database";
|
||||||
let currentCollectionName: string | undefined;
|
let currentCollectionName: string | undefined;
|
||||||
|
|
||||||
if (tab.tableMeta) {
|
if (supportsMetadata && tab.tableMeta) {
|
||||||
schemaScope = "focused_table";
|
schemaScope = "focused_table";
|
||||||
const s = tab.tableMeta.schema ?? "";
|
const s = tab.tableMeta.schema ?? "";
|
||||||
const tName = tab.tableMeta.tableName;
|
const tName = tab.tableMeta.tableName;
|
||||||
@@ -809,7 +810,7 @@ export async function buildAiContext(
|
|||||||
truncated = tab.tableMeta.columns.length > maxColumnsPerTable;
|
truncated = tab.tableMeta.columns.length > maxColumnsPerTable;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const mention of options.mentionedTables ?? []) {
|
for (const mention of supportsMetadata ? (options.mentionedTables ?? []) : []) {
|
||||||
const key = aiTableMentionKey(mention.schema, mention.table);
|
const key = aiTableMentionKey(mention.schema, mention.table);
|
||||||
if (tableKeys.has(key)) continue;
|
if (tableKeys.has(key)) continue;
|
||||||
const entry = await loadMentionedTableContext(tab, connection, mention, maxColumnsPerTable, maxIndexesPerTable, maxFksPerTable).catch(() => undefined);
|
const entry = await loadMentionedTableContext(tab, connection, mention, maxColumnsPerTable, maxIndexesPerTable, maxFksPerTable).catch(() => undefined);
|
||||||
@@ -819,7 +820,7 @@ export async function buildAiContext(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Vector databases: load collections instead of SQL tables
|
// Vector databases: load collections instead of SQL tables
|
||||||
if (isVectorDbType(databaseType)) {
|
if (supportsMetadata && isVectorDbType(databaseType)) {
|
||||||
try {
|
try {
|
||||||
const collections = await api.vectorListCollections(tab.connectionId, database);
|
const collections = await api.vectorListCollections(tab.connectionId, database);
|
||||||
|
|
||||||
@@ -854,7 +855,7 @@ export async function buildAiContext(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!tab.tableMeta && !["redis", "mongodb"].includes(connection.db_type) && !isVectorDbType(databaseType)) {
|
if (supportsMetadata && !tab.tableMeta && !["redis", "mongodb"].includes(connection.db_type) && !isVectorDbType(databaseType)) {
|
||||||
try {
|
try {
|
||||||
const schemas = await loadCandidateSchemas(tab, connection);
|
const schemas = await loadCandidateSchemas(tab, connection);
|
||||||
for (const schema of schemas) {
|
for (const schema of schemas) {
|
||||||
|
|||||||
@@ -1,7 +1,29 @@
|
|||||||
import type { AiAgentPlan, AiAgentStep } from "@/lib/ai/aiAgentPlan";
|
import type { AiAgentPlan, AiAgentStep } from "@/lib/ai/aiAgentPlan";
|
||||||
|
import type { AiToolApprovalOutcome } from "@/types/pluginAiTools";
|
||||||
|
|
||||||
export type AiAgentStepTone = "success" | "active" | "warning" | "danger" | "muted";
|
export type AiAgentStepTone = "success" | "active" | "warning" | "danger" | "muted";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Approval state of a plugin tool call that may change state. `pending` shows
|
||||||
|
* the approve/deny buttons; `submitting` is the in-flight answer; `expired`
|
||||||
|
* means the backend no longer waited when the answer arrived.
|
||||||
|
*/
|
||||||
|
export type AiAgentStepApprovalStatus = "pending" | "submitting" | "expired" | AiToolApprovalOutcome;
|
||||||
|
|
||||||
|
export interface AiAgentStepApproval {
|
||||||
|
approvalId: string;
|
||||||
|
/** Agent session that asked; the answer must go back to the same run. */
|
||||||
|
sessionId: string;
|
||||||
|
pluginName: string;
|
||||||
|
pluginTool: string;
|
||||||
|
connectionName: string;
|
||||||
|
/** Exactly the arguments DBX forwards when approved. */
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
/** Wall-clock deadline after which the backend treats the call as denied. */
|
||||||
|
expiresAtMs: number;
|
||||||
|
status: AiAgentStepApprovalStatus;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AiAgentStepItem {
|
export interface AiAgentStepItem {
|
||||||
key: string;
|
key: string;
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
@@ -24,6 +46,8 @@ export interface AiAgentStepItem {
|
|||||||
endedAtMs?: number;
|
endedAtMs?: number;
|
||||||
/** Computed tool duration (endedAtMs - startedAtMs), present only when both stamps exist */
|
/** Computed tool duration (endedAtMs - startedAtMs), present only when both stamps exist */
|
||||||
durationMs?: number;
|
durationMs?: number;
|
||||||
|
/** Present while (and after) a plugin tool call asked the user for approval */
|
||||||
|
approval?: AiAgentStepApproval;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Backend fallback tool_call_id values that repeat across calls and must not be used as stable merge keys. */
|
/** Backend fallback tool_call_id values that repeat across calls and must not be used as stable merge keys. */
|
||||||
@@ -52,6 +76,7 @@ export function upsertAgentStep(steps: AiAgentStepItem[], step: AiAgentStepItem)
|
|||||||
if (!merged.explainData && existing.explainData) merged.explainData = existing.explainData;
|
if (!merged.explainData && existing.explainData) merged.explainData = existing.explainData;
|
||||||
if (!merged.titleKey && existing.titleKey) merged.titleKey = existing.titleKey;
|
if (!merged.titleKey && existing.titleKey) merged.titleKey = existing.titleKey;
|
||||||
if (!merged.titleParams && existing.titleParams) merged.titleParams = existing.titleParams;
|
if (!merged.titleParams && existing.titleParams) merged.titleParams = existing.titleParams;
|
||||||
|
if (!merged.approval && existing.approval) merged.approval = existing.approval;
|
||||||
if (merged.startedAtMs === undefined && existing.startedAtMs !== undefined) merged.startedAtMs = existing.startedAtMs;
|
if (merged.startedAtMs === undefined && existing.startedAtMs !== undefined) merged.startedAtMs = existing.startedAtMs;
|
||||||
if (merged.endedAtMs !== undefined && merged.startedAtMs !== undefined) {
|
if (merged.endedAtMs !== undefined && merged.startedAtMs !== undefined) {
|
||||||
merged.durationMs = Math.max(0, merged.endedAtMs - merged.startedAtMs);
|
merged.durationMs = Math.max(0, merged.endedAtMs - merged.startedAtMs);
|
||||||
@@ -59,6 +84,28 @@ export function upsertAgentStep(steps: AiAgentStepItem[], step: AiAgentStepItem)
|
|||||||
steps.splice(idx, 1, merged);
|
steps.splice(idx, 1, merged);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply an approval change to the step keyed `toolCallKey`. Returns false when
|
||||||
|
* the card does not exist (yet), so callers can decide whether to create it.
|
||||||
|
*/
|
||||||
|
export function updateAgentStepApproval(steps: AiAgentStepItem[], toolCallKey: string, update: (approval: AiAgentStepApproval | undefined) => AiAgentStepApproval | undefined): boolean {
|
||||||
|
const idx = steps.findIndex((step) => step.key === toolCallKey);
|
||||||
|
if (idx < 0) return false;
|
||||||
|
const approval = update(steps[idx].approval);
|
||||||
|
steps.splice(idx, 1, { ...steps[idx], approval });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `ssh__ssh_exec` → `ssh › ssh_exec`: plugin tools carry a DBX prefix that the
|
||||||
|
* card shows as provenance; built-in tool names pass through unchanged.
|
||||||
|
*/
|
||||||
|
export function formatAgentToolName(toolName: string): string {
|
||||||
|
const separator = toolName.indexOf("__");
|
||||||
|
if (separator <= 0 || separator + 2 >= toolName.length) return toolName;
|
||||||
|
return `${toolName.slice(0, separator)} › ${toolName.slice(separator + 2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format a tool duration for the step-card tail: sub-second → one decimal
|
* Format a tool duration for the step-card tail: sub-second → one decimal
|
||||||
* ("0.8s"); 1s–10s → one decimal ("1.2s"); ≥10s → integer ("12s").
|
* ("0.8s"); 1s–10s → one decimal ("1.2s"); ≥10s → integer ("12s").
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
* by `tool_call_start`/`tool_call_end`, not by `assistantMode`.
|
* by `tool_call_start`/`tool_call_end`, not by `assistantMode`.
|
||||||
*/
|
*/
|
||||||
import type { AgentEvent } from "@/lib/backend/tauri";
|
import type { AgentEvent } from "@/lib/backend/tauri";
|
||||||
|
import { formatAgentToolName } from "@/lib/ai/aiAgentStepPresentation";
|
||||||
|
|
||||||
export interface AiGenerationStatus {
|
export interface AiGenerationStatus {
|
||||||
/** When `send()` set `isGenerating=true`. Elapsed time is derived from this. */
|
/** When `send()` set `isGenerating=true`. Elapsed time is derived from this. */
|
||||||
@@ -56,7 +57,7 @@ export type AiStatusTranslate = {
|
|||||||
(key: string, named: Record<string, unknown>): string;
|
(key: string, named: Record<string, unknown>): string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** i18n key per agent tool name; unknown tools fall back to the raw snake_case name. */
|
/** i18n key per agent tool name; unknown tools fall back to the raw name (plugin tools as `prefix › tool`). */
|
||||||
export const STATUS_TOOL_LABEL_KEYS: Record<string, string> = {
|
export const STATUS_TOOL_LABEL_KEYS: Record<string, string> = {
|
||||||
execute_query: "ai.status.toolLabels.executeQuery",
|
execute_query: "ai.status.toolLabels.executeQuery",
|
||||||
execute_sql: "ai.status.toolLabels.executeSql",
|
execute_sql: "ai.status.toolLabels.executeSql",
|
||||||
@@ -148,7 +149,10 @@ export function applyStatusEvent(status: AiGenerationStatus, event: AgentEvent,
|
|||||||
case "write_sql_confirmation_required":
|
case "write_sql_confirmation_required":
|
||||||
case "production_write_blocked":
|
case "production_write_blocked":
|
||||||
case "context_compacted":
|
case "context_compacted":
|
||||||
// Only refresh lastEventAt above; do NOT change phase.
|
case "tool_approval_required":
|
||||||
|
case "tool_approval_resolved":
|
||||||
|
// Only refresh lastEventAt above; do NOT change phase. A pending plugin
|
||||||
|
// tool approval keeps its tool active until the call ends.
|
||||||
break;
|
break;
|
||||||
case "agent_end":
|
case "agent_end":
|
||||||
case "error":
|
case "error":
|
||||||
@@ -186,7 +190,7 @@ export function markCancelling(status: AiGenerationStatus, now: number): AiGener
|
|||||||
/** Resolve a snake_case tool name through the i18n label map, falling back to the raw name. */
|
/** Resolve a snake_case tool name through the i18n label map, falling back to the raw name. */
|
||||||
export function toolLabel(toolName: string, t: AiStatusTranslate): string {
|
export function toolLabel(toolName: string, t: AiStatusTranslate): string {
|
||||||
const key = STATUS_TOOL_LABEL_KEYS[toolName];
|
const key = STATUS_TOOL_LABEL_KEYS[toolName];
|
||||||
return key ? t(key) : toolName;
|
return key ? t(key) : formatAgentToolName(toolName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Round a duration up to whole seconds, never below 0 (guards a stale timer -1s). */
|
/** Round a duration up to whole seconds, never below 0 (guards a stale timer -1s). */
|
||||||
|
|||||||
@@ -282,6 +282,9 @@ export const buildExplainSql = forward("buildExplainSql");
|
|||||||
export const getExplainInfo = forward("getExplainInfo");
|
export const getExplainInfo = forward("getExplainInfo");
|
||||||
export const getPluginPlanCapabilities = forward("getPluginPlanCapabilities");
|
export const getPluginPlanCapabilities = forward("getPluginPlanCapabilities");
|
||||||
export const getPluginEstimatedPlan = forward("getPluginEstimatedPlan");
|
export const getPluginEstimatedPlan = forward("getPluginEstimatedPlan");
|
||||||
|
export const queryPluginData = forward("queryPluginData");
|
||||||
|
export const getPluginDataGrants = forward("getPluginDataGrants");
|
||||||
|
export const setPluginDataGrant = forward("setPluginDataGrant");
|
||||||
export const buildCreateUserSql = forward("buildCreateUserSql");
|
export const buildCreateUserSql = forward("buildCreateUserSql");
|
||||||
export const buildDroppedFilePreviewSql = forward("buildDroppedFilePreviewSql");
|
export const buildDroppedFilePreviewSql = forward("buildDroppedFilePreviewSql");
|
||||||
export const buildTableSelectSql = forward("buildTableSelectSql");
|
export const buildTableSelectSql = forward("buildTableSelectSql");
|
||||||
@@ -345,6 +348,10 @@ export const aiComplete = forward("aiComplete");
|
|||||||
export const aiStream = forward("aiStream");
|
export const aiStream = forward("aiStream");
|
||||||
export const aiAgentStream = forward("aiAgentStream");
|
export const aiAgentStream = forward("aiAgentStream");
|
||||||
export const aiCancelStream = forward("aiCancelStream");
|
export const aiCancelStream = forward("aiCancelStream");
|
||||||
|
export const resolveAiToolApproval = forward("resolveAiToolApproval");
|
||||||
|
export const getAiPluginToolPlugins = forward("getAiPluginToolPlugins");
|
||||||
|
export const setAiPluginToolPluginEnabled = forward("setAiPluginToolPluginEnabled");
|
||||||
|
export const previewPluginAiTools = forward("previewPluginAiTools");
|
||||||
export const aiTestConnection = forward("aiTestConnection");
|
export const aiTestConnection = forward("aiTestConnection");
|
||||||
export const aiListModels = forward("aiListModels");
|
export const aiListModels = forward("aiListModels");
|
||||||
export const aiResolveModelEffort = forward("aiResolveModelEffort");
|
export const aiResolveModelEffort = forward("aiResolveModelEffort");
|
||||||
|
|||||||
@@ -198,6 +198,8 @@ import type {
|
|||||||
} from "@/lib/backend/tauri";
|
} from "@/lib/backend/tauri";
|
||||||
import type { QueryEditability } from "@/lib/sql/sqlAnalysis";
|
import type { QueryEditability } from "@/lib/sql/sqlAnalysis";
|
||||||
import type { PluginTableMetadata, PluginTableMetadataRequest } from "@/types/pluginSchemaMetadata";
|
import type { PluginTableMetadata, PluginTableMetadataRequest } from "@/types/pluginSchemaMetadata";
|
||||||
|
import type { PluginDataGrant, PluginDataQueryRequest, PluginDataQueryResult } from "@/types/pluginData";
|
||||||
|
import type { PluginToolPreview } from "@/types/pluginAiTools";
|
||||||
import type { CsvQuoteMode } from "@/lib/export/csvQuoteMode";
|
import type { CsvQuoteMode } from "@/lib/export/csvQuoteMode";
|
||||||
import type { MigrationPreflight, MigrationReport } from "./migration";
|
import type { MigrationPreflight, MigrationReport } from "./migration";
|
||||||
export type { MigrationPreflight, MigrationReport } from "./migration";
|
export type { MigrationPreflight, MigrationReport } from "./migration";
|
||||||
@@ -1680,6 +1682,22 @@ export async function getPluginEstimatedPlan(request: PluginPlanRequest): Promis
|
|||||||
return post<PluginPlanResult>("/api/query/plugin-estimated-plan", request);
|
return post<PluginPlanResult>("/api/query/plugin-estimated-plan", request);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plugin Host API (`host.data:read`): one read-only statement on a connection
|
||||||
|
* the user granted to `pluginId`. The backend enforces permission, grant, and gate.
|
||||||
|
*/
|
||||||
|
export async function queryPluginData(pluginId: string, request: PluginDataQueryRequest): Promise<PluginDataQueryResult> {
|
||||||
|
return post<PluginDataQueryResult>("/api/plugin/data/query", { pluginId, request });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPluginDataGrants(pluginId: string): Promise<PluginDataGrant[]> {
|
||||||
|
return post<PluginDataGrant[]>("/api/plugin/data/grants", { pluginId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setPluginDataGrant(pluginId: string, connectionId: string, granted: boolean): Promise<PluginDataGrant[]> {
|
||||||
|
return post<PluginDataGrant[]>("/api/plugin/data/grant", { pluginId, connectionId, granted });
|
||||||
|
}
|
||||||
|
|
||||||
export async function buildDroppedFilePreviewSql(options: DroppedFilePreviewSqlOptions): Promise<string | undefined> {
|
export async function buildDroppedFilePreviewSql(options: DroppedFilePreviewSqlOptions): Promise<string | undefined> {
|
||||||
const result = await post<string | null>("/api/query/build-dropped-file-preview-sql", { options });
|
const result = await post<string | null>("/api/query/build-dropped-file-preview-sql", { options });
|
||||||
return result ?? undefined;
|
return result ?? undefined;
|
||||||
@@ -1981,6 +1999,25 @@ export async function aiCancelStream(sessionId: string): Promise<boolean> {
|
|||||||
return post("/api/ai/cancel-stream", { sessionId });
|
return post("/api/ai/cancel-stream", { sessionId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Answers a pending plugin tool approval of the agent run `sessionId`; false when nothing was waiting. */
|
||||||
|
export async function resolveAiToolApproval(sessionId: string, approvalId: string, approved: boolean): Promise<boolean> {
|
||||||
|
return post("/api/ai/tool-approval", { sessionId, approvalId, approved });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plugin ids whose MCP tools the built-in AI agent may call. */
|
||||||
|
export async function getAiPluginToolPlugins(): Promise<string[]> {
|
||||||
|
return get("/api/ai/plugin-tools/plugins");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setAiPluginToolPluginEnabled(pluginId: string, enabled: boolean): Promise<string[]> {
|
||||||
|
return post("/api/ai/plugin-tools/plugins", { pluginId, enabled });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tools the built-in AI would get from a plugin (may start its sidecar). */
|
||||||
|
export async function previewPluginAiTools(pluginId: string): Promise<PluginToolPreview> {
|
||||||
|
return post("/api/ai/plugin-tools/preview", { pluginId });
|
||||||
|
}
|
||||||
|
|
||||||
export async function aiTestConnection(config: AiConfig): Promise<AiTestConnectionResult> {
|
export async function aiTestConnection(config: AiConfig): Promise<AiTestConnectionResult> {
|
||||||
return post("/api/ai/test-connection", { config });
|
return post("/api/ai/test-connection", { config });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ import { collectBrowserSupportInfo } from "@/lib/app/supportInfo";
|
|||||||
// for this module's own signatures (a re-export does not bind local names).
|
// for this module's own signatures (a re-export does not bind local names).
|
||||||
import type { PluginPlanCapabilities, PluginPlanRequest, PluginPlanResult } from "@/types/pluginPlan";
|
import type { PluginPlanCapabilities, PluginPlanRequest, PluginPlanResult } from "@/types/pluginPlan";
|
||||||
import type { PluginTableMetadata, PluginTableMetadataRequest } from "@/types/pluginSchemaMetadata";
|
import type { PluginTableMetadata, PluginTableMetadataRequest } from "@/types/pluginSchemaMetadata";
|
||||||
|
import type { PluginDataGrant, PluginDataQueryRequest, PluginDataQueryResult } from "@/types/pluginData";
|
||||||
|
import type { AiToolApprovalOutcome, PluginToolPreview } from "@/types/pluginAiTools";
|
||||||
|
|
||||||
function invoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
function invoke<T>(command: string, args?: Record<string, unknown>): Promise<T> {
|
||||||
assertUpdateAllowsCommand(command);
|
assertUpdateAllowsCommand(command);
|
||||||
@@ -600,6 +602,22 @@ export type AgentEvent =
|
|||||||
tool_name: string;
|
tool_name: string;
|
||||||
args: Record<string, unknown>;
|
args: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
| {
|
||||||
|
/** A plugin tool call that may change state waits for the user's approval; the run is paused. */
|
||||||
|
type: "tool_approval_required";
|
||||||
|
approval_id: string;
|
||||||
|
tool_call_id: string;
|
||||||
|
tool_name: string;
|
||||||
|
plugin_id: string;
|
||||||
|
plugin_name: string;
|
||||||
|
plugin_tool: string;
|
||||||
|
connection_id: string;
|
||||||
|
connection_name: string;
|
||||||
|
/** Exactly the arguments DBX forwards if the user approves. */
|
||||||
|
args: Record<string, unknown>;
|
||||||
|
timeout_secs: number;
|
||||||
|
}
|
||||||
|
| { type: "tool_approval_resolved"; approval_id: string; tool_call_id: string; outcome: AiToolApprovalOutcome }
|
||||||
| {
|
| {
|
||||||
type: "tool_call_end";
|
type: "tool_call_end";
|
||||||
tool_call_id: string;
|
tool_call_id: string;
|
||||||
@@ -716,6 +734,25 @@ export async function aiCancelStream(sessionId: string): Promise<boolean> {
|
|||||||
return invoke("ai_cancel_stream", { sessionId });
|
return invoke("ai_cancel_stream", { sessionId });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Answers a pending plugin tool approval of the agent run `sessionId`; false when nothing was waiting. */
|
||||||
|
export async function resolveAiToolApproval(sessionId: string, approvalId: string, approved: boolean): Promise<boolean> {
|
||||||
|
return invoke("ai_resolve_tool_approval", { sessionId, approvalId, approved });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Plugin ids whose MCP tools the built-in AI agent may call. */
|
||||||
|
export async function getAiPluginToolPlugins(): Promise<string[]> {
|
||||||
|
return invoke("get_ai_plugin_tool_plugins");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setAiPluginToolPluginEnabled(pluginId: string, enabled: boolean): Promise<string[]> {
|
||||||
|
return invoke("set_ai_plugin_tool_plugin_enabled", { pluginId, enabled });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tools the built-in AI would get from a plugin (may start its sidecar). */
|
||||||
|
export async function previewPluginAiTools(pluginId: string): Promise<PluginToolPreview> {
|
||||||
|
return invoke("preview_plugin_ai_tools", { pluginId });
|
||||||
|
}
|
||||||
|
|
||||||
export async function saveAiConfigs(configs: AiConfigItem[]): Promise<void> {
|
export async function saveAiConfigs(configs: AiConfigItem[]): Promise<void> {
|
||||||
return invoke("save_ai_configs", { configs });
|
return invoke("save_ai_configs", { configs });
|
||||||
}
|
}
|
||||||
@@ -1932,6 +1969,22 @@ export async function getPluginEstimatedPlan(request: PluginPlanRequest): Promis
|
|||||||
return invoke<PluginPlanResult>("get_plugin_estimated_plan", { request });
|
return invoke<PluginPlanResult>("get_plugin_estimated_plan", { request });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plugin Host API (`host.data:read`): one read-only statement on a connection
|
||||||
|
* the user granted to `pluginId`. The backend enforces permission, grant, and gate.
|
||||||
|
*/
|
||||||
|
export async function queryPluginData(pluginId: string, request: PluginDataQueryRequest): Promise<PluginDataQueryResult> {
|
||||||
|
return invoke<PluginDataQueryResult>("query_plugin_data", { pluginId, request });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getPluginDataGrants(pluginId: string): Promise<PluginDataGrant[]> {
|
||||||
|
return invoke<PluginDataGrant[]>("get_plugin_data_grants", { pluginId });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setPluginDataGrant(pluginId: string, connectionId: string, granted: boolean): Promise<PluginDataGrant[]> {
|
||||||
|
return invoke<PluginDataGrant[]>("set_plugin_data_grant", { pluginId, connectionId, granted });
|
||||||
|
}
|
||||||
|
|
||||||
export async function buildDroppedFilePreviewSql(options: DroppedFilePreviewSqlOptions): Promise<string | undefined> {
|
export async function buildDroppedFilePreviewSql(options: DroppedFilePreviewSqlOptions): Promise<string | undefined> {
|
||||||
const result = await invoke<string | null>("build_dropped_file_preview_sql", {
|
const result = await invoke<string | null>("build_dropped_file_preview_sql", {
|
||||||
options,
|
options,
|
||||||
|
|||||||
+122
-1
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { reactive, readonly } from "vue";
|
import { reactive, readonly } from "vue";
|
||||||
import { PLUGIN_CLIPBOARD_AUDIT_CAPACITY, PluginHostBridge, clipboardReadGateAllows, createClipboardReadGate, pluginSandboxDocument, pluginSdkSource, recordClipboardRead } from "./pluginHostBridge";
|
import { MAX_CONCURRENT_PLUGIN_DATA_QUERIES, PLUGIN_CLIPBOARD_AUDIT_CAPACITY, PluginHostBridge, clipboardReadGateAllows, createClipboardReadGate, pluginSandboxDocument, pluginSdkSource, recordClipboardRead } from "./pluginHostBridge";
|
||||||
import type { InstalledPlugin, PluginResultViewContribution, PluginWorkbenchContribution } from "@/types/database";
|
import type { InstalledPlugin, PluginResultViewContribution, PluginWorkbenchContribution } from "@/types/database";
|
||||||
|
|
||||||
function plugin(permissions: string[] = [], contributions: InstalledPlugin["manifest"]["contributions"] = []): InstalledPlugin {
|
function plugin(permissions: string[] = [], contributions: InstalledPlugin["manifest"]["contributions"] = []): InstalledPlugin {
|
||||||
@@ -1170,6 +1170,127 @@ describe("PluginHostBridge", () => {
|
|||||||
return bridge.clipboardAudit.map((entry) => entry.outcome);
|
return bridge.clipboardAudit.map((entry) => entry.outcome);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function dataApi(overrides: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
invoke: vi.fn(),
|
||||||
|
notify: vi.fn(),
|
||||||
|
sendBinary: vi.fn(),
|
||||||
|
readAsset: vi.fn(),
|
||||||
|
queryData: vi.fn().mockResolvedValue({ dbType: "postgres", columns: [{ name: "id" }], rows: [[1]], truncated: false, elapsedMs: 3 }),
|
||||||
|
hasDataGrant: vi.fn().mockResolvedValue(false),
|
||||||
|
confirmDataAccess: vi.fn().mockResolvedValue(true),
|
||||||
|
grantDataAccess: vi.fn().mockResolvedValue(undefined),
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function dataHarness(permissions: string[], api: ReturnType<typeof dataApi>) {
|
||||||
|
const messages: unknown[] = [];
|
||||||
|
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
|
||||||
|
const bridge = new PluginHostBridge(plugin(permissions), workbench, {}, () => target, api);
|
||||||
|
const query = async (id: string, params: unknown) => {
|
||||||
|
bridge.handleWindowMessage({ source: target, data: { source: "dbx-plugin", version: 1, type: "request", id, method: "host.queryData", params } } as MessageEvent);
|
||||||
|
await vi.waitFor(() => expect(messages.some((message) => (message as { id?: string }).id === id)).toBe(true));
|
||||||
|
return messages.find((message) => (message as { id?: string }).id === id) as { result?: unknown; error?: string };
|
||||||
|
};
|
||||||
|
return { bridge, messages, query };
|
||||||
|
}
|
||||||
|
|
||||||
|
it("serves host.queryData only with host.data:read and after the user's consent", async () => {
|
||||||
|
const withoutPermission = dataHarness([], dataApi());
|
||||||
|
expect(await withoutPermission.query("no-permission", { connectionId: "conn-1", sql: "SELECT 1" })).toMatchObject({ error: "Plugin has not declared permission 'host.data:read'" });
|
||||||
|
|
||||||
|
const api = dataApi();
|
||||||
|
const { query } = dataHarness(["host.data:read"], api);
|
||||||
|
const first = await query("first", { connectionId: " conn-1 ", sql: " SELECT id FROM users ", maxRows: 99_999, database: " " });
|
||||||
|
expect(first).toMatchObject({ result: { rows: [[1]] } });
|
||||||
|
expect(api.confirmDataAccess).toHaveBeenCalledWith("sample", expect.any(String), "conn-1");
|
||||||
|
expect(api.grantDataAccess).toHaveBeenCalledWith("sample", "conn-1");
|
||||||
|
// Bound to the owning plugin, trimmed, and pre-clamped to the host row cap.
|
||||||
|
expect(api.queryData).toHaveBeenCalledWith("sample", { connectionId: "conn-1", sql: "SELECT id FROM users", maxRows: 5_000 });
|
||||||
|
|
||||||
|
// The consent is remembered for this connection; another connection asks again.
|
||||||
|
await query("second", { connectionId: "conn-1", sql: "SELECT 2" });
|
||||||
|
expect(api.confirmDataAccess).toHaveBeenCalledTimes(1);
|
||||||
|
expect(api.hasDataGrant).toHaveBeenCalledTimes(1);
|
||||||
|
await query("other-connection", { connectionId: "conn-2", sql: "SELECT 3" });
|
||||||
|
expect(api.confirmDataAccess).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips the prompt for an existing grant and asks again after the backend reports a revocation", async () => {
|
||||||
|
const api = dataApi({
|
||||||
|
hasDataGrant: vi.fn().mockResolvedValue(true),
|
||||||
|
queryData: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({ dbType: "mysql", columns: [], rows: [], truncated: false, elapsedMs: 1 })
|
||||||
|
.mockRejectedValueOnce(new Error("PLUGIN_DATA_ACCESS_NOT_GRANTED: the user has not granted this plugin access to the connection"))
|
||||||
|
.mockResolvedValue({ dbType: "mysql", columns: [], rows: [], truncated: false, elapsedMs: 1 }),
|
||||||
|
});
|
||||||
|
const { query } = dataHarness(["host.data:read"], api);
|
||||||
|
await query("granted", { connectionId: "conn-1", sql: "SELECT 1" });
|
||||||
|
expect(api.confirmDataAccess).not.toHaveBeenCalled();
|
||||||
|
expect(await query("revoked", { connectionId: "conn-1", sql: "SELECT 1" })).toMatchObject({ error: expect.stringContaining("PLUGIN_DATA_ACCESS_NOT_GRANTED") });
|
||||||
|
await query("after-revoke", { connectionId: "conn-1", sql: "SELECT 1" });
|
||||||
|
expect(api.hasDataGrant).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("remembers a declined data access and never queries without consent", async () => {
|
||||||
|
const api = dataApi({ confirmDataAccess: vi.fn().mockResolvedValue(false) });
|
||||||
|
const { query } = dataHarness(["host.data:read"], api);
|
||||||
|
expect(await query("declined", { connectionId: "conn-1", sql: "SELECT 1" })).toMatchObject({ error: "Data access to this connection was denied" });
|
||||||
|
expect(await query("declined-again", { connectionId: "conn-1", sql: "SELECT 1" })).toMatchObject({ error: "Data access to this connection was denied for this plugin session" });
|
||||||
|
expect(api.confirmDataAccess).toHaveBeenCalledTimes(1);
|
||||||
|
expect(api.grantDataAccess).not.toHaveBeenCalled();
|
||||||
|
expect(api.queryData).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
// A host without a consent surface denies instead of granting silently.
|
||||||
|
const noSurface = dataApi({ confirmDataAccess: undefined });
|
||||||
|
const { query: queryWithoutSurface } = dataHarness(["host.data:read"], noSurface);
|
||||||
|
expect(await queryWithoutSurface("no-surface", { connectionId: "conn-1", sql: "SELECT 1" })).toMatchObject({ error: "Data access to this connection was denied" });
|
||||||
|
expect(noSurface.queryData).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("caps concurrent data queries per bridge so a plugin cannot occupy the shared pool", async () => {
|
||||||
|
const pending: Array<(value: unknown) => void> = [];
|
||||||
|
const api = dataApi({
|
||||||
|
hasDataGrant: vi.fn().mockResolvedValue(true),
|
||||||
|
queryData: vi.fn().mockImplementation(() => new Promise((resolve) => pending.push(resolve))),
|
||||||
|
});
|
||||||
|
const { bridge, messages } = dataHarness(["host.data:read"], api);
|
||||||
|
const target = (bridge as unknown as { targetWindow: () => Window }).targetWindow();
|
||||||
|
const start = (id: string) => bridge.handleWindowMessage({ source: target, data: { source: "dbx-plugin", version: 1, type: "request", id, method: "host.queryData", params: { connectionId: "conn-1", sql: "SELECT 1" } } } as MessageEvent);
|
||||||
|
for (let index = 0; index < MAX_CONCURRENT_PLUGIN_DATA_QUERIES; index += 1) start(`running-${index}`);
|
||||||
|
await vi.waitFor(() => expect(api.queryData).toHaveBeenCalledTimes(MAX_CONCURRENT_PLUGIN_DATA_QUERIES));
|
||||||
|
start("over-limit");
|
||||||
|
await vi.waitFor(() => expect(messages.some((message) => (message as { id?: string }).id === "over-limit")).toBe(true));
|
||||||
|
expect(messages.find((message) => (message as { id?: string }).id === "over-limit")).toMatchObject({ error: expect.stringContaining("data queries may run at once") });
|
||||||
|
|
||||||
|
// A finished query frees its slot.
|
||||||
|
pending.shift()?.({ dbType: "postgres", columns: [], rows: [], truncated: false, elapsedMs: 1 });
|
||||||
|
await vi.waitFor(() => expect(messages.some((message) => (message as { id?: string }).id === "running-0")).toBe(true));
|
||||||
|
start("after-slot");
|
||||||
|
await vi.waitFor(() => expect(api.queryData).toHaveBeenCalledTimes(MAX_CONCURRENT_PLUGIN_DATA_QUERIES + 1));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects malformed data requests and advertises dataApi only with a complete consent surface", async () => {
|
||||||
|
const api = dataApi();
|
||||||
|
const { query, bridge, messages } = dataHarness(["host.data:read"], api);
|
||||||
|
expect(await query("no-sql", { connectionId: "conn-1" })).toMatchObject({ error: "host.queryData requires sql" });
|
||||||
|
expect(await query("blank-sql", { connectionId: "conn-1", sql: " " })).toMatchObject({ error: "host.queryData requires a non-empty sql" });
|
||||||
|
expect(await query("no-connection", { sql: "SELECT 1" })).toMatchObject({ error: "connectionId must be a string" });
|
||||||
|
expect(await query("huge-sql", { connectionId: "conn-1", sql: "x".repeat(100_001) })).toMatchObject({ error: "sql must be at most 100000 characters" });
|
||||||
|
expect(api.queryData).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
bridge.sendInit();
|
||||||
|
const init = messages.find((message) => (message as { type?: string }).type === "init") as { capabilities?: Record<string, boolean> };
|
||||||
|
expect(init.capabilities?.dataApi).toBe(true);
|
||||||
|
|
||||||
|
const partial = dataHarness(["host.data:read"], dataApi({ grantDataAccess: undefined }));
|
||||||
|
partial.bridge.sendInit();
|
||||||
|
const partialInit = partial.messages.find((message) => (message as { type?: string }).type === "init") as { capabilities?: Record<string, boolean> };
|
||||||
|
expect(partialInit.capabilities?.dataApi).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("denies clipboard reads on hosts without a consent surface and rate-limits repeated reads", async () => {
|
it("denies clipboard reads on hosts without a consent surface and rate-limits repeated reads", async () => {
|
||||||
const messages: unknown[] = [];
|
const messages: unknown[] = [];
|
||||||
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
|
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { clonePluginData, snapshotPluginWorkbenchContext } from "./pluginData";
|
|||||||
import { MAX_PLUGIN_PLAN_SQL_CHARS, MAX_PLUGIN_PLAN_TIMEOUT_MS, PLUGIN_PLAN_PERMISSION, type PluginPlanCapabilities, type PluginPlanRequest, type PluginPlanResult } from "@/types/pluginPlan";
|
import { MAX_PLUGIN_PLAN_SQL_CHARS, MAX_PLUGIN_PLAN_TIMEOUT_MS, PLUGIN_PLAN_PERMISSION, type PluginPlanCapabilities, type PluginPlanRequest, type PluginPlanResult } from "@/types/pluginPlan";
|
||||||
import { createPluginAiConversation, type AiPluginConversationRequest } from "@/lib/ai/aiPluginConversation";
|
import { createPluginAiConversation, type AiPluginConversationRequest } from "@/lib/ai/aiPluginConversation";
|
||||||
import { MAX_PLUGIN_SCHEMA_METADATA_NAME_CHARS, PLUGIN_SCHEMA_METADATA_CAPABILITY, PLUGIN_SCHEMA_METADATA_PERMISSION, type PluginTableContext, type PluginTableMetadata } from "@/types/pluginSchemaMetadata";
|
import { MAX_PLUGIN_SCHEMA_METADATA_NAME_CHARS, PLUGIN_SCHEMA_METADATA_CAPABILITY, PLUGIN_SCHEMA_METADATA_PERMISSION, type PluginTableContext, type PluginTableMetadata } from "@/types/pluginSchemaMetadata";
|
||||||
|
import { MAX_PLUGIN_DATA_MAX_ROWS, MAX_PLUGIN_DATA_NAME_CHARS, MAX_PLUGIN_DATA_SQL_CHARS, MAX_PLUGIN_DATA_TIMEOUT_MS, PLUGIN_DATA_ACCESS_NOT_GRANTED, PLUGIN_DATA_CAPABILITY, PLUGIN_DATA_READ_PERMISSION, type PluginDataQueryRequest, type PluginDataQueryResult } from "@/types/pluginData";
|
||||||
|
|
||||||
const PLUGIN_MESSAGE_SOURCE = "dbx-plugin";
|
const PLUGIN_MESSAGE_SOURCE = "dbx-plugin";
|
||||||
const HOST_MESSAGE_SOURCE = "dbx-host";
|
const HOST_MESSAGE_SOURCE = "dbx-host";
|
||||||
@@ -14,6 +15,12 @@ const MAX_BRIDGE_BINARY_BYTES = 8 * 1024 * 1024;
|
|||||||
const MAX_BRIDGE_SAVE_BYTES = 512 * 1024 * 1024;
|
const MAX_BRIDGE_SAVE_BYTES = 512 * 1024 * 1024;
|
||||||
// Mirrors MAX_PLUGIN_PLAN_NAME_CHARS in crates/dbx-core/src/query/plugin_plan.rs.
|
// Mirrors MAX_PLUGIN_PLAN_NAME_CHARS in crates/dbx-core/src/query/plugin_plan.rs.
|
||||||
const MAX_PLUGIN_PLAN_IDENTIFIER_CHARS = 256;
|
const MAX_PLUGIN_PLAN_IDENTIFIER_CHARS = 256;
|
||||||
|
/**
|
||||||
|
* In-flight `host.queryData` calls per bridge. Plugin queries share the
|
||||||
|
* connection pool with the user's own tabs, so a runaway plugin must not be
|
||||||
|
* able to occupy it.
|
||||||
|
*/
|
||||||
|
export const MAX_CONCURRENT_PLUGIN_DATA_QUERIES = 4;
|
||||||
|
|
||||||
// Clipboard reads are the one permission that hands environment data (the
|
// Clipboard reads are the one permission that hands environment data (the
|
||||||
// system clipboard) to plugin code with no user interaction on each call, so
|
// system clipboard) to plugin code with no user interaction on each call, so
|
||||||
@@ -160,6 +167,22 @@ export interface PluginHostBridgeApi {
|
|||||||
explainPlan?(request: PluginPlanRequest): Promise<PluginPlanResult>;
|
explainPlan?(request: PluginPlanRequest): Promise<PluginPlanResult>;
|
||||||
/** Read-only table schema metadata over an already-open Host connection. */
|
/** Read-only table schema metadata over an already-open Host connection. */
|
||||||
getTableMetadata?(context: PluginTableContext): Promise<PluginTableMetadata>;
|
getTableMetadata?(context: PluginTableContext): Promise<PluginTableMetadata>;
|
||||||
|
/**
|
||||||
|
* One read-only SQL statement on a connection the user granted to the
|
||||||
|
* plugin (`host.data:read`). The backend re-checks the permission, the
|
||||||
|
* grant, the open connection, and the read-only statement gate.
|
||||||
|
*/
|
||||||
|
queryData?(pluginId: string, request: PluginDataQueryRequest): Promise<PluginDataQueryResult>;
|
||||||
|
/** Whether the user already granted `pluginId` data access to `connectionId`. */
|
||||||
|
hasDataGrant?(pluginId: string, connectionId: string): Promise<boolean>;
|
||||||
|
/**
|
||||||
|
* Asks the user whether `pluginId` may read `connectionId`. Resolves true to
|
||||||
|
* allow; the bridge then persists the grant through `grantDataAccess`. A
|
||||||
|
* host without a consent surface must omit it so the bridge denies.
|
||||||
|
*/
|
||||||
|
confirmDataAccess?(pluginId: string, pluginName: string, connectionId: string): Promise<boolean> | boolean;
|
||||||
|
/** Persists the grant the user just allowed. */
|
||||||
|
grantDataAccess?(pluginId: string, connectionId: string): Promise<void>;
|
||||||
closeTab?(): Promise<void> | void;
|
closeTab?(): Promise<void> | void;
|
||||||
/** Persist plugin bytes through the host's native save dialog. Resolves null when the user cancels. */
|
/** Persist plugin bytes through the host's native save dialog. Resolves null when the user cancels. */
|
||||||
saveFile?(pluginId: string, request: PluginSaveFileRequest, data: Uint8Array): Promise<PluginSaveFileResult | null>;
|
saveFile?(pluginId: string, request: PluginSaveFileRequest, data: Uint8Array): Promise<PluginSaveFileResult | null>;
|
||||||
@@ -217,6 +240,16 @@ export class PluginHostBridge {
|
|||||||
private theme?: PluginBridgeTheme;
|
private theme?: PluginBridgeTheme;
|
||||||
/** Consent + audit + rate state for clipboard reads; lives for the bridge lifetime. */
|
/** Consent + audit + rate state for clipboard reads; lives for the bridge lifetime. */
|
||||||
private clipboardReadGate = createClipboardReadGate();
|
private clipboardReadGate = createClipboardReadGate();
|
||||||
|
/**
|
||||||
|
* Data-access answers for this bridge lifetime, per connection. A denial is
|
||||||
|
* remembered so a plugin cannot re-prompt in a loop; an iframe reload builds
|
||||||
|
* a new bridge and may ask again. Grants live in the backend; this only
|
||||||
|
* spares a lookup per query.
|
||||||
|
*/
|
||||||
|
private dataAccess = new Map<string, "granted" | "denied">();
|
||||||
|
/** One consent prompt per connection at a time; concurrent queries share it. */
|
||||||
|
private pendingDataAccess = new Map<string, Promise<void>>();
|
||||||
|
private inFlightDataQueries = 0;
|
||||||
|
|
||||||
/** Bounded audit trail of this session's clipboard read attempts (oldest first). */
|
/** Bounded audit trail of this session's clipboard read attempts (oldest first). */
|
||||||
get clipboardAudit(): readonly PluginClipboardAuditEntry[] {
|
get clipboardAudit(): readonly PluginClipboardAuditEntry[] {
|
||||||
@@ -364,6 +397,7 @@ export class PluginHostBridge {
|
|||||||
downloadFile: !!this.api.downloadFile,
|
downloadFile: !!this.api.downloadFile,
|
||||||
planApi: !!this.api.getPlanCapabilities && !!this.api.explainPlan,
|
planApi: !!this.api.getPlanCapabilities && !!this.api.explainPlan,
|
||||||
[PLUGIN_SCHEMA_METADATA_CAPABILITY]: !!this.api.getTableMetadata,
|
[PLUGIN_SCHEMA_METADATA_CAPABILITY]: !!this.api.getTableMetadata,
|
||||||
|
[PLUGIN_DATA_CAPABILITY]: !!this.api.queryData && !!this.api.hasDataGrant && !!this.api.confirmDataAccess && !!this.api.grantDataAccess,
|
||||||
storage: !!this.api.storageGet && !!this.api.storageSet && !!this.api.storageDelete,
|
storage: !!this.api.storageGet && !!this.api.storageSet && !!this.api.storageDelete,
|
||||||
ai: !!this.api.openAiConversation,
|
ai: !!this.api.openAiConversation,
|
||||||
// Additive with the same "absence means unsupported" contract: an older
|
// Additive with the same "absence means unsupported" contract: an older
|
||||||
@@ -528,6 +562,26 @@ export class PluginHostBridge {
|
|||||||
if (!this.api.getTableMetadata) throw new Error("Host schema metadata API is unavailable");
|
if (!this.api.getTableMetadata) throw new Error("Host schema metadata API is unavailable");
|
||||||
return this.api.getTableMetadata(requirePluginTableContext(requireRecord(params, "host.getTableMetadata params")));
|
return this.api.getTableMetadata(requirePluginTableContext(requireRecord(params, "host.getTableMetadata params")));
|
||||||
}
|
}
|
||||||
|
if (method === "host.queryData") {
|
||||||
|
this.requirePermission(PLUGIN_DATA_READ_PERMISSION);
|
||||||
|
if (!this.api.queryData) throw new Error("Host data API is unavailable");
|
||||||
|
const request = requirePluginDataQueryRequest(requireRecord(params, "host.queryData params"));
|
||||||
|
await this.ensureDataAccess(request.connectionId);
|
||||||
|
if (this.inFlightDataQueries >= MAX_CONCURRENT_PLUGIN_DATA_QUERIES) {
|
||||||
|
throw new Error(`At most ${MAX_CONCURRENT_PLUGIN_DATA_QUERIES} data queries may run at once; wait for one to finish`);
|
||||||
|
}
|
||||||
|
this.inFlightDataQueries += 1;
|
||||||
|
try {
|
||||||
|
return await this.api.queryData(this.plugin.manifest.id, request);
|
||||||
|
} catch (error) {
|
||||||
|
// The grant was revoked while this workbench stayed open: forget the
|
||||||
|
// cached answer so the next query asks the user again.
|
||||||
|
if ((error instanceof Error ? error.message : String(error)).includes(PLUGIN_DATA_ACCESS_NOT_GRANTED)) this.dataAccess.delete(request.connectionId);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
this.inFlightDataQueries -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
if (method === "host.saveFile") {
|
if (method === "host.saveFile") {
|
||||||
const input = isRecord(params) ? params : {};
|
const input = isRecord(params) ? params : {};
|
||||||
// The sandboxed iframe cannot trigger downloads (WKWebView cancels blob
|
// The sandboxed iframe cannot trigger downloads (WKWebView cancels blob
|
||||||
@@ -659,6 +713,35 @@ export class PluginHostBridge {
|
|||||||
if (!this.hasPermission(permission)) throw new Error(`Plugin has not declared permission '${permission}'`);
|
if (!this.hasPermission(permission)) throw new Error(`Plugin has not declared permission '${permission}'`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Resolves once the user allowed this plugin to read `connectionId`; rejects on denial. */
|
||||||
|
private async ensureDataAccess(connectionId: string): Promise<void> {
|
||||||
|
const known = this.dataAccess.get(connectionId);
|
||||||
|
if (known === "granted") return;
|
||||||
|
if (known === "denied") throw new Error("Data access to this connection was denied for this plugin session");
|
||||||
|
let pending = this.pendingDataAccess.get(connectionId);
|
||||||
|
if (!pending) {
|
||||||
|
pending = this.requestDataAccess(connectionId).finally(() => this.pendingDataAccess.delete(connectionId));
|
||||||
|
this.pendingDataAccess.set(connectionId, pending);
|
||||||
|
}
|
||||||
|
await pending;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async requestDataAccess(connectionId: string): Promise<void> {
|
||||||
|
const pluginId = this.plugin.manifest.id;
|
||||||
|
if (this.api.hasDataGrant && (await this.api.hasDataGrant(pluginId, connectionId))) {
|
||||||
|
this.dataAccess.set(connectionId, "granted");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A host that cannot ask denies; it never grants silently.
|
||||||
|
const allowed = this.api.confirmDataAccess && this.api.grantDataAccess ? (await this.api.confirmDataAccess(pluginId, this.plugin.manifest.name, connectionId)) === true : false;
|
||||||
|
if (!allowed || !this.api.grantDataAccess) {
|
||||||
|
this.dataAccess.set(connectionId, "denied");
|
||||||
|
throw new Error("Data access to this connection was denied");
|
||||||
|
}
|
||||||
|
await this.api.grantDataAccess(pluginId, connectionId);
|
||||||
|
this.dataAccess.set(connectionId, "granted");
|
||||||
|
}
|
||||||
|
|
||||||
private hasPermission(permission: string): boolean {
|
private hasPermission(permission: string): boolean {
|
||||||
return (this.plugin.manifest.permissions || []).includes(permission);
|
return (this.plugin.manifest.permissions || []).includes(permission);
|
||||||
}
|
}
|
||||||
@@ -977,6 +1060,9 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string {
|
|||||||
explainPlan: (planRequest) => request('host.explainPlan', planRequest),
|
explainPlan: (planRequest) => request('host.explainPlan', planRequest),
|
||||||
// Read-only metadata; the host enforces the permission and open-session gate.
|
// Read-only metadata; the host enforces the permission and open-session gate.
|
||||||
getTableMetadata: (tableContext) => request('host.getTableMetadata', tableContext),
|
getTableMetadata: (tableContext) => request('host.getTableMetadata', tableContext),
|
||||||
|
// One read-only statement on a connection the user granted to this
|
||||||
|
// plugin (host.data:read); the first query per connection asks the user.
|
||||||
|
queryData: (dataRequest) => request('host.queryData', dataRequest),
|
||||||
saveFile: (options = {}, data) => {
|
saveFile: (options = {}, data) => {
|
||||||
if (data === undefined) return request('host.saveFile', options);
|
if (data === undefined) return request('host.saveFile', options);
|
||||||
if (typeof data === 'string') return request('host.saveFile', { ...(options || {}), dataBase64: data });
|
if (typeof data === 'string') return request('host.saveFile', { ...(options || {}), dataBase64: data });
|
||||||
@@ -1189,6 +1275,42 @@ function requirePluginPlanRequest(input: Record<string, unknown>): PluginPlanReq
|
|||||||
return request;
|
return request;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates one `host.queryData` request: a connection reference, optional
|
||||||
|
* scope, one SQL text, and optional bounds. The backend re-checks every bound
|
||||||
|
* and owns the read-only decision; this only refuses malformed input early.
|
||||||
|
*/
|
||||||
|
function requirePluginDataQueryRequest(input: Record<string, unknown>): PluginDataQueryRequest {
|
||||||
|
if (typeof input.sql !== "string") throw new Error("host.queryData requires sql");
|
||||||
|
const sql = input.sql.trim();
|
||||||
|
if (!sql) throw new Error("host.queryData requires a non-empty sql");
|
||||||
|
if (sql.length > MAX_PLUGIN_DATA_SQL_CHARS) throw new Error(`sql must be at most ${MAX_PLUGIN_DATA_SQL_CHARS} characters`);
|
||||||
|
const request: PluginDataQueryRequest = { connectionId: requirePluginDataName(input.connectionId, "connectionId"), sql };
|
||||||
|
for (const key of ["database", "schema"] as const) {
|
||||||
|
const value = input[key];
|
||||||
|
if (value === undefined || value === null) continue;
|
||||||
|
if (typeof value !== "string") throw new Error(`${key} must be a string`);
|
||||||
|
if (value.trim()) request[key] = requirePluginDataName(value, key);
|
||||||
|
}
|
||||||
|
if (input.maxRows !== undefined && input.maxRows !== null) {
|
||||||
|
if (typeof input.maxRows !== "number" || !Number.isFinite(input.maxRows)) throw new Error("maxRows must be a number");
|
||||||
|
request.maxRows = Math.min(MAX_PLUGIN_DATA_MAX_ROWS, Math.max(1, Math.floor(input.maxRows)));
|
||||||
|
}
|
||||||
|
if (input.timeoutMs !== undefined && input.timeoutMs !== null) {
|
||||||
|
if (typeof input.timeoutMs !== "number" || !Number.isFinite(input.timeoutMs)) throw new Error("timeoutMs must be a number");
|
||||||
|
request.timeoutMs = Math.min(MAX_PLUGIN_DATA_TIMEOUT_MS, Math.max(1, Math.round(input.timeoutMs)));
|
||||||
|
}
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requirePluginDataName(value: unknown, label: string): string {
|
||||||
|
if (typeof value !== "string") throw new Error(`${label} must be a string`);
|
||||||
|
const name = value.trim();
|
||||||
|
if (!name) throw new Error(`${label} must not be empty`);
|
||||||
|
if (Array.from(name).length > MAX_PLUGIN_DATA_NAME_CHARS) throw new Error(`${label} must be at most ${MAX_PLUGIN_DATA_NAME_CHARS} characters`);
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
/** Pre-clamps to the host ceiling; the backend additionally clamps to the connection's own timeout. */
|
/** Pre-clamps to the host ceiling; the backend additionally clamps to the connection's own timeout. */
|
||||||
function clampPluginPlanTimeout(value: unknown): number {
|
function clampPluginPlanTimeout(value: unknown): number {
|
||||||
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error("timeoutMs must be a number");
|
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error("timeoutMs must be a number");
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Plugin MCP tools in the built-in AI agent.
|
||||||
|
*
|
||||||
|
* Mirrors `crates/dbx-core/src/ai/plugin_tools.rs` (preview types) and the
|
||||||
|
* approval events of `crates/dbx-ai-provider/src/agent_events.rs`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface PluginToolSummary {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
/** Declared read-only via MCP `annotations.readOnlyHint`; other tools need an approval per call. */
|
||||||
|
readOnly: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginToolConnectionSummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginToolPreview {
|
||||||
|
tools: PluginToolSummary[];
|
||||||
|
/** Open connections of the plugin the agent would bind tool calls to. */
|
||||||
|
openConnections: PluginToolConnectionSummary[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AiToolApprovalOutcome = "approved" | "denied" | "timed_out" | "cancelled";
|
||||||
|
|
||||||
|
/** Separator between the plugin prefix and the plugin's own tool name (`ssh__ssh_exec`). */
|
||||||
|
export const PLUGIN_TOOL_NAME_SEPARATOR = "__";
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/**
|
||||||
|
* Consent-gated read-only data queries for plugins (`host.data:read`, Host API 1.4).
|
||||||
|
*
|
||||||
|
* Mirrors `crates/dbx-core/src/query/plugin_data.rs`. The plugin sends one
|
||||||
|
* read-only SQL statement for a connection; the host enforces the manifest
|
||||||
|
* permission, the user's per-connection grant, the open-connection rule, the
|
||||||
|
* read-only statement gate, and the row/byte caps.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Permission required by `host.queryData`. */
|
||||||
|
export const PLUGIN_DATA_READ_PERMISSION = "host.data:read";
|
||||||
|
|
||||||
|
/** Capability advertised by the bridge when the host implements data queries. */
|
||||||
|
export const PLUGIN_DATA_CAPABILITY = "dataApi";
|
||||||
|
|
||||||
|
/** Error code prefix the backend uses for a missing or revoked grant. */
|
||||||
|
export const PLUGIN_DATA_ACCESS_NOT_GRANTED = "PLUGIN_DATA_ACCESS_NOT_GRANTED";
|
||||||
|
|
||||||
|
/** Mirrors the Rust-side bounds in plugin_data.rs. */
|
||||||
|
export const MAX_PLUGIN_DATA_SQL_CHARS = 100_000;
|
||||||
|
export const MAX_PLUGIN_DATA_MAX_ROWS = 5_000;
|
||||||
|
export const MAX_PLUGIN_DATA_NAME_CHARS = 256;
|
||||||
|
export const MAX_PLUGIN_DATA_TIMEOUT_MS = 60_000;
|
||||||
|
|
||||||
|
export interface PluginDataQueryRequest {
|
||||||
|
connectionId: string;
|
||||||
|
database?: string;
|
||||||
|
schema?: string;
|
||||||
|
sql: string;
|
||||||
|
/** Default 500, capped at 5000 by the host. */
|
||||||
|
maxRows?: number;
|
||||||
|
/** Clamped to the connection timeout and a 60s host ceiling. */
|
||||||
|
timeoutMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginDataColumn {
|
||||||
|
name: string;
|
||||||
|
dataType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginDataQueryResult {
|
||||||
|
dbType: string;
|
||||||
|
columns: PluginDataColumn[];
|
||||||
|
rows: unknown[][];
|
||||||
|
/** True when rows were cut by the row cap, the byte cap, or the driver. */
|
||||||
|
truncated: boolean;
|
||||||
|
elapsedMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PluginDataGrant {
|
||||||
|
connectionId: string;
|
||||||
|
/** Absent when the granted connection no longer exists. */
|
||||||
|
connectionName?: string;
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
use std::borrow::Cow;
|
||||||
|
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
/// Events emitted by the agent loop, streamed to the frontend via SSE.
|
/// Events emitted by the agent loop, streamed to the frontend via SSE.
|
||||||
@@ -18,6 +20,25 @@ pub enum AgentEvent {
|
|||||||
ReasoningDelta { delta: String },
|
ReasoningDelta { delta: String },
|
||||||
/// The LLM wants to call a tool.
|
/// The LLM wants to call a tool.
|
||||||
ToolCallStart { tool_call_id: String, tool_name: String, args: serde_json::Value },
|
ToolCallStart { tool_call_id: String, tool_name: String, args: serde_json::Value },
|
||||||
|
/// A plugin tool call that may change state waits for the user's explicit
|
||||||
|
/// approval. The run stays paused until the client answers through the
|
||||||
|
/// approval command, the approval times out, or the run is cancelled.
|
||||||
|
ToolApprovalRequired {
|
||||||
|
approval_id: String,
|
||||||
|
tool_call_id: String,
|
||||||
|
tool_name: String,
|
||||||
|
plugin_id: String,
|
||||||
|
plugin_name: String,
|
||||||
|
/// The tool name as the plugin declared it (without the DBX prefix).
|
||||||
|
plugin_tool: String,
|
||||||
|
connection_id: String,
|
||||||
|
connection_name: String,
|
||||||
|
/// Exactly the arguments DBX will forward if the user approves.
|
||||||
|
args: serde_json::Value,
|
||||||
|
timeout_secs: u64,
|
||||||
|
},
|
||||||
|
/// The approval requested by `ToolApprovalRequired` has been settled.
|
||||||
|
ToolApprovalResolved { approval_id: String, tool_call_id: String, outcome: ToolApprovalOutcome },
|
||||||
/// A tool execution has completed.
|
/// A tool execution has completed.
|
||||||
ToolCallEnd { tool_call_id: String, tool_name: String, result: serde_json::Value, is_error: bool },
|
ToolCallEnd { tool_call_id: String, tool_name: String, result: serde_json::Value, is_error: bool },
|
||||||
/// A turn in the agent loop has ended.
|
/// A turn in the agent loop has ended.
|
||||||
@@ -41,6 +62,18 @@ pub enum AgentEvent {
|
|||||||
Error { message: String },
|
Error { message: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How a tool approval request ended.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum ToolApprovalOutcome {
|
||||||
|
Approved,
|
||||||
|
Denied,
|
||||||
|
/// Nobody answered before the approval deadline.
|
||||||
|
TimedOut,
|
||||||
|
/// The run was cancelled while the approval was pending.
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
/// A unified tool call extracted from any provider's response.
|
/// A unified tool call extracted from any provider's response.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ToolCall {
|
pub struct ToolCall {
|
||||||
@@ -67,10 +100,13 @@ pub struct ToolResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Definition of a tool available to the agent.
|
/// Definition of a tool available to the agent.
|
||||||
|
///
|
||||||
|
/// Built-in tools use static names and descriptions; tools contributed by
|
||||||
|
/// plugins are discovered at run time, so both fields accept owned text too.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct ToolDefinition {
|
pub struct ToolDefinition {
|
||||||
pub name: &'static str,
|
pub name: Cow<'static, str>,
|
||||||
pub description: &'static str,
|
pub description: Cow<'static, str>,
|
||||||
pub parameters: serde_json::Value,
|
pub parameters: serde_json::Value,
|
||||||
pub read_only: bool,
|
pub read_only: bool,
|
||||||
/// Whether this tool can be executed in parallel with other tools.
|
/// Whether this tool can be executed in parallel with other tools.
|
||||||
|
|||||||
@@ -5698,8 +5698,8 @@ mod tests {
|
|||||||
let (endpoint, server) = spawn_json_capture_server("text/event-stream", response).await;
|
let (endpoint, server) = spawn_json_capture_server("text/event-stream", response).await;
|
||||||
let request = anthropic_compatible_test_request(endpoint);
|
let request = anthropic_compatible_test_request(endpoint);
|
||||||
let tools = [crate::agent_events::ToolDefinition {
|
let tools = [crate::agent_events::ToolDefinition {
|
||||||
name: "get_tables",
|
name: "get_tables".into(),
|
||||||
description: "List tables",
|
description: "List tables".into(),
|
||||||
parameters: serde_json::json!({
|
parameters: serde_json::json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": { "schema": { "type": "string" } }
|
"properties": { "schema": { "type": "string" } }
|
||||||
@@ -5754,8 +5754,8 @@ mod tests {
|
|||||||
let request = claude_http_test_request(endpoint);
|
let request = claude_http_test_request(endpoint);
|
||||||
let client = build_ai_http_client(&request.config, 10).unwrap();
|
let client = build_ai_http_client(&request.config, 10).unwrap();
|
||||||
let tools = [crate::agent_events::ToolDefinition {
|
let tools = [crate::agent_events::ToolDefinition {
|
||||||
name: "get_tables",
|
name: "get_tables".into(),
|
||||||
description: "List tables",
|
description: "List tables".into(),
|
||||||
parameters: serde_json::json!({ "type": "object", "properties": {} }),
|
parameters: serde_json::json!({ "type": "object", "properties": {} }),
|
||||||
read_only: true,
|
read_only: true,
|
||||||
parallel_ok: true,
|
parallel_ok: true,
|
||||||
@@ -7330,8 +7330,8 @@ mod tests {
|
|||||||
assert_eq!(input[3]["call_id"], "call_1");
|
assert_eq!(input[3]["call_id"], "call_1");
|
||||||
|
|
||||||
let tool = crate::agent_events::ToolDefinition {
|
let tool = crate::agent_events::ToolDefinition {
|
||||||
name: "list_tables",
|
name: "list_tables".into(),
|
||||||
description: "List tables",
|
description: "List tables".into(),
|
||||||
parameters: serde_json::json!({"type": "object"}),
|
parameters: serde_json::json!({"type": "object"}),
|
||||||
read_only: true,
|
read_only: true,
|
||||||
parallel_ok: true,
|
parallel_ok: true,
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
//! End-to-end check of plugin MCP tools in the built-in AI agent, run against
|
||||||
|
//! the packaged example plugin: install it, open a plugin connection, opt the
|
||||||
|
//! plugin in, discover its tools through the real sidecar, and call them the
|
||||||
|
//! way an agent run does.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use dbx_core::agent_events::ToolCall;
|
||||||
|
use dbx_core::connection::AppState;
|
||||||
|
use dbx_core::models::connection::ConnectionConfig;
|
||||||
|
use dbx_core::plugin_tools::{self, PluginToolSet};
|
||||||
|
use dbx_core::plugins::{PluginInstallPolicy, PluginPackageInstaller};
|
||||||
|
use dbx_core::storage::Storage;
|
||||||
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
const PLUGIN_ID: &str = "dbx.example.hello";
|
||||||
|
const APP_VERSION: &str = "0.5.68";
|
||||||
|
const CONNECTION_ID: &str = "hello-ai-connection";
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() {
|
||||||
|
if let Err(error) = run().await {
|
||||||
|
eprintln!("plugin AI tools smoke failed: {error}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run() -> Result<(), String> {
|
||||||
|
let package = std::env::args().nth(1).map(PathBuf::from).ok_or("Usage: plugin_ai_tools_smoke <package.dbxp>")?;
|
||||||
|
let root = tempfile::tempdir().map_err(|error| error.to_string())?;
|
||||||
|
let plugin_dir = root.path().join("plugins");
|
||||||
|
PluginPackageInstaller::new(plugin_dir.clone(), APP_VERSION.to_string())?
|
||||||
|
.install_file(&package, PluginInstallPolicy::LocalDevelopment)?;
|
||||||
|
let storage = Storage::open(&root.path().join("dbx.db")).await?;
|
||||||
|
let state = Arc::new(AppState::new_with_plugin_dir_and_app_version(storage, plugin_dir, APP_VERSION));
|
||||||
|
let config: ConnectionConfig = serde_json::from_value(json!({
|
||||||
|
"id": CONNECTION_ID,
|
||||||
|
"name": "Hello AI connection",
|
||||||
|
"db_type": "plugin",
|
||||||
|
"driver_profile": "plugin",
|
||||||
|
"host": "localhost",
|
||||||
|
"port": 22,
|
||||||
|
"username": "",
|
||||||
|
"password": "",
|
||||||
|
"database": null,
|
||||||
|
"external_config": { "greeting": "Hello" },
|
||||||
|
"plugin_id": PLUGIN_ID,
|
||||||
|
"plugin_connection_provider": "dbx.example.hello.connection",
|
||||||
|
"plugin_connection_type": "hello",
|
||||||
|
"connection_secrets": { "api_token": "smoke-secret" }
|
||||||
|
}))
|
||||||
|
.map_err(|error| error.to_string())?;
|
||||||
|
state.configs.write().await.insert(CONNECTION_ID.to_string(), config);
|
||||||
|
|
||||||
|
// Nothing reaches the model before the user opts the plugin in, and
|
||||||
|
// nothing for a connection that is not open.
|
||||||
|
if !plugin_tools::discover_plugin_tools(&state, None).await.is_empty() {
|
||||||
|
return Err("tools were exposed before the plugin was enabled for the AI".to_string());
|
||||||
|
}
|
||||||
|
state.storage.set_ai_plugin_tool_plugin_enabled(PLUGIN_ID, true).await?;
|
||||||
|
if !plugin_tools::discover_plugin_tools(&state, None).await.is_empty() {
|
||||||
|
return Err("tools were exposed without an open plugin connection".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
state.get_or_create_pool(CONNECTION_ID, None).await?;
|
||||||
|
let tools = plugin_tools::discover_plugin_tools(&state, None).await;
|
||||||
|
let definitions = tools.definitions();
|
||||||
|
let names = definitions.iter().map(|definition| definition.name.as_ref()).collect::<Vec<_>>();
|
||||||
|
if names != ["hello__hello_greet", "hello__hello_set_greeting"] {
|
||||||
|
return Err(format!("unexpected tool names: {names:?}"));
|
||||||
|
}
|
||||||
|
let read_only = definitions.iter().map(|definition| definition.read_only).collect::<Vec<_>>();
|
||||||
|
if read_only != [true, false] {
|
||||||
|
return Err(format!("readOnlyHint was not honored: {read_only:?}"));
|
||||||
|
}
|
||||||
|
if definitions.iter().any(|definition| definition.parameters.pointer("/properties/connectionId").is_some()) {
|
||||||
|
return Err("the host-bound connectionId leaked into a model-facing schema".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
expect_tool_text(
|
||||||
|
&state,
|
||||||
|
&tools,
|
||||||
|
"hello__hello_greet",
|
||||||
|
json!({ "name": "Agent" }),
|
||||||
|
"Hello, Agent, from Hello AI connection!",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
// A real run pauses here for the user's approval; this call stands in for
|
||||||
|
// an approved one.
|
||||||
|
expect_tool_text(
|
||||||
|
&state,
|
||||||
|
&tools,
|
||||||
|
"hello__hello_set_greeting",
|
||||||
|
json!({ "greeting": "Hi" }),
|
||||||
|
"Hello AI connection now greets with \"Hi\".",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
expect_tool_text(
|
||||||
|
&state,
|
||||||
|
&tools,
|
||||||
|
"hello__hello_greet",
|
||||||
|
json!({ "name": "Agent" }),
|
||||||
|
"Hi, Agent, from Hello AI connection!",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let preview = plugin_tools::preview_plugin_tools(&state, None, PLUGIN_ID).await?;
|
||||||
|
let preview_names = preview.tools.iter().map(|tool| (tool.name.as_str(), tool.read_only)).collect::<Vec<_>>();
|
||||||
|
if preview_names != [("hello_greet", true), ("hello_set_greeting", false)]
|
||||||
|
|| preview.open_connections.iter().map(|connection| connection.id.as_str()).collect::<Vec<_>>()
|
||||||
|
!= [CONNECTION_ID]
|
||||||
|
{
|
||||||
|
return Err(format!("unexpected preview: {preview:?}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let call = ToolCall {
|
||||||
|
id: "revoked-call".to_string(),
|
||||||
|
name: "hello__hello_set_greeting".to_string(),
|
||||||
|
arguments: json!({ "greeting": "Must not run" }),
|
||||||
|
provider_payload: None,
|
||||||
|
};
|
||||||
|
let prepared = tools.prepare_call(&call).ok_or("missing write tool")??;
|
||||||
|
state.storage.set_ai_plugin_tool_plugin_enabled(PLUGIN_ID, false).await?;
|
||||||
|
let revoked = plugin_tools::execute_plugin_tool(&state, None, &call, &prepared).await;
|
||||||
|
if !revoked.is_error || !revoked.content.contains("has been disabled") {
|
||||||
|
return Err(format!("revoked AI access was not enforced: {revoked:?}"));
|
||||||
|
}
|
||||||
|
state.storage.set_ai_plugin_tool_plugin_enabled(PLUGIN_ID, true).await?;
|
||||||
|
expect_tool_text(
|
||||||
|
&state,
|
||||||
|
&tools,
|
||||||
|
"hello__hello_greet",
|
||||||
|
json!({ "name": "Agent" }),
|
||||||
|
"Hi, Agent, from Hello AI connection!",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
state.remove_connection_pools(CONNECTION_ID).await;
|
||||||
|
let closed = plugin_tools::execute_plugin_tool(&state, None, &call, &prepared).await;
|
||||||
|
if !closed.is_error || !closed.content.contains("no longer open") {
|
||||||
|
return Err(format!("closed plugin connection was not rejected: {closed:?}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("plugin AI tools smoke passed: {names:?}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn expect_tool_text(
|
||||||
|
state: &Arc<AppState>,
|
||||||
|
tools: &PluginToolSet,
|
||||||
|
name: &str,
|
||||||
|
arguments: Value,
|
||||||
|
expected: &str,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let call = ToolCall { id: format!("call-{name}"), name: name.to_string(), arguments, provider_payload: None };
|
||||||
|
let prepared = tools.prepare_call(&call).ok_or_else(|| format!("{name} is not a plugin tool"))??;
|
||||||
|
if prepared.connection_id != CONNECTION_ID || prepared.arguments.get("connectionId") != Some(&json!(CONNECTION_ID))
|
||||||
|
{
|
||||||
|
return Err(format!("{name} was not bound to the open connection: {prepared:?}"));
|
||||||
|
}
|
||||||
|
let result = plugin_tools::execute_plugin_tool(state, None, &call, &prepared).await;
|
||||||
|
if result.is_error || result.content != expected {
|
||||||
|
return Err(format!("{name} returned {:?} (error: {})", result.content, result.is_error));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -6,13 +6,15 @@ use futures::FutureExt;
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tokio::sync::Notify;
|
use tokio::sync::Notify;
|
||||||
|
|
||||||
use crate::agent_events::{AgentEvent, ToolCall, ToolDefinition, ToolResult};
|
use crate::agent_events::{AgentEvent, ToolApprovalOutcome, ToolCall, ToolDefinition, ToolResult};
|
||||||
use crate::agent_tools;
|
use crate::agent_tools;
|
||||||
use crate::ai::{self, AiCompletionRequest, AiConfig, AiMessage, AiProvider, AiStreamChunk, AiTaskContract};
|
use crate::ai::{self, AiCompletionRequest, AiConfig, AiMessage, AiProvider, AiStreamChunk, AiTaskContract};
|
||||||
use crate::ai_cli_agent::CliAgentCommandSpec;
|
use crate::ai_cli_agent::CliAgentCommandSpec;
|
||||||
use crate::connection::AppState;
|
use crate::connection::AppState;
|
||||||
use crate::models::connection::DatabaseType;
|
use crate::models::connection::DatabaseType;
|
||||||
|
use crate::plugin_tools::{self, PluginToolSet, PreparedPluginToolCall};
|
||||||
use crate::token_usage::TokenUsage;
|
use crate::token_usage::TokenUsage;
|
||||||
|
use crate::tool_approval::{self, ToolApprovalWait};
|
||||||
|
|
||||||
/// Default number of agent loop turns to prevent infinite loops.
|
/// Default number of agent loop turns to prevent infinite loops.
|
||||||
/// Users can raise the limit in Settings → AI; it is clamped to
|
/// Users can raise the limit in Settings → AI; it is clamped to
|
||||||
@@ -88,6 +90,12 @@ pub struct AgentLoopContext {
|
|||||||
/// Stable per-conversation key forwarded to providers that support prompt
|
/// Stable per-conversation key forwarded to providers that support prompt
|
||||||
/// caching (OpenAI Responses API). `None` disables the field entirely.
|
/// caching (OpenAI Responses API). `None` disables the field entirely.
|
||||||
pub prompt_cache_key: Option<String>,
|
pub prompt_cache_key: Option<String>,
|
||||||
|
/// Client AI session id. Plugin tool calls that need the user's approval
|
||||||
|
/// are routed to the client through it; without one they are refused.
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
/// Runtime that owns plugin sidecar sessions. The web server runs each
|
||||||
|
/// agent loop on a runtime of its own, so plugin calls are spawned here.
|
||||||
|
pub host_runtime: Option<tokio::runtime::Handle>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -281,11 +289,21 @@ async fn run_agent_loop_inner(
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
let mut sql_permissions = agent_ctx.sql_permissions.clone();
|
let mut sql_permissions = agent_ctx.sql_permissions.clone();
|
||||||
let tools = if is_agent_mode {
|
let mut tools = if is_agent_mode {
|
||||||
agent_tools::all_tools(agent_ctx.db_type, sql_permissions.clone())
|
agent_tools::all_tools(agent_ctx.db_type, sql_permissions.clone())
|
||||||
} else {
|
} else {
|
||||||
agent_tools::read_only_tools(agent_ctx.db_type)
|
agent_tools::read_only_tools(agent_ctx.db_type)
|
||||||
};
|
};
|
||||||
|
// Plugin tools join agent runs only; ask mode keeps its database-only
|
||||||
|
// read tools.
|
||||||
|
let plugin_tool_set = if is_agent_mode {
|
||||||
|
plugin_tools::discover_plugin_tools(&agent_ctx.state, agent_ctx.host_runtime.as_ref()).await
|
||||||
|
} else {
|
||||||
|
PluginToolSet::default()
|
||||||
|
};
|
||||||
|
tools.extend(plugin_tool_set.definitions());
|
||||||
|
let plugin_system_prompt = plugin_tool_set.prompt_section().map(|section| format!("{system_prompt}\n\n{section}"));
|
||||||
|
let system_prompt = plugin_system_prompt.as_deref().unwrap_or(system_prompt);
|
||||||
let task_contract = task_contract.cloned();
|
let task_contract = task_contract.cloned();
|
||||||
let mut conversation_messages: Vec<AiMessage> = messages.to_vec();
|
let mut conversation_messages: Vec<AiMessage> = messages.to_vec();
|
||||||
let mut final_text = String::new();
|
let mut final_text = String::new();
|
||||||
@@ -542,6 +560,34 @@ async fn run_agent_loop_inner(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Plugin tool calls are validated first; a call not declared read-only
|
||||||
|
// runs only after the user approved it. Nothing has executed yet, so a
|
||||||
|
// cancellation here stops the run without side effects.
|
||||||
|
let mut plugin_calls = match gate_plugin_tool_calls(
|
||||||
|
&plugin_tool_set,
|
||||||
|
&collected_tool_calls,
|
||||||
|
agent_ctx,
|
||||||
|
&on_event,
|
||||||
|
cancelled,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Some(plugin_calls) => plugin_calls,
|
||||||
|
None => {
|
||||||
|
for tc in &collected_tool_calls {
|
||||||
|
on_event(AgentEvent::ToolCallEnd {
|
||||||
|
tool_call_id: tc.id.clone(),
|
||||||
|
tool_name: tc.name.clone(),
|
||||||
|
result: json!({ "content": "Cancelled before the tool ran." }),
|
||||||
|
is_error: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
final_text = accumulated_text;
|
||||||
|
loop_exit = LoopExit::Cancelled;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Execute tool calls: parallel for read tools, sequential for execute_query
|
// Execute tool calls: parallel for read tools, sequential for execute_query
|
||||||
let state2 = Arc::clone(&agent_ctx.state);
|
let state2 = Arc::clone(&agent_ctx.state);
|
||||||
let conn2 = agent_ctx.connection_id.clone();
|
let conn2 = agent_ctx.connection_id.clone();
|
||||||
@@ -552,7 +598,7 @@ async fn run_agent_loop_inner(
|
|||||||
|
|
||||||
// Split by index into parallel and sequential groups using tool metadata
|
// Split by index into parallel and sequential groups using tool metadata
|
||||||
let tool_parallel_map: std::collections::HashMap<&str, bool> =
|
let tool_parallel_map: std::collections::HashMap<&str, bool> =
|
||||||
tools.iter().map(|t| (t.name, t.parallel_ok)).collect();
|
tools.iter().map(|t| (t.name.as_ref(), t.parallel_ok)).collect();
|
||||||
let (parallel_indices, sequential_indices): (Vec<usize>, Vec<usize>) = (0..collected_tool_calls.len())
|
let (parallel_indices, sequential_indices): (Vec<usize>, Vec<usize>) = (0..collected_tool_calls.len())
|
||||||
.partition(|&i| *tool_parallel_map.get(collected_tool_calls[i].name.as_str()).unwrap_or(&false));
|
.partition(|&i| *tool_parallel_map.get(collected_tool_calls[i].name.as_str()).unwrap_or(&false));
|
||||||
|
|
||||||
@@ -585,19 +631,26 @@ async fn run_agent_loop_inner(
|
|||||||
let mut sequential_results = Vec::with_capacity(sequential_indices.len());
|
let mut sequential_results = Vec::with_capacity(sequential_indices.len());
|
||||||
for &i in &sequential_indices {
|
for &i in &sequential_indices {
|
||||||
let tc = make_tc(&collected_tool_calls[i]);
|
let tc = make_tc(&collected_tool_calls[i]);
|
||||||
let execution_permissions = sequential_tool_permissions(&tc, db_type, &mut sql_permissions);
|
let result = match plugin_calls.remove(&i) {
|
||||||
sequential_results.push(
|
Some(Ok(prepared)) => {
|
||||||
agent_tools::execute_tool(
|
plugin_tools::execute_plugin_tool(&state2, agent_ctx.host_runtime.as_ref(), &tc, &prepared).await
|
||||||
&tc,
|
}
|
||||||
&state2,
|
Some(Err(not_executed)) => not_executed,
|
||||||
&conn2,
|
None => {
|
||||||
&db2,
|
let execution_permissions = sequential_tool_permissions(&tc, db_type, &mut sql_permissions);
|
||||||
schema2.as_deref(),
|
agent_tools::execute_tool(
|
||||||
&db_type,
|
&tc,
|
||||||
execution_permissions,
|
&state2,
|
||||||
)
|
&conn2,
|
||||||
.await,
|
&db2,
|
||||||
);
|
schema2.as_deref(),
|
||||||
|
&db_type,
|
||||||
|
execution_permissions,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
};
|
||||||
|
sequential_results.push(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge results back into original order
|
// Merge results back into original order
|
||||||
@@ -1095,7 +1148,7 @@ fn estimate_tool_schema_tokens(tools: &[ToolDefinition]) -> u32 {
|
|||||||
.map(|tool| {
|
.map(|tool| {
|
||||||
let schema_tokens =
|
let schema_tokens =
|
||||||
serde_json::to_string(&tool.parameters).map(|schema| estimate_text_tokens(&schema)).unwrap_or_default();
|
serde_json::to_string(&tool.parameters).map(|schema| estimate_text_tokens(&schema)).unwrap_or_default();
|
||||||
estimate_text_tokens(tool.name) + estimate_text_tokens(tool.description) + schema_tokens + 16
|
estimate_text_tokens(&tool.name) + estimate_text_tokens(&tool.description) + schema_tokens + 16
|
||||||
})
|
})
|
||||||
.sum()
|
.sum()
|
||||||
}
|
}
|
||||||
@@ -1290,6 +1343,98 @@ fn write_attempt_response(sql: &str, targets_production: bool) -> WriteAttemptRe
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PLUGIN_TOOL_DECLINED: &str =
|
||||||
|
"The user declined this tool call, so it did not run. Do not retry it; continue without it.";
|
||||||
|
const PLUGIN_TOOL_APPROVAL_TIMED_OUT: &str =
|
||||||
|
"Nobody approved this tool call in time, so it did not run. Do not retry it; tell the user what you intended to run.";
|
||||||
|
const PLUGIN_TOOL_APPROVAL_UNAVAILABLE: &str =
|
||||||
|
"This tool may change state and needs the user's approval, which this session cannot ask for. It did not run.";
|
||||||
|
|
||||||
|
/// Per-call outcome of the plugin tool gate: run the prepared call, or reply
|
||||||
|
/// with the ready result explaining why it did not run.
|
||||||
|
type PluginCallGate = std::collections::HashMap<usize, Result<PreparedPluginToolCall, ToolResult>>;
|
||||||
|
|
||||||
|
/// Validates this turn's plugin tool calls and asks the user to approve every
|
||||||
|
/// call that is not declared read-only, one at a time in call order. Returns
|
||||||
|
/// `None` when the run was cancelled while an approval was pending.
|
||||||
|
async fn gate_plugin_tool_calls<F>(
|
||||||
|
plugin_tool_set: &PluginToolSet,
|
||||||
|
tool_calls: &[ToolCall],
|
||||||
|
agent_ctx: &AgentLoopContext,
|
||||||
|
on_event: &F,
|
||||||
|
cancelled: &Notify,
|
||||||
|
) -> Option<PluginCallGate>
|
||||||
|
where
|
||||||
|
F: Fn(AgentEvent) + Sync,
|
||||||
|
{
|
||||||
|
let mut gate = PluginCallGate::new();
|
||||||
|
if plugin_tool_set.is_empty() {
|
||||||
|
return Some(gate);
|
||||||
|
}
|
||||||
|
for (index, tool_call) in tool_calls.iter().enumerate() {
|
||||||
|
let Some(prepared) = plugin_tool_set.prepare_call(tool_call) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let outcome = match prepared {
|
||||||
|
Err(error) => Err(plugin_tools::not_executed_result(tool_call, error)),
|
||||||
|
Ok(prepared) if prepared.read_only => Ok(prepared),
|
||||||
|
Ok(prepared) => match agent_ctx.session_id.as_deref() {
|
||||||
|
None => Err(plugin_tools::not_executed_result(tool_call, PLUGIN_TOOL_APPROVAL_UNAVAILABLE)),
|
||||||
|
Some(session_id) => match request_tool_approval(session_id, tool_call, &prepared, on_event, cancelled)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
ToolApprovalWait::Approved => Ok(prepared),
|
||||||
|
ToolApprovalWait::Denied => Err(plugin_tools::not_executed_result(tool_call, PLUGIN_TOOL_DECLINED)),
|
||||||
|
ToolApprovalWait::TimedOut => {
|
||||||
|
Err(plugin_tools::not_executed_result(tool_call, PLUGIN_TOOL_APPROVAL_TIMED_OUT))
|
||||||
|
}
|
||||||
|
ToolApprovalWait::Cancelled => return None,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
gate.insert(index, outcome);
|
||||||
|
}
|
||||||
|
Some(gate)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request_tool_approval<F>(
|
||||||
|
session_id: &str,
|
||||||
|
tool_call: &ToolCall,
|
||||||
|
prepared: &PreparedPluginToolCall,
|
||||||
|
on_event: &F,
|
||||||
|
cancelled: &Notify,
|
||||||
|
) -> ToolApprovalWait
|
||||||
|
where
|
||||||
|
F: Fn(AgentEvent) + Sync,
|
||||||
|
{
|
||||||
|
let approval = tool_approval::register_tool_approval(session_id);
|
||||||
|
let approval_id = approval.id().to_string();
|
||||||
|
on_event(AgentEvent::ToolApprovalRequired {
|
||||||
|
approval_id: approval_id.clone(),
|
||||||
|
tool_call_id: tool_call.id.clone(),
|
||||||
|
tool_name: tool_call.name.clone(),
|
||||||
|
plugin_id: prepared.plugin_id.clone(),
|
||||||
|
plugin_name: prepared.plugin_name.clone(),
|
||||||
|
plugin_tool: prepared.plugin_tool.clone(),
|
||||||
|
connection_id: prepared.connection_id.clone(),
|
||||||
|
connection_name: prepared.connection_name.clone(),
|
||||||
|
args: prepared.arguments.clone(),
|
||||||
|
timeout_secs: tool_approval::TOOL_APPROVAL_TIMEOUT.as_secs(),
|
||||||
|
});
|
||||||
|
let wait = approval.wait(tool_approval::TOOL_APPROVAL_TIMEOUT, cancelled).await;
|
||||||
|
on_event(AgentEvent::ToolApprovalResolved {
|
||||||
|
approval_id,
|
||||||
|
tool_call_id: tool_call.id.clone(),
|
||||||
|
outcome: match wait {
|
||||||
|
ToolApprovalWait::Approved => ToolApprovalOutcome::Approved,
|
||||||
|
ToolApprovalWait::Denied => ToolApprovalOutcome::Denied,
|
||||||
|
ToolApprovalWait::TimedOut => ToolApprovalOutcome::TimedOut,
|
||||||
|
ToolApprovalWait::Cancelled => ToolApprovalOutcome::Cancelled,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
wait
|
||||||
|
}
|
||||||
|
|
||||||
fn sequential_tool_permissions(
|
fn sequential_tool_permissions(
|
||||||
tool_call: &ToolCall,
|
tool_call: &ToolCall,
|
||||||
db_type: DatabaseType,
|
db_type: DatabaseType,
|
||||||
@@ -1517,6 +1662,113 @@ fn summarize_message_content(content: &str) -> String {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn plugin_tool_set() -> PluginToolSet {
|
||||||
|
PluginToolSet::from_listings_for_tests(
|
||||||
|
&[("io.dbx.kafka", "Kafka Studio")],
|
||||||
|
vec![(
|
||||||
|
plugin_tools::OpenPluginConnection {
|
||||||
|
connection_id: "k1".to_string(),
|
||||||
|
connection_name: "prod-kafka".to_string(),
|
||||||
|
plugin_id: "io.dbx.kafka".to_string(),
|
||||||
|
},
|
||||||
|
json!({ "tools": [
|
||||||
|
{ "name": "kafka_topics_list", "annotations": { "readOnlyHint": true }, "inputSchema": { "type": "object", "properties": {} } },
|
||||||
|
{ "name": "kafka_topics_delete", "inputSchema": { "type": "object", "properties": { "topics": { "type": "array", "items": { "type": "string" } } } } }
|
||||||
|
] }),
|
||||||
|
)],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn plugin_tool_call(id: &str, name: &str) -> ToolCall {
|
||||||
|
ToolCall {
|
||||||
|
id: id.to_string(),
|
||||||
|
name: name.to_string(),
|
||||||
|
arguments: json!({ "topics": ["orders"] }),
|
||||||
|
provider_payload: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn gate_context(session_id: Option<&str>) -> (tempfile::TempDir, AgentLoopContext) {
|
||||||
|
let temp_dir = tempfile::tempdir().unwrap();
|
||||||
|
let storage = crate::storage::Storage::open(&temp_dir.path().join("storage.db")).await.unwrap();
|
||||||
|
let ctx = AgentLoopContext {
|
||||||
|
state: Arc::new(AppState::new(storage)),
|
||||||
|
connection_id: "db".to_string(),
|
||||||
|
database: String::new(),
|
||||||
|
selected_databases: Vec::new(),
|
||||||
|
schema: None,
|
||||||
|
db_type: DatabaseType::Postgres,
|
||||||
|
cli_mcp_server_command: None,
|
||||||
|
sql_permissions: agent_tools::AgentSqlPermissions::default(),
|
||||||
|
max_agent_turns: DEFAULT_MAX_AGENT_TURNS,
|
||||||
|
prompt_cache_key: None,
|
||||||
|
session_id: session_id.map(str::to_string),
|
||||||
|
host_runtime: None,
|
||||||
|
};
|
||||||
|
(temp_dir, ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn plugin_tool_gate_runs_read_only_calls_and_asks_before_the_rest() {
|
||||||
|
let (_dir, ctx) = gate_context(Some("session-gate")).await;
|
||||||
|
let set = plugin_tool_set();
|
||||||
|
let calls = vec![
|
||||||
|
plugin_tool_call("c1", "kafka__kafka_topics_list"),
|
||||||
|
plugin_tool_call("c2", "kafka__kafka_topics_delete"),
|
||||||
|
plugin_tool_call("c3", "list_tables"),
|
||||||
|
];
|
||||||
|
let events = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let recorded = events.clone();
|
||||||
|
// The "user" approves as soon as the question appears.
|
||||||
|
let on_event = move |event: AgentEvent| {
|
||||||
|
if let AgentEvent::ToolApprovalRequired { approval_id, .. } = &event {
|
||||||
|
assert!(tool_approval::resolve_tool_approval("session-gate", approval_id, true));
|
||||||
|
}
|
||||||
|
recorded.lock().unwrap().push(event);
|
||||||
|
};
|
||||||
|
let gate = gate_plugin_tool_calls(&set, &calls, &ctx, &on_event, &Notify::new()).await.unwrap();
|
||||||
|
|
||||||
|
assert!(gate[&0].as_ref().is_ok_and(|prepared| prepared.read_only));
|
||||||
|
let approved = gate[&1].as_ref().unwrap();
|
||||||
|
assert_eq!(approved.connection_id, "k1");
|
||||||
|
assert_eq!(approved.arguments, json!({ "topics": ["orders"] }));
|
||||||
|
assert!(!gate.contains_key(&2), "database tools bypass the plugin gate");
|
||||||
|
|
||||||
|
let events = events.lock().unwrap();
|
||||||
|
assert_eq!(events.len(), 2, "only the non-read-only call asks: {events:?}");
|
||||||
|
assert!(
|
||||||
|
matches!(&events[0], AgentEvent::ToolApprovalRequired { tool_call_id, plugin_tool, .. } if tool_call_id == "c2" && plugin_tool == "kafka_topics_delete")
|
||||||
|
);
|
||||||
|
assert!(matches!(&events[1], AgentEvent::ToolApprovalResolved { outcome: ToolApprovalOutcome::Approved, .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn plugin_tool_gate_refuses_declined_and_unaskable_calls_and_stops_on_cancel() {
|
||||||
|
let set = plugin_tool_set();
|
||||||
|
let calls = vec![plugin_tool_call("c1", "kafka__kafka_topics_delete")];
|
||||||
|
|
||||||
|
let (_dir, ctx) = gate_context(Some("session-deny")).await;
|
||||||
|
let deny = |event: AgentEvent| {
|
||||||
|
if let AgentEvent::ToolApprovalRequired { approval_id, .. } = &event {
|
||||||
|
tool_approval::resolve_tool_approval("session-deny", approval_id, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let gate = gate_plugin_tool_calls(&set, &calls, &ctx, &deny, &Notify::new()).await.unwrap();
|
||||||
|
let declined = gate[&0].as_ref().unwrap_err();
|
||||||
|
assert!(declined.is_error);
|
||||||
|
assert_eq!(declined.content, PLUGIN_TOOL_DECLINED);
|
||||||
|
|
||||||
|
// No client session: nobody can be asked, so the call never runs.
|
||||||
|
let (_dir, headless) = gate_context(None).await;
|
||||||
|
let gate = gate_plugin_tool_calls(&set, &calls, &headless, &|_event| {}, &Notify::new()).await.unwrap();
|
||||||
|
assert_eq!(gate[&0].as_ref().unwrap_err().content, PLUGIN_TOOL_APPROVAL_UNAVAILABLE);
|
||||||
|
|
||||||
|
let (_dir, ctx) = gate_context(Some("session-cancel")).await;
|
||||||
|
let cancelled = Notify::new();
|
||||||
|
cancelled.notify_one();
|
||||||
|
assert!(gate_plugin_tool_calls(&set, &calls, &ctx, &|_event| {}, &cancelled).await.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn compaction_prompt_preserves_attachment_trust_boundary() {
|
fn compaction_prompt_preserves_attachment_trust_boundary() {
|
||||||
assert!(COMPACT_SYSTEM_PROMPT.contains("<attached-text-data>"));
|
assert!(COMPACT_SYSTEM_PROMPT.contains("<attached-text-data>"));
|
||||||
|
|||||||
@@ -280,13 +280,14 @@ pub fn is_vector_db(db_type: DatabaseType) -> bool {
|
|||||||
/// the current UTC time plus a caller-provided local offset.
|
/// the current UTC time plus a caller-provided local offset.
|
||||||
fn get_current_time_tool() -> ToolDefinition {
|
fn get_current_time_tool() -> ToolDefinition {
|
||||||
ToolDefinition {
|
ToolDefinition {
|
||||||
name: "get_current_time",
|
name: "get_current_time".into(),
|
||||||
description: "Get the current date and time with timezone information. \
|
description: "Get the current date and time with timezone information. \
|
||||||
Pass the client UTC offset from the system prompt; when \
|
Pass the client UTC offset from the system prompt; when \
|
||||||
omitted, local time safely falls back to UTC. Use this to resolve \
|
omitted, local time safely falls back to UTC. Use this to resolve \
|
||||||
relative time expressions like \"last 7 days\", \
|
relative time expressions like \"last 7 days\", \
|
||||||
\"yesterday\", \"this month\" into concrete dates \
|
\"yesterday\", \"this month\" into concrete dates \
|
||||||
for constructing SQL queries.",
|
for constructing SQL queries."
|
||||||
|
.into(),
|
||||||
parameters: json!({
|
parameters: json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -341,7 +342,9 @@ fn execute_get_current_time(tool_call: &ToolCall) -> Result<String, String> {
|
|||||||
/// Get read-only tool definitions for the given database type.
|
/// Get read-only tool definitions for the given database type.
|
||||||
/// Returns vector tools for vector DBs, SQL tools otherwise.
|
/// Returns vector tools for vector DBs, SQL tools otherwise.
|
||||||
pub fn read_only_tools(db_type: DatabaseType) -> Vec<ToolDefinition> {
|
pub fn read_only_tools(db_type: DatabaseType) -> Vec<ToolDefinition> {
|
||||||
if is_vector_db(db_type) {
|
if db_type == DatabaseType::Plugin {
|
||||||
|
vec![get_current_time_tool()]
|
||||||
|
} else if is_vector_db(db_type) {
|
||||||
vec![list_collections_tool(), get_current_time_tool()]
|
vec![list_collections_tool(), get_current_time_tool()]
|
||||||
} else {
|
} else {
|
||||||
vec![list_databases_tool(), list_tables_tool(), get_columns_tool(db_type), get_current_time_tool()]
|
vec![list_databases_tool(), list_tables_tool(), get_columns_tool(db_type), get_current_time_tool()]
|
||||||
@@ -352,6 +355,9 @@ pub fn read_only_tools(db_type: DatabaseType) -> Vec<ToolDefinition> {
|
|||||||
/// Includes read-only tools plus execute_query, get_sample_data, and
|
/// Includes read-only tools plus execute_query, get_sample_data, and
|
||||||
/// explain_query for database types that support them.
|
/// explain_query for database types that support them.
|
||||||
pub fn all_tools(db_type: DatabaseType, sql_permissions: AgentSqlPermissions) -> Vec<ToolDefinition> {
|
pub fn all_tools(db_type: DatabaseType, sql_permissions: AgentSqlPermissions) -> Vec<ToolDefinition> {
|
||||||
|
if db_type == DatabaseType::Plugin {
|
||||||
|
return vec![get_current_time_tool()];
|
||||||
|
}
|
||||||
if is_vector_db(db_type) {
|
if is_vector_db(db_type) {
|
||||||
return vec![list_collections_tool(), browse_collection_tool(), get_current_time_tool()];
|
return vec![list_collections_tool(), browse_collection_tool(), get_current_time_tool()];
|
||||||
}
|
}
|
||||||
@@ -375,8 +381,8 @@ pub fn all_tools(db_type: DatabaseType, sql_permissions: AgentSqlPermissions) ->
|
|||||||
|
|
||||||
fn list_databases_tool() -> ToolDefinition {
|
fn list_databases_tool() -> ToolDefinition {
|
||||||
ToolDefinition {
|
ToolDefinition {
|
||||||
name: "list_databases",
|
name: "list_databases".into(),
|
||||||
description: "List databases available through the current connection. If more than one database is returned, cross-database read queries can use fully qualified names such as database.table (or database.schema.table for SQL Server).",
|
description: "List databases available through the current connection. If more than one database is returned, cross-database read queries can use fully qualified names such as database.table (or database.schema.table for SQL Server).".into(),
|
||||||
parameters: json!({"type": "object", "properties": {}, "required": []}),
|
parameters: json!({"type": "object", "properties": {}, "required": []}),
|
||||||
read_only: true,
|
read_only: true,
|
||||||
parallel_ok: true,
|
parallel_ok: true,
|
||||||
@@ -385,8 +391,8 @@ fn list_databases_tool() -> ToolDefinition {
|
|||||||
|
|
||||||
fn mongo_execute_query_tool(_sql_permissions: AgentSqlPermissions) -> ToolDefinition {
|
fn mongo_execute_query_tool(_sql_permissions: AgentSqlPermissions) -> ToolDefinition {
|
||||||
ToolDefinition {
|
ToolDefinition {
|
||||||
name: "execute_query",
|
name: "execute_query".into(),
|
||||||
description: "Execute a read-only MongoDB shell command and return results (max 50 rows). Use commands such as db.collection.find({}), db.collection.findOne({}), db.collection.aggregate([]), or db.collection.countDocuments({}). Write commands are not available to the MongoDB Agent.",
|
description: "Execute a read-only MongoDB shell command and return results (max 50 rows). Use commands such as db.collection.find({}), db.collection.findOne({}), db.collection.aggregate([]), or db.collection.countDocuments({}). Write commands are not available to the MongoDB Agent.".into(),
|
||||||
parameters: json!({
|
parameters: json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -423,8 +429,8 @@ fn solr_execute_query_tool(sql_permissions: AgentSqlPermissions) -> ToolDefiniti
|
|||||||
request in one ```sql code block and ask for confirmation."
|
request in one ```sql code block and ask for confirmation."
|
||||||
};
|
};
|
||||||
ToolDefinition {
|
ToolDefinition {
|
||||||
name: "execute_query",
|
name: "execute_query".into(),
|
||||||
description,
|
description: description.into(),
|
||||||
parameters: json!({
|
parameters: json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -461,8 +467,9 @@ fn solr_execute_query_tool(sql_permissions: AgentSqlPermissions) -> ToolDefiniti
|
|||||||
/// list_tables tool definition.
|
/// list_tables tool definition.
|
||||||
fn list_tables_tool() -> ToolDefinition {
|
fn list_tables_tool() -> ToolDefinition {
|
||||||
ToolDefinition {
|
ToolDefinition {
|
||||||
name: "list_tables",
|
name: "list_tables".into(),
|
||||||
description: "List all tables and views in the current database. Returns table names, types, and comments.",
|
description: "List all tables and views in the current database. Returns table names, types, and comments."
|
||||||
|
.into(),
|
||||||
parameters: json!({
|
parameters: json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -485,15 +492,17 @@ fn list_tables_tool() -> ToolDefinition {
|
|||||||
/// get_columns tool definition.
|
/// get_columns tool definition.
|
||||||
fn get_columns_tool(db_type: DatabaseType) -> ToolDefinition {
|
fn get_columns_tool(db_type: DatabaseType) -> ToolDefinition {
|
||||||
ToolDefinition {
|
ToolDefinition {
|
||||||
name: "get_columns",
|
name: "get_columns".into(),
|
||||||
description: if db_type == DatabaseType::MongoDb {
|
description: if db_type == DatabaseType::MongoDb {
|
||||||
"Sample up to 100 documents from a MongoDB collection and infer up to 512 top-level field names and types. \
|
"Sample up to 100 documents from a MongoDB collection and infer up to 512 top-level field names and types. \
|
||||||
The sample may be smaller and is not a complete schema or a guarantee of required fields. \
|
The sample may be smaller and is not a complete schema or a guarantee of required fields. \
|
||||||
Nested documents and arrays remain object and array fields; numeric BSON types are reported as number."
|
Nested documents and arrays remain object and array fields; numeric BSON types are reported as number."
|
||||||
|
.into()
|
||||||
} else {
|
} else {
|
||||||
"Get column definitions for a table: names, types, primary keys, nullable, defaults, and comments. \
|
"Get column definitions for a table: names, types, primary keys, nullable, defaults, and comments. \
|
||||||
Use this when the user asks about table structure, column details, or field information — \
|
Use this when the user asks about table structure, column details, or field information — \
|
||||||
even if some schema context was provided, this tool returns the authoritative and complete column list."
|
even if some schema context was provided, this tool returns the authoritative and complete column list."
|
||||||
|
.into()
|
||||||
},
|
},
|
||||||
parameters: json!({
|
parameters: json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -529,8 +538,8 @@ fn execute_query_tool(sql_permissions: AgentSqlPermissions) -> ToolDefinition {
|
|||||||
"Execute a read-only SQL query and return results (default 50 rows, up to 1000 with the limit argument). Cross-database reads may use fully qualified names such as database.table (or database.schema.table for SQL Server) without switching the current database. This run cannot execute writes or DDL because no specific SQL has been confirmed yet; this does not mean the database itself is read-only. When the user requests a write, first propose the exact SQL in one ```sql code block and ask for confirmation. After confirmation, DBX starts a new run that can execute only that exact SQL. Only SELECT, WITH, SHOW, DESCRIBE, EXPLAIN statements may be executed in this run."
|
"Execute a read-only SQL query and return results (default 50 rows, up to 1000 with the limit argument). Cross-database reads may use fully qualified names such as database.table (or database.schema.table for SQL Server) without switching the current database. This run cannot execute writes or DDL because no specific SQL has been confirmed yet; this does not mean the database itself is read-only. When the user requests a write, first propose the exact SQL in one ```sql code block and ask for confirmation. After confirmation, DBX starts a new run that can execute only that exact SQL. Only SELECT, WITH, SHOW, DESCRIBE, EXPLAIN statements may be executed in this run."
|
||||||
};
|
};
|
||||||
ToolDefinition {
|
ToolDefinition {
|
||||||
name: "execute_query",
|
name: "execute_query".into(),
|
||||||
description,
|
description: description.into(),
|
||||||
parameters: json!({
|
parameters: json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -571,8 +580,8 @@ fn execute_query_tool(sql_permissions: AgentSqlPermissions) -> ToolDefinition {
|
|||||||
/// get_sample_data tool definition.
|
/// get_sample_data tool definition.
|
||||||
fn get_sample_data_tool() -> ToolDefinition {
|
fn get_sample_data_tool() -> ToolDefinition {
|
||||||
ToolDefinition {
|
ToolDefinition {
|
||||||
name: "get_sample_data",
|
name: "get_sample_data".into(),
|
||||||
description: "Get sample rows from a table to understand its data. Returns up to 20 rows.",
|
description: "Get sample rows from a table to understand its data. Returns up to 20 rows.".into(),
|
||||||
parameters: json!({
|
parameters: json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -603,11 +612,12 @@ fn get_sample_data_tool() -> ToolDefinition {
|
|||||||
/// explain_query tool definition (Phase 3).
|
/// explain_query tool definition (Phase 3).
|
||||||
fn explain_query_tool() -> ToolDefinition {
|
fn explain_query_tool() -> ToolDefinition {
|
||||||
ToolDefinition {
|
ToolDefinition {
|
||||||
name: "explain_query",
|
name: "explain_query".into(),
|
||||||
description: "Get the execution plan for a SQL query using EXPLAIN. \
|
description: "Get the execution plan for a SQL query using EXPLAIN. \
|
||||||
Shows how the database will execute the query (scan type, indexes, cost). \
|
Shows how the database will execute the query (scan type, indexes, cost). \
|
||||||
Only read-only queries (SELECT, WITH, SHOW, DESCRIBE, EXPLAIN) are allowed. \
|
Only read-only queries (SELECT, WITH, SHOW, DESCRIBE, EXPLAIN) are allowed. \
|
||||||
Use this to analyze query performance and suggest index optimizations.",
|
Use this to analyze query performance and suggest index optimizations."
|
||||||
|
.into(),
|
||||||
parameters: json!({
|
parameters: json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -626,8 +636,9 @@ fn explain_query_tool() -> ToolDefinition {
|
|||||||
/// list_collections tool definition (vector databases).
|
/// list_collections tool definition (vector databases).
|
||||||
fn list_collections_tool() -> ToolDefinition {
|
fn list_collections_tool() -> ToolDefinition {
|
||||||
ToolDefinition {
|
ToolDefinition {
|
||||||
name: "list_collections",
|
name: "list_collections".into(),
|
||||||
description: "List all collections in the current vector database. Returns collection names and dimensions.",
|
description: "List all collections in the current vector database. Returns collection names and dimensions."
|
||||||
|
.into(),
|
||||||
parameters: json!({
|
parameters: json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {},
|
"properties": {},
|
||||||
@@ -641,8 +652,8 @@ fn list_collections_tool() -> ToolDefinition {
|
|||||||
/// browse_collection tool definition (vector databases).
|
/// browse_collection tool definition (vector databases).
|
||||||
fn browse_collection_tool() -> ToolDefinition {
|
fn browse_collection_tool() -> ToolDefinition {
|
||||||
ToolDefinition {
|
ToolDefinition {
|
||||||
name: "browse_collection",
|
name: "browse_collection".into(),
|
||||||
description: "Browse documents in a collection. Returns up to 20 items with payload/metadata (vectors excluded for compactness). For ChromaDB, use the collection id (UUID from list_collections) instead of the collection name.",
|
description: "Browse documents in a collection. Returns up to 20 items with payload/metadata (vectors excluded for compactness). For ChromaDB, use the collection id (UUID from list_collections) instead of the collection name.".into(),
|
||||||
parameters: json!({
|
parameters: json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -1697,7 +1708,7 @@ for line in sys.stdin:
|
|||||||
#[test]
|
#[test]
|
||||||
fn vector_read_only_tools_do_not_include_collection_browsing() {
|
fn vector_read_only_tools_do_not_include_collection_browsing() {
|
||||||
let tools = read_only_tools(DatabaseType::Qdrant);
|
let tools = read_only_tools(DatabaseType::Qdrant);
|
||||||
let names: Vec<&str> = tools.iter().map(|tool| tool.name).collect();
|
let names: Vec<&str> = tools.iter().map(|tool| tool.name.as_ref()).collect();
|
||||||
|
|
||||||
assert!(names.contains(&"list_collections"));
|
assert!(names.contains(&"list_collections"));
|
||||||
assert!(!names.contains(&"browse_collection"));
|
assert!(!names.contains(&"browse_collection"));
|
||||||
@@ -1707,17 +1718,27 @@ for line in sys.stdin:
|
|||||||
#[test]
|
#[test]
|
||||||
fn vector_agent_tools_include_collection_browsing() {
|
fn vector_agent_tools_include_collection_browsing() {
|
||||||
let tools = all_tools(DatabaseType::Qdrant, AgentSqlPermissions::default());
|
let tools = all_tools(DatabaseType::Qdrant, AgentSqlPermissions::default());
|
||||||
let names: Vec<&str> = tools.iter().map(|tool| tool.name).collect();
|
let names: Vec<&str> = tools.iter().map(|tool| tool.name.as_ref()).collect();
|
||||||
|
|
||||||
assert!(names.contains(&"list_collections"));
|
assert!(names.contains(&"list_collections"));
|
||||||
assert!(names.contains(&"browse_collection"));
|
assert!(names.contains(&"browse_collection"));
|
||||||
assert!(names.contains(&"get_current_time"));
|
assert!(names.contains(&"get_current_time"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn plugin_connections_only_receive_database_independent_builtin_tools() {
|
||||||
|
for tools in
|
||||||
|
[read_only_tools(DatabaseType::Plugin), all_tools(DatabaseType::Plugin, AgentSqlPermissions::default())]
|
||||||
|
{
|
||||||
|
let names = tools.iter().map(|tool| tool.name.as_ref()).collect::<Vec<_>>();
|
||||||
|
assert_eq!(names, ["get_current_time"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn solr_agent_registers_rest_execute_query_tool() {
|
fn solr_agent_registers_rest_execute_query_tool() {
|
||||||
let tools = all_tools(DatabaseType::Solr, AgentSqlPermissions::default());
|
let tools = all_tools(DatabaseType::Solr, AgentSqlPermissions::default());
|
||||||
let names = tools.iter().map(|tool| tool.name).collect::<Vec<_>>();
|
let names = tools.iter().map(|tool| tool.name.as_ref()).collect::<Vec<_>>();
|
||||||
// Solr gets the shared metadata tools plus a REST `execute_query`; SQL
|
// Solr gets the shared metadata tools plus a REST `execute_query`; SQL
|
||||||
// sample/explain helpers are not meaningful for a non-SQL backend.
|
// sample/explain helpers are not meaningful for a non-SQL backend.
|
||||||
assert!(names.contains(&"list_tables"));
|
assert!(names.contains(&"list_tables"));
|
||||||
@@ -1746,7 +1767,7 @@ for line in sys.stdin:
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn mongodb_agent_registers_shell_query_tool_and_routes_find_one_as_read_only() {
|
async fn mongodb_agent_registers_shell_query_tool_and_routes_find_one_as_read_only() {
|
||||||
let tools = all_tools(DatabaseType::MongoDb, AgentSqlPermissions::default());
|
let tools = all_tools(DatabaseType::MongoDb, AgentSqlPermissions::default());
|
||||||
let names = tools.iter().map(|tool| tool.name).collect::<Vec<_>>();
|
let names = tools.iter().map(|tool| tool.name.as_ref()).collect::<Vec<_>>();
|
||||||
assert!(names.contains(&"execute_query"));
|
assert!(names.contains(&"execute_query"));
|
||||||
assert!(!names.contains(&"get_sample_data"));
|
assert!(!names.contains(&"get_sample_data"));
|
||||||
assert!(!names.contains(&"explain_query"));
|
assert!(!names.contains(&"explain_query"));
|
||||||
@@ -1906,7 +1927,7 @@ for line in sys.stdin:
|
|||||||
#[test]
|
#[test]
|
||||||
fn oracle_agent_tools_include_explain_query() {
|
fn oracle_agent_tools_include_explain_query() {
|
||||||
let tools = all_tools(DatabaseType::Oracle, AgentSqlPermissions::default());
|
let tools = all_tools(DatabaseType::Oracle, AgentSqlPermissions::default());
|
||||||
let names: Vec<&str> = tools.iter().map(|tool| tool.name).collect();
|
let names: Vec<&str> = tools.iter().map(|tool| tool.name.as_ref()).collect();
|
||||||
|
|
||||||
assert!(names.contains(&"explain_query"));
|
assert!(names.contains(&"explain_query"));
|
||||||
}
|
}
|
||||||
@@ -2456,28 +2477,28 @@ for line in sys.stdin:
|
|||||||
#[test]
|
#[test]
|
||||||
fn get_current_time_is_in_all_tools_postgres() {
|
fn get_current_time_is_in_all_tools_postgres() {
|
||||||
let tools = all_tools(DatabaseType::Postgres, AgentSqlPermissions::default());
|
let tools = all_tools(DatabaseType::Postgres, AgentSqlPermissions::default());
|
||||||
let names: Vec<&str> = tools.iter().map(|tool| tool.name).collect();
|
let names: Vec<&str> = tools.iter().map(|tool| tool.name.as_ref()).collect();
|
||||||
assert!(names.contains(&"get_current_time"), "get_current_time missing from all_tools(Postgres)");
|
assert!(names.contains(&"get_current_time"), "get_current_time missing from all_tools(Postgres)");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn get_current_time_is_in_read_only_tools_postgres() {
|
fn get_current_time_is_in_read_only_tools_postgres() {
|
||||||
let tools = read_only_tools(DatabaseType::Postgres);
|
let tools = read_only_tools(DatabaseType::Postgres);
|
||||||
let names: Vec<&str> = tools.iter().map(|tool| tool.name).collect();
|
let names: Vec<&str> = tools.iter().map(|tool| tool.name.as_ref()).collect();
|
||||||
assert!(names.contains(&"get_current_time"), "get_current_time missing from read_only_tools(Postgres)");
|
assert!(names.contains(&"get_current_time"), "get_current_time missing from read_only_tools(Postgres)");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn get_current_time_is_in_all_tools_qdrant() {
|
fn get_current_time_is_in_all_tools_qdrant() {
|
||||||
let tools = all_tools(DatabaseType::Qdrant, AgentSqlPermissions::default());
|
let tools = all_tools(DatabaseType::Qdrant, AgentSqlPermissions::default());
|
||||||
let names: Vec<&str> = tools.iter().map(|tool| tool.name).collect();
|
let names: Vec<&str> = tools.iter().map(|tool| tool.name.as_ref()).collect();
|
||||||
assert!(names.contains(&"get_current_time"), "get_current_time missing from all_tools(Qdrant)");
|
assert!(names.contains(&"get_current_time"), "get_current_time missing from all_tools(Qdrant)");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn get_current_time_is_in_read_only_tools_qdrant() {
|
fn get_current_time_is_in_read_only_tools_qdrant() {
|
||||||
let tools = read_only_tools(DatabaseType::Qdrant);
|
let tools = read_only_tools(DatabaseType::Qdrant);
|
||||||
let names: Vec<&str> = tools.iter().map(|tool| tool.name).collect();
|
let names: Vec<&str> = tools.iter().map(|tool| tool.name.as_ref()).collect();
|
||||||
assert!(names.contains(&"get_current_time"), "get_current_time missing from read_only_tools(Qdrant)");
|
assert!(names.contains(&"get_current_time"), "get_current_time missing from read_only_tools(Qdrant)");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,4 +5,6 @@ pub mod agent_kv;
|
|||||||
pub mod agent_loop;
|
pub mod agent_loop;
|
||||||
pub mod agent_tools;
|
pub mod agent_tools;
|
||||||
pub mod mcp_policy;
|
pub mod mcp_policy;
|
||||||
|
pub mod plugin_tools;
|
||||||
pub mod prompt_template;
|
pub mod prompt_template;
|
||||||
|
pub mod tool_approval;
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,158 @@
|
|||||||
|
//! Interactive approvals for agent tool calls.
|
||||||
|
//!
|
||||||
|
//! A tool call that may change state outside DBX (today: plugin MCP tools not
|
||||||
|
//! declared read-only) must not run on the model's word alone. The agent loop
|
||||||
|
//! registers a pending approval, emits `AgentEvent::ToolApprovalRequired`, and
|
||||||
|
//! suspends until the client answers through [`resolve_tool_approval`], the
|
||||||
|
//! deadline passes, or the run is cancelled.
|
||||||
|
//!
|
||||||
|
//! The registry is process-wide and deliberately runtime-agnostic (a std mutex
|
||||||
|
//! plus `oneshot` channels): the web server runs each agent loop on its own
|
||||||
|
//! runtime while the approval arrives through an HTTP handler on another one.
|
||||||
|
//! An approval is bound to the AI session that requested it, so a client can
|
||||||
|
//! only answer questions its own run asked.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use tokio::sync::{oneshot, Notify};
|
||||||
|
|
||||||
|
/// How long a pending approval waits for the user before it counts as denied.
|
||||||
|
pub const TOOL_APPROVAL_TIMEOUT: Duration = Duration::from_secs(300);
|
||||||
|
|
||||||
|
struct PendingApproval {
|
||||||
|
session_id: String,
|
||||||
|
responder: oneshot::Sender<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pending_approvals() -> &'static Mutex<HashMap<String, PendingApproval>> {
|
||||||
|
static PENDING: OnceLock<Mutex<HashMap<String, PendingApproval>>> = OnceLock::new();
|
||||||
|
PENDING.get_or_init(|| Mutex::new(HashMap::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// How a wait for approval ended.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ToolApprovalWait {
|
||||||
|
Approved,
|
||||||
|
Denied,
|
||||||
|
TimedOut,
|
||||||
|
Cancelled,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A registered approval. Dropping it removes the entry, so an abandoned wait
|
||||||
|
/// (cancelled future, early return) can never leave a stale approval behind
|
||||||
|
/// for a later answer to hit.
|
||||||
|
pub struct PendingToolApproval {
|
||||||
|
approval_id: String,
|
||||||
|
receiver: Option<oneshot::Receiver<bool>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PendingToolApproval {
|
||||||
|
pub fn id(&self) -> &str {
|
||||||
|
&self.approval_id
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Waits for the user's answer. A dropped responder (the entry was purged)
|
||||||
|
/// counts as a denial: without an answer there is no approval.
|
||||||
|
pub async fn wait(mut self, timeout: Duration, cancelled: &Notify) -> ToolApprovalWait {
|
||||||
|
let Some(receiver) = self.receiver.take() else {
|
||||||
|
return ToolApprovalWait::Denied;
|
||||||
|
};
|
||||||
|
tokio::select! {
|
||||||
|
biased;
|
||||||
|
_ = cancelled.notified() => ToolApprovalWait::Cancelled,
|
||||||
|
answer = tokio::time::timeout(timeout, receiver) => match answer {
|
||||||
|
Ok(Ok(true)) => ToolApprovalWait::Approved,
|
||||||
|
Ok(Ok(false)) | Ok(Err(_)) => ToolApprovalWait::Denied,
|
||||||
|
Err(_) => ToolApprovalWait::TimedOut,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Drop for PendingToolApproval {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
pending_approvals().lock().unwrap_or_else(|poisoned| poisoned.into_inner()).remove(&self.approval_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Registers a pending approval for `session_id`.
|
||||||
|
pub fn register_tool_approval(session_id: &str) -> PendingToolApproval {
|
||||||
|
let approval_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let (responder, receiver) = oneshot::channel();
|
||||||
|
pending_approvals()
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||||
|
.insert(approval_id.clone(), PendingApproval { session_id: session_id.to_string(), responder });
|
||||||
|
PendingToolApproval { approval_id, receiver: Some(receiver) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Answers a pending approval. Returns `false` when no approval with this id is
|
||||||
|
/// pending for `session_id` (already answered, timed out, or never asked).
|
||||||
|
pub fn resolve_tool_approval(session_id: &str, approval_id: &str, approved: bool) -> bool {
|
||||||
|
let mut pending = pending_approvals().lock().unwrap_or_else(|poisoned| poisoned.into_inner());
|
||||||
|
let belongs_to_session = pending.get(approval_id).is_some_and(|entry| entry.session_id == session_id);
|
||||||
|
if !belongs_to_session {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let Some(entry) = pending.remove(approval_id) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
entry.responder.send(approved).is_ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn resolved_approval_reports_the_user_answer() {
|
||||||
|
let cancelled = Notify::new();
|
||||||
|
let approval = register_tool_approval("session-a");
|
||||||
|
let id = approval.id().to_string();
|
||||||
|
assert!(resolve_tool_approval("session-a", &id, true));
|
||||||
|
assert_eq!(approval.wait(Duration::from_secs(5), &cancelled).await, ToolApprovalWait::Approved);
|
||||||
|
|
||||||
|
let denied = register_tool_approval("session-a");
|
||||||
|
let id = denied.id().to_string();
|
||||||
|
assert!(resolve_tool_approval("session-a", &id, false));
|
||||||
|
assert_eq!(denied.wait(Duration::from_secs(5), &cancelled).await, ToolApprovalWait::Denied);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn another_session_cannot_answer_an_approval() {
|
||||||
|
let cancelled = Notify::new();
|
||||||
|
let approval = register_tool_approval("owner");
|
||||||
|
let id = approval.id().to_string();
|
||||||
|
assert!(!resolve_tool_approval("intruder", &id, true));
|
||||||
|
assert!(resolve_tool_approval("owner", &id, false));
|
||||||
|
assert_eq!(approval.wait(Duration::from_secs(5), &cancelled).await, ToolApprovalWait::Denied);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn unanswered_approval_times_out_and_is_forgotten() {
|
||||||
|
let cancelled = Notify::new();
|
||||||
|
let approval = register_tool_approval("session-b");
|
||||||
|
let id = approval.id().to_string();
|
||||||
|
assert_eq!(approval.wait(Duration::from_millis(20), &cancelled).await, ToolApprovalWait::TimedOut);
|
||||||
|
assert!(!resolve_tool_approval("session-b", &id, true), "a timed-out approval must not accept a late answer");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cancellation_ends_the_wait() {
|
||||||
|
let cancelled = Notify::new();
|
||||||
|
cancelled.notify_one();
|
||||||
|
let approval = register_tool_approval("session-c");
|
||||||
|
assert_eq!(approval.wait(Duration::from_secs(5), &cancelled).await, ToolApprovalWait::Cancelled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn cancellation_wins_over_an_answer_that_arrived_at_the_same_time() {
|
||||||
|
let cancelled = Notify::new();
|
||||||
|
let approval = register_tool_approval("session-race");
|
||||||
|
assert!(resolve_tool_approval("session-race", approval.id(), true));
|
||||||
|
cancelled.notify_one();
|
||||||
|
assert_eq!(approval.wait(Duration::from_secs(5), &cancelled).await, ToolApprovalWait::Cancelled);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ pub use ai::agent_explain;
|
|||||||
pub use ai::agent_kv;
|
pub use ai::agent_kv;
|
||||||
pub use ai::agent_loop;
|
pub use ai::agent_loop;
|
||||||
pub use ai::agent_tools;
|
pub use ai::agent_tools;
|
||||||
|
pub use ai::plugin_tools;
|
||||||
|
pub use ai::tool_approval;
|
||||||
pub use dbx_drivers::agent_catalog;
|
pub use dbx_drivers::agent_catalog;
|
||||||
pub use dbx_drivers::agent_connection;
|
pub use dbx_drivers::agent_connection;
|
||||||
pub use dbx_drivers::agent_manager;
|
pub use dbx_drivers::agent_manager;
|
||||||
|
|||||||
@@ -56,6 +56,14 @@ const MCP_HTTP_SERVER_SETTINGS_KEY: &str = "mcp_http_server_settings";
|
|||||||
const MAX_RETRIES_KEY: &str = "max_retries";
|
const MAX_RETRIES_KEY: &str = "max_retries";
|
||||||
const HISTORY_RETENTION_LIMIT_KEY: &str = "history_retention_limit";
|
const HISTORY_RETENTION_LIMIT_KEY: &str = "history_retention_limit";
|
||||||
const SQL_FILE_UPLOAD_MAX_MB_KEY: &str = "sql_file_upload_max_mb";
|
const SQL_FILE_UPLOAD_MAX_MB_KEY: &str = "sql_file_upload_max_mb";
|
||||||
|
/// Plugin ids whose MCP tools the built-in AI agent may call.
|
||||||
|
const AI_PLUGIN_TOOL_PLUGINS_KEY: &str = "ai_plugin_tool_plugins";
|
||||||
|
/// `{ pluginId: [connectionId, ...] }` — connections a plugin may read through
|
||||||
|
/// the `host.data:read` Host API. Written only after an explicit user consent.
|
||||||
|
const PLUGIN_DATA_GRANTS_KEY: &str = "plugin_data_grants";
|
||||||
|
/// Upper bound for one plugin's persisted data grants; consent is per
|
||||||
|
/// connection, so a legitimate plugin never approaches it.
|
||||||
|
const MAX_PLUGIN_DATA_GRANTS_PER_PLUGIN: usize = 512;
|
||||||
const APP_STATE_AI_GLOBAL_INSTRUCTIONS_KEY: &str = "ai_global_custom_instructions";
|
const APP_STATE_AI_GLOBAL_INSTRUCTIONS_KEY: &str = "ai_global_custom_instructions";
|
||||||
const APP_STATE_AI_CHAT_SELECTION_KEY: &str = "ai_chat_selection_v1";
|
const APP_STATE_AI_CHAT_SELECTION_KEY: &str = "ai_chat_selection_v1";
|
||||||
const SNIPPET_SYNC_IDS_KEY: &str = "snippet_sync_ids";
|
const SNIPPET_SYNC_IDS_KEY: &str = "snippet_sync_ids";
|
||||||
@@ -3132,6 +3140,56 @@ fn load_history_retention_limit_from_conn(conn: &Connection) -> Result<u32, Stri
|
|||||||
Ok(history_retention_limit_from_settings(&settings))
|
Ok(history_retention_limit_from_settings(&settings))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn app_settings_map_from_conn(conn: &Connection) -> Result<serde_json::Map<String, serde_json::Value>, String> {
|
||||||
|
let current: Option<String> = conn
|
||||||
|
.query_row("SELECT settings_json FROM app_settings WHERE id = 1", [], |row| row.get(0))
|
||||||
|
.optional()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
match current {
|
||||||
|
Some(json) => serde_json::from_str::<serde_json::Map<String, serde_json::Value>>(&json)
|
||||||
|
.map_err(|e| format!("invalid app settings JSON: {e}")),
|
||||||
|
None => Ok(serde_json::Map::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_app_settings_map(
|
||||||
|
conn: &Connection,
|
||||||
|
settings: &serde_json::Map<String, serde_json::Value>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let json = serde_json::to_string(settings).map_err(|e| e.to_string())?;
|
||||||
|
conn.execute("INSERT OR REPLACE INTO app_settings (id, settings_json) VALUES (1, ?1)", [json])
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reads a JSON string array setting, dropping blanks and duplicates so a
|
||||||
|
/// hand-edited or partially written value can never widen a permission list.
|
||||||
|
fn normalized_string_list(value: Option<&serde_json::Value>) -> Vec<String> {
|
||||||
|
let mut values: Vec<String> = value
|
||||||
|
.and_then(serde_json::Value::as_array)
|
||||||
|
.map(|items| {
|
||||||
|
items
|
||||||
|
.iter()
|
||||||
|
.filter_map(serde_json::Value::as_str)
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|item| !item.is_empty())
|
||||||
|
.map(str::to_string)
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
values.sort();
|
||||||
|
values.dedup();
|
||||||
|
values
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized_setting_id(value: &str, label: &str) -> Result<String, String> {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.is_empty() || trimmed.chars().count() > 256 {
|
||||||
|
return Err(format!("A valid {label} is required"));
|
||||||
|
}
|
||||||
|
Ok(trimmed.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
impl Storage {
|
impl Storage {
|
||||||
pub async fn save_history_entry(&self, entry: &HistoryEntry) -> Result<(), String> {
|
pub async fn save_history_entry(&self, entry: &HistoryEntry) -> Result<(), String> {
|
||||||
let entry = entry.clone();
|
let entry = entry.clone();
|
||||||
@@ -3956,8 +4014,14 @@ impl Storage {
|
|||||||
.query_row("SELECT settings_json FROM app_settings WHERE id = 1", [], |row| row.get(0))
|
.query_row("SELECT settings_json FROM app_settings WHERE id = 1", [], |row| row.get(0))
|
||||||
.optional()
|
.optional()
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let dedicated_keys =
|
let dedicated_keys = [
|
||||||
[MCP_GLOBAL_POLICY_KEY, MAX_RETRIES_KEY, SQL_FILE_UPLOAD_MAX_MB_KEY, HISTORY_RETENTION_LIMIT_KEY];
|
MCP_GLOBAL_POLICY_KEY,
|
||||||
|
MAX_RETRIES_KEY,
|
||||||
|
SQL_FILE_UPLOAD_MAX_MB_KEY,
|
||||||
|
HISTORY_RETENTION_LIMIT_KEY,
|
||||||
|
AI_PLUGIN_TOOL_PLUGINS_KEY,
|
||||||
|
PLUGIN_DATA_GRANTS_KEY,
|
||||||
|
];
|
||||||
for key in dedicated_keys {
|
for key in dedicated_keys {
|
||||||
settings.remove(key);
|
settings.remove(key);
|
||||||
}
|
}
|
||||||
@@ -4067,6 +4131,101 @@ impl Storage {
|
|||||||
.map_err(|e| format!("MCP_POLICY_UNAVAILABLE: {e}"))
|
.map_err(|e| format!("MCP_POLICY_UNAVAILABLE: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Plugin ids whose MCP tools the built-in AI agent may call. Empty until
|
||||||
|
/// the user enables a plugin in the Plugin Center.
|
||||||
|
pub async fn load_ai_plugin_tool_plugin_ids(&self) -> Result<Vec<String>, String> {
|
||||||
|
let settings = self.load_app_settings_json().await?;
|
||||||
|
Ok(normalized_string_list(settings.get(AI_PLUGIN_TOOL_PLUGINS_KEY)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enables or disables built-in AI access to one plugin's tools and returns
|
||||||
|
/// the updated list. The read-modify-write runs inside one connection
|
||||||
|
/// closure so concurrent settings saves cannot drop the change.
|
||||||
|
pub async fn set_ai_plugin_tool_plugin_enabled(
|
||||||
|
&self,
|
||||||
|
plugin_id: &str,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<Vec<String>, String> {
|
||||||
|
let plugin_id = normalized_setting_id(plugin_id, "plugin id")?;
|
||||||
|
self.with_conn(move |conn| {
|
||||||
|
let mut settings = app_settings_map_from_conn(conn)?;
|
||||||
|
let mut plugin_ids = normalized_string_list(settings.get(AI_PLUGIN_TOOL_PLUGINS_KEY));
|
||||||
|
plugin_ids.retain(|candidate| candidate != &plugin_id);
|
||||||
|
if enabled {
|
||||||
|
plugin_ids.push(plugin_id);
|
||||||
|
plugin_ids.sort();
|
||||||
|
}
|
||||||
|
settings.insert(AI_PLUGIN_TOOL_PLUGINS_KEY.to_string(), serde_json::json!(plugin_ids));
|
||||||
|
write_app_settings_map(conn, &settings)?;
|
||||||
|
Ok(plugin_ids)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Connections the plugin may read through `host.data:read`.
|
||||||
|
pub async fn load_plugin_data_grants(&self, plugin_id: &str) -> Result<Vec<String>, String> {
|
||||||
|
let settings = self.load_app_settings_json().await?;
|
||||||
|
Ok(normalized_string_list(settings.get(PLUGIN_DATA_GRANTS_KEY).and_then(|grants| grants.get(plugin_id.trim()))))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records or revokes the user's consent for one plugin to read one
|
||||||
|
/// connection and returns the plugin's updated grant list.
|
||||||
|
pub async fn set_plugin_data_grant(
|
||||||
|
&self,
|
||||||
|
plugin_id: &str,
|
||||||
|
connection_id: &str,
|
||||||
|
granted: bool,
|
||||||
|
) -> Result<Vec<String>, String> {
|
||||||
|
let plugin_id = normalized_setting_id(plugin_id, "plugin id")?;
|
||||||
|
let connection_id = normalized_setting_id(connection_id, "connection id")?;
|
||||||
|
self.with_conn(move |conn| {
|
||||||
|
let mut settings = app_settings_map_from_conn(conn)?;
|
||||||
|
let mut all_grants = match settings.remove(PLUGIN_DATA_GRANTS_KEY) {
|
||||||
|
Some(serde_json::Value::Object(grants)) => grants,
|
||||||
|
_ => serde_json::Map::new(),
|
||||||
|
};
|
||||||
|
let mut connection_ids = normalized_string_list(all_grants.get(&plugin_id));
|
||||||
|
connection_ids.retain(|candidate| candidate != &connection_id);
|
||||||
|
if granted {
|
||||||
|
if connection_ids.len() >= MAX_PLUGIN_DATA_GRANTS_PER_PLUGIN {
|
||||||
|
return Err(format!(
|
||||||
|
"A plugin can hold at most {MAX_PLUGIN_DATA_GRANTS_PER_PLUGIN} data access grants"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
connection_ids.push(connection_id);
|
||||||
|
connection_ids.sort();
|
||||||
|
}
|
||||||
|
if connection_ids.is_empty() {
|
||||||
|
all_grants.remove(&plugin_id);
|
||||||
|
} else {
|
||||||
|
all_grants.insert(plugin_id, serde_json::json!(connection_ids));
|
||||||
|
}
|
||||||
|
if !all_grants.is_empty() {
|
||||||
|
settings.insert(PLUGIN_DATA_GRANTS_KEY.to_string(), serde_json::Value::Object(all_grants));
|
||||||
|
}
|
||||||
|
write_app_settings_map(conn, &settings)?;
|
||||||
|
Ok(connection_ids)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drops everything the user granted to a plugin (AI tool access and data
|
||||||
|
/// grants). Called after uninstall so a reinstall starts without consent.
|
||||||
|
pub async fn forget_plugin_permissions(&self, plugin_id: &str) -> Result<(), String> {
|
||||||
|
let plugin_id = normalized_setting_id(plugin_id, "plugin id")?;
|
||||||
|
self.with_conn(move |conn| {
|
||||||
|
let mut settings = app_settings_map_from_conn(conn)?;
|
||||||
|
let mut plugin_ids = normalized_string_list(settings.get(AI_PLUGIN_TOOL_PLUGINS_KEY));
|
||||||
|
plugin_ids.retain(|candidate| candidate != &plugin_id);
|
||||||
|
settings.insert(AI_PLUGIN_TOOL_PLUGINS_KEY.to_string(), serde_json::json!(plugin_ids));
|
||||||
|
if let Some(serde_json::Value::Object(grants)) = settings.get_mut(PLUGIN_DATA_GRANTS_KEY) {
|
||||||
|
grants.remove(&plugin_id);
|
||||||
|
}
|
||||||
|
write_app_settings_map(conn, &settings)
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn load_mcp_http_server_settings(&self) -> Result<McpHttpServerSettings, String> {
|
pub async fn load_mcp_http_server_settings(&self) -> Result<McpHttpServerSettings, String> {
|
||||||
let settings = self.load_app_settings_json().await?;
|
let settings = self.load_app_settings_json().await?;
|
||||||
match settings.get(MCP_HTTP_SERVER_SETTINGS_KEY) {
|
match settings.get(MCP_HTTP_SERVER_SETTINGS_KEY) {
|
||||||
@@ -11092,6 +11251,40 @@ mod tests {
|
|||||||
assert_eq!(storage.load_max_retries().await.unwrap(), crate::ai::MAX_MAX_RETRIES);
|
assert_eq!(storage.load_max_retries().await.unwrap(), crate::ai::MAX_MAX_RETRIES);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn plugin_ai_tools_and_data_grants_persist_and_survive_legacy_settings_saves() {
|
||||||
|
let path = temp_db_path("plugin-permissions");
|
||||||
|
let storage = Storage::open(&path).await.unwrap();
|
||||||
|
assert!(storage.load_ai_plugin_tool_plugin_ids().await.unwrap().is_empty());
|
||||||
|
assert!(storage.load_plugin_data_grants("io.dbx.chart").await.unwrap().is_empty());
|
||||||
|
|
||||||
|
assert_eq!(storage.set_ai_plugin_tool_plugin_enabled("io.dbx.ssh", true).await.unwrap(), ["io.dbx.ssh"]);
|
||||||
|
storage.set_ai_plugin_tool_plugin_enabled("io.dbx.kafka", true).await.unwrap();
|
||||||
|
storage.set_ai_plugin_tool_plugin_enabled("io.dbx.ssh", true).await.unwrap();
|
||||||
|
assert_eq!(storage.load_ai_plugin_tool_plugin_ids().await.unwrap(), ["io.dbx.kafka", "io.dbx.ssh"]);
|
||||||
|
|
||||||
|
storage.set_plugin_data_grant("io.dbx.chart", "conn-b", true).await.unwrap();
|
||||||
|
assert_eq!(storage.set_plugin_data_grant("io.dbx.chart", "conn-a", true).await.unwrap(), ["conn-a", "conn-b"]);
|
||||||
|
storage.set_plugin_data_grant("io.dbx.other", "conn-a", true).await.unwrap();
|
||||||
|
|
||||||
|
// A settings writer that rebuilds the whole JSON map must not drop the
|
||||||
|
// dedicated permission keys.
|
||||||
|
storage.save_password_hash("hash").await.unwrap();
|
||||||
|
storage.save_app_settings_json(&serde_json::Map::new()).await.unwrap();
|
||||||
|
assert_eq!(storage.load_ai_plugin_tool_plugin_ids().await.unwrap(), ["io.dbx.kafka", "io.dbx.ssh"]);
|
||||||
|
assert_eq!(storage.load_plugin_data_grants("io.dbx.chart").await.unwrap(), ["conn-a", "conn-b"]);
|
||||||
|
|
||||||
|
assert_eq!(storage.set_plugin_data_grant("io.dbx.chart", "conn-b", false).await.unwrap(), ["conn-a"]);
|
||||||
|
assert_eq!(storage.set_ai_plugin_tool_plugin_enabled("io.dbx.kafka", false).await.unwrap(), ["io.dbx.ssh"]);
|
||||||
|
assert!(storage.set_plugin_data_grant("io.dbx.chart", " ", true).await.is_err());
|
||||||
|
|
||||||
|
storage.set_ai_plugin_tool_plugin_enabled("io.dbx.chart", true).await.unwrap();
|
||||||
|
storage.forget_plugin_permissions("io.dbx.chart").await.unwrap();
|
||||||
|
assert_eq!(storage.load_ai_plugin_tool_plugin_ids().await.unwrap(), ["io.dbx.ssh"]);
|
||||||
|
assert!(storage.load_plugin_data_grants("io.dbx.chart").await.unwrap().is_empty());
|
||||||
|
assert_eq!(storage.load_plugin_data_grants("io.dbx.other").await.unwrap(), ["conn-a"]);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn max_retries_survives_stale_app_settings_save() {
|
async fn max_retries_survives_stale_app_settings_save() {
|
||||||
let path = temp_db_path("max-retries-stale-save");
|
let path = temp_db_path("max-retries-stale-save");
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ pub mod document_ops;
|
|||||||
pub mod hbase_ops;
|
pub mod hbase_ops;
|
||||||
pub mod mongo_ops;
|
pub mod mongo_ops;
|
||||||
pub mod object_cache;
|
pub mod object_cache;
|
||||||
|
pub mod plugin_data;
|
||||||
pub mod plugin_plan;
|
pub mod plugin_plan;
|
||||||
pub mod query_cancel;
|
pub mod query_cancel;
|
||||||
pub mod redis_ops;
|
pub mod redis_ops;
|
||||||
|
|||||||
@@ -0,0 +1,473 @@
|
|||||||
|
//! Read-only data access for plugins (`host.data:read`, Host API 1.4).
|
||||||
|
//!
|
||||||
|
//! A plugin UI can ask the host to run one read-only SQL statement on a DBX
|
||||||
|
//! connection. The plugin never receives a driver, a pool, a credential, or a
|
||||||
|
//! connection string, and the host owns every safety decision:
|
||||||
|
//!
|
||||||
|
//! * the plugin must be installed, compatible, and declare `host.data:read`;
|
||||||
|
//! * the user must have granted this plugin access to this connection — the
|
||||||
|
//! grant is persisted per (plugin, connection) and revocable in the Plugin
|
||||||
|
//! Center, and a plugin cannot grant itself anything;
|
||||||
|
//! * the connection must already be open: like the plan and schema metadata
|
||||||
|
//! APIs, DBX never connects on a plugin's behalf;
|
||||||
|
//! * the SQL must be a single statement the shared risk classifier rates
|
||||||
|
//! read-only (the same gate as MCP read-only access and the AI agent), and
|
||||||
|
//! may not switch the session's database, because the statement runs on the
|
||||||
|
//! connection's shared pool;
|
||||||
|
//! * execution uses the host's own connection, timeout ceiling, and row and
|
||||||
|
//! byte caps.
|
||||||
|
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use super::plugin_plan::plugin_plan_timeout_secs;
|
||||||
|
use super::{execute_sql_statement_with_options, QueryExecutionOptions};
|
||||||
|
use crate::connection::AppState;
|
||||||
|
use crate::db::QueryResult;
|
||||||
|
use crate::models::connection::{ConnectionConfig, DatabaseType};
|
||||||
|
use crate::sql_risk::SqlRisk;
|
||||||
|
|
||||||
|
pub const PLUGIN_DATA_READ_PERMISSION: &str = "host.data:read";
|
||||||
|
/// Error code prefix for a missing (or revoked) user grant, so the host UI can
|
||||||
|
/// ask for consent instead of showing a failure.
|
||||||
|
pub const PLUGIN_DATA_ACCESS_NOT_GRANTED: &str = "PLUGIN_DATA_ACCESS_NOT_GRANTED";
|
||||||
|
pub const DEFAULT_PLUGIN_DATA_MAX_ROWS: usize = 500;
|
||||||
|
pub const MAX_PLUGIN_DATA_MAX_ROWS: usize = 5_000;
|
||||||
|
/// Serialized row payload cap; well inside what the plugin bridge carries.
|
||||||
|
pub const MAX_PLUGIN_DATA_RESULT_BYTES: usize = 8 * 1024 * 1024;
|
||||||
|
pub const MAX_PLUGIN_DATA_SQL_CHARS: usize = 100_000;
|
||||||
|
const MAX_PLUGIN_DATA_NAME_CHARS: usize = 256;
|
||||||
|
|
||||||
|
/// One read request. There is deliberately no way to pass a session id, an
|
||||||
|
/// execution mode, or several statements.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginDataQueryRequest {
|
||||||
|
pub connection_id: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub database: Option<String>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub schema: Option<String>,
|
||||||
|
pub sql: String,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_rows: Option<usize>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub timeout_ms: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginDataColumn {
|
||||||
|
pub name: String,
|
||||||
|
/// Database type name when the driver reports one.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub data_type: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginDataQueryResult {
|
||||||
|
pub db_type: String,
|
||||||
|
pub columns: Vec<PluginDataColumn>,
|
||||||
|
pub rows: Vec<Vec<Value>>,
|
||||||
|
/// True when rows were cut by the row cap, the byte cap, or the driver.
|
||||||
|
pub truncated: bool,
|
||||||
|
pub elapsed_ms: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginDataGrant {
|
||||||
|
pub connection_id: String,
|
||||||
|
/// Absent when the granted connection no longer exists.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub connection_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs one read-only statement for `plugin_id`. `plugin_id` must come from
|
||||||
|
/// the host (the plugin bridge binds it), never from plugin-supplied data.
|
||||||
|
pub async fn query_plugin_data(
|
||||||
|
state: &AppState,
|
||||||
|
plugin_id: &str,
|
||||||
|
request: PluginDataQueryRequest,
|
||||||
|
) -> Result<PluginDataQueryResult, String> {
|
||||||
|
let request = validate_request(request)?;
|
||||||
|
require_plugin_permission(state, plugin_id)?;
|
||||||
|
// The grant is checked before the connection is resolved, so an unknown id
|
||||||
|
// and an ungranted one look the same to the plugin.
|
||||||
|
require_grant(state, plugin_id, &request.connection_id).await?;
|
||||||
|
let config = connection_config(state, &request.connection_id).await?;
|
||||||
|
if !state.is_connection_open(&request.connection_id).await {
|
||||||
|
return Err("Connection is not open".to_string());
|
||||||
|
}
|
||||||
|
require_sql_connection(&config)?;
|
||||||
|
require_single_read_only_statement(&request.sql, config.db_type)?;
|
||||||
|
|
||||||
|
let max_rows = request.max_rows.unwrap_or(DEFAULT_PLUGIN_DATA_MAX_ROWS).clamp(1, MAX_PLUGIN_DATA_MAX_ROWS);
|
||||||
|
let started = Instant::now();
|
||||||
|
let result = execute_sql_statement_with_options(
|
||||||
|
state,
|
||||||
|
&request.connection_id,
|
||||||
|
request.database.as_deref().unwrap_or_default(),
|
||||||
|
&request.sql,
|
||||||
|
request.schema.as_deref(),
|
||||||
|
None,
|
||||||
|
QueryExecutionOptions {
|
||||||
|
max_rows: Some(max_rows),
|
||||||
|
max_result_bytes: Some(MAX_PLUGIN_DATA_RESULT_BYTES),
|
||||||
|
timeout_secs: Some(plugin_plan_timeout_secs(request.timeout_ms, &config)),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
let shaped = shape_result(config.db_type, result, max_rows, started.elapsed().as_millis() as u64);
|
||||||
|
// Audit trail without SQL text or values: literals can carry user data.
|
||||||
|
log::info!(
|
||||||
|
"[plugin-data] plugin={plugin_id} connection={} rows={} truncated={}",
|
||||||
|
request.connection_id,
|
||||||
|
shaped.rows.len(),
|
||||||
|
shaped.truncated
|
||||||
|
);
|
||||||
|
Ok(shaped)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The connections the user granted to `plugin_id`, with current names.
|
||||||
|
pub async fn list_plugin_data_grants(state: &AppState, plugin_id: &str) -> Result<Vec<PluginDataGrant>, String> {
|
||||||
|
let connection_ids = state.storage.load_plugin_data_grants(plugin_id).await?;
|
||||||
|
let configs = state.configs.read().await;
|
||||||
|
Ok(connection_ids
|
||||||
|
.into_iter()
|
||||||
|
.map(|connection_id| PluginDataGrant {
|
||||||
|
connection_name: configs.get(&connection_id).map(|config| config.name.clone()),
|
||||||
|
connection_id,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Records the user's answer to a consent prompt, or a revocation. Granting
|
||||||
|
/// requires a plugin that declared the permission and an existing SQL
|
||||||
|
/// connection, so a grant can never name something the API would refuse.
|
||||||
|
pub async fn set_plugin_data_grant(
|
||||||
|
state: &AppState,
|
||||||
|
plugin_id: &str,
|
||||||
|
connection_id: &str,
|
||||||
|
granted: bool,
|
||||||
|
) -> Result<Vec<PluginDataGrant>, String> {
|
||||||
|
if granted {
|
||||||
|
require_plugin_permission(state, plugin_id)?;
|
||||||
|
let config = connection_config(state, connection_id.trim()).await?;
|
||||||
|
require_sql_connection(&config)?;
|
||||||
|
}
|
||||||
|
state.storage.set_plugin_data_grant(plugin_id, connection_id, granted).await?;
|
||||||
|
list_plugin_data_grants(state, plugin_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate_request(mut request: PluginDataQueryRequest) -> Result<PluginDataQueryRequest, String> {
|
||||||
|
request.connection_id = required_name(&request.connection_id, "connectionId")?;
|
||||||
|
let sql = request.sql.trim();
|
||||||
|
if sql.is_empty() {
|
||||||
|
return Err("Plugin data queries need a non-empty sql".to_string());
|
||||||
|
}
|
||||||
|
if sql.chars().count() > MAX_PLUGIN_DATA_SQL_CHARS {
|
||||||
|
return Err(format!("Plugin data query sql exceeds {MAX_PLUGIN_DATA_SQL_CHARS} characters"));
|
||||||
|
}
|
||||||
|
request.sql = sql.to_string();
|
||||||
|
request.database = optional_name(request.database, "database")?;
|
||||||
|
request.schema = optional_name(request.schema, "schema")?;
|
||||||
|
Ok(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn required_name(value: &str, label: &str) -> Result<String, String> {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.is_empty() || trimmed.chars().count() > MAX_PLUGIN_DATA_NAME_CHARS {
|
||||||
|
return Err(format!("Plugin data queries need a valid {label}"));
|
||||||
|
}
|
||||||
|
Ok(trimmed.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn optional_name(value: Option<String>, label: &str) -> Result<Option<String>, String> {
|
||||||
|
match value.as_deref().map(str::trim) {
|
||||||
|
None | Some("") => Ok(None),
|
||||||
|
Some(trimmed) if trimmed.chars().count() > MAX_PLUGIN_DATA_NAME_CHARS => {
|
||||||
|
Err(format!("Plugin data query {label} is too long"))
|
||||||
|
}
|
||||||
|
Some(trimmed) => Ok(Some(trimmed.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_plugin_permission(state: &AppState, plugin_id: &str) -> Result<(), String> {
|
||||||
|
let plugin =
|
||||||
|
state.plugins.find_plugin(plugin_id)?.ok_or_else(|| format!("Plugin '{plugin_id}' is not installed"))?;
|
||||||
|
if !plugin.compatibility.compatible {
|
||||||
|
return Err(format!("Plugin '{plugin_id}' is not compatible with this DBX version"));
|
||||||
|
}
|
||||||
|
if !plugin.manifest.permissions.iter().any(|permission| permission == PLUGIN_DATA_READ_PERMISSION) {
|
||||||
|
return Err(format!("Plugin has not declared permission '{PLUGIN_DATA_READ_PERMISSION}'"));
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn require_grant(state: &AppState, plugin_id: &str, connection_id: &str) -> Result<(), String> {
|
||||||
|
let grants = state.storage.load_plugin_data_grants(plugin_id).await?;
|
||||||
|
if grants.iter().any(|granted| granted == connection_id) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(format!("{PLUGIN_DATA_ACCESS_NOT_GRANTED}: the user has not granted this plugin access to the connection"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connection_config(state: &AppState, connection_id: &str) -> Result<ConnectionConfig, String> {
|
||||||
|
state.configs.read().await.get(connection_id).cloned().ok_or_else(|| "Connection config not found".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_sql_connection(config: &ConnectionConfig) -> Result<(), String> {
|
||||||
|
let sql_capable = crate::query_execution_sql::supports_sql_query(config.db_type)
|
||||||
|
&& !matches!(config.db_type, DatabaseType::Plugin | DatabaseType::MessageQueue | DatabaseType::Mqtt);
|
||||||
|
if sql_capable {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
Err(format!("Plugin data queries are not available for '{}' connections", config.db_type.as_str()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn require_single_read_only_statement(sql: &str, db_type: DatabaseType) -> Result<(), String> {
|
||||||
|
if crate::sql::split_sql_statements_for_database(sql, db_type).len() != 1 {
|
||||||
|
return Err("Plugin data queries accept exactly one SQL statement".to_string());
|
||||||
|
}
|
||||||
|
// Messages stay generic so a plugin cannot use them to probe the gate.
|
||||||
|
match crate::sql_risk::classify_sql_risk_for_database(sql, db_type) {
|
||||||
|
Ok(SqlRisk::ReadOnly) => {}
|
||||||
|
Ok(_) => return Err("Plugin data queries are read-only; the statement was rejected".to_string()),
|
||||||
|
Err(_) => return Err("The statement could not be verified as read-only".to_string()),
|
||||||
|
}
|
||||||
|
if crate::sql_risk::mcp_sql_has_forbidden_database_switch(sql, db_type) {
|
||||||
|
return Err("Plugin data queries cannot switch the session database; pass `database` instead".to_string());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn shape_result(db_type: DatabaseType, result: QueryResult, max_rows: usize, elapsed_ms: u64) -> PluginDataQueryResult {
|
||||||
|
let columns = result
|
||||||
|
.columns
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(index, name)| PluginDataColumn {
|
||||||
|
name: name.clone(),
|
||||||
|
data_type: result.column_types.get(index).filter(|data_type| !data_type.is_empty()).cloned(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut truncated = result.truncated || result.has_more || result.rows.len() > max_rows;
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
let mut bytes = 0usize;
|
||||||
|
for row in result.rows.into_iter().take(max_rows) {
|
||||||
|
let row_bytes = serde_json::to_vec(&row).map_or(0, |encoded| encoded.len());
|
||||||
|
if bytes + row_bytes > MAX_PLUGIN_DATA_RESULT_BYTES {
|
||||||
|
truncated = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
bytes += row_bytes;
|
||||||
|
rows.push(row);
|
||||||
|
}
|
||||||
|
PluginDataQueryResult { db_type: db_type.as_str().to_string(), columns, rows, truncated, elapsed_ms }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn request(sql: &str) -> PluginDataQueryRequest {
|
||||||
|
PluginDataQueryRequest {
|
||||||
|
connection_id: " conn-1 ".to_string(),
|
||||||
|
database: Some(" ".to_string()),
|
||||||
|
schema: None,
|
||||||
|
sql: sql.to_string(),
|
||||||
|
max_rows: None,
|
||||||
|
timeout_ms: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn requests_are_trimmed_and_bounded() {
|
||||||
|
let validated = validate_request(request(" SELECT 1 ")).unwrap();
|
||||||
|
assert_eq!(validated.connection_id, "conn-1");
|
||||||
|
assert_eq!(validated.database, None);
|
||||||
|
assert_eq!(validated.sql, "SELECT 1");
|
||||||
|
assert!(validate_request(request(" ")).is_err());
|
||||||
|
assert!(
|
||||||
|
validate_request(PluginDataQueryRequest { connection_id: " ".to_string(), ..request("SELECT 1") }).is_err()
|
||||||
|
);
|
||||||
|
assert!(validate_request(request(&"x".repeat(MAX_PLUGIN_DATA_SQL_CHARS + 1))).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn only_a_single_read_only_statement_passes() {
|
||||||
|
assert!(require_single_read_only_statement("SELECT * FROM users WHERE id = 1", DatabaseType::Postgres).is_ok());
|
||||||
|
assert!(require_single_read_only_statement("WITH t AS (SELECT 1) SELECT * FROM t", DatabaseType::Mysql).is_ok());
|
||||||
|
for rejected in [
|
||||||
|
"DELETE FROM users",
|
||||||
|
"UPDATE users SET name = 'x' WHERE id = 1",
|
||||||
|
"DROP TABLE users",
|
||||||
|
"SELECT * FROM users FOR UPDATE",
|
||||||
|
"SELECT 1; SELECT 2",
|
||||||
|
"SELECT 1; DELETE FROM users",
|
||||||
|
] {
|
||||||
|
assert!(require_single_read_only_statement(rejected, DatabaseType::Postgres).is_err(), "{rejected}");
|
||||||
|
}
|
||||||
|
assert!(require_single_read_only_statement("USE other_db", DatabaseType::Mysql).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_sql_connections_are_rejected() {
|
||||||
|
let config = |db_type: &str| -> ConnectionConfig {
|
||||||
|
serde_json::from_value(serde_json::json!({
|
||||||
|
"id": "c", "name": "c", "db_type": db_type, "host": "h", "port": 1, "username": "", "password": ""
|
||||||
|
}))
|
||||||
|
.unwrap()
|
||||||
|
};
|
||||||
|
assert!(require_sql_connection(&config("postgres")).is_ok());
|
||||||
|
for db_type in ["redis", "mongodb", "plugin"] {
|
||||||
|
assert!(require_sql_connection(&config(db_type)).is_err(), "{db_type}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn results_respect_the_row_cap_and_keep_column_types() {
|
||||||
|
let result: QueryResult = serde_json::from_value(serde_json::json!({
|
||||||
|
"columns": ["id", "name"],
|
||||||
|
"column_types": ["int4", ""],
|
||||||
|
"rows": (0..5).map(|index| serde_json::json!([index, "n"])).collect::<Vec<_>>(),
|
||||||
|
"affected_rows": 0,
|
||||||
|
"execution_time_ms": 0
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
let shaped = shape_result(DatabaseType::Postgres, result, 3, 7);
|
||||||
|
assert_eq!(shaped.rows.len(), 3);
|
||||||
|
assert!(shaped.truncated);
|
||||||
|
assert_eq!(shaped.columns[0].data_type.as_deref(), Some("int4"));
|
||||||
|
assert_eq!(shaped.columns[1].data_type, None);
|
||||||
|
assert_eq!(shaped.db_type, "postgres");
|
||||||
|
assert_eq!(shaped.elapsed_ms, 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A UI-only plugin package declaring `permissions`, in the `.dbxp` layout
|
||||||
|
/// the installer verifies (every file covered by `checksums.json`).
|
||||||
|
fn ui_plugin_package(id: &str, permissions: &[&str]) -> Vec<u8> {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use std::io::Write;
|
||||||
|
let manifest = serde_json::to_vec_pretty(&serde_json::json!({
|
||||||
|
"manifest_version": 1,
|
||||||
|
"id": id,
|
||||||
|
"name": "Data sample",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"publisher": "sample",
|
||||||
|
"engines": { "dbx": ">=0.5.0", "host_api": "^1.4" },
|
||||||
|
"permissions": permissions,
|
||||||
|
"entrypoints": { "ui": { "root": "ui", "entry": "ui/index.html" } },
|
||||||
|
"contributions": [{ "type": "workbench", "id": format!("{id}.main"), "label": "Data" }]
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
let files = [("manifest.json", manifest), ("ui/index.html", b"<!doctype html>".to_vec())];
|
||||||
|
let checksums = serde_json::to_vec_pretty(&serde_json::json!({
|
||||||
|
"algorithm": "sha256",
|
||||||
|
"files": files.iter().map(|(path, bytes)| (path.to_string(), format!("{:x}", Sha256::digest(bytes)))).collect::<std::collections::BTreeMap<_, _>>()
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
let mut output = std::io::Cursor::new(Vec::new());
|
||||||
|
{
|
||||||
|
let mut zip = zip::ZipWriter::new(&mut output);
|
||||||
|
let options = zip::write::SimpleFileOptions::default().unix_permissions(0o644);
|
||||||
|
for (path, bytes) in files
|
||||||
|
.iter()
|
||||||
|
.map(|(path, bytes)| (*path, bytes.as_slice()))
|
||||||
|
.chain([("checksums.json", checksums.as_slice())])
|
||||||
|
{
|
||||||
|
zip.start_file(path, options).unwrap();
|
||||||
|
zip.write_all(bytes).unwrap();
|
||||||
|
}
|
||||||
|
zip.finish().unwrap();
|
||||||
|
}
|
||||||
|
output.into_inner()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn granted_plugins_read_an_open_sqlite_connection_and_nothing_else() {
|
||||||
|
use crate::plugins::{PluginInstallPolicy, PluginPackageInstaller};
|
||||||
|
const PLUGIN: &str = "sample.data";
|
||||||
|
let root = tempfile::tempdir().unwrap();
|
||||||
|
let plugin_dir = root.path().join("plugins");
|
||||||
|
let installer = PluginPackageInstaller::new(plugin_dir.clone(), "0.6.0").unwrap();
|
||||||
|
installer
|
||||||
|
.install_bytes(&ui_plugin_package(PLUGIN, &["host.data:read"]), PluginInstallPolicy::LocalDevelopment)
|
||||||
|
.unwrap();
|
||||||
|
installer
|
||||||
|
.install_bytes(&ui_plugin_package("sample.nodata", &[]), PluginInstallPolicy::LocalDevelopment)
|
||||||
|
.unwrap();
|
||||||
|
let storage = crate::storage::Storage::open(&root.path().join("dbx.db")).await.unwrap();
|
||||||
|
let state = AppState::new_with_plugin_dir_and_app_version(storage, plugin_dir, "0.6.0");
|
||||||
|
|
||||||
|
let path = root.path().join("orders.db");
|
||||||
|
crate::db::sqlite::connect_path_create_if_missing(path.to_str().unwrap()).await.unwrap();
|
||||||
|
let config: ConnectionConfig = serde_json::from_value(serde_json::json!({
|
||||||
|
"id": "orders", "name": "Local orders", "db_type": "sqlite", "host": path.to_str().unwrap(),
|
||||||
|
"port": 0, "username": "", "password": "", "database": null,
|
||||||
|
"one_time": false, "save_password": false, "read_only": false
|
||||||
|
}))
|
||||||
|
.unwrap();
|
||||||
|
state.configs.write().await.insert("orders".to_string(), config);
|
||||||
|
for sql in [
|
||||||
|
"CREATE TABLE orders(id INTEGER PRIMARY KEY, status TEXT)",
|
||||||
|
"INSERT INTO orders(status) VALUES ('paid'), ('paid'), ('refunded')",
|
||||||
|
] {
|
||||||
|
execute_sql_statement_with_options(&state, "orders", "main", sql, None, None, Default::default())
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
}
|
||||||
|
let request = |sql: &str| PluginDataQueryRequest {
|
||||||
|
connection_id: "orders".to_string(),
|
||||||
|
database: Some("main".to_string()),
|
||||||
|
schema: None,
|
||||||
|
sql: sql.to_string(),
|
||||||
|
max_rows: None,
|
||||||
|
timeout_ms: None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let refused = query_plugin_data(&state, PLUGIN, request("SELECT status FROM orders")).await.unwrap_err();
|
||||||
|
assert!(refused.starts_with(PLUGIN_DATA_ACCESS_NOT_GRANTED), "{refused}");
|
||||||
|
assert!(set_plugin_data_grant(&state, "sample.nodata", "orders", true).await.is_err());
|
||||||
|
assert!(set_plugin_data_grant(&state, PLUGIN, "missing-connection", true).await.is_err());
|
||||||
|
|
||||||
|
let grants = set_plugin_data_grant(&state, PLUGIN, "orders", true).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
grants,
|
||||||
|
[PluginDataGrant {
|
||||||
|
connection_id: "orders".to_string(),
|
||||||
|
connection_name: Some("Local orders".to_string())
|
||||||
|
}]
|
||||||
|
);
|
||||||
|
let rows = query_plugin_data(
|
||||||
|
&state,
|
||||||
|
PLUGIN,
|
||||||
|
PluginDataQueryRequest { max_rows: Some(2), ..request("SELECT status FROM orders ORDER BY id") },
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(rows.columns[0].name, "status");
|
||||||
|
assert_eq!(rows.rows, [[serde_json::json!("paid")], [serde_json::json!("paid")]]);
|
||||||
|
assert!(rows.truncated);
|
||||||
|
|
||||||
|
// The gate holds on a granted connection: nothing but one read runs.
|
||||||
|
for rejected in ["DELETE FROM orders", "SELECT 1; DELETE FROM orders", "DROP TABLE orders"] {
|
||||||
|
assert!(query_plugin_data(&state, PLUGIN, request(rejected)).await.is_err(), "{rejected}");
|
||||||
|
}
|
||||||
|
let count = query_plugin_data(&state, PLUGIN, request("SELECT count(*) FROM orders")).await.unwrap();
|
||||||
|
assert_eq!(count.rows[0][0], serde_json::json!(3));
|
||||||
|
|
||||||
|
set_plugin_data_grant(&state, PLUGIN, "orders", false).await.unwrap();
|
||||||
|
let revoked = query_plugin_data(&state, PLUGIN, request("SELECT 1")).await.unwrap_err();
|
||||||
|
assert!(revoked.starts_with(PLUGIN_DATA_ACCESS_NOT_GRANTED), "{revoked}");
|
||||||
|
|
||||||
|
set_plugin_data_grant(&state, PLUGIN, "orders", true).await.unwrap();
|
||||||
|
state.remove_connection_pools("orders").await;
|
||||||
|
assert_eq!(query_plugin_data(&state, PLUGIN, request("SELECT 1")).await.unwrap_err(), "Connection is not open");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,15 @@ impl PluginConnectionHandle {
|
|||||||
self.session.as_ref().is_none_or(|session| session.status().state == PluginSessionState::Running)
|
self.session.as_ref().is_none_or(|session| session.status().state == PluginSessionState::Running)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The lifecycle payload this connection was opened with: provider,
|
||||||
|
/// hydrated connection, and the runtime endpoint *after* DBX transport
|
||||||
|
/// layers. Host-initiated calls that act on an open connection (for
|
||||||
|
/// example plugin MCP tools) must reuse it instead of rebuilding a payload
|
||||||
|
/// from the saved config, which would bypass the tunnel.
|
||||||
|
pub fn lifecycle_params(&self) -> &serde_json::Value {
|
||||||
|
&self.params
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn disconnect(&self) -> Result<(), String> {
|
pub async fn disconnect(&self) -> Result<(), String> {
|
||||||
let Some(session) = &self.session else {
|
let Some(session) = &self.session else {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ pub const SUPPORTED_PLUGIN_MANIFEST_VERSION: u32 = 1;
|
|||||||
/// schema metadata (`host.getTableMetadata` behind `host.schema:read`) and the
|
/// schema metadata (`host.getTableMetadata` behind `host.schema:read`) and the
|
||||||
/// plugin-initiated clipboard Host API (`host.clipboardRead` behind the
|
/// plugin-initiated clipboard Host API (`host.clipboardRead` behind the
|
||||||
/// `host.clipboard:read` permission; clipboard writes reuse the existing
|
/// `host.clipboard:read` permission; clipboard writes reuse the existing
|
||||||
/// ungated `host.copy`). All are additive: older plugins keep working, and a
|
/// ungated `host.copy`). 1.4 adds consent-gated read-only data queries
|
||||||
/// plugin that wants a capability must check the advertised version (or the
|
/// (`host.queryData` behind `host.data:read`). All are additive: older plugins
|
||||||
/// matching `capabilities` / `host.features` entry) before calling it.
|
/// keep working, and a plugin that wants a capability must check the
|
||||||
pub const SUPPORTED_PLUGIN_HOST_API_VERSION: &str = "1.3.0";
|
/// advertised version (or the matching `capabilities` / `host.features` entry)
|
||||||
|
/// before calling it.
|
||||||
|
pub const SUPPORTED_PLUGIN_HOST_API_VERSION: &str = "1.4.0";
|
||||||
/// Capabilities the host advertises to a plugin backend at `plugin/initialize`.
|
/// Capabilities the host advertises to a plugin backend at `plugin/initialize`.
|
||||||
pub const SUPPORTED_PLUGIN_HOST_FEATURES: &[&str] = &["host.requestUserInput"];
|
pub const SUPPORTED_PLUGIN_HOST_FEATURES: &[&str] = &["host.requestUserInput"];
|
||||||
pub const SUPPORTED_PLUGIN_PROTOCOL_VERSION: u32 = 1;
|
pub const SUPPORTED_PLUGIN_PROTOCOL_VERSION: u32 = 1;
|
||||||
@@ -34,6 +36,7 @@ pub const SUPPORTED_PLUGIN_PERMISSIONS: &[&str] = &[
|
|||||||
"host.storage",
|
"host.storage",
|
||||||
"host.ai",
|
"host.ai",
|
||||||
"host.clipboard:read",
|
"host.clipboard:read",
|
||||||
|
"host.data:read",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Cap the number of `host.network:<origin>` entries so a manifest cannot bloat
|
/// Cap the number of `host.network:<origin>` entries so a manifest cannot bloat
|
||||||
@@ -2240,27 +2243,27 @@ mod tests {
|
|||||||
assert_eq!(declared, SUPPORTED_PLUGIN_PERMISSIONS.iter().map(|value| value.to_string()).collect::<Vec<_>>());
|
assert_eq!(declared, SUPPORTED_PLUGIN_PERMISSIONS.iter().map(|value| value.to_string()).collect::<Vec<_>>());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The reason for the 1.3.0 bump: `engines.host_api` is how a plugin states
|
/// `engines.host_api` is how a plugin states "I need the schema metadata
|
||||||
/// "I need the schema metadata API" or "I need clipboard reads", so the
|
/// API" / "I need clipboard reads" (1.3) or "I need data queries" (1.4), so
|
||||||
/// advertised version has to satisfy `^1.3` while a floor this host cannot
|
/// the advertised version has to satisfy each floor while a floor this host
|
||||||
/// meet stays rejected.
|
/// cannot meet stays rejected.
|
||||||
#[test]
|
#[test]
|
||||||
fn host_api_advertises_the_floor_a_schema_metadata_plugin_declares() {
|
fn host_api_advertises_the_floor_a_schema_metadata_plugin_declares() {
|
||||||
let advertised = semver::Version::parse(SUPPORTED_PLUGIN_HOST_API_VERSION)
|
let advertised = semver::Version::parse(SUPPORTED_PLUGIN_HOST_API_VERSION)
|
||||||
.expect("the advertised Host API version must be semver");
|
.expect("the advertised Host API version must be semver");
|
||||||
assert!(
|
assert!(
|
||||||
semver::VersionReq::parse("^1.3").unwrap().matches(&advertised),
|
semver::VersionReq::parse("^1.3").unwrap().matches(&advertised),
|
||||||
"the host must satisfy the schema metadata API floor it asks plugins to declare"
|
"the host must satisfy the schema metadata and clipboard-read floor it asks plugins to declare"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
semver::VersionReq::parse("^1.3").unwrap().matches(&advertised),
|
semver::VersionReq::parse("^1.4").unwrap().matches(&advertised),
|
||||||
"the host must satisfy the clipboard-read floor it asks plugins to declare"
|
"the host must satisfy the data-query floor it asks plugins to declare"
|
||||||
);
|
);
|
||||||
|
|
||||||
for requirement in ["^1.0", "^1.1", "^1.2", "^1.3", ">=1.1.0, <2.0.0"] {
|
for requirement in ["^1.0", "^1.1", "^1.2", "^1.3", "^1.4", ">=1.1.0, <2.0.0"] {
|
||||||
assert!(host_api_requirement_errors(requirement).is_empty(), "{requirement} must be satisfiable");
|
assert!(host_api_requirement_errors(requirement).is_empty(), "{requirement} must be satisfiable");
|
||||||
}
|
}
|
||||||
for requirement in [">=1.4.0", "^2.0"] {
|
for requirement in [">=1.5.0", "^2.0"] {
|
||||||
assert!(!host_api_requirement_errors(requirement).is_empty(), "{requirement} must be rejected");
|
assert!(!host_api_requirement_errors(requirement).is_empty(), "{requirement} must be rejected");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,8 @@ const BLOCKED_EXACT: &[&str] = &[
|
|||||||
"app-settings/config/decrypt",
|
"app-settings/config/decrypt",
|
||||||
// 读取服务器本地 ~/.ssh/config
|
// 读取服务器本地 ~/.ssh/config
|
||||||
"ssh/config-hosts",
|
"ssh/config-hosts",
|
||||||
|
// 插件数据访问授权写入(插件读取已存连接数据的许可)
|
||||||
|
"plugin/data/grant",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// 非 GET 请求整族拦截的前缀:插件/JDBC/驱动安装与运行时控制、云同步外发通道。
|
/// 非 GET 请求整族拦截的前缀:插件/JDBC/驱动安装与运行时控制、云同步外发通道。
|
||||||
@@ -76,6 +78,9 @@ const BLOCKED_AI_EXACT: &[&str] = &[
|
|||||||
"ai/test-connection",
|
"ai/test-connection",
|
||||||
"ai/models",
|
"ai/models",
|
||||||
"ai/model-effort",
|
"ai/model-effort",
|
||||||
|
// 允许内置 AI 调用插件工具的开关;预览会拉起插件 sidecar
|
||||||
|
"ai/plugin-tools/plugins",
|
||||||
|
"ai/plugin-tools/preview",
|
||||||
];
|
];
|
||||||
|
|
||||||
pub(crate) fn is_demo_blocked(method: &Method, suffix: &str) -> bool {
|
pub(crate) fn is_demo_blocked(method: &Method, suffix: &str) -> bool {
|
||||||
|
|||||||
@@ -733,6 +733,9 @@ async fn serve() {
|
|||||||
.route("/query/get-explain-info", post(routes::query::get_explain_info))
|
.route("/query/get-explain-info", post(routes::query::get_explain_info))
|
||||||
.route("/query/plugin-plan-capabilities", post(routes::query::get_plugin_plan_capabilities))
|
.route("/query/plugin-plan-capabilities", post(routes::query::get_plugin_plan_capabilities))
|
||||||
.route("/query/plugin-estimated-plan", post(routes::query::get_plugin_estimated_plan))
|
.route("/query/plugin-estimated-plan", post(routes::query::get_plugin_estimated_plan))
|
||||||
|
.route("/plugin/data/query", post(routes::query::query_plugin_data))
|
||||||
|
.route("/plugin/data/grants", post(routes::query::get_plugin_data_grants))
|
||||||
|
.route("/plugin/data/grant", post(routes::query::set_plugin_data_grant))
|
||||||
.route("/query/build-create-user-sql", post(routes::query::build_create_user_sql))
|
.route("/query/build-create-user-sql", post(routes::query::build_create_user_sql))
|
||||||
.route("/query/build-table-select-sql", post(routes::query::build_table_select_sql))
|
.route("/query/build-table-select-sql", post(routes::query::build_table_select_sql))
|
||||||
.route("/query/build-database-search-sql", post(routes::query::build_database_search_sql))
|
.route("/query/build-database-search-sql", post(routes::query::build_database_search_sql))
|
||||||
@@ -1211,6 +1214,12 @@ async fn serve() {
|
|||||||
.route("/ai/stream", post(routes::ai::ai_stream))
|
.route("/ai/stream", post(routes::ai::ai_stream))
|
||||||
.route("/ai/agent-stream", post(routes::ai::ai_agent_stream))
|
.route("/ai/agent-stream", post(routes::ai::ai_agent_stream))
|
||||||
.route("/ai/cancel-stream", post(routes::ai::ai_cancel_stream))
|
.route("/ai/cancel-stream", post(routes::ai::ai_cancel_stream))
|
||||||
|
.route("/ai/tool-approval", post(routes::ai::ai_resolve_tool_approval))
|
||||||
|
.route(
|
||||||
|
"/ai/plugin-tools/plugins",
|
||||||
|
get(routes::ai::get_ai_plugin_tool_plugins).post(routes::ai::set_ai_plugin_tool_plugin_enabled),
|
||||||
|
)
|
||||||
|
.route("/ai/plugin-tools/preview", post(routes::ai::preview_plugin_ai_tools))
|
||||||
.route("/ai/test-connection", post(routes::ai::ai_test_connection))
|
.route("/ai/test-connection", post(routes::ai::ai_test_connection))
|
||||||
.route("/ai/models", post(routes::ai::ai_list_models))
|
.route("/ai/models", post(routes::ai::ai_list_models))
|
||||||
.route("/ai/model-effort", post(routes::ai::ai_resolve_model_effort))
|
.route("/ai/model-effort", post(routes::ai::ai_resolve_model_effort))
|
||||||
|
|||||||
@@ -344,6 +344,58 @@ pub async fn ai_cancel_stream(Json(body): Json<AiCancelStreamRequest>) -> Result
|
|||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Plugin tools for the built-in agent
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AiResolveToolApprovalRequest {
|
||||||
|
pub session_id: String,
|
||||||
|
pub approval_id: String,
|
||||||
|
pub approved: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct AiPluginToolPluginRequest {
|
||||||
|
pub plugin_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Answers a pending plugin tool approval of the agent run `session_id`.
|
||||||
|
pub async fn ai_resolve_tool_approval(Json(body): Json<AiResolveToolApprovalRequest>) -> Result<Json<bool>, AppError> {
|
||||||
|
Ok(Json(dbx_core::tool_approval::resolve_tool_approval(&body.session_id, &body.approval_id, body.approved)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_ai_plugin_tool_plugins(State(state): State<Arc<WebState>>) -> Result<Json<Vec<String>>, AppError> {
|
||||||
|
Ok(Json(state.app.storage.load_ai_plugin_tool_plugin_ids().await.map_err(AppError::from)?))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_ai_plugin_tool_plugin_enabled(
|
||||||
|
State(state): State<Arc<WebState>>,
|
||||||
|
Json(body): Json<AiPluginToolPluginRequest>,
|
||||||
|
) -> Result<Json<Vec<String>>, AppError> {
|
||||||
|
let plugin_ids = state
|
||||||
|
.app
|
||||||
|
.storage
|
||||||
|
.set_ai_plugin_tool_plugin_enabled(&body.plugin_id, body.enabled)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::bad_request)?;
|
||||||
|
Ok(Json(plugin_ids))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn preview_plugin_ai_tools(
|
||||||
|
State(state): State<Arc<WebState>>,
|
||||||
|
Json(body): Json<AiPluginToolPluginRequest>,
|
||||||
|
) -> Result<Json<dbx_core::plugin_tools::PluginToolPreview>, AppError> {
|
||||||
|
let preview = dbx_core::plugin_tools::preview_plugin_tools(&state.app, None, &body.plugin_id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::bad_request)?;
|
||||||
|
Ok(Json(preview))
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// AI stream (POST returns SSE directly)
|
// AI stream (POST returns SSE directly)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -461,6 +513,10 @@ pub async fn ai_agent_stream(
|
|||||||
sql_permissions,
|
sql_permissions,
|
||||||
max_agent_turns,
|
max_agent_turns,
|
||||||
prompt_cache_key: request.prompt_cache_key.clone(),
|
prompt_cache_key: request.prompt_cache_key.clone(),
|
||||||
|
session_id: Some(session_id.clone()),
|
||||||
|
// The loop below runs on its own current-thread runtime; plugin
|
||||||
|
// sidecar calls must stay on the server runtime that owns the sessions.
|
||||||
|
host_runtime: Some(tokio::runtime::Handle::current()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let sid = session_id.clone();
|
let sid = session_id.clone();
|
||||||
|
|||||||
@@ -359,12 +359,17 @@ pub async fn uninstall_plugin(
|
|||||||
}
|
}
|
||||||
let root_dir = state.app.plugins.root_dir().to_path_buf();
|
let root_dir = state.app.plugins.root_dir().to_path_buf();
|
||||||
let app_version = state.app.plugins.app_version().to_string();
|
let app_version = state.app.plugins.app_version().to_string();
|
||||||
|
let plugin_id = request.plugin_id.clone();
|
||||||
tokio::task::spawn_blocking(move || {
|
tokio::task::spawn_blocking(move || {
|
||||||
PluginPackageInstaller::new(root_dir, app_version)?.uninstall(&request.plugin_id)
|
PluginPackageInstaller::new(root_dir, app_version)?.uninstall(&request.plugin_id)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|error| AppError::internal(error.to_string()))?
|
.map_err(|error| AppError::internal(error.to_string()))?
|
||||||
.map_err(AppError::bad_request)?;
|
.map_err(AppError::bad_request)?;
|
||||||
|
// A reinstall must ask for AI tool access and data grants again.
|
||||||
|
if let Err(error) = state.app.storage.forget_plugin_permissions(&plugin_id).await {
|
||||||
|
log::warn!("Failed to clear permissions of uninstalled plugin {plugin_id}: {error}");
|
||||||
|
}
|
||||||
list_plugins(State(state)).await
|
list_plugins(State(state)).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -886,6 +886,59 @@ pub async fn get_plugin_estimated_plan(
|
|||||||
Ok(Json(result))
|
Ok(Json(result))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct QueryPluginDataRequest {
|
||||||
|
pub plugin_id: String,
|
||||||
|
pub request: dbx_core::query::plugin_data::PluginDataQueryRequest,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct PluginDataGrantRequest {
|
||||||
|
pub plugin_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub connection_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub granted: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin Host API: consent-gated read-only data query (`host.data:read`).
|
||||||
|
pub async fn query_plugin_data(
|
||||||
|
State(state): State<Arc<WebState>>,
|
||||||
|
Json(body): Json<QueryPluginDataRequest>,
|
||||||
|
) -> Result<Json<dbx_core::query::plugin_data::PluginDataQueryResult>, AppError> {
|
||||||
|
let result = dbx_core::query::plugin_data::query_plugin_data(&state.app, &body.plugin_id, body.request)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
Ok(Json(result))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_plugin_data_grants(
|
||||||
|
State(state): State<Arc<WebState>>,
|
||||||
|
Json(body): Json<PluginDataGrantRequest>,
|
||||||
|
) -> Result<Json<Vec<dbx_core::query::plugin_data::PluginDataGrant>>, AppError> {
|
||||||
|
let grants = dbx_core::query::plugin_data::list_plugin_data_grants(&state.app, &body.plugin_id)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::from)?;
|
||||||
|
Ok(Json(grants))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_plugin_data_grant(
|
||||||
|
State(state): State<Arc<WebState>>,
|
||||||
|
Json(body): Json<PluginDataGrantRequest>,
|
||||||
|
) -> Result<Json<Vec<dbx_core::query::plugin_data::PluginDataGrant>>, AppError> {
|
||||||
|
let grants = dbx_core::query::plugin_data::set_plugin_data_grant(
|
||||||
|
&state.app,
|
||||||
|
&body.plugin_id,
|
||||||
|
&body.connection_id,
|
||||||
|
body.granted,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(AppError::bad_request)?;
|
||||||
|
Ok(Json(grants))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn build_create_user_sql(Json(req): Json<BuildCreateUserSqlRequest>) -> Result<Json<String>, AppError> {
|
pub async fn build_create_user_sql(Json(req): Json<BuildCreateUserSqlRequest>) -> Result<Json<String>, AppError> {
|
||||||
Ok(Json(dbx_core::db_admin_sql::build_create_user_sql(&req.username, &req.password, &req.tablespace)))
|
Ok(Json(dbx_core::db_admin_sql::build_create_user_sql(&req.username, &req.password, &req.tablespace)))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -122,6 +122,19 @@ Desktop 可以把只读的 [`SKILL.md`](https://developers.openai.com/codex/skil
|
|||||||
在输入区的 `Skills` 入口(或输入 `/skill`)选择技能,选中项以芯片形式显示在输入框上方,可随时移除。选择的生命周期是一个打开的 AI 面板:切换会话、新建会话都会保留;关闭面板或重启 DBX 即清空。选择不会持久化,DBX 也绝不写入技能文件。
|
在输入区的 `Skills` 入口(或输入 `/skill`)选择技能,选中项以芯片形式显示在输入框上方,可随时移除。选择的生命周期是一个打开的 AI 面板:切换会话、新建会话都会保留;关闭面板或重启 DBX 即清空。选择不会持久化,DBX 也绝不写入技能文件。
|
||||||
|
|
||||||
技能正文只在发送时从磁盘读取,并当场校验:文件被删除或不可读、frontmatter 无效、不是 UTF-8、超过单文件 1 MiB 上限、符号链接逃逸出所在目录,都会阻断发送,并在输入框上方显示横幅,标明失败的技能并提供重试、刷新、移除或打开设置等恢复操作。未选择任何技能时,提示词与既有行为逐字节一致。Web 端不提供该功能。
|
技能正文只在发送时从磁盘读取,并当场校验:文件被删除或不可读、frontmatter 无效、不是 UTF-8、超过单文件 1 MiB 上限、符号链接逃逸出所在目录,都会阻断发送,并在输入框上方显示横幅,标明失败的技能并提供重试、刷新、移除或打开设置等恢复操作。未选择任何技能时,提示词与既有行为逐字节一致。Web 端不提供该功能。
|
||||||
|
|
||||||
|
## 插件工具
|
||||||
|
|
||||||
|
使用 API 供应商的 Agent 模式下,助手还可以调用已安装插件提供的工具——例如通过 SSH 执行命令、读取容器日志、查看 Kafka 消费延迟——并与数据库工具结合起来回答同一个问题。
|
||||||
|
|
||||||
|
1. 打开「插件中心 → 已安装」,选中插件,开启 **内置 AI 工具**。点击 **查看工具** 可以看到助手能用哪些工具,以及哪些需要确认。
|
||||||
|
2. 打开助手要使用的插件连接(例如那台 SSH 服务器)。工具只会作用于已打开的连接。
|
||||||
|
3. 在 Agent 模式下提问。插件声明为只读的工具会直接执行;其他调用会暂停运行,并展示完整参数,由你选择 **允许本次** 或 **拒绝**。五分钟未响应视为拒绝,点击停止会取消等待中的请求。
|
||||||
|
|
||||||
|
绑定插件连接时,输入框显示插件图标,并隐藏数据库和 Schema 选择器。AI 不再加载数据库表结构,Agent 模式仅保留时间工具与已启用的插件工具。关闭插件的内置 AI 工具开关,也会阻止正在进行的会话继续调用该插件。
|
||||||
|
|
||||||
|
工具输出会像查询结果一样发送给你配置的 AI 模型;不希望数据发给模型的插件,请保持该开关关闭。CLI Agent 供应商不会获得插件工具。
|
||||||
|
|
||||||
## 写入和生产安全
|
## 写入和生产安全
|
||||||
|
|
||||||
AI Agent 的执行边界是后端强制的,不只是提示词:
|
AI Agent 的执行边界是后端强制的,不只是提示词:
|
||||||
|
|||||||
@@ -124,6 +124,19 @@ Desktop can attach read-only [`SKILL.md`](https://developers.openai.com/codex/sk
|
|||||||
Select skills from the composer `Skills` entry (or type `/skill`) and remove them from the chips above the input. The selection lives for the lifetime of one open AI panel — conversation switches and new conversations keep it; closing the panel or restarting DBX clears it. Nothing is persisted, and DBX never writes to skill files.
|
Select skills from the composer `Skills` entry (or type `/skill`) and remove them from the chips above the input. The selection lives for the lifetime of one open AI panel — conversation switches and new conversations keep it; closing the panel or restarting DBX clears it. Nothing is persisted, and DBX never writes to skill files.
|
||||||
|
|
||||||
Skill bodies are read only when a request is sent, directly from disk, and validated then: file deleted or unreadable, invalid frontmatter, non-UTF-8 content, over the 1 MiB per-file limit, or a symlink escaping its root blocks the send with a banner above the composer naming the failed skill and offering Retry, Refresh, Remove, or Open Settings. With no skills selected, prompts are byte-for-byte identical to the pre-feature behavior. Web ships this feature unsupported.
|
Skill bodies are read only when a request is sent, directly from disk, and validated then: file deleted or unreadable, invalid frontmatter, non-UTF-8 content, over the 1 MiB per-file limit, or a symlink escaping its root blocks the send with a banner above the composer naming the failed skill and offering Retry, Refresh, Remove, or Open Settings. With no skills selected, prompts are byte-for-byte identical to the pre-feature behavior. Web ships this feature unsupported.
|
||||||
|
|
||||||
|
## Plugin Tools
|
||||||
|
|
||||||
|
In Agent mode with an API provider, the assistant can also call tools contributed by installed plugins — for example run a command over SSH, read container logs, or inspect Kafka consumer lag — and combine them with database tools in one answer.
|
||||||
|
|
||||||
|
1. Open Plugin Center → Installed, select the plugin, and turn on **Built-in AI tools**. **Show tools** lists what the assistant would get and which tools need approval.
|
||||||
|
2. Open the plugin connection the assistant should use (for example the SSH server). Tools only run against connections that are open.
|
||||||
|
3. Ask in Agent mode. Tools the plugin declares read-only run directly; any other call pauses the run and shows the exact arguments with **Allow once** / **Deny**. Unanswered requests are denied after five minutes, and Stop cancels a pending request.
|
||||||
|
|
||||||
|
When bound to a plugin connection, the assistant shows the plugin logo and hides the database/schema selectors. It skips database metadata loading and offers only the time utility and enabled plugin tools in Agent mode.
|
||||||
|
|
||||||
|
Tool output is sent to your AI model like query results. Leave the switch off for plugins whose data should not reach the model. Turning it off also blocks subsequent calls from a run already in progress. CLI agent providers do not receive plugin tools.
|
||||||
|
|
||||||
## Writes and Production Safety
|
## Writes and Production Safety
|
||||||
|
|
||||||
AI Agent execution boundaries are enforced by the backend, not only by prompting:
|
AI Agent execution boundaries are enforced by the backend, not only by prompting:
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ Manifest 中的路径相对于包根目录,不能以 `/` 开头、不能包含
|
|||||||
"host.schema:read",
|
"host.schema:read",
|
||||||
"host.storage",
|
"host.storage",
|
||||||
"host.clipboard:read",
|
"host.clipboard:read",
|
||||||
|
"host.data:read",
|
||||||
"host.network:https://s3.example.com:443"
|
"host.network:https://s3.example.com:443"
|
||||||
],
|
],
|
||||||
"entrypoints": {
|
"entrypoints": {
|
||||||
@@ -140,6 +141,7 @@ Manifest 中的路径相对于包根目录,不能以 `/` 开头、不能包含
|
|||||||
- `host.storage` 为工作台 UI 提供本插件独享的持久化键值存储(见 [持久化 UI 状态](#持久化-ui-状态));数据只落在插件自己的插件数据目录里。
|
- `host.storage` 为工作台 UI 提供本插件独享的持久化键值存储(见 [持久化 UI 状态](#持久化-ui-状态));数据只落在插件自己的插件数据目录里。
|
||||||
- `host.ai` 允许插件在 DBX 内置 AI 面板中新建带数据快照的对话,沿用宿主的模型选择、历史、追问、停止和导出功能。旧版 DBX 会在安装时因未知权限而失败,需要兼容旧宿主的插件应把 `host.ai` 作为可选能力。
|
- `host.ai` 允许插件在 DBX 内置 AI 面板中新建带数据快照的对话,沿用宿主的模型选择、历史、追问、停止和导出功能。旧版 DBX 会在安装时因未知权限而失败,需要兼容旧宿主的插件应把 `host.ai` 作为可选能力。
|
||||||
- `host.clipboard:read` 允许工作台 UI 读取系统剪贴板(见 [系统剪贴板](#系统剪贴板))。写剪贴板不需要权限;读取会把用户数据(密码、令牌)交给插件代码,因此需要门控。
|
- `host.clipboard:read` 允许工作台 UI 读取系统剪贴板(见 [系统剪贴板](#系统剪贴板))。写剪贴板不需要权限;读取会把用户数据(密码、令牌)交给插件代码,因此需要门控。
|
||||||
|
- `host.data:read` 允许 `window.dbxPlugin.queryData` 在用户授权给插件的连接上执行一条只读 SQL(见 [只读数据查询](#只读数据查询))。每个连接都需要用户同意;它不会授予写入、DDL 或重连。
|
||||||
- `host.network:https://host[:port]` 声明浏览器可访问的 HTTPS origin,DBX 将它加入 CSP 的 `connect-src`;最多 8 个,不允许路径、通配符或 Token。仍受目标服务 CORS 规则约束。该权限不是原生 Sidecar 的网络防火墙。
|
- `host.network:https://host[:port]` 声明浏览器可访问的 HTTPS origin,DBX 将它加入 CSP 的 `connect-src`;最多 8 个,不允许路径、通配符或 Token。仍受目标服务 CORS 规则约束。该权限不是原生 Sidecar 的网络防火墙。
|
||||||
- `backend.transport` 默认是 `stdio-jsonl`;需要二进制帧时使用 `stdio-framed`,并同时声明 `host.binary`。
|
- `backend.transport` 默认是 `stdio-jsonl`;需要二进制帧时使用 `stdio-framed`,并同时声明 `host.binary`。
|
||||||
- `backend.executable`、`ui.root` 和 `ui.entry` 必须指向包内文件;带原生后端的包必须为每个目标平台提供对应可执行文件。
|
- `backend.executable`、`ui.root` 和 `ui.entry` 必须指向包内文件;带原生后端的包必须为每个目标平台提供对应可执行文件。
|
||||||
@@ -348,7 +350,8 @@ const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg");
|
|||||||
| `getPlanCapabilities(connectionId)` | 读取宿主与该连接能提供什么计划能力;需要 `host.plans:read`。 |
|
| `getPlanCapabilities(connectionId)` | 读取宿主与该连接能提供什么计划能力;需要 `host.plans:read`。 |
|
||||||
| `explainPlan({ connectionId, database?, schema?, sql, mode, timeoutMs? })` | 返回 `sql` 的估算执行计划;需要 `host.plans:read`。 |
|
| `explainPlan({ connectionId, database?, schema?, sql, mode, timeoutMs? })` | 返回 `sql` 的估算执行计划;需要 `host.plans:read`。 |
|
||||||
| `getTableMetadata({ connectionId, database?, schema?, table })` | 读取已打开连接的窄化 table schema metadata;需要 `host.schema:read`。 |
|
| `getTableMetadata({ connectionId, database?, schema?, table })` | 读取已打开连接的窄化 table schema metadata;需要 `host.schema:read`。 |
|
||||||
| `capabilities` | 来自 init 消息的 `{ downloadFile, planApi, schemaMetadataApi, storage, ai, clipboardWrite, clipboardRead }`。字段缺失或为 `false` 表示当前宿主不支持该组 Host API,应据此关闭功能而不是用真实请求试探。 |
|
| `queryData({ connectionId, database?, schema?, sql, maxRows?, timeoutMs? })` | 在用户授权给插件的连接上执行一条只读 SQL;需要 `host.data:read`。 |
|
||||||
|
| `capabilities` | 来自 init 消息的 `{ downloadFile, planApi, schemaMetadataApi, dataApi, storage, ai, clipboardWrite, clipboardRead }`。字段缺失或为 `false` 表示当前宿主不支持该组 Host API,应据此关闭功能而不是用真实请求试探。 |
|
||||||
| `ai.openConversation({ title, prompt, context, send? })` | 在内置 AI 面板新建插件对话;`send` 默认 `false`,为 `true` 时立即分析;需要 `host.ai`,并以 `capabilities.ai` 广播——旧宿主不带该字段,请按能力位关闭而不是用真实请求试探。 |
|
| `ai.openConversation({ title, prompt, context, send? })` | 在内置 AI 面板新建插件对话;`send` 默认 `false`,为 `true` 时立即分析;需要 `host.ai`,并以 `capabilities.ai` 广播——旧宿主不带该字段,请按能力位关闭而不是用真实请求试探。 |
|
||||||
| `onInit(fn)` / `onEvent(fn)` | 监听初始化、环境变化或后端事件;转发后端事件需要 `host.events`。 |
|
| `onInit(fn)` / `onEvent(fn)` | 监听初始化、环境变化或后端事件;转发后端事件需要 `host.events`。 |
|
||||||
| `storage` | 本插件独享的持久化键值状态;需要 `host.storage`;见下文。 |
|
| `storage` | 本插件独享的持久化键值状态;需要 `host.storage`;见下文。 |
|
||||||
@@ -456,6 +459,49 @@ if (window.dbxPlugin.capabilities.schemaMetadataApi) {
|
|||||||
|
|
||||||
该 API 属于 Host API 1.3。无法离开它工作的插件应声明:`"engines": { "host_api": "^1.3" }`;同时仍应读取 `capabilities.schemaMetadataApi`,因为旧宿主不会提供该字段。没有 `host.schema:read` 时,宿主会在请求到达 backend 前拒绝调用。
|
该 API 属于 Host API 1.3。无法离开它工作的插件应声明:`"engines": { "host_api": "^1.3" }`;同时仍应读取 `capabilities.schemaMetadataApi`,因为旧宿主不会提供该字段。没有 `host.schema:read` 时,宿主会在请求到达 backend 前拒绝调用。
|
||||||
|
|
||||||
|
### 只读数据查询
|
||||||
|
|
||||||
|
插件可以在用户授权给它的 DBX 连接上执行一条只读 SQL,而不需要驱动、连接池、凭据或连接串。声明权限,并按能力位决定是否调用:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"engines": { "host_api": "^1.4" },
|
||||||
|
"permissions": ["host.data:read"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (window.dbxPlugin.capabilities.dataApi) {
|
||||||
|
const result = await window.dbxPlugin.queryData({
|
||||||
|
connectionId,
|
||||||
|
database,
|
||||||
|
sql: "SELECT status, count(*) AS total FROM orders GROUP BY status",
|
||||||
|
maxRows: 200
|
||||||
|
});
|
||||||
|
renderChart(result.columns, result.rows);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`queryData({ connectionId, database?, schema?, sql, maxRows?, timeoutMs? })` 返回:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dbType": "postgres",
|
||||||
|
"columns": [{ "name": "status", "dataType": "text" }, { "name": "total", "dataType": "int8" }],
|
||||||
|
"rows": [["paid", 1204], ["refunded", 37]],
|
||||||
|
"truncated": false,
|
||||||
|
"elapsedMs": 12
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **按连接征求同意。** 插件第一次查询某个连接时,宿主会弹窗写明插件名和连接名。用户允许后授权会持久保存,并显示在「插件中心 → 已安装」该插件下,用户可随时撤销;拒绝会在本次工作台会话内被记住。插件无法自行授权。
|
||||||
|
- **只读且单条语句。** 宿主只接受一条被 DBX SQL 风险分类器判定为只读的语句——与 MCP 只读模式和 AI Agent 使用同一道闸门。写入、DDL、`SELECT … FOR UPDATE`、多条语句以及 `USE` 这类切换会话数据库的语句都会被拒绝;请改用 `database` 参数。
|
||||||
|
- **只用已打开的连接。** 与执行计划和表结构元数据 API 一样,DBX 不会替插件发起连接:已保存但未打开的连接会以 `Connection is not open` 拒绝。Redis、MongoDB、搜索引擎等非 SQL 连接不提供该能力。
|
||||||
|
- **有上限。** `maxRows` 默认 500、最多 5000;序列化后的行数据上限 8 MiB;`timeoutMs` 会被限制在连接自身超时和 60 秒上限之内。只要有行被截断,`truncated` 就为 `true`。
|
||||||
|
- **不含凭据。** 响应只包含列和行。授权被撤销后,调用会返回以 `PLUGIN_DATA_ACCESS_NOT_GRANTED` 开头的错误,下一次查询会重新征求用户同意。
|
||||||
|
|
||||||
|
该 API 属于 Host API 1.4。请同时保留 `engines.host_api` 下限和 `capabilities.dataApi` 运行时检查;无法弹出确认框的 Web 宿主会拒绝请求,而不会静默授权。
|
||||||
|
|
||||||
### 系统剪贴板
|
### 系统剪贴板
|
||||||
|
|
||||||
插件沙箱是 opaque origin 且没有剪贴板权限,`navigator.clipboard` 在里面不可用。宿主代为桥接系统剪贴板:
|
插件沙箱是 opaque origin 且没有剪贴板权限,`navigator.clipboard` 在里面不可用。宿主代为桥接系统剪贴板:
|
||||||
@@ -647,6 +693,23 @@ Sidecar 约束:stdout 只能输出协议消息,日志写 stderr;请求 ID
|
|||||||
|
|
||||||
上例可替换 Rust 模板的 `backend/src/main.rs`,同时让 Cargo 包版本与 Manifest 一致。Go 使用模板生成的 `dbxpluginsdk.NewServer(metadata, handler).Serve()`;当前 Go SDK 支持 JSONL,Rust SDK 还支持 framed transport。不要仅修改 Manifest transport 就认为 Go 已具备二进制帧处理能力。完整接口见 [Rust SDK](https://github.com/t8y2/dbx/tree/main/plugins/sdk/rust/dbx-plugin-sdk) 和 [Go SDK](https://github.com/t8y2/dbx/tree/main/plugins/sdk/go/dbx-plugin-sdk)。
|
上例可替换 Rust 模板的 `backend/src/main.rs`,同时让 Cargo 包版本与 Manifest 一致。Go 使用模板生成的 `dbxpluginsdk.NewServer(metadata, handler).Serve()`;当前 Go SDK 支持 JSONL,Rust SDK 还支持 framed transport。不要仅修改 Manifest transport 就认为 Go 已具备二进制帧处理能力。完整接口见 [Rust SDK](https://github.com/t8y2/dbx/tree/main/plugins/sdk/rust/dbx-plugin-sdk) 和 [Go SDK](https://github.com/t8y2/dbx/tree/main/plugins/sdk/go/dbx-plugin-sdk)。
|
||||||
|
|
||||||
|
## 为 DBX AI 助手提供工具
|
||||||
|
|
||||||
|
带原生后端的插件可以暴露工具,供 DBX 内置 AI 助手在 Agent 模式下调用。这样「订单接口为什么变慢了?」这类问题就能把数据库工具和插件提供的指标、日志或 Shell 能力结合起来。Sidecar 需要实现两个方法:
|
||||||
|
|
||||||
|
- `mcp/tools` 以 MCP 工具格式返回 `{ "tools": [{ "name", "description", "inputSchema", "annotations"? }] }`。DBX 会传入正在绑定的 `{ "connectionId" }`,插件可以据此在只读连接上隐藏写工具。
|
||||||
|
- `mcp/call` 接收 `{ "tool", "arguments", "lifecycle" }`,返回 MCP `CallToolResult`(`{ "content": [{ "type": "text", "text": "..." }], "isError": false }`)。`lifecycle` 与已打开连接的 `connection/connect` 载荷相同——凭据由宿主解析,运行端点已经过 SSH 或代理层——因此工具参数里永远不需要密钥。
|
||||||
|
|
||||||
|
宿主在此之上负责:
|
||||||
|
|
||||||
|
- **需要用户开启。** 用户在「插件中心 → 已安装 → 内置 AI 工具」中开启前,不会暴露任何工具;同一面板会列出工具并标出哪些需要确认。
|
||||||
|
- **只用已打开的连接。** 只为用户当前已打开的插件连接提供工具。连接由 DBX 绑定:模型看到的 schema 中会去掉 `connectionId` / `connectionName`;如果插件 schema 声明了 `connectionId`,宿主会在转发参数中注入绑定连接的 id。打开了多个连接时,模型通过额外的 `dbx_connection` 参数选择其一。
|
||||||
|
- **默认需要确认。** 只有声明了 `"annotations": { "readOnlyHint": true }` 的工具才会直接执行。其他调用都会暂停运行,并向用户展示将要转发的完整参数,由用户选择允许一次或拒绝;五分钟未响应视为拒绝。请为真正只读的工具加上标注,同时保留插件自身的防护(只读连接标志、两阶段确认等)——宿主确认拦截的是模型的误操作,不能替代插件自己的安全规则。
|
||||||
|
- **可移植的 schema。** 工具以 `<前缀>__<工具名>` 暴露(例如 `ssh__ssh_exec`),schema 会被收敛到所有支持的模型供应商都接受的子集:`type`、`description`、`properties`、`required`、`items`、字符串 `enum` 以及数值、长度和元素数量边界。参数名请只使用字母、数字和下划线。
|
||||||
|
- **结果有上限。** 单次调用 120 秒超时,结果在交给模型前会被压缩。长任务请作为后台任务启动并返回句柄,不要阻塞。
|
||||||
|
|
||||||
|
内置 Agent 会把工具输出当作不可信数据处理。结果里请返回事实而不是指令,也不要回显任何密钥。
|
||||||
|
|
||||||
## 创建插件
|
## 创建插件
|
||||||
|
|
||||||
直接安装预编译 CLI,不需要 Rust,也不需要克隆 DBX 源码:
|
直接安装预编译 CLI,不需要 Rust,也不需要克隆 DBX 源码:
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ Manifest paths are relative to the package root. They cannot start with `/`, con
|
|||||||
"host.schema:read",
|
"host.schema:read",
|
||||||
"host.storage",
|
"host.storage",
|
||||||
"host.clipboard:read",
|
"host.clipboard:read",
|
||||||
|
"host.data:read",
|
||||||
"host.network:https://s3.example.com:443"
|
"host.network:https://s3.example.com:443"
|
||||||
],
|
],
|
||||||
"entrypoints": {
|
"entrypoints": {
|
||||||
@@ -140,6 +141,7 @@ Manifest paths are relative to the package root. They cannot start with `/`, con
|
|||||||
- `host.storage` gives the workbench UI a persistent key-value store scoped to this plugin (see [Persistent UI State](#persistent-ui-state)). Values never leave the plugin's own data directory.
|
- `host.storage` gives the workbench UI a persistent key-value store scoped to this plugin (see [Persistent UI State](#persistent-ui-state)). Values never leave the plugin's own data directory.
|
||||||
- `host.ai` lets the plugin open a conversation with a data snapshot in the built-in DBX AI panel, using its model selector, history, follow-up messages, cancellation and export. Older DBX builds reject the unknown permission at install time, so plugins that must install everywhere should treat `host.ai` as optional.
|
- `host.ai` lets the plugin open a conversation with a data snapshot in the built-in DBX AI panel, using its model selector, history, follow-up messages, cancellation and export. Older DBX builds reject the unknown permission at install time, so plugins that must install everywhere should treat `host.ai` as optional.
|
||||||
- `host.clipboard:read` allows reading the system clipboard from the workbench UI (see [System Clipboard](#system-clipboard)). Clipboard writes need no permission; reads hand user data (passwords, tokens) to plugin code, so they are gated.
|
- `host.clipboard:read` allows reading the system clipboard from the workbench UI (see [System Clipboard](#system-clipboard)). Clipboard writes need no permission; reads hand user data (passwords, tokens) to plugin code, so they are gated.
|
||||||
|
- `host.data:read` allows `window.dbxPlugin.queryData` to run one read-only SQL statement on a connection the user granted to the plugin (see [Read-only Data Queries](#read-only-data-queries)). Every connection needs the user's consent; it never grants writes, DDL, or reconnects.
|
||||||
- `host.network:https://host[:port]` declares a browser-accessible HTTPS origin added to CSP `connect-src`; at most eight origins are allowed, with no paths, wildcards, or tokens. The service's CORS rules still apply. This permission is not a native Sidecar network firewall.
|
- `host.network:https://host[:port]` declares a browser-accessible HTTPS origin added to CSP `connect-src`; at most eight origins are allowed, with no paths, wildcards, or tokens. The service's CORS rules still apply. This permission is not a native Sidecar network firewall.
|
||||||
- `backend.transport` defaults to `stdio-jsonl`. Use `stdio-framed` for binary frames and declare `host.binary` as well.
|
- `backend.transport` defaults to `stdio-jsonl`. Use `stdio-framed` for binary frames and declare `host.binary` as well.
|
||||||
- `backend.executable`, `ui.root`, and `ui.entry` must point to package files. Native packages need a matching executable for every target.
|
- `backend.executable`, `ui.root`, and `ui.entry` must point to package files. Native packages need a matching executable for every target.
|
||||||
@@ -348,7 +350,8 @@ const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg");
|
|||||||
| `getPlanCapabilities(connectionId)` | Reads what the host and this connection can plan; requires `host.plans:read`. |
|
| `getPlanCapabilities(connectionId)` | Reads what the host and this connection can plan; requires `host.plans:read`. |
|
||||||
| `explainPlan({ connectionId, database?, schema?, sql, mode, timeoutMs? })` | Returns the estimated plan for `sql`; requires `host.plans:read`. |
|
| `explainPlan({ connectionId, database?, schema?, sql, mode, timeoutMs? })` | Returns the estimated plan for `sql`; requires `host.plans:read`. |
|
||||||
| `getTableMetadata({ connectionId, database?, schema?, table })` | Reads narrow table schema metadata from an already-open connection; requires `host.schema:read`. |
|
| `getTableMetadata({ connectionId, database?, schema?, table })` | Reads narrow table schema metadata from an already-open connection; requires `host.schema:read`. |
|
||||||
| `capabilities` | `{ downloadFile, planApi, schemaMetadataApi, storage, ai, clipboardWrite, clipboardRead }` from the init message. A missing or `false` entry means that Host API group is unavailable here, so gate the matching call on it instead of probing with a request. |
|
| `queryData({ connectionId, database?, schema?, sql, maxRows?, timeoutMs? })` | Runs one read-only SQL statement on a connection the user granted to the plugin; requires `host.data:read`. |
|
||||||
|
| `capabilities` | `{ downloadFile, planApi, schemaMetadataApi, dataApi, storage, ai, clipboardWrite, clipboardRead }` from the init message. A missing or `false` entry means that Host API group is unavailable here, so gate the matching call on it instead of probing with a request. |
|
||||||
| `ai.openConversation({ title, prompt, context, send? })` | Opens a new plugin conversation in the built-in AI panel; `send` defaults to `false`, or starts analysis when `true`; requires `host.ai` and is advertised as `capabilities.ai` — gate on it instead of probing, since older hosts omit it. |
|
| `ai.openConversation({ title, prompt, context, send? })` | Opens a new plugin conversation in the built-in AI panel; `send` defaults to `false`, or starts analysis when `true`; requires `host.ai` and is advertised as `capabilities.ai` — gate on it instead of probing, since older hosts omit it. |
|
||||||
| `onInit(fn)` / `onEvent(fn)` | Observes initialization, environment changes, or backend events; forwarding backend events requires `host.events`. |
|
| `onInit(fn)` / `onEvent(fn)` | Observes initialization, environment changes, or backend events; forwarding backend events requires `host.events`. |
|
||||||
| `storage` | Persistent per-plugin key-value state; requires `host.storage`; see below. |
|
| `storage` | Persistent per-plugin key-value state; requires `host.storage`; see below. |
|
||||||
@@ -456,6 +459,49 @@ if (window.dbxPlugin.capabilities.schemaMetadataApi) {
|
|||||||
|
|
||||||
This API is part of Host API 1.3. A plugin that cannot work without it should declare `"engines": { "host_api": "^1.3" }` and still read `capabilities.schemaMetadataApi`, because older hosts omit that field. Without `host.schema:read`, the host rejects the request before it reaches the backend.
|
This API is part of Host API 1.3. A plugin that cannot work without it should declare `"engines": { "host_api": "^1.3" }` and still read `capabilities.schemaMetadataApi`, because older hosts omit that field. Without `host.schema:read`, the host rejects the request before it reaches the backend.
|
||||||
|
|
||||||
|
### Read-only Data Queries
|
||||||
|
|
||||||
|
A plugin can run one read-only SQL statement on a DBX connection the user granted to it, without a driver, a pool, a credential, or a connection string. Declare the permission and gate the call on the capability:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"engines": { "host_api": "^1.4" },
|
||||||
|
"permissions": ["host.data:read"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (window.dbxPlugin.capabilities.dataApi) {
|
||||||
|
const result = await window.dbxPlugin.queryData({
|
||||||
|
connectionId,
|
||||||
|
database,
|
||||||
|
sql: "SELECT status, count(*) AS total FROM orders GROUP BY status",
|
||||||
|
maxRows: 200
|
||||||
|
});
|
||||||
|
renderChart(result.columns, result.rows);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`queryData({ connectionId, database?, schema?, sql, maxRows?, timeoutMs? })` returns:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"dbType": "postgres",
|
||||||
|
"columns": [{ "name": "status", "dataType": "text" }, { "name": "total", "dataType": "int8" }],
|
||||||
|
"rows": [["paid", 1204], ["refunded", 37]],
|
||||||
|
"truncated": false,
|
||||||
|
"elapsedMs": 12
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Consent per connection.** The first query for a connection opens a host dialog that names the plugin and the connection. An allow is persisted and listed under the plugin in Plugin Center → Installed, where the user can revoke it at any time; a denial is remembered for the workbench session. A plugin cannot grant itself access.
|
||||||
|
- **Read-only, one statement.** The host accepts exactly one statement that DBX's SQL risk classifier rates read-only — the same gate as MCP read-only access and the AI agent. Writes, DDL, `SELECT … FOR UPDATE`, multiple statements, and session database switches such as `USE` are rejected. Pass `database` instead of switching.
|
||||||
|
- **Open connections only.** Like the plan and schema metadata APIs, DBX never connects on the plugin's behalf: a saved but closed connection is rejected with `Connection is not open`. Redis, MongoDB, search engines, and other non-SQL connections are not served.
|
||||||
|
- **Bounded.** `maxRows` defaults to 500 and is capped at 5000; the serialized rows are capped at 8 MiB; `timeoutMs` is clamped to the connection timeout and a 60-second ceiling. `truncated` is `true` whenever rows were cut.
|
||||||
|
- **No credentials.** The response carries columns and rows only. A revoked grant fails with an error that starts with `PLUGIN_DATA_ACCESS_NOT_GRANTED`; the next query asks the user again.
|
||||||
|
|
||||||
|
This API is Host API 1.4. Keep both the `engines.host_api` floor and the `capabilities.dataApi` runtime check; web hosts that cannot show a consent dialog deny the request instead of granting it.
|
||||||
|
|
||||||
### System Clipboard
|
### System Clipboard
|
||||||
|
|
||||||
The plugin sandbox has an opaque origin and no clipboard permission, so `navigator.clipboard` is unavailable there. The host bridges the system clipboard instead:
|
The plugin sandbox has an opaque origin and no clipboard permission, so `navigator.clipboard` is unavailable there. The host bridges the system clipboard instead:
|
||||||
@@ -647,6 +693,23 @@ Every sidecar starts with `DBX_PLUGIN_DATA_DIR` set to a persistent directory re
|
|||||||
|
|
||||||
The example can replace the Rust template's `backend/src/main.rs`; keep its Cargo package version equal to the Manifest. Go templates use `dbxpluginsdk.NewServer(metadata, handler).Serve()`. The current Go SDK supports JSONL; the Rust SDK also supports framed transport. Changing a Go Manifest's transport alone does not implement binary framing. See the [Rust SDK](https://github.com/t8y2/dbx/tree/main/plugins/sdk/rust/dbx-plugin-sdk) and [Go SDK](https://github.com/t8y2/dbx/tree/main/plugins/sdk/go/dbx-plugin-sdk) for complete interfaces.
|
The example can replace the Rust template's `backend/src/main.rs`; keep its Cargo package version equal to the Manifest. Go templates use `dbxpluginsdk.NewServer(metadata, handler).Serve()`. The current Go SDK supports JSONL; the Rust SDK also supports framed transport. Changing a Go Manifest's transport alone does not implement binary framing. See the [Rust SDK](https://github.com/t8y2/dbx/tree/main/plugins/sdk/rust/dbx-plugin-sdk) and [Go SDK](https://github.com/t8y2/dbx/tree/main/plugins/sdk/go/dbx-plugin-sdk) for complete interfaces.
|
||||||
|
|
||||||
|
## Tools for the DBX AI Assistant
|
||||||
|
|
||||||
|
A plugin with a native backend can expose tools that the built-in DBX AI assistant calls in Agent mode, so a question like "why is the orders API slow?" can combine database tools with your plugin's metrics, logs, or shell access. The sidecar implements two methods:
|
||||||
|
|
||||||
|
- `mcp/tools` returns `{ "tools": [{ "name", "description", "inputSchema", "annotations"? }] }` in MCP tool format. DBX passes `{ "connectionId" }` for the connection it is binding, so a plugin may hide write tools on read-only connections.
|
||||||
|
- `mcp/call` receives `{ "tool", "arguments", "lifecycle" }` and returns an MCP `CallToolResult` (`{ "content": [{ "type": "text", "text": "..." }], "isError": false }`). `lifecycle` is the same payload as `connection/connect` for the open connection — credentials resolved by the host and the runtime endpoint after SSH or proxy layers — so tools never take secrets as arguments.
|
||||||
|
|
||||||
|
What the host adds on top:
|
||||||
|
|
||||||
|
- **Opt-in.** Nothing is exposed until the user enables the plugin under Plugin Center → Installed → Built-in AI tools; the same panel lists the tools and marks which ones need approval.
|
||||||
|
- **Open connections only.** Tools are offered for the plugin connections the user currently has open. DBX binds the connection itself: it strips `connectionId` / `connectionName` from the schema the model sees and injects the bound `connectionId` into the forwarded arguments when your schema declares it. With several open connections the model picks one through an added `dbx_connection` argument.
|
||||||
|
- **Approval by default.** Only tools whose entry sets `"annotations": { "readOnlyHint": true }` run without asking. Every other call pauses the run and shows the exact arguments to the user, who allows it once or denies it; an unanswered request is denied after five minutes. Mark genuinely read-only tools, and keep your own guards (read-only connection flags, two-phase confirmations) — the approval covers model mistakes, not your plugin's own safety rules.
|
||||||
|
- **Portable schemas.** Tool names are exposed as `<prefix>__<tool>` (for example `ssh__ssh_exec`), and schemas are reduced to the subset every supported model provider accepts: `type`, `description`, `properties`, `required`, `items`, string `enum`, and numeric/length/item bounds. Keep argument names to letters, digits, and underscores.
|
||||||
|
- **Bounded results.** Calls time out after 120 seconds and results are compacted before they reach the model. Start long jobs as background tasks and return a handle instead of blocking.
|
||||||
|
|
||||||
|
The built-in agent treats tool output as untrusted data. Return facts, not instructions, and never echo secrets in results.
|
||||||
|
|
||||||
## Create a Plugin
|
## Create a Plugin
|
||||||
|
|
||||||
Install the precompiled CLI without Rust or a DBX source checkout:
|
Install the precompiled CLI without Rust or a DBX source checkout:
|
||||||
|
|||||||
+55
-3
@@ -372,9 +372,10 @@ A workbench opens in a normal persistent DBX tab. The iframe is loaded with `san
|
|||||||
- `openFilesystem(providerId, context)` — requires `host.filesystem`
|
- `openFilesystem(providerId, context)` — requires `host.filesystem`
|
||||||
- `getPlanCapabilities(connectionId)` / `explainPlan(request)` — reads an estimated execution plan for one connection; requires `host.plans:read`, see [Estimated execution plans](#estimated-execution-plans)
|
- `getPlanCapabilities(connectionId)` / `explainPlan(request)` — reads an estimated execution plan for one connection; requires `host.plans:read`, see [Estimated execution plans](#estimated-execution-plans)
|
||||||
- `getTableMetadata({ connectionId, database?, schema?, table })` — reads narrow schema metadata for one table on an already-open connection; requires `host.schema:read`, see [Table Schema Metadata](#table-schema-metadata)
|
- `getTableMetadata({ connectionId, database?, schema?, table })` — reads narrow schema metadata for one table on an already-open connection; requires `host.schema:read`, see [Table Schema Metadata](#table-schema-metadata)
|
||||||
|
- `queryData({ connectionId, database?, schema?, sql, maxRows?, timeoutMs? })` — runs one read-only SQL statement on a connection the user granted to the plugin; requires `host.data:read`, see [Read-only data queries](#read-only-data-queries)
|
||||||
- `storage` — `storage.get(key)` / `storage.set(key, value)` / `storage.delete(key)` persist small JSON state per plugin in `plugin-data/<id>`; requires `host.storage`; values cap at 256 KiB and the whole store at 1 MiB, bulk data belongs in the sidecar's `DBX_PLUGIN_DATA_DIR`
|
- `storage` — `storage.get(key)` / `storage.set(key, value)` / `storage.delete(key)` persist small JSON state per plugin in `plugin-data/<id>`; requires `host.storage`; values cap at 256 KiB and the whole store at 1 MiB, bulk data belongs in the sidecar's `DBX_PLUGIN_DATA_DIR`
|
||||||
- `ai.openConversation({ title, prompt, context, send? })` — opens a plugin conversation in the built-in DBX AI panel from a snapshot the plugin supplies; requires `host.ai`; `title` caps at 200 characters, `prompt` at 32000, and `context` at 2 MiB, and `send` defaults to `false` so pass `true` to start the analysis immediately. The host copies the snapshot into the conversation as history data and never hands model output or model configuration back to the plugin
|
- `ai.openConversation({ title, prompt, context, send? })` — opens a plugin conversation in the built-in DBX AI panel from a snapshot the plugin supplies; requires `host.ai`; `title` caps at 200 characters, `prompt` at 32000, and `context` at 2 MiB, and `send` defaults to `false` so pass `true` to start the analysis immediately. The host copies the snapshot into the conversation as history data and never hands model output or model configuration back to the plugin
|
||||||
- `capabilities` — `{ downloadFile, planApi, schemaMetadataApi, storage, ai }` advertised in the init message; a missing or `false` entry means that Host API group is unavailable on this host, so gate the matching call on it instead of probing with a request
|
- `capabilities` — `{ downloadFile, planApi, schemaMetadataApi, dataApi, storage, ai }` advertised in the init message; a missing or `false` entry means that Host API group is unavailable on this host, so gate the matching call on it instead of probing with a request
|
||||||
- `onEvent(listener)` — events are forwarded only with `host.events`
|
- `onEvent(listener)` — events are forwarded only with `host.events`
|
||||||
- `onBinary(listener)` — binary frames are forwarded only with `host.binary`; listeners receive `{ channel, data: Uint8Array }`
|
- `onBinary(listener)` — binary frames are forwarded only with `host.binary`; listeners receive `{ channel, data: Uint8Array }`
|
||||||
|
|
||||||
@@ -429,7 +430,7 @@ A result view contributes a plugin-rendered visualization for query results. DBX
|
|||||||
|
|
||||||
A result view declares display metadata only: it carries no UI of its own and never names a workbench. The opened contribution id reaches the plugin UI in the init payload (`dbx-plugin-init` detail `contributionId`), so a plugin that declares several result views selects the matching one inside its single UI entrypoint.
|
A result view declares display metadata only: it carries no UI of its own and never names a workbench. The opened contribution id reaches the plugin UI in the init payload (`dbx-plugin-init` detail `contributionId`), so a plugin that declares several result views selects the matching one inside its single UI entrypoint.
|
||||||
|
|
||||||
The `context.result` snapshot is bounded — `{ columns, rows (<= 500), truncated }` plus `sql`, `connectionId`, and `database`. Plugins that need the full or streamed result set should re-execute through their backend using the SQL and connection reference. Requires a UI entrypoint.
|
The `context.result` snapshot is bounded — `{ columns, rows (<= 500), truncated }` plus `sql`, `connectionId`, and `database`. Plugins that need more rows can re-run a read-only statement with [`queryData`](#read-only-data-queries) (requires `host.data:read` and the user's consent for that connection). Requires a UI entrypoint.
|
||||||
|
|
||||||
### `context-menu`
|
### `context-menu`
|
||||||
|
|
||||||
@@ -580,6 +581,37 @@ This is Host API 1.3. A plugin that requires it declares:
|
|||||||
|
|
||||||
The `schemaMetadataApi` capability is runtime detection for older hosts; a plugin should gate the call on it rather than probing the request. Without `host.schema:read`, the bridge rejects the call before the backend adapter runs.
|
The `schemaMetadataApi` capability is runtime detection for older hosts; a plugin should gate the call on it rather than probing the request. Without `host.schema:read`, the bridge rejects the call before the backend adapter runs.
|
||||||
|
|
||||||
|
### Read-only data queries
|
||||||
|
|
||||||
|
A plugin can run one read-only SQL statement on a DBX connection the user granted to it. It never receives a driver, pool, credential, or connection string:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"engines": { "host_api": "^1.4" },
|
||||||
|
"permissions": ["host.data:read"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
if (window.dbxPlugin.capabilities.dataApi) {
|
||||||
|
const { columns, rows, truncated } = await window.dbxPlugin.queryData({
|
||||||
|
connectionId,
|
||||||
|
database,
|
||||||
|
sql: "SELECT status, count(*) AS total FROM orders GROUP BY status",
|
||||||
|
maxRows: 200
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The result is `{ dbType, columns: [{ name, dataType? }], rows, truncated, elapsedMs }`. The host owns every decision:
|
||||||
|
|
||||||
|
- **Consent per (plugin, connection).** The first query for a connection shows a host dialog naming both. An allow is persisted in `app_settings.plugin_data_grants` and listed under the plugin in Plugin Center → Installed, where it can be revoked; a denial is remembered for the workbench session. Uninstalling a plugin drops its grants. A revoked grant fails with `PLUGIN_DATA_ACCESS_NOT_GRANTED: …` and the next query asks again.
|
||||||
|
- **One read-only statement.** The statement must be the only one in the request and must be rated read-only by the shared SQL risk classifier used for MCP read-only access and the AI agent. Writes, DDL, locking reads, and session database switches (`USE …`) are rejected.
|
||||||
|
- **Open SQL connections only.** A saved-but-closed connection is rejected with `Connection is not open`; non-SQL connections are not served.
|
||||||
|
- **Bounds.** `maxRows` defaults to 500 (max 5000), serialized rows are capped at 8 MiB, and `timeoutMs` is clamped to the connection timeout and 60 s.
|
||||||
|
|
||||||
|
The backend re-checks the manifest permission and the grant on every call (`crates/dbx-core/src/query/plugin_data.rs`); the bridge only adds the consent prompt and a per-session cache. Hosts without a consent surface deny instead of granting.
|
||||||
|
|
||||||
### Estimated execution plans
|
### Estimated execution plans
|
||||||
|
|
||||||
A plugin can read the **estimated** execution plan of a query without touching a database driver, a credential, or a connection string. The host builds the `EXPLAIN` statement with its own `build_explain_sql`, applies the same read-only safety gate DBX uses for its own plan view, and executes it on the connection the plugin names. The plugin receives only the raw plan.
|
A plugin can read the **estimated** execution plan of a query without touching a database driver, a credential, or a connection string. The host builds the `EXPLAIN` statement with its own `build_explain_sql`, applies the same read-only safety gate DBX uses for its own plan view, and executes it on the connection the plugin names. The plugin receives only the raw plan.
|
||||||
@@ -690,6 +722,25 @@ Plugins normally answer requests, but Host API 1.1 adds one method a plugin back
|
|||||||
- Capability gating: `plugin/initialize` advertises `host.hostApiVersion` (`1.1.0` or later) and `host.features` (containing `host.requestUserInput` when available). Only call the method when it is advertised; an older host reports `1.0.0` and drops the frame.
|
- Capability gating: `plugin/initialize` advertises `host.hostApiVersion` (`1.1.0` or later) and `host.features` (containing `host.requestUserInput` when available). Only call the method when it is advertised; an older host reports `1.0.0` and drops the frame.
|
||||||
- The Rust SDK (`dbx-plugin-sdk`) wraps this: `dbx_plugin_sdk::host_client()`, `HostClient::supports("host/requestUserInput")`, and `HostClient::request_user_input(&UserInputPrompt::secret("Verification code"))`.
|
- The Rust SDK (`dbx-plugin-sdk`) wraps this: `dbx_plugin_sdk::host_client()`, `HostClient::supports("host/requestUserInput")`, and `HostClient::request_user_input(&UserInputPrompt::secret("Verification code"))`.
|
||||||
|
|
||||||
|
## Tools for the built-in AI assistant
|
||||||
|
|
||||||
|
A backend can expose tools to the built-in DBX AI agent through the MCP-shaped sidecar methods `mcp/tools` and `mcp/call` — the same methods DBX's MCP bridge uses:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"jsonrpc":"2.0","id":7,"method":"mcp/tools","params":{"connectionId":"<open connection id>"}}
|
||||||
|
{"jsonrpc":"2.0","id":8,"method":"mcp/call","params":{"tool":"orders_lag","arguments":{"group":"billing"},"lifecycle":{"provider":{},"connection":{},"runtime":{}}}}
|
||||||
|
```
|
||||||
|
|
||||||
|
`mcp/tools` returns `{ "tools": [{ "name", "description", "inputSchema", "annotations"? }] }`; `mcp/call` returns an MCP `CallToolResult` (`{ "content": [{ "type": "text", "text": "…" }], "isError": false }`). `lifecycle` is the open connection's `connection/connect` payload, with secrets resolved by the host and the runtime endpoint after transport layers.
|
||||||
|
|
||||||
|
Host rules (`crates/dbx-core/src/ai/plugin_tools.rs`):
|
||||||
|
|
||||||
|
- Tools are offered only for plugins the user enabled in Plugin Center → Installed → Built-in AI tools, only in Agent mode with API model providers, and only for plugin connections that are currently open.
|
||||||
|
- The host binds the connection: `connectionId` / `connectionName` are removed from the model-facing schema, and the bound `connectionId` is injected into the forwarded arguments when the plugin schema declares it. With several open connections the model chooses through an added `dbx_connection` argument.
|
||||||
|
- A tool runs without asking only when its entry sets `annotations.readOnlyHint: true`. Every other call pauses the run behind an inline approval that shows the exact forwarded arguments; unanswered approvals are denied after five minutes (`crates/dbx-core/src/ai/tool_approval.rs`). The hint is trusted because the plugin's native backend is already trusted code; the approval protects against model mistakes and prompt injection, not against a hostile plugin.
|
||||||
|
- Names are exposed as `<prefix>__<tool>` (`io.dbx.ssh` → `ssh__…`). Schemas are reduced to a provider-portable subset (`type`, `description`, `properties`, `required`, `items`, string `enum`, numeric/length/item bounds); argument names must match `[A-Za-z_][A-Za-z0-9_]{0,63}`.
|
||||||
|
- Discovery times out after 8 s per connection, calls after 120 s, and results are compacted before reaching the model.
|
||||||
|
|
||||||
## Backend protocol
|
## Backend protocol
|
||||||
|
|
||||||
The backend is a persistent child process with stdin/stdout reserved for the DBX protocol. Diagnostics must go to stderr.
|
The backend is a persistent child process with stdin/stdout reserved for the DBX protocol. Diagnostics must go to stderr.
|
||||||
@@ -772,7 +823,8 @@ Sidecars are shared per plugin process, not spawned per tab. Plugins own their i
|
|||||||
- **UI isolation:** sandboxed iframe, restrictive CSP, bounded bridge payloads, safe asset paths, plugin identity binding.
|
- **UI isolation:** sandboxed iframe, restrictive CSP, bounded bridge payloads, safe asset paths, plugin identity binding.
|
||||||
- **Secret persistence:** plugin secrets are removed from connection JSON and stored through DBX's secret-store path. Ordinary cloud-sync snapshots always contain redacted placeholders. Secrets enter sync data only inside the encrypted payload when the user has configured a sync passphrase; without one, plugin secrets remain local and are not synchronized.
|
- **Secret persistence:** plugin secrets are removed from connection JSON and stored through DBX's secret-store path. Ordinary cloud-sync snapshots always contain redacted placeholders. Secrets enter sync data only inside the encrypted payload when the user has configured a sync passphrase; without one, plugin secrets remain local and are not synchronized.
|
||||||
- **Native backend trust:** a native sidecar runs with the current OS user's privileges. A signature identifies the repository that approved and published the package; it is not an OS sandbox or proof that the author is harmless. Install only plugins whose backend code you trust.
|
- **Native backend trust:** a native sidecar runs with the current OS user's privileges. A signature identifies the repository that approved and published the package; it is not an OS sandbox or proof that the author is harmless. Install only plugins whose backend code you trust.
|
||||||
- **Permission declarations:** privileged host bridge operations require declared permissions. Plugin UI network egress is fully blocked except for explicitly declared `host.network:` origins. Native process filesystem/network access cannot currently be completely mediated by DBX. `host.plans:read` grants reading host-generated estimated execution plans only; it never grants SQL execution, writes, DDL, or actual plans. `host.schema:read` grants only narrow metadata for a table on an already-open host connection; it never grants arbitrary SQL, writes, or reconnects. `host.storage` confines the workbench UI to a small JSON store inside its own `plugin-data/<id>` directory; it grants no other filesystem reach. `host.ai` lets a workbench open a built-in AI conversation seeded with a snapshot the plugin supplies; the plugin gets no model output, no model configuration, and no SQL execution out of it.
|
- **Permission declarations:** privileged host bridge operations require declared permissions. Plugin UI network egress is fully blocked except for explicitly declared `host.network:` origins. Native process filesystem/network access cannot currently be completely mediated by DBX. `host.plans:read` grants reading host-generated estimated execution plans only; it never grants SQL execution, writes, DDL, or actual plans. `host.schema:read` grants only narrow metadata for a table on an already-open host connection; it never grants arbitrary SQL, writes, or reconnects. `host.storage` confines the workbench UI to a small JSON store inside its own `plugin-data/<id>` directory; it grants no other filesystem reach. `host.ai` lets a workbench open a built-in AI conversation seeded with a snapshot the plugin supplies; the plugin gets no model output, no model configuration, and no SQL execution out of it. `host.data:read` grants single read-only statements only on connections the user consented to, per plugin and connection, revocable in Plugin Center; it never grants writes, DDL, locking reads, database switches, or reconnects.
|
||||||
|
- **AI tool exposure:** plugin MCP tools reach the built-in AI agent only after the user enables the plugin for it, only on open connections, and — unless declared read-only — only after a per-call approval.
|
||||||
|
|
||||||
Custom repository public keys can be added or removed in Plugin Center. Obtain them through a channel independent from the downloaded package.
|
Custom repository public keys can be added or removed in Plugin Center. Obtain them through a channel independent from the downloaded package.
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ This example exercises the complete manifest v1 path instead of mocking a contri
|
|||||||
- asynchronous connection/progress events;
|
- asynchronous connection/progress events;
|
||||||
- sandboxed workbench UI using `window.dbxPlugin`;
|
- sandboxed workbench UI using `window.dbxPlugin`;
|
||||||
- workbench-to-sidecar RPC plus a read-only filesystem contribution rendered by DBX's host-owned file manager;
|
- workbench-to-sidecar RPC plus a read-only filesystem contribution rendered by DBX's host-owned file manager;
|
||||||
|
- `mcp/tools` / `mcp/call` tools for the built-in DBX AI assistant: `hello_greet` is declared read-only, `hello_set_greeting` changes state and therefore asks the user first;
|
||||||
- unsigned `.dbxp` candidate packaging plus separate repository signing;
|
- unsigned `.dbxp` candidate packaging plus separate repository signing;
|
||||||
- automated install-to-uninstall smoke runner.
|
- automated install-to-uninstall smoke runner.
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ hello-workbench/
|
|||||||
└── smoke.mjs # package + lifecycle smoke test
|
└── smoke.mjs # package + lifecycle smoke test
|
||||||
```
|
```
|
||||||
|
|
||||||
The reusable smoke executable lives at `crates/dbx-core/examples/plugin_package_smoke.rs` so it shares DBX's workspace lockfile and dependency patches.
|
The reusable smoke executables live at `crates/dbx-core/examples/plugin_package_smoke.rs` (package lifecycle) and `crates/dbx-core/examples/plugin_ai_tools_smoke.rs` (AI tool discovery and calls through the host) so they share DBX's workspace lockfile and dependency patches.
|
||||||
|
|
||||||
## Build an unsigned development package
|
## Build an unsigned development package
|
||||||
|
|
||||||
@@ -58,6 +59,7 @@ In DBX, open **Plugin Center**, enable **Allow unsigned development package**, a
|
|||||||
5. Click **Save & Open**.
|
5. Click **Save & Open**.
|
||||||
6. Invoke the sidecar from the workbench.
|
6. Invoke the sidecar from the workbench.
|
||||||
7. Click **Open host files** to browse and preview the virtual `hello:/` filesystem without plugin-owned file-browser UI.
|
7. Click **Open host files** to browse and preview the virtual `hello:/` filesystem without plugin-owned file-browser UI.
|
||||||
|
8. In Plugin Center → Installed, turn on **Built-in AI tools** for the plugin, keep the Hello connection open, and ask the AI assistant in Agent mode to greet someone or change the greeting. The greeting change pauses for your approval.
|
||||||
|
|
||||||
The example token is persisted through `connection_secrets`; the raw connection `config_json` contains only an empty placeholder. The iframe receives the saved connection ID, never the token.
|
The example token is persisted through `connection_secrets`; the raw connection `config_json` contains only an empty placeholder. The iframe receives the saved connection ID, never the token.
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ use serde_json::{json, Value};
|
|||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
struct HelloPlugin {
|
struct HelloPlugin {
|
||||||
connections: Mutex<HashMap<String, String>>,
|
connections: Mutex<HashMap<String, String>>,
|
||||||
|
/// Greetings changed through the `hello_set_greeting` AI tool, per connection.
|
||||||
|
greetings: Mutex<HashMap<String, String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PluginHandler for HelloPlugin {
|
impl PluginHandler for HelloPlugin {
|
||||||
@@ -154,11 +156,89 @@ impl PluginHandler for HelloPlugin {
|
|||||||
"etag": format!("hello-{preview_length}")
|
"etag": format!("hello-{preview_length}")
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
// Tools for the DBX AI assistant. DBX lists them with `mcp/tools`
|
||||||
|
// (passing the connection it binds) and runs them with `mcp/call`,
|
||||||
|
// handing over the open connection's lifecycle payload.
|
||||||
|
"mcp/tools" => Ok(mcp_tools()),
|
||||||
|
"mcp/call" => Ok(self.call_tool(¶ms)),
|
||||||
_ => Err(PluginError::method_not_found(method)),
|
_ => Err(PluginError::method_not_found(method)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn mcp_tools() -> Value {
|
||||||
|
json!({
|
||||||
|
"tools": [
|
||||||
|
{
|
||||||
|
"name": "hello_greet",
|
||||||
|
"description": "Greet someone through the open Hello connection and report which connection answered.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"connectionId": { "type": "string" },
|
||||||
|
"name": { "type": "string", "description": "Who to greet", "default": "DBX" }
|
||||||
|
},
|
||||||
|
"required": ["connectionId"]
|
||||||
|
},
|
||||||
|
// Read-only tools run without asking the user.
|
||||||
|
"annotations": { "readOnlyHint": true }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "hello_set_greeting",
|
||||||
|
"description": "Change the greeting the Hello connection uses from now on.",
|
||||||
|
"inputSchema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"connectionId": { "type": "string" },
|
||||||
|
"greeting": { "type": "string", "description": "New greeting, e.g. Hi" }
|
||||||
|
},
|
||||||
|
"required": ["connectionId", "greeting"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HelloPlugin {
|
||||||
|
/// Tool failures become `isError` results rather than protocol errors, so
|
||||||
|
/// the model can read the problem and adjust.
|
||||||
|
fn call_tool(&self, params: &Value) -> Value {
|
||||||
|
let arguments = params.get("arguments").cloned().unwrap_or_default();
|
||||||
|
let connection = params.pointer("/lifecycle/connection").cloned().unwrap_or_default();
|
||||||
|
let connection_id = connection.get("id").and_then(Value::as_str).unwrap_or_default().to_string();
|
||||||
|
let connection_name = connection.get("name").and_then(Value::as_str).unwrap_or("Hello connection").to_string();
|
||||||
|
let Ok(mut greetings) = self.greetings.lock() else {
|
||||||
|
return tool_result("Greeting registry is poisoned", true);
|
||||||
|
};
|
||||||
|
match params.get("tool").and_then(Value::as_str) {
|
||||||
|
Some("hello_greet") => {
|
||||||
|
let greeting = greetings.get(&connection_id).cloned().unwrap_or_else(|| {
|
||||||
|
connection
|
||||||
|
.pointer("/external_config/greeting")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.unwrap_or("Hello")
|
||||||
|
.to_string()
|
||||||
|
});
|
||||||
|
let name = arguments.get("name").and_then(Value::as_str).unwrap_or("DBX");
|
||||||
|
tool_result(&format!("{greeting}, {name}, from {connection_name}!"), false)
|
||||||
|
}
|
||||||
|
Some("hello_set_greeting") => match arguments.get("greeting").and_then(Value::as_str).map(str::trim) {
|
||||||
|
Some(greeting) if !greeting.is_empty() => {
|
||||||
|
greetings.insert(connection_id, greeting.to_string());
|
||||||
|
tool_result(&format!("{connection_name} now greets with \"{greeting}\"."), false)
|
||||||
|
}
|
||||||
|
_ => tool_result("Pass a non-empty greeting", true),
|
||||||
|
},
|
||||||
|
Some(tool) => tool_result(&format!("Unknown tool: {tool}"), true),
|
||||||
|
None => tool_result("Missing tool name", true),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tool_result(text: &str, is_error: bool) -> Value {
|
||||||
|
json!({ "content": [{ "type": "text", "text": text }], "isError": is_error })
|
||||||
|
}
|
||||||
|
|
||||||
fn require_filesystem_provider(params: &Value) -> Result<(), PluginError> {
|
fn require_filesystem_provider(params: &Value) -> Result<(), PluginError> {
|
||||||
if params.get("providerId").and_then(Value::as_str) == Some("dbx.example.hello.files") {
|
if params.get("providerId").and_then(Value::as_str) == Some("dbx.example.hello.files") {
|
||||||
Ok(())
|
Ok(())
|
||||||
|
|||||||
@@ -6,14 +6,14 @@
|
|||||||
"icon": "assets/plugin.svg",
|
"icon": "assets/plugin.svg",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"publisher": "dbx",
|
"publisher": "dbx",
|
||||||
"description": "A complete DBX plugin example with a native sidecar, events, workbench UI, connection provider, and virtual filesystem provider.",
|
"description": "A complete DBX plugin example with a native sidecar, events, workbench UI, connection provider, virtual filesystem provider, AI tools, and a result view that reads data through host.data:read.",
|
||||||
"source": "https://github.com/t8y2/dbx",
|
"source": "https://github.com/t8y2/dbx",
|
||||||
"homepage": "https://github.com/t8y2/dbx/tree/main/plugins/examples/hello-workbench",
|
"homepage": "https://github.com/t8y2/dbx/tree/main/plugins/examples/hello-workbench",
|
||||||
"engines": {
|
"engines": {
|
||||||
"dbx": ">=0.5.68",
|
"dbx": ">=0.5.68",
|
||||||
"host_api": "^1.0"
|
"host_api": "^1.0"
|
||||||
},
|
},
|
||||||
"permissions": ["host.events", "host.filesystem"],
|
"permissions": ["host.events", "host.filesystem", "host.data:read"],
|
||||||
"entrypoints": {
|
"entrypoints": {
|
||||||
"backend": {
|
"backend": {
|
||||||
"executable": "bin/dbx-example-hello"
|
"executable": "bin/dbx-example-hello"
|
||||||
@@ -98,12 +98,18 @@
|
|||||||
"schemes": ["hello"],
|
"schemes": ["hello"],
|
||||||
"root_uri": "hello:/",
|
"root_uri": "hello:/",
|
||||||
"capabilities": ["read"]
|
"capabilities": ["read"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "result-view",
|
||||||
|
"id": "dbx.example.hello.rows",
|
||||||
|
"label": "Hello rows",
|
||||||
|
"description": "Re-runs the current query through host.data:read to show consent-gated plugin data access."
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"localizations": {
|
"localizations": {
|
||||||
"zh-CN": {
|
"zh-CN": {
|
||||||
"name": "DBX Hello 工作台",
|
"name": "DBX Hello 工作台",
|
||||||
"description": "完整的 DBX 插件示例,包含原生 Sidecar、事件、工作台界面、连接提供者和虚拟文件系统提供者。",
|
"description": "完整的 DBX 插件示例,包含原生 Sidecar、事件、工作台界面、连接提供者、虚拟文件系统提供者、AI 工具,以及通过 host.data:read 读取数据的结果视图。",
|
||||||
"contributions": {
|
"contributions": {
|
||||||
"dbx.example.hello.connection": {
|
"dbx.example.hello.connection": {
|
||||||
"label": "Hello 连接",
|
"label": "Hello 连接",
|
||||||
@@ -120,12 +126,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dbx.example.hello.main": { "label": "Hello 工作台", "description": "托管在普通 DBX 工作台标签页中的沙箱插件界面。" },
|
"dbx.example.hello.main": { "label": "Hello 工作台", "description": "托管在普通 DBX 工作台标签页中的沙箱插件界面。" },
|
||||||
"dbx.example.hello.files": { "label": "Hello 虚拟文件", "description": "最小化的只读文件系统提供者契约示例。" }
|
"dbx.example.hello.files": { "label": "Hello 虚拟文件", "description": "最小化的只读文件系统提供者契约示例。" },
|
||||||
|
"dbx.example.hello.rows": { "label": "Hello 结果行", "description": "通过 host.data:read 重新执行当前查询,演示需用户授权的插件数据访问。" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"zh-TW": {
|
"zh-TW": {
|
||||||
"name": "DBX Hello 工作台",
|
"name": "DBX Hello 工作台",
|
||||||
"description": "完整的 DBX 外掛範例,包含原生 Sidecar、事件、工作台介面、連線提供者和虛擬檔案系統提供者。",
|
"description": "完整的 DBX 外掛範例,包含原生 Sidecar、事件、工作台介面、連線提供者、虛擬檔案系統提供者、AI 工具,以及透過 host.data:read 讀取資料的結果檢視。",
|
||||||
"contributions": {
|
"contributions": {
|
||||||
"dbx.example.hello.connection": {
|
"dbx.example.hello.connection": {
|
||||||
"label": "Hello 連線",
|
"label": "Hello 連線",
|
||||||
@@ -142,12 +149,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dbx.example.hello.main": { "label": "Hello 工作台", "description": "託管在一般 DBX 工作台分頁中的沙箱外掛介面。" },
|
"dbx.example.hello.main": { "label": "Hello 工作台", "description": "託管在一般 DBX 工作台分頁中的沙箱外掛介面。" },
|
||||||
"dbx.example.hello.files": { "label": "Hello 虛擬檔案", "description": "最小化的唯讀檔案系統提供者契約範例。" }
|
"dbx.example.hello.files": { "label": "Hello 虛擬檔案", "description": "最小化的唯讀檔案系統提供者契約範例。" },
|
||||||
|
"dbx.example.hello.rows": { "label": "Hello 結果列", "description": "透過 host.data:read 重新執行目前查詢,示範需使用者授權的外掛資料存取。" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"ja": {
|
"ja": {
|
||||||
"name": "DBX Hello ワークベンチ",
|
"name": "DBX Hello ワークベンチ",
|
||||||
"description": "ネイティブ Sidecar、イベント、ワークベンチ UI、接続プロバイダー、仮想ファイルシステムプロバイダーを含む完全な DBX プラグイン例です。",
|
"description": "ネイティブ Sidecar、イベント、ワークベンチ UI、接続プロバイダー、仮想ファイルシステムプロバイダー、AI ツール、host.data:read でデータを読む結果ビューを含む完全な DBX プラグイン例です。",
|
||||||
"contributions": {
|
"contributions": {
|
||||||
"dbx.example.hello.connection": {
|
"dbx.example.hello.connection": {
|
||||||
"label": "Hello 接続",
|
"label": "Hello 接続",
|
||||||
@@ -164,12 +172,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dbx.example.hello.main": { "label": "Hello ワークベンチ", "description": "通常の DBX ワークベンチタブ内でホストされるサンドボックス化されたプラグイン UI です。" },
|
"dbx.example.hello.main": { "label": "Hello ワークベンチ", "description": "通常の DBX ワークベンチタブ内でホストされるサンドボックス化されたプラグイン UI です。" },
|
||||||
"dbx.example.hello.files": { "label": "Hello 仮想ファイル", "description": "最小限の読み取り専用ファイルシステムプロバイダー契約例です。" }
|
"dbx.example.hello.files": { "label": "Hello 仮想ファイル", "description": "最小限の読み取り専用ファイルシステムプロバイダー契約例です。" },
|
||||||
|
"dbx.example.hello.rows": { "label": "Hello 結果行", "description": "host.data:read で現在のクエリを再実行し、ユーザーの許可が必要なプラグインのデータアクセスを示します。" }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"es": {
|
"es": {
|
||||||
"name": "Área de trabajo Hello de DBX",
|
"name": "Área de trabajo Hello de DBX",
|
||||||
"description": "Ejemplo completo de plugin de DBX con sidecar nativo, eventos, interfaz de trabajo, proveedor de conexión y proveedor de sistema de archivos virtual.",
|
"description": "Ejemplo completo de plugin de DBX con sidecar nativo, eventos, interfaz de trabajo, proveedor de conexión, proveedor de sistema de archivos virtual, herramientas de IA y una vista de resultados que lee datos con host.data:read.",
|
||||||
"contributions": {
|
"contributions": {
|
||||||
"dbx.example.hello.connection": {
|
"dbx.example.hello.connection": {
|
||||||
"label": "Conexión Hello",
|
"label": "Conexión Hello",
|
||||||
@@ -186,12 +195,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dbx.example.hello.main": { "label": "Área de trabajo Hello", "description": "Interfaz de plugin aislada alojada en una pestaña de trabajo normal de DBX." },
|
"dbx.example.hello.main": { "label": "Área de trabajo Hello", "description": "Interfaz de plugin aislada alojada en una pestaña de trabajo normal de DBX." },
|
||||||
"dbx.example.hello.files": { "label": "Archivos virtuales Hello", "description": "Ejemplo mínimo de contrato de proveedor de sistema de archivos de solo lectura." }
|
"dbx.example.hello.files": { "label": "Archivos virtuales Hello", "description": "Ejemplo mínimo de contrato de proveedor de sistema de archivos de solo lectura." },
|
||||||
|
"dbx.example.hello.rows": { "label": "Filas de Hello", "description": "Vuelve a ejecutar la consulta actual con host.data:read para mostrar el acceso a datos del plugin con consentimiento." }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"it": {
|
"it": {
|
||||||
"name": "Area di lavoro Hello di DBX",
|
"name": "Area di lavoro Hello di DBX",
|
||||||
"description": "Esempio completo di plugin DBX con sidecar nativo, eventi, interfaccia di lavoro, provider di connessione e provider di file system virtuale.",
|
"description": "Esempio completo di plugin DBX con sidecar nativo, eventi, interfaccia di lavoro, provider di connessione, provider di file system virtuale, strumenti IA e una vista risultati che legge i dati tramite host.data:read.",
|
||||||
"contributions": {
|
"contributions": {
|
||||||
"dbx.example.hello.connection": {
|
"dbx.example.hello.connection": {
|
||||||
"label": "Connessione Hello",
|
"label": "Connessione Hello",
|
||||||
@@ -208,12 +218,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dbx.example.hello.main": { "label": "Area di lavoro Hello", "description": "Interfaccia plugin in sandbox ospitata in una normale scheda di lavoro DBX." },
|
"dbx.example.hello.main": { "label": "Area di lavoro Hello", "description": "Interfaccia plugin in sandbox ospitata in una normale scheda di lavoro DBX." },
|
||||||
"dbx.example.hello.files": { "label": "File virtuali Hello", "description": "Esempio minimo di contratto provider di file system in sola lettura." }
|
"dbx.example.hello.files": { "label": "File virtuali Hello", "description": "Esempio minimo di contratto provider di file system in sola lettura." },
|
||||||
|
"dbx.example.hello.rows": { "label": "Righe Hello", "description": "Riesegue la query corrente tramite host.data:read per mostrare l'accesso ai dati del plugin soggetto a consenso." }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"pt-BR": {
|
"pt-BR": {
|
||||||
"name": "Área de trabalho Hello do DBX",
|
"name": "Área de trabalho Hello do DBX",
|
||||||
"description": "Exemplo completo de plugin do DBX com sidecar nativo, eventos, interface de trabalho, provedor de conexão e provedor de sistema de arquivos virtual.",
|
"description": "Exemplo completo de plugin do DBX com sidecar nativo, eventos, interface de trabalho, provedor de conexão, provedor de sistema de arquivos virtual, ferramentas de IA e uma visualização de resultados que lê dados via host.data:read.",
|
||||||
"contributions": {
|
"contributions": {
|
||||||
"dbx.example.hello.connection": {
|
"dbx.example.hello.connection": {
|
||||||
"label": "Conexão Hello",
|
"label": "Conexão Hello",
|
||||||
@@ -230,7 +241,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"dbx.example.hello.main": { "label": "Área de trabalho Hello", "description": "Interface de plugin em sandbox hospedada em uma aba normal de trabalho do DBX." },
|
"dbx.example.hello.main": { "label": "Área de trabalho Hello", "description": "Interface de plugin em sandbox hospedada em uma aba normal de trabalho do DBX." },
|
||||||
"dbx.example.hello.files": { "label": "Arquivos virtuais Hello", "description": "Exemplo mínimo de contrato de provedor de sistema de arquivos somente leitura." }
|
"dbx.example.hello.files": { "label": "Arquivos virtuais Hello", "description": "Exemplo mínimo de contrato de provedor de sistema de arquivos somente leitura." },
|
||||||
|
"dbx.example.hello.rows": { "label": "Linhas Hello", "description": "Executa novamente a consulta atual via host.data:read para mostrar o acesso a dados do plugin com consentimento." }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ try {
|
|||||||
run("cargo", ["run", "--locked", "-p", "dbx-core", "--no-default-features", "--example", "plugin_package_smoke", "--", packagePath], {
|
run("cargo", ["run", "--locked", "-p", "dbx-core", "--no-default-features", "--example", "plugin_package_smoke", "--", packagePath], {
|
||||||
CARGO_TARGET_DIR: cargoTarget,
|
CARGO_TARGET_DIR: cargoTarget,
|
||||||
});
|
});
|
||||||
|
// The same package through the built-in AI agent's plugin tool path. It
|
||||||
|
// opens a DBX storage, so it needs a SQLite that exposes the extension API
|
||||||
|
// (macOS system SQLite does not); `sqlite-bundled` is the test build's choice.
|
||||||
|
run("cargo", ["run", "--locked", "-p", "dbx-core", "--no-default-features", "--features", "sqlite-bundled", "--example", "plugin_ai_tools_smoke", "--", packagePath], {
|
||||||
|
CARGO_TARGET_DIR: cargoTarget,
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
if (ownedBuildRoot) await rm(ownedBuildRoot, { recursive: true, force: true });
|
if (ownedBuildRoot) await rm(ownedBuildRoot, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,6 +128,7 @@
|
|||||||
<input id="name" value="DBX" aria-label="Name" />
|
<input id="name" value="DBX" aria-label="Name" />
|
||||||
<button id="greet">Invoke sidecar</button>
|
<button id="greet">Invoke sidecar</button>
|
||||||
<button id="files" class="secondary">Open host files</button>
|
<button id="files" class="secondary">Open host files</button>
|
||||||
|
<button id="rows" hidden>Re-run with queryData</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="result" class="result">Waiting for the plugin host…</div>
|
<div id="result" class="result">Waiting for the plugin host…</div>
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
@@ -157,6 +158,10 @@
|
|||||||
openSaved: "Open this workbench from a saved Hello connection.",
|
openSaved: "Open this workbench from a saved Hello connection.",
|
||||||
calling: "Calling native sidecar…",
|
calling: "Calling native sidecar…",
|
||||||
error: "Error",
|
error: "Error",
|
||||||
|
rows: "Re-run with queryData",
|
||||||
|
resultView: "Opened from a query result. Re-run it through host.data:read; DBX asks for your consent once per connection.",
|
||||||
|
noDataApi: "This DBX host does not offer host.data:read.",
|
||||||
|
querying: "Running a read-only query through the host…",
|
||||||
},
|
},
|
||||||
"zh-CN": {
|
"zh-CN": {
|
||||||
eyebrow: "受沙箱保护的 DBX 插件",
|
eyebrow: "受沙箱保护的 DBX 插件",
|
||||||
@@ -175,6 +180,10 @@
|
|||||||
openSaved: "请从已保存的 Hello 连接打开此工作台。",
|
openSaved: "请从已保存的 Hello 连接打开此工作台。",
|
||||||
calling: "正在调用原生 Sidecar…",
|
calling: "正在调用原生 Sidecar…",
|
||||||
error: "错误",
|
error: "错误",
|
||||||
|
rows: "用 queryData 重新查询",
|
||||||
|
resultView: "已从查询结果打开。可通过 host.data:read 重新执行;每个连接 DBX 只会征求一次你的同意。",
|
||||||
|
noDataApi: "当前 DBX 宿主不支持 host.data:read。",
|
||||||
|
querying: "正在通过宿主执行只读查询…",
|
||||||
},
|
},
|
||||||
"zh-TW": {
|
"zh-TW": {
|
||||||
eyebrow: "受沙箱保護的 DBX 外掛",
|
eyebrow: "受沙箱保護的 DBX 外掛",
|
||||||
@@ -193,6 +202,10 @@
|
|||||||
openSaved: "請從已儲存的 Hello 連線開啟此工作台。",
|
openSaved: "請從已儲存的 Hello 連線開啟此工作台。",
|
||||||
calling: "正在呼叫原生 Sidecar…",
|
calling: "正在呼叫原生 Sidecar…",
|
||||||
error: "錯誤",
|
error: "錯誤",
|
||||||
|
rows: "用 queryData 重新查詢",
|
||||||
|
resultView: "已從查詢結果開啟。可透過 host.data:read 重新執行;每個連線 DBX 只會徵求一次你的同意。",
|
||||||
|
noDataApi: "目前的 DBX 主程式不支援 host.data:read。",
|
||||||
|
querying: "正在透過主程式執行唯讀查詢…",
|
||||||
},
|
},
|
||||||
ja: {
|
ja: {
|
||||||
eyebrow: "サンドボックス化された DBX プラグイン",
|
eyebrow: "サンドボックス化された DBX プラグイン",
|
||||||
@@ -211,6 +224,10 @@
|
|||||||
openSaved: "保存済みの Hello 接続からこのワークベンチを開いてください。",
|
openSaved: "保存済みの Hello 接続からこのワークベンチを開いてください。",
|
||||||
calling: "ネイティブ Sidecar を呼び出し中…",
|
calling: "ネイティブ Sidecar を呼び出し中…",
|
||||||
error: "エラー",
|
error: "エラー",
|
||||||
|
rows: "queryData で再実行",
|
||||||
|
resultView: "クエリ結果から開かれました。host.data:read で再実行できます。DBX は接続ごとに一度だけ許可を求めます。",
|
||||||
|
noDataApi: "この DBX ホストは host.data:read を提供していません。",
|
||||||
|
querying: "ホスト経由で読み取り専用クエリを実行中…",
|
||||||
},
|
},
|
||||||
es: {
|
es: {
|
||||||
eyebrow: "Plugin de DBX aislado",
|
eyebrow: "Plugin de DBX aislado",
|
||||||
@@ -229,6 +246,10 @@
|
|||||||
openSaved: "Abre esta área de trabajo desde una conexión Hello guardada.",
|
openSaved: "Abre esta área de trabajo desde una conexión Hello guardada.",
|
||||||
calling: "Invocando el sidecar nativo…",
|
calling: "Invocando el sidecar nativo…",
|
||||||
error: "Error",
|
error: "Error",
|
||||||
|
rows: "Volver a ejecutar con queryData",
|
||||||
|
resultView: "Abierto desde un resultado de consulta. Vuelve a ejecutarlo con host.data:read; DBX pide tu consentimiento una vez por conexión.",
|
||||||
|
noDataApi: "Este host de DBX no ofrece host.data:read.",
|
||||||
|
querying: "Ejecutando una consulta de solo lectura a través del host…",
|
||||||
},
|
},
|
||||||
it: {
|
it: {
|
||||||
eyebrow: "Plugin DBX in sandbox",
|
eyebrow: "Plugin DBX in sandbox",
|
||||||
@@ -247,6 +268,10 @@
|
|||||||
openSaved: "Apri quest'area di lavoro da una connessione Hello salvata.",
|
openSaved: "Apri quest'area di lavoro da una connessione Hello salvata.",
|
||||||
calling: "Chiamata del sidecar nativo…",
|
calling: "Chiamata del sidecar nativo…",
|
||||||
error: "Errore",
|
error: "Errore",
|
||||||
|
rows: "Riesegui con queryData",
|
||||||
|
resultView: "Aperto da un risultato di query. Rieseguilo tramite host.data:read; DBX chiede il tuo consenso una volta per connessione.",
|
||||||
|
noDataApi: "Questo host DBX non offre host.data:read.",
|
||||||
|
querying: "Esecuzione di una query di sola lettura tramite l'host…",
|
||||||
},
|
},
|
||||||
"pt-BR": {
|
"pt-BR": {
|
||||||
eyebrow: "Plugin DBX em sandbox",
|
eyebrow: "Plugin DBX em sandbox",
|
||||||
@@ -265,14 +290,22 @@
|
|||||||
openSaved: "Abra esta área de trabalho a partir de uma conexão Hello salva.",
|
openSaved: "Abra esta área de trabalho a partir de uma conexão Hello salva.",
|
||||||
calling: "Invocando o sidecar nativo…",
|
calling: "Invocando o sidecar nativo…",
|
||||||
error: "Erro",
|
error: "Erro",
|
||||||
|
rows: "Executar novamente com queryData",
|
||||||
|
resultView: "Aberto a partir de um resultado de consulta. Execute-o novamente via host.data:read; o DBX pede seu consentimento uma vez por conexão.",
|
||||||
|
noDataApi: "Este host do DBX não oferece host.data:read.",
|
||||||
|
querying: "Executando uma consulta somente leitura pelo host…",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const result = document.querySelector("#result");
|
const result = document.querySelector("#result");
|
||||||
const button = document.querySelector("#greet");
|
const button = document.querySelector("#greet");
|
||||||
const input = document.querySelector("#name");
|
const input = document.querySelector("#name");
|
||||||
const files = document.querySelector("#files");
|
const files = document.querySelector("#files");
|
||||||
|
const rows = document.querySelector("#rows");
|
||||||
let connectionId = "";
|
let connectionId = "";
|
||||||
let copy = translations.en;
|
let copy = translations.en;
|
||||||
|
// Set when DBX opens this page as the "Hello rows" result view: the
|
||||||
|
// context then carries the query result's connection, database, and SQL.
|
||||||
|
let resultContext = null;
|
||||||
|
|
||||||
function applyLocale(locale) {
|
function applyLocale(locale) {
|
||||||
copy = translations[locale] || translations.en;
|
copy = translations[locale] || translations.en;
|
||||||
@@ -283,6 +316,7 @@
|
|||||||
input.setAttribute("aria-label", copy.name);
|
input.setAttribute("aria-label", copy.name);
|
||||||
button.textContent = copy.invoke;
|
button.textContent = copy.invoke;
|
||||||
files.textContent = copy.files;
|
files.textContent = copy.files;
|
||||||
|
rows.textContent = copy.rows;
|
||||||
document.querySelector("#plugin-label").textContent = copy.plugin;
|
document.querySelector("#plugin-label").textContent = copy.plugin;
|
||||||
document.querySelector("#workbench-label").textContent = copy.workbench;
|
document.querySelector("#workbench-label").textContent = copy.workbench;
|
||||||
document.querySelector("#connection-label").textContent = copy.connection;
|
document.querySelector("#connection-label").textContent = copy.connection;
|
||||||
@@ -292,14 +326,25 @@
|
|||||||
window.dbxPlugin.onEvent((event) => {
|
window.dbxPlugin.onEvent((event) => {
|
||||||
document.querySelector("#event").textContent = `${event.method}: ${JSON.stringify(event.params)}`;
|
document.querySelector("#event").textContent = `${event.method}: ${JSON.stringify(event.params)}`;
|
||||||
});
|
});
|
||||||
|
function showContext(context) {
|
||||||
|
resultContext = context?.result && typeof context.sql === "string" ? context : null;
|
||||||
|
const isResultView = !!resultContext;
|
||||||
|
for (const element of [input, button, files]) element.hidden = isResultView;
|
||||||
|
rows.hidden = !isResultView;
|
||||||
|
document.querySelector("#workbench").textContent = isResultView ? "dbx.example.hello.rows" : "dbx.example.hello.main";
|
||||||
|
connectionId = context?.connectionId || "";
|
||||||
|
document.querySelector("#connection").textContent = connectionId || copy.noConnection;
|
||||||
|
if (isResultView) result.textContent = window.dbxPlugin.capabilities.dataApi ? copy.resultView : copy.noDataApi;
|
||||||
|
else result.textContent = connectionId ? copy.ready : copy.openSaved;
|
||||||
|
}
|
||||||
|
|
||||||
window.dbxPlugin.ready.then((context) => {
|
window.dbxPlugin.ready.then((context) => {
|
||||||
applyLocale(window.dbxPlugin.locale);
|
applyLocale(window.dbxPlugin.locale);
|
||||||
document.querySelector("#plugin").textContent = "dbx.example.hello";
|
document.querySelector("#plugin").textContent = "dbx.example.hello";
|
||||||
document.querySelector("#workbench").textContent = "dbx.example.hello.main";
|
showContext(context);
|
||||||
connectionId = context?.connectionId || "";
|
|
||||||
document.querySelector("#connection").textContent = connectionId || copy.noConnection;
|
|
||||||
result.textContent = connectionId ? copy.ready : copy.openSaved;
|
|
||||||
});
|
});
|
||||||
|
// Reopening the result view for another result pushes a new context.
|
||||||
|
window.dbxPlugin.onContext(showContext);
|
||||||
button.addEventListener("click", async () => {
|
button.addEventListener("click", async () => {
|
||||||
button.disabled = true;
|
button.disabled = true;
|
||||||
result.textContent = copy.calling;
|
result.textContent = copy.calling;
|
||||||
@@ -312,6 +357,33 @@
|
|||||||
button.disabled = false;
|
button.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// host.data:read: the first query for a connection asks the user; later
|
||||||
|
// ones run directly until the grant is revoked in the Plugin Center.
|
||||||
|
rows.addEventListener("click", async () => {
|
||||||
|
rows.disabled = true;
|
||||||
|
result.textContent = copy.querying;
|
||||||
|
try {
|
||||||
|
const data = await window.dbxPlugin.queryData({
|
||||||
|
connectionId: resultContext.connectionId,
|
||||||
|
database: resultContext.database || undefined,
|
||||||
|
sql: resultContext.sql,
|
||||||
|
maxRows: 1000,
|
||||||
|
});
|
||||||
|
const summary = {
|
||||||
|
dbType: data.dbType,
|
||||||
|
columns: data.columns.map((column) => column.name),
|
||||||
|
rows: data.rows.length,
|
||||||
|
truncated: data.truncated,
|
||||||
|
elapsedMs: data.elapsedMs,
|
||||||
|
firstRows: data.rows.slice(0, 5),
|
||||||
|
};
|
||||||
|
result.textContent = JSON.stringify(summary, null, 2);
|
||||||
|
} catch (error) {
|
||||||
|
result.textContent = `${copy.error}: ${error.message}`;
|
||||||
|
} finally {
|
||||||
|
rows.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
files.addEventListener("click", async () => {
|
files.addEventListener("click", async () => {
|
||||||
files.disabled = true;
|
files.disabled = true;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
"uniqueItems": true,
|
"uniqueItems": true,
|
||||||
"items": {
|
"items": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{ "enum": ["host.events", "host.binary", "host.workbench", "host.filesystem", "host.plans:read", "host.schema:read", "host.storage", "host.ai", "host.clipboard:read"] },
|
{ "enum": ["host.events", "host.binary", "host.workbench", "host.filesystem", "host.plans:read", "host.schema:read", "host.storage", "host.ai", "host.clipboard:read", "host.data:read"] },
|
||||||
{ "type": "string", "pattern": "^host\\.network:https://[A-Za-z0-9._-]+(:[0-9]+)?$" }
|
{ "type": "string", "pattern": "^host\\.network:https://[A-Za-z0-9._-]+(:[0-9]+)?$" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -316,6 +316,37 @@ pub async fn ai_cancel_stream(session_id: String) -> Result<bool, String> {
|
|||||||
Ok(dbx_core::ai::cancel_stream(&session_id).await)
|
Ok(dbx_core::ai::cancel_stream(&session_id).await)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Answers a pending plugin tool approval of the agent run `session_id`.
|
||||||
|
/// Returns false when nothing was waiting (already answered or expired).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn ai_resolve_tool_approval(session_id: String, approval_id: String, approved: bool) -> Result<bool, String> {
|
||||||
|
Ok(dbx_core::tool_approval::resolve_tool_approval(&session_id, &approval_id, approved))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plugin ids whose MCP tools the built-in AI agent may call.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_ai_plugin_tool_plugins(state: State<'_, Arc<AppState>>) -> Result<Vec<String>, String> {
|
||||||
|
state.storage.load_ai_plugin_tool_plugin_ids().await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_ai_plugin_tool_plugin_enabled(
|
||||||
|
state: State<'_, Arc<AppState>>,
|
||||||
|
plugin_id: String,
|
||||||
|
enabled: bool,
|
||||||
|
) -> Result<Vec<String>, String> {
|
||||||
|
state.storage.set_ai_plugin_tool_plugin_enabled(&plugin_id, enabled).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists the tools the built-in AI would get from `plugin_id`.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn preview_plugin_ai_tools(
|
||||||
|
state: State<'_, Arc<AppState>>,
|
||||||
|
plugin_id: String,
|
||||||
|
) -> Result<dbx_core::plugin_tools::PluginToolPreview, String> {
|
||||||
|
dbx_core::plugin_tools::preview_plugin_tools(state.inner(), None, &plugin_id).await
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn ai_agent_stream(
|
pub async fn ai_agent_stream(
|
||||||
@@ -395,6 +426,8 @@ pub async fn ai_agent_stream(
|
|||||||
sql_permissions,
|
sql_permissions,
|
||||||
max_agent_turns,
|
max_agent_turns,
|
||||||
prompt_cache_key: request.prompt_cache_key.clone(),
|
prompt_cache_key: request.prompt_cache_key.clone(),
|
||||||
|
session_id: Some(session_id.clone()),
|
||||||
|
host_runtime: Some(tokio::runtime::Handle::current()),
|
||||||
};
|
};
|
||||||
let is_agent_mode = mode.as_deref() == Some("agent");
|
let is_agent_mode = mode.as_deref() == Some("agent");
|
||||||
|
|
||||||
|
|||||||
@@ -282,11 +282,16 @@ pub async fn uninstall_plugin(
|
|||||||
}
|
}
|
||||||
let root_dir = state.plugins.root_dir().to_path_buf();
|
let root_dir = state.plugins.root_dir().to_path_buf();
|
||||||
let app_version = state.plugins.app_version().to_string();
|
let app_version = state.plugins.app_version().to_string();
|
||||||
|
let uninstalled_id = plugin_id.clone();
|
||||||
tauri::async_runtime::spawn_blocking(move || {
|
tauri::async_runtime::spawn_blocking(move || {
|
||||||
PluginPackageInstaller::new(root_dir, app_version)?.uninstall(&plugin_id)
|
PluginPackageInstaller::new(root_dir, app_version)?.uninstall(&uninstalled_id)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|error| error.to_string())??;
|
.map_err(|error| error.to_string())??;
|
||||||
|
// A reinstall must ask for AI tool access and data grants again.
|
||||||
|
if let Err(error) = state.storage.forget_plugin_permissions(&plugin_id).await {
|
||||||
|
log::warn!("Failed to clear permissions of uninstalled plugin {plugin_id}: {error}");
|
||||||
|
}
|
||||||
list_plugins(state).await
|
list_plugins(state).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -999,6 +999,36 @@ pub async fn get_plugin_estimated_plan(
|
|||||||
dbx_core::query::plugin_plan::explain_estimated_plan(&state, request).await
|
dbx_core::query::plugin_plan::explain_estimated_plan(&state, request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read-only data query for the plugin Host API (`host.data:read`). The
|
||||||
|
/// plugin id is bound by the host bridge; the core enforces the manifest
|
||||||
|
/// permission, the user's grant, and the read-only statement gate.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn query_plugin_data(
|
||||||
|
state: tauri::State<'_, std::sync::Arc<dbx_core::connection::AppState>>,
|
||||||
|
plugin_id: String,
|
||||||
|
request: dbx_core::query::plugin_data::PluginDataQueryRequest,
|
||||||
|
) -> Result<dbx_core::query::plugin_data::PluginDataQueryResult, String> {
|
||||||
|
dbx_core::query::plugin_data::query_plugin_data(&state, &plugin_id, request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_plugin_data_grants(
|
||||||
|
state: tauri::State<'_, std::sync::Arc<dbx_core::connection::AppState>>,
|
||||||
|
plugin_id: String,
|
||||||
|
) -> Result<Vec<dbx_core::query::plugin_data::PluginDataGrant>, String> {
|
||||||
|
dbx_core::query::plugin_data::list_plugin_data_grants(&state, &plugin_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_plugin_data_grant(
|
||||||
|
state: tauri::State<'_, std::sync::Arc<dbx_core::connection::AppState>>,
|
||||||
|
plugin_id: String,
|
||||||
|
connection_id: String,
|
||||||
|
granted: bool,
|
||||||
|
) -> Result<Vec<dbx_core::query::plugin_data::PluginDataGrant>, String> {
|
||||||
|
dbx_core::query::plugin_data::set_plugin_data_grant(&state, &plugin_id, &connection_id, granted).await
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn build_create_user_sql(username: String, password: String, tablespace: String) -> Result<String, String> {
|
pub fn build_create_user_sql(username: String, password: String, tablespace: String) -> Result<String, String> {
|
||||||
Ok(dbx_core::db_admin_sql::build_create_user_sql(&username, &password, &tablespace))
|
Ok(dbx_core::db_admin_sql::build_create_user_sql(&username, &password, &tablespace))
|
||||||
|
|||||||
@@ -1802,6 +1802,10 @@ pub fn run() {
|
|||||||
commands::ai::ai_stream,
|
commands::ai::ai_stream,
|
||||||
commands::ai::ai_agent_stream,
|
commands::ai::ai_agent_stream,
|
||||||
commands::ai::ai_cancel_stream,
|
commands::ai::ai_cancel_stream,
|
||||||
|
commands::ai::ai_resolve_tool_approval,
|
||||||
|
commands::ai::get_ai_plugin_tool_plugins,
|
||||||
|
commands::ai::set_ai_plugin_tool_plugin_enabled,
|
||||||
|
commands::ai::preview_plugin_ai_tools,
|
||||||
commands::ai::ai_test_connection,
|
commands::ai::ai_test_connection,
|
||||||
commands::ai::ai_list_models,
|
commands::ai::ai_list_models,
|
||||||
commands::ai::ai_resolve_model_effort,
|
commands::ai::ai_resolve_model_effort,
|
||||||
@@ -2066,6 +2070,9 @@ pub fn run() {
|
|||||||
commands::query::get_explain_info,
|
commands::query::get_explain_info,
|
||||||
commands::query::get_plugin_plan_capabilities,
|
commands::query::get_plugin_plan_capabilities,
|
||||||
commands::query::get_plugin_estimated_plan,
|
commands::query::get_plugin_estimated_plan,
|
||||||
|
commands::query::query_plugin_data,
|
||||||
|
commands::query::get_plugin_data_grants,
|
||||||
|
commands::query::set_plugin_data_grant,
|
||||||
commands::query::build_create_user_sql,
|
commands::query::build_create_user_sql,
|
||||||
commands::query::build_dropped_file_preview_sql,
|
commands::query::build_dropped_file_preview_sql,
|
||||||
commands::query::build_table_select_sql,
|
commands::query::build_table_select_sql,
|
||||||
|
|||||||
Reference in new issue
Block a user