Keyboard shortcuts

Press ← or → to navigate between chapters

Press ? to show this help

Press Esc to hide this help


type: 参考 status: 已接受 last-reviewed: 2026-09-07 relates-to:

  • FND-001
  • ADR-003
  • crates/zeroclaw-plugins

插件协议

本文档定义了 ZeroClaw 的插件宿主与 WASM 插件组件之间的协议。

什么是插件

插件是一个自包含的 WebAssembly 组件,ZeroClaw 会在运行时加载它,以添加核心二进制未随附的能力。它位于 ~/.zeroclaw/plugins/ 下自己的目录中,并带有一个清单,说明其名称并声明它提供什么。ZeroClaw 会在启动时发现它,验证它,并将其导出的函数接入正在运行的 agent,使其表现得像内置能力:工具插件在模型看来只是另一个可调用的工具(WasmTool 实现了与原生工具相同的 Tool trait),通道插件表现为消息通道,内存插件表现为存储后端。

插件可以提供 PluginCapability(crates/zeroclaw-plugins/src/lib.rs)中定义的一项或多项能力:可调用工具、消息通道、内存后端、可观测性后端,或一组 markdown 技能。技能这一情况比较特殊:它完全不包含 WASM,只提供一个包含 markdown 的 skills/ 目录,这也是它唯一省略编译组件的能力。

为什么要构建一个

  • 无需分叉即可扩展。 无需修改 ZeroClaw 源码树或等待发布即可添加工具或通道;插件归你所有,并从你的安装目录加载。
  • 原生行为。 已加载的插件不是二等附加组件。桥接实现了内置项使用的相同运行时特性,因此插件工具会像第一方工具一样被提供给模型、归属并被调用。
  • 语言选择。 合约是 WIT 和 WASI Component Model,而不是 Rust API。任何可编译为 wasm32-wasip2 component 的语言都可以实现一个 world。下面的实操指南以 Rust 为例,因为这是目前支持最完善的路径,但边界本身与语言无关。
  • 默认沙箱隔离。 宿主会将每个插件加载到一个 WASI 上下文中,该上下文没有文件系统预打开项,也没有环境网络访问权限。插件无法悄无声息地连接宿主;它只能获得接入其 world 的宿主函数,别无其他。出站 HTTP、原始套接字和 WebSocket 是可以被开启的网络接口,每一项都受两层控制:清单授权(http_client、socket_client 或 websocket_client)决定适配器是否链接该_接口_,而运维人员针对每个实例设置的 plugins.entries.<key>.egress_hosts 授权则决定_可达范围_,三者共用同一范围设置。工具和通道适配器会链接这些接口;内存模块目前不提供这些接口。若未设置出站授权,可达范围为全部拒绝,因此仅链接接口本身不会发送任何数据。
  • 可验证的来源。 清单可以使用 Ed25519 签名,并且运营者可以要求在任何插件加载之前,由受信任的发布者提供签名。

插件(目前)不能做什么

这些是当前主机的真实限制,不是样式偏好。在围绕一个并不存在的能力进行设计之前,请先了解它们。

  • logging、类型化配置、实例作用域的密钥和持久化状态,以及 http_client 和主机驱动的入站功能均已接入。 在清单可声明的权限中,config_read 用于暴露插件自身经过 schema 校验的公开配置。工具或通道的 schema 可以指定一些密钥,这些密钥不会出现在公开配置中,而是在经过授权的服务调用中解析。state_read 和 state_write 用于控制加密的、支持比较并交换(compare-and-swap)的状态,这些状态归属于经准入的特定实例。http_client 授权是出站 wasi:http 的必要条件,但并不充分:能力适配器必须链接该主机接口(工具和通道会链接,memory 有意保持不使用 HTTP,直到其网络边界具备组件级覆盖为止),并且运维人员必须通过 egress_hosts 授予目标地址。若没有该授权,所有目标都会在数据包发出或域名解析之前被拒绝。socket_client 和 websocket_client 按相同条件链接 sockets 和 websocket 导入:相同的授权、地址类别规则以及每个实例的连接预算。授权以主机名而非端口为单位:对于 socket_client,已授权的主机可通过原始 TCP 在任意端口访问,因此只应授权插件可以使用任何协议与之通信的主机。清单 schema 仍接受文件系统和内存访问权限,但它们目前不起作用:其主机函数尚未在 linker 中注册。参见下文的权限和主机导入。
  • 无环境主机网络或文件系统访问。 WASI 上下文没有预打开的目录(preopens),也没有环境网络访问,因此插件无法通过环境 WASI 打开原始套接字或读取主机文件。持有 http_client 授权的工具或通道插件可获得出站 wasi:http;持有 socket_client 或 websocket_client 授权的插件则可获得由主机代理的 TCP/TLS/STARTTLS 或 WebSocket 连接,且这些连接仅能到达其运营者出口授权所列出的目标地址;记忆插件目前不获得上述任何一种访问权限。没有任何插件可以监听端口。需要接收入站流量的通道插件不会自行打开监听器:由主机运行监听器,并通过 inbound 导入函数投递消息,插件再从其 poll-message 导出函数中取出这些消息。
  • 32 位边界。 目标是 wasm32-wasip2。来宾内存是 32 位地址空间,组件 ABI 会将偏移量降级为 32 位,而不受宿主字长大小影响。大值(例如通道附件的原始字节)通过按值传递跨越该边界。有关这为何是上游工具链的约束,而不是本仓库可以切换的标志,请参见 32 位地址空间部分。
  • 每个工具插件只对应一个工具。 tool-plugin world 只导出一个名为 tool 的接口,并带有一个 schema。需要暴露多个工具的插件会提供多个组件,或者使用不同的 world。
  • 实验性,未冻结的契约。 wit/v0 目前还没有 .frozen 标记,因此这些接口在首次稳定版发布前仍可能变更。请固定到某个版本,并预期在 WIT 升级后重新编译。

架构

ZeroClaw 插件是由 wit/v0/ 下的 WIT 接口定义的 WebAssembly 组件,并通过直接使用 wasmtime(crates/zeroclaw-plugins)托管。插件会编译为 WASI Preview 2 组件(wasm32-wasip2),导出插件 world 中的一个(tool-plugin、channel-plugin、memory-plugin),并导入该 world 在 wit/v0/ 中声明的宿主接口。

宿主位于 crates/zeroclaw-plugins/src/component.rs。它持有一个启用异步的 wasmtime::Engine,使用 wasmtime::component::bindgen! 从 wit/v0 生成 world 绑定,并将沙箱化的 WASI p2 接口接入每个 world 的链接器。每个 store 的宿主状态(PluginState)包含一个在无预打开目录且无网络的条件下构建的 WasiCtx、WASI 所需的 ResourceTable、宿主签发的作用域,以及带类型的活动服务句柄。每个 world 都导入 logging;tool 导入 secrets,而 channel 导入 config、secrets 和 inbound。获得 http_client 权限后,还会附加并链接 wasi:http。world 声明和获准作用域仍是该接口的规范契约(参见“宿主导入”)。

这三个 world bridges 将每个 WIT world 映射到运行时的原生 traits:

World桥接模块运行时表面
tool-pluginruntime.rs, wasm_tool.rszeroclaw_api::tool::Tool
channel-pluginwasm_channel.rschannel trait
memory-pluginwasm_memory.rs内存后端 trait

工具插件在每次调用时使用全新的存储(无状态)。Channel 和 memory 插件在插件生命周期内持有一个由异步互斥锁保护的热存储。

工具插件已完成端到端的发现与注册:运行时会遍历 channel_plugin_details() 的工具对应项,并为每个工具构建一个 WasmTool。通道宿主适配器(WasmChannel,包括其需经授权门控的 wasi:http 接口、由宿主掌控的出口策略、就近取用的配置服务,以及由宿主填充的 inbound 队列)已经完成,并通过了基于真实组件的出口测试。PluginHost::channel_plugin_details() 会暴露可供注册的 Wasm 通道插件。运行时现在会解析显式声明的 [channels.plugin.<alias>] 绑定,构建其 WasmChannel,并以配置的别名进行注册。这种感知别名的构造方式与运行时配置解析已在 #10146 中落地。通道还可以选择启用下文所述的通用网关自有 POST webhook 入口;厂商隧道、轮询客户端及其他传输方式仍属于后续工作。内存桥接器(WasmMemory)处于更早一步的相同状态:适配器已针对 memory-plugin world 完整实现了 Memory trait,但宿主尚未向 channel_plugin_details() 暴露对应的内存插件,运行时也尚未将 WasmMemory 构造为可配置的后端。

插件结构

插件是一个包含以下内容的目录:

my-plugin/
  manifest.toml    # Plugin metadata and permissions
  plugin.wasm      # Compiled WASM module (optional for skill-only plugins)

插件从 ~/.zeroclaw/plugins/ 目录中自动发现(可通过配置文件中的 plugins.plugins_dir 进行配置)。

注册表搜索和安装

本地插件安装路径仍然是已安装插件的事实来源。注册表只是一个在命令执行时用于发现并下载插件归档的 JSON 索引:

zeroclaw plugin search calendar
zeroclaw plugin install team-calendar
zeroclaw plugin install team-calendar@0.2.0
zeroclaw plugin search calendar --registry https://example.invalid/registry.json
zeroclaw plugin install team-calendar --registry https://example.invalid/registry.json

zeroclaw plugin search 会获取注册表元数据,并将查询与插件名称和描述进行匹配。它不会安装、启用或执行插件代码。

该获取到的索引已被缓存。zeroclaw plugin list 会读取该缓存,并与宿主已接纳的已安装记录一同显示一行软件包信息,不会发起获取操作,也不会将注册表元数据视为代码已安装的证明。

zeroclaw plugin install <name> 会从注册表中解析名称,下载所选的 zip 压缩包,校验可选的 SHA-256 摘要,安全地解压该压缩包,然后将解压后的插件目录交给与本地安装相同的准入流程。准入(PluginHost::admit_source)会解析并检查清单的签名,验证其结构和配置,拒绝已安装的同名插件,并且只读取一次组件:路径必须位于软件包内部,且路径上不能有符号链接;读取上限为 64 MiB,若文件元数据显示其超过该大小则直接拒绝;同时检查可选的 wasm_sha256 摘要。随后 CLI 会对这些已准入的字节执行安装时加载检查:即守护进程启动时执行的同一实例化过程,因此基于已漂移 WIT 构建的组件会在安装时失败并给出完整诊断信息,而不会在之后被静默跳过。plugin install --no-verify 会完全跳过加载检查(不进行任何编译),并输出一条提示说明;但它不会绕过准入流程。未内置 WASM 后端的主机没有可供实例化的运行环境,因此会在不进行检查的情况下安装。安装(PluginHost::install_admitted)随后会持久化已准入的清单字节,并写入已准入的组件字节,因此经过验证的内容与守护进程将要加载的内容逐字节一致。本地路径安装遵循相同的流程。

当没有固定版本时,ZeroClaw 会选择注册表索引中的最后一个匹配条目,因此注册表发布者应有意安排重复名称的顺序。

zeroclaw plugin install ./my-plugin
zeroclaw plugin install ./my-plugin/manifest.toml

zeroclaw plugin update 通过同一套准入流程(PluginHost::admit_update)替换已安装的软件包,只是身份判定方向相反:指定的软件包必须已安装,且源中必须带有其名称。若新清单新增了权限,CLI 会拒绝,除非传入 --allow 予以接受;随后执行同样的加载检查,且不可跳过。PluginHost::update_admitted 会先在暂存目录中构建替换版本,再将其重命名到已安装软件包的位置。面向用户的约定见 更新插件。

zeroclaw plugin update team-calendar
zeroclaw plugin update team-calendar@0.2.0 --registry https://example.invalid/registry.json
zeroclaw plugin update --all
zeroclaw plugin update my-plugin --from ./my-plugin

默认的注册表 URL 是:

https://raw.githubusercontent.com/zeroclaw-labs/zeroclaw-plugins/main/registry.json

对于私有或分阶段的 registry,可为每个命令使用 --registry <url>,或设置 ZEROCLAW_PLUGIN_REGISTRY_URL。

Registry 条目使用此格式:

{
  "plugins": [
    {
      "name": "team-calendar",
      version: "0.8.6",
      "描述": 在团队日历上安排会议,
      "author": "Example Team",
      "capabilities": [tool],
      "url": "https://example.invalid/team-calendar-0.2.0.zip",
      "sha256": "sha256:<zip 的十六进制摘要>"
    }
  ]
}

归档必须包含根级 manifest.toml,或包含一个嵌套插件目录且其中有 manifest.toml。带有遍历路径、绝对路径、Windows 驱动器前缀路径,或包含多个 manifest 的归档会在安装前被拒绝。下载在流式传输时也有上限,因此没有 Content-Length 的服务器无法强迫 ZeroClaw 缓冲一个过大的归档。解压同样有上限,因此压缩归档不能在临时安装区域内无限膨胀。

注册表请求有时间限制。连接注册表或归档主机最多允许 10 秒;响应在返回头部之前以及读取正文的两次读取之间,若超过 30 秒未传输任何数据,也会超时。获取索引必须在 30 秒内完成,下载归档文件必须在 30 分钟内完成。因此,当注册表或归档主机停滞时,zeroclaw plugin search 或 zeroclaw plugin install 会直接失败,而不会无限等待;而持续传输数据的慢速归档下载,则只受 30 分钟上限约束。

搜索是未认证的发现。安装是安全边界:注册表安装会使用已配置的插件签名策略和受信任的发布者密钥,与通过 PluginHost::install 进行的本地插件安装相同。

仅含技能的插件布局(markdown 包)

仅具备 skill 能力的插件会将技能以 agentskills.io 格式放置在 skills/ 目录下,并省略 wasm_path:

my-toolkit/
  manifest.toml              # declares the skill capability, no wasm_path
  README.md                  # optional bundle-level overview
  skills/
    design-review/
      SKILL.md
      scripts/
      references/
    code-review/
      SKILL.md
    data-analysis/
      SKILL.md
      references/

每个 SKILL.md 都必须包含带有 name 和 description 字段的 YAML frontmatter;如果 bundle 中的技能缺少其中任一字段,运行时会在发现阶段(而非首次调用时)将其拒绝。技能以插件命名空间 ID 的形式注册,格式为 plugin:<plugin-name>/<skill-name>(例如 plugin:my-toolkit/design-review),以避免与用户自定义技能以及不同 bundle 之间发生冲突。

清单格式

manifest 是插件目录中名为 manifest.toml 的文件。其字段是 crates/zeroclaw-plugins/src/lib.rs 中 PluginManifest 的 serde 表面,这是唯一的事实来源:

字段必填含义
name是唯一的规范包 slug,也是每个派生实例配置键中的包组件。它本身不是操作符配置键。使用 1–128 个小写 ASCII 字符;以 [a-z0-9] 开头和结尾,中间只能包含 [a-z0-9._-]。发现过程会拒绝无效或重复的名称。
version是版本字符串,例如 0.1.0。
description不由 zeroclaw plugin list 显示的人类可读描述。
author不作者姓名或组织。
wasm_path用于 WASM 能力组件文件名,相对于插件目录。除非唯一的 capability 是 skill,否则为必填。如果指定的文件不存在,发现过程会跳过该插件。
wasm_sha256在 WASM 能力的严格模式下十六进制的 SHA-256 值,对应组件的精确字节内容。若存在该值,则始终强制校验;严格签名策略要求必须提供该值,以便已签名的清单能够绑定被准入的可执行文件。
capabilities是,非空插件类型:tool、channel、memory、observer、skill 中的任意一个(PluginCapability,序列化为 snake_case)。
provides不面向内置通道的 channel 插件(drop-in):它所镜像的蛇形命名配置 ID,例如 telegram、gmail_push。宿主会为每个已配置、已启用,且被已启用 agent 路由到的 [channels.<id>.<alias>] 准入一个逻辑实例,因此规范的通道配置仍是这些设置的唯一归属。需要 config_read。plugin 系列为保留项,会被拒绝;本构建未定义的 ID 会被拒绝;两个已安装包声明同一 ID 时,两者都会被拒绝。未设置(默认)时,表示从 [channels.plugin.<alias>] 配置的新通道。镜像已准入但尚未构建,参见 crates/zeroclaw-runtime/src/plugin_runtime.rs 中的激活计划。
permissions不宿主服务(代码可能访问的服务):http_client、config_read、state_read、state_write、file_read、file_write、memory_read、memory_write(PluginPermission)。HTTP、配置和精确实例状态权限仅对工具/通道适配器强制执行;文件系统和内存权限目前不生效。声明 config_read 时必须同时声明 config_schema。
config_schema恰好使用 config_read为此插件的私有配置起草一份 Draft 2020-12 JSON Schema。它包含在规范清单字节中,因此清单签名时会一并覆盖。根对象必须是带有 properties 映射且 additionalProperties = false 的对象。每个顶层属性必须直接或通过本地 JSON Pointer 明确指定一种受支持的类型:string、boolean、integer、number、array 或 object。工具和通道的使用方可以直接在顶层 string 属性上设置 x-secret = true,使其从公共配置中移除,并通过限定作用域的 secrets.get 宿主导入提供。secret 属性名是 1–128 字节的可移植 ASCII 名称,只能包含字母、数字、_、- 或 .。工具通过 __config 接收公共配置,并可在 execute 期间读取 secret。通道在 configure 和运行时操作调用期间,通过 config.get 读取当前公共对象,并通过 secrets.get 读取 secret;这两个导入在实例化和静态元数据发现期间均不可用。嵌套的、值为 false 或非布尔值的 secret 标记,以及非字符串的 secret 属性都会被拒绝。没有 config_read 的 schema,或没有 schema 的 config_read,都会被拒绝。
signature不对规范化清单字节进行的 Base64url Ed25519 签名。用于发布签名时设置。
publisher_key不签名者的十六进制编码 Ed25519 公钥。
egress不可选的 [egress] 表,包含一个 hosts 列表:声明该插件所需访问的目标地址,可以是精确主机名(api.example.com)或显式的后缀模式(*.example.com,匹配子域名但不匹配顶级域名本身)。该表属于规范化的清单字节内容,因此在清单签名时会被一并覆盖,修改它需要重新签名。无效语法会在发现和安装阶段导致整个清单被拒绝。缺失、为空以及“未声明任何内容“三种状态视为相同。

只声明代码实际使用的权限。未声明的权限是组件无法触达的宿主表面;不必要声明的权限则是你主动增加的攻击面,也是审查你的插件的人需要承担的审核负担。

[egress] 是一项声明,而非授权。清单文件本身不赋予任何网络访问权限:主机强制执行的白名单是运维人员在该实例自身的 zpi1_… 记录上设置的 plugins.entries[].egress_hosts,因此即使未签名的组件自行写入了 [egress] 表,也无法访问任何地址。声明的作用在于安装流程:zeroclaw plugin install 会将其写入它新建的记录中,并打印已授予的内容;对于已存在的记录,它从不会扩大其权限。参见 Declaring and granting egress。

操作方提供的值在 plugins.entries 中仍保持为字符串,并会在持久化时加密,以一个由宿主拥有的包、能力和绑定标识派生的版本化 zpi1_… 字符串作为键(安装过程会打印并初始化默认工具绑定的完整实例密钥):字符串按原样存储,布尔值和数字使用 JSON 标量文本,数组和对象使用 JSON 文本。在任何来宾代码运行之前,宿主会将这些字符串物化为包模式规定的类型,并针对工具和频道适配器验证完整对象。非机密工具属性构成 __config;频道通过 config.get 获取非机密对象。标记为 x-secret = true 的属性会从两个公共接口中省略,仅可在授权的服务帧中通过 secrets.get("property") 获取。频道在一次调用中的公共和机密读取共享同一个规范修订版本,调用结束时宿主会丢弃该物化视图。符合要求的频道插件 必须 在每个使用点都解析这两类值,并且不得在来宾的热状态中保留配置或凭据值;将明文返回给来宾意味着宿主无法针对恶意代码强制实施不保留策略。如果请求了 config_read 但未实际获授予,宿主会验证一个空对象;因此,包含必需属性的模式会拒绝启动,而不会在缺少必需配置的情况下启动。如果空对象有效,工具会省略空的 __config,而频道的配置/机密导入会返回 access-denied;在授权帧之外的调用、解析失败以及宿主调用预算耗尽会返回 unavailable。

功能

capabilities 是一个非空的 PluginCapability 值列表,定义在 crates/zeroclaw-plugins/src/lib.rs 中(序列化为 snake_case)。每个值要么选择插件导出的 WIT world(tool、channel、memory),要么命名一个可观测性后端(observer),要么标记一个仅限 markdown 的技能包(skill)。请阅读该枚举以获取规范集合;它是唯一的真实来源,本页面不再重复说明。

清单必须声明至少一个能力。除仅具有 skill 这一能力的插件外,每个能力都需要 wasm_path;该插件不携带 WASM 载荷,并且如果它省略了有效的 skills/ bundle,则会在发现时被拒绝(host.rs 中的 validate_manifest_shape)。

权限

permissions 是一个 PluginPermission 值列表,也定义在 crates/zeroclaw-plugins/src/lib.rs 中。请阅读该枚举以获取规范集合。

请注意声明与实际强制执行之间的差距:目前在组件宿主中,config_read、http_client、state_read 和 state_write 具有实际行为效果。请求 config_read 时必须提供 config_schema,而仅声明该 schema 却未申请该权限同样会被拒绝。在使用工具或通道组件之前,宿主会先解析其有效授权,将插件的运维人员配置值物化为带类型的 JSON,并校验完整对象。runtime.rs 会剥离调用方提供的任何 __config,再将经过校验的非机密值注入工具调用中;直接的顶层字符串属性若标记为 x-secret: true,则会从公开配置中省略,并通过宿主范围的 secrets 导入读取。工具在 execute 期间可以获得该服务。通道在 configure 和运行期操作中,通过 config.get 获取公开配置,通过 secrets.get 获取机密;实例化和静态元数据发现阶段则仍不可用。http_client 是必要的授权,而非完整的权限决策:能力适配器还必须构造 HTTP 上下文并链接 wasi:http,且宿主自有的出口策略必须允许目标地址。工具和通道适配器在授权校验之后链接该接口;内存适配器则有意不链接它,因此向内存范围授予 http_client 不会增加任何网络接口。对于工具和通道,在运维人员于 plugins.entries.<key>.egress_hosts 下列出目标地址之前,该接口实际上无法访问任何地址;策略会拒绝其他所有主机。已授权的主机如果解析为回环、私有或链路本地地址,除非运维人员还在 plugins.entries.<key>.egress_allow_private 下列出该主机,否则仍会被拒绝。该例外仅放宽已授权目标的地址类别,绝不会授予权限(运维端说明见 Plugins)。状态权限独立地控制工具和通道服务帧中 state.get 以及 state.put/delete 导入的使用。包、能力和绑定仅来自已准入的宿主范围;访客不提供命名空间。其余变体(file_read、file_write、memory_read、memory_write)已被清单 schema 接受,但尚未接入任何宿主导入:仅声明它们本身不授予任何权限。它们只是为将来用于控制这些权限的宿主函数预留名称(见下文 Host imports)。

WIT 接口

插件契约是 wit/v0/ 中的 WIT 文件集合,包为 zeroclaw:plugin@0.1.0。在该包稳定之前,每个条目都受 @unstable(feature = plugins-wit-v0) 保护;兼容性规则请参见 wit/VERSIONING.md。下面的接口仅作概览;.wit 文件才是准确签名的权威来源。

世界

wit/v0/ 定义了三个 world,由 component.rs 中的 bindgen! 进行绑定。每个 world 都导入 logging(宿主提供),并导出 plugin-info 以及其主要接口:tool-plugin 导出 tool,channel-plugin 导出 channel,memory-plugin 导出 memory。tool 还导入 secrets 和 state;channel 导入 config、secrets、state 和 inbound。tool 和 channel 另外在各自的 feature 下导入 sockets 和 websocket(plugins-wit-v0-sockets、plugins-wit-v0-websocket)。宿主只会为持有对应授权(grant)的实例链接这些接口,因此导入了未获授权接口的组件将无法实例化。每个 world 必需的(无默认值的)导出列在其 .wit 文件的 world 文档注释中。

tool 接口

wit/v0/tool.wit 定义了单工具接口。宿主在加载时调用一次 name、description 和 parameters-schema,然后在每次调用时分派 execute:

record tool-result {
    success: bool,
    output: string,
    error: option<string>,
}

name: func() -> string;
description: func() -> string;
parameters-schema: func() -> json-string;
execute: func(args: json-string) -> result<tool-result, string>;

parameters-schema 返回一个呈现给 LLM 用于工具调用的 JSON Schema 字符串。execute 接收与该 schema 匹配的 JSON 编码参数,并返回一个 tool-result 或错误字符串。json-string 是来自 wit/v0/types.wit 的 string 类型别名;调用方生成有效的 JSON,接收方对其进行解析。

channel 和 memory 接口

wit/v0/channel.wit 和 wit/v0/memory.wit 定义了受能力标志控制的接口面。宿主在加载时只调用一次 get-channel-capabilities / get-memory-capabilities,对于每个未设置的标志,它会使用 Rust trait 默认值,而不是调用插件。插件仍必须导出每个函数(返回文档中所述默认值的存根即可);宿主只是不调用那些对应标志缺失的函数。每个未设置标志所解析出的默认值都在 WIT 中 *-capabilities 标志旁的内联文档里注明,这是标志集合及其默认值的唯一事实来源。

功能标志

可选方法通过 flags channel-capabilities 和 flags memory-capabilities 进行公开说明。由于 flags 是位掩码,可以在不破坏兼容性的情况下向 vN/ 包添加新的可选方法,并配套一个新的 @since 函数。移除或重命名 flag、function、field 或 variant case 都属于破坏性更改,并且需要新的 vN+1/ 目录。

主机导入

宿主函数由插件导入,并由运行时提供。每个 world 的链接器都会连接 logging(通过 component_logging.rs 中的宿主实现,与 component.rs 中的 add_wasi 一起链接)。Tool 和 channel 链接实例作用域的 secrets 服务。Channel 还会导入 config(用于其类型化的公共对象),以及 inbound(用于其从 poll-message 中消费的、由宿主填充的消息队列)。Tool 和 channel 适配器仅在已准入的作用域授予 http_client 之后,才链接出站 wasi:http(PluginStoreSpec::with_granted_http 与 component.rs 中的 add_wasi_http),并且两者都会组合宿主拥有的可达性策略(PluginStoreSpec::with_egress_policy),从而使访客永远不会获得 Wasmtime 默认的无限制钩子。Memory 既不提供上下文,也不提供链接器接口。文件系统与内存访问权限仍处于未生效状态:用于限制它们的宿主函数尚未接入链接器。插件的环境权限由 WASI 上下文(无预打开目录、无隐式网络访问)加上其授权与适配器选择加入所共同启用的宿主导入构成,且仅限于这些导入。

ZeroClaw 所有的导入在每个由主机调度的服务帧中共享固定的安全预算。规范上限是 crates/zeroclaw-plugins/src/component.rs 中的 MAX_HOST_CALLS_PER_FRAME。预算耗尽后,日志记录变为空操作,入站轮询报告为空,读取公共配置或机密时返回 unavailable。新帧会重置预算。此上限是固定的主机策略,而不是重复的运维人员配置。

inbound

wit/v0/inbound.wit 被 channel-plugin world 导入。channel 插件本身不运行监听器,因此由宿主运行监听器(webhook 服务器、供应商隧道或轮询客户端),并将接收到的每条消息入队。插件通过调用 inbound-poll 从其 poll-message 导出中清空队列,并可使用 inbound-pending 以批量方式清空:

inbound-poll: func() -> option<host-inbound-message>;
inbound-pending: func() -> u32;

主机侧为每个通道持有一个 InboundQueue;WasmChannel::inbound 会将一个克隆体交给监听任务,因此入队的流量对插件的 drain 可见。

Channel Webhook 入口

一个通道组件可以在其返回的 channel-capabilities 中添加 webhook-ingress,并实现 webhook-path 和 parse-webhook。宿主随后会在 /plugin/<path> 上挂载 GET/POST 路由。路径是运行时声明,而非配置项:它必须包含 1–64 个 ASCII 字母、数字、连字符或下划线。当声明为空、无效或重复时,该通道实例会被拒绝。所有声明都会在守护进程替换路由表之前解析完成,因此清单的遍历顺序无法决定胜出者,重建失败也不会发布部分生成的结果。发布操作携带一个生成租约;已退役的旧通道监督器无法清除更新的路由集。

Webhook 入口要求组件具有有效的 config_read 授权。对于每个请求,网关会在一次性的已配置存储中,将包含 HTTP 方法、原始查询字符串、小写 UTF-8 请求头和精确请求体字节的类型化 webhook-request 传递给 parse-webhook。Guest 在该调用中解析其当前的范围化配置和密钥,校验平台来源真实性,并返回 webhook-response.messages、webhook-response.reply,或类型化的 unauthorized / bad-request 拒绝结果。取消或超时 HTTP 请求会丢弃该一次性存储,且绝不会影响轮询和出站通道调用所使用的常驻存储。

经过验证的挑战会返回 reply(body):网关以 HTTP 200 响应该文本,不进行发送方授权、幂等性处理或智能体投递。主机在组件和 HTTP 两个边界处最多接受 4096 个 UTF-8 字节,超出部分返回固定的 502 invalid webhook response。方法和查询参数取自请求字段,而非保留头部。消息的通道名称没有响应控制含义。HEAD 及其他不受支持的方法会在到达组件之前返回 405,并附带 Allow: GET, POST。

The 未认证的边缘仍由宿主治理:

  • 规范的、感知可信转发头的 webhook 速率限制器在路由查找之前运行。请求体采用网关的 64 KiB 上限,每条路由有 64 个请求的队列,解析与投递的截止时间为 10 秒。
  • 公共响应固定如下:成功返回 200;访客认证失败和负载被拒绝时,分别返回不透露具体原因的 401 和 400;主机、组件或下游故障时,返回不透露具体原因的 503;超时返回 504;队列或速率饱和时返回 429。访客和 Wasmtime 的诊断信息有长度限制、可溯源,并且仅保留在主机日志中;访客诊断信息绝不能包含凭据或原始机密。
  • 在客人身份验证之后,主机会为已准入的 plugin.<alias> 身份打上标记,并针对该频道实时解析 [peer_groups.*].external_peers。插件发送者的匹配是精确匹配("*" 仍是显式的全部允许条目)。被拒绝的发送者不会占用幂等键,也不会进入频道队列。同样的实时、默认拒绝的门控也适用于 poll-message 返回的消息,因此每个插件入站桥接都共享同一套主机策略。
  • Non-empty guest 消息 ID 会在由路由派生的命名空间内去重。正在处理中的重复消息会等待所属投递提交或回滚;基于代际的所有权令牌可防止过期的所有者清除替换后的预留。投递失败或被取消时会回滚,因此提供方重试可以成为新的所有者。

此变更影响未冻结的实验性 wit/v0 channel world。所有 channel 组件(包括不提供 webhook 服务的组件)都必须针对目标主机随附的 WIT 重新构建,并导出已文档化的、受能力门控的 stub。冻结版本的兼容性过渡期目前尚不适用。

logging

wit/v0/logging.wit 被这三个 world 都导入。插件调用 log-record 将结构化事件发回宿主:

log-record: func(level: log-level, event: plugin-event);

调用是即发即忘的:它不返回任何内容,宿主(component_logging.rs)会吸收所有错误,因此日志写入失败绝不会导致插件执行崩溃。传递是异步的:该导入将记录交给有界的宿主端队列,由专用线程负责排空,并在不阻塞的情况下返回,因此缓慢或卡死的日志消费者绝不可能让 guest 导出调用持续超过 plugins.limits.call_timeout_ms。延迟处理不会改变事件的含义:每条记录都会捕获 guest 调用点当前的宿主 span,并在该作用域内写入,因此 agent/channel/tool 归因和终止标签与内联发出时一致。该上限是真正的内存上限,因为事件字段是无界字符串,会被复制到 guest 的 max_memory_mb 上限之外的宿主内存中:guest 控制的字节数超过 64 KiB 的记录会被丢弃,而不是截断;排队记录使用固定的 8 MiB 总字节预算,该预算仅在记录写入后释放。队列已满、记录超出上限或预算耗尽时,都会丢弃最新记录;排空线程会在每次写入后以及空闲唤醒时报告累计丢弃数,因此即使被拒绝的记录之后始终没有任何被接受的记录,丢失情况仍然可被观察到。plugin-action 和 plugin-outcome 对应 zeroclaw-log 中封闭的 Action / EventOutcome 分类体系;不存在用于规避限制的变体,这是有意为之。不要直接调用 wasi:logging,否则插件事件的格式会不一致,也无法到达 zeroclaw_log 写入的所有目标。

config

wit/v0/config.wit 由 channel world 导入。它以 JSON 格式返回当前通过架构验证的非机密对象:

get: func() -> result<json-string, config-error>;

该对象保留 config_schema 声明的类型;标记为 x-secret: true 的属性会被省略。该服务在 configure 期间和操作通道导出期间可用。当已准入的实例缺少有效的 config_read 授权时,它会返回 access-denied。在组件初始化或静态元数据发现期间发起的调用、解析器或验证失败,以及主机调用预算耗尽时,会返回 unavailable,且不会暴露内部详细信息。

config.get 是使用点访问,而不是加载时快照。符合要求的 channel 插件必须在每个使用配置的操作中调用它,并且不得在 warm guest 状态中保留其返回的对象。这是一项插件符合性规则:将 JSON 返回给受信任的 guest 代码后,主机无法阻止恶意组件复制它。

secrets

wit/v0/secrets.wit 由 tool 和 channel 世界导入。来宾仅提供顶层属性名称:

get: func(name: string) -> result<string, secret-error>;

宿主从已准入的 PluginInstanceScope 推导 package、capability、binding 和有效授权,这些都不是 guest 的输入。只有清单 schema 中标记为 x-secret: true 的顶层直接字符串属性可以被读取。宿主在分派 execute 时,Tool 可以读取这些属性。Channel 可以在 configure 以及 send、poll、webhook 解析、health 和受 capability 控制的操作等运行时调用中读取。组件初始化和静态元数据导出返回 unavailable,且不解析配置。在同一个 channel service 帧内,每一次 config.get 和 secrets.get 都使用同一个已解析的规范配置版本;该帧在每一种退出路径上都会被丢弃。因此,合规的插件会在同一 binding 下,于其下一次操作中同时观察到公开配置与密文的轮换。access-denied、not-found 和 unavailable 有意不暴露任何解析器或 schema 细节。成功的读取会向可信的 guest 返回明文。该服务防止公开值注入和跨实例选择;它不是出口代理,无法让插件代码看不到该值。合规的 channel 插件必须在每个使用点解析密文,且不得在热状态中保留第二份副本。宿主在返回明文之后,无法强制执行不保留的要求。

机密属性名与状态键使用相同的可移植插件本地语法:1–128 个 ASCII 字节,且只能包含字母、数字、_、- 或 .。在清单准入阶段,URI、路径、命名空间、控制字符和非 ASCII 语法都会被拒绝。

state

wit/v0/state.wit 被 tool 和 channel world 导入。它提供加密的持久字节值,这些值由经过准入的确切 package、capability 和 binding 所拥有:

get: func(key: string) -> result<option<state-entry>, state-error>;
put: func(key: string, value: list<u8>, expected-revision: option<u64>)
    -> result<u64, state-error>;
delete: func(key: string, expected-revision: u64) -> result<_, state-error>;

state_read 允许 get;state_write 允许 put 和 delete。put 上的 none 表示键不存在时的比较并交换(CAS),而 some(revision) 必须与当前 revision 完全匹配。写入操作返回新的 revision。revision 是不透明的,且在同一实例内从不复用,因此已删除后重新创建的键无法满足过时的比较并交换。键使用上文所述的可移植插件本地语法,不能指定其他实例。状态仅在工具执行和通道服务帧期间可用;静态调用、宿主调用预算耗尽、存储/键错误以及完整性错误都会返回封闭式错误。每个实例固定的条目数、值和总大小配额会返回 quota-exceeded,而不会部分提交写入。

The runtime 将每个值的一个已认证信封存储在 data/plugin-state.db 中,并使用安装目录中已有的 .secret_key 进行加密。数据库仅包含带密钥的盲索引和 enc2: 密文。当安装密钥缺失或被替换时,系统会直接失败(fail closed),并且在存在持久化数据行的情况下,绝不会静默地重新生成密钥。请将 data/plugin-state.db 和 .secret_key 一起备份。

sockets

Host 中介的出站 TCP。connect 接受 host、port、mode 以及可选的 TLS profile,并返回一个 connection 资源:

  • plaintext:原始 TCP。可在运营方授予该主机的任何位置访问;不存在单独的明文例外(参见 ADR-014)。
  • direct-tls:TLS 在 connect 返回之前建立。
  • start-tls:连接会先进入协商阶段,此阶段仅 send-negotiation 和 receive-negotiation 可用。upgrade-tls 会将流原地切换为 TLS;失败时会关闭该流,且不会回退到明文重试。

主机只解析一次目标地址,仅连接已校验的地址,并在资源被释放前持有该实例的连接租约。除非请求指定了 TLS 配置文件,否则 TLS 会使用与插件 HTTPS 相同的根证书进行验证。receive 从不阻塞;当缓冲区中没有数据时,它返回 idle。失败情况为类型化的 socket-error 错误。

websocket

由宿主代理的出站 WebSocket。connect 接受明文(ws)或 TLS(wss)WebSocket URL、额外请求头、提供的子协议列表以及可选的 TLS 配置文件,并返回一个 connection 资源,该资源提供 send、receive、close 和 negotiated-subprotocol。DNS、目标地址决策、TLS、升级握手以及有界队列均由宿主负责。属于握手本身的请求头(Host、Connection、Upgrade、Sec-WebSocket-*)会被拒绝。receive 从不阻塞;一旦终止事件 closed 或 failed 被取出,它将返回 closed 错误,而不是 none。释放资源时会关闭套接字并释放连接租约。

TLS 配置文件

请求中的 sockets 或 websocket 可以指定运营者在实例上配置的 TLS 配置文件([[plugins.entries.tls_profiles]])。TLS 配置文件用于选择要信任的证书颁发机构,并可选地提供用于双向 TLS(mTLS)的客户端证书,这些内容读取自实例的 x-secret 属性。配置文件本身从不授予目标地址的访问权限:请求必须先通过 egress_hosts 检查,且该配置文件还必须覆盖目标主机。在明文连接上指定 TLS 配置文件属于无效请求。配置文件引用的属性仅限主机使用:对这些属性调用 secrets.get 会返回 access-denied,因此插件永远不会持有其用于身份验证的私钥。

宿主在每个连接打开时,根据打开该连接的服务帧所对应的配置版本构建该连接的 TLS 配置,并由该连接持有。帧的临时凭据只与由匹配的规范配置输入推导出的授权配对。如果策略、配置文件引用或凭据在这些视图之间发生变化(包括在 DNS 解析等待期间),连接打开将失败并默认拒绝,而不会混合不同的版本。比较范围涵盖实例的整个配置条目(其授权、其 TLS 配置文件以及所有配置值),以及部署的 NAT64 前缀和连接数上限。因此,在帧运行期间进行的任何此类修改,都会导致该帧之后使用自定义 CA 或客户端身份的连接失败。之后的帧会解析轮换后的凭据;已打开的连接不受影响。出口授权仍会在每次请求时检查,因此已移除的配置文件或授权会拒绝下一次连接。系统根证书是例外:保留系统根证书的配置文件,以及没有配置文件的连接,使用 HTTPS 所信任的根证书插件,该插件在每个进程中只读取一次。已打开的连接会保留其握手时提交的身份,直到连接被丢弃或关闭。丢弃连接,或取消仍在打开该连接的调用,会与连接租约一起释放该配置。

每个插件的配置(__config 和 config.get)

权限: config_read

插件不会读取进程环境变量。其清单必须将 config_read 与 Draft 2020-12 config_schema 配对;二者缺一均会导致清单无效。模式根必须是一个包含 properties 映射且 additionalProperties = false 的对象。每个顶层属性必须直接声明为 string、boolean、integer、number、array 或 object 之一,或通过包本地 JSON Pointer 声明为其中之一。工具和通道使用方可以在直接的顶层字符串属性上设置 x-secret: true;嵌套标记、值为 false 的标记或非布尔标记,以及非字符串的机密属性,都会被拒绝。未知键、格式错误的编码以及约束违规会在值传递给来宾代码之前拒绝该实例。

操作员的规范 plugins.entries.<instance-key>.config 值在内存中仍是带有机密标记的字符串映射,并在持久化时加密。主机根据完整的软件包、能力和绑定标识派生版本化的 zpi1_… 条目键;这样,不同的软件包和能力域就可以安全地复用 main 等别名。获准的软件包清单决定模式。string 值直接存储;boolean、integer 和 number 值使用 JSON 标量文本,例如 "true"、"4" 或 "0.5";array 和 object 值使用 JSON 文本,例如 '["urgent","ops"]' 或 '{"region":"us-east"}'。主机在每次使用时将完整数据具体化为类型化 JSON 并进行验证,然后对每个属性恰好划分一次。对于工具,非机密值会注入保留的 __config 键下:

{
  "提示": "a sunset",
  "__config": {
    "retry_limit": 4,
    "已启用": true,
    "labels": ["紧急", "ops"]
  }
}

如果其架构将被省略的 api_key 标记为机密,则会通过 secrets.get("api_key") 显式读取。runtime.rs 会在注入公共部分之前剥离调用方提供的 __config,因此无法伪造该部分。工具的公共配置注入和同一 execute 帧内的机密读取共享同一个已解析的实时配置版本;该帧会在成功、错误、陷阱、panic 或取消时丢弃。通道的 configure 导出没有配置参数。它会调用 config.get 获取公共对象,并调用 secrets.get 获取机密属性;后续每个使用配置的操作导出也同样如此。一次调用中的两个导入共享同一个已解析版本。宿主会在成功、错误、陷阱、panic 或取消时丢弃该物化视图。因此,当符合规范的来宾在使用点解析这两者时,同一逻辑绑定中的公共配置和凭据变更会在下一次操作中同时可用。

当清单请求 config_read,但主机实际上未授予该权限时,解析会用空对象替代,并在来宾代码运行前验证该对象。包含必填字段的架构会在构造期间安全失败。如果空对象有效,工具会省略空的 __config,而通道 config.get 和 secrets.get 会返回 access-denied。插件始终只能看到自己的配置节。

频道静态元数据导出项无法调用任一配置服务,并且只在加载时读取一次。因此,更改机器人/账户身份或任何配置派生的能力、自身句柄、提及方式或多消息延迟,都需要重建频道生命周期;而同一逻辑绑定的普通公共配置和凭据轮换则不需要。工具和频道是当前的配置使用者。memory 世界尚未支持配置导入,因此在该 ABI 和运行时接入完成之前,memory 插件不得请求 config_read。

声明的出站流量([egress])

适用于: 任何请求传输权限(http_client)的插件

清单可以包含一个可选的 [egress] 表,用于声明插件需要访问的目标地址。

[egress]
hosts = ["api.example.com", "*.cdn.example.com"]

条目采用严格的出站(egress)语法:精确主机名或显式的 *.suffix 模式,不支持全部放行的形式。无效语法会在发现和安装阶段拒绝整个清单。该表是规范清单字节的一部分,因此签名清单会覆盖它,修改它需要重新签名。

这是一项意图声明,而非授权。主机强制执行的允许列表是运维人员配置的 plugins.entries.<instance-key>.egress_hosts,它位于承载该实例私有 config 映射的同一条 zpi1_… 记录中,并在每次请求时从实时配置中解析,而不是快照存储到数据库中。组件若自带 [egress] 表却没有运维人员的授权,则无法访问任何主机。在本版本中,该检查仅由授权决定:声明不会在请求时与授权取交集,因此声明某个主机既不会授予访问权限,也不会限制运维人员设定的授权范围。两者取交集属于后续推出工作,参见 #8850。

zeroclaw plugin install 的价值在于安装时的确认流程。它会将声明的目标地址预填到所创建的 [[plugins.entries]] 条目中,因此对于声明了目标地址的插件,首次安装无需运维人员手动抄录主机地址即可正常工作,并且预填的值会被打印出来。它从不扩展已存在的条目:当升级后的声明有所增长时,会打印差异以及确切的 zeroclaw config set 命令,由运维人员自行应用。zeroclaw plugin update 完全不做任何预填,即使是在它新建的条目中也不预填,并会打印相同的差异。zeroclaw plugin list 会将同样的差距作为一项常驻诊断信息进行报告。请声明你的代码实际访问的目标地址,并将声明的增长视为每位运维人员在每次升级时都必须审批的事项。

渠道实例的行在操作员绑定其别名时创建,即使用 zeroclaw plugin bind <package> --channel-alias <alias> 或 zeroclaw plugin install <source> --channel-alias <alias>,并且仅在显式指定 --egress declared 时,才会将声明写入该行。--egress none 则以空授权进行绑定。若绑定操作在未提供任一参数的情况下会创建该行,则会拒绝执行,并列出你已声明的主机。对于渠道实例,声明同样适用于 websocket_client 或 socket_client,以及 http_client。

如果重新安装时发现一个以包名为键、且不受支持的类型化配置之前的旧版记录,安装会在创建派生的 zpi1_… 记录之前拒绝执行,并输出与 plugin list 相同的有序更新步骤。软件包安装会回滚,旧的私有配置和两个出口列表保持不变,直到操作员更新 beta 配置并重试。

目标为部署配置(例如自托管的 Gitea、局域网内的 Nextcloud)的插件,应在此处不声明任何内容。该授权由运维人员直接在实例记录中编写,而未声明任何内容的清单,永远不会被报告为丢失了目标。

WASI 组件宿主

主机(crates/zeroclaw-plugins/src/component.rs)会针对单个异步 wasmtime::Engine 编译并实例化组件。.wasm 文件的加载方式取决于构建的执行后端:

  • plugins-wasm-cranelift:存在 JIT 后端,因此 load_component 会在加载时通过 Component::from_file 编译一个 .wasm 组件。
  • No JIT backend (plugins-wasm-pulley or runtime-only):二进制中没有编译器,因此 load_component 会直接通过 Component::deserialize_file 反序列化该文件,将其视为由匹配的 wasmtime 生成的预编译 .cwasm。版本不匹配的制品会在反序列化的版本检查中被拒绝。

这两个后端功能都会引入 plugins-wasmtime;加载路径取决于构建中是否包含 cranelift 编译器,而不是取决于 pulley。

每次调用的执行限制

每个 guest 导出都在主机为 store 应用的按调用资源限制下运行。引擎启用燃料计量,并为每次调用分配全新的燃料预算,因此失控或恶意组件会触发陷阱,而不会让主机挂起。主机还会为完整的导出 future 设置挂钟截止时间,其中包括等待 wasi:http 等异步主机导入的时间;定期的燃料让出可确保不间断的 guest 计算不会使该计时器得不到调度,而 guest 可访问的主机导入绝不会阻塞执行器(日志记录会交给有界队列,并由专用主机线程写入),因此在主机工作运行期间仍可观察到该截止时间。StoreLimits 上限限制线性内存、表元素和实例数量。工具 world 每次 execute 都会获得一个全新的 store;warm channel 和 memory store 会在每次调用前重新补充燃料,因此长生命周期插件获得的是全新的预算,而不是在其生命周期内逐渐耗尽预算。

这五个边界均可由运维人员调整,且每个值都会被校验为非零:plugins.limits.call_fuel(默认 1,000,000,000 条指令单位)、plugins.limits.call_timeout_ms(默认 30,000 毫秒)、plugins.limits.max_memory_mb(默认 256)、plugins.limits.max_table_elements(默认 100,000)和 plugins.limits.max_instances(默认 64)。Store 只能在显式设置限制的情况下构建,因此任何加载路径都无法构造出未沙箱化的插件。客户机的 wasi:http 请求选项可以让调用提前结束,但无法延长宿主的截止时间。被中断的预热 store 永远不会被恢复,通道和内存后端也都不会复用在 Wasmtime 内部调用失败的 store:在客户机陷阱(trap)、燃料耗尽或宿主导入失败之后,Wasmtime 会拒绝之后对该 store 的所有调用;无法提升(lift)的结果会跳过导出函数的 post-return 清理。通道会在下一次调用时,使用宿主持有的输入重新创建实例。当陷阱和错过截止时间的次数超过重建预算(同时最多三次,此后每五分钟一次)时,除审批和选择提示外,调用需要等待。内存实例在其所有者重建之前一直不可用,之后到达该插件的调用会因错过截止时间所留下的错误而失败。重建后的内存实例以空状态启动,因为 memory 世界不会为插件提供其实例之外的任何存储。规范字段及默认值位于配置参考中。

32 位地址空间(wasip2 是 wasm32)

插件目标是 wasm32-wasip2,而宿主引擎是在启用 fuel 计量(Config::consume_fuel(true))且未启用 wasm_memory64 的情况下构建的。插件边界是固定的 32 位格式,这带来一些值得明确说明的后果:

  • 客体地址空间是 32 位。 插件运行在 wasm32 线性内存中。大值通过按值跨越边界:通道插件的 media-attachment 以 list<u8> 形式携带其完整字节,而 wit/v0/channel.wit 已经指出这可能达到数兆字节,并将资源句柄模型留给未来修订。在这 32 位空间内,宿主从 plugins.limits.max_memory_mb(默认 256)对每个 store 施加显式内存上限,因此客体受限于 wasm32 地址空间与该 ZeroClaw 配置上限中的较小者。
  • 该组件 ABI 将偏移量降为 32 位,而不论宿主字长大小。 即使在 64 位宿主上,规范 ABI 中的列表和字符串偏移量也是 i32。memory64 扩展的是 guest 的线性内存寻址,而不是组件模型的规范 ABI,因此启用它也不会使 WIT 层字段变为 64 位。
  • 没有可供绑定的 64 位 wasip2 目标。 wasm32-wasip2 是目前 rustc 和 LLVM 中唯一的 WASI Preview 2 目标;插件无法编译为 64 位 p2 组件,因此即使引擎启用了 memory64,主机也没有可加载的内容。

这是上游工具链约束,而不是本仓库中的某个标志可以解除的主机限制。当 64 位 p2 目标和更宽的组件 ABI 在上游落地时,bindgen! 接缝会针对它们重新生成,字段宽度也会在 wit/VERSIONING.md 窗口内的 WIT 中重新审视。在那之前,请将插件边界按设计视为 32 位。

签名

插件清单可能包含 Ed25519 签名(crates/zeroclaw-plugins/src/signature.rs)。该签名是对规范化清单字节进行 base64url 编码的结果(解析后的 TOML,仅移除根级别中名称完全为 signature 和 publisher_key 的条目);发布者的公钥采用十六进制编码。嵌套架构属性中使用这些名称的属性仍会被签名。主机根据 plugins.security.signature_mode 强制执行三种模式之一:

模式未签名插件不受信任或无效的签名
strict已拒绝已拒绝
permissive带有警告加载带有警告加载
disabled已加载未检查

验证会在发现和安装两个阶段运行。发现阶段会跳过未通过其策略的插件,而不会中止整个主机;安装阶段则会返回该错误。

使用 Rust 编写插件

插件是一个面向组件模型的 cdylib crate。请从宿主使用的同一个 wit/v0 package 生成 guest bindings,实现导出的 world,并编译为 wasm32-wasip2。从空 crate 到已安装插件的完整实操教程,请参见 plugin guides;下面的说明涵盖构建和安装机制。

构建

sh

# 安装 WASI Preview 2 target(一次)
rustup target add wasm32-wasip2

# 构建组件
cargo build --target wasm32-wasip2 --release

输出组件位于 target/wasm32-wasip2/release/<crate_name>.wasm。将它复制到你的 manifest.toml 旁边。对于仅运行时的宿主构建(不含 JIT 后端),请使用匹配的 wasmtime 将该组件预编译为 .cwasm 并改为分发,因为这类宿主在加载时会反序列化,而不是编译。

宿主的工具插件测试不依赖已发布的制品:crates/zeroclaw-plugins/tests/fixtures/tool-fixture 是一个在测试时从源代码构建的源码树内组件,而 reference_plugin.rs 和 reference_plugin_e2e.rs 会通过守护进程使用的相同 PluginHost、config_schema 和配置解析路径驱动它。如果无法构建该 fixture,这些测试就会失败。

安装中

sh

# 复制到插件目录
zeroclaw plugin install /path/to/my-plugin/

# 或手动
cp -r my-plugin/ ~/.zeroclaw/plugins/my-plugin/

配置

操作员值目前通过通用的字符串映射存储录入:在 TOML 中编辑 [[plugins.entries]],或在该实例的条目已存在后使用 zeroclaw config set。工具安装时会为其默认绑定条目预先生成种子数据。渠道键依赖于其配置的别名,因此渠道实例的条目会在该别名被绑定时生成种子数据,绑定方式为 zeroclaw plugin bind <package> --channel-alias <alias> 或 zeroclaw plugin install <source> --channel-alias <alias>。根据已配置别名解析渠道类型化配置的、感知别名的构造逻辑已在 #10146 中合入。zeroclaw plugin info <package> 会打印该包拥有的每个键,用于迁移和后续编辑:包括工具绑定的键,以及当已安装版本拥有实例状态且实例可通过该别名指定时,每个已绑定别名对应的键。每个此类已绑定别名还会显示该实例的就绪状态,无论是否带有键行:必需键已设置或缺失、配置与出口(egress)判定结果,以及运行时是否会接纳该实例。基于 schema 的表单和内联字段帮助尚未实现。当前可用的界面如下:

  • CLI 通过 list、search、install、bind、remove、info 和 migrate 处理插件的生命周期。zeroclaw config set 用于写入单个原始插件值,它不解析插件的 schema。
  • zerocode 可以编辑 ZeroClaw 的静态插件主机设置,但目前还不会根据 config_schema 为每个插件生成字段。
  • Web 网关对插件是只读的:GET /api/plugins 会报告已加载的插件以及系统是否已启用。
  • 主机在接纳软件包时验证 config_schema,并在来宾使用前再次验证并实例化操作符值。
  • 对于插件作者而言,清单模式是来宾边界处唯一的类型和验证契约。在其中定义所有受支持的键及约束;不要在宿主运行时配置结构体中重复该契约。来宾代码应将经过宿主验证的 JSON 反序列化为其本机类型化结构体。

静态配置架构提供通用的存储和机密标记路径,而不是按插件动态生成的编辑器。crates/zeroclaw-config/src/schema.rs 中的插件配置类型带有 #[prefix = "plugins"]、#[prefix = "plugins.entries"] 和 #[prefix = "plugins.security"],而 Configurable 派生会将每个带前缀的字段转换为通用配置路径。机密字段(插件条目的 config 映射标记为 #[secret])会使用相邻的 .secret_key 进行静态加密。主机配置的规范字段、默认值和 signature_mode 值位于配置参考中;该架构是权威来源,而每个插件清单则是其私有配置结构的权威来源。

构建功能

插件宿主是编译时可选启用的。工作区 Cargo.toml 中的二进制级功能决定是否完全内置插件,以及随附哪种执行后端:

  • plugins-wasm 是一个总括特性,会将插件宿主及其运行时集成引入二进制文件。下面的每个后端特性都隐含启用它,因此启用任何执行后端(例如 --features plugins-wasm-cranelift)都会始终包含插件宿主及其 CLI 接口;仅后端构建不会悄然生成一个不带 plugin 子命令的二进制文件。仅启用该总括特性等同于 plugins-wasm-runtime-only:没有 JIT,因此只能加载预编译的 .cwasm 组件。
  • plugins-wasm-runtime-only 是最小且启动最快的:没有 JIT,因此组件会从预编译的 .cwasm 反序列化。
  • plugins-wasm-cranelift 添加了 Cranelift 编译器,因此 .wasm 组件会在加载时被编译。它是唯一包含编译器的功能,也是唯一能够加载插件注册表所发布的 .wasm 组件的功能。
  • plugins-wasm-pulley 可在 wasmtime 的 Pulley 解释器中编译。ZeroClaw 从不选择 Pulley 作为编译目标,因此在具有原生 Cranelift 后端的主机上,该特性不会产生任何效果:单独启用时,其行为与 plugins-wasm-runtime-only 相同;与 plugins-wasm-cranelift 一起启用时,则会编译原生代码。在没有原生后端的主机上(例如 32 位 ARM),wasmtime 会自行将 Pulley 作为目标。

这些委托给 zeroclaw-plugins crate 的功能(plugins-wasmtime、plugins-wasm-cranelift、plugins-wasm-pulley),它们会连接 wasmtime。加载路径会根据构建中是否包含 Cranelift 编译器而定,如 WASI Component Host 中所述。请阅读工作区 Cargo.toml 中的功能注释以获取权威说明。

插件支持按发布产物提供

标准发行版功能集(cargo generate features --selection dist,按目标从工作区 Cargo.toml 中的注册表解析)在七个 64 位桌面和服务器目标上包含 plugins-wasm-cranelift,而在 32 位 ARM 和实验性 Android 目标上则排除它。哪个构件承载宿主,以及由什么证明这一点:

工件插件宿主已接受的软件包Proof
CLI 归档文件适用于 x86_64 和 aarch64 Linux(glibc 和 musl)、aarch64 和 x86_64 macOS,以及 x86_64 WindowsCranelift 编译器,.wasm 在加载时编译.wasm 组件,基于发布版本中的 wit/v0 构建该发布会在 publish 和 docker 运行之前,于各自平台上执行每个归档包中的插件(plugin-smoke 任务)。
适用于 armv7 和 arm(ARMv6)Linux 的 CLI 归档文件none: plugin 不是命令无registry 的按目标策略,由一个 xtask 测试固定
CLI 归档文件,适用于 aarch64-linux-android(实验性)无无请提供需要翻译的英文技术文档字符串。我会按照同样的规则翻译为简体中文。
GHCR latest、带版本号的,以及 debian 镜像Cranelift:他们重新打包 x86_64 和 aarch64 Linux glibc 归档文件.wasmз»§жүҝдәҶ archive smoke;й•ңеғҸеҶ…дёҚдјҡиҝҗиЎҢд»»дҪ•еҶ…е®№гҖӮ
GHCR dist 变体、AUR 软件包、Nix flake 软件包、setup.bat 源码构建Cranelift:从生成的分发列表构建源码.wasm无
GHCR all-features 镜像Cranelift、Pulley 和 runtime-only 均已编译进来.wasm无
GHCR minimal 和 default-features 镜像无无
桌面安装包(dmg、deb、AppImage、msi、setup exe)Cranelift:标准 sidecar 构建会使用目标平台生成的 dist 特性(在 macOS 上还会加上 embedded-web).wasm在 scripts/desktop/prepare-kernel.sh 中接入源;桌面端安装包没有插件冒烟测试门禁
cargo install zeroclaw 和 install.sh 源码构建默认无;需通过 --features plugins-wasm-cranelift 显式启用。.wasm 需要显式启用(opt-in)
Homebrew 核心仓库由 formula 管理,位于本仓库之外

对于主机上的每个产物,以下限制均适用:

  • wit/v0 尚不稳定。插件必须针对运行它的发行版所附带的 WIT 进行构建(参见 wit/VERSIONING.md);清单中不包含宿主或 WIT 版本信息,因此版本不匹配会在安装时的加载检查中暴露出来。
  • 编译组件时需要先写入、之后才变为可执行的内存。在禁止此类内存的环境中,例如设置了 MemoryDenyWriteExecute=yes 的 systemd 单元,或出现 SELinux execmem 拒绝记录的环境,所有插件加载都会被拒绝,而二进制文件的其余部分仍可正常运行。
  • 官方注册表中的软件包目前未签名,因此在 signature_mode = "strict" 模式下,它们都无法安装。
  • 每次工具调用都会重新编译该组件,目前没有编译缓存。
  • 运行时默认保持关闭。已安装的工具或技能包在操作者设置 plugins.enabled 和 plugins.auto_discover 之前不会产生任何作用。两者都设置后,每次构建工具注册表时,都会编译并实例化每个已准入的工具组件,并在该软件包声明的权限和出口策略下查询其名称、描述和参数模式;审批仅作用于 execute。频道包在设置 plugins.enabled 后即可运行,前提是操作者在 [channels.plugin.<alias>] 下声明了它,并且已启用的代理在其频道列表中包含 plugin.<alias>。对于通过 provides 镜像原生频道的软件包,在 [channels.<provides>.<alias>] 下的激活会被记录,但尚未构建。每次插件工具调用仍需操作者批准,除非代理的风险配置已预先批准该调用(auto_approve,包括 * 通配符,或 level = "full"),或者操作者在同一 CLI 会话或频道轮次中此前已对该工具选择了“Always”。always_ask 中的工具每次都会提示。

中断安装恢复

zeroclaw plugin remove <name> 可以恢复未加载的、空的或结构不完整的插件包目录,这些目录中仅包含安装过程写入的内容:一个声明该插件包名称的清单文件、位于 wasm_path 处的组件文件及其所在路径上的目录,以及对于 skill 插件,一个由文件夹和文件组成的 skills/ 目录树。签名策略拒绝、健康的插件包、已加载的别名、符号链接、无法检查的内容,以及包含其他任何内容的目录仍受保护。恢复操作在准入和删除之前会先占用单一的目录代次;它绝不会依据过时的判定结果,递归删除位于该包名称下的替代目录。

安装与恢复通过插件根目录下一个持久的隐藏操作系统锁进行协调。只有安装、更新、删除以及列出中断的更新所移出的内容时才会打开该根目录及其锁,因此仅发现插件的宿主(例如守护进程所持有的宿主)不会在插件目录中持有任何东西。在 Windows 上,已打开的根目录会锁定插件目录及其所有上级目录,使其无法被重命名,直到持有它的宿主释放该句柄。无法在 60 秒内获取锁的进程会放弃操作,并指明锁文件的名称。凡是能够读取该文件的账户都可以持有该锁;若操作系统或文件系统不提供文件锁定功能,安装、更新和删除操作将拒绝执行。新的暂存目录使用唯一的代际编号和由操作系统持有的租约,而不是以 PID 作为所有权凭证。恢复过程仅在恢复实际的最终包时,或在完成先前删除操作已判定需删除的阶段的清理时,才清理已被遗弃的协议暂存阶段。仅含 PID 的旧版暂存阶段若无遗弃证明,则予以保留并打印其路径;它们不会阻止新的安装使用全新的暂存目录。

被拒绝认领的包会被恢复,且不会覆盖并发占用者。如果无法完成恢复,错误信息会指明其保留的隐藏事务位置。保留该目录,解决报告的冲突,然后重试 plugin remove:重试会先恢复该包,然后再应用常规准入策略。已开始删除的认领会被标记,重试时会完成该删除,而不是恢复它。仅凭隐藏事务的名称,绝不能证明一个健康的包可以被删除。文件系统命名空间变更和不受支持的加锁会拒绝执行恢复,以确保安全。包的移动需要一种原子重命名操作,该操作会拒绝所有已存在的目标,包括空目录。若平台或文件系统无法提供该能力(如 FreeBSD 等其他 Unix 平台、不支持禁止替换的 NFS 挂载,或 3.15 之前的 Linux),安装和恢复将拒绝执行该移动。认领失败时,最终条目保持不动。以此方式被拒绝的安装会删除其已暂存的副本,并提示该移动不受支持;其他发布失败以及恢复失败,都会将包保留在其隐藏事务中,并报告该路径。不使用普通的覆盖式重命名。