Files
HomeAgent/internal/plugin/proc/plugin.go
JianFeeeee 2ebdb9a5b7 plugin: 权限梯度显式化(Part 6.4)
迁移前,「外部插件拿不到 Selftest/Supervisor/Tracker」是 C ABI 表达能力的
**意外产物**——C 结构体不好传函数指针,这些能力自然到不了插件侧。那是运气
不是策略:任何人给 dispatch 加个 case 就能捅穿。

现在变成显式声明并强制,分三道闸:

1. **类型层**(proc_core.go,Part 6.2 已落地):procCore 用命名字段持有
   内核 SDK 而非嵌入,未在收窄面写出的方法编译期就不存在。
2. **能力集**(新增 capability.go):54 个 plugin→kernel method 划入 11 个
   capability 组,manifest 未声明的组被拒。
3. **RPC 边界**(corehandler.Handle 入口):被拒时返回**明确错误**而非
   静默忽略。

第 3 条针对一类真实故障:C ABI 时代 case 23/24(事件订阅)是空实现,
返回成功但永远收不到事件(§1.3 的「给不了」而非「不给」),插件作者无从得知。
错误消息含四要素:哪个插件、哪个调用、缺什么能力、在哪声明。

## 能力划分的两个判断

**粒度按能力域而非单 method**。逐 method 授权看似更精细,但插件作者要在
manifest 里列 60 个名字,且内核每加 method 所有 manifest 都得改。

**空声明 = 不受限,而非「只有 core」**。17 个存量插件的 plugin.json 都没有
capabilities 字段。若空声明当作最小权限,它们会全部失去 IO 注入、记忆读写
而**静默降级**——违反「外部插件零改动」的硬约束。收紧的路径是让插件显式
声明,而不是默默拒绝老插件。

## core 与受限能力的边界

core(无需声明,始终可用):注册自身工具/阶段/通道/API、读写**自己的**配置、
共享段锁仲裁、握手、autoRestart 自述、setToolBlocks。没有这些插件无法工作。

受限(需声明):io / memory / doc_memory / knowledge / text_memory / llm /
social / events / plugin_mgr / settings_cross。

settings 刻意拆成两级:读写自己的配置属 core(正常工作所需),读写**其他插件**
配置或**内核核心**配置属 settings_cross(能改别人/内核的行为)。

## withheldCapabilities:让「不给」可见

10 项刻意不提供的内核内部机制列在表里并附理由。它们没有对应 method 常量——
不是忘了加,是决定不加。列表存在本身就是「这是策略而非疏漏」的证据,
读代码的人能看到边界在哪,而不是从「protocol.go 里没有」这个负面事实去推断。

## 测试

proc 包 10 项:
- AllMethodsClassified:**最重要的一项**。漏登记的 method 会按 CapCore 放行,
  等于绕过整套检查。新增 method 忘登记时当场报出。
- EmptyDeclarationIsUnrestricted / DeclaredSetRestrictsOthers / CoreAlwaysAllowed
- SettingsScopeSeparation:自身配置 vs 跨插件配置的归属
- DeniedErrorIsActionable:错误消息四要素
- HandleEnforcesAtRPCBoundary:被拒的调用不进 switch
- WithheldListIsDocumented:每项都有理由,且不被任何 method 暴露
- UnknownMethodFallsThrough:未知 method 报「未知」而非「权限被拒」,
  否则作者会以为是漏声明能力

写这个测试时踩到自己的坑:第一版用子串匹配查 withheld 泄漏,"Tool" 匹配到
tool.register 和 io.setToolBlocks 误报——那两个是合法开放的(注册自己的工具)。
改成前缀 + unregister 关键字匹配,withheld 项也改名带 API 后缀以示区分。

internal/plugins 2 项接线验证:
- RestrictedPluginStillLoads:只声明 io 的 weather 仍能加载并注册工具
  (它在 Start 里读 Settings,属 core)
- LegacyManifestUnrestricted:无 capabilities 字段的存量插件正常加载

真实 homed 实测:
  [plugin] weather-capped 声明能力: [io]
  [plugin] weather-capped: 经 proc 通道加载(子进程)
  registering tool: weather-capped_current / _forecast / _set_location

验证:go build ./... 通过;go test ./... 全仓无失败;
go test -race ./internal/plugin/... 全绿;go vet 干净。

Ref: docs/zh/架构迁移评估.md §3.8、docs/zh/plugin-migration-plan.md Part 6.4
2026-09-02 21:27:13 +08:00

242 lines
7.1 KiB
Go
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.

package proc
import (
"context"
"encoding/json"
"fmt"
"log"
"sync"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// Plugin 是 registry 可加载的子进程插件,与内置插件同构的启停接口。
//
// 生命周期:
//
// New() 创建(尚未 spawn
// Start(core) spawn 子进程 → 握手(传共享段 fd→ plugin.init → plugin.start
// plugin.start 期间插件反向注册工具/阶段/通道)
// Stop() plugin.stop → 宽限期 → 必要时 Kill
// Close() 强制结束registry 卸载/重载路径)
//
// **共享段不属于 Plugin**:它属于 Host被全部子进程插件共享。
// 若每插件一段,「内核 ctx → 段 → 插件改 → 回读 ctx」在多插件下会退化成
// 副本模型lost update 原样复现§8.4)。
type Plugin struct {
name string
bin string
dir string
config map[string]interface{}
host *Host
proc *Process
handler *coreHandler
// env 追加到子进程环境变量(测试用;生产由 registry 按需设置)。
env []string
// onCrash 由 registry 注入,把进程退出喂给 plugin_health.recordCrash§2.3)。
onCrash func(name string, err error)
// caps 是 manifest 声明的能力集§3.8 权限梯度)。
caps *capabilitySet
stopOnce sync.Once
}
// New 创建子进程插件(不启动进程)。
//
// host 必须是全部子进程插件共用的实例(由 registry 创建一次)。
// New 创建子进程插件(不启动进程)。
//
// host 必须是全部子进程插件共用的实例(由 registry 创建一次)。
// capabilities 来自 manifest 的 capabilities 字段;为空时不限制(存量插件向后兼容)。
func New(name, bin, dir string, config map[string]interface{}, host *Host, onCrash func(string, error), capabilities ...string) *Plugin {
return &Plugin{
name: name,
bin: bin,
dir: dir,
config: config,
host: host,
onCrash: onCrash,
caps: newCapabilitySet(capabilities),
}
}
// Name 实现 sdk.Plugin。
func (p *Plugin) Name() string { return p.name }
// Start 启动子进程并完成注册。
//
// core 是内核为该插件构建的能力面internal/sdk.PluginSDK 天然满足 CoreSDK
func (p *Plugin) Start(core CoreSDK) error {
if p.host == nil {
return fmt.Errorf("proc: %s 缺少共享段 Host", p.name)
}
p.handler = &coreHandler{
sdk: core,
name: p.name,
host: p.host,
locks: p.host.locks,
evtRing: p.host.evtSubscriber,
caps: p.caps,
}
// 反向调用闭包:注册回调时捕获,运行期经 RPC 打到插件进程。
p.handler.invokeTool = p.invokeTool
p.handler.invokeStageFn = p.invokeStage
p.handler.invokeOutput = p.invokeOutput
proc, err := Spawn(p.name, p.bin, Options{
Dir: p.dir,
// 共享段的传递机制按平台不同shmpass_*.go
// Unix 经 ExtraFiles 传继承 fd 3=StageContext, 4=事件环, 5=通知);
// Windows 无 fd 继承语义,改用命名内核对象,名字经环境变量传入。
Env: append(p.env, p.host.procEnvForShm()...),
ExtraFiles: p.host.procExtraFilesForShm(),
ShmSize: p.host.shmSize,
EvtRingSize: evtTotalSize,
Handler: p.handler.Handle,
OnExit: p.handleExit,
})
if err != nil {
return err
}
p.proc = proc
// plugin.init构造插件实例
if _, err := proc.Call(MethodPluginInit, PluginInitParams{
Name: p.name,
Config: p.config,
}); err != nil {
proc.Kill()
return fmt.Errorf("proc: %s plugin.init 失败: %w", p.name, err)
}
// plugin.start插件在此期间反向注册工具/阶段/通道
if _, err := proc.Call(MethodPluginStart, nil); err != nil {
proc.Kill()
return fmt.Errorf("proc: %s plugin.start 失败: %w", p.name, err)
}
return nil
}
// Stop 优雅停止(实现 sdk.Plugin
func (p *Plugin) Stop() error {
var err error
p.stopOnce.Do(func() {
if p.proc != nil {
err = p.proc.Stop()
}
})
return err
}
// Close 强制结束子进程。
//
// **这里是真 kill + wait**——对比 cabi 路径的 Close 只做 dlclose
// 而 dlclose 对 Go c-shared 是 no-op§1.1,热重载静默失效的根因)。
func (p *Plugin) Close() error {
var err error
p.stopOnce.Do(func() {
if p.proc != nil {
err = p.proc.Kill()
}
})
return err
}
// handleExit 在子进程退出时把信号喂给 plugin_health§2.3 逻辑复用),
// 并释放该插件可能持有的 stage 锁。
//
// 后者是"锁仲裁回内核"的自愈价值:持锁者死亡不会导致全局死锁,
// 无需 robust pthread_mutex实验 9
func (p *Plugin) handleExit(name string, err error) {
if p.host != nil && p.host.ForceReleaseLock(name) {
log.Printf("[proc] %s 退出,内核已释放其持有的 stage 锁", name)
}
if err != nil && p.onCrash != nil {
p.onCrash(name, err)
}
}
// ---- 内核 → 插件的反向调用 ----
func (p *Plugin) invokeTool(name string, args map[string]interface{}) (interface{}, error) {
if p.proc == nil {
return nil, ErrProcessExited
}
raw, err := p.proc.Call(MethodToolInvoke, ToolInvokeParams{Name: name, Args: args})
if err != nil {
return nil, err
}
var res ToolInvokeResult
if err := json.Unmarshal(raw, &res); err != nil {
return nil, fmt.Errorf("proc: %s 工具 %s 应答解析失败: %w", p.name, name, err)
}
return res.Result, nil
}
func (p *Plugin) invokeStage(ctx context.Context, stage string, seq uint64) error {
if p.proc == nil {
return ErrProcessExited
}
raw, err := p.proc.CallContext(ctx, MethodStageInvoke, StageInvokeParams{
Stage: stage,
Seq: seq,
})
if err != nil {
return err
}
var res StageInvokeResult
if len(raw) > 0 {
if err := json.Unmarshal(raw, &res); err != nil {
return fmt.Errorf("proc: %s stage %s 应答解析失败: %w", p.name, stage, err)
}
}
if res.DirtyFields > 0 {
log.Printf("[proc] %s stage %s 改写了 %d 个字段", p.name, stage, res.DirtyFields)
}
return nil
}
// invokeOutput 经插件输出通道发送,**同步等待真实结果**。
//
// 这是 §9.4 的根治C ABI 下 cgo 不可嵌套,只能异步 fire-and-forget
// 导致 output_send 永远返回 {status:queued} + err=nil模型永远以为发送成功
// (现网 7 天内 2 次消息实际发不出)。进程模型下 RPC 天然可等应答。
func (p *Plugin) invokeOutput(channel string, args map[string]interface{}) (interface{}, error) {
if p.proc == nil {
return nil, ErrProcessExited
}
raw, err := p.proc.Call(MethodOutputInvoke, OutputInvokeParams{
Channel: channel,
Args: args,
})
if err != nil {
return nil, err // 真实失败上报,模型可感知并重试
}
if len(raw) == 0 {
return map[string]interface{}{"status": "sent"}, nil
}
var res map[string]interface{}
if err := json.Unmarshal(raw, &res); err != nil {
return map[string]interface{}{"status": "sent"}, nil
}
if _, ok := res["status"]; !ok {
res["status"] = "sent"
}
return res, nil
}
// 编译期确认 Plugin 具备 registry 需要的启停形状。
var _ interface {
Name() string
Stop() error
Close() error
} = (*Plugin)(nil)
// 引用一下公开 SDK确保本文件的类型假设与它同版本。
var _ = pubsdk.StageScopeGlobal