5 Commits

Author SHA1 Message Date
5d14afa8d6 fix(qq): 输出工具不再受"当前会话身份"限制(修「可信 QQ 会话身份不完整」误拒)
现场(用户线上,驻留子联调回执原文):
  被**子的中断**唤醒的一轮里,父 agent 调用 output_send__qq(meta 带齐 user_id)被拒:
  「QQ 权限策略拒绝工具 output_send__qq:可信 QQ 会话身份不完整;请不要改用其他会话 ID 重试」

根因:`sessionToolArgsAllowed` 对**所有**工具都先要求"本轮能精确匹配可信 OneBot 事件"。
`onInputAuthContext` 在来源是 QQ 但匹配不到可信事件时会降权成
`auth = qqAuthContext{active: true}`(无 peer、非 owner)⇒ `currentPeer == 0`
⇒ 连**输出**也一并被拒 ✗。

但输出是 agent 的**主动调用**:发到哪个会话由它自己给的 meta(group_id / user_id)决定,
`handleChannelOutput` 已经强制要求该字段存在(缺了给明确报错)。再要求"当前会话身份"
是多余的门,而且会把合法发送一起挡掉 —— 设计上收到输入后可以往任意(已授权)通道
发任意多次。

改法:`output_send__qq` 在身份判据**之前**直接放行;「只能访问当前会话」这类限制
保留给**读取类**工具(get_history / mark_read / get_message)—— 那才真的不能跨会话读。

判据 `TestDowngradedAuthStillAllowsQQOutput`:
降权态下输出放行、读取类仍被当前会话限制挡住。
扰动验证:去掉放行分支 ⇒ 该判据报出与现场**一字不差**的那句拒绝。
线上实测:CLI 发起的轮次里 output_send__qq 返回 ok,插件日志 handleChannelOutput 确认送达。

(cherry picked from commit 4852d70d77)
2026-09-13 16:03:24 +08:00
87241bc4ff docs(sdk): 同步 1.3.0 能力说明与发版口径到发布线
发布线是 SDK 1.3.0 的产物来源,README 停在内核 1.2.0 的口径会让插件作者
按过期说明写代码。只捡文档,不动本线 meta.Version(这里必须恒为 1.3.0)。
2026-09-13 14:40:35 +08:00
535922c6a0 feat(example): plugindev 插件 —— 把 hmapdev 工具链封装成 Agent 可调用的工具
用户要求:把 SDK 的 hmapdev 额外封装为 HomeAgent 插件并装上。

## 为什么

hmapdev 原本是"给人/CI 用"的命令行。做成插件后,Agent 能自己完成
「新建插件 → 构建 → 安装 → 重载」全流程(配合已有的 plugin_install / plgreload):

  plugindev_init → plugindev_build → plugin_install(path) → plgreload

## 工具面(5 个)

`plugindev_status`(可用性/版本/当前 SDK/工作区,排障首选)、
`plugindev_init`(脚手架,插件名约束 `[a-zA-Z0-9_-]{1,64}`)、
`plugindev_build`(在工程目录构建打包,返回产物路径与下一步提示)、
`plugindev_sdk`(SDK 版本 list/current/path/latest/install/use,`from` 支持本地源码)、
`plugindev_projects`(列出工作区已有工程与产物)。

## 安全边界(实现里落实)

- 只 exec **hmapdev 一个可执行文件**,不做 shell 拼接;
- `plugindev_build` 只接受含 `plg.json` 的目录 —— 这个工具不会变成"对任意目录跑构建";
- 子进程全部带超时;输出截断(6000 字符,保留首尾)后才返回,避免几百 KB 构建日志灌爆模型上下文;
- 工作区默认落在 `<data_dir>/plugindev`,配置可改。

## 现场验证

已在本机生产装上(`plugin_install` 带 path):5 个工具注册成功、
启动日志 `[plugindev] 就绪:hmapdev=/usr/local/bin/hmapdev 工作区=/home/newqqagent/plugindev`,
`hmapdev` 已装到 `/usr/local/bin/hmapdev`(版本 1.3.0)。
2026-09-13 14:17:06 +08:00
fe66f72b9c chore(example): a2a 1.3.0→1.3.1、acp 1.2.0→1.2.1(补 RegisterInputChannel 后升 patch)
上一提交(4cb3a0b)给这两个示例补了显式 `RegisterInputChannel`,但没有升版本号,
于是生产上"重装"出来的包与实际安装版本同名(1.3.0/1.2.0),既不好追溯也没法用版本比较升级。

本次只改版本号(`plg.json` 是仓库里的真源;`plugin.json` 由 `hmapdev build` 从它同步,不入库)。

实测:升级并 reload 后,内核启动日志里这两个插件的"只声明了出站通道"告警消失
(它们此前是真实缺口 —— 部署件是 9-11 的旧构建)。
2026-09-13 14:09:09 +08:00
0a4eb76542 docs(sdk): 通道名会拼进 LLM 函数名,写明命名约束([A-Za-z0-9_-]{1,64})
生产事故(v1.3.0 部署后 agent 完全不应答)的根因之一就是这个约束没写清:
远程设备通道名 `device/<id>` 里的 `/` 让 `output_send__device/<id>` 无法通过上游的
函数名校验,上游对**整条请求**回 400(`Invalid 'tools[N].function.name'`),
网关 auto tier 全链条失败,内核只能报"所有 provider 都失败"。

这不是"某个工具不可用",而是**整个 agent 哑掉** —— 所以这条约束必须出现在
插件作者会看的地方(`RegisterOutputChannel` 文档 + README 的通道一节):
- 通道名只允许 `[A-Za-z0-9_-]`,且总长要留出 `output_send__`(13) 的余量;
- 名字若来自外部输入(设备自报 id 等),请在插件侧派生合规且唯一的名字。

内核侧**不做**净化/反解:通道名是插件自己的声明,就该由插件遵守契约。
2026-09-13 13:07:07 +08:00
13 changed files with 668 additions and 9 deletions

View File

@ -4,7 +4,7 @@ HomeAgent 插件开发 SDK用于构建与 HomeAgent 平台交互的智能插
## 版本与兼容性 ## 版本与兼容性
当前:**SDK 1.2.0**(需内核 **1.2.0+**)。 当前:**SDK 1.3.0**(需内核 **1.3.0+**)。
**版本号跟随内核的中版本patch 位恒为 `.0`** **版本号跟随内核的中版本patch 位恒为 `.0`**
@ -12,11 +12,15 @@ HomeAgent 插件开发 SDK用于构建与 HomeAgent 平台交互的智能插
|---|---| |---|---|
| 1.0.0 / 1.0.1 / … / 1.0.4 | 1.0.0 | | 1.0.0 / 1.0.1 / … / 1.0.4 | 1.0.0 |
| 1.1.0 / 1.1.1 / … / 1.1.N | **1.1.0** | | 1.1.0 / 1.1.1 / … / 1.1.N | **1.1.0** |
| 1.2.0 起 | 1.2.0 | | 1.2.0 / 1.2.1 / … / 1.2.N | **1.2.0** |
| 1.3.0 起 | **1.3.0** |
内核的 patch 位专用于 bugfix 与漏洞修复,不碰公开接口,所以 SDK 版本号不跟着动—— 内核的 patch 位专用于 bugfix 与漏洞修复,不碰公开接口,所以 SDK 版本号不跟着动——
否则你要么被迫跟版、要么怀疑自己版本过时,而接口其实一个字都没变。 否则你要么被迫跟版、要么怀疑自己版本过时,而接口其实一个字都没变。
因此 **SDK 仓在一个中版本里只发一次**`vX.Y.0`),核心的 `v1.3.1`/`v1.3.2`/… 不伴随 SDK 发版。
2026-09-13 曾误发过 `v1.3.1`,已撤回 —— patch 位带非零数字的 SDK tag 都是错误的。)
**1.0.x 插件升到 1.1.x不需要改代码也不需要重编。** 1.1.0 的新增全部是 **1.0.x 插件升到 1.1.x不需要改代码也不需要重编。** 1.1.0 的新增全部是
「插件调用、内核实现」方向,不调就不受影响(已用 SDK 0.9.2 编的旧 `plugin.bin` 「插件调用、内核实现」方向,不调就不受影响(已用 SDK 0.9.2 编的旧 `plugin.bin`
实测验证:在新内核上直接建链通过,因为握手校验的是 `ProtocolVersion`、不是 SDK 版本)。 实测验证:在新内核上直接建链通过,因为握手校验的是 `ProtocolVersion`、不是 SDK 版本)。
@ -28,6 +32,40 @@ HomeAgent 插件开发 SDK用于构建与 HomeAgent 平台交互的智能插
所以 `plugin.bin` 必须用配套的 `hmapdev` 重编后与内核**同批**安装——否则握手时协议版本 所以 `plugin.bin` 必须用配套的 `hmapdev` 重编后与内核**同批**安装——否则握手时协议版本
不匹配会被拒绝(错误信息会明确提示用配套 hmapdev 重编,不会静默降级)。 不匹配会被拒绝(错误信息会明确提示用配套 hmapdev 重编,不会静默降级)。
## 1.3.0 新增:注入优先级与动态输出通道
### 注入优先级(`InjectOptions.Priority`
插件可以声明**自己这次注入的中断级别**,内核按四级阶梯调度:
| 级别 | 常量 | 谁用 |
|---|---|---|
| L1L3 | `PriorityL1` / `PriorityL2` / `PriorityL3` | 插件按紧急程度自选L1 最低) |
| L4 | `PriorityL4` | **保留给内核与内核级插件**(内核自身事件、内核级通道) |
- 零值(不声明)与旧的注入调用**完全等价**:按排队处理,不抢占任何正在执行的回合
⇒ 存量插件不需要改一行、也不需要重编。
- 高优先级中断可以**抢占**低优先级正在跑的回合;被抢占的回合挂起、之后恢复继续
(现场保存/恢复对插件透明)。
- 排队输入**没有级别**:排队就是排队,任何中断都能插到它前面。
### 动态输出通道(`UnregisterOutputChannel`
`RegisterOutputChannel` 注册的通道此前只增不减。对**随资源生灭**的通道(典型:远程设备
一台设备一个输出通道),设备掉线后通道还在,模型会继续对一个死通道发消息并以为发成功了。
1.3.0 起成对提供:
| API | 用途 |
|---|---|
| `UnregisterOutputChannel(name)` | 注销输出通道(含能力表与工具) |
| `OutputChannelUnregistrar` / `SetOutputChannelUnregistrar` | 插件侧拿到注销句柄(内核注入) |
⚠️ 通道名要**由插件派生得又合法又唯一**(外部 id 不能直接当通道名)——
设备 id 这类外部输入可能带 `/` 等字符,而通道名会拼进 LLM 函数名 `output_send__<name>`
违规会让**整条 LLM 请求**被上游拒绝2026-09-13 生产事故:`device/<id>` 导致全量对话 403
派生规则与约束见下方「输出通道」一节。
## 注入行为与上下文裁剪1.2.0 ## 注入行为与上下文裁剪1.2.0
「记不记入记忆」与「要不要据此裁剪上下文」这两件事,原先只有 `ToolDef` 能声明; 「记不记入记忆」与「要不要据此裁剪上下文」这两件事,原先只有 `ToolDef` 能声明;
@ -92,7 +130,7 @@ type Plugin interface {
|------|------|------| |------|------|------|
| 阶段钩子 | `RegisterStage(stage, handler, scope...)` | 注册阶段回调scope 可选:`StageScopeGlobal`(全局,默认)或 `StageScopeOwnTools`(仅自己工具) | | 阶段钩子 | `RegisterStage(stage, handler, scope...)` | 注册阶段回调scope 可选:`StageScopeGlobal`(全局,默认)或 `StageScopeOwnTools`(仅自己工具) |
| 输入通道 | `RegisterInputChannel(name, def)` | 注册输入通道(**入站**谁会往这个通道注入输入def 为 `ChannelDef`NoMemory/Cleaner | | 输入通道 | `RegisterInputChannel(name, def)` | 注册输入通道(**入站**谁会往这个通道注入输入def 为 `ChannelDef`NoMemory/Cleaner |
| 输出通道 | `RegisterOutputChannel(name, caps, desc, def, handler)` | 注册输出通道(**出站**`output_send__<name>` 的回复发给谁def 为 `ChannelDef`caps 为能力位掩码 | | 输出通道 | `RegisterOutputChannel(name, caps, desc, def, handler)` | 注册输出通道(**出站**`output_send__<name>` 的回复发给谁def 为 `ChannelDef`caps 为能力位掩码。⚠️ 通道名只能用 `[A-Za-z0-9_-]`(见下方"输出通道"一节的命名约束) |
| 工具注册 | `RegisterTool(name, def, handler)` | 注册工具供 LLM 调用 | | 工具注册 | `RegisterTool(name, def, handler)` | 注册工具供 LLM 调用 |
| 插件 API | `RegisterPluginAPI(name)` | 注册插件 API 供其他插件访问 | | 插件 API | `RegisterPluginAPI(name)` | 注册插件 API 供其他插件访问 |
| 图记忆 | `Memory()` | 访问图记忆 API实体-关系存储) | | 图记忆 | `Memory()` | 访问图记忆 API实体-关系存储) |
@ -139,6 +177,14 @@ sdk.RegisterInputChannel("qq", ChannelDef{
### 输出通道 ### 输出通道
> ⚠️ **命名约束(会进 LLM 函数名)**:内核按 `output_send__<name>` 生成工具,
> 而上游对函数名的规范是 `^[a-zA-Z0-9_-]{1,64}$`。名字违规的后果不是
> "这个工具不可用",而是**整条请求被 400 拒绝**`Invalid 'tools[N].function.name'`
> 网关 auto tier 全链条失败,表现成**整个 agent 不回应**。
> 所以 `name` 只能用 `[A-Za-z0-9_-]`,并留出 `output_send__`13 字符)的余量。
> 名字若来自外部输入(设备自报 id 之类),请在插件侧派生一个合规且唯一的名字 ——
> 内核**不会**替你净化。
```go ```go
sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "通道描述", ChannelDef{}, handler) sdk.RegisterOutputChannel("my-channel", CapText|CapFile, "通道描述", ChannelDef{}, handler)
``` ```

View File

@ -4,7 +4,7 @@ Plugin development SDK for building intelligent plugins that interact with the H
## Version and Compatibility ## Version and Compatibility
Current: **SDK 1.2.0** (requires kernel **1.2.0+**). Current: **SDK 1.3.0** (requires kernel **1.3.0+**).
**The version tracks the kernel's minor version, with the patch position pinned at `.0`**: **The version tracks the kernel's minor version, with the patch position pinned at `.0`**:
@ -12,13 +12,33 @@ Current: **SDK 1.2.0** (requires kernel **1.2.0+**).
|---|---| |---|---|
| 1.0.0 / 1.0.1 / … / 1.0.4 | 1.0.0 | | 1.0.0 / 1.0.1 / … / 1.0.4 | 1.0.0 |
| 1.1.0 / 1.1.1 / … / 1.1.N | **1.1.0** | | 1.1.0 / 1.1.1 / … / 1.1.N | **1.1.0** |
| 1.2.0 onward | 1.2.0 | | 1.2.0 / 1.2.1 / … / 1.2.N | **1.2.0** |
| 1.3.0 onward | **1.3.0** |
The kernel's patch position is reserved for bugfixes and vulnerability fixes, which never touch the The kernel's patch position is reserved for bugfixes and vulnerability fixes, which never touch the
public interface, so the SDK version has no reason to move with it — otherwise you would either be public interface, so the SDK version has no reason to move with it — otherwise you would either be
forced to chase releases or suspect your version is stale, when not one character of the interface forced to chase releases or suspect your version is stale, when not one character of the interface
has changed. has changed.
The SDK repository therefore publishes **exactly once per minor version** (`vX.Y.0`); kernel patches
such as `v1.3.1` do not trigger an SDK release. (A `v1.3.1` tag was mistakenly cut on 2026-09-13 and
has been withdrawn — any SDK tag with a non-zero patch position is wrong.)
## New in 1.3.0: Injection Priority and Dynamic Output Channels
- **`InjectOptions.Priority` / `PriorityL1``PriorityL4`** — a plugin declares the interrupt level of
its own injection; the kernel schedules L1L4, where **L4 is reserved for the kernel and
kernel-level plugins**. The zero value is fully equivalent to the old three-argument call
(queued, never preempting), so existing plugins need neither a code change nor a rebuild.
Queued input has no level: anything can jump ahead of it.
- **`UnregisterOutputChannel` / `OutputChannelUnregistrar` / `SetOutputChannelUnregistrar`** —
channels that die with their resource (one channel per remote device) can now be unregistered;
previously they lingered and the model kept "successfully" sending into a dead channel.
- **Channel names must be legal and unique.** The name is spliced into the LLM function name
`output_send__<name>`, so it may only contain `[A-Za-z0-9_-]`. A real production incident
(2026-09-13): `device/<id>` made every LLM request fail with 403. Derive channel names from
external IDs — never use the raw ID.
**Upgrading a 1.0.x plugin to 1.1.x: no code changes, no rebuild.** Everything added in 1.1.0 is **Upgrading a 1.0.x plugin to 1.1.x: no code changes, no rebuild.** Everything added in 1.1.0 is
in the "plugin calls, kernel implements" direction, so not calling it means not being affected in the "plugin calls, kernel implements" direction, so not calling it means not being affected
(verified with an old `plugin.bin` built against SDK 0.9.2: it handshakes fine on the new kernel, (verified with an old `plugin.bin` built against SDK 0.9.2: it handshakes fine on the new kernel,

View File

@ -2,7 +2,7 @@
"name": "a2a", "name": "a2a",
"name_zh": "A2A 代理通信", "name_zh": "A2A 代理通信",
"name_en": "A2A Agent Communication", "name_en": "A2A Agent Communication",
"version": "1.3.0", "version": "1.3.1",
"description": "Agent-to-Agent 协议通信插件,支持双向 A2A 通信:可查询其他 Agent 并回复其请求。提供 HTTP 服务端暴露本 Agent 能力。", "description": "Agent-to-Agent 协议通信插件,支持双向 A2A 通信:可查询其他 Agent 并回复其请求。提供 HTTP 服务端暴露本 Agent 能力。",
"author": "HomeAgent", "author": "HomeAgent",
"entry": "plugin.so", "entry": "plugin.so",

View File

@ -2,7 +2,7 @@
"name": "acp", "name": "acp",
"name_zh": "ACP 代理通信", "name_zh": "ACP 代理通信",
"name_en": "ACP Agent Client Protocol", "name_en": "ACP Agent Client Protocol",
"version": "1.2.0", "version": "1.2.1",
"description": "Agent Client Protocol 通信插件:充当 ACP 服务端接受其他 Agent 的任务请求,同时提供客户端工具向远程 ACP Agent如 opencode发起会话并读取回复", "description": "Agent Client Protocol 通信插件:充当 ACP 服务端接受其他 Agent 的任务请求,同时提供客户端工具向远程 ACP Agent如 opencode发起会话并读取回复",
"author": "HomeAgent", "author": "HomeAgent",
"entry": "plugin.so", "entry": "plugin.so",

View File

@ -0,0 +1,47 @@
# plugindev — 插件开发工具链Agent 可调用)
把 SDK 的 `hmapdev` 封装成插件,让 **Agent 自己**走完「新建插件 → 构建 → 安装」全流程,
不需要人来敲命令行:
```
plugindev_init 生成工程骨架(等价 hmapdev init <name> [--lua]
↓ 改 plugin.go
plugindev_build 构建打包(等价在该目录 hmapdev build→ dist/*.hmap
plugin_install 安装(用 path 指向刚构建出的 .hmapoverwrite=true 表示原地更新)
plgreload 重载生效
```
## 工具
| 工具 | 参数 | 说明 |
|---|---|---|
| `plugindev_status` | — | hmapdev 是否可用/版本/当前 SDK 版本与路径/工作区;**排查"为什么不能构建"先用它** |
| `plugindev_init` | `name``lang`(go/lua)、`dir` | 生成工程骨架;插件名必须 `[a-zA-Z0-9_-]{1,64}` |
| `plugindev_build` | `dir``target` | 在工程目录构建打包;产物路径会在返回里给出 |
| `plugindev_sdk` | `action``version``from` | SDK 版本管理list/current/path/latest/install/use`from` 可指向本地 SDK 源码 |
| `plugindev_projects` | — | 列出工作区里已有工程与产物 |
## 配置
| 键 | 默认 | 说明 |
|---|---|---|
| `hmapdev_path` | 自动查找 | 依次尝试:本配置项 → PATH → `/usr/local/bin/hmapdev``/root/go/bin/hmapdev` |
| `workspace_dir` | `<data_dir>/plugindev` | `plugindev_init` 生成工程的默认目录 |
| `build_timeout_sec` | 600 | 单次 hmapdev 调用超时 |
## 前置:装 hmapdev
```bash
cd <sdk-repo>/tools/hmapdev && go build -buildvcs=false -o /usr/local/bin/hmapdev .
hmapdev version
```
## 安全边界(都在实现里,不只写在文档里)
- 只 exec **hmapdev 一个可执行文件**,不接受任意命令、不做 shell 拼接;
- `plugindev_build` 只接受含 `plg.json` 的目录("看起来是插件工程"才构建),
避免把这个工具变成对任意目录跑构建;
- 子进程全部带超时,输出**截断**后才返回(构建日志动辄几百 KB直接回灌会撑爆模型上下文
- 工程名约束与内核/上游对"进工具名的标识符"的规则一致(`[a-zA-Z0-9_-]{1,64}`)。

7
example/plugindev/go.mod Normal file
View File

@ -0,0 +1,7 @@
module plugindev
go 1.25.0
require gitcode.com/JianFeeeee/homeagent-sdk v0.0.0
replace gitcode.com/JianFeeeee/homeagent-sdk => ../../

11
example/plugindev/main.go Normal file
View File

@ -0,0 +1,11 @@
//go:build !windows || !cgo
package main
import (
sdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
func NewPlugin(name string, config map[string]interface{}) (sdk.Plugin, error) {
return NewPluginFactory(name, config)
}

View File

@ -0,0 +1,19 @@
{
"name": "plugindev",
"name_zh": "插件开发工具链",
"name_en": "Plugin Dev Toolchain",
"version": "1.0.0",
"description": "把 hmapdev 工具链封装成 Agent 可调用的工具:脚手架生成插件工程、构建打包 .hmap、管理 SDK 版本。配合 plugin_install 即可让 Agent 自己做完「新建插件 → 构建 → 安装」全流程。",
"author": "HomeAgent",
"entry": "plugin.so",
"tags": [
"plugindev",
"toolchain",
"developer"
],
"targets": "linux/amd64",
"outdir": "dist",
"bundle": true,
"replaces": {},
"source_dirs": []
}

455
example/plugindev/plugin.go Normal file
View File

@ -0,0 +1,455 @@
package main
// plugindev把 SDK 的 hmapdev 工具链封装成 Agent 可调用的插件。
//
// 为什么需要它hmapdev 是"给人和 CI 用"的命令行工具。做成插件后Agent 能自己:
// plugindev_init脚手架→ plugindev_build构建出 .hmap→ plugin_install安装→ plgreload
// 也就是"让 Agent 自己写/改/装插件"这条链不需要人来敲命令。
//
// 安全边界(都在实现里落实,不只写在描述里):
// - 只有 **hmapdev 一个可执行文件**会被 exec不接受任意命令/参数拼接);
// - `plugindev_build` 只接受"看起来是插件工程"的目录(含 plg.json
// 避免把一个 `hmapdev build` 变成对任意目录的操作;
// - `plugindev_init` 生成的工程名必须满足 `[a-zA-Z0-9_-]{1,64}`(与 LLM 函数名
// 同一套约束 —— 插件名会进 `output_send__<通道>` 之类的工具名);
// - 所有子进程都有超时,输出截断后再返回(防止把几十 MB 构建日志灌进模型上下文)。
import (
"context"
"fmt"
"log"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
const (
defaultBuildTimeout = 10 * time.Minute
maxOutputChars = 6000
)
// namePattern 与内核/上游对"会进工具名的标识符"的约束一致。
var namePattern = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`)
type Plugin struct {
name string
sdk *sdk.PluginSDK
hmapdev string // 解析到的 hmapdev 可执行文件路径
workspace string // 默认工作区(生成的工程落在这里)
timeout time.Duration // 单次 hmapdev 调用的超时
}
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
return &Plugin{name: name}, nil
}
func (p *Plugin) Name() string { return p.name }
func (p *Plugin) Start(s *sdk.PluginSDK) error {
p.sdk = s
s.SetAutoRestart(true)
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "hmapdev_path", Type: "string", DisplayName: "hmapdev 路径",
Description: "插件开发工具链可执行文件路径。留空则按 PATH → /usr/local/bin/hmapdev → /root/go/bin/hmapdev 查找",
Category: p.name,
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "workspace_dir", Type: "string", DisplayName: "工程工作区",
Description: "plugindev_init 生成工程的默认目录。留空则用 <data_dir>/plugindev",
Category: p.name,
})
s.Settings().RegisterDef(sdk.ConfigDef{
Key: "build_timeout_sec", Default: 600, Type: "int", DisplayName: "构建超时(秒)",
Description: "单次 hmapdev 调用的超时上限",
Category: p.name,
})
p.hmapdev = p.resolveHmapdev()
p.workspace = p.resolveWorkspace()
p.timeout = defaultBuildTimeout
if v, _ := s.Settings().Get("build_timeout_sec"); v != nil {
if n, ok := toInt(v); ok && n > 0 {
p.timeout = time.Duration(n) * time.Second
}
}
s.RegisterTool("plugindev_status", sdk.ToolDef{
Name: "plugindev_status",
Description: "查看插件开发工具链状态hmapdev 是否可用、版本、当前 SDK 版本与路径、工程工作区目录。排查\"为什么不能构建插件\"时先用它。",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
}, p.handleStatus)
s.RegisterTool("plugindev_init", sdk.ToolDef{
Name: "plugindev_init",
Description: "生成一个新的插件工程骨架(等价于 `hmapdev init <name> [--lua]`)。生成后在返回的目录里改 plugin.go再用 plugindev_build 构建。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"name": map[string]interface{}{
"type": "string",
"description": "插件名(也是工程目录名):只允许字母数字下划线短横,长度 1-64。例my_plugin",
},
"lang": map[string]interface{}{
"type": "string", "description": "go默认或 lua",
},
"dir": map[string]interface{}{
"type": "string", "description": "在哪个目录下生成(默认工作区)。必须是已存在的目录",
},
},
"required": []string{"name"},
},
}, p.handleInit)
s.RegisterTool("plugindev_build", sdk.ToolDef{
Name: "plugindev_build",
Description: "构建并打包一个插件工程(等价于在该工程目录里执行 `hmapdev build [target]`),产物是 dist/*.hmap。构建成功后用 plugin_install 安装(本地路径)。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"dir": map[string]interface{}{
"type": "string", "description": "插件工程目录(必须含 plg.json",
},
"target": map[string]interface{}{
"type": "string", "description": "构建目标,留空 = native当前平台。例linux/amd64",
},
},
"required": []string{"dir"},
},
}, p.handleBuild)
s.RegisterTool("plugindev_sdk", sdk.ToolDef{
Name: "plugindev_sdk",
Description: "管理插件 SDK 版本hmapdev sdk 子命令list 列出已安装、current 当前版本、path 当前路径、latest 远端最新、install 安装某版本(可用 from 指定本地源码目录、use 切换版本。构建插件报\"SDK 缺少某能力\"时用它升级 SDK。",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"action": map[string]interface{}{
"type": "string", "description": "list | current | path | latest | install | use",
},
"version": map[string]interface{}{
"type": "string", "description": "install/use 的版本号,如 v1.3.0",
},
"from": map[string]interface{}{
"type": "string", "description": "install 时用本地 SDK 源码目录(开发中的 SDK 用这个)",
},
},
"required": []string{"action"},
},
}, p.handleSDK)
s.RegisterTool("plugindev_projects", sdk.ToolDef{
Name: "plugindev_projects",
Description: "列出工作区里已有的插件工程(名字、版本、是否已构建出 dist 产物),用于接续之前的开发。",
Parameters: map[string]interface{}{"type": "object", "properties": map[string]interface{}{}},
}, p.handleProjects)
log.Printf("[plugindev] 就绪hmapdev=%s 工作区=%s", fallback(p.hmapdev, "(未找到)"), p.workspace)
return nil
}
func (p *Plugin) Stop() error { return nil }
// ---------------- 工具实现 ----------------
func (p *Plugin) handleStatus(args map[string]interface{}) (interface{}, error) {
out := map[string]interface{}{
"hmapdev": fallback(p.hmapdev, ""),
"workspace": p.workspace,
}
if p.hmapdev == "" {
out["available"] = false
out["hint"] = "未找到 hmapdev。请安装go build -o /usr/local/bin/hmapdev <sdk>/tools/hmapdev"
return out, nil
}
out["available"] = true
if txt, err := p.run(nil, ""); err == nil {
out["version"] = strings.TrimSpace(txt)
} else {
out["error"] = err.Error()
}
if txt, err := p.run([]string{"sdk", "current"}, ""); err == nil {
out["sdk_current"] = strings.TrimSpace(txt)
}
if txt, err := p.run([]string{"sdk", "path"}, ""); err == nil {
out["sdk_path"] = strings.TrimSpace(txt)
}
return out, nil
}
func (p *Plugin) handleInit(args map[string]interface{}) (interface{}, error) {
name, _ := args["name"].(string)
name = strings.TrimSpace(name)
if !namePattern.MatchString(name) {
return map[string]interface{}{
"error": "插件名只允许 [a-zA-Z0-9_-],长度 1-64它会进 LLM 工具名,违规会让整条请求被上游拒绝)",
}, nil
}
dir, _ := args["dir"].(string)
if dir == "" {
dir = p.workspace
}
if st, err := os.Stat(dir); err != nil || !st.IsDir() {
return map[string]interface{}{"error": fmt.Sprintf("目录不存在: %s", dir)}, nil
}
cmd := []string{"init", name}
if lang, _ := args["lang"].(string); strings.EqualFold(lang, "lua") {
cmd = append(cmd, "--lua")
}
txt, err := p.run(cmd, dir)
res := map[string]interface{}{"output": txt, "project_dir": filepath.Join(dir, name)}
if err != nil {
res["error"] = err.Error()
}
return res, nil
}
func (p *Plugin) handleBuild(args map[string]interface{}) (interface{}, error) {
dir, _ := args["dir"].(string)
if dir == "" {
return map[string]interface{}{"error": "dir 不能为空"}, nil
}
abs, err := filepath.Abs(dir)
if err != nil {
return map[string]interface{}{"error": err.Error()}, nil
}
// 只在"插件工程"里构建:必须存在 plg.json。这样这个工具不会变成对任意目录跑构建。
manifest := filepath.Join(abs, "plg.json")
if _, err := os.Stat(manifest); err != nil {
return map[string]interface{}{
"error": fmt.Sprintf("%s 不是插件工程(缺 plg.json用 plugindev_init 先建一个", abs),
}, nil
}
cmd := []string{"build"}
if target, _ := args["target"].(string); strings.TrimSpace(target) != "" {
cmd = append(cmd, strings.TrimSpace(target))
}
txt, runErr := p.run(cmd, abs)
res := map[string]interface{}{"output": txt, "project_dir": abs}
if pkgs := listHmap(filepath.Join(abs, "dist")); len(pkgs) > 0 {
res["artifacts"] = pkgs
res["next"] = "用 plugin_install 安装本地产物path 指向上面 artifacts 里的 .hmap然后 plgreload"
}
if runErr != nil {
res["error"] = runErr.Error()
}
return res, nil
}
func (p *Plugin) handleSDK(args map[string]interface{}) (interface{}, error) {
action, _ := args["action"].(string)
action = strings.TrimSpace(action)
switch action {
case "list", "current", "path", "latest":
txt, err := p.run([]string{"sdk", action}, "")
res := map[string]interface{}{"output": txt}
if err != nil {
res["error"] = err.Error()
}
return res, nil
case "install":
version, _ := args["version"].(string)
from, _ := args["from"].(string)
cmd := []string{"sdk", "install"}
if strings.TrimSpace(from) != "" {
cmd = append(cmd, "--from", strings.TrimSpace(from))
}
if strings.TrimSpace(version) != "" {
cmd = append(cmd, strings.TrimSpace(version))
} else if strings.TrimSpace(from) == "" {
cmd = append(cmd, "latest")
}
txt, err := p.run(cmd, "")
res := map[string]interface{}{"output": txt}
if err != nil {
res["error"] = err.Error()
}
return res, nil
case "use":
version, _ := args["version"].(string)
if strings.TrimSpace(version) == "" {
return map[string]interface{}{"error": "use 需要 version"}, nil
}
txt, err := p.run([]string{"sdk", "use", strings.TrimSpace(version)}, "")
res := map[string]interface{}{"output": txt}
if err != nil {
res["error"] = err.Error()
}
return res, nil
default:
return map[string]interface{}{"error": "action 只能是 list/current/path/latest/install/use"}, nil
}
}
func (p *Plugin) handleProjects(args map[string]interface{}) (interface{}, error) {
entries, err := os.ReadDir(p.workspace)
if err != nil {
return map[string]interface{}{"error": err.Error(), "workspace": p.workspace}, nil
}
var out []map[string]interface{}
for _, e := range entries {
if !e.IsDir() {
continue
}
dir := filepath.Join(p.workspace, e.Name())
projects := []string{dir}
// 有些工程会被生成到子目录里hmapdev init 支持指定目录),这里只看一层
for _, sub := range projects {
if _, err := os.Stat(filepath.Join(sub, "plg.json")); err != nil {
continue
}
item := map[string]interface{}{"name": e.Name(), "dir": sub}
if v := readPlgVersion(filepath.Join(sub, "plg.json")); v != "" {
item["version"] = v
}
if pkgs := listHmap(filepath.Join(sub, "dist")); len(pkgs) > 0 {
item["artifacts"] = pkgs
}
out = append(out, item)
}
}
sort.Slice(out, func(i, j int) bool { return out[i]["name"].(string) < out[j]["name"].(string) })
return map[string]interface{}{"workspace": p.workspace, "projects": out}, nil
}
// ---------------- 基础设施 ----------------
// run 执行一次 hmapdev。args 为空时执行 `hmapdev version`(用于探活)。
func (p *Plugin) run(args []string, dir string) (string, error) {
if p.hmapdev == "" {
return "", fmt.Errorf("未找到 hmapdev 可执行文件")
}
ctx, cancel := context.WithTimeout(context.Background(), p.timeout)
defer cancel()
cmd := exec.CommandContext(ctx, p.hmapdev, args...)
if dir != "" {
cmd.Dir = dir
}
// 继承环境Go 工具链需要 GOCACHE/GOPATH/PATH 等)。
out, err := cmd.CombinedOutput()
txt := truncateOutput(string(out))
if ctx.Err() == context.DeadlineExceeded {
return txt, fmt.Errorf("hmapdev %s 超时(%s", strings.Join(args, " "), p.timeout)
}
if err != nil {
return txt, fmt.Errorf("hmapdev %s 失败: %v", strings.Join(args, " "), err)
}
return txt, nil
}
// resolveHmapdev 依次尝试:配置项 → PATH → 常见安装位置。
func (p *Plugin) resolveHmapdev() string {
if v, _ := p.sdk.Settings().Get("hmapdev_path"); v != nil {
if s, _ := v.(string); strings.TrimSpace(s) != "" {
if _, err := os.Stat(strings.TrimSpace(s)); err == nil {
return strings.TrimSpace(s)
}
}
}
if path, err := exec.LookPath("hmapdev"); err == nil {
return path
}
for _, cand := range []string{"/usr/local/bin/hmapdev", "/root/go/bin/hmapdev"} {
if _, err := os.Stat(cand); err == nil {
return cand
}
}
return ""
}
// resolveWorkspace配置项 → <data_dir>/plugindev → ./plugindev。
func (p *Plugin) resolveWorkspace() string {
if v, _ := p.sdk.Settings().Get("workspace_dir"); v != nil {
if s, _ := v.(string); strings.TrimSpace(s) != "" {
ws := strings.TrimSpace(s)
_ = os.MkdirAll(ws, 0o755)
return ws
}
}
if v, err := p.sdk.Settings().GetCore("core.daemon.data_dir"); err == nil {
if dd, _ := v.(string); dd != "" {
ws := filepath.Join(dd, "plugindev")
_ = os.MkdirAll(ws, 0o755)
return ws
}
}
ws := "plugindev"
_ = os.MkdirAll(ws, 0o755)
return ws
}
// truncateOutput 截断长输出:构建日志动辄几百 KB直接返回会灌爆模型上下文。
func truncateOutput(s string) string {
if len(s) <= maxOutputChars {
return s
}
head := s[:maxOutputChars/2]
tail := s[len(s)-maxOutputChars/2:]
return fmt.Sprintf("%s\n…输出被截断共 %d 字节)…\n%s", head, len(s), tail)
}
// listHmap 列出目录下的 .hmap 产物(按名字排序,稳定输出)。
func listHmap(dir string) []string {
entries, err := os.ReadDir(dir)
if err != nil {
return nil
}
var out []string
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".hmap") {
continue
}
out = append(out, filepath.Join(dir, e.Name()))
}
sort.Strings(out)
return out
}
func readPlgVersion(path string) string {
b, err := os.ReadFile(path)
if err != nil {
return ""
}
s := string(b)
i := strings.Index(s, `"version"`)
if i < 0 {
return ""
}
rest := s[i:]
j := strings.Index(rest, ":")
if j < 0 {
return ""
}
rest = strings.TrimSpace(rest[j+1:])
rest = strings.TrimPrefix(rest, `"`)
if k := strings.Index(rest, `"`); k > 0 {
return rest[:k]
}
return ""
}
func toInt(v interface{}) (int, bool) {
switch n := v.(type) {
case int:
return n, true
case int64:
return int(n), true
case float64:
return int(n), true
}
return 0, false
}
func fallback(s, def string) string {
if strings.TrimSpace(s) == "" {
return def
}
return s
}

View File

@ -2,7 +2,7 @@
"name": "qq", "name": "qq",
"name_zh": "QQ消息", "name_zh": "QQ消息",
"name_en": "qq", "name_en": "qq",
"version": "1.4.0", "version": "1.4.1",
"description": "QQ 消息收发插件,通过 NapCat 协议桥接", "description": "QQ 消息收发插件,通过 NapCat 协议桥接",
"author": "HomeAgent", "author": "HomeAgent",
"entry": "plugin.so", "entry": "plugin.so",

View File

@ -925,6 +925,19 @@ func (p *Plugin) sessionToolArgsAllowed(name string, args map[string]interface{}
if !auth.active || auth.owner { if !auth.active || auth.owner {
return true, "" return true, ""
} }
// 输出工具**不受"当前会话"身份限制**(先于身份判据返回)。
//
// 为什么:输出是 agent 的**主动调用**,发到哪个会话由它自己给的 meta
// group_id / user_id决定 —— handleChannelOutput 会强制要求该字段存在,
// 缺了会得到明确的报错。这里再要求"本轮能精确匹配可信 OneBot 事件"是多余的门,
// 而且会把合法发送一起拒掉:现场(被子的中断唤醒的一轮)父带齐 meta 也发不出去,
// 报「可信 QQ 会话身份不完整」。
// 「只能访问当前会话」这类限制只对**读取类**工具get_history / mark_read /
// get_message成立 —— 那才是真的不能跨会话读。
if name == "output_send__"+p.name {
return true, ""
}
currentPeer := auth.userID currentPeer := auth.userID
if auth.isGroup { if auth.isGroup {
currentPeer = auth.groupID currentPeer = auth.groupID

View File

@ -202,3 +202,36 @@ func TestZeroLimitsMeanUnlimited(t *testing.T) {
} }
} }
} }
// 降权(本轮无法精确匹配可信 OneBot 事件 ⇒ auth={active:true}、无 peer、非 owner
// **输出仍必须放行**:发到哪个会话由 agent 自己给的 meta 决定,
// 不该被「当前会话身份」挡住。现场:被子的中断唤醒的一轮里,父带齐 meta 也发不出去
// (报「可信 QQ 会话身份不完整」)。
//
// 反之,**读取类**工具在降权时仍受当前会话限制 —— 那才是真的不能跨会话读。
func TestDowngradedAuthStillAllowsQQOutput(t *testing.T) {
p := newPermissionTestPlugin(t)
p.auth = qqAuthContext{active: true}
p.privateToolAllowlist = []string{"output_send__qq", "qq_get_history"}
p.groupToolAllowlists = map[int64][]string{0: {"output_send__qq", "qq_get_history"}}
ctx := toolCallContext("output_send__qq", map[string]interface{}{
"payload": "带齐 meta 的主动发送",
"type": "text",
"meta": `{"user_id":2198972886}`,
})
if err := p.beforeToolcall(ctx); err != nil {
t.Fatal(err)
}
if ctx.Response != nil {
t.Fatalf("降权时输出被拒: %s", *ctx.Response)
}
ctx2 := toolCallContext("qq_get_history", map[string]interface{}{"group_id": 1027993713})
if err := p.beforeToolcall(ctx2); err != nil {
t.Fatal(err)
}
if ctx2.Response == nil || !strings.Contains(*ctx2.Response, "可信 QQ 会话身份不完整") {
t.Fatalf("读取类工具在降权时应被当前会话限制挡住: %#v", ctx2.Response)
}
}

View File

@ -480,7 +480,15 @@ func (s *PluginSDK) RegisterPluginAPI(name string) error {
// 入站(谁会往 <name> 注入输入)是另一件事,用 RegisterInputChannel 声明。 // 入站(谁会往 <name> 注入输入)是另一件事,用 RegisterInputChannel 声明。
// 若该通道同时也是你的注入入口,两个都要登记。 // 若该通道同时也是你的注入入口,两个都要登记。
// //
// name: channel name (e.g. "qq", "webui") // name: channel name (e.g. "qq", "webui")
//
// ❗**命名约束**:内核会把通道名拼进 LLM 的函数名(`output_send__<name>`
// 而上游对函数名的规范是 `^[a-zA-Z0-9_-]{1,64}$`。违反的后果不是"这个工具不可用"
// 而是**整条请求被上游 400 拒绝**`Invalid 'tools[N].function.name'`
// 网关的 auto tier 会全链条失败 —— 表现成"整个 agent 不说话了"。
// 所以通道名只能用 `[A-Za-z0-9_-]`,且总长要留出 `output_send__`13 字符)的余量。
// 若通道名来自外部输入(设备自报 id 之类),请**在插件侧派生一个合规且唯一的名字**
// 而不是把原始值直接当通道名。
// caps: bitmask of supported output capabilities (CapText, CapFile, etc.) // caps: bitmask of supported output capabilities (CapText, CapFile, etc.)
// desc: description of the channel, expected meta format, and type enum // desc: description of the channel, expected meta format, and type enum
// def: 通道在记忆计算层的行为NoMemory/Cleaner // def: 通道在记忆计算层的行为NoMemory/Cleaner