Files
homeagent-sdk/docs/guide/security.md
JianFeeeee 0a6e2b7dc4 docs: 插件 SDK 文档站(API 参考从源码生成 + 自建检索)
为插件作者建一个文档站,重点是**能按描述搜到 API**,以及**明确能力边界**。

## 为什么 API 参考要生成而不是手写

公开 API 面有 115 个符号、11 个接口。手抄必然与代码漂移——这是文档站最常见的
死法(本仓 README 里已经有过几处「文档说一套、代码是另一套」)。

所以 `tools/apidoc` 直接从 `sdk/*.go` 提取签名、文档注释与代码块示例,渲染成
`docs/api/*.md`。发现文档不对时改的是**源码注释**,不是生成物。生成页首行带
「勿手改」标记,防止有人改了下次构建白改。

- `extract.go`:go/ast + go/doc 提取(只用标准库,离线可跑,不引入依赖)
- `gensite/`:渲染 Markdown + 检索索引
- `gensite/usages.go`:从 `example/` 21 个示例插件里反查**真实调用点**,
  贴在每个 API 下(源码注释里几乎没有可运行示例,但示例插件都是能编译跑的真代码)

## 能力边界:写这个站时查出的三处文档错误

这是本次最有价值的部分。原以为「公开 SDK 里有的 API 外部插件都能用」,
实测对照桥接模板后发现三处不符,站内已更正:

1. **`PluginMgr()` 被写成「仅内置可用」——错的。** 桥接模板第 692 行显式
   `base.SetPluginMgrAPI(procPluginMgr{})`,公开 `PluginMgrAPI` 注释也写「外部插件可调用」。
   真正的区别是**方法数**:公开面 3 个(ReloadOne/ListLoadedPlugins/IsPluginDisabled),
   内部面 9 个。容易混淆是因为两个包里有同名但不同的接口。
2. **`Events()` 外部插件恒为 nil。** `SetEventSubscriber` 全仓只有定义、无调用点,
   故 subscriber 从未被注入。外部插件的事件订阅实际由生成的运行时走
   `events.subscribe` RPC 完成——旧文档把它当成可用入口,会让人写出必然失效的代码。
3. **`UnregisterOutputChannel` 是静默无效,不是报错。** 桥接只注入 registrar、
   不注入 unregistrar,于是 `regOutputUnreg == nil`,函数命中 else 分支**直接返回 nil**
   (sdk/plugin.go:539-549)——不报错、通道也没注销。

每条裁定的依据写进 `tools/apidoc/tiers.json`(文件:行号 或 grep 结论),
站上以告警框呈现,读者可自行核对。判断依据三源:桥接模板的 `base.Set*` 注入点、
`internal/sdk` 完整面、`internal/plugin/proc/protocol.go` 的 RPC 表。

## 检索(用户的核心诉求)

两套互补:

- **MkDocs 内置搜索**:全文,中文走 jieba 分词。
- **自建 API 检索**(`docs/javascripts/api-search.js` + `assets/api-index.json`):
  支持四类查询——按名称、**按功能描述**(「注册工具」→ RegisterTool、
  「崩溃」→ SetAutoRestart)、按 `限定符.方法`(`memory.recall` → MemoryAPI.Recall)、
  按签名片段(`(string) error`)。并标出「仅内置」,避免外部插件作者踩空。

自建的理由:Material 内置搜索按整页文本索引,搜 `InjectText` 会列出所有提到它的
页面,但分不清哪条是它的定义;而且它要等 mkdocs build 才更新。

## 文档结构

- `docs/guide/`:快速开始、Go/Lua 首个插件、能力边界、打包发布、多平台、受限 SDK 与安全
- `docs/api/`:10 个按「你想做什么」划分的章节(工具/阶段/记忆/通道/配置/生命周期/
  事件/LLM/常量/桥接)+ 仅内置汇总页
- `docs/versions.md`:SDK 版本语义(跟随内核中版本、patch 恒为 .0)、
  1.0.0 是唯一破坏性变更、RPC 协议版本

## 验证

- `mkdocs build --strict` 零告警
- 23 个页面的全部站内链接与锚点可达(自动校验)
- 1440 / 768 / 390px 三视口:无横向溢出、无控制台错误
- 四种检索模式实测有结果且跳转锚点正确
- 构建产物 `site_build/` 已 gitignore

用法:`tools/apidoc/build.sh`(生成+构建)、`tools/apidoc/build.sh serve`(预览)。
2026-09-24 12:08:37 +08:00

82 lines
3.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 受限 SDK 与安全
外部插件与内置插件的区别不只是「能不能调某个函数」,还包含一层**安全边界**:
外部插件的进程不共享内核地址空间,能力通过显式注入点交过去。
## 三层隔离
| 层 | 机制 | 防住了什么 |
|---|---|---|
| **进程** | 插件跑在独立子进程 | 插件 panic / 内存越界**不会带崩内核** |
| **能力** | 只注入显式声明的接口 | 插件拿不到未授权的内核内部结构 |
| **权限** | 公开接口是内部接口的**只读子集** | 插件无法改写他人数据 |
第一种是 v1.0.0 从 C ABI 动态库改为子进程 + 共享内存的直接收益:
在此之前,插件 panic 会带崩 `homed`。
## 受限接口是怎么实现的
**按接口裁剪,而不是按方法裁剪。** 同一个概念在公开包与内部包里是**两个不同的
接口声明**,公开的那个只保留安全子集:
```go
// 公开 SDK:6 个只读方法
type SocialAPI interface {
GetPerson(name string) (*PersonProfile, error)
GetTrait(name, trait string) (string, bool)
GetRelations(name string) ([]SocialRelation, error)
GetNetwork(name string, depth int) ([]*PersonProfile, error)
ListPersons() ([]string, error)
}
```
写操作只在内核内部接口里。这样外部插件**在类型层面就调不到**,
不是靠运行时检查拦截。
同理,`EventSubscriber` 公开版**刻意只有 `Subscribe`,没有 `Publish`**:
```go
// 插件可以订阅,但由内核决定投递哪些事件
type EventSubscriber interface {
Subscribe(eventType EventType, handler EventHandler) func()
}
```
## 进程边界带来的约束
事件订阅是理解这层边界的典型例子。公开包里有一个 `Events() EventSubscriber`,
但**外部插件拿到的恒为 nil** —— 桥接运行时不注入它(`SetEventSubscriber`
在全仓没有调用点)。外部插件的事件订阅由生成的运行时通过 `events.subscribe`
RPC 完成,Lua 插件走内部 SDK 的 `Subscribe`。
这不是缺陷,而是进程边界的结果:跨进程无法共享内核的事件发布通道。
详见[能力边界](capability-boundary.md)。
## 共享内存中的数据面
工具调用帧、Cleaner、输入输出通道、媒体块、文档与知识正文**都走共享内存**,
RPC 只传偏移描述符。因此:
- 大对象不经 JSON 序列化,避免了大 payload 的性能与内存放大;
- StageContext 在同一份状态上读改写,消除了副本模型的 lost update
(实测由 35.8~36.8% 降到 0)。
`SharedRef`(共享内存描述符)是**内部实现细节**,插件开发者看不到它 ——
公开 SDK 只暴露普通字符串与 map。
## 插件作者的实践建议
- **不要在 `Start` 里长时间阻塞** —— 内核在等待它返回。
- **工具处理器要可并发**:模型可能并发发起多个调用;共享状态用锁保护
(`example/memo` 用 `sync.RWMutex`)。
- **写文件用原子替换**(临时文件 + rename),避免进程被强杀时截断数据。
- **声明 `NoMemory`**:定时提醒、连接状态这类不是对话内容的东西,
别让它们污染记忆(`InjectOptions{NoMemory: true}`)。
- **在 `-race` 下测**:插件重载瞬间的并发访问是历史高发缺陷。
## 许可与分发
SDK 是 **MIT**,插件可以**闭源分发**,可商用、可私有,无需回馈。
这是刻意的:SDK 随插件静态链接(源码进入插件二进制),用传染性许可会
强迫插件开源。内核本身是 AGPL-3.0-only,但那是内核的许可,与外部插件无关。