# 开发和提交 DBX 插件

> 从 CLI、Manifest、Workbench 和 Sidecar 开始开发 DBX 插件，完成本地调试、打包、自动上架和官方签名发布。

Source: https://dbxio.com/cn/docs/plugin-development

Language: zh-CN

Relative links resolve against https://dbxio.com/cn/docs/plugin-development.



DBX 插件是独立安装的 `.dbxp` 包，可以提供连接类型、工作台、文件系统和原生 Sidecar。插件源码、DBX 插件框架和官方商店目录由不同仓库负责。

**推荐路径：**

 使用 

`dbx-plugin create`

 创建项目，在自己的仓库开发并发布未签名候选包；

`dbx-store`

 会为已注册的插件自动创建或更新候选 PR，维护者审核并签名后才会进入官方目录。

**插件上架 Pull Request 提交到 [`t8y2/dbx-store`](https://github.com/t8y2/dbx-store)，不是 `t8y2/dbx`。** 插件自己的功能代码通常保留在开发者自己的源码仓库；只有修改 DBX Host、SDK、CLI、协议、Schema、文档或官方示例时，才向 [`t8y2/dbx`](https://github.com/t8y2/dbx) 提交 PR。

## 插件快捷入口

在 **插件中心 → 设置 → 全局配置** 中管理快捷入口。默认开启并显示在右侧顶部；可选择左侧或右侧的顶部/底部优先、连接树底部，或更新按钮左侧的浮动栏。浮动栏默认显示 3 个图标，可设置 0–10 个，空间不足的入口放入下拉菜单，以图标和名称逐行显示。

连接树底部区域的「插件」标题栏右上角默认提供「插件设置」入口，其他显示位置则将该入口固定在末尾，点击直达插件中心的设置页，不参与拖动排序。浮动栏溢出时，该入口位于下拉菜单末尾。在「全局配置」中关闭「显示“插件设置”快捷入口」可隐藏它；仍可从插件中心的「设置」页重新开启。没有可显示的插件快捷入口时，整个快捷区域（包括设置入口）隐藏，不占用布局空间。

每个功能有独立图标，可以拖动排序；显示开关按插件生效，关闭后隐藏该插件的所有入口。所有位置共用保存的顺序。隐藏或卸载不会删除已有排序记录，重新出现的相同入口恢复原位置，未记录的入口排列在已保存入口之后。排序标识由插件 ID、入口类型和功能 ID 组成，修改这些 ID 会被视为新入口。

拖动预览跟随光标，靠近窗口边缘时自动调整方向和位置；右侧停靠优先显示在光标左侧，长名称会根据可用宽度省略显示。

选择「插件中心右侧（下拉）」可通过插件中心按钮旁的箭头展开全部快捷入口，每行显示图标和名称，支持拖动排序；该位置不受浮动栏显示数量限制。

连接树底部采用左侧图标、右侧名称的单列列表，行样式和字号与连接树一致。默认按入口数量显示所需行数，最多五行，超出后竖向滚动。拖动分隔线可保存自定义高度，双击恢复自动高度。拖动图标时按 Esc、切换窗口或在区域外释放会取消排序；保存失败会提示并回滚。

快捷入口保留 `appToolbar` 声明的可见条件及命令执行条件。无连接类型的插件还可从快捷栏打开已有工作台；有对应命令时优先执行命令，没有命令体系的旧式插件也可显示文件浏览入口。连接型插件只显示明确声明的全局命令，不自动生成缺少连接上下文的工作台入口。不兼容插件不展示。

显示位置只决定图标位置，内容继续按原有 `panel/tab` 语义打开。隐藏快捷栏、切换位置或隐藏插件均不会关闭已打开的会话。面板快捷入口仅切换当前选中且匹配的命令面板，恢复时保留该实例。无需修改插件 Manifest 或后端协议；旧 `left/right` 设置迁移为对应侧顶部优先。

## 应该提交到哪个仓库

| 你要修改的内容                                                  | 提交位置                                                  | 是否向 DBX 官方提 PR          |
| -------------------------------------------------------- | ----------------------------------------------------- | ----------------------- |
| 你的插件前端、Rust/Go 后端、测试和发布脚本                                | 插件自己的源码仓库                                             | 不向 `dbx` 提交；在自己的仓库开发和发布 |
| 新插件上架、版本更新、商店图标和介绍                                       | [`t8y2/dbx-store`](https://github.com/t8y2/dbx-store) | 是，目标分支为 `main`          |
| DBX 插件 Host、Manifest/Marketplace Schema、SDK、CLI、Packager | [`t8y2/dbx`](https://github.com/t8y2/dbx)             | 是，目标分支为 `main`          |
| DBX 官方示例或插件开发文档                                          | [`t8y2/dbx`](https://github.com/t8y2/dbx)             | 是                       |
| 某个第三方插件自身的 Bug                                           | 该插件的源码仓库                                              | 不要提交到 `dbx-store`       |
| 自定义或企业私有仓库                                               | 你的私有仓库                                                | 不需要向官方商店提交              |
| 数据库厂商 JDBC Driver JAR                                    | 不提交到 Git 仓库                                           | 用户在本机或服务器中导入            |

DBX 官方维护但需要独立发布节奏的插件，通常也应使用独立源码仓库。只有与 Host 强耦合的最小示例、SDK 验证项目或维护者明确同意的组件才放进 `t8y2/dbx`。

## 插件由哪些部分组成

```text
my-plugin/
├── manifest.json
├── dbx-plugin.toml
├── assets/
├── ui/
├── backend/                       # frontend 模板没有此目录
├── .github/workflows/
│   └── plugin-release.yml
└── dist/                          # 构建输出，不提交到 Git
```

* `manifest.json` 声明插件身份、版本、权限、入口和 Contributions。
* `ui/` 在沙箱工作台中运行，通过 Host API 与 DBX 交互。
* `backend/` 是可选的原生 Sidecar，可以使用 Rust、Go 或其他能够实现 DBX JSON-RPC 协议的语言。
* `.dbxp` 是安装包，不是源码仓库。
* 前端插件可以发布一个 `universal` 包；包含原生 Sidecar 的插件需要按操作系统和 CPU 架构分别构建。

## Manifest 完整说明

`manifest.json` 是运行时契约，文件必须位于插件包根目录。Manifest v1 不接受未声明字段，建议在编辑器中引用仓库内的 [`plugins/manifest.schema.json`](https://github.com/t8y2/dbx/blob/main/plugins/manifest.schema.json)。最小的纯前端 Manifest 如下：

```json
{
  "$schema": "https://raw.githubusercontent.com/t8y2/dbx/main/plugins/manifest.schema.json",
  "manifest_version": 1,
  "id": "com.example.files",
  "name": "Example Files",
  "version": "0.1.0",
  "publisher": "example",
  "description": "Browse files from an example service.",
  "icon": "assets/plugin.svg",
  "source": "https://github.com/example/dbx-plugin-files",
  "homepage": "https://github.com/example/dbx-plugin-files",
  "engines": { "host_api": "1" },
  "permissions": [],
  "entrypoints": { "ui": { "root": "ui", "entry": "ui/index.html" } },
  "contributions": [{ "type": "workbench", "id": "com.example.files.main", "label": "Files", "icon": "assets/plugin.svg" }]
}
```

Manifest 中的路径相对于包根目录，不能以 `/` 开头、不能包含 `..`、反斜杠或重复斜杠。`ui.entry` 必须位于 `ui.root` 内：当 root 为 `ui` 时，入口写 `ui/index.html`，不是 `index.html`。`signingKeyId` 属于商店 artifact metadata，不是 Manifest 字段。

| 字段                 | 必需 | 说明                                                                      |
| ------------------ | -- | ----------------------------------------------------------------------- |
| `$schema`          | 否  | 编辑器使用的 JSON Schema 地址，不参与身份校验。                                          |
| `manifest_version` | 是  | 当前必须为数字 `1`。                                                            |
| `id`               | 是  | 全局稳定 ID，只能使用小写字母、数字、`.`, `_`, `-`，例如 `io.github.example.files`；发布后不要更改。 |
| `name`             | 是  | 默认显示名称。                                                                 |
| `icon`             | 否  | 插件默认图标的包内相对路径；推荐 SVG 或 PNG。                                             |
| `version`          | 是  | `major.minor.patch` 语义化版本，可带 prerelease/build 后缀。相同 ID 和版本的正式包不能覆盖。     |
| `publisher`        | 是  | 发布者标识；首次上架后应保持不变。                                                       |
| `description`      | 否  | 插件说明。                                                                   |
| `source`           | 否  | 源码仓库 HTTPS 地址，安装后可在插件信息中查看。                                             |
| `homepage`         | 否  | 项目主页、文档或支持地址。                                                           |
| `engines`          | 是  | 运行时兼容范围，至少包含 `host_api`。                                                |
| `permissions`      | 否  | 最小权限集合，见下文。                                                             |
| `entrypoints`      | 否  | UI 和原生 Sidecar 入口；贡献点存在时按需声明。                                           |
| `contributions`    | 否  | 连接、工作台、文件系统、连接右键菜单、结果视图、命令/菜单和 MCP 工具面声明。                               |
| `localizations`    | 否  | 按 locale 覆盖名称、说明、贡献点、字段和动作文案。                                           |

### `engines`、权限和入口

```json
{
  "engines": { "dbx": ">=0.6.0", "host_api": "1" },
  "permissions": [
    "host.workbench",
    "host.events",
    "host.filesystem",
    "host.binary",
    "host.plans:read",
    "host.schema:read",
    "host.storage",
    "host.clipboard:read",
    "host.data:read",
    "host.connections:read",
    "host.network:https://s3.example.com:443"
  ],
  "entrypoints": {
    "ui": { "root": "ui", "entry": "ui/index.html" },
    "backend": {
      "protocol_versions": [1],
      "transport": "stdio-jsonl",
      "executable": "bin/linux-x64/backend"
    }
  }
}
```

* `engines.dbx` 是可选的 DBX 版本范围；`engines.host_api` 是必需的 Host API 版本声明。
* `host.workbench` 允许打开另一个工作台，`host.events` 接收宿主事件，`host.binary` 使用二进制通道，`host.filesystem` 使用文件系统入口。
* `host.plans:read` 允许针对 DBX 自己的连接读取估算执行计划（见 [估算执行计划](#估算执行计划)）。它是只读权限：不授予普通 SQL 执行、写操作、DDL 或真实执行计划，且这是唯一存在的计划类权限。
* `host.schema:read` 允许通过 `host.getTableMetadata` 读取已打开连接的窄化 table schema metadata；它不会授予任意 SQL、写入或隐式重连（见 [Table Schema Metadata](#table-schema-metadata)）。
* `host.storage` 为工作台 UI 提供本插件独享的持久化键值存储（见 [持久化 UI 状态](#持久化-ui-状态)）；数据只落在插件自己的插件数据目录里。
* `host.ai` 允许插件接入 DBX 全局 AI 面板：可以打开 Ask 快照对话、选择性打开使用实时插件工具的 Agent 对话，以及注册当前工作台的推荐问题。旧版 DBX 会在安装时因未知权限而失败，需要兼容旧宿主的插件应把 `host.ai` 作为可选能力，并通过 `capabilities.ai` / `capabilities.aiRecommendations` 判断运行时支持。
* `host.clipboard:read` 允许工作台 UI 读取系统剪贴板（见 [系统剪贴板](#系统剪贴板)）。写剪贴板不需要权限；读取会把用户数据（密码、令牌）交给插件代码，因此需要门控。
* `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 的网络防火墙。
* `backend.transport` 默认是 `stdio-jsonl`；需要二进制帧时使用 `stdio-framed`，并同时声明 `host.binary`。
* `backend.executable`、`ui.root` 和 `ui.entry` 必须指向包内文件；带原生后端的包必须为每个目标平台提供对应可执行文件。

### Connection Provider

连接 Provider 负责声明连接表单，DBX 负责渲染表单和保存生命周期；插件只处理自己的连接协议。`binding` 决定字段语义：`config` 写入外部配置，`secret` 写入 Secret Store，`name`、`host`、`port`、`username`、`password`、`database` 映射到标准连接字段。

```json
{
  "type": "connection-provider",
  "id": "com.example.files.connection",
  "label": "Example Service",
  "icon": "assets/connection.svg",
  "database_type": "example-files",
  "description": "Connect to an Example Service.",
  "fields": [
    { "key": "display_name", "label": "Name", "type": "text", "binding": "name", "required": true },
    { "key": "endpoint", "label": "Endpoint", "type": "text", "binding": "host", "required": true },
    { "key": "port", "label": "Port", "type": "number", "binding": "port", "default": 443 },
    {
      "key": "region",
      "label": "Region",
      "type": "radio",
      "options": [
        { "label": "US", "value": "us" },
        { "label": "EU", "value": "eu" }
      ],
      "binding": "config"
    },
    { "key": "token", "label": "Access token", "type": "password", "binding": "secret", "required": true }
  ],
  "workbench": "com.example.files.main",
  "capabilities": ["test", "connect", "disconnect"],
  "actions": [{ "id": "refresh", "label": "Refresh metadata", "variant": "outline", "when": "edit", "requires_valid_form": true, "timeout_ms": 30000 }]
}
```

字段类型为 `text`、`password`、`number`、`boolean`、`select`、`radio` 或 `textarea`；`select`/`radio` 必须提供非空 `options`，每项包含 `label` 和字符串 `value`。`binding: "port"` 必须使用 `number`。不要把密码、Token 或私钥放进 `config`、Workbench context、事件或日志。

每个字段必须声明 `key`、`label`、`type`，可选 `description`、`placeholder`、`required`、`default`、`options` 和 `binding`；默认值必须符合字段类型。省略 binding 的 `password` 字段默认按 `secret` 处理。`database_type` 是插件自定义类型标识，不会给 DBX 内置数据库枚举添加成员。

字段还支持条件显隐/条件必填。`visible_when` 与 `required_when` 接受单条子句 `{ "field": "mode", "one_of": ["custom"] }`（引用字段取到列表中的值时命中，按字符串比较，因此 `[false]` 与 `["false"]` 都能匹配布尔 `false`），并用 `all_of` / `any_of` / `not` 组合：

```json
{
  "key": "sudo_command",
  "label": "Sudo command",
  "type": "text",
  "visible_when": {
    "all_of": [
      { "field": "sudo_source", "one_of": ["custom"] },
      { "field": "read_only", "one_of": [false] }
    ]
  }
}
```

条件会级联：子句引用的字段自身处于隐藏状态时该子句不参与判定，因此隐藏容器的默认值不会把孙级字段点亮；未填写或空值永不命中；组合树最多 8 层、64 个节点。DBX 在对话框与保存/测试/连接校验中使用同一套求值，不会出现「表单放行、宿主拒绝」的分歧。

`text`、`password`、`textarea` 字段可以用 `picker` 声明**本机文件选择**（例如 SSH 私钥、keystore）：

```json
{
  "key": "private_key_path",
  "label": "Private key path",
  "type": "text",
  "binding": "config",
  "picker": { "kind": "file", "accept": [".pem", ".key"], "content_field": "private_key" }
}
```

桌面端打开系统文件选择器，把选中的**绝对路径**写回该字段（插件后端同机运行，可自行读取文件）；网页端拿不到客户端路径，同一操作变成**上传**：DBX 读取文件内容写入声明的 `content_field` 兄弟字段，并清空路径字段。两者互相清除——选了路径就丢弃已上传内容，上传内容就清空路径，避免同时存在两份互相矛盾的来源。`kind` 还支持 `directory`（仅桌面端），`accept` 最多 16 条扩展名或 MIME 过滤，上传上限 1 MiB。

插件在连接过程中需要用户输入时（堡垒机登录期 keyboard-interactive 验证码、主机密钥确认、账号选择等），可以调用 Host API 1.1 新增的 `host/requestUserInput`，通过 DBX 同一个阻塞对话框拿到用户输入，`连接测试` 与真实连接走同一条通道：

```json
{
  "jsonrpc": "2.0",
  "id": "prompt-1",
  "method": "host/requestUserInput",
  "params": { "prompt": "Verification code (6 digits)", "title": "JumpServer login", "echo": false, "timeoutSecs": 300 }
}
```

返回值为 `{ "action": "submit", "value": "123456" }`、`{ "action": "cancel" }` 或 `{ "action": "timeout" }`；只有 `submit` 带值，其余必须按「没有答案」处理并失败关闭。插件发起的请求使用字符串 id，DBX 自己的请求与响应继续使用数字 id；提示期间 DBX 会暂停等待该回答的请求超时计时，用户输入验证码不会被误判成连接超时。错误码 `-32001` 表示没有可用的用户界面（headless/MCP），`-32602` 参数非法，`-32601` 宿主不支持该方法，三种情况都要优雅降级。调用前请确认 `plugin/initialize` 通告的 `hostApiVersion` 为 `1.1.0`（或 `host.features` 含 `host.requestUserInput`）；Rust SDK 对应 `dbx_plugin_sdk::host_client()` 与 `HostClient::supports("host/requestUserInput")`、`HostClient::request_user_input(&UserInputPrompt::secret("Verification code"))`。

连接生命周期使用固定的 `connection/test`、`connection/connect`、`connection/disconnect`，自定义表单动作使用 `connection/action`，参数包含 `provider`、`connection`、`runtime`；动作额外包含 `action: { id }`。后端使用 `connection.id` 保存会话，并使用 `runtime.host`/`runtime.port` 连接经过 DBX 隧道或代理转换后的端点。仅在后端生命周期请求中接收补齐 Secret 的连接；前端通过 `connectionId` 使用已经建立的会话。

多端点协议（Kafka `advertised.listeners`、集群发现等）在贡献点上声明 `proxy_route`。配置了传输层后，DBX 会在 payload 中下发 SOCKS5 路由而非静态转发：`runtime.host`/`runtime.port` 保留逻辑端点，插件通过 `runtime.proxy`（`{ "type": "socks5", "host": "...", "port": 1080, "username": "...", "password": "..." }`）拨号每个广播端点——SSH 末层暴露其动态 SOCKS5 端点，SOCKS5 代理层直接使用；代理凭据与连接 Secret 走同一加密通道，插件不得记录日志。未声明该旗标时传输层保持静态转发行为，要求标准 `host`/`port` 字段给出单一远端端点；对会隧道到空端点的插件连接，DBX 直接给出可行动的报错而非静默超时。

动作的 `when` 可为 `always`、`create`、`edit`；`variant` 可为 `default`、`outline`、`secondary`、`destructive`、`ghost`；`timeout_ms` 为 1–120000。`requires_valid_form` 决定是否要求完整表单，`close_on_success` 决定成功后是否关闭。返回 `{ "success": true, "message": "...", "fieldValues": { "port": 443 } }` 可以更新已声明的字段；返回 `success: false` 表示失败。

### 其他 Contributions

| 类型                    | 必需字段                                                             | 用途                                                                                  |
| --------------------- | ---------------------------------------------------------------- | ----------------------------------------------------------------------------------- |
| `workbench`           | `type`, `id`, `label`                                            | 注册可从侧边栏或插件入口打开的工作台。                                                                 |
| `filesystem-provider` | `type`, `id`, `label`, `schemes`                                 | 注册虚拟文件系统；`root_uri` 例如 `s3://bucket`，能力可选 `read`、`write`、`delete`、`rename`、`mkdir`。 |
| `context-menu`        | `type`、`id`、`label`、`menu: "connection"` 或 `"table"`、可选 `action` | 向 Sidebar Tree 的连接或 table 右键菜单增加后端动作，或打开已声明的 Workbench。                             |
| `result-view`         | `type`, `id`, `label`                                            | 注册查询或任务结果视图。                                                                        |

`menu: "table"` contribution 当前只在用户对 Sidebar Tree 中的具体 table 节点打开右键菜单时生效。未声明 `action` 的旧式 contribution 会通过必需的 backend entrypoint 调用 `contextMenu/<contribution-id>`，并传递 table identity：

```json
{
  "table": {
    "connectionId": "connection-id",
    "database": "example",
    "schema": "public",
    "table": "users"
  }
}
```

`database` 和 `schema` 是可选字段；不可用时会省略，不会强制传递空字符串。该 context 只包含对象 identity，不包含 credential、connection string 或原始连接配置。Object Browser 后续可以复用相同 identity contract，但本次版本没有把它作为 table 菜单入口。

context-menu 也可以声明由 Host 处理的 Workbench action：

```json
{
  "type": "context-menu",
  "id": "vendor.example.generate",
  "label": "生成测试数据",
  "menu": "table",
  "action": { "type": "open-workbench", "workbench": "vendor.example.main" }
}
```

`action.workbench` 必须引用同一插件 manifest 中声明的 `workbench`。Host 会直接打开该 Workbench，不调用 sidecar，并将当前 invocation context 作为 Workbench context 传入。`menu: "table"` 传入上面的 `TableContext` 对象本身（不是 backend 的 `{ "table": ... }` envelope）；`menu: "connection"` 传入现有的非敏感 connection summary（`id`、`dbType`、`name`、`database`）。Host 还可能提供用于 tab 关联的标准 `connectionId`。两者都不会暴露 host、port、username、password、connection string 或原始连接配置。再次打开同一个 Workbench 时会用最新 invocation context 刷新其 context。

只有未声明 `action` 的旧式 context-menu 才要求 backend entrypoint；其返回的 `{ "message": "..." }` 仍会显示为 toast。

同一插件内通过 `id` 互相引用：Connection Provider 的 `workbench` 必须指向一个已声明的工作台，`filesystem_provider` 必须指向一个已声明的文件系统 Provider。图标优先使用 Contribution 的 `icon`，缺失时回退到插件级 `icon`。

自定义文件浏览器应使用 `workbench`：列表、虚拟滚动、预览、右键菜单和分栏由插件前端实现。只有希望使用 DBX 通用文件管理器时才选择 `filesystem_provider`；同时声明两者时默认打开工作台。未声明 `action` 的旧式 `context-menu` 调用后端 `contextMenu/<contribution-id>`；声明式 `open-workbench` action 则由 Host 直接打开同插件 Workbench。`result-view` 在工作台 context 中接收结果快照。SDK 会在初始化后通过 `dbxPlugin.contributionId` 暴露 Host 选中的 contribution id，因此单个 UI 入口可以为多个工作台和结果视图服务；具体负载见[完整贡献点协议](https://github.com/t8y2/dbx/blob/main/plugins/README.md)。

### 文件系统协议

使用 DBX 通用文件管理器时，后端实现以下方法；自定义工作台也可以自行通过 RPC 使用这些方法，但不能把声明 Contribution 当成已经实现业务逻辑。

| 方法                           | 参数与返回                                                                                   |
| ---------------------------- | --------------------------------------------------------------------------------------- |
| `filesystem/list`            | `providerId`、可选 `connectionId`、`uri`、可选 `cursor`、`limit`；返回 `{ entries, nextCursor? }`。 |
| `filesystem/read`            | 同上身份字段、`uri`、`maxBytes`；返回 `{ dataBase64, contentType?, truncated, etag? }`。            |
| `filesystem/write`           | `uri`、`dataBase64`、`create`、`overwrite`、可选 `etag`；需声明 `write`。                          |
| `filesystem/createDirectory` | `uri`；需声明 `mkdir`。                                                                      |
| `filesystem/delete`          | `uri`、`recursive`；需声明 `delete`。                                                         |
| `filesystem/rename`          | `sourceUri`、`targetUri`、`overwrite`；需声明 `rename`。                                       |

所有方法都携带 `providerId` 和可选 `connectionId`。目录项包含单个文件名 `name`、完整 `uri`、`kind`（`file`、`directory`、`symlink`、`other`）以及可选 `size`、`modifiedAt`、`contentType`。`name` 不能包含 `/`、`\\` 或是 `.`/`..`；完整路径放在 `uri`。最后一页省略 `nextCursor`，不要返回已经使用的 cursor。图片预览应返回实际 MIME 和 base64 字节，不能把二进制当作 UTF-8 文本。

写操作返回 `{ success, message?, entry? }`；内联读写限制为 4 MiB。大文件传输应采用分块与进度/取消协议，而不是一个巨大的 JSON/base64 值。

### 国际化

`localizations` 的 key 使用 `zh-CN`、`en` 等 locale。可覆盖插件名称、说明以及每个 Contribution 的 `label`、`description`、字段和动作文案；它不会翻译插件 UI 自己的文本。

```json
{
  "localizations": {
    "zh-CN": {
      "name": "示例文件",
      "contributions": {
        "com.example.files.connection": {
          "label": "示例服务",
          "fields": { "token": { "label": "访问令牌" } }
        }
      }
    }
  }
}
```

## Workbench 上下文契约

Workbench 上下文跨越 DBX 与插件边界时是 JSON 数据快照。宿主会递归移除 Vue 响应式包装并发送独立副本，因此插件不能依赖 Vue ref、Proxy、DOM 节点、函数、组件实例或凭据。

上下文可以包含 `null`、布尔值、有限数字、字符串、数组和普通对象。对象中的 `undefined` 字段会省略，数组中的 `undefined` 会转换为 `null`。Date、Map、Set、Symbol、BigInt、非有限数字、循环引用和自定义 class 实例会被拒绝并返回错误。上下文 UTF-8 编码后的大小上限为 2 MiB。

连接 Provider 使用 `binding: "config"` 的字段会保存到 `external_config`；使用 `binding: "secret"` 的字段会保存到 Secret Store。插件连接的 `external_config` 会在新建、编辑、保存和重新连接流程中保留。

宿主还会为每个 workbench 上下文写入三个保留字段：`workbenchId`（宿主生成的稳定实例 id）、`restored` 和 `surface`。`surface` 表示当前 UI 跑在哪种容器里——`"tab"`（主多标签工作台）、`"dock"`（底部面板）或 `"window"`（桌面浮动小组件窗口，见[浮动窗口](#浮动窗口)）。同一个插件的同一个 contribution 可以同时开在多个 surface 上，各自拥有独立的上下文和生命周期；遇到未知取值应退化成 `"tab"` 的行为。

## 前端 Host API

UI 在隔离的 iframe 中运行，DBX 通过 `window.dbxPlugin` 提供桥接对象。不要导入 DBX 的 Vue/Tauri 模块，也不要假设浏览器页面能直接访问 Node.js、文件系统或网络。

多个 Workbench 或 result-view contribution 共用同一个 UI 入口时，可通过 Host 选中的 identity 路由页面：

```js
await window.dbxPlugin.ready;
const contributionId = window.dbxPlugin.contributionId;
```

`contributionId` 与 `context` 相互独立；例如从 Plugin Center 打开的 Workbench，其 context 可以是空对象 `{}`。SDK 会在 resolve `ready` 前记录 identity，因此 `await dbxPlugin.ready` 后可确定性地读取。为兼容旧插件，`dbx-plugin-init` 仍会携带完整 init payload 并作为通知派发；但该事件只派发一次，不应成为读取权威状态的唯一来源。

以下自定义 `objects/list` 和 `objects/changed` 需要由插件后端实现，资源示例假设包内存在 `ui/assets/empty-state.svg`：

```js
await window.dbxPlugin.ready;

const contributionId = window.dbxPlugin.contributionId;
const context = window.dbxPlugin.context;
const result = await window.dbxPlugin.invoke("objects/list", { prefix: "docs/" }, { timeoutMs: 30000 });
const off = window.dbxPlugin.onContext((nextContext) => render(nextContext));

await window.dbxPlugin.notify("objects/changed", { count: result.items.length });
const assetUrl = await window.dbxPlugin.readAssetUrl("assets/empty-state.svg");
```

| API                                                                          | 作用                                                                                                                                                                                                                                                          |
| ---------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `ready`                                                                      | 等待 Host 完成初始化；应用启动逻辑放在 `await dbxPlugin.ready` 之后。                                                                                                                                                                                                          |
| `contributionId`                                                             | Host 打开的 contribution 的只读 identity；`await ready` 后可读取，且与 `context` 独立。                                                                                                                                                                                      |
| `context` / `onContext(fn)`                                                  | 读取或监听当前工作台上下文。                                                                                                                                                                                                                                              |
| `locale`                                                                     | 读取当前语言；插件 UI 需自行完成翻译。                                                                                                                                                                                                                                       |
| `theme`                                                                      | 读取 `appearance` 和 DBX 设计 tokens，适配明暗主题。                                                                                                                                                                                                                     |
| `request(method, params)`                                                    | 调用 Host API；常用内部方法包括 `host.getContext`、`ui.readAsset`。                                                                                                                                                                                                      |
| `invoke(method, params, { timeoutMs })`                                      | 向自己的 Sidecar 发送有响应的 RPC。                                                                                                                                                                                                                                    |
| `notify(method, params)`                                                     | 向 Sidecar 发送无需返回业务结果的通知。                                                                                                                                                                                                                                    |
| `sendBinary(channel, data)` / `onBinary(fn)`                                 | 发送或接收二进制数据；需要 `stdio-framed` 和 `host.binary`。                                                                                                                                                                                                               |
| `readAsset(path)` / `readAssetUrl(path)`                                     | 读取包内资源；路径不能越出插件资源根目录。                                                                                                                                                                                                                                       |
| `openWorkbench(id, context)`                                                 | 打开本插件的另一个工作台；需要 `host.workbench`。                                                                                                                                                                                                                           |
| `setTitle(title)`                                                            | 修改当前工作台页签标题（传入 `null` 恢复默认）；需要 `host.workbench`。                                                                                                                                                                                                            |
| `getTitle()`                                                                 | 读取当前工作台页签标题；需要 `host.workbench`。                                                                                                                                                                                                                            |
| `closeWorkbench()`                                                           | 关闭承载当前 UI 的表面——工作台标签、dock 面板条目，或本插件的浮动窗口。与 `Ctrl/Cmd+W` 快捷键走同一条路径，因此插件可以提供自己的关闭或最小化按钮。                                                                                                                                                                      |
| `executeCommand(commandId, context?)`                                        | 执行本插件自己声明的命令，与菜单执行完全同径——重新检查 `enablement`、按单例复用，并遵循声明的 `presentation`（panel 停靠底部面板，tab 打开工作台标签页）。`context` 合并到命令上下文之上，并解析 `instance_key` 的 `{{path}}` 占位符（如 `instance_key: "logs:{{connectionId}}"` 即每个连接一个面板实例）。预期结果通过 `{ error }` 返回；需要 `host.workbench`。 |
| `openFilesystem(id, context)`                                                | 打开本插件的文件系统入口；需要 `host.filesystem`。                                                                                                                                                                                                                          |
| `getPlanCapabilities(connectionId)`                                          | 读取宿主与该连接能提供什么计划能力；需要 `host.plans:read`。                                                                                                                                                                                                                     |
| `explainPlan({ connectionId, database?, schema?, sql, mode, timeoutMs? })`   | 返回 `sql` 的估算执行计划；需要 `host.plans:read`。                                                                                                                                                                                                                      |
| `getTableMetadata({ connectionId, database?, schema?, table })`              | 读取已打开连接的窄化 table schema metadata；需要 `host.schema:read`。                                                                                                                                                                                                     |
| `queryData({ connectionId, database?, schema?, sql, maxRows?, timeoutMs? })` | 在用户授权给插件的连接上执行一条只读 SQL；需要 `host.data:read`。                                                                                                                                                                                                                 |
| `capabilities`                                                               | 来自 init 消息的 `{ downloadFile, planApi, schemaMetadataApi, dataApi, storage, ai, aiRecommendations, clipboardWrite, clipboardRead, floating, workbenchTitle }`。字段缺失或为 `false` 表示当前宿主不支持该组 Host API，应据此关闭功能而不是用真实请求试探。                                         |
| `ai.openConversation({ title, prompt, context, send?, mode? })`              | 在内置 AI 面板新建插件对话；`send` 默认 `false`，`mode` 默认 `ask`。`mode: "agent"` 会在 context 包含已打开插件连接时使用实时插件工具；需要 `host.ai`，并以 `capabilities.ai` 广播。                                                                                                                       |
| `ai.setRecommendations({ context, items })`                                  | 覆盖当前工作台的默认推荐问题并立即刷新全局 AI 面板；每次最多 5 条，支持 `{{path.to.value}}` 占位符；需要 `host.ai` 和 `capabilities.aiRecommendations`。                                                                                                                                            |
| `ai.clearRecommendations()`                                                  | 清空当前工作台的推荐问题；需要 `host.ai` 和 `capabilities.aiRecommendations`。                                                                                                                                                                                               |
| `onInit(fn)` / `onEvent(fn)`                                                 | 监听初始化、环境变化或后端事件；转发后端事件需要 `host.events`。                                                                                                                                                                                                                     |
| `storage`                                                                    | 本插件独享的持久化键值状态；需要 `host.storage`；见下文。                                                                                                                                                                                                                        |
| `fileTransfer`                                                               | 本地文件流式传输、下载与（桌面端）系统拖放；见下文。                                                                                                                                                                                                                                  |
| `clipboard`                                                                  | 沙箱内的系统剪贴板访问；见下文。                                                                                                                                                                                                                                            |
| `floating`                                                                   | 打开并控制桌面浮动小组件窗口（仅桌面宿主）；见[浮动窗口](#浮动窗口)。                                                                                                                                                                                                                       |

调用失败会以 Promise rejection 返回，处理时应展示可理解的错误并允许重试。通过 `request` 调用的宿主方法、参数和返回值以当前 Host API 版本为准，不要调用未在协议中声明的 DBX 内部函数。

`readAsset` 的路径相对于 `ui.root`，所以这里的 `assets/empty-state.svg` 对应包内 `ui/assets/empty-state.svg`。资源不用时调用 `URL.revokeObjectURL(assetUrl)`；组件销毁时调用 `onContext`/`onEvent` 等返回的取消订阅函数，避免重复监听。

### 估算执行计划

插件可以在不自带驱动、连接池和凭据的前提下读取一条查询的**估算**执行计划。插件只传自己的原始 SQL 和连接引用；DBX 用自己已有的方言规则生成 `EXPLAIN` 语句、应用与自身计划视图完全相同的只读安全校验，并在宿主连接上执行。插件只拿到原始计划，由自己完成解析、归一化和可视化。

```json
{
  "permissions": ["host.plans:read"]
}
```

```js
if (window.dbxPlugin.capabilities.planApi) {
  const capabilities = await window.dbxPlugin.getPlanCapabilities(connectionId);
  if (capabilities.supports.estimatedPlan) {
    const plan = await window.dbxPlugin.explainPlan({ connectionId, database, sql, mode: "estimated", timeoutMs: 15000 });
    render(plan.rawPlan, plan.format);
  }
}
```

`getPlanCapabilities(connectionId)` 返回 `{ dbType, dbVersion?, supports: { estimatedPlan }, limits: { maxTimeoutMs, maxPlanBytes } }`：

* `supports.estimatedPlan` 为 `false` 表示该连接的方言在 DBX 中没有估算计划路径；应直接禁用功能，而不是调用 `explainPlan`。
* `limits.maxTimeoutMs` 已经考虑该连接自身的查询超时，请求里的 `timeoutMs` 会被夹到该上限；`limits.maxPlanBytes` 限制计划负载大小。
* `dbVersion` 只在 DBX 已经知道该连接产品版本时才存在；宿主不会为了插件去探测服务器。
* 该调用只读取已保存的连接配置，不会建立连接；但连接必须已经打开：已保存但当前已断开的连接会被拒绝，而不是替插件重新建连。

`explainPlan(request)` 返回 `{ dbType, dbVersion?, format, rawPlan, truncated, warnings }`：

* `format` 为 `json`、`xml`（SQL Server `ShowPlanXML`）或 `text`。`format` 为 `json` 时 `rawPlan` 是已解析的 JSON 文档，其余情况是计划文本。
* `warnings` 可能包含：`plan_not_json`（服务端返回的内容不是 JSON，负载会如实标记为 `text`，而不是假装可解析）、`plan_truncated`（宿主为遵守 `maxPlanBytes` 截断了计划）、`plan_rows_truncated`（驱动达到计划行数上限）。只要发生过任一截断，`truncated` 都是 `true`。

计划 API 的边界：

* **只做估算计划。** `mode` 必须是 `"estimated"`，其它取值一律拒绝。真实执行计划（`EXPLAIN ANALYZE`、`SET STATISTICS XML`）会真正执行语句，不属于本 API。
* **插件不能自己提供要执行的 SQL。** 只接受原始 `sql`，`EXPLAIN` 语句完全由宿主生成，因此插件无法传入 `EXPLAIN` 文本、驱动命令或执行模式。
* **只允许只读目标。** DBX 自身计划视图使用的同一道校验会拒绝多语句、DDL、DML 和危险关键字。Oracle 是唯一例外：DBX 也会为它规划 DML，因为 `EXPLAIN PLAN FOR` 并不会执行该语句。
* **不涉及凭据，也不返回结果集。** 返回体只有计划和元信息，密码、凭据、连接串、驱动内部结构都不会越过这条边界。
* **连接必须已经打开。** DBX 不会替插件建立连接，也不会为插件创建连接。`getPlanCapabilities` 与 `explainPlan` 对「已保存但已断开」的连接都会以 `Connection is not open` 拒绝；只有 DBX 当前已经打开的连接才能规划。

判断能力时请读 `dbxPlugin.capabilities.planApi`（旧宿主不会带这个字段），再用 `getPlanCapabilities` 确认这条连接是否支持，最后才调用 `explainPlan`。自行拼 `EXPLAIN` 文本既没必要，也不会被接受。

计划 API 属于 Host API 1.2。无法离开它工作的插件应在 manifest 里声明下限：`"engines": { "host_api": "^1.2" }`。manifest 里的版本范围是兼容下限，`capabilities.planApi` 是运行期探测，两者都保留。

### Table Schema Metadata（表结构元数据）

插件可以读取当前 DBX 已打开连接中某个 table 的窄化 schema metadata，而不需要拥有驱动、连接池、凭据或自己拼 SQL。请求复用现有的 `PluginTableContext`，只允许 table identity：

```json
{
  "permissions": ["host.schema:read"]
}
```

```js
if (window.dbxPlugin.capabilities.schemaMetadataApi) {
  const metadata = await window.dbxPlugin.getTableMetadata({
    connectionId,
    database,
    schema,
    table: "users",
  });
  for (const column of metadata.columns) {
    renderColumn(column.name, column.dataType, column.nullable);
  }
}
```

`getTableMetadata({ connectionId, database?, schema?, table })` 返回：

```json
{
  "columns": [
    {
      "name": "id",
      "dataType": "integer",
      "nullable": false,
      "precision": 32,
      "default": "nextval('users_id_seq'::regclass)"
    }
  ],
  "fieldCapabilities": {
    "length": "supported",
    "precision": "supported",
    "scale": "supported",
    "default": "supported"
  }
}
```

* `columns` 只包含 `name`、`dataType`、`nullable`、可选的 `length`、`precision`、`scale` 和 `default`。不会返回 comment、key/index、credential、连接串、驱动内部对象或任意 SQL 结果。
* 可选值在没有值时保持 `null` 或直接省略，绝不会被伪造为 `0` 或空字符串。`fieldCapabilities` 用 `supported`、`unsupported`、`unknown` 区分提供方支持、明确不支持和宿主没有可靠 provenance 的情况；`unknown` 不应被插件当成支持。
* `database` 和 `schema` 可选；不可用时省略，不要把空字符串当作 public identity。`connectionId` 和 `table` 必须是非空且不超过 256 个字符的 identity 值。
* 这是只读 API。DBX 只复用已打开的 Host connection/session，禁止插件隐式重连、创建连接池或执行任意 SQL。已保存但当前断开的连接会以 `Connection is not open` 拒绝；请求的 database 没有对应已打开 session 时也会拒绝，而不是切换到别的连接。

该 API 属于 Host API 1.3。无法离开它工作的插件应声明：`"engines": { "host_api": "^1.3" }`；同时仍应读取 `capabilities.schemaMetadataApi`，因为旧宿主不会提供该字段。没有 `host.schema:read` 时，宿主会在请求到达 backend 前拒绝调用。

### 只读数据查询

插件可以在用户授权给它的 DBX 连接上执行一条只读 SQL，而不需要驱动、连接池、凭据或连接串。声明权限，并按能力位决定是否调用：

```json
{
  "engines": { "host_api": "^1.4" },
  "permissions": ["host.data:read"]
}
```

```js
if (window.dbxPlugin.capabilities.dataApi) {
  const result = await window.dbxPlugin.queryData({
    connectionId,
    database,
    sql: "SELECT status, count(*) AS total FROM orders GROUP BY status",
    maxRows: 200,
  });
  renderChart(result.columns, result.rows);
}
```

`queryData({ connectionId, database?, schema?, sql, maxRows?, timeoutMs? })` 返回：

```json
{
  "dbType": "postgres",
  "columns": [
    { "name": "status", "dataType": "text" },
    { "name": "total", "dataType": "int8" }
  ],
  "rows": [
    ["paid", 1204],
    ["refunded", 37]
  ],
  "truncated": false,
  "elapsedMs": 12
}
```

* **按连接征求同意。** 插件第一次查询某个连接时，宿主会弹窗写明插件名和连接名。用户允许后授权会持久保存，并显示在「插件中心 → 已安装」该插件下，用户可随时撤销；拒绝会在本次工作台会话内被记住。插件无法自行授权。
* **只读且单条语句。** 宿主只接受一条被 DBX SQL 风险分类器判定为只读的语句——与 MCP 只读模式和 AI Agent 使用同一道闸门。写入、DDL、`SELECT … FOR UPDATE`、多条语句以及 `USE` 这类切换会话数据库的语句都会被拒绝；请改用 `database` 参数。
* **只用已打开的连接。** 与执行计划和表结构元数据 API 一样，DBX 不会替插件发起连接：已保存但未打开的连接会以 `Connection is not open` 拒绝。Redis、MongoDB、搜索引擎等非 SQL 连接不提供该能力。
* **有上限。** `maxRows` 默认 500、最多 5000；序列化后的行数据上限 8 MiB；`timeoutMs` 会被限制在连接自身超时和 60 秒上限之内。只要有行被截断，`truncated` 就为 `true`。
* **不含凭据。** 响应只包含列和行。授权被撤销后，调用会返回以 `PLUGIN_DATA_ACCESS_NOT_GRANTED` 开头的错误，下一次查询会重新征求用户同意。

该 API 属于 Host API 1.4。请同时保留 `engines.host_api` 下限和 `capabilities.dataApi` 运行时检查；无法弹出确认框的 Web 宿主会拒绝请求，而不会静默授权。

### 连接信息读取

需要展示连接下拉框或根据连接执行特定任务（例如通过 `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` 在里面不可用。宿主代为桥接系统剪贴板：

* `window.dbxPlugin.copy(text)` / `window.dbxPlugin.clipboard.writeText(text)` 写系统剪贴板。两者走同一条无权限门控的桥接方法：写操作的输入就是用户数据本身，是低风险的一半。
* `window.dbxPlugin.clipboard.readText()` 读系统剪贴板。它需要 `host.clipboard:read` 权限——读取会把用户数据（密码、令牌）交给插件代码，且没有后续用户交互兜底——属于 Host API 1.3：离开它无法工作的插件应声明 `"engines": { "host_api": "^1.3" }`。

读剪贴板请看 init 能力而不是试探：`capabilities.clipboardRead`（写对应 `capabilities.clipboardWrite`）在旧宿主上缺失即为 falsy。读被拒绝（缺权限、Web 宿主没有原生剪贴板访问）时 Promise 会 reject——请优雅降级，例如回落到键盘粘贴路径，而不是让交互直接失败。

```js
await window.dbxPlugin.ready;
const canRead = !!window.dbxPlugin.capabilities.clipboardRead;
const canWrite = !!window.dbxPlugin.capabilities.clipboardWrite;

async function pasteFromClipboard() {
  if (!canRead) return null;
  try {
    return await window.dbxPlugin.clipboard.readText();
  } catch {
    // 缺权限或读取失败：保留键盘粘贴路径。
    return null;
  }
}
```

### 文件传输与系统拖放

桌面端宿主通过 `window.dbxPlugin.fileTransfer` 把本地文件流式接入插件沙箱。句柄只在用户明确同意后打开：原生打开/保存对话框（`pick` / `beginSave`），或从系统拖放到本插件工作台区域内的文件（`onDrop`）。传输按分块进行，多 GB 文件也不会整体载入内存。

```js
const fileTransfer = window.dbxPlugin.fileTransfer;
if (!fileTransfer) {
  // Web 宿主：回退到 <input type="file"> 和 sidecar 落盘。
}

// 上传：用户在原生对话框选择文件，插件把分块推给自己的 sidecar。
const { files } = await fileTransfer.pick({ multiple: true });
for (const file of files) {
  let offset = 0;
  for (;;) {
    const chunk = await fileTransfer.read(file.handleId, offset, 256 * 1024);
    await uploadChunk(file.name, offset, chunk.dataBase64); // 你自己的 RPC
    if (chunk.eof) break;
    offset += chunk.length;
  }
  await fileTransfer.cancel(file.handleId);
}

// 下载：打开保存目标，分块写入，最后 finish 落盘。
const target = await fileTransfer.beginSave({ name: "export.csv", size });
await fileTransfer.write(target.handleId, 0, bytes);
await fileTransfer.finish(target.handleId);

// 系统拖放（仅桌面端）：被拖入的文件以已打开的句柄送达。
const offDrop = fileTransfer.onDrop((files) => uploadAll(files));
const offDrag = fileTransfer.onDragState((active) => showDropOverlay(active));
```

| API                                        | 用途                                                                                                                                                                                                                                                                                         |
| ------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `pick({ multiple })`                       | 原生打开对话框；返回已打开的读句柄 `{ handleId, name, size, contentType }`。                                                                                                                                                                                                                                 |
| `read(handleId, offset, length?)`          | 从读句柄流式读取一个分块 `{ dataBase64, length, eof }`。                                                                                                                                                                                                                                                |
| `beginSave({ name, contentType?, size? })` | 原生保存对话框 + 写句柄；返回 `{ handleId, chunkBytes }`，用户取消时为 `null`。                                                                                                                                                                                                                                 |
| `write(handleId, offset, data)`            | 写入一个分块（二进制 transfer 或 base64）；返回 `{ written, nextOffset }`。                                                                                                                                                                                                                                |
| `finish(handleId)` / `cancel(handleId)`    | 冲洗并关闭写句柄 / 丢弃任意句柄。                                                                                                                                                                                                                                                                         |
| `onDrop(fn)`                               | 拖入本工作台的文件以读句柄送达；拖入文件夹会被展开为其中包含的文件（`.git` 等点开头文件与目录会被跳过），每个文件额外携带 `relativePath`（以 `/` 分隔、以拖入文件夹自身名字开头的路径，如 `folder/nested/a.csv`），可用于还原拖入的目录结构，也能区分不同拖入根目录下的同名文件。监听器签名为 `(files, { dropId, truncated })`：`dropId` 标识本次拖入的整组条目（取消与进度记账的分组键），`truncated` 为 `true` 表示展开因 2000 文件或 8 层深度上限被截断。 |
| `onDragState(fn)`                          | 是否有系统拖拽悬在本工作台上方；用它驱动拖放遮罩。                                                                                                                                                                                                                                                                  |

桌面端与网页端的行为差异：请以 init 消息里 additive 的 `capabilities.fileTransfer` 标志位（`pick`、`beginSave`、`read`、`drop`、`folderExpansion`）做能力探测，不要靠试调用。桌面端 `pick` / `beginSave` 走原生对话框、`onDrop` 接收系统拖放（句柄由宿主惰性打开，读时分块流式，因此超大文件夹与大文件都不会占满句柄；选取文件数超过共享句柄池时自动降级为惰性条目，不会静默丢弃）。网页端 `pick` 回退到浏览器文件选择器、`beginSave` 回退为内存缓冲加浏览器下载、`onDrop` / `onDragState` 不会触发（`drop`/`folderExpansion` 为 `false`）、句柄也没有 `relativePath`。`onDrop` 只在拖放落点位于本插件工作台区域内时触发；落点在宿主其他区域时保持宿主自身的行为。

### 持久化 UI 状态

沙箱是 opaque origin，`localStorage` 在里面会直接抛错。需要跨重载或跨应用重启保留状态的工作台（最近访问的目录、面板尺寸、草稿等）改用 `window.dbxPlugin.storage`。它需要 `host.storage` 权限，并以 init 消息里的 `capabilities.storage` 广播；使用前请同时探测这两者。

```js
if (window.dbxPlugin.capabilities.storage) {
  const storage = window.dbxPlugin.storage;
  await storage.set("layout", { sidebar: "collapsed", sort: "name" });
  const layout = await storage.get("layout"); // { sidebar: "collapsed", sort: "name" }，未设置时为 null
  await storage.delete("layout");
}
```

| API               | 用途                                     |
| ----------------- | -------------------------------------- |
| `get(key)`        | 返回存储的 JSON 值；键未设置时返回 `null`。           |
| `set(key, value)` | 持久化任意 JSON 值（`undefined` 归一化为 `null`）。 |
| `delete(key)`     | 删除一个键；删除不存在的键同样成功。                     |

键为不超过 256 字符的字符串；单个值序列化后上限 256 KiB，整库上限 1 MiB——这是 UI 状态存储，不是数据落盘通道。数据保存在插件自己的 `plugin-data/<id>` 目录里，按插件隔离，版本升级和卸载后都保留。大批量数据、缓存以及 Sidecar 产生的任何文件请放进 Sidecar 的数据目录（见 [原生 Sidecar 和 RPC](#原生-sidecar-和-rpc)）。Web 宿主上同一 API 由浏览器本地存储支撑，两端代码完全一致。

### 在 DBX AI 中分析插件数据

在 manifest 中声明 `"permissions": ["host.ai"]`。插件可以直接打开全局 AI 面板，也可以为每个 Workbench 注册快捷推荐问题。两种能力都沿用 DBX 的模型选择、历史、追问、停止和导出功能。

#### 声明 Workbench 的默认推荐问题

在 `workbench` contribution 下增加可选的 `ai.recommendations`。`label` 显示在 DBX 全局 AI 面板，`prompt` 是点击后发送给 AI 的文本；`order` 越小越靠前。

```json
{
  "type": "workbench",
  "id": "com.example.files.main",
  "label": "Files",
  "ai": {
    "recommendations": [
      {
        "id": "health",
        "label": "检查 {{resource.name}} 健康状态",
        "prompt": "分析 {{resource.kind}}/{{resource.name}} 的健康状态和风险",
        "order": 10
      }
    ]
  }
}
```

占位符从当前 Workbench context 读取，支持对象属性和数组下标，例如 `{{resource.name}}`、`{{items.0.status}}`。无法解析的占位符会使该条推荐被隐藏；`__proto__`、`prototype` 和 `constructor` 等路径会被拒绝。推荐问题最多显示 5 条。

#### 根据资源变化更新推荐问题

插件切换页面或资源时，可以提交运行时推荐问题。运行时 `items` 会覆盖 manifest 默认值；传入空数组表示清空当前推荐。运行时 context 通常只需要提交资源字段，Host 会保留当前 Workbench 的 `connectionId`、数据库和实例标识，确保点击推荐问题后仍绑定到正确的插件连接。

```js
function updateRecommendations(resource) {
  if (!dbxPlugin.capabilities.aiRecommendations) return;

  return dbxPlugin.ai.setRecommendations({
    context: { resource },
    items: [
      {
        id: "health",
        label: `检查 ${resource.name} 健康状态`,
        prompt: "分析 {{resource.kind}}/{{resource.name}} 的健康状态和风险",
        order: 10,
      },
      {
        id: "events",
        label: "查看最近事件",
        prompt: "列出这个资源最近的重要事件并给出处理建议",
        order: 20,
      },
    ],
  });
}

await dbxPlugin.ai.clearRecommendations();
```

推荐问题显示在 DBX 全局 AI 对话窗口中，而不是插件页面内新增一个 AI 入口。用户点击后，DBX 会使用当前推荐文本创建新的 Agent 对话并立即发送；Agent 会通过当前已打开的插件连接调用插件工具获取实时数据。推荐列表本身只属于当前 Workbench 上下文，不会写入对话历史；用户点击后发送的实际问题会正常保存。

#### 直接打开 Ask 或 Agent 对话

`ai.openConversation` 仍适合插件按钮或其他明确动作。默认 `mode` 为 `ask`，宿主会复制插件提供的 JSON 快照；快照会随对话保存，后续资源刷新不会改变已经创建的对话。需要实时工具时显式使用 `mode: "agent"`，并在 context 中提供当前已打开插件连接的 `connectionId`：

```js
await dbxPlugin.ai.openConversation({
  title: "Watchlist analysis",
  prompt: "Compare the current quotes and explain risks and missing data.",
  context: {
    connectionId,
    resource: { kind: "watchlist", name: "primary" },
    dataTimestamp,
  },
  mode: "agent",
  send: true,
});
```

`title` 最多 200 字符，`prompt` 最多 32000 字符，`context` 必须是纯 JSON 对象，上限 2 MiB。Agent 只能使用宿主为该插件连接公开的工具；插件不会收到模型回复或模型配置，也不能借此获得数据库执行能力。旧插件不声明推荐问题时仍可正常使用原有 AI API；旧宿主缺少新增 capability 字段时，插件应关闭对应入口并继续提供其他功能。

### 浮动窗口

浮动窗口把插件的某个 workbench 放进一个独立的无边框、置顶桌面窗口，而不是塞在 DBX 外壳里——这正是小型常驻小组件需要的表面（播放器胶囊、会话状态徽章、快捷选择器）。它会盖在其他应用之上，可以被拖到桌面任意位置。在屏幕中间松手就停在落点；落到或越过工作区边缘（不管怼得多用力）都会齐着那条边吸附并停靠。停靠不会在落下的那一刻就把小组件藏起来：它先保持整颗可读的胶囊，光标离开约 2.5 秒后才滑到屏外只留一条细边，光标碰到细边就滑回来。重新打开一个上次吸在边上的小组件也是这套时序（先胶囊、后收起）；刚唤出的小组件即使默认落点就贴着边，也只停在胶囊态。使用前先判断 `capabilities.floating`：Web 宿主没有窗口，桌面宿主也可能不支持窗口管理。

```js
if (window.dbxPlugin.capabilities.floating) {
  // 在标签或 dock 工作台里：打开小组件。
  // 一个（插件, workbench contribution）只会有一个窗口，重复调用只是把已有窗口带到前台。
  const { windowId } = await window.dbxPlugin.floating.open({
    contributionId: "capsule",
    title: "Now playing",
    width: 260,
    height: 60,
    context: { queueId: currentQueueId },
  });
}

// 在小组件内部（context.surface === "window"）：控制自己所在的窗口。
grip.addEventListener("pointerdown", (event) => {
  grip.setPointerCapture(event.pointerId);
  dragging = false;
});
grip.addEventListener("pointermove", async (event) => {
  if (dragging || Math.hypot(event.clientX - downX, event.clientY - downY) < 4) return;
  dragging = true;
  // 从这一刻起窗口归宿主移动：它自己读原生光标，不需要插件逐帧上报。
  await window.dbxPlugin.floating.beginDrag();
});
grip.addEventListener("pointerup", async () => {
  if (!dragging) return;
  dragging = false;
  await window.dbxPlugin.floating.endDrag(); // 吸附并记住位置
});
```

| API                               | 作用                                                                                                                                                                                                                                                            |
| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `floating.open(options)`          | 为本插件的某个 `workbench` contribution 打开（或前置）浮动窗口。`options`：`contributionId`（必填）、`title`、逻辑像素的 `width`/`height`、物理屏幕像素的 `x`/`y`、`alwaysOnTop`（默认 `true`）、`skipTaskbar`（默认 `true`）、`resizable`（默认 `false`）、`context`。返回 `{ windowId, reused }`；需要 `host.workbench`。 |
| `floating.close(contributionId?)` | 关闭本插件的浮动窗口——指定某个 contribution，或省略 id 关闭本插件的全部浮动窗口。在小组件内部不带参数调用即关闭自己。                                                                                                                                                                                          |
| `floating.beginDrag()`            | 把窗口交给宿主的拖动循环。在 `pointermove` 里、自己的拖动阈值越过之后调用（此时按键还按住）。之后由宿主自己读原生光标，插件没有逐帧调用要做。                                                                                                                                                                                |
| `floating.endDrag({ snap? })`     | 结束拖动。`snap`（默认 `true`）时宿主把窗口齐着它到达或越过的工作区边缘吸附并停靠在那（先保持整颗胶囊，光标离开一会儿才收起到屏外、只留一条悬停细边），落点在屏幕中间则完整夹回屏内；位置与停靠边都会记住，供下次打开使用。返回物理像素的最终矩形。实践中可不调：宿主在光标静止后也会自己收尾，收不到 pointerup 的小组件不会把窗口一直粘在光标上。                                                                         |
| `floating.setSize(width, height)` | 以逻辑像素调整所在窗口大小；宿主会夹到自己的上下限内。                                                                                                                                                                                                                                   |

小组件决定拖动**何时**开始（哪个元素是拖拽把手、指针要先走多远）；窗口由宿主移动，吸附与位置记忆也归宿主。刻意没有逐帧的桥调用：宿主轮询原生光标，webview 忙不过来时小组件也照样贴着指针走，光标静止即收尾，pointerup 丢了也不会一直拖下去。几何类调用（`beginDrag`/`endDrag`/`setSize`）只在浮动窗口内部有效，在其他表面调用会被拒绝，因此标签页永远无法移动它不在其中的窗口。

几条规则让这个表面的行为可预期：

* **一个 contribution 只有一个窗口。** 窗口 id 由插件 id 和 contribution id 推导，所以 `floating.open` 是幂等的：第二次调用会把已开的窗口带到前台，而不是叠出更多小组件。
* **窗口是不透明的。** 宿主自己不画任何外壳，用户看到的就是小组件本身：给页面一个背景加圆角，配合宿主按内容收好的窗口尺寸，读起来就是浮在桌面上的一张卡片。刻意不提供逐像素透明（带镂空角的塑形小组件）——透明区域还收不收鼠标事件各平台不一致，而一个要给用户拖的小组件必须处处点得动。
* **外壳导航会被转发。** 浮动窗口没有标签栏、dock 和面板，所以从它发起的 `openWorkbench` 和 `openFilesystem` 会在 DBX 主窗口里打开，主窗口会自行前置。`executeCommand` 在这个表面完全不提供：每个命令动作都要打开一个外壳表面，调用会成功返回但什么都看不见。
* **位置跨重启保留**，按插件和 contribution 分别记录；当记录的位置已经不在任何已连接的显示器上时会被丢弃（拔掉显示器后退化为工作区默认位置，而不是开到屏幕外）。
* **小组件就是一个完整工作台。** 它拿到和标签页相同的桥、权限、上下文契约和 sidecar RPC；`context.surface` 为 `"window"`，`context.workbenchId` 为窗口 id。它启动的是你 UI 的另一份实例，所以共享状态必须走 sidecar（或 `storage`），不能指望模块内存。

### 主题、布局和静态资源

DBX 将主题 tokens 注入文档根元素，并更新 `data-dbx-theme`。自定义组件库（包括 Svelte、shadcn-svelte）应使用这些变量，不要依赖父页面 CSS 自动穿透 iframe：

```css
body {
  margin: 0;
  background: var(--color-background, #fff);
  color: var(--color-foreground, #18181b);
}
button {
  background: var(--color-primary, #2563eb);
  color: var(--color-primary-foreground, #fff);
  cursor: pointer;
}
```

使用 `window.addEventListener("dbx-plugin-env", handler)` 响应语言或主题变化。先读取 `dbxPlugin.locale`/`theme` 初始化，再在事件中刷新自己的状态。DBX 提供轻量 `dbx-btn`、`dbx-input` 等样式，但开发宿主不模拟完整组件 kit，自定义 token 样式在两者中更一致。

#### 主题负载携带什么

env 消息的 `theme` 结构为 `{ appearance, tokens, editor? }`。`appearance` 是 `"light" | "dark"`；`tokens` 是按前缀扫取的根节点设计变量（`--color-*`、`--radius-*`、`--font-*`）；`editor` 是无 CSS 令牌载体的 SQL 编辑器设置结构化快照。投递矩阵：

| DBX 设置             | 投递形式                                            |
| ------------------ | ----------------------------------------------- |
| 浅色/深色/跟随系统         | `theme.appearance`                              |
| 调色板 + 自定义配色        | `--color-*` tokens                              |
| 圆角风格               | `--radius-*` tokens                             |
| UI 字体              | `--font-sans` token                             |
| 编辑器（等宽）字体          | `--font-mono` token + `theme.editor.fontFamily` |
| 编辑器字号              | `theme.editor.fontSize`                         |
| SQL 编辑器语法主题        | `theme.editor.theme`                            |
| UI 缩放（宿主窗口级）       | 不投递——插件自管缩放                                     |
| 数据网格字体/类型配色、编辑器背景图 | 不投递——DB 域（`--dbx-*` 不在扫取范围）                     |

字体"字号"类关注点归插件所有：宿主不规定你的字号；如适合你的 UI，可把 `theme.editor.fontSize` 当默认值消费。所有负载变化都经 env 通道实时推送——字体族变更走主题修订号，字号/语法主题走显式监听——无需刷新。

UI 必须构建为包内静态资源，不要把 Vite 开发服务器或 CDN 的脚本地址写入发行包。宿主把入口及资源转换成沙箱可加载内容；CSP 报错时检查构建产物是否存在、路径是否落在 `ui.root` 内，并使用匹配版本的 CLI 复现。用插件自己的对话框组件处理删除/重命名，不要依赖沙箱中的原生 `alert`/`confirm`/`prompt`。

#### 懒加载与代码分割

宿主会把入口脚本内联进沙箱文档，`ui/` 下的其余资源在运行时通过 `dbx-plugin` 协议保持可寻址：`dbx-plugin://localhost/<插件id>/<ui.root内相对路径>`（WebView2 上映射为 `http://dbx-plugin.localhost/<插件id>/…`）。宿主会把平台对应的形式注入为沙箱文档的 `<base>`，并放行资源类 CSP，因此打包后的插件可以正常动态 `import()` 代码分割的 chunk——前提是构建产物使用**相对**资源地址：Vite 设 `base: "./"`、webpack 设 `publicPath: "./"`，让 chunk 和 CSS 资源地址基于注入的 base 解析而不是宿主 origin。绝对路径 `/assets/…` 携带不了插件 id，会 404。该协议只读文件、限定在每个插件的 `ui.root` 内，且插件更新后不会沿用旧缓存。

#### 内联后的样式表

宿主会读取所有指向 `ui.root` 内部的 `<link rel="stylesheet">`，替换成带同样属性的 `<style>` 元素，因此插件运行时仍然能找到自己的样式表（`document.querySelectorAll("style[data-skin-css]")`）并切换它们。有一处差异需要注意：`disabled` 在 `<style>` 元素上不会像 `<link>` 那样从内容属性反映到 IDL，宿主重新序列化时也无法用 IDL 属性把它带过去。所以自带 disabled 样式表的插件必须在加载后自己重新设置 `sheet.disabled = true`；其余属性（id、`data-*` 标记、`media`）都会原样保留。

#### 全屏

沙箱里的 `requestFullscreen()` 取决于框架的 Permissions-Policy，宿主已经做了委派（`allow="clipboard-write; fullscreen"`）。插件无法自己授予该策略，因此应当用 `document.fullscreenEnabled` 做特性检测，为 `false` 时退化成页面内的最大化布局——否则在不委派该策略的旧宿主上，每次全屏请求都会被拒绝。

## 原生 Sidecar 和 RPC

只有浏览器沙箱无法完成的能力才需要原生后端，例如 S3/SSH 协议、系统凭据、长连接或高性能计算。Sidecar 不是操作系统沙箱，会以当前用户权限运行。

Rust 和 Go SDK 都实现 Sidecar Protocol v1：

```json
{"jsonrpc":"2.0","id":1,"method":"plugin/initialize","params":{"host":{"protocolVersions":[1]}}}
{"jsonrpc":"2.0","id":2,"method":"objects/list","params":{"prefix":"docs/"}}
```

响应必须使用相同的 `id`，成功返回 `result`，失败返回 JSON-RPC `error`。初始化响应至少包含 `protocolVersion`、`capabilities` 和 `plugin: { id, version }`；ID 和版本必须与 Manifest 一致，协议必须在双方支持范围内，否则会出现 `Sidecar identity or protocol does not match manifest`。初始化 capabilities 描述后端能力，不等于 Manifest 的 Host 权限列表。

```rust
use dbx_plugin_sdk::{PluginEmitter, PluginError, PluginHandler, PluginMetadata, PluginServer, RequestContext};
use serde_json::{json, Value};

struct Plugin;

impl PluginHandler for Plugin {
    fn handle(&self, _context: RequestContext, method: &str, _params: Value, _emitter: &PluginEmitter) -> Result<Value, PluginError> {
        match method {
            "example/ping" => Ok(json!({ "ok": true })),
            _ => Err(PluginError::method_not_found(method)),
        }
    }
}

fn main() -> std::io::Result<()> {
    let metadata = PluginMetadata::new("com.example.files", env!("CARGO_PKG_VERSION"));
    PluginServer::new(metadata, Plugin).serve()
}
```

Sidecar 约束：stdout 只能输出协议消息，日志写 stderr；请求 ID 必须支持并发关联；连接和断开应幂等；长任务应设计超时、取消和分块确认；不要在事件、context 或错误消息中泄露 Secret。

每个 Sidecar 启动时都会带上 `DBX_PLUGIN_DATA_DIR` 环境变量，指向为本插件保留的持久化目录（`plugin-data/<id>`，与安装包目录平级）。它在版本升级和卸载后都保留，插件的所有 Sidecar 运行共享；缓存、Token、后端管理的数据库等应放在这里——首次写入前需自行创建目录，宿主不会预创建。Go SDK 提供了 `dbxpluginsdk.DataDir()` / `EnsureDataDir()` 封装。工作台 UI 需要小体量持久化时走同一目录里的 `host.storage`（见 [持久化 UI 状态](#持久化-ui-状态)），不要直接读写这些文件。

上例可替换 Rust 模板的 `backend/src/main.rs`，同时让 Cargo 包版本与 Manifest 一致。Go 使用模板生成的 `dbxpluginsdk.NewServer(metadata, handler).Serve()`；当前 Go SDK 支持 JSONL，Rust SDK 还支持 framed transport。不要仅修改 Manifest transport 就认为 Go 已具备二进制帧处理能力。完整接口见 [Rust SDK](https://github.com/t8y2/dbx/tree/main/plugins/sdk/rust/dbx-plugin-sdk) 和 [Go SDK](https://github.com/t8y2/dbx/tree/main/plugins/sdk/go/dbx-plugin-sdk)。

## 为 DBX AI 助手提供工具

带原生后端的插件可以暴露工具，供 DBX 内置 AI 助手在 Agent 模式下调用。这样「订单接口为什么变慢了？」这类问题就能把数据库工具和插件提供的指标、日志或 Shell 能力结合起来。Sidecar 需要实现两个方法：

* `mcp/tools` 以 MCP 工具格式返回 `{ "tools": [{ "name", "description", "inputSchema", "annotations"? }] }`。DBX 会传入正在绑定的 `{ "connectionId" }`，插件可以据此在只读连接上隐藏写工具。
* `mcp/call` 接收 `{ "tool", "arguments", "lifecycle" }`，返回 MCP `CallToolResult`（`{ "content": [{ "type": "text", "text": "..." }], "isError": false }`）。`lifecycle` 与已打开连接的 `connection/connect` 载荷相同——凭据由宿主解析，运行端点已经过 SSH 或代理层——因此工具参数里永远不需要密钥。

宿主在此之上负责：

* **显式加入、可撤销。** 插件只有在用户于「插件中心 → 已安装 → 内置 AI 工具」中开启后才会贡献工具——仅安装不会暴露任何东西。同一面板会列出工具并标出哪些需要确认，开关可把某个插件永久关闭。manifest 中的 `mcp` 贡献声明 `ai_tools: false` 可让插件即使被用户开启也不进入该面。
* **只用已打开的连接。** 只为用户当前已打开的插件连接提供工具。连接由 DBX 绑定：模型看到的 schema 中会去掉 `connectionId` / `connectionName`；如果插件 schema 声明了 `connectionId`，宿主会在转发参数中注入绑定连接的 id。打开了多个连接时，模型通过额外的 `dbx_connection` 参数选择其一。
* **默认需要确认。** 只有声明了 `"annotations": { "readOnlyHint": true }` 的工具才会直接执行。其他调用都会暂停运行，并向用户展示将要转发的完整参数，由用户选择允许一次或拒绝；五分钟未响应视为拒绝。请为真正只读的工具加上标注，同时保留插件自身的防护（只读连接标志、两阶段确认等）——宿主确认拦截的是模型的误操作，不能替代插件自己的安全规则。
* **可移植的 schema。** 工具以 `<前缀>__<工具名>` 暴露（例如 `ssh__ssh_exec`），schema 会被收敛到所有支持的模型供应商都接受的子集：`type`、`description`、`properties`、`required`、`items`、字符串 `enum` 以及数值、长度和元素数量边界。参数名请只使用字母、数字和下划线。
* **结果有上限。** 单次调用 120 秒超时，结果在交给模型前会被压缩。长任务请作为后台任务启动并返回句柄，不要阻塞。

内置 Agent 会把工具输出当作不可信数据处理。结果里请返回事实而不是指令，也不要回显任何密钥。

同样的工具也会出现在外部 `dbx` MCP 服务器（Claude Code、Cursor 等）上，名称为 `dbx_<前缀>__<工具名>`，并受全局 MCP 设置（工具白名单 + 连接白名单）约束、绑定到插件已保存的连接。该面按作者声明加入：manifest 中的 `mcp` 贡献声明 `external_tools: true` 才会暴露；连接选择参数为 `dbx_connection`。

### 有状态工具的生命周期与失败语义

对于管理监听器、后台会话或可丢弃故障实验的插件，请将资源管理与工具发现分开：

* **发现只描述，显式调用才执行。** `mcp/tools` 不应创建插件自己的监听器、连接数据库或注入故障。宿主可能为了发现工具而启动 Sidecar，但这不代表允许启动插件的实际工作负载。外部客户端需要管理会话时，请提供明确的修改型 start/stop 工具，并限制会话数量、时长和清理时间。不要将生命周期变更标为只读。
* **执行时检查策略。** 发现阶段隐藏写工具不能代替授权。在 `mcp/call` 执行操作前，应验证宿主绑定的连接身份及本次调用可用的策略。故障实验插件应在连接只读或插件自身未开启故障注入时拒绝注入。这些是插件自己的检查，MCP 标注不会授予额外权限。内置 Agent 使用已打开连接的 lifecycle 快照，外部 MCP 后端则在调用时依据宿主保存的连接配置生成载荷。不要假定修改已保存连接后，运行中会话的配置就会刷新；若插件无法安全协调变更，应要求停止并重新启动会话。
* **如实报告不确定结果。** PostgreSQL `COMMIT` 已转发后发生超时或断连，不能证明事务已回滚。除非通过独立检查确定了结果，否则应报告事务结果为 `unknown`；不要自动重试可能已经提交的写操作。应区分注入的传输事件与数据库结果，分别验证已提交数据和锁清理。这是事务感知插件的设计模式，不是 DBX 提供的通用事务状态 API。

## 创建插件

直接安装预编译 CLI，不需要 Rust，也不需要克隆 DBX 源码：

```bash
npm install --global @dbx-app/plugin-cli
dbx-plugin --help
```

也可以直接使用 `npx`：

```bash
npx @dbx-app/plugin-cli create my-ui-plugin --template frontend
```

npm 包会自动选择当前平台的预编译二进制，并携带匹配版本的 Rust/Go 插件 SDK。纯前端插件不需要 Rust 或 Go；原生编译环境只用于构建插件自己的 Sidecar。

创建项目：

```bash
# 纯前端、跨平台
dbx-plugin create my-ui-plugin --template frontend

# Svelte + Vite、跨平台
dbx-plugin create my-svelte-plugin --template svelte

# Rust Sidecar + 前端
dbx-plugin create my-rust-plugin --template rust

# Go Sidecar + 前端
dbx-plugin create my-go-plugin --template go
```

四种模板的选择：`frontend` 适合原生 HTML 或自行接入 Vue/React 的静态 UI；`svelte` 自带 Svelte 5 + Vite；`rust` 和 `go` 额外生成原生 Sidecar。`--backend none|svelte|rust|go` 可以作为模板别名，`--language rust|go` 是原生项目兼容别名。脚本或 CI 中可以用 `--yes` 跳过交互向导：

```bash
dbx-plugin create my-plugin \
  --template svelte \
  --id com.example.my-plugin \
  --name "My Plugin" \
  --publisher example \
  --description "A DBX plugin." \
  --version 0.1.0 \
  --yes
```

Svelte 模板构建脚本会把 Vite 输出到 `ui/`，并在成功构建后输出 `DBX_UI_BUILD_SUCCESS`：

```bash
cd my-svelte-plugin
npm install
npm run build
dbx-plugin dev --port 5190
```

生成项目后，先在自己的插件源码仓库开发、测试和维护版本记录。不要为了发布一个普通插件而把整个源码目录复制进 `t8y2/dbx`。

## 构建候选包

在插件项目根目录执行：

```bash
dbx-plugin package .
```

该命令只生成**未签名审核候选包**：

```text
dist/<plugin-id>-<version>-<target>.dbxp
dist/<plugin-id>-<version>-<target>.artifact.json
```

前端-only 项目默认使用 `universal` target；Rust/Go 项目使用当前平台 target。`--output-dir` 可以改变输出目录，`--artifact-url` 可以写入发布后的 HTTPS 地址：

```bash
dbx-plugin package . --target universal --output-dir dist --artifact-url https://downloads.example.com/my-plugin.dbxp
```

CLI 会只打包 `dbx-plugin.toml` 的 `[package].include` 声明目录，并拒绝符号链接、越界路径、`.dbx-dev`、过大的文件和不安全的输出位置。不要把 `dist/` 或开发数据再次作为输入目录。

官方插件作者不创建或持有 DBX Store 私钥。生成的 GitHub Release 工作流会按目标平台构建候选包，并合并产生 `release-candidates.json`。

本地测试未签名包时，需要在插件中心显式开启“允许安装未签名开发包”。该选项不会放宽官方商店安装校验。

## 本地调试和验证

需要实时观察前端时，在插件项目根目录运行独立开发宿主；它不会启动 DBX 桌面端：

```bash
dbx-plugin dev --path . --port 5190
```

打开终端输出的 `http://127.0.0.1:5190/`。`--port 0` 或端口被占用时会选择可用端口，`--data-dir` 可以把开发数据移出项目目录：

```bash
dbx-plugin dev --path . --data-dir /tmp/my-plugin-dev
```

`dbx-plugin dev` 不会自动安装插件依赖；前端构建由 `dbx-plugin.toml` 的 `[dev]` 命令决定：

```toml
[dev]
ui_build = ["npm", "run", "build"]
ui_watch = ["npm", "run", "build:watch"]
```

配置 `ui_watch` 时，命令只有在一次完整成功构建后输出独立一行 `DBX_UI_BUILD_SUCCESS`，开发宿主才会重载 UI。不要在失败路径或无条件的退出钩子中输出这个标记。

调试页可以切换语言、主题、自动重载和日志面板。日志只保留内存中的最近 500 条，密码、Token 和 Manifest 中的 Secret 字段会脱敏，二进制和过大的值会省略或截断；开发目录 `.dbx-dev/` 可能包含明文凭据，必须加入 `.gitignore`，不要提交或共享。

需要从脚本读取脱敏日志时：

```bash
curl -sS 'http://127.0.0.1:5190/api/diagnostics?after=0&limit=100&level=error'
```

后续请求使用响应里的 `nextAfter` 和 `instanceId`。开发宿主只模拟受支持的 Host API 子集；安装、签名、真实 Secret Store、桌面端生命周期和生产权限必须再用 DBX 验证。

### 本地验收清单

* 关闭未使用的权限，确认 Manifest 中的权限与实际调用一致。
* 用空配置、错误凭据、超时、断网和 Sidecar 重启分别测试失败路径。
* 测试 DBX 浅色/深色主题和 `zh-CN`/`en`；不要依赖宿主替你翻译插件 UI。
* 测试重新连接、关闭工作台、重复打开同一工作台以及上下文为空的情况。
* 检查包内所有资源路径、可执行文件权限和最终包是否包含不应发布的文件。

## 提交官方商店的完整流程

### 第一步：发布源码和候选 Release

在插件自己的源码仓库创建版本 Tag 和 GitHub Release。Release 中应包含：

* 每个平台的未签名 `.dbxp` 候选包；
* 每个候选包对应的 `.artifact.json`；
* 汇总后的 `release-candidates.json`；
* Release Notes 和对应源码 Tag。

候选包可以放在插件源码仓库的 GitHub Releases、CDN 或对象存储中，但不要把 `.dbxp` 提交到 Git 历史。

### 第二步：让 dbx-store 自动创建候选 PR

`dbx-store` 的同步 Workflow 会定期检查已在 `automation/plugin-sources.json` 注册且 `autoUpdate: true` 的公开插件仓库。它读取最新 Release 的 `release-candidates.json`，创建或更新唯一的：

```text
candidates/<plugin-id>.json
```

因此，已启用自动更新的插件作者**不需要每个版本手工创建 Issue 或 PR**，也不需要在插件仓库配置商店签名 Secret。插件仓库的 Release、候选 metadata 和可访问的源码 Tag 是同步所需的主要信息。首次上架或需要修改商店展示字段时，才按 [`dbx-store` 的贡献指南](https://github.com/t8y2/dbx-store/blob/main/CONTRIBUTING.md) 提供发布者和候选信息。

`.dbx-store.json` 是可选的商店元数据文件：首次登记或主动修改商店名称、标签、许可证、主页等信息时使用；仅发布新版本时不需要重复修改它。它不是插件运行时 Manifest，也不能放签名私钥。

例如：

```json
{
  "name": "Example Files",
  "description": "Browse files from an example service.",
  "icon": "assets/plugin.svg",
  "tags": ["files", "storage"],
  "permissions": ["host.workbench"],
  "source": "https://github.com/example/dbx-plugin-files",
  "homepage": "https://github.com/example/dbx-plugin-files",
  "license": "Apache-2.0",
  "releaseNotes": "Initial release."
}
```

允许的商店字段是 `name`、`description`、`icon`、`tags`、`permissions`、`source`、`homepage`、`license`、`releaseNotes` 和 `localizations`；`icon` 可以是包内相对路径，自动同步时会转换成源码 Tag 下的 HTTPS 地址。`autoUpdate` 是 `dbx-store/automation/plugin-sources.json` 的注册字段，不是这个文件的字段。

自动同步只创建或更新候选 PR，**不会自动批准、签名或合并**。候选 PR 的目标仓库是 [`t8y2/dbx-store`](https://github.com/t8y2/dbx-store)，目标分支是 `main`，而不是 `t8y2/dbx`。

如果插件还没有登记到自动同步列表，首次上架可以手动 Fork `dbx-store`，向 `main` 提交**一个** PR，包含发布者首次需要的 `publishers/<publisher-id>.json` 和 `candidates/<plugin-id>.json`。不需要先创建单独的 Issue；CI 会在签名前有意保持 `open candidate(s) awaiting DBX Store signing`，签名 Workflow 回写正式目录后才会变绿。插件作者可以在 `dbx-store` 提交登记自动同步源的 PR，或请维护者登记；无需在插件仓库增加 App 私钥。

候选文件的关键结构如下。它只引用插件仓库自己的未签名 Release 资产，不能填写官方 `signingKeyId`：

```json
{
  "schemaVersion": 1,
  "id": "com.example.files",
  "publisher": "example",
  "version": "0.1.0",
  "name": "Example Files",
  "source": "https://github.com/example/dbx-plugin-files/tree/v0.1.0",
  "license": "Apache-2.0",
  "targets": [
    {
      "target": "universal",
      "url": "https://github.com/example/dbx-plugin-files/releases/download/v0.1.0/com.example.files-0.1.0-universal.dbxp",
      "sha256": "<64 位十六进制 SHA-256>",
      "size": 123456
    }
  ]
}
```

新版本仅提交新增版本；生成的 `plugins/<plugin-id>.json` 和 `catalog/index.json` 不要手工编辑。

### 第三步：DBX Store 审核并签名

维护者会检查源码、Manifest、权限、候选 SHA-256、大小和原生行为。审核通过后，受保护的 DBX Store 工作流会：

1. 按已审核 SHA-256 和大小下载候选包；
2. 确认候选包没有已有签名，并核对插件 ID 和版本；
3. 使用 DBX Store 仓库密钥添加 Ed25519 签名；
4. 发布最终 `.dbxp`、最终 artifact metadata 和 signing receipt。

开发者不会接触官方仓库私钥。

### 第四步：合并候选 PR

维护者运行受保护的签名 Workflow 后，Workflow 会把最终的 `plugins/<plugin-id>.json` 和生成的 `catalog/index.json` 回写到同一个候选 PR，并将签名包、artifact metadata 和 signing receipt 发布到 `dbx-store` Release。校验通过后，维护者再审核并合并这个 PR。

上架或更新 PR 中不要包含：

* `.dbxp` 二进制；
* 插件完整源码副本；
* Ed25519 私钥、Token 或下载凭据；
* 未经 DBX Store 签名的候选包 URL；
* 自行伪造的 `verified: true`。

## 更新已有插件

每次更新都发布新语义版本，不允许覆盖旧版本的 Release 资产：

1. 修改插件源码，并递增 `manifest.json` 的 `version`；不要复用旧版本号。
2. 发布新的源码 Tag 和候选 Release；旧 Release 资产不可覆盖。
3. 等待同步 Workflow 创建或更新 `automation/plugin-release/<plugin-id>/<version>` 候选 PR。
4. 维护者审核、签名并合并该 PR；目录随后包含新版本和对应平台包。

如果仓库未启用自动同步，才需要按照 `dbx-store` 贡献指南手动提交 `candidates/<plugin-id>.json`。新版本只提交新版本候选信息，不要手工重写已生成的 `plugins/` 或 `catalog/` 文件。

插件代码问题继续在插件源码仓库修复；只有目录、审核状态、最终下载地址、哈希、大小或商店文案变更才进入 `dbx-store`。

## 签名和发布者身份

DBX 当前使用单一仓库签名：

* `publisher` 表示作者归属和商店记录；
* `signingKeyId` 表示发布最终安装包的仓库密钥；
* 官方插件由 DBX Store 审核后统一签名；
* 自定义或私有仓库由仓库运营方维护自己的密钥；
* 开发者签名或双签名不是当前上架要求。

人工审核决定插件能否展示，Ed25519 仓库签名保证审核后的安装包没有被替换。签名不能替代源码审查，也不是原生 Sidecar 的操作系统沙箱。

官方商店插件作者不要运行 `keygen`，也不要自行填写 `signingKeyId`。如果你运营自己的私有插件仓库，才使用 CLI 的高级签名工具：

```bash
dbx-plugin keygen company.plugins.release
source .dbx-repository-signing-key.env
```

该命令生成 32 字节 Ed25519 seed 的受保护环境文件（Unix 权限为 `0600`），并输出 Key ID 和 Base64 公钥；私钥文件绝不能提交 Git。自定义仓库还必须把公钥加入自己的 DBX 信任配置。官方商店的私钥只存在 `dbx-store` 受保护的 Environment Secret 中，公钥由 DBX 客户端随官方信任列表发布。

`artifact.json` 中的 `target`、`url`、`sha256` 和 `size` 绑定确切字节；官方签名后才会增加 `signingKeyId`。已发布资产不可覆盖，任何字节变化都必须递增插件版本并重新审核。

## 相关资料

* [JDBC 插件与数据库驱动](/cn/docs/plugins)
* [参与 DBX 主仓库贡献](/cn/docs/contributing)
* [`t8y2/dbx` 插件平台源码](https://github.com/t8y2/dbx/tree/main/plugins)
* [`t8y2/dbx-store` 官方商店仓库](https://github.com/t8y2/dbx-store)

