mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(plugins): expose connection enumeration to plugins
This commit is contained in:
@@ -41,9 +41,19 @@ vi.mock("@/stores/connectionStore", () => ({
|
|||||||
useConnectionStore: () => ({
|
useConnectionStore: () => ({
|
||||||
repushPluginConnection: mocks.repushPluginConnection,
|
repushPluginConnection: mocks.repushPluginConnection,
|
||||||
reopenPluginConnection: mocks.reopenPluginConnection,
|
reopenPluginConnection: mocks.reopenPluginConnection,
|
||||||
|
connections: [
|
||||||
|
{ id: "conn-mysql", name: "Prod MySQL", db_type: "mysql", plugin_connection_provider: undefined, plugin_connection_type: undefined, read_only: false },
|
||||||
|
{ id: "conn-sample", name: "Sample Connection", db_type: "plugin", plugin_connection_provider: "sample.provider", plugin_connection_type: "sample", read_only: true },
|
||||||
|
],
|
||||||
|
connectedIds: new Set(["conn-mysql"]),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("@/stores/queryStore", () => ({
|
||||||
|
useQueryStore: () => ({
|
||||||
|
openPluginWorkbench: mocks.openPluginWorkbench,
|
||||||
|
pluginWorkbenchTitles: vi.fn((_id: string) => ["Terminal 1"]),
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
vi.mock("@/stores/queryStore", () => ({ useQueryStore: () => ({ openPluginWorkbench: mocks.openPluginWorkbench }) }));
|
|
||||||
vi.mock("vue-i18n", () => ({ useI18n: () => ({ locale: ref("en"), t: (key: string) => key }) }));
|
vi.mock("vue-i18n", () => ({ useI18n: () => ({ locale: ref("en"), t: (key: string) => key }) }));
|
||||||
|
|
||||||
import { clearPluginUiHtmlCache } from "@/lib/plugins/pluginUiHtmlCache";
|
import { clearPluginUiHtmlCache } from "@/lib/plugins/pluginUiHtmlCache";
|
||||||
@@ -319,4 +329,38 @@ describe("PluginWorkbenchHost initialization", () => {
|
|||||||
expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({ type: "dragstate", active: false }), "*");
|
expect(postMessage).toHaveBeenCalledWith(expect.objectContaining({ type: "dragstate", active: false }), "*");
|
||||||
elementFromPoint.mockRestore();
|
elementFromPoint.mockRestore();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("serves all connections with connected status when plugin has host.connections:read", async () => {
|
||||||
|
const pluginWithPerm: InstalledPlugin = {
|
||||||
|
manifest: { id: "sample", name: "Sample", version: "1.0.0", permissions: ["host.connections:read"], drivers: [], contributions: [] },
|
||||||
|
compatibility: { compatible: true },
|
||||||
|
};
|
||||||
|
app = createApp(PluginWorkbenchHost, { plugin: pluginWithPerm, contribution, context: { connectionId: "connection" } });
|
||||||
|
app.mount(root);
|
||||||
|
await flushWorkbenchLoad();
|
||||||
|
const frame = root.querySelector("iframe")!;
|
||||||
|
const target = frame.contentWindow!;
|
||||||
|
const postMessage = vi.spyOn(target, "postMessage").mockImplementation(() => {});
|
||||||
|
window.dispatchEvent(new MessageEvent("message", { source: target, data: { source: "dbx-plugin", version: 1, type: "ready" } }));
|
||||||
|
await vi.waitFor(() => expect(mocks.repushPluginConnection).toHaveBeenCalled());
|
||||||
|
postMessage.mockClear();
|
||||||
|
|
||||||
|
window.dispatchEvent(
|
||||||
|
new MessageEvent("message", {
|
||||||
|
source: target,
|
||||||
|
data: { source: "dbx-plugin", version: 1, type: "request", id: "conn-req-1", method: "host.getConnections", params: {} },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(postMessage).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
source: "dbx-host",
|
||||||
|
type: "response",
|
||||||
|
id: "conn-req-1",
|
||||||
|
result: expect.arrayContaining([expect.objectContaining({ id: "conn-mysql", name: "Prod MySQL", dbType: "mysql", dialect: "mysql", connected: true, isConnected: true }), expect.objectContaining({ id: "conn-sample", name: "Sample Connection", connected: false, isConnected: false })]),
|
||||||
|
}),
|
||||||
|
"*",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -537,21 +537,77 @@ function createBridge() {
|
|||||||
openFilesystem: async (pluginId, providerId, context) => emit("openFilesystem", pluginId, providerId, context),
|
openFilesystem: async (pluginId, providerId, context) => emit("openFilesystem", pluginId, providerId, context),
|
||||||
reopenConnection: (pluginId, connectionId) => useConnectionStore().reopenPluginConnection(connectionId, pluginId),
|
reopenConnection: (pluginId, connectionId) => useConnectionStore().reopenPluginConnection(connectionId, pluginId),
|
||||||
// PR-A4 generic extension point: a read-only, secret-free, plugin-scoped connection list (for in-panel connection switching).
|
// PR-A4 generic extension point: a read-only, secret-free, plugin-scoped connection list (for in-panel connection switching).
|
||||||
listConnections: (ownerPluginId) => {
|
// Extended for #12603 with `host.connections:read` / options.all to return all connections.
|
||||||
|
listConnections: (ownerPluginId, options?: { all?: boolean }) => {
|
||||||
|
const connectionStore = useConnectionStore();
|
||||||
|
const queryStore = useQueryStore();
|
||||||
|
const connectedIds = connectionStore.connectedIds;
|
||||||
|
if (options?.all) {
|
||||||
|
return connectionStore.connections.map((connection) => {
|
||||||
|
const connected = connectedIds.has(connection.id);
|
||||||
|
return {
|
||||||
|
id: connection.id,
|
||||||
|
name: connection.name,
|
||||||
|
dbType: connection.db_type,
|
||||||
|
dialect: connection.db_type,
|
||||||
|
databaseType: connection.db_type,
|
||||||
|
connected,
|
||||||
|
isConnected: connected,
|
||||||
|
readOnly: connection.read_only === true,
|
||||||
|
database: connection.database,
|
||||||
|
schema: connection.default_schema,
|
||||||
|
providerId: connection.plugin_connection_provider ?? "",
|
||||||
|
connectionType: connection.plugin_connection_type,
|
||||||
|
workbenchTitles: queryStore.pluginWorkbenchTitles(connection.id),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
const providerIds = new Set((props.plugin.manifest.contributions || []).filter((candidate) => candidate.type === "connection-provider").map((candidate) => candidate.id));
|
const providerIds = new Set((props.plugin.manifest.contributions || []).filter((candidate) => candidate.type === "connection-provider").map((candidate) => candidate.id));
|
||||||
if (ownerPluginId !== props.plugin.manifest.id) return [];
|
if (ownerPluginId !== props.plugin.manifest.id) return [];
|
||||||
|
return connectionStore.connections
|
||||||
|
.filter((connection) => providerIds.has(connection.plugin_connection_provider ?? ""))
|
||||||
|
.map((connection) => {
|
||||||
|
const connected = connectedIds.has(connection.id);
|
||||||
|
return {
|
||||||
|
id: connection.id,
|
||||||
|
name: connection.name,
|
||||||
|
dbType: connection.db_type,
|
||||||
|
dialect: connection.db_type,
|
||||||
|
databaseType: connection.db_type,
|
||||||
|
connected,
|
||||||
|
isConnected: connected,
|
||||||
|
providerId: connection.plugin_connection_provider ?? "",
|
||||||
|
connectionType: connection.plugin_connection_type,
|
||||||
|
readOnly: connection.read_only === true,
|
||||||
|
database: connection.database,
|
||||||
|
schema: connection.default_schema,
|
||||||
|
// #193b — dock/tab titles bound to the connection (dock first), so plugins can label session rows after renamed terminals.
|
||||||
|
workbenchTitles: queryStore.pluginWorkbenchTitles(connection.id),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
},
|
||||||
|
listAllConnections: () => {
|
||||||
|
const connectionStore = useConnectionStore();
|
||||||
const queryStore = useQueryStore();
|
const queryStore = useQueryStore();
|
||||||
return useConnectionStore()
|
const connectedIds = connectionStore.connectedIds;
|
||||||
.connections.filter((connection) => providerIds.has(connection.plugin_connection_provider ?? ""))
|
return connectionStore.connections.map((connection) => {
|
||||||
.map((connection) => ({
|
const connected = connectedIds.has(connection.id);
|
||||||
|
return {
|
||||||
id: connection.id,
|
id: connection.id,
|
||||||
name: connection.name,
|
name: connection.name,
|
||||||
|
dbType: connection.db_type,
|
||||||
|
dialect: connection.db_type,
|
||||||
|
databaseType: connection.db_type,
|
||||||
|
connected,
|
||||||
|
isConnected: connected,
|
||||||
|
readOnly: connection.read_only === true,
|
||||||
|
database: connection.database,
|
||||||
|
schema: connection.default_schema,
|
||||||
providerId: connection.plugin_connection_provider ?? "",
|
providerId: connection.plugin_connection_provider ?? "",
|
||||||
connectionType: connection.plugin_connection_type,
|
connectionType: connection.plugin_connection_type,
|
||||||
readOnly: connection.read_only === true,
|
|
||||||
// #193b — dock/tab titles bound to the connection (dock first), so plugins can label session rows after renamed terminals.
|
|
||||||
workbenchTitles: queryStore.pluginWorkbenchTitles(connection.id),
|
workbenchTitles: queryStore.pluginWorkbenchTitles(connection.id),
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
},
|
},
|
||||||
// Both plan calls carry the plugin's declared `host.plans:read` gate in the
|
// Both plan calls carry the plugin's declared `host.plans:read` gate in the
|
||||||
// bridge; the backend owns EXPLAIN generation, the timeout, and the plan cap.
|
// bridge; the backend owns EXPLAIN generation, the timeout, and the plan cap.
|
||||||
|
|||||||
+111
-1
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import { reactive, readonly } from "vue";
|
import { reactive, readonly } from "vue";
|
||||||
import { MAX_CONCURRENT_PLUGIN_DATA_QUERIES, PLUGIN_CLIPBOARD_AUDIT_CAPACITY, PluginHostBridge, clipboardReadGateAllows, createClipboardReadGate, pluginSandboxDocument, pluginSdkSource, recordClipboardRead } from "./pluginHostBridge";
|
import { MAX_CONCURRENT_PLUGIN_DATA_QUERIES, PLUGIN_CLIPBOARD_AUDIT_CAPACITY, PluginHostBridge, clipboardReadGateAllows, createClipboardReadGate, pluginSandboxDocument, pluginSdkSource, recordClipboardRead } from "./pluginHostBridge";
|
||||||
import type { InstalledPlugin, PluginResultViewContribution, PluginWorkbenchContribution } from "@/types/database";
|
import type { InstalledPlugin, PluginResultViewContribution, PluginWorkbenchContribution } from "@/types/database";
|
||||||
|
import type { PluginConnectionSummary } from "@/types/pluginConnection";
|
||||||
|
|
||||||
function plugin(permissions: string[] = [], contributions: InstalledPlugin["manifest"]["contributions"] = []): InstalledPlugin {
|
function plugin(permissions: string[] = [], contributions: InstalledPlugin["manifest"]["contributions"] = []): InstalledPlugin {
|
||||||
return {
|
return {
|
||||||
@@ -17,7 +18,7 @@ describe("PluginHostBridge", () => {
|
|||||||
it("host.listConnections returns a read-only secret-free list scoped to the calling plugin", async () => {
|
it("host.listConnections returns a read-only secret-free list scoped to the calling plugin", async () => {
|
||||||
const messages: unknown[] = [];
|
const messages: unknown[] = [];
|
||||||
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
|
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
|
||||||
const listConnections = vi.fn(() => [{ id: "conn-1", name: "Prod", providerId: "sample.connection", connectionType: "sample", readOnly: true }]);
|
const listConnections = vi.fn((_pluginId?: string, _options?: { all?: boolean }): PluginConnectionSummary[] => [{ id: "conn-1", name: "Prod", dbType: "sample", dialect: "sample", connected: true, isConnected: true, providerId: "sample.connection", connectionType: "sample", readOnly: true }]);
|
||||||
const contributions = [
|
const contributions = [
|
||||||
{ type: "connection-provider", id: "sample.connection", label: "Sample", database_type: "sample", fields: [] },
|
{ type: "connection-provider", id: "sample.connection", label: "Sample", database_type: "sample", fields: [] },
|
||||||
{ type: "workbench", id: "sample.main", label: "Sample" },
|
{ type: "workbench", id: "sample.main", label: "Sample" },
|
||||||
@@ -38,6 +39,96 @@ describe("PluginHostBridge", () => {
|
|||||||
expect(messages[0]).toMatchObject({ source: "dbx-host", type: "response", id: "9", result: [{ id: "conn-1", name: "Prod", providerId: "sample.connection", readOnly: true }] });
|
expect(messages[0]).toMatchObject({ source: "dbx-host", type: "response", id: "9", result: [{ id: "conn-1", name: "Prod", providerId: "sample.connection", readOnly: true }] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("host.getConnections and host.listConnections require host.connections:read for all connections", async () => {
|
||||||
|
const messages: any[] = [];
|
||||||
|
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
|
||||||
|
const listAllConnections = vi.fn(() => [
|
||||||
|
{ id: "conn-1", name: "MySQL Main", dbType: "mysql", dialect: "mysql", connected: true, isConnected: true, readOnly: false },
|
||||||
|
{ id: "conn-2", name: "PG Replica", dbType: "postgres", dialect: "postgres", connected: false, isConnected: false, readOnly: true },
|
||||||
|
]);
|
||||||
|
const bridge = new PluginHostBridge(plugin(["host.connections:read"]), workbench, {}, () => target, {
|
||||||
|
invoke: vi.fn(),
|
||||||
|
notify: vi.fn(),
|
||||||
|
sendBinary: vi.fn(),
|
||||||
|
readAsset: vi.fn(),
|
||||||
|
listAllConnections,
|
||||||
|
});
|
||||||
|
|
||||||
|
bridge.handleWindowMessage({
|
||||||
|
source: target,
|
||||||
|
data: { source: "dbx-plugin", version: 1, type: "request", id: "10", method: "host.getConnections", params: {} },
|
||||||
|
} as MessageEvent);
|
||||||
|
await vi.waitFor(() => expect(listAllConnections).toHaveBeenCalled());
|
||||||
|
expect(messages[0]).toMatchObject({
|
||||||
|
source: "dbx-host",
|
||||||
|
type: "response",
|
||||||
|
id: "10",
|
||||||
|
result: [
|
||||||
|
{ id: "conn-1", name: "MySQL Main", dbType: "mysql", dialect: "mysql", connected: true, isConnected: true },
|
||||||
|
{ id: "conn-2", name: "PG Replica", dbType: "postgres", dialect: "postgres", connected: false, isConnected: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
bridge.handleWindowMessage({
|
||||||
|
source: target,
|
||||||
|
data: { source: "dbx-plugin", version: 1, type: "request", id: "11", method: "host.listConnections", params: {} },
|
||||||
|
} as MessageEvent);
|
||||||
|
await vi.waitFor(() => expect(messages.length).toBe(2));
|
||||||
|
expect(messages[1]).toMatchObject({
|
||||||
|
source: "dbx-host",
|
||||||
|
type: "response",
|
||||||
|
id: "11",
|
||||||
|
result: [
|
||||||
|
{ id: "conn-1", name: "MySQL Main" },
|
||||||
|
{ id: "conn-2", name: "PG Replica" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("host.getConnections accepts host.connections::read alias", async () => {
|
||||||
|
const messages: any[] = [];
|
||||||
|
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
|
||||||
|
const listAllConnections = vi.fn(() => [{ id: "c1", name: "Test", dbType: "sqlite", dialect: "sqlite", connected: true, isConnected: true, readOnly: false }]);
|
||||||
|
const bridge = new PluginHostBridge(plugin(["host.connections::read"]), workbench, {}, () => target, {
|
||||||
|
invoke: vi.fn(),
|
||||||
|
notify: vi.fn(),
|
||||||
|
sendBinary: vi.fn(),
|
||||||
|
readAsset: vi.fn(),
|
||||||
|
listAllConnections,
|
||||||
|
});
|
||||||
|
|
||||||
|
bridge.handleWindowMessage({
|
||||||
|
source: target,
|
||||||
|
data: { source: "dbx-plugin", version: 1, type: "request", id: "alias-1", method: "host.getConnections", params: {} },
|
||||||
|
} as MessageEvent);
|
||||||
|
await vi.waitFor(() => expect(listAllConnections).toHaveBeenCalled());
|
||||||
|
expect(messages[0]).toMatchObject({ source: "dbx-host", type: "response", id: "alias-1", result: [{ id: "c1", name: "Test" }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects host.getConnections when host.connections:read is not declared", async () => {
|
||||||
|
const messages: any[] = [];
|
||||||
|
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
|
||||||
|
const bridge = new PluginHostBridge(plugin(["host.workbench"]), workbench, {}, () => target, {
|
||||||
|
invoke: vi.fn(),
|
||||||
|
notify: vi.fn(),
|
||||||
|
sendBinary: vi.fn(),
|
||||||
|
readAsset: vi.fn(),
|
||||||
|
listAllConnections: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
bridge.handleWindowMessage({
|
||||||
|
source: target,
|
||||||
|
data: { source: "dbx-plugin", version: 1, type: "request", id: "err-1", method: "host.getConnections", params: {} },
|
||||||
|
} as MessageEvent);
|
||||||
|
await vi.waitFor(() => expect(messages.length).toBe(1));
|
||||||
|
expect(messages[0]).toMatchObject({
|
||||||
|
source: "dbx-host",
|
||||||
|
type: "response",
|
||||||
|
id: "err-1",
|
||||||
|
error: expect.stringContaining("host.connections:read"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("streams downloads under the owning plugin, scopes cancellation and reports native capability", async () => {
|
it("streams downloads under the owning plugin, scopes cancellation and reports native capability", async () => {
|
||||||
const messages: any[] = [];
|
const messages: any[] = [];
|
||||||
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
|
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
|
||||||
@@ -2110,4 +2201,23 @@ describe("AI completion bridge", () => {
|
|||||||
bridge.dispose();
|
bridge.dispose();
|
||||||
expect(cancelAiGeneration).toHaveBeenCalledWith("gen-1");
|
expect(cancelAiGeneration).toHaveBeenCalledWith("gen-1");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("exposes connection enumeration in the sandbox SDK and advertises connectionsApi capability", () => {
|
||||||
|
const source = pluginSdkSource();
|
||||||
|
expect(source).toContain("getConnections: () => request('host.getConnections', {})");
|
||||||
|
expect(source).toContain("listConnections: (options) => request('host.listConnections', options || {})");
|
||||||
|
expect(source).toContain("connections: Object.freeze({");
|
||||||
|
|
||||||
|
const messages: any[] = [];
|
||||||
|
const target = { postMessage: (message: unknown) => messages.push(message) } as unknown as Window;
|
||||||
|
const bridge = new PluginHostBridge(plugin(["host.connections:read"]), workbench, {}, () => target, {
|
||||||
|
invoke: vi.fn(),
|
||||||
|
notify: vi.fn(),
|
||||||
|
sendBinary: vi.fn(),
|
||||||
|
readAsset: vi.fn(),
|
||||||
|
listAllConnections: vi.fn(() => []),
|
||||||
|
});
|
||||||
|
bridge.handleWindowMessage({ source: target, data: { source: "dbx-plugin", version: 1, type: "ready" } } as MessageEvent);
|
||||||
|
expect(messages.some((m) => m.type === "init" && m.capabilities?.connectionsApi === true)).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { isValidPluginAiRecommendationTemplate, resolvePluginAiRecommendationUpd
|
|||||||
import type { PluginAiRecommendation } from "@/types/pluginAiRecommendations";
|
import type { PluginAiRecommendation } from "@/types/pluginAiRecommendations";
|
||||||
import { MAX_PLUGIN_SCHEMA_METADATA_NAME_CHARS, PLUGIN_SCHEMA_METADATA_CAPABILITY, PLUGIN_SCHEMA_METADATA_PERMISSION, type PluginTableContext, type PluginTableMetadata } from "@/types/pluginSchemaMetadata";
|
import { MAX_PLUGIN_SCHEMA_METADATA_NAME_CHARS, PLUGIN_SCHEMA_METADATA_CAPABILITY, PLUGIN_SCHEMA_METADATA_PERMISSION, type PluginTableContext, type PluginTableMetadata } from "@/types/pluginSchemaMetadata";
|
||||||
import { MAX_PLUGIN_DATA_MAX_ROWS, MAX_PLUGIN_DATA_NAME_CHARS, MAX_PLUGIN_DATA_SQL_CHARS, MAX_PLUGIN_DATA_TIMEOUT_MS, PLUGIN_DATA_ACCESS_NOT_GRANTED, PLUGIN_DATA_CAPABILITY, PLUGIN_DATA_READ_PERMISSION, type PluginDataQueryRequest, type PluginDataQueryResult } from "@/types/pluginData";
|
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";
|
||||||
|
import { PLUGIN_CONNECTIONS_CAPABILITY, PLUGIN_CONNECTIONS_READ_PERMISSION, type PluginConnectionSummary } from "@/types/pluginConnection";
|
||||||
import { floatingWindowLabel } from "@/lib/app/windowContext";
|
import { floatingWindowLabel } from "@/lib/app/windowContext";
|
||||||
import { parseFloatingWindowRequest, type FloatingRect, type OpenFloatingWindowRequest, type OpenFloatingWindowResult } from "@/lib/plugins/pluginFloatingWindow";
|
import { parseFloatingWindowRequest, type FloatingRect, type OpenFloatingWindowRequest, type OpenFloatingWindowResult } from "@/lib/plugins/pluginFloatingWindow";
|
||||||
|
|
||||||
@@ -195,8 +196,11 @@ export interface PluginHostBridgeApi {
|
|||||||
* PR-A4 generic extension point: a read-only, secret-free connection list scoped to the calling plugin's own
|
* PR-A4 generic extension point: a read-only, secret-free connection list scoped to the calling plugin's own
|
||||||
* connection-providers, so plugins can implement their own connection switching inside panels/workbenches and
|
* connection-providers, so plugins can implement their own connection switching inside panels/workbenches and
|
||||||
* their own business (the host stays unaware of the purpose).
|
* their own business (the host stays unaware of the purpose).
|
||||||
|
*
|
||||||
|
* With `host.connections:read` (#12603), returns all connections when requested.
|
||||||
*/
|
*/
|
||||||
listConnections?(pluginId: string): Array<{ id: string; name: string; providerId: string; connectionType?: string; readOnly: boolean; workbenchTitles?: string[] }>;
|
listConnections?(pluginId: string, options?: { all?: boolean }): PluginConnectionSummary[];
|
||||||
|
listAllConnections?(): PluginConnectionSummary[];
|
||||||
/**
|
/**
|
||||||
* Estimated plan capability metadata for one connection. Requires the plugin
|
* Estimated plan capability metadata for one connection. Requires the plugin
|
||||||
* to declare `host.plans:read`. The host only reads the stored connection
|
* to declare `host.plans:read`. The host only reads the stored connection
|
||||||
@@ -520,6 +524,7 @@ export class PluginHostBridge {
|
|||||||
drop: !!this.api.receiveOsDrops,
|
drop: !!this.api.receiveOsDrops,
|
||||||
folderExpansion: !!this.api.receiveOsDrops,
|
folderExpansion: !!this.api.receiveOsDrops,
|
||||||
},
|
},
|
||||||
|
[PLUGIN_CONNECTIONS_CAPABILITY]: !!this.api.listConnections || !!this.api.listAllConnections,
|
||||||
},
|
},
|
||||||
context: snapshotPluginWorkbenchContext(this.context),
|
context: snapshotPluginWorkbenchContext(this.context),
|
||||||
});
|
});
|
||||||
@@ -790,10 +795,24 @@ export class PluginHostBridge {
|
|||||||
await this.api.reopenConnection(this.plugin.manifest.id, requireProtocolName(input.connectionId, "connectionId"));
|
await this.api.reopenConnection(this.plugin.manifest.id, requireProtocolName(input.connectionId, "connectionId"));
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
if (method === "host.getConnections") {
|
||||||
|
this.requirePermission(PLUGIN_CONNECTIONS_READ_PERMISSION);
|
||||||
|
if (!this.api.listConnections && !this.api.listAllConnections) throw new Error("Connection enumeration is unavailable on this host");
|
||||||
|
if (this.api.listAllConnections) return this.api.listAllConnections();
|
||||||
|
return this.api.listConnections!(this.plugin.manifest.id, { all: true });
|
||||||
|
}
|
||||||
if (method === "host.listConnections") {
|
if (method === "host.listConnections") {
|
||||||
this.requirePermission("host.workbench");
|
const hasConnectionsRead = this.hasPermission(PLUGIN_CONNECTIONS_READ_PERMISSION);
|
||||||
if (!this.api.listConnections) throw new Error("Connection enumeration is unavailable on this host");
|
const forceScoped = isRecord(params) && params.scoped === true;
|
||||||
return this.api.listConnections(this.plugin.manifest.id);
|
if (!hasConnectionsRead) {
|
||||||
|
this.requirePermission("host.workbench");
|
||||||
|
}
|
||||||
|
if (!this.api.listConnections && !this.api.listAllConnections) throw new Error("Connection enumeration is unavailable on this host");
|
||||||
|
if (hasConnectionsRead && !forceScoped) {
|
||||||
|
if (this.api.listAllConnections) return this.api.listAllConnections();
|
||||||
|
return this.api.listConnections!(this.plugin.manifest.id, { all: true });
|
||||||
|
}
|
||||||
|
return this.api.listConnections ? this.api.listConnections(this.plugin.manifest.id) : [];
|
||||||
}
|
}
|
||||||
if (method === "host.openFilesystem") {
|
if (method === "host.openFilesystem") {
|
||||||
this.requirePermission("host.filesystem");
|
this.requirePermission("host.filesystem");
|
||||||
@@ -1058,7 +1077,12 @@ export class PluginHostBridge {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private hasPermission(permission: string): boolean {
|
private hasPermission(permission: string): boolean {
|
||||||
return (this.plugin.manifest.permissions || []).includes(permission);
|
const permissions = this.plugin.manifest.permissions || [];
|
||||||
|
if (permissions.includes(permission)) return true;
|
||||||
|
if (permission === PLUGIN_CONNECTIONS_READ_PERMISSION && permissions.includes("host.connections::read")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private respond(target: Window, id: string, payload: { result?: unknown; error?: string }): void {
|
private respond(target: Window, id: string, payload: { result?: unknown; error?: string }): void {
|
||||||
@@ -1427,6 +1451,12 @@ export function pluginSdkSource(initialTheme?: PluginBridgeTheme): string {
|
|||||||
executeCommand: (commandId, context) => request('host.executeCommand', { commandId, context }),
|
executeCommand: (commandId, context) => request('host.executeCommand', { commandId, context }),
|
||||||
openFilesystem: (providerId, childContext) => request('host.openFilesystem', { providerId, context: childContext }),
|
openFilesystem: (providerId, childContext) => request('host.openFilesystem', { providerId, context: childContext }),
|
||||||
reopenConnection: (connectionId) => request('host.reopenConnection', { connectionId }),
|
reopenConnection: (connectionId) => request('host.reopenConnection', { connectionId }),
|
||||||
|
getConnections: () => request('host.getConnections', {}),
|
||||||
|
listConnections: (options) => request('host.listConnections', options || {}),
|
||||||
|
connections: Object.freeze({
|
||||||
|
list: (options) => request('host.listConnections', options || {}),
|
||||||
|
getAll: () => request('host.getConnections', {}),
|
||||||
|
}),
|
||||||
// Estimated plans only: mode must be sent explicitly so a plugin states
|
// Estimated plans only: mode must be sent explicitly so a plugin states
|
||||||
// its intent, and the host refuses anything other than "estimated".
|
// its intent, and the host refuses anything other than "estimated".
|
||||||
getPlanCapabilities: (connectionId) => request('host.getPlanCapabilities', { connectionId }),
|
getPlanCapabilities: (connectionId) => request('host.getPlanCapabilities', { connectionId }),
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Read-only Plugin Host connection list contract (#12603).
|
||||||
|
*
|
||||||
|
* Provides a secret-free, read-only list of configured database connections
|
||||||
|
* so workbench plugins can display connection pickers and interact with
|
||||||
|
* user-selected connections.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Permission required by `host.getConnections` / all-connections `host.listConnections`. */
|
||||||
|
export const PLUGIN_CONNECTIONS_READ_PERMISSION = "host.connections:read";
|
||||||
|
|
||||||
|
/** Capability advertised by the bridge when connection listing is available. */
|
||||||
|
export const PLUGIN_CONNECTIONS_CAPABILITY = "connectionsApi";
|
||||||
|
|
||||||
|
export interface PluginConnectionSummary {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
dbType: string;
|
||||||
|
dialect: string;
|
||||||
|
databaseType?: string;
|
||||||
|
connected: boolean;
|
||||||
|
isConnected: boolean;
|
||||||
|
readOnly: boolean;
|
||||||
|
database?: string;
|
||||||
|
schema?: string;
|
||||||
|
providerId?: string;
|
||||||
|
connectionType?: string;
|
||||||
|
workbenchTitles?: string[];
|
||||||
|
}
|
||||||
@@ -37,6 +37,8 @@ pub const SUPPORTED_PLUGIN_PERMISSIONS: &[&str] = &[
|
|||||||
"host.ai",
|
"host.ai",
|
||||||
"host.clipboard:read",
|
"host.clipboard:read",
|
||||||
"host.data:read",
|
"host.data:read",
|
||||||
|
"host.connections:read",
|
||||||
|
"host.connections::read",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Cap the number of `host.network:<origin>` entries so a manifest cannot bloat
|
/// Cap the number of `host.network:<origin>` entries so a manifest cannot bloat
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ Manifest 中的路径相对于包根目录,不能以 `/` 开头、不能包含
|
|||||||
"host.storage",
|
"host.storage",
|
||||||
"host.clipboard:read",
|
"host.clipboard:read",
|
||||||
"host.data:read",
|
"host.data:read",
|
||||||
|
"host.connections:read",
|
||||||
"host.network:https://s3.example.com:443"
|
"host.network:https://s3.example.com:443"
|
||||||
],
|
],
|
||||||
"entrypoints": {
|
"entrypoints": {
|
||||||
@@ -146,6 +147,7 @@ Manifest 中的路径相对于包根目录,不能以 `/` 开头、不能包含
|
|||||||
- `host.ai` 允许插件接入 DBX 全局 AI 面板:可以打开 Ask 快照对话、选择性打开使用实时插件工具的 Agent 对话,以及注册当前工作台的推荐问题。旧版 DBX 会在安装时因未知权限而失败,需要兼容旧宿主的插件应把 `host.ai` 作为可选能力,并通过 `capabilities.ai` / `capabilities.aiRecommendations` 判断运行时支持。
|
- `host.ai` 允许插件接入 DBX 全局 AI 面板:可以打开 Ask 快照对话、选择性打开使用实时插件工具的 Agent 对话,以及注册当前工作台的推荐问题。旧版 DBX 会在安装时因未知权限而失败,需要兼容旧宿主的插件应把 `host.ai` 作为可选能力,并通过 `capabilities.ai` / `capabilities.aiRecommendations` 判断运行时支持。
|
||||||
- `host.clipboard:read` 允许工作台 UI 读取系统剪贴板(见 [系统剪贴板](#系统剪贴板))。写剪贴板不需要权限;读取会把用户数据(密码、令牌)交给插件代码,因此需要门控。
|
- `host.clipboard:read` 允许工作台 UI 读取系统剪贴板(见 [系统剪贴板](#系统剪贴板))。写剪贴板不需要权限;读取会把用户数据(密码、令牌)交给插件代码,因此需要门控。
|
||||||
- `host.data:read` 允许 `window.dbxPlugin.queryData` 在用户授权给插件的连接上执行一条只读 SQL(见 [只读数据查询](#只读数据查询))。每个连接都需要用户同意;它不会授予写入、DDL 或重连。
|
- `host.data:read` 允许 `window.dbxPlugin.queryData` 在用户授权给插件的连接上执行一条只读 SQL(见 [只读数据查询](#只读数据查询))。每个连接都需要用户同意;它不会授予写入、DDL 或重连。
|
||||||
|
- `host.connections:read` 允许通过 `window.dbxPlugin.getConnections()` 或 `window.dbxPlugin.connections.list()` 获取当前所有已配置连接的只读脱敏信息(见 [连接信息读取](#连接信息读取))。
|
||||||
- `host.network:https://host[:port]` 声明浏览器可访问的 HTTPS origin,DBX 将它加入 CSP 的 `connect-src`;最多 8 个,不允许路径、通配符或 Token。仍受目标服务 CORS 规则约束。该权限不是原生 Sidecar 的网络防火墙。
|
- `host.network:https://host[:port]` 声明浏览器可访问的 HTTPS origin,DBX 将它加入 CSP 的 `connect-src`;最多 8 个,不允许路径、通配符或 Token。仍受目标服务 CORS 规则约束。该权限不是原生 Sidecar 的网络防火墙。
|
||||||
- `backend.transport` 默认是 `stdio-jsonl`;需要二进制帧时使用 `stdio-framed`,并同时声明 `host.binary`。
|
- `backend.transport` 默认是 `stdio-jsonl`;需要二进制帧时使用 `stdio-framed`,并同时声明 `host.binary`。
|
||||||
- `backend.executable`、`ui.root` 和 `ui.entry` 必须指向包内文件;带原生后端的包必须为每个目标平台提供对应可执行文件。
|
- `backend.executable`、`ui.root` 和 `ui.entry` 必须指向包内文件;带原生后端的包必须为每个目标平台提供对应可执行文件。
|
||||||
@@ -524,6 +526,37 @@ if (window.dbxPlugin.capabilities.dataApi) {
|
|||||||
|
|
||||||
该 API 属于 Host API 1.4。请同时保留 `engines.host_api` 下限和 `capabilities.dataApi` 运行时检查;无法弹出确认框的 Web 宿主会拒绝请求,而不会静默授权。
|
该 API 属于 Host API 1.4。请同时保留 `engines.host_api` 下限和 `capabilities.dataApi` 运行时检查;无法弹出确认框的 Web 宿主会拒绝请求,而不会静默授权。
|
||||||
|
|
||||||
|
### 连接信息读取
|
||||||
|
|
||||||
|
需要展示连接下拉框或根据连接执行特定任务(例如通过 `host.data:read` 查询数据)的插件,可以通过 `host.connections:read` 权限枚举当前配置的所有连接列表:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"permissions": ["host.connections:read"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
await window.dbxPlugin.ready;
|
||||||
|
if (window.dbxPlugin.capabilities.connectionsApi) {
|
||||||
|
const connections = await window.dbxPlugin.getConnections();
|
||||||
|
// 也可通过 window.dbxPlugin.connections.list();
|
||||||
|
for (const conn of connections) {
|
||||||
|
console.log(conn.id, conn.name, conn.dialect, conn.connected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
返回的每个连接对象包含:
|
||||||
|
- `id`: 连接唯一标识,可直接传给 `queryData`、`getPlanCapabilities` 等;
|
||||||
|
- `name`: 连接名称;
|
||||||
|
- `dialect` / `dbType`: 数据库方言/类型(如 `mysql`, `postgres`, `sqlite` 等);
|
||||||
|
- `connected` / `isConnected`: 当前是否已建立物理连接;
|
||||||
|
- `readOnly`: 是否为只读连接;
|
||||||
|
- `database` / `schema`: 默认数据库或模式(如有)。
|
||||||
|
|
||||||
|
> **安全边界**:返回列表完全脱敏,不包含密码、用户名、主机、端口或连接串等敏感凭据。
|
||||||
|
|
||||||
### 系统剪贴板
|
### 系统剪贴板
|
||||||
|
|
||||||
插件沙箱是 opaque origin 且没有剪贴板权限,`navigator.clipboard` 在里面不可用。宿主代为桥接系统剪贴板:
|
插件沙箱是 opaque origin 且没有剪贴板权限,`navigator.clipboard` 在里面不可用。宿主代为桥接系统剪贴板:
|
||||||
|
|||||||
@@ -125,6 +125,7 @@ Manifest paths are relative to the package root. They cannot start with `/`, con
|
|||||||
"host.storage",
|
"host.storage",
|
||||||
"host.clipboard:read",
|
"host.clipboard:read",
|
||||||
"host.data:read",
|
"host.data:read",
|
||||||
|
"host.connections:read",
|
||||||
"host.network:https://s3.example.com:443"
|
"host.network:https://s3.example.com:443"
|
||||||
],
|
],
|
||||||
"entrypoints": {
|
"entrypoints": {
|
||||||
@@ -146,6 +147,7 @@ Manifest paths are relative to the package root. They cannot start with `/`, con
|
|||||||
- `host.ai` lets a plugin use the global DBX AI panel for Ask snapshot conversations, optional Agent conversations backed by live plugin tools, and workbench-specific quick-question recommendations. Older DBX builds reject the unknown permission at install time, so plugins that must install everywhere should treat `host.ai` as optional and check `capabilities.ai` / `capabilities.aiRecommendations` at runtime.
|
- `host.ai` lets a plugin use the global DBX AI panel for Ask snapshot conversations, optional Agent conversations backed by live plugin tools, and workbench-specific quick-question recommendations. Older DBX builds reject the unknown permission at install time, so plugins that must install everywhere should treat `host.ai` as optional and check `capabilities.ai` / `capabilities.aiRecommendations` at runtime.
|
||||||
- `host.clipboard:read` allows reading the system clipboard from the workbench UI (see [System Clipboard](#system-clipboard)). Clipboard writes need no permission; reads hand user data (passwords, tokens) to plugin code, so they are gated.
|
- `host.clipboard:read` allows reading the system clipboard from the workbench UI (see [System Clipboard](#system-clipboard)). Clipboard writes need no permission; reads hand user data (passwords, tokens) to plugin code, so they are gated.
|
||||||
- `host.data:read` allows `window.dbxPlugin.queryData` to run one read-only SQL statement on a connection the user granted to the plugin (see [Read-only Data Queries](#read-only-data-queries)). Every connection needs the user's consent; it never grants writes, DDL, or reconnects.
|
- `host.data:read` allows `window.dbxPlugin.queryData` to run one read-only SQL statement on a connection the user granted to the plugin (see [Read-only Data Queries](#read-only-data-queries)). Every connection needs the user's consent; it never grants writes, DDL, or reconnects.
|
||||||
|
- `host.connections:read` allows `window.dbxPlugin.getConnections()` or `window.dbxPlugin.connections.list()` to read secret-free summary metadata for all configured connections (see [Connection Enumeration](#connection-enumeration)).
|
||||||
- `host.network:https://host[:port]` declares a browser-accessible HTTPS origin added to CSP `connect-src`; at most eight origins are allowed, with no paths, wildcards, or tokens. The service's CORS rules still apply. This permission is not a native Sidecar network firewall.
|
- `host.network:https://host[:port]` declares a browser-accessible HTTPS origin added to CSP `connect-src`; at most eight origins are allowed, with no paths, wildcards, or tokens. The service's CORS rules still apply. This permission is not a native Sidecar network firewall.
|
||||||
- `backend.transport` defaults to `stdio-jsonl`. Use `stdio-framed` for binary frames and declare `host.binary` as well.
|
- `backend.transport` defaults to `stdio-jsonl`. Use `stdio-framed` for binary frames and declare `host.binary` as well.
|
||||||
- `backend.executable`, `ui.root`, and `ui.entry` must point to package files. Native packages need a matching executable for every target.
|
- `backend.executable`, `ui.root`, and `ui.entry` must point to package files. Native packages need a matching executable for every target.
|
||||||
@@ -524,6 +526,37 @@ if (window.dbxPlugin.capabilities.dataApi) {
|
|||||||
|
|
||||||
This API is Host API 1.4. Keep both the `engines.host_api` floor and the `capabilities.dataApi` runtime check; web hosts that cannot show a consent dialog deny the request instead of granting it.
|
This API is Host API 1.4. Keep both the `engines.host_api` floor and the `capabilities.dataApi` runtime check; web hosts that cannot show a consent dialog deny the request instead of granting it.
|
||||||
|
|
||||||
|
### Connection Enumeration
|
||||||
|
|
||||||
|
Plugins that display connection pickers or perform connection-specific tasks (e.g. running queries through `host.data:read`) can enumerate all configured connections with the `host.connections:read` permission:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"permissions": ["host.connections:read"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
await window.dbxPlugin.ready;
|
||||||
|
if (window.dbxPlugin.capabilities.connectionsApi) {
|
||||||
|
const connections = await window.dbxPlugin.getConnections();
|
||||||
|
// or await window.dbxPlugin.connections.list();
|
||||||
|
for (const conn of connections) {
|
||||||
|
console.log(conn.id, conn.name, conn.dialect, conn.connected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each returned connection object includes:
|
||||||
|
- `id`: unique connection identifier, accepted by `queryData`, `getPlanCapabilities`, etc.;
|
||||||
|
- `name`: user-configured display name;
|
||||||
|
- `dialect` / `dbType`: database dialect or type (e.g. `mysql`, `postgres`, `sqlite`);
|
||||||
|
- `connected` / `isConnected`: whether the connection is currently open;
|
||||||
|
- `readOnly`: whether the connection is marked read-only;
|
||||||
|
- `database` / `schema`: default database or schema if configured.
|
||||||
|
|
||||||
|
> **Security Boundary**: The returned list is completely sanitized and never contains passwords, usernames, hostnames, ports, or raw connection strings.
|
||||||
|
|
||||||
### System Clipboard
|
### System Clipboard
|
||||||
|
|
||||||
The plugin sandbox has an opaque origin and no clipboard permission, so `navigator.clipboard` is unavailable there. The host bridges the system clipboard instead:
|
The plugin sandbox has an opaque origin and no clipboard permission, so `navigator.clipboard` is unavailable there. The host bridges the system clipboard instead:
|
||||||
|
|||||||
+2
-2
@@ -380,7 +380,7 @@ A workbench opens in a normal persistent DBX tab. The iframe is loaded with `san
|
|||||||
- `ai.listModels()` — requires `host.ai` and `capabilities.aiCompletion`. Returns only `{ configId, name, model, isDefault }` for configured API models. No endpoints, keys, headers, or CLI environment are exposed. CLI agents are excluded from text completion.
|
- `ai.listModels()` — requires `host.ai` and `capabilities.aiCompletion`. Returns only `{ configId, name, model, isDefault }` for configured API models. No endpoints, keys, headers, or CLI environment are exposed. CLI agents are excluded from text completion.
|
||||||
- `ai.generateText({ configId, model, prompt })` — requires `host.ai` and `capabilities.aiCompletion`; prompts are nonempty and limited to 100000 characters. The desktop host asks for confirmation naming the plugin and selected model before each send, resolves credentials itself, and returns plain text (up to 16000 characters). No tools or file writes are enabled. One generation per workbench may run at a time. Provider failures return a generic error to avoid leaking secrets. Old hosts may omit these methods/capability.
|
- `ai.generateText({ configId, model, prompt })` — requires `host.ai` and `capabilities.aiCompletion`; prompts are nonempty and limited to 100000 characters. The desktop host asks for confirmation naming the plugin and selected model before each send, resolves credentials itself, and returns plain text (up to 16000 characters). No tools or file writes are enabled. One generation per workbench may run at a time. Provider failures return a generic error to avoid leaking secrets. Old hosts may omit these methods/capability.
|
||||||
- `ai.setRecommendations({ context, items })` / `ai.clearRecommendations()` — updates the context-aware quick-question chips shown by the global DBX AI panel; requires `host.ai`. Each item is `{ id, label, prompt, order? }`, supports `{{path.to.value}}` placeholders resolved against the supplied context, and is capped at five visible items. Recommendations belong to the current workbench instance and are not persisted into conversation history. Use `ai.openConversation()` when a user clicks a recommendation; DBX sends the selected prompt through the same snapshot safety boundary.
|
- `ai.setRecommendations({ context, items })` / `ai.clearRecommendations()` — updates the context-aware quick-question chips shown by the global DBX AI panel; requires `host.ai`. Each item is `{ id, label, prompt, order? }`, supports `{{path.to.value}}` placeholders resolved against the supplied context, and is capped at five visible items. Recommendations belong to the current workbench instance and are not persisted into conversation history. Use `ai.openConversation()` when a user clicks a recommendation; DBX sends the selected prompt through the same snapshot safety boundary.
|
||||||
- `capabilities` — `{ downloadFile, planApi, schemaMetadataApi, dataApi, storage, ai, aiModelDiscovery, aiCompletion, aiRecommendations }` advertised in the init message; a missing or `false` entry means that Host API group is unavailable on this host, so gate the matching call on it instead of probing with a request
|
- `capabilities` — `{ downloadFile, planApi, schemaMetadataApi, dataApi, connectionsApi, storage, ai, aiModelDiscovery, aiCompletion, aiRecommendations }` advertised in the init message; a missing or `false` entry means that Host API group is unavailable on this host, so gate the matching call on it instead of probing with a request
|
||||||
- `onEvent(listener)` — events are forwarded only with `host.events`
|
- `onEvent(listener)` — events are forwarded only with `host.events`
|
||||||
- `onBinary(listener)` — binary frames are forwarded only with `host.binary`; listeners receive `{ channel, data: Uint8Array }`
|
- `onBinary(listener)` — binary frames are forwarded only with `host.binary`; listeners receive `{ channel, data: Uint8Array }`
|
||||||
|
|
||||||
@@ -884,7 +884,7 @@ Sidecars are shared per plugin process, not spawned per tab. Plugins own their i
|
|||||||
- **UI isolation:** sandboxed iframe, restrictive CSP, bounded bridge payloads, safe asset paths, plugin identity binding.
|
- **UI isolation:** sandboxed iframe, restrictive CSP, bounded bridge payloads, safe asset paths, plugin identity binding.
|
||||||
- **Secret persistence:** plugin secrets are removed from connection JSON and stored through DBX's secret-store path. Ordinary cloud-sync snapshots always contain redacted placeholders. Secrets enter sync data only inside the encrypted payload when the user has configured a sync passphrase; without one, plugin secrets remain local and are not synchronized.
|
- **Secret persistence:** plugin secrets are removed from connection JSON and stored through DBX's secret-store path. Ordinary cloud-sync snapshots always contain redacted placeholders. Secrets enter sync data only inside the encrypted payload when the user has configured a sync passphrase; without one, plugin secrets remain local and are not synchronized.
|
||||||
- **Native backend trust:** a native sidecar runs with the current OS user's privileges. A signature identifies the repository that approved and published the package; it is not an OS sandbox or proof that the author is harmless. Install only plugins whose backend code you trust.
|
- **Native backend trust:** a native sidecar runs with the current OS user's privileges. A signature identifies the repository that approved and published the package; it is not an OS sandbox or proof that the author is harmless. Install only plugins whose backend code you trust.
|
||||||
- **Permission declarations:** privileged host bridge operations require declared permissions. Plugin UI network egress is fully blocked except for explicitly declared `host.network:` origins. Native process filesystem/network access cannot currently be completely mediated by DBX. `host.plans:read` grants reading host-generated estimated execution plans only; it never grants SQL execution, writes, DDL, or actual plans. `host.schema:read` grants only narrow metadata for a table on an already-open host connection; it never grants arbitrary SQL, writes, or reconnects. `host.storage` confines the workbench UI to a small JSON store inside its own `plugin-data/<id>` directory; it grants no other filesystem reach. `host.ai` lets a workbench open a built-in AI conversation seeded with a snapshot the plugin supplies and publish context-aware recommendation chips; the conversation API returns no model output or configuration. The optional text completion API returns sanitized model choices and generated text only after host confirmation; it never exposes credentials or SQL execution. `host.data:read` grants single read-only statements only on connections the user consented to, per plugin and connection, revocable in Plugin Center; it never grants writes, DDL, locking reads, database switches, or reconnects.
|
- **Permission declarations:** privileged host bridge operations require declared permissions. Plugin UI network egress is fully blocked except for explicitly declared `host.network:` origins. Native process filesystem/network access cannot currently be completely mediated by DBX. `host.plans:read` grants reading host-generated estimated execution plans only; it never grants SQL execution, writes, DDL, or actual plans. `host.schema:read` grants only narrow metadata for a table on an already-open host connection; it never grants arbitrary SQL, writes, or reconnects. `host.storage` confines the workbench UI to a small JSON store inside its own `plugin-data/<id>` directory; it grants no other filesystem reach. `host.ai` lets a workbench open a built-in AI conversation seeded with a snapshot the plugin supplies and publish context-aware recommendation chips; the conversation API returns no model output or configuration. The optional text completion API returns sanitized model choices and generated text only after host confirmation; it never exposes credentials or SQL execution. `host.data:read` grants single read-only statements only on connections the user consented to, per plugin and connection, revocable in Plugin Center; it never grants writes, DDL, locking reads, database switches, or reconnects. `host.connections:read` grants read-only, secret-free summary metadata for all configured connections; it never grants passwords, credentials, or connection strings.
|
||||||
- **AI tool exposure:** plugin MCP tools reach the built-in AI agent only after the user enabled the plugin in Plugin Center, only on open connections and — unless declared read-only — only after a per-call approval; visibility is revocable per plugin (a manifest `ai_tools: false` also opts out). The external `dbx` MCP server exposes a plugin's tools only when its manifest declares `external_tools: true`, and additionally applies the global MCP connection allowlist.
|
- **AI tool exposure:** plugin MCP tools reach the built-in AI agent only after the user enabled the plugin in Plugin Center, only on open connections and — unless declared read-only — only after a per-call approval; visibility is revocable per plugin (a manifest `ai_tools: false` also opts out). The external `dbx` MCP server exposes a plugin's tools only when its manifest declares `external_tools: true`, and additionally applies the global MCP connection allowlist.
|
||||||
|
|
||||||
Custom repository public keys can be added or removed in Plugin Center. Obtain them through a channel independent from the downloaded package.
|
Custom repository public keys can be added or removed in Plugin Center. Obtain them through a channel independent from the downloaded package.
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
"uniqueItems": true,
|
"uniqueItems": true,
|
||||||
"items": {
|
"items": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{ "enum": ["host.events", "host.binary", "host.workbench", "host.filesystem", "host.plans:read", "host.schema:read", "host.storage", "host.ai", "host.clipboard:read", "host.data:read"] },
|
{ "enum": ["host.events", "host.binary", "host.workbench", "host.filesystem", "host.plans:read", "host.schema:read", "host.storage", "host.ai", "host.clipboard:read", "host.data:read", "host.connections:read", "host.connections::read"] },
|
||||||
{ "type": "string", "pattern": "^host\\.network:https://[A-Za-z0-9._-]+(:[0-9]+)?$" }
|
{ "type": "string", "pattern": "^host\\.network:https://[A-Za-z0-9._-]+(:[0-9]+)?$" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -60,7 +60,16 @@
|
|||||||
"contributions": {
|
"contributions": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"oneOf": [{ "$ref": "#/$defs/connectionProviderContribution" }, { "$ref": "#/$defs/workbenchContribution" }, { "$ref": "#/$defs/filesystemProviderContribution" }, { "$ref": "#/$defs/contextMenuContribution" }, { "$ref": "#/$defs/resultViewContribution" }, { "$ref": "#/$defs/commandContribution" }, { "$ref": "#/$defs/menusContribution" }, { "$ref": "#/$defs/mcpContribution" }]
|
"oneOf": [
|
||||||
|
{ "$ref": "#/$defs/connectionProviderContribution" },
|
||||||
|
{ "$ref": "#/$defs/workbenchContribution" },
|
||||||
|
{ "$ref": "#/$defs/filesystemProviderContribution" },
|
||||||
|
{ "$ref": "#/$defs/contextMenuContribution" },
|
||||||
|
{ "$ref": "#/$defs/resultViewContribution" },
|
||||||
|
{ "$ref": "#/$defs/commandContribution" },
|
||||||
|
{ "$ref": "#/$defs/menusContribution" },
|
||||||
|
{ "$ref": "#/$defs/mcpContribution" }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"localizations": {
|
"localizations": {
|
||||||
@@ -182,12 +191,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"fieldCondition": {
|
"fieldCondition": {
|
||||||
"oneOf": [
|
"oneOf": [{ "$ref": "#/$defs/fieldConditionClause" }, { "$ref": "#/$defs/fieldConditionAllOf" }, { "$ref": "#/$defs/fieldConditionAnyOf" }, { "$ref": "#/$defs/fieldConditionNot" }]
|
||||||
{ "$ref": "#/$defs/fieldConditionClause" },
|
|
||||||
{ "$ref": "#/$defs/fieldConditionAllOf" },
|
|
||||||
{ "$ref": "#/$defs/fieldConditionAnyOf" },
|
|
||||||
{ "$ref": "#/$defs/fieldConditionNot" }
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"fieldConditionClause": {
|
"fieldConditionClause": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
|
|||||||
@@ -133,6 +133,12 @@ function installBridge(channel) {
|
|||||||
openWorkbench: (contributionId, context, options) => request("host.openWorkbench", { contributionId, context, forceNew: !!(options && options.forceNew) }),
|
openWorkbench: (contributionId, context, options) => request("host.openWorkbench", { contributionId, context, forceNew: !!(options && options.forceNew) }),
|
||||||
openFilesystem: (providerId, context) => request("host.openFilesystem", { providerId, context }),
|
openFilesystem: (providerId, context) => request("host.openFilesystem", { providerId, context }),
|
||||||
reopenConnection: (connectionId) => request("host.reopenConnection", { connectionId }),
|
reopenConnection: (connectionId) => request("host.reopenConnection", { connectionId }),
|
||||||
|
getConnections: () => request("host.getConnections", {}),
|
||||||
|
listConnections: (options) => request("host.listConnections", options || {}),
|
||||||
|
connections: Object.freeze({
|
||||||
|
list: (options) => request("host.listConnections", options || {}),
|
||||||
|
getAll: () => request("host.getConnections", {}),
|
||||||
|
}),
|
||||||
copy: (text) => request("host.copy", { text }),
|
copy: (text) => request("host.copy", { text }),
|
||||||
storage: {
|
storage: {
|
||||||
get: (key) => request("host.storageGet", { key }),
|
get: (key) => request("host.storageGet", { key }),
|
||||||
|
|||||||
@@ -318,6 +318,25 @@ export async function createMockHost(options) {
|
|||||||
if (typeof p.connectionId !== "string" || !p.connectionId) throw new Error("connectionId is invalid");
|
if (typeof p.connectionId !== "string" || !p.connectionId) throw new Error("connectionId is invalid");
|
||||||
return { ok: true, mockReopenConnection: p.connectionId };
|
return { ok: true, mockReopenConnection: p.connectionId };
|
||||||
}
|
}
|
||||||
|
case "host.getConnections":
|
||||||
|
case "host.listConnections": {
|
||||||
|
const hasConnectionsRead = manifest.permissions?.includes("host.connections:read") || manifest.permissions?.includes("host.connections::read");
|
||||||
|
if (!hasConnectionsRead) {
|
||||||
|
requirePermission(manifest, "host.workbench");
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: frame.connectionId || "mock-conn-1",
|
||||||
|
name: "Mock Connection",
|
||||||
|
dbType: "sqlite",
|
||||||
|
dialect: "sqlite",
|
||||||
|
databaseType: "sqlite",
|
||||||
|
connected: true,
|
||||||
|
isConnected: true,
|
||||||
|
readOnly: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
case "host.storageGet":
|
case "host.storageGet":
|
||||||
case "host.storageSet":
|
case "host.storageSet":
|
||||||
case "host.storageDelete": {
|
case "host.storageDelete": {
|
||||||
|
|||||||
Reference in New Issue
Block a user