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

@@ -0,0 +1,166 @@
//! End-to-end check of plugin MCP tools in the built-in AI agent, run against
//! the packaged example plugin: install it, open a plugin connection, opt the
//! plugin in, discover its tools through the real sidecar, and call them the
//! way an agent run does.
use std::path::PathBuf;
use std::sync::Arc;
use dbx_core::agent_events::ToolCall;
use dbx_core::connection::AppState;
use dbx_core::models::connection::ConnectionConfig;
use dbx_core::plugin_tools::{self, PluginToolSet};
use dbx_core::plugins::{PluginInstallPolicy, PluginPackageInstaller};
use dbx_core::storage::Storage;
use serde_json::{json, Value};
const PLUGIN_ID: &str = "dbx.example.hello";
const APP_VERSION: &str = "0.5.68";
const CONNECTION_ID: &str = "hello-ai-connection";
#[tokio::main]
async fn main() {
if let Err(error) = run().await {
eprintln!("plugin AI tools smoke failed: {error}");
std::process::exit(1);
}
}
async fn run() -> Result<(), String> {
let package = std::env::args().nth(1).map(PathBuf::from).ok_or("Usage: plugin_ai_tools_smoke <package.dbxp>")?;
let root = tempfile::tempdir().map_err(|error| error.to_string())?;
let plugin_dir = root.path().join("plugins");
PluginPackageInstaller::new(plugin_dir.clone(), APP_VERSION.to_string())?
.install_file(&package, PluginInstallPolicy::LocalDevelopment)?;
let storage = Storage::open(&root.path().join("dbx.db")).await?;
let state = Arc::new(AppState::new_with_plugin_dir_and_app_version(storage, plugin_dir, APP_VERSION));
let config: ConnectionConfig = serde_json::from_value(json!({
"id": CONNECTION_ID,
"name": "Hello AI connection",
"db_type": "plugin",
"driver_profile": "plugin",
"host": "localhost",
"port": 22,
"username": "",
"password": "",
"database": null,
"external_config": { "greeting": "Hello" },
"plugin_id": PLUGIN_ID,
"plugin_connection_provider": "dbx.example.hello.connection",
"plugin_connection_type": "hello",
"connection_secrets": { "api_token": "smoke-secret" }
}))
.map_err(|error| error.to_string())?;
state.configs.write().await.insert(CONNECTION_ID.to_string(), config);
// Nothing reaches the model before the user opts the plugin in, and
// nothing for a connection that is not open.
if !plugin_tools::discover_plugin_tools(&state, None).await.is_empty() {
return Err("tools were exposed before the plugin was enabled for the AI".to_string());
}
state.storage.set_ai_plugin_tool_plugin_enabled(PLUGIN_ID, true).await?;
if !plugin_tools::discover_plugin_tools(&state, None).await.is_empty() {
return Err("tools were exposed without an open plugin connection".to_string());
}
state.get_or_create_pool(CONNECTION_ID, None).await?;
let tools = plugin_tools::discover_plugin_tools(&state, None).await;
let definitions = tools.definitions();
let names = definitions.iter().map(|definition| definition.name.as_ref()).collect::<Vec<_>>();
if names != ["hello__hello_greet", "hello__hello_set_greeting"] {
return Err(format!("unexpected tool names: {names:?}"));
}
let read_only = definitions.iter().map(|definition| definition.read_only).collect::<Vec<_>>();
if read_only != [true, false] {
return Err(format!("readOnlyHint was not honored: {read_only:?}"));
}
if definitions.iter().any(|definition| definition.parameters.pointer("/properties/connectionId").is_some()) {
return Err("the host-bound connectionId leaked into a model-facing schema".to_string());
}
expect_tool_text(
&state,
&tools,
"hello__hello_greet",
json!({ "name": "Agent" }),
"Hello, Agent, from Hello AI connection!",
)
.await?;
// A real run pauses here for the user's approval; this call stands in for
// an approved one.
expect_tool_text(
&state,
&tools,
"hello__hello_set_greeting",
json!({ "greeting": "Hi" }),
"Hello AI connection now greets with \"Hi\".",
)
.await?;
expect_tool_text(
&state,
&tools,
"hello__hello_greet",
json!({ "name": "Agent" }),
"Hi, Agent, from Hello AI connection!",
)
.await?;
let preview = plugin_tools::preview_plugin_tools(&state, None, PLUGIN_ID).await?;
let preview_names = preview.tools.iter().map(|tool| (tool.name.as_str(), tool.read_only)).collect::<Vec<_>>();
if preview_names != [("hello_greet", true), ("hello_set_greeting", false)]
|| preview.open_connections.iter().map(|connection| connection.id.as_str()).collect::<Vec<_>>()
!= [CONNECTION_ID]
{
return Err(format!("unexpected preview: {preview:?}"));
}
let call = ToolCall {
id: "revoked-call".to_string(),
name: "hello__hello_set_greeting".to_string(),
arguments: json!({ "greeting": "Must not run" }),
provider_payload: None,
};
let prepared = tools.prepare_call(&call).ok_or("missing write tool")??;
state.storage.set_ai_plugin_tool_plugin_enabled(PLUGIN_ID, false).await?;
let revoked = plugin_tools::execute_plugin_tool(&state, None, &call, &prepared).await;
if !revoked.is_error || !revoked.content.contains("has been disabled") {
return Err(format!("revoked AI access was not enforced: {revoked:?}"));
}
state.storage.set_ai_plugin_tool_plugin_enabled(PLUGIN_ID, true).await?;
expect_tool_text(
&state,
&tools,
"hello__hello_greet",
json!({ "name": "Agent" }),
"Hi, Agent, from Hello AI connection!",
)
.await?;
state.remove_connection_pools(CONNECTION_ID).await;
let closed = plugin_tools::execute_plugin_tool(&state, None, &call, &prepared).await;
if !closed.is_error || !closed.content.contains("no longer open") {
return Err(format!("closed plugin connection was not rejected: {closed:?}"));
}
println!("plugin AI tools smoke passed: {names:?}");
Ok(())
}
async fn expect_tool_text(
state: &Arc<AppState>,
tools: &PluginToolSet,
name: &str,
arguments: Value,
expected: &str,
) -> Result<(), String> {
let call = ToolCall { id: format!("call-{name}"), name: name.to_string(), arguments, provider_payload: None };
let prepared = tools.prepare_call(&call).ok_or_else(|| format!("{name} is not a plugin tool"))??;
if prepared.connection_id != CONNECTION_ID || prepared.arguments.get("connectionId") != Some(&json!(CONNECTION_ID))
{
return Err(format!("{name} was not bound to the open connection: {prepared:?}"));
}
let result = plugin_tools::execute_plugin_tool(state, None, &call, &prepared).await;
if result.is_error || result.content != expected {
return Err(format!("{name} returned {:?} (error: {})", result.content, result.is_error));
}
Ok(())
}