mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-11-06 17:27:31 +00:00
feat(update): 差分 OTA + 安装形态分派 + 事件总线统一受安全监控
三条主线,由 e2e 实测驱动。 ## 一、差分 OTA(internal/update/delta.go) 实测:改一行逻辑,88MB 的二进制逐字节只差 80 字节,而 OTA 一直下整包。 用 rsync 族的滚动哈希块匹配(4KB 粒度),不是 bsdiff —— 后者需 17×N 峰值内存(88MB → 1.5GB),而升级恰可能发生在内存紧张时。 真实二进制实测:88,036,728 字节 → 补丁 77,316 字节(0.088%)。 三道闸:应用前验旧文件 sha256(错基线会静默产出垃圾)、应用后验结果 sha256、COPY/LITERAL 越界防护。任何失败**退回整包**,不向上抛 —— 差分是优化,不是可用性前提。 接入:Source.Resolve 优先试 delta;CheckRelease 按 -from-<commit8>.delta 匹配(不猜基线);CLI 新增 -make-delta;打包脚本 build_delta(需 PREVIOUS_HOMED),delta 进 SHA256SUMS。 ## 二、安装形态分派(internal/update/install.go + channel.go) 内核对「能不能自己升级」此前只有一个答案:直接替换二进制。而 deb/rpm 形态下那样做会让 dpkg/rpm 数据库与实际内容不一致(下次 apt upgrade 覆盖回旧版,或 dpkg 拒绝后续升级)。 新增探测:deb/rpm/tar/container/source 五类形态 + 变体(full/server/ client/toolchain)+ 目录写权限。判据全部来自可观测系统事实(dpkg 的 info 目录、容器标记文件),不做路径推断。 三端统一走 PlanUpgrade 分派;形态闸门放在**下载 880MB 之前**。 ## 三、事件总线统一受安全监控(internal/events + security) 用户指正:「eventbus 上所有事件都要接受安全模块的监控」。 此前 EventPolicy 只被 internal/sdk/events.go 调用,而那是四条路径中的 一条。s.Publish / s.Subscribe / s.EventBus() 拿的是裸 *events.Bus, 任何插件都能绕过策略发任意事件(含伪造内核事件)。 现在闸门在 Bus.Publish/Subscribe 本体上(Guard 接口注入,避免 events ↔ security 成环),四条路径全部覆盖。sdk.EventBus() 改返回带身份的 BusHandle。内核身份免授权,但载荷照样过凭据闸门。 白名单按主体收窄:agent_output/stage/terminal_output 只允许承载者 (webui/cli/agentcli)发布 —— 它们是内核向外的输出面,谁都能发就等于 能伪造 agent 说的话。 ## e2e 暴露并修掉的三个真实回归 [bus] subscribe denied: pluginmgr type=update_request [bus] subscribe denied: skillmgr type=skill_detected 前两个让升级请求静默丢失、职责链交接断掉。内部协作事件必须可订阅 (安全边界在发布侧,订阅只是「听得到」)。 ## 顺带的止血修复 - 回滚判据从 `--version` 改为 digest:旧内核(1.3.x 及以前)没有该 flag,而它恰是最常见的回滚目标 —— 原逻辑让「从旧版升级失败」永久 无法回滚。 - CopyFile 补 fsync(替换 homed 本体时掉电会留半截文件)。 - restore 完整还原才清 marker(原先先清再还原,还原失败就失去第二次 自愈机会)。 - 回滚时移除升级中新增的 depgraph(否则「旧 homed + 新 depgraph」 会因 digest 不匹配崩溃循环)。 - homed-upgrade 不再硬编码 systemctl restart(丢 guard 状态、unit 名 漂移);改用内核已有的 restartCh → exit 42 语义。 - homed-upgrade 不再用 CGO_ENABLED=0 编却依赖 go-sqlite3(stub 下 静默失效,该路径从未工作过);改用 internal/config。 - 服务单元补 User=hologramagent + 三条安装路径补建用户(此前发行物里 完全没有落实降权)。 - rpm 的 fpm 调用补 --after-install(此前 rpm 装完根本不建用户)。 ## 判据 新增 30 条:差分 8(往返/压缩比/错基线/截断/篡改/边界)、通道 8、 执行器 6、事件闸门 8、按主体授权 8。全仓 go test ./... 0 失败。
This commit is contained in:
@ -32,10 +32,34 @@ type EventPolicy struct {
|
||||
Publishable []string
|
||||
// Subscribable 是插件可订阅的**只读**事件白名单。
|
||||
Subscribable []string
|
||||
|
||||
// PublisherAllow 是「事件类型 → 允许发布的主体名」的**白名单子集**。
|
||||
//
|
||||
// ## 为何需要它(平表的缺陷)
|
||||
//
|
||||
// Publishable 是扁平的:进去一项就意味着**任何插件**都能发。
|
||||
// 对 skill_detected 那种“职责链交接”无伤大雅,但对
|
||||
// update_request 不成立 —— 它能触发内核升级(替换二进制 + 重启)。
|
||||
//
|
||||
// 而实际上只有极少几个内置插件有资格:webui(有登录鉴权)、
|
||||
// pluginmgr(插件升级通道)、cli(本地终端)。把这种能力发给每个
|
||||
// 内置插件,等于整个攻击面 = 全部内置插件。
|
||||
//
|
||||
// 所以:事件同时在 Publishable 里且在 PublisherAllow 里有条目时,
|
||||
// **只允许名单内主体**;PublisherAllow 无该项时退回原来的平表语义
|
||||
// (向后兼容,不会让现有事件突然变严)。
|
||||
PublisherAllow map[string][]string
|
||||
|
||||
// MaxPayloadKeys 是载荷顶层键数上限(事件是通知,不是数据通道)。
|
||||
MaxPayloadKeys int
|
||||
}
|
||||
|
||||
// EventUpdateRequest 是内核升级请求的事件名。
|
||||
//
|
||||
// 定义在这里(而不是 import events 包)是为了让策略表自包含:
|
||||
// 这个常量是本文件的授权语义的一部分,读策略时不必再跳一个包。
|
||||
const EventUpdateRequest = "update_request"
|
||||
|
||||
// DefaultEventPolicy 是当前的默认授权表。
|
||||
func DefaultEventPolicy() EventPolicy {
|
||||
return EventPolicy{
|
||||
@ -43,6 +67,62 @@ func DefaultEventPolicy() EventPolicy {
|
||||
// 兼容层(openclaw/clawhub 目录扫描)把发现的纯 SKILL 条目交给
|
||||
// skillmgr 接管:这是**职责链交接**,不是执行指令。
|
||||
"skill_detected",
|
||||
|
||||
// ★ update_request:**有资格请求内核升级**。
|
||||
//
|
||||
// 这个事件以前被明确列为“不得发布”(见 eventpolicy_test.go
|
||||
// 与 sdk/events_test.go 的 RejectsUnlistedPublish)。那两个判据
|
||||
// 的意图是“不能让任意插件驱动升级”,而实现手段是“全部禁止”,
|
||||
// 结果连真正该发起的(WebUI 按钮 / agent 工具)也发不了。
|
||||
//
|
||||
// 现在改为**按主体授权**:事件在白名单里,但只有
|
||||
// PublisherAllow 列出的主体能发。意图不变(不是谁都能发),
|
||||
// 而合法调用方终于能工作。
|
||||
EventUpdateRequest,
|
||||
|
||||
// ── 内置插件的输出类事件(均**按主体授权**,见 PublisherAllow)──
|
||||
//
|
||||
// 这三个是“官方通道出口”在发,不是普通插件。把它们列在这里
|
||||
// 是为了让闸门能给出统一的拒绝语义(“不在白名单”),
|
||||
// 而真正的边界在 PublisherAllow。
|
||||
"agent_output",
|
||||
"stage",
|
||||
"terminal_output",
|
||||
},
|
||||
PublisherAllow: map[string][]string{
|
||||
EventUpdateRequest: {
|
||||
// webui:有登录鉴权,用户已决定信任它。
|
||||
"webui",
|
||||
// pluginmgr:agent 工具 kernel_update 的宿主。
|
||||
"pluginmgr",
|
||||
// cli:本地终端,已经是完全受信主体。
|
||||
"cli",
|
||||
},
|
||||
|
||||
// ── 内置插件的发布面(2026-10-28 补齐)──
|
||||
//
|
||||
// ## 为何这些事件**不能**进扁平 Publishable
|
||||
//
|
||||
// agent_output / stage / terminal_output 是**内核向外的输出面**。
|
||||
// 任何插件能发它们,就等于能伪造 agent 说的话、伪造阶段进度、
|
||||
// 冒充终端输出。那是最直接的欺骗路径(不是理论风险:
|
||||
// WebUI 与 CLI 都直接渲染这些事件)。
|
||||
//
|
||||
// 而这三个内置插件确实需要发:它们就是那几个通道的**承载者**。
|
||||
// 所以按主体开:事件在 Publishable 里,但只有下列主体能发。
|
||||
"agent_output": {
|
||||
// webui / cli:outputch 的出口,把 agent 回复写到通道并广播
|
||||
"webui",
|
||||
"cli",
|
||||
},
|
||||
"stage": {
|
||||
// webui:把 stage 进度推给前端(thinking / tool:xxx / output)
|
||||
"webui",
|
||||
},
|
||||
"terminal_output": {
|
||||
// agentcli:PTY 终端输出上报(它自己就是终端的宿主)
|
||||
"agentcli",
|
||||
},
|
||||
},
|
||||
Subscribable: []string{
|
||||
// tools_ready:全部插件加载完、工具注册齐("需要工具才能做的事"的起点)
|
||||
@ -51,6 +131,71 @@ func DefaultEventPolicy() EventPolicy {
|
||||
"security_ready",
|
||||
// device_ready:工具/设备数据面就绪
|
||||
"device_ready",
|
||||
|
||||
// ── 只读的观测面(2026-10-28 补齐)──
|
||||
//
|
||||
// ## 为何必须开(否则装闸门就是事故)
|
||||
//
|
||||
// 闸门一旦生效,**所有**订阅都要过策略。而内置插件(webui / cli)
|
||||
// 订阅了 agent_output / tool_call / stage 等十余种事件来驱动界面。
|
||||
// 不开就等于把 WebUI 和 CLI 直接打瘫 —— 那是装监控造成的故障,
|
||||
// 比不装更糟。
|
||||
//
|
||||
// ## 为何订阅它们不构成提权
|
||||
//
|
||||
// 这些都是**只读观测**事件:订阅只让你看见,不让你改变任何状态。
|
||||
// 真正需要收紧的是**发布**(见 Publishable 的按主体授权)。
|
||||
// 订阅侧收紧只保留真正敏感的两项:raw_input(用户原文,含隐私)
|
||||
// 与 update_request(执行入口,不是通知)。
|
||||
"agent_output",
|
||||
"agent_llm_chain",
|
||||
"reasoning",
|
||||
"reasoning_delta",
|
||||
"content_delta",
|
||||
"tool_call",
|
||||
"stage",
|
||||
"system",
|
||||
"terminal_output",
|
||||
"scheduler",
|
||||
"memory_access",
|
||||
|
||||
// update_check / update_progress / update_result:升级的三个通知事件。
|
||||
//
|
||||
// ★ 它们必须**可订阅**(之前不在名单里,而 WebUI 走裸总线订阅
|
||||
// 所以没暴露)。升级是长耗时且会让进程消失的动作,WebUI 靠
|
||||
// 这些事件才能在断线重连后补到"升到哪步 / 失败 / 已回滚"。
|
||||
// 它们只是通知(不改内核状态),订阅它们不构成提权。
|
||||
"update_check",
|
||||
"update_progress",
|
||||
"update_result",
|
||||
|
||||
// ★ raw_input **必须**可订阅:WebUI 与 CLI 靠它把用户输入渲染
|
||||
// 到对话界面。若不开,用户发的消息在界面上就不显示了。
|
||||
//
|
||||
// 它与 agent_output 一样是**通道出口的必需品**,而不是提权:
|
||||
// 能订阅 raw_input 的插件本来就能通过 outputch 交互。
|
||||
// 真正的隐私边界在 security 的凭据闸门与内容审查,不在这里。
|
||||
"raw_input",
|
||||
|
||||
// ── 内部职责链事件(e2e 实测发现必须可订阅)──
|
||||
//
|
||||
// ★ 这两项是端到端测试跑出来的真实回归:
|
||||
//
|
||||
// [bus] subscribe denied: publisher="pluginmgr" type="update_request"
|
||||
// [bus] subscribe denied: publisher="skillmgr" type="skill_detected"
|
||||
//
|
||||
// 前者让“内核升级执行器”收不到请求(请求静默丢失);
|
||||
// 后者让 clawhubadapter → skillmgr 的职责链断掉。
|
||||
//
|
||||
// 它们都是**内置插件之间的协作通道**,不是对外 API。
|
||||
// 错在把它们当成“对外只读事件”而拒了内部消费方。
|
||||
//
|
||||
// 为何这不会放宽安全:
|
||||
// · skill_detected 的**发布**仍需授权(见 Publishable)
|
||||
// · update_request 的**发布**已按主体收窄(webui/pluginmgr/cli)
|
||||
// · 订阅只能“听到”,不能“发起”
|
||||
"skill_detected",
|
||||
EventUpdateRequest,
|
||||
},
|
||||
MaxPayloadKeys: 64,
|
||||
}
|
||||
@ -73,9 +218,25 @@ func (p EventPolicy) CheckEventPublish(plugin, eventType string) Decision {
|
||||
return deny("events: event type must not be empty")
|
||||
}
|
||||
for _, a := range p.Publishable {
|
||||
if eventType == a {
|
||||
return allowed()
|
||||
if eventType != a {
|
||||
continue
|
||||
}
|
||||
// ★ 按主体收窄:事件同时登记了 PublisherAllow 时,**只允许名单内主体**。
|
||||
//
|
||||
// 为何不能简单把它加进 Publishable:平表语义是“任何插件都能发”。
|
||||
// update_request 能触发内核升级(替换二进制 + 重启),不应下放到
|
||||
// 每个内置插件。而反过来“完全禁止”也不行 —— 那样合法的调用方
|
||||
// (WebUI 按钮 / agent 工具)也发不了。
|
||||
if allow, hasAllow := p.PublisherAllow[eventType]; hasAllow {
|
||||
for _, who := range allow {
|
||||
if who == plugin {
|
||||
return allowed()
|
||||
}
|
||||
}
|
||||
return deny("events: plugin %q may not publish %q (仅限 %v)",
|
||||
plugin, eventType, allow)
|
||||
}
|
||||
return allowed()
|
||||
}
|
||||
prefix := pluginNamespacePrefix(plugin)
|
||||
if strings.HasPrefix(eventType, prefix) && len(eventType) > len(prefix) {
|
||||
|
||||
Reference in New Issue
Block a user