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 失败
This commit is contained in:
JianFeeeee
2026-10-22 21:17:05 +08:00
parent f850d754c1
commit b20e32d81d
7 changed files with 117 additions and 25 deletions

View File

@ -141,34 +141,57 @@ type ProxyDef struct {
Auth string `json:"auth,omitempty"`
}
// ProxyAuth 取值。空串按 ProxyAuthHomeAgent 处理(安全的默认)。
// ProxyAuth 取值。空串按 ProxyAuthAgent 处理(安全的默认)。
const (
// ProxyAuthHomeAgent 表示由 HomeAgent 统一保护:浏览器走门户会话
// (homeagent_session cookie),非浏览器客户端走 X-API-Key。
// ProxyAuthAgent 表示由内核统一保护:浏览器走门户会话
// (HologramAgent session cookie),非浏览器客户端走 X-API-Key。
// 两者都没有时返回 401,而不是把请求透传给上游。
ProxyAuthHomeAgent = "homeagent"
ProxyAuthAgent = "agent"
// ProxyAuthNone 表示不经 HomeAgent 鉴权,直接把请求转发给上游。
// ProxyAuthNone 表示不经内核鉴权,直接把请求转发给上游。
//
// 适用场景:上游自己有鉴权且调用方不是浏览器(设备/嵌入式客户端),
// 或上游是刻意公开的服务。选用它意味着**信任上游自身的鉴权**,
// 且该服务在网络层可达范围内对所有人开放。
ProxyAuthNone = "none"
// ProxyAuthAgentLegacy 是旧值的字面量,**只用于兼容读取**,不要在新声明里写它。
//
// 为什么必须继续接受(2026-10-22):`Auth` 是写进 plugin.json 的**持久化
// 声明值**,存量插件的配置文件里已经写着旧值(本机实测 huawei_smarthome 的
// plugin.json:19 就是 "auth": "homeagent")。删掉这个分支 ⇒ 那些插件在加载
// 时被判为非法声明,反代直接不生效,且症状是"插件好好的但页面打不开"。
//
// 这正是 sdkModuleNames 处理 module 路径改名时的同一条教训:**协议值的
// 改名必须保留旧值的读取路径**,否则存量数据无声失效。
ProxyAuthAgentLegacy = "homeagent"
)
// ValidProxyAuth 校验 Auth 取值;空串合法(等价 ProxyAuthHomeAgent)。
// ProxyAuthHomeAgent 是 ProxyAuthAgent 的旧名,仅为**存量源码**保留。
//
// Deprecated: 用 ProxyAuthAgent。存量插件的 plugin.json 里写的旧值仍被
// ValidProxyAuth/EffectiveProxyAuth 接受(见 ProxyAuthAgentLegacy)。
const ProxyAuthHomeAgent = ProxyAuthAgent
// ValidProxyAuth 校验 Auth 取值;空串合法(等价 ProxyAuthAgent)。
//
// 同时接受旧值 ProxyAuthAgentLegacy —— 存量 plugin.json 里写的仍是它。
func ValidProxyAuth(auth string) bool {
switch auth {
case "", ProxyAuthHomeAgent, ProxyAuthNone:
case "", ProxyAuthAgent, ProxyAuthNone, ProxyAuthAgentLegacy:
return true
}
return false
}
// EffectiveProxyAuth 返回生效的鉴权模式(空串归一化为 ProxyAuthHomeAgent)。
// EffectiveProxyAuth 返回生效的鉴权模式(空串与旧值都归一化为 ProxyAuthAgent)。
//
// 归一化旧值是关键:下游一律只可能拿到 ProxyAuthAgent,不必各自兼容旧值
// (否则每个判断点都要写 `== ProxyAuthAgent || == ProxyAuthAgentLegacy`,
// 漏一处就是鉴权绕过)。
func EffectiveProxyAuth(auth string) string {
if auth == "" {
return ProxyAuthHomeAgent
if auth == "" || auth == ProxyAuthAgentLegacy {
return ProxyAuthAgent
}
return auth
}