Repository navigation
Plugin Events zh
English · 中文
对应 v0.12.0(
3a762b1)。ABI 是wit/nanopi-extension.wit(world extension-events); 授权在src/agent/hook::parse_event_grants里校验,交集在src/wasm/loader.rs里算, 投递走src/subscriber.rs(跨 guest 边界的那一段在src/wasm/loader.rs)。 设计笔记和完整理由:docs/v0.12-events.md。 重新验证:cargo test --features wasm -- --test-threads=1
v0.12 新增:插件可以观察全部十一个生命周期事件——和 shell hook 看到的是同一批事件、同样的 payload。每个事件是什么、什么时候触发,见 Hooks-zh。
插件不能挡住一个事件、不能取消一轮对话、不能改写 prompt。这不是一道以后可以放宽的权限检查,而是写在类型里的:
-
handle-event的返回值被丢弃。返回"{}"就行。 - 宿主侧的 trait 方法根本没有返回值(
src/subscriber.rs:fn handle_event(&self, event: &str, payload_json: &str);),所以实现者连"不小心"交回一个否决或改写都做不到。
为什么。 否决和改写是用户的特权,通过用户自己写的配置文件行使。把它交给一个别人打包的沙箱二进制,等于颠倒了谁在掌控。要拦住一件事,写 hook。
投递要求这个事件同时在两边出现:
- 插件自己的
list-events导出——它要求什么,以及 - 它那条
[[extensions]]里的events列表——你授予什么。
[[extensions]]
path = "~/.nanopi/extensions/audit-watcher.wasm"
events = ["tool_execution_start", "session_start", "session_shutdown"]export list-events: func() -> string
export handle-event: func(event: string, payload-json: string) -> stringlist-events 返回一个 PI 事件名的 JSON 数组,加载时只调一次,之后再也不调:
["tool_execution_start", "turn_start"]交集在加载时算一次,永不重算。这就是插件的 list-events 无法自我扩权的原因。插件要了而你没给的事件,会在启动时被报出来:
requested event ["input"] but the config's `events` did not grant it — not delivered.
所以一个"订阅了却什么都没发生"的事件,会告诉你为什么。
events 不写或为空,就什么都不授。 一个导出了 list-events 的插件,在它要的名字也出现在你的配置里之前,收不到任何投递——和 url_allowlist、allow_tools 是同一种默认拒绝的形状。
十一个 PI 名字,也正是 [[hooks.*]] 的配置键:
tool_execution_start tool_execution_end input
before_agent_start turn_start turn_end message_end
session_start session_shutdown session_before_compact session_compact
events 里出现未知名字是插件加载时的一条告警,不是配置错误,告警会列出所有合法名字。四个已退役的 Claude Code 名字会得到和 hook 配置一样的改写建议:
unknown event "pre_tool_use" — did you mean "tool_execution_start"? (see docs/v0.12-events.md §2.1)
有两个名字对插件永久不可投递,而且会明说而不是被静默丢掉:
event "message_update" fires per streaming delta and is permanently undeliverable to plugins (see docs/v0.12-events.md §5.2)
message_update 和 tool_execution_update 是按流式 delta 触发的——每轮上百次跨边界调用——再怎么授权都不会改变这一点。
events里的未知项不能是硬配置错误,因为[[extensions]]整块必须在不带wasm特性的标准二进制里保持"可忽略 + 告警"。对比之下,拼错的授权键就是硬错误——见 Configuration-zh。
payload-json 就是同一个事件下 shell hook 从 stdin 收到的那份 JSON,由同一个函数构造。插件和 hook 看到的东西完全一样——Hooks-zh 的线上协议一节对两者都是参考。
event 是 PI 对这个事件的命名:tool_execution_start,不是 pre_tool_use。
有一个细节值得知道:在 tool_execution_start 上,投递给插件的 payload 带的是改写前的参数——模型要求的那份——即使你的某个 hook 改写过它。而且当一个 hook 挡下这次调用时,插件仍然会收到这个事件:阻断被暂存起来,投递先跑,这是故意的。一个只观察的审计插件,不能恰好在策略拒绝了某件事的地方有盲区——被拒绝的那些调用正是它最在意的。
如果这个插件已经在另一次 guest 调用里面(一次工具执行,或者另一次事件投递),这次投递会直接丢弃,不等待。宿主用的是 try_lock,从不用阻塞锁:
nanopi: dropped event delivery, plugin busy [event=turn_start]
为什么丢弃是对的答案。 事件位于一轮对话的关键路径上。阻塞锁意味着一个慢插件把自己的延迟加到每一个事件上,而一个卡在循环里的插件会把整个 agent 拖停。既然 handler 只能观察,一次被丢弃的投递损失的只是信息,别无其他——没有任何决策押在它上面。这个取舍之所以成立,正是因为返回值被丢弃——这是"只能观察"是结构性而非政策性的第二个理由。
被污染(poisoned)的锁不算作"忙":它会被恢复,因为一次 panic 的调用不该让本会话余下的投递全部停摆。
每个 handler 跑在 spawn_blocking 上并被 await。panic 的 handler 会被记录,投递继续走向下一个订阅者——一个坏插件不会挡住其余插件的投递。一个零订阅者的事件只花一次 hashmap 查找,别无开销:构造 payload 的闭包连调都不会被调用——这也是 事件 → 订阅者 的索引在加载时预先算好、而不是每次触发时推导的原因。
handle-event 里的 guest 代码有 2 秒,刻意小于工具调用的 30 秒(EVENT_EPOCH_BUDGET_TICKS = 2 vs EPOCH_BUDGET_TICKS = 30,且有一条编译期断言保证前者小于后者)。一个事件 handler 跑的时候屏幕上什么都不显示,它在一轮对话的关键路径上,而且没有任何人在等它的结果。
这个预算只管guest 代码。一个调用了 host-http-get 的 handler,还要再叠上那个函数自己的 10 秒超时——所以最坏情况大约 12 秒。
不要在事件 handler 里发网络请求。 如果一个 handler 需要访问网络,让它把看到的东西记进自己的 store,把真正的活儿放到有人在等结果的地方去做。
在受限网络下这条更要认真:一个被 GFW 拖到超时的 host-http-get,会让每一个触发它的事件都吃满那 10 秒。所以"记下来,晚点在别处做"不只是风格建议——它是在这种环境下唯一能用的形状。
/tools 会列出当前所有在观察的插件:
Watching events (1 plugins)
audit-watcher tool_execution_start, session_start, session_shutdown
这一段列的是生效的订阅——交集,不是插件要求的那份。如果你授予的某个事件在这一行里没出现,说明插件从没要求过它;如果整行都不见了,说明这个插件什么都没订阅上。
/tools 还会给每个插件一行 Plugin grants,同一份订阅会在那里以 events(...) 这个 token 和其他授权并列出现。见 Plugin-Capabilities-zh。
events 是比 allow_fs 或 allow_network 更大的一次能力跃迁。 v0.12 之前,插件只能看到模型选择作为工具参数交给它的那些东西。现在:
- 一个
tool_execution_start订阅者看到每一次工具调用的参数——每条命令、每个路径、每次文件写入 - 一个
input订阅者看到你敲的每一句 prompt,原文 - 一个
tool_execution_end订阅者看到每个工具的输出
同一个插件上再加 allow_network = true,这就是一条外泄通道,nanopi 会在启动时对这个组合告警:
has both `events` and `allow_network = true` — this plugin can observe lifecycle
events AND reach the network, and could exfiltrate event payloads. Grant both only
if you trust the plugin.
两个能力各自都是显式 opt-in 的,所以这个组合得到的是告警而不是拒绝。但授予 events 时,请用你授予一个日志上传器的谨慎程度。
顺带说一句在国内环境里格外要注意的:一个 url_allowlist 只写了国内域名的插件,看起来"很本地",但它照样能把 payload 送出去——外泄的门是 allow_network 加白名单里的那些 host,和这些 host 在境内境外无关。别用"它只连国内"来替代阅读白名单。
--no-hooks 会连插件的事件投递一起关掉。 所有投递点都在和 shell hook 相同的 hooks_active() 判断后面(src/agent/permission.rs),所以这个紧急开关覆盖的是每一个生命周期观察者,不只是你配置里的那些。
这几个世界是一条线性阶梯,不是格:
extension ⊂ extension-commands ⊂ extension-events
WIT 无法表达"可选的导出"——声明了却没实现,wasm-tools component new 就会失败——所以一个以 extension-events 为目标的插件必须同时导出 list-commands 和 execute-command,即使它一个命令都不注册。两行空实现是接受下来的代价。做成格的话,今天是四个世界,下个特性之后是八个。
import 和 export 不同,它是叠加的、不需要新世界:它们全都挂在 world extension 上,向上继承。
通过 list-events 声明了事件却没有导出 handle-event,是硬加载错误。和命令不同,插件一旦说了"把这些投给我",就不存在一个"那就不派发"的合理退路。
反过来,缺少 list-events 导出是软的:插件正常加载,什么都不订阅。这正是让那些以 extension 和 extension-commands 为目标的插件不用改就继续能用的原因。
list-events 返回非法 JSON、trap、或者返回超过 32 个名字(MAX_EVENTS_PER_PLUGIN),对该插件都是硬错误:
list-events returned invalid JSON: <e> (got …)
list-events returned <n> events, more than the 32 allowed
list-events trapped: <e>
能用的形状是:读 payload、更新自己的 store、返回 "{}"。想告诉用户点什么,host-notify 没有门(每插件每轮限 10 行)。想对看到的东西采取行动,那是 allow_tools 或 allow_send_message——都是独立的授权,都在 Plugin-Capabilities-zh。
message_end 的 payload 带的是 turn_count 和 response_length——不是助手的文本。一个想对模型实际说了什么做出反应的插件,今天没法从事件里拿到它。这仍然是一个开放的设计问题,不是一个等着开关的特性。
- 十一个事件各自是什么、什么时候触发 → Hooks-zh
- 在
config.toml里授予events→ Configuration-zh - 另外七个 import 和它们的门 → Plugin-Capabilities-zh
- 构造 guest 侧 → Plugin-Development-zh
English
- Home
- Features
- Configuration
- Hooks
- Plugin Development
- Example Plugins
- Plugin Capabilities
- Plugin Events
- Debugging Plugins
中文
v0.12.0 · source of truth:
wit/nanopi-extension.wit