feat(plugins): add AI tools and consent-gated data access

This commit is contained in:
t8y2 committed 2026-09-25 01:56:26 +08:00
1 parent 1bc6f70cd8
commit 26a015cd86
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 { Input } from "@/components/ui/input";
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 { connectionIconType } from "@/lib/connection/connectionPresentation";
import { cn } from "@/lib/common/utils";
import type { ConnectionConfig, SidebarLayout } from "@/types/database";
@@ -184,7 +183,7 @@ function handleKeydown(event: KeyboardEvent) {
@click="selectConnection(row.id)"
>
<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>
</button>
</template>
@@ -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 connections: ConnectionConfig[] = [
@@ -45,12 +52,12 @@ const layout: SidebarLayout = {
order: [{ type: "group", id: "g1", children: [{ type: "connection", id: "c1" }] }],
};
async function mountPicker() {
async function mountPicker(extraConnections: ConnectionConfig[] = []) {
const container = document.createElement("div");
document.body.append(container);
const app = createApp(ConnectionTreeSelect, {
modelValue: "",
connections,
connections: [...connections, ...extraConnections],
layout,
placeholder: "Select connection",
searchPlaceholder: "Search connections",
@@ -82,6 +89,13 @@ afterEach(() => {
});
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 () => {
await mountPicker();
await setSearchText("Primary");
@@ -66,8 +66,8 @@ import { usePromptTemplateStore } from "@/stores/promptTemplateStore";
import { useUserSkillStore } from "@/stores/userSkillStore";
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 { connectionIconType } from "@/lib/connection/connectionPresentation";
import DatabaseIcon from "@/components/icons/DatabaseIcon.vue";
import { supportsAiAssistantContext } from "@/lib/database/databaseFeatureSupport";
import ConnectionIcon from "@/components/icons/ConnectionIcon.vue";
import ConnectionTreeSelect from "@/components/connection/ConnectionTreeSelect.vue";
import SearchableSelect from "@/components/ui/searchable-select/SearchableSelect.vue";
import { useQueryStore } from "@/stores/queryStore";
@@ -154,11 +154,11 @@ import { buildAiAgentPlan } from "@/lib/ai/aiAgentPlan";
import { extractFirstSqlCodeBlock, extractSingleSqlCodeBlock } from "@/lib/ai/aiSqlExecutionPolicy";
import { productionContextForDatabase } from "@/lib/database/productionSafety";
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 { createAiMessageRenderer } from "@/lib/ai/aiMessageRender";
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 { AiConfigItem, AiEffortCapability, AiEffortOption, AiEffortSelection } from "@/types/ai";
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 { buildAiConversationSearchIndex, filterAiConversationSearchIndex } from "@/lib/ai/aiConversationSearch";
import AiAttachmentCard from "@/components/editor/AiAttachmentCard.vue";
import AiToolApprovalCard from "@/components/editor/AiToolApprovalCard.vue";
import { resolveAiMessageCopyText } from "@/lib/ai/aiMessageCopy";
import { buildPluginAiRequest, pluginContextFromMessages, pluginContextText, streamPluginAiConversation, type AiPluginContext, type AiPluginConversationRequest } from "@/lib/ai/aiPluginConversation";
@@ -1780,9 +1781,11 @@ watch(
);
watch([dbSelectOptions, selectedNamespace], syncSelectedDatabases, { immediate: true });
const showAiDatabaseSelector = computed(() => !!boundConnection.value && supportsAiAssistantContext(boundConnection.value.db_type));
const showAiSchemaSelector = computed(() => {
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(() => {
@@ -1804,7 +1807,7 @@ async function loadAiSchemas() {
}
async function loadDatabases(connection = boundConnection.value): Promise<string[]> {
if (!connection) return [];
if (!connection || !supportsAiAssistantContext(connection.db_type)) return [];
if (connection.db_type !== "dameng") {
await loadDatabaseOptions(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 {
if (event.type === "context_compacted") {
return {
@@ -2343,7 +2361,7 @@ function mentionTargetDatabase(): string {
async function loadMentionCandidates(query: string) {
const connection = boundConnection.value;
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);
if (mentionCache.value[key]) {
@@ -3693,6 +3711,28 @@ async function send() {
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 (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" />
<component :is="agentStepIcon(step.tone)" v-else class="h-3 w-3 shrink-0" />
<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">
<span class="ml-auto flex shrink-0 items-center gap-1">
<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>
</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>
<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>
<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="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)">
@@ -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>
</details>
<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" />
<ConnectionTreeSelect
:model-value="boundConnectionId"
@@ -5739,7 +5780,7 @@ async function openExternalUrl(url: string) {
list-class="w-72 max-w-[calc(100vw-2rem)]"
@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" />
<Popover
@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 AiAssistant from "@/components/editor/AiAssistant.vue";
import { useSettingsStore } from "@/stores/settingsStore";
import { useConnectionStore } from "@/stores/connectionStore";
import type { ConnectionConfig } from "@/types/database";
vi.mock("@/lib/backend/api", async (importOriginal) => {
const actual = await importOriginal<Record<string, unknown>>();
@@ -22,6 +24,7 @@ vi.mock("@/lib/backend/api", async (importOriginal) => {
loadAiRuns: empty,
readUserSkills: empty,
loadAiConfigs: empty,
listPlugins: empty,
loadPromptTemplates: empty,
getAiGlobalCustomInstructions: () => Promise.resolve(""),
saveAiChatSelection: () => Promise.resolve(),
@@ -35,15 +38,16 @@ afterEach(() => {
while (cleanups.length) cleanups.pop()?.();
});
async function mountPanel(aiConfigLoaded: boolean): Promise<unknown[]> {
async function mountPanel(aiConfigLoaded: boolean, connection?: ConnectionConfig) {
const pinia = createPinia();
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(i18n);
app.config.errorHandler = (error) => errors.push(error);
app.config.warnHandler = () => {};
useSettingsStore(pinia).isAiConfigLoaded = aiConfigLoaded;
if (connection) useConnectionStore(pinia).connections = [connection];
const container = document.createElement("div");
document.body.append(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.
await new Promise((resolve) => setTimeout(resolve, 20));
return errors;
return { errors, container };
}
describe("AiAssistant mount", () => {
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 () => {
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;
// 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
// bounded snapshot — plugins re-query through their backend when they need the
// full or streamed result set.
// bounded snapshot — plugins that need more rows re-run the statement through
// `host.queryData` (host.data:read, with the user's consent).
const cappedRows = result.rows.slice(0, 500);
queryStore.openPluginWorkbench(pluginId, contributionId, {
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 PluginShortcutSettings from "./PluginShortcutSettings.vue";
import PluginIcon from "@/components/plugins/PluginIcon.vue";
import PluginAiAccessSection from "@/components/plugins/PluginAiAccessSection.vue";
import * as api from "@/lib/backend/api";
import { clearPluginIconCache } from "@/lib/plugins/pluginIconResolver";
import { loadPinnedPluginIds, savePinnedPluginIds, sortPluginsPinnedFirst } from "@/lib/plugins/pluginPinning";
@@ -1354,6 +1355,8 @@ onBeforeUnmount(() => {
</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 class="flex flex-wrap gap-2">
<Button
@@ -403,6 +403,14 @@ function createBridge() {
getPlanCapabilities: (connectionId) => api.getPluginPlanCapabilities(connectionId),
explainPlan: (request) => api.getPluginEstimatedPlan(request),
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"),
saveFile: (_pluginId, request, data) => savePluginFile(request, data),
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. */
function safeFileName(value: string | undefined): string {
const base = (value || "").split(/[\\/]/).pop() || "";
@@ -73,6 +73,11 @@ const FEATURE_MESSAGES: FeatureMessages[] = [
{ feature: "PostgreSQL legacy TLS", keys: ["connection.postgresLegacyTls", "connection.postgresLegacyTlsHint"] },
{ feature: "settings search sections", keys: ["settings.syncWebDavWebDescription", "settings.performanceSection"] },
{ 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 {
+38
View File
@@ -82,6 +82,29 @@ const consul = {
export default withEnglishFallback({
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)",
shortcutsToolbar: "Yeniləmə düyməsinin solunda (üzən)",
shortcutsToolbarCount: "Görünən nişanların sayı",
@@ -3029,6 +3052,21 @@ export default withEnglishFallback({
proposalConfirmYes: "Bəli, davam et",
proposalConfirmNo: "Xeyr, təşəkkürlər",
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: {
generated: "SQL yaradıldı",
noSql: "SQL tapılmadı",
+38
View File
@@ -168,6 +168,29 @@ export default {
docs,
dataDictionary,
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)",
shortcutsToolbar: "Before Update (floating)",
shortcutsToolbarCount: "Visible toolbar icons",
@@ -3385,6 +3408,21 @@ export default {
proposalConfirmYes: "Yes, proceed",
proposalConfirmNo: "No thanks",
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: {
generated: "SQL generated",
noSql: "No SQL found",
+38
View File
@@ -171,6 +171,29 @@ export default withEnglishFallback({
docs,
dataDictionary,
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)",
shortcutsToolbar: "Antes de Actualizar (flotante)",
shortcutsToolbarCount: "Iconos visibles",
@@ -3479,6 +3502,21 @@ export default withEnglishFallback({
clearDatabaseSelection: "Clear selection",
searchDatabases: "Buscar 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: {
generated: "SQL generado",
noSql: "No se encontró SQL",
+39
View File
@@ -170,6 +170,30 @@ export default withEnglishFallback({
docs,
dataDictionary,
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)",
shortcutsToolbar: "Prima di Aggiorna (flottante)",
shortcutsToolbarCount: "Icone visibili",
@@ -3297,6 +3321,21 @@ export default withEnglishFallback({
clearDatabaseSelection: "Clear selection",
searchDatabases: "Cerca database…",
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: {
generated: "SQL generato",
noSql: "Nessun SQL trovato",
+38
View File
@@ -171,6 +171,29 @@ export default withEnglishFallback({
docs,
dataDictionary,
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: "プラグインセンターの右側(ドロップダウン)",
shortcutsToolbar: "更新ボタンの左側(フローティング)",
shortcutsToolbarCount: "表示するアイコン数",
@@ -3438,6 +3461,21 @@ export default withEnglishFallback({
clearDatabaseSelection: "Clear selection",
searchDatabases: "データベースを検索…",
noDatabasesFound: "データベースが見つかりません",
toolApproval: {
prompt: "「{plugin}」が「{connection}」で {tool} を実行しようとしています。以下の引数を確認してください。",
approve: "今回のみ許可",
deny: "拒否",
expiresAt: "{time} までに応答がない場合は自動的に拒否されます",
waiting: "承認待ち",
answerFailed: "応答を送信できませんでした: {message}",
status: {
approved: "許可済み",
denied: "拒否済み",
timed_out: "時間内に応答がなかったため実行されませんでした",
cancelled: "キャンセル済み",
expired: "この承認はすでに無効です",
},
},
agentSteps: {
generated: "SQL生成完了",
noSql: "SQLが見つかりません",
+38
View File
@@ -170,6 +170,29 @@ export default withEnglishFallback({
docs,
dataDictionary,
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: "플러그인 센터 오른쪽 (드롭다운)",
shortcutsToolbar: "업데이트 버튼 왼쪽 (플로팅)",
shortcutsToolbarCount: "표시할 아이콘 수",
@@ -3107,6 +3130,21 @@ export default withEnglishFallback({
proposalConfirmYes: "예, 진행",
proposalConfirmNo: "아니오",
proposalConfirmTitle: "작업 확인이 필요합니다",
toolApproval: {
prompt: "{plugin}이(가) {connection}에서 {tool}을(를) 실행하려고 합니다. 아래 인수를 확인하세요.",
approve: "이번만 허용",
deny: "거부",
expiresAt: "응답이 없으면 {time}에 자동으로 거부됩니다",
waiting: "승인 대기 중",
answerFailed: "응답을 보낼 수 없습니다: {message}",
status: {
approved: "승인됨",
denied: "거부됨",
timed_out: "제시간에 응답하지 않아 실행되지 않았습니다",
cancelled: "취소됨",
expired: "이 승인 요청은 더 이상 유효하지 않습니다",
},
},
agentSteps: {
generated: "SQL 생성됨",
noSql: "SQL을 찾을 수 없음",
+38
View File
@@ -171,6 +171,29 @@ export default withEnglishFallback({
docs,
dataDictionary,
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)",
shortcutsToolbar: "Antes de Atualizar (flutuante)",
shortcutsToolbarCount: "Ícones visíveis",
@@ -3405,6 +3428,21 @@ export default withEnglishFallback({
clearDatabaseSelection: "Clear selection",
searchDatabases: "Pesquisar bancos de dados…",
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: {
generated: "SQL gerado",
noSql: "Nenhum SQL encontrado",
+38
View File
@@ -118,6 +118,29 @@ export default withEnglishFallback({
name: "DBX",
},
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: "Справа от центра плагинов (меню)",
shortcutsToolbar: "Слева от обновления (плавающая)",
shortcutsToolbarCount: "Число видимых значков",
@@ -3252,6 +3275,21 @@ export default withEnglishFallback({
proposalConfirmYes: "Да, продолжить",
proposalConfirmNo: "Нет, спасибо",
proposalConfirmTitle: "Требуется подтверждение действия",
toolApproval: {
prompt: "{plugin} хочет выполнить {tool} на {connection}. Проверьте аргументы ниже.",
approve: "Разрешить один раз",
deny: "Отклонить",
expiresAt: "Без ответа будет автоматически отклонено в {time}",
waiting: "Ожидает подтверждения",
answerFailed: "Не удалось отправить ответ: {message}",
status: {
approved: "Разрешено",
denied: "Отклонено",
timed_out: "Нет ответа вовремя; вызов не выполнен",
cancelled: "Отменено",
expired: "Это подтверждение больше не ожидается",
},
},
agentSteps: {
generated: "SQL сгенерирован",
noSql: "SQL не найден",
+38
View File
@@ -84,6 +84,29 @@ const consul = {
export default withEnglishFallback({
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ü)",
shortcutsToolbar: "Güncelle düğmesinin solunda (kayan)",
shortcutsToolbarCount: "Görünür simge sayısı",
@@ -3005,6 +3028,21 @@ export default withEnglishFallback({
proposalConfirmYes: "Evet, devam et",
proposalConfirmNo: "Hayır, teşekkürler",
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: {
generated: "SQL üretildi",
noSql: "SQL bulunamadı",
+38
View File
@@ -93,6 +93,29 @@ export default withEnglishFallback({
docs,
dataDictionary,
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: "插件中心右侧(下拉)",
shortcutsToolbar: "更新按钮左侧(浮动)",
shortcutsToolbarCount: "主栏显示数量",
@@ -3297,6 +3320,21 @@ export default withEnglishFallback({
proposalConfirmYes: "是,请继续",
proposalConfirmNo: "不用了",
proposalConfirmTitle: "需要确认是否执行以下操作",
toolApproval: {
prompt: "「{plugin}」请求在「{connection}」上运行 {tool},请核对下面的参数。",
approve: "允许本次",
deny: "拒绝",
expiresAt: "{time} 前未确认将自动拒绝",
waiting: "等待确认",
answerFailed: "发送确认结果失败:{message}",
status: {
approved: "已允许",
denied: "已拒绝",
timed_out: "未及时确认,未执行",
cancelled: "已取消",
expired: "该确认已失效",
},
},
agentSteps: {
generated: "已生成 SQL",
noSql: "未找到 SQL",
+38
View File
@@ -171,6 +171,29 @@ export default withEnglishFallback({
docs,
dataDictionary,
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: "外掛中心右側(下拉)",
shortcutsToolbar: "更新按鈕左側(浮動)",
shortcutsToolbarCount: "主列顯示數量",
@@ -3271,6 +3294,21 @@ export default withEnglishFallback({
templateDefaultBadgeTitle: "{type} 的預設範本",
exportMarkdown: "匯出為 Markdown",
analysis: "AI 分析",
toolApproval: {
prompt: "「{plugin}」請求在「{connection}」上執行 {tool},請核對下方的參數。",
approve: "允許本次",
deny: "拒絕",
expiresAt: "{time} 前未確認將自動拒絕",
waiting: "等待確認",
answerFailed: "傳送確認結果失敗:{message}",
status: {
approved: "已允許",
denied: "已拒絕",
timed_out: "未及時確認,未執行",
cancelled: "已取消",
expired: "此確認已失效",
},
},
agentSteps: {
generated: "已產生 SQL",
noSql: "未找到 SQL",
+19
View File
@@ -217,3 +217,22 @@ describe("AI schema selector visibility", () => {
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 { 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 = {
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", () => {
it("formats sub-second durations with one decimal", () => {
expect(formatToolDurationMs(799)).toBe("0.8s");
@@ -401,6 +401,38 @@ describe("aiGenerationStatus", () => {
it("falls back to the raw snake_case name for unknown tools", () => {
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)", () => {
+5 -4
View File
@@ -784,13 +784,14 @@ export async function buildAiContext(
const maxFksPerTable = options.maxFksPerTable ?? 10;
const databaseType = aiDatabaseTypeForConnection(connection);
const { database, schema } = resolveAiDatabaseTarget(tab, connection);
const supportsMetadata = connection.db_type !== "plugin";
const tables: AiSchemaTable[] = [];
const tableKeys = new Set<string>();
let truncated = false;
let schemaScope: AiContext["schemaScope"] = "database";
let currentCollectionName: string | undefined;
if (tab.tableMeta) {
if (supportsMetadata && tab.tableMeta) {
schemaScope = "focused_table";
const s = tab.tableMeta.schema ?? "";
const tName = tab.tableMeta.tableName;
@@ -809,7 +810,7 @@ export async function buildAiContext(
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);
if (tableKeys.has(key)) continue;
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
if (isVectorDbType(databaseType)) {
if (supportsMetadata && isVectorDbType(databaseType)) {
try {
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 {
const schemas = await loadCandidateSchemas(tab, connection);
for (const schema of schemas) {
@@ -1,7 +1,29 @@
import type { AiAgentPlan, AiAgentStep } from "@/lib/ai/aiAgentPlan";
import type { AiToolApprovalOutcome } from "@/types/pluginAiTools";
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 {
key: string;
labelKey: string;
@@ -24,6 +46,8 @@ export interface AiAgentStepItem {
endedAtMs?: number;
/** Computed tool duration (endedAtMs - startedAtMs), present only when both stamps exist */
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. */
@@ -52,6 +76,7 @@ export function upsertAgentStep(steps: AiAgentStepItem[], step: AiAgentStepItem)
if (!merged.explainData && existing.explainData) merged.explainData = existing.explainData;
if (!merged.titleKey && existing.titleKey) merged.titleKey = existing.titleKey;
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.endedAtMs !== undefined && merged.startedAtMs !== undefined) {
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);
}
/**
* 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
* ("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`.
*/
import type { AgentEvent } from "@/lib/backend/tauri";
import { formatAgentToolName } from "@/lib/ai/aiAgentStepPresentation";
export interface AiGenerationStatus {
/** 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;
};
/** 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> = {
execute_query: "ai.status.toolLabels.executeQuery",
execute_sql: "ai.status.toolLabels.executeSql",
@@ -148,7 +149,10 @@ export function applyStatusEvent(status: AiGenerationStatus, event: AgentEvent,
case "write_sql_confirmation_required":
case "production_write_blocked":
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;
case "agent_end":
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. */
export function toolLabel(toolName: string, t: AiStatusTranslate): string {
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). */
+7
View File
@@ -282,6 +282,9 @@ export const buildExplainSql = forward("buildExplainSql");
export const getExplainInfo = forward("getExplainInfo");
export const getPluginPlanCapabilities = forward("getPluginPlanCapabilities");
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 buildDroppedFilePreviewSql = forward("buildDroppedFilePreviewSql");
export const buildTableSelectSql = forward("buildTableSelectSql");
@@ -345,6 +348,10 @@ export const aiComplete = forward("aiComplete");
export const aiStream = forward("aiStream");
export const aiAgentStream = forward("aiAgentStream");
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 aiListModels = forward("aiListModels");
export const aiResolveModelEffort = forward("aiResolveModelEffort");
+37
View File
@@ -198,6 +198,8 @@ import type {
} from "@/lib/backend/tauri";
import type { QueryEditability } from "@/lib/sql/sqlAnalysis";
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 { 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);
}
/**
* 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> {
const result = await post<string | null>("/api/query/build-dropped-file-preview-sql", { options });
return result ?? undefined;
@@ -1981,6 +1999,25 @@ export async function aiCancelStream(sessionId: string): Promise<boolean> {
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> {
return post("/api/ai/test-connection", { config });
}
+53
View File
@@ -25,6 +25,8 @@ import { collectBrowserSupportInfo } from "@/lib/app/supportInfo";
// for this module's own signatures (a re-export does not bind local names).
import type { PluginPlanCapabilities, PluginPlanRequest, PluginPlanResult } from "@/types/pluginPlan";
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> {
assertUpdateAllowsCommand(command);
@@ -600,6 +602,22 @@ export type AgentEvent =
tool_name: string;
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";
tool_call_id: string;
@@ -716,6 +734,25 @@ export async function aiCancelStream(sessionId: string): Promise<boolean> {
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> {
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 });
}
/**
* 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> {
const result = await invoke<string | null>("build_dropped_file_preview_sql", {
options,
+122 -1
View File
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from "vitest";
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";
function plugin(permissions: string[] = [], contributions: InstalledPlugin["manifest"]["contributions"] = []): InstalledPlugin {
@@ -1170,6 +1170,127 @@ describe("PluginHostBridge", () => {
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 () => {
const messages: unknown[] = [];
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 { 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_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 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;
// Mirrors MAX_PLUGIN_PLAN_NAME_CHARS in crates/dbx-core/src/query/plugin_plan.rs.
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
// 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>;
/** Read-only table schema metadata over an already-open Host connection. */
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;
/** 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>;
@@ -217,6 +240,16 @@ export class PluginHostBridge {
private theme?: PluginBridgeTheme;
/** Consent + audit + rate state for clipboard reads; lives for the bridge lifetime. */
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). */
get clipboardAudit(): readonly PluginClipboardAuditEntry[] {
@@ -364,6 +397,7 @@ export class PluginHostBridge {
downloadFile: !!this.api.downloadFile,
planApi: !!this.api.getPlanCapabilities && !!this.api.explainPlan,
[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,
ai: !!this.api.openAiConversation,
// 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");
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") {
const input = isRecord(params) ? params : {};
// 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}'`);
}
/** 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 {
return (this.plugin.manifest.permissions || []).includes(permission);
}
@@ -977,6 +1060,9 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string {
explainPlan: (planRequest) => request('host.explainPlan', planRequest),
// Read-only metadata; the host enforces the permission and open-session gate.
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) => {
if (data === undefined) return request('host.saveFile', options);
if (typeof data === 'string') return request('host.saveFile', { ...(options || {}), dataBase64: data });
@@ -1189,6 +1275,42 @@ function requirePluginPlanRequest(input: Record<string, unknown>): PluginPlanReq
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. */
function clampPluginPlanTimeout(value: unknown): number {
if (typeof value !== "number" || !Number.isFinite(value)) throw new Error("timeoutMs must be a number");
+29
View File
@@ -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 = "__";
+54
View File
@@ -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;
}