Files
HomeAgent/internal/sdk/proxy.go
JianFeeeee b20e32d81d feat(outputch): 提示词显式声明「必须调 outputch 才送达」+ 契约值改名 Agent
## ① 提示词:只有显式调用 outputch 才会送达用户(用户明确要求)

模型最常见的错误不是不会调工具,而是**以为返回文本就等于回复了** ——
在异步通道(qq/wechat/群聊)上那等于自言自语,用户永远收不到。

改动前【输出规则】开篇是「消息不会自动发送到对话来源通道,你必须自己决定
如何回复」,语气偏描述。改为开篇总述句:

    **只有显式调用 `output_send__{通道名}` 才会把内容送达用户。**
    返回纯文本只是在这次推理里说话,用户看不到 —— 除非是下面说的同步通道。

同时补上本次内核新增能力:payload 传数组可一次发多条。

## ② 契约值改名:ProxyAuthHomeAgent="homeagent" → ProxyAuthAgent="agent"

用户指示契约值一起改。常量名与字符串值同时改,但**保留旧值读取路径**:

    ProxyAuthAgent       = "agent"      ← 新值
    ProxyAuthAgentLegacy = "homeagent"  ← 只用于兼容读取
    ProxyAuthHomeAgent   = ProxyAuthAgent  (Deprecated 别名,存量源码可编译)

为什么必须保留:`Auth` 是**写进 plugin.json 的持久化声明值**。存量配置里
写着旧值 —— 本机实测 `/home/newqqagent/plugins/huawei_smarthome/plugin.json:19`
就是 `"auth": "homeagent"`。只认新值 ⇒ 该插件加载时被判为非法声明 ⇒
反代不生效,症状是「插件好好的但页面打不开」,且无任何报错指向改名。

### 归一化是安全关键

`EffectiveProxyAuth` 把**空串与旧值都归一化为 ProxyAuthAgent**。

判断点全都写成 `auth == ProxyAuthAgent`。若不归一化,就得每点写两遍比较
—— **漏一处就是鉴权绕过**:旧值被判为「非门户鉴权」⇒ 跳过 authorizeProxy
⇒ 请求直接透传给上游。所以兼容收在一个函数里,不散落在调用点。

### 顺带修掉两处隐患

1. `webui/proxy.go` 的裸字面量 `"homeagent"`(非法行占位)改为
   `sdk.ProxyAuthAgent` —— 裸字面量在改名时会被漏掉,届时它悄悄变成
   未知值,而未知值在旧代码里等于「无鉴权」。
2. 两处鉴权判断改用 `EffectiveProxyAuth(...)`,堵住旧值绕过。

## 判据

| 判据 | 覆盖 | 变异自证 |
|---|---|---|
| `TestSystemPromptRequiresExplicitOutputCall` | 总述句必须存在、必须说明「返回文本用户看不到」、同步通道是唯一例外、payload 可传数组 | 删掉总述句 ⇒ 红 |
| `TestProxyAuthLegacyValueStillAccepted` | 旧值仍被接受 + 必须归一化为新值 | 去掉归一化分支 ⇒ 「旧值应归一化为 "agent",实际 "homeagent"」红 |

## 验证

- `go build ./...` / `go vet ./...` 干净
- webui / internal-sdk / agent-core 全绿
- SDK 仓(独立提交 acd1166):`go build` + `go test ./...` 0 失败
2026-10-22 21:17:05 +08:00

122 lines
4.3 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 sdk —— 反代声明的内核侧登记表。
//
// 契约定义在公开 SDK(pubsdk.ProxyDef)。本文件只做两件事:
//
// 1. 把公开类型重新导出,让内核侧调用方只需 import 本包。
// 2. 维护**内置插件**的声明表(它们没有 plugin.json,扫目录发现不了)。
//
// 外部插件的声明由反代层直接读 plugin.json 得到,不经过这里 —— 那份声明
// 是静态的、插件没启动也可见,而内置插件的声明只能运行期拿到。两种来源在
// 反代层合并(见 webui.buildProxyTable)。
package sdk
import (
"strings"
"sync"
pubsdk "github.com/JianFeeeee/homeagentsdk/sdk"
)
// ProxyDef 是一条反代声明(见 pubsdk.ProxyDef 的完整文档,含「单一入口原则」)。
// 与 ConfigDef / ChannelDef / ToolDef 同族:SDK 定契约,内核实现行为。
type ProxyDef = pubsdk.ProxyDef
// 鉴权与 Host 规范化的常量/函数转发(调用方不必两处 import)。
const (
// ProxyAuthAgent 表示由内核统一保护(门户会话或 X-API-Key)。
ProxyAuthAgent = pubsdk.ProxyAuthAgent
ProxyAuthNone = pubsdk.ProxyAuthNone
// ProxyAuthAgentLegacy 是旧值 "homeagent":存量 plugin.json 里写的仍是它。
// 只用于兼容读取;判断时一律先过 EffectiveProxyAuth 归一化。
ProxyAuthAgentLegacy = pubsdk.ProxyAuthAgentLegacy
// ProxyAuthHomeAgent 是 ProxyAuthAgent 的旧名,仅为存量源码保留。
//
// Deprecated: 用 ProxyAuthAgent。
ProxyAuthHomeAgent = pubsdk.ProxyAuthHomeAgent
)
// ValidateProxyDef 校验一条声明,返回人类可读的错误(合法时为空)。
func ValidateProxyDef(d ProxyDef) string { return pubsdk.ValidateProxyDef(d) }
// EffectiveProxyAuth 返回生效的鉴权模式(空串与旧值都归一化为 ProxyAuthAgent)。
func EffectiveProxyAuth(auth string) string { return pubsdk.EffectiveProxyAuth(auth) }
// ValidProxyAuth 判断鉴权取值是否合法。
func ValidProxyAuth(auth string) bool { return pubsdk.ValidProxyAuth(auth) }
// NormalizeProxyHost 由插件名推导默认的 Host 标签。
func NormalizeProxyHost(plugin string) string { return pubsdk.NormalizeProxyHost(plugin) }
// ValidProxyHostLabel 校验子域名标签是否合法(DNS label 规则)。
func ValidProxyHostLabel(label string) bool { return pubsdk.ValidProxyHostLabel(label) }
// 内置插件(编译进内核、无 plugin.json)的反代声明表。
//
// 版本号 builtinProxyVer 每次变更自增。反代层据此判断缓存的路由表是否过期 ——
// 比每次请求都重新聚合一遍便宜得多。
var (
builtinProxyMu sync.RWMutex
builtinProxyVer int64
builtinProxyDefs = map[string][]ProxyDef{}
)
// RegisterBuiltinProxy 登记一个内置插件的服务声明(由 RegisterProxy 转发)。
//
// ProxyDef.Name 由调用方保证非空(RegisterProxy 会在缺失时兜底为 "service")。
func RegisterBuiltinProxy(plugin, name string, d ProxyDef) {
if plugin == "" || strings.TrimSpace(d.Target) == "" {
return
}
if d.Name == "" {
d.Name = name
}
if d.Name == "" {
d.Name = "service"
}
builtinProxyMu.Lock()
defer builtinProxyMu.Unlock()
// 同名重复登记(如自动重启后再次 Start)视为刷新,不重复累积。
list := builtinProxyDefs[plugin]
for i := range list {
if list[i].Name == d.Name {
list[i] = d
builtinProxyVer++
return
}
}
builtinProxyDefs[plugin] = append(list, d)
builtinProxyVer++
}
// ClearBuiltinProxyDefs 清除某插件的声明(插件停止/卸载时调用)。
func ClearBuiltinProxyDefs(plugin string) {
builtinProxyMu.Lock()
defer builtinProxyMu.Unlock()
if _, ok := builtinProxyDefs[plugin]; ok {
delete(builtinProxyDefs, plugin)
builtinProxyVer++
}
}
// BuiltinProxyDefs 返回内置声明的快照(plugin → defs)。
func BuiltinProxyDefs() map[string][]ProxyDef {
builtinProxyMu.RLock()
defer builtinProxyMu.RUnlock()
out := make(map[string][]ProxyDef, len(builtinProxyDefs))
for k, v := range builtinProxyDefs {
cp := make([]ProxyDef, len(v))
copy(cp, v)
out[k] = cp
}
return out
}
// BuiltinProxyVersion 是声明表的版本号,供反代层判断缓存是否过期。
func BuiltinProxyVersion() int64 {
builtinProxyMu.RLock()
defer builtinProxyMu.RUnlock()
return builtinProxyVer
}