@@ -157,6 +158,10 @@
openSaved: "Open this workbench from a saved Hello connection.",
calling: "Calling native sidecar…",
error: "Error",
+ rows: "Re-run with queryData",
+ resultView: "Opened from a query result. Re-run it through host.data:read; DBX asks for your consent once per connection.",
+ noDataApi: "This DBX host does not offer host.data:read.",
+ querying: "Running a read-only query through the host…",
},
"zh-CN": {
eyebrow: "受沙箱保护的 DBX 插件",
@@ -175,6 +180,10 @@
openSaved: "请从已保存的 Hello 连接打开此工作台。",
calling: "正在调用原生 Sidecar…",
error: "错误",
+ rows: "用 queryData 重新查询",
+ resultView: "已从查询结果打开。可通过 host.data:read 重新执行;每个连接 DBX 只会征求一次你的同意。",
+ noDataApi: "当前 DBX 宿主不支持 host.data:read。",
+ querying: "正在通过宿主执行只读查询…",
},
"zh-TW": {
eyebrow: "受沙箱保護的 DBX 外掛",
@@ -193,6 +202,10 @@
openSaved: "請從已儲存的 Hello 連線開啟此工作台。",
calling: "正在呼叫原生 Sidecar…",
error: "錯誤",
+ rows: "用 queryData 重新查詢",
+ resultView: "已從查詢結果開啟。可透過 host.data:read 重新執行;每個連線 DBX 只會徵求一次你的同意。",
+ noDataApi: "目前的 DBX 主程式不支援 host.data:read。",
+ querying: "正在透過主程式執行唯讀查詢…",
},
ja: {
eyebrow: "サンドボックス化された DBX プラグイン",
@@ -211,6 +224,10 @@
openSaved: "保存済みの Hello 接続からこのワークベンチを開いてください。",
calling: "ネイティブ Sidecar を呼び出し中…",
error: "エラー",
+ rows: "queryData で再実行",
+ resultView: "クエリ結果から開かれました。host.data:read で再実行できます。DBX は接続ごとに一度だけ許可を求めます。",
+ noDataApi: "この DBX ホストは host.data:read を提供していません。",
+ querying: "ホスト経由で読み取り専用クエリを実行中…",
},
es: {
eyebrow: "Plugin de DBX aislado",
@@ -229,6 +246,10 @@
openSaved: "Abre esta área de trabajo desde una conexión Hello guardada.",
calling: "Invocando el sidecar nativo…",
error: "Error",
+ rows: "Volver a ejecutar con queryData",
+ resultView: "Abierto desde un resultado de consulta. Vuelve a ejecutarlo con host.data:read; DBX pide tu consentimiento una vez por conexión.",
+ noDataApi: "Este host de DBX no ofrece host.data:read.",
+ querying: "Ejecutando una consulta de solo lectura a través del host…",
},
it: {
eyebrow: "Plugin DBX in sandbox",
@@ -247,6 +268,10 @@
openSaved: "Apri quest'area di lavoro da una connessione Hello salvata.",
calling: "Chiamata del sidecar nativo…",
error: "Errore",
+ rows: "Riesegui con queryData",
+ resultView: "Aperto da un risultato di query. Rieseguilo tramite host.data:read; DBX chiede il tuo consenso una volta per connessione.",
+ noDataApi: "Questo host DBX non offre host.data:read.",
+ querying: "Esecuzione di una query di sola lettura tramite l'host…",
},
"pt-BR": {
eyebrow: "Plugin DBX em sandbox",
@@ -265,14 +290,22 @@
openSaved: "Abra esta área de trabalho a partir de uma conexão Hello salva.",
calling: "Invocando o sidecar nativo…",
error: "Erro",
+ rows: "Executar novamente com queryData",
+ resultView: "Aberto a partir de um resultado de consulta. Execute-o novamente via host.data:read; o DBX pede seu consentimento uma vez por conexão.",
+ noDataApi: "Este host do DBX não oferece host.data:read.",
+ querying: "Executando uma consulta somente leitura pelo host…",
},
};
const result = document.querySelector("#result");
const button = document.querySelector("#greet");
const input = document.querySelector("#name");
const files = document.querySelector("#files");
+ const rows = document.querySelector("#rows");
let connectionId = "";
let copy = translations.en;
+ // Set when DBX opens this page as the "Hello rows" result view: the
+ // context then carries the query result's connection, database, and SQL.
+ let resultContext = null;
function applyLocale(locale) {
copy = translations[locale] || translations.en;
@@ -283,6 +316,7 @@
input.setAttribute("aria-label", copy.name);
button.textContent = copy.invoke;
files.textContent = copy.files;
+ rows.textContent = copy.rows;
document.querySelector("#plugin-label").textContent = copy.plugin;
document.querySelector("#workbench-label").textContent = copy.workbench;
document.querySelector("#connection-label").textContent = copy.connection;
@@ -292,14 +326,25 @@
window.dbxPlugin.onEvent((event) => {
document.querySelector("#event").textContent = `${event.method}: ${JSON.stringify(event.params)}`;
});
+ function showContext(context) {
+ resultContext = context?.result && typeof context.sql === "string" ? context : null;
+ const isResultView = !!resultContext;
+ for (const element of [input, button, files]) element.hidden = isResultView;
+ rows.hidden = !isResultView;
+ document.querySelector("#workbench").textContent = isResultView ? "dbx.example.hello.rows" : "dbx.example.hello.main";
+ connectionId = context?.connectionId || "";
+ document.querySelector("#connection").textContent = connectionId || copy.noConnection;
+ if (isResultView) result.textContent = window.dbxPlugin.capabilities.dataApi ? copy.resultView : copy.noDataApi;
+ else result.textContent = connectionId ? copy.ready : copy.openSaved;
+ }
+
window.dbxPlugin.ready.then((context) => {
applyLocale(window.dbxPlugin.locale);
document.querySelector("#plugin").textContent = "dbx.example.hello";
- document.querySelector("#workbench").textContent = "dbx.example.hello.main";
- connectionId = context?.connectionId || "";
- document.querySelector("#connection").textContent = connectionId || copy.noConnection;
- result.textContent = connectionId ? copy.ready : copy.openSaved;
+ showContext(context);
});
+ // Reopening the result view for another result pushes a new context.
+ window.dbxPlugin.onContext(showContext);
button.addEventListener("click", async () => {
button.disabled = true;
result.textContent = copy.calling;
@@ -312,6 +357,33 @@
button.disabled = false;
}
});
+ // host.data:read: the first query for a connection asks the user; later
+ // ones run directly until the grant is revoked in the Plugin Center.
+ rows.addEventListener("click", async () => {
+ rows.disabled = true;
+ result.textContent = copy.querying;
+ try {
+ const data = await window.dbxPlugin.queryData({
+ connectionId: resultContext.connectionId,
+ database: resultContext.database || undefined,
+ sql: resultContext.sql,
+ maxRows: 1000,
+ });
+ const summary = {
+ dbType: data.dbType,
+ columns: data.columns.map((column) => column.name),
+ rows: data.rows.length,
+ truncated: data.truncated,
+ elapsedMs: data.elapsedMs,
+ firstRows: data.rows.slice(0, 5),
+ };
+ result.textContent = JSON.stringify(summary, null, 2);
+ } catch (error) {
+ result.textContent = `${copy.error}: ${error.message}`;
+ } finally {
+ rows.disabled = false;
+ }
+ });
files.addEventListener("click", async () => {
files.disabled = true;
try {
diff --git a/plugins/manifest.schema.json b/plugins/manifest.schema.json
index 2c5cff2f8e..e2622efc91 100644
--- a/plugins/manifest.schema.json
+++ b/plugins/manifest.schema.json
@@ -30,7 +30,7 @@
"uniqueItems": true,
"items": {
"anyOf": [
- { "enum": ["host.events", "host.binary", "host.workbench", "host.filesystem", "host.plans:read", "host.schema:read", "host.storage", "host.ai", "host.clipboard:read"] },
+ { "enum": ["host.events", "host.binary", "host.workbench", "host.filesystem", "host.plans:read", "host.schema:read", "host.storage", "host.ai", "host.clipboard:read", "host.data:read"] },
{ "type": "string", "pattern": "^host\\.network:https://[A-Za-z0-9._-]+(:[0-9]+)?$" }
]
}
diff --git a/src-tauri/src/commands/ai.rs b/src-tauri/src/commands/ai.rs
index 03f8e2e026..dbdbf26cc4 100644
--- a/src-tauri/src/commands/ai.rs
+++ b/src-tauri/src/commands/ai.rs
@@ -316,6 +316,37 @@ pub async fn ai_cancel_stream(session_id: String) -> Result
{
Ok(dbx_core::ai::cancel_stream(&session_id).await)
}
+/// Answers a pending plugin tool approval of the agent run `session_id`.
+/// Returns false when nothing was waiting (already answered or expired).
+#[tauri::command]
+pub async fn ai_resolve_tool_approval(session_id: String, approval_id: String, approved: bool) -> Result {
+ Ok(dbx_core::tool_approval::resolve_tool_approval(&session_id, &approval_id, approved))
+}
+
+/// Plugin ids whose MCP tools the built-in AI agent may call.
+#[tauri::command]
+pub async fn get_ai_plugin_tool_plugins(state: State<'_, Arc>) -> Result, String> {
+ state.storage.load_ai_plugin_tool_plugin_ids().await
+}
+
+#[tauri::command]
+pub async fn set_ai_plugin_tool_plugin_enabled(
+ state: State<'_, Arc>,
+ plugin_id: String,
+ enabled: bool,
+) -> Result, String> {
+ state.storage.set_ai_plugin_tool_plugin_enabled(&plugin_id, enabled).await
+}
+
+/// Lists the tools the built-in AI would get from `plugin_id`.
+#[tauri::command]
+pub async fn preview_plugin_ai_tools(
+ state: State<'_, Arc>,
+ plugin_id: String,
+) -> Result {
+ dbx_core::plugin_tools::preview_plugin_tools(state.inner(), None, &plugin_id).await
+}
+
#[tauri::command]
#[allow(clippy::too_many_arguments)]
pub async fn ai_agent_stream(
@@ -395,6 +426,8 @@ pub async fn ai_agent_stream(
sql_permissions,
max_agent_turns,
prompt_cache_key: request.prompt_cache_key.clone(),
+ session_id: Some(session_id.clone()),
+ host_runtime: Some(tokio::runtime::Handle::current()),
};
let is_agent_mode = mode.as_deref() == Some("agent");
diff --git a/src-tauri/src/commands/plugins.rs b/src-tauri/src/commands/plugins.rs
index 5e4d0b1448..041e78de56 100644
--- a/src-tauri/src/commands/plugins.rs
+++ b/src-tauri/src/commands/plugins.rs
@@ -282,11 +282,16 @@ pub async fn uninstall_plugin(
}
let root_dir = state.plugins.root_dir().to_path_buf();
let app_version = state.plugins.app_version().to_string();
+ let uninstalled_id = plugin_id.clone();
tauri::async_runtime::spawn_blocking(move || {
- PluginPackageInstaller::new(root_dir, app_version)?.uninstall(&plugin_id)
+ PluginPackageInstaller::new(root_dir, app_version)?.uninstall(&uninstalled_id)
})
.await
.map_err(|error| error.to_string())??;
+ // A reinstall must ask for AI tool access and data grants again.
+ if let Err(error) = state.storage.forget_plugin_permissions(&plugin_id).await {
+ log::warn!("Failed to clear permissions of uninstalled plugin {plugin_id}: {error}");
+ }
list_plugins(state).await
}
diff --git a/src-tauri/src/commands/query.rs b/src-tauri/src/commands/query.rs
index 79b7b56c3e..908158f729 100644
--- a/src-tauri/src/commands/query.rs
+++ b/src-tauri/src/commands/query.rs
@@ -999,6 +999,36 @@ pub async fn get_plugin_estimated_plan(
dbx_core::query::plugin_plan::explain_estimated_plan(&state, request).await
}
+/// Read-only data query for the plugin Host API (`host.data:read`). The
+/// plugin id is bound by the host bridge; the core enforces the manifest
+/// permission, the user's grant, and the read-only statement gate.
+#[tauri::command]
+pub async fn query_plugin_data(
+ state: tauri::State<'_, std::sync::Arc>,
+ plugin_id: String,
+ request: dbx_core::query::plugin_data::PluginDataQueryRequest,
+) -> Result {
+ dbx_core::query::plugin_data::query_plugin_data(&state, &plugin_id, request).await
+}
+
+#[tauri::command]
+pub async fn get_plugin_data_grants(
+ state: tauri::State<'_, std::sync::Arc>,
+ plugin_id: String,
+) -> Result, String> {
+ dbx_core::query::plugin_data::list_plugin_data_grants(&state, &plugin_id).await
+}
+
+#[tauri::command]
+pub async fn set_plugin_data_grant(
+ state: tauri::State<'_, std::sync::Arc>,
+ plugin_id: String,
+ connection_id: String,
+ granted: bool,
+) -> Result, String> {
+ dbx_core::query::plugin_data::set_plugin_data_grant(&state, &plugin_id, &connection_id, granted).await
+}
+
#[tauri::command]
pub fn build_create_user_sql(username: String, password: String, tablespace: String) -> Result {
Ok(dbx_core::db_admin_sql::build_create_user_sql(&username, &password, &tablespace))
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 3518e478da..35ac665a10 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -1802,6 +1802,10 @@ pub fn run() {
commands::ai::ai_stream,
commands::ai::ai_agent_stream,
commands::ai::ai_cancel_stream,
+ commands::ai::ai_resolve_tool_approval,
+ commands::ai::get_ai_plugin_tool_plugins,
+ commands::ai::set_ai_plugin_tool_plugin_enabled,
+ commands::ai::preview_plugin_ai_tools,
commands::ai::ai_test_connection,
commands::ai::ai_list_models,
commands::ai::ai_resolve_model_effort,
@@ -2066,6 +2070,9 @@ pub fn run() {
commands::query::get_explain_info,
commands::query::get_plugin_plan_capabilities,
commands::query::get_plugin_estimated_plan,
+ commands::query::query_plugin_data,
+ commands::query::get_plugin_data_grants,
+ commands::query::set_plugin_data_grant,
commands::query::build_create_user_sql,
commands::query::build_dropped_file_preview_sql,
commands::query::build_table_select_sql,