Commit Graph

11 Commits

Author SHA1 Message Date
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
f256098c9f refactor: 全面改名为 HologramAgent + 提示词技术内容下沉到 skill/工具描述 + 修 21 处乱码
## ① 全面改名 HomeAgent → HologramAgent

仓库已改名,但代码与文档里仍有大量旧名。本次按"用户能看到的优先"全量替换:

- 用户可见:登录页标题/页脚、默认人格第一句、CLI 状态输出、启动日志、
  plugin_install 的工具描述、AI 图片插件的 User-Agent
- 文档与脚本:README、assets/docs、docs/zh、deploy/*.sh、.github/workflows
- 鸿蒙工程:cmd/ohos/HomeAgent → cmd/ohos/HologramAgent(git mv)
  目录内的 module/app 描述同步;**证书路径原样保留**
  (default_HomeAgent_xxx.cer/p7b/p12 是签名材料的实际文件名,改了会找不到证书)
- 插件元数据:plugin.json 的 author

★ 必须保持原样的两处(改了会坏):
  · legacySeededPromptSignature = "你是 HomeAgent 的看板娘「小宅」…"
    —— 那是识别历史播种人格卡的**原文锚点**,改了就识别不出旧数据
  · 公开 SDK 的 ProxyAuthHomeAgent = "homeagent"
    —— 那是 SDK 契约里的值,属独立仓库,本仓不改

## ② 修 21 处乱码(U+FFFD)

这些是历史编辑留下的编码破坏,全在注释里,例如:
  「赌注重下���遍」→「赌注重下一遍」
  「「不��滤」」  →「「不过滤」」
  「**待��退场**」→「**待退场**」

★ 保留 providers/chineseclip/tokenizer.go 里的 2 处:那是**故意**用
  U+FFFD 演示"非法 UTF-8 字节被归一成 U+FFFD",不是乱码。

## ③ 提示词技术内容下沉(用户:技术性内容应该收敛到 skill 与 -help 中)

实测系统提示词 2309 字,其中两段几乎全是**模型无法据以行动的实现细节**:

  【工具执行顺序】1272 字(ParallelSafe 声明、批内降级、batchRunnable 判据…)
  【三层记忆】   1069 字(L1/L2/L3 编号、消费式删除、向量空间、指纹…)

收敛后 **2309 → 1723 字(-25%)**,保留的全是"模型据此做什么不同的事":

  工具顺序:默认并行 / 不要依赖先后 / 同通道保序 / 写类工具可能串行
  三层记忆:时序里能看最近 / doc_query 把历史事件按原始时间戳插回时序 /
           关系用 memory_recall / 成文知识用 knowledge_search

判断口径写进判据:新加一段提示词前先问"模型会据此做什么不同的事",
答不上来就该进 skill 或工具描述。

## 判据

prompt_memory_test.go 新增 2 条:
- TestSystemPromptHasNoImplementationJargon
  黑名单:ParallelSafe / batchRunnable / 消费式 / DenseCosine / fingerprint /
  向量空间 / 批内降级 / 自激
- TestSystemPromptStaysCompact:上限 2000 字(当前 1723)

变异:把 "ParallelSafe 声明、批内降级" 加回工具顺序段 ⇒ 第一条立刻红。

## 验证

- go build ./... / go vet ./... 干净
- go test ./... -count=1:58 包 0 失败
- make check-client-versions:客户端与内核版本一致
2026-10-22 20:49:44 +08:00
4e70b8d5d8 refactor(module): 核心仓 Go module 路径改为 github.com/JianFeeeee/HologramAgent
批 1,不含 SDK 仓与目录改名。

- go.mod 第 1 行 module 路径
- 366 个 .go 文件的完整前缀 github.com/JianFeeeee/HomeAgent 机械替换
  (857 处,仅完整前缀;展示名/注释里的 HomeAgent 字样留给批 5)
- 未改:third_party 目录名、SDK 自身 go.mod module、go.mod replace 目标、
  homed/waiter 可执行名、.hmap 后缀
2026-10-21 12:17:19 +08:00
6d38a164eb chore(module): Go module 路径随托管地迁移 gitcode → github
## 背景

仓库早已迁移到 GitHub(`github.com/JianFeeeee/HomeAgent`,CI/CD 都在那边跑),
gitcode 保留为**国内只读镜像**(`ci.yml` 的 sync-gitcode-main 快进推送)。
但 go.mod 的 module 路径一直是 `gitcode.com/JianFeeeee/...` —— 既与权威源
不符,`meta.SourceURL`(AGPL §13 的源码提供入口)也指向镜像而非实际运行的代码。

## 关键区分(这是本次改动安全的前提)

两个 module 是**不同**的,影响面差一个数量级:

| module | 谁依赖 | 改它的后果 |
|---|---|---|
| `.../HomeAgent`(主仓) | **只有主仓自己** | 内部重构,零外部影响 |
| `.../homeagent-sdk`(SDK 仓) | 主仓 49 文件 + 所有外部插件 | 需重编插件 |

★ 实测确认:**插件不 import 主仓 module**(`strings qq/plugin.bin | grep HomeAgent` = 0),
只依赖 SDK 仓。而插件与内核的边界是 **RPC 方法名字符串**
(`plugin.init` / `tool.invoke`…,见 proc/protocol.go),**与 Go module 名无关**。
⇒ module 改名**不改变任何 wire 协议**,现网已装的插件照常运行。

SDK 的 GitHub 仓名是 `homeagentsdk`(无连字符),module 路径必须与之精确匹配,
否则 `go get` 会因路径不符失败,故取 `github.com/JianFeeeee/homeagentsdk`。

## 改动

- `go.mod` / `go.sum`:两个 module 路径 + require/replace
- 277 个 Go 文件 + Makefile + 打包脚本的 import 与 ldflags 路径
- SDK 仓 `meta.CoreModule`、`tools/hmapdev` 的 module 声明与工程模板
- 对外文档/链接的仓库地址(README、PLUGIN_DEV、OVERVIEW 等)

## 明确**保留** gitcode 的地方(镜像机制,不能动)

- `GITCODE_TOKEN`、`GC_URL="…@gitcode.com"`(CI 同步 job)
- `gitcode.com/api/v5/...`(release 附件上传)
- git remote `gitcode`(镜像拉取/推送)
- `docs/zh/ci-cd-runbook.md`(整篇讲镜像运维)

## 验证

- `go build ./...` / `go vet ./...` 绿
- `go test ./... -count=1` → 48 包 ok / 0 FAIL
- hmapdev 工具链编译通过(`third_party/homeagent-sdk/tools/hmapdev`)
- 全仓残留检查:module 形态的 `gitcode.com/JianFeeeee` = 0
- 镜像机制抽验:GC_URL / @gitcode / api/v5 / remote 均完好

## 顺带修正

`hmapdev/templates.go` 的注释此前写「gitcode 的模块不在 proxy.golang.org 上」——
module 已迁 GitHub,该论断不再成立。改为如实说明**为何仍默认 replace 到本机 SDK**:
离线可构建、避免版本漂移、内网可用。理由与托管地无关,故 replace 保留。
2026-10-08 10:40:17 +08:00
8af12e0138 fix(webui): 服务入口「打开」改用路径形态 + 别名模式不补尾斜杠
验收时发现的**用户可见缺口**:API 早就同时返回 url(子域)与 url_portal
(路径),但「服务入口」卡片只用了 url —— 而子域形态在穿透部署下
**恰恰是打不开的那个**(外层只放行一个 Host、三级子域通配证书不匹配)。
用户点「打开」得到坏链接,还会以为是插件的问题。

## 改动

1. 卡片「打开」优先 url_portal(路径形态):
   无 DNS 依赖,单端口穿透 / 子域无证书时都能用。
   子域链接保留为次选按钮(局域网内直连时更直观)。
   文案补一句说明两者差别(子域需 DNS 能解析 `*.<基域名>`)。

2. **别名模式不再补尾斜杠**(顺带发现的 bug):
   原实现给所有 url_portal 无条件加 `/`。前缀模式下对(那是规范形态,
   前端靠它算相对路径基准);别名模式下错 —— 那里的 path 是上游真实
   路径语义(/api/v1/device 是 /api/v1/device/xxx 的前缀),补成
   /api/v1/device/ 会让人误以为存在一个可访问的根。

## 判据

+2 条:TestProxyServicesOffersBothForms(两种形态都必须给出,
且前缀模式的 url_portal 必须带尾斜杠)、
TestProxyServicesAliasKeepsExactPath(别名模式不得带尾斜杠)。

变异验证(2 条,均按预期打红后还原回绿):
- 别名模式也加尾斜杠 → 判红
- 前缀模式不加尾斜杠 → 判红

另修正一条旧判据的期望值:它当年断言的是「所有 url_portal 都带尾斜杠」
(即把 bug 当成契约钉住了)。那条路由正是设备网关(别名模式),
现在改为断言不补尾斜杠,并注明理由。

全量:35 包全绿。
2026-09-26 13:49:59 +08:00
23cb96f15a feat(webui): 路径挂载的 strip_path 两态 + 尾斜杠重定向(修 /p/huawei 打不开数据)
用户要求用方案 A(路径挂载)让 huawei 插件 UI 在外部可用,
并把「通过反代的插件必须使用单一入口」写入 SDK 声明。

## 实测暴露的两个真问题

1. **Path 的语义不能一刀切**。原设计「原样保留」只对**机器接口**成立
   (设备客户端硬编码 /api/v1/device/ws,不可能知道反代的存在);
   而自带 UI 的服务需要**剥掉前缀**(/p/huawei/api/status → 上游 /api/status)。
   猜错的结果是全部请求 404,且看起来像上游故障 —— 所以由声明者选:
   strip_path=false 别名模式 / true 前缀模式。非法组合被 validate 挡住。

2. **前缀模式的尾斜杠是必需的**(自测发现的 bug)。
   访问 /p/huawei(无尾斜杠)时页面能开,但页面里所有 fetch 都 404 ——
   相对路径以「当前文档目录」为基准,没尾斜杠时浏览器把最后一段当文件名,
   目录退回上一级,fetch('api/status') 打到 /p/api/status。
   修:前缀模式且路径恰等于前缀时 301 到 /p/huawei/(保留查询串)。
   **别名模式不做此事** —— 那类路径是上游真实语义,加斜杠会改坏它。

## 插件侧(huawei_smarthome)

- 前端 4 处根绝对路径(fetch('/api/status') 等)改为相对路径,
  基准由 location.pathname 推导(BASE)。这是 Path 形态能成立的**前提** ——
  否则请求会打到门户自己身上。
- plg.json 声明:host + path=/p/huawei + strip_path=true + auth=homeagent。
- SDK 升到 1.4.0,并用 hmapdev 1.4.0 重新打包(1.3.0 的 hmapdev 无
  proxies 支持,会把声明**静默丢弃** —— 实测确认过,这是打包链路上
  一个不报警的坑,值得记住)。

## 判据

+6 条:TestProxyPathAliasVsStrip(两态各自正确)、
TestProxyPathLongestPrefixWins(/p/app 不得劫持 /p/apple,
且长前缀胜出)、TestProxyStripPathRedirectsToTrailingSlash(尾斜杠,
含查询串保留 + 别名模式不得重定向)。

变异验证(4 条,均按预期打红后还原回绿):
- 删尾斜杠重定向 → 判红(还原了真实 bug 形态)
- 让别名模式也重定向 → 判红(设备网关语义被毁)
- 从 hmapdev schema 探测体删 StripPath → 判红(漂移检测有效)
- 删 SDK 里的「单一入口原则」字样 → 判红(契约不能只剩口头约定)

全量:35 包全绿。
2026-09-26 13:49:59 +08:00
83548bcc16 feat(webui): 外部入口 base_url 配置 —— 穿透场景下链接不再靠猜
用户指出 webui 实际是经 https://homeagent.jianfgit.xyz/ 穿透出去的,
应当支持配置 base URL。实测确认了这个诉求的正当性。

## 实测发现的约束(决定方案)

1. **子域形态在外部不可用**:`*.homeagent.jianfgit.xyz` 泛解析存在,
   但外层只给 `*.jianfgit.xyz` 通配证书 —— 该证书**不匹配三级子域**,
   实测 `huawei-smarthome.homeagent.jianfgit.xyz` 外部握手失败(HTTP 000)。
   外层只放行 `homeagent.jianfgit.xyz` 这一个 Host。
2. **路径挂载形态外部可用**:实测
   `https://homeagent.jianfgit.xyz/api/v1/device/online` → 200。
   所以「一个外部 Host + 路径挂载」是这条链路的正解,且已经工作。
3. 外层 nginx/WAF 会带 `X-Forwarded-Proto: https` 与 `X-Forwarded-Host`,
   因此即使不配置也能推出正确链接;配 base_url 则是显式兜底。

## 新增设置项 base_url

三级优先解析「对外入口」(resolveEntry):

  1. **配置项 base_url** —— 外部入口是部署事实,不该靠请求猜。
     经多层网关时请求可能带内网 Host,按它推导会拼出用户点不开的链接。
  2. **X-Forwarded-Proto / X-Forwarded-Host** —— 反代层给权威信息时可靠。
  3. **请求自身** —— 直连时的正确来源。

base_url 的主机名同时用作**子域反代的基域名**:入口是 homeagent.example.com
时,插件服务自然是 <标签>.homeagent.example.com。

服务清单另增 entry_url 字段,直接给出「外部入口是什么」,便于前端与排错。

## 生效点

- `/api/v1/proxy/services`:url / url_portal / base_domain / entry_url
- `/api/v1/device/gateway`:url / url_portal / http_url / host
- 两处原先各自推导 portalHost,现统一走 resolveEntry,避免再次漂移

## 部署后实测(生产,经真实外部入口)

  entry_url:   https://homeagent.jianfgit.xyz
  base_domain: homeagent.jianfgit.xyz
  remotedevice    | https://devices.homeagent.jianfgit.xyz
                  | https://homeagent.jianfgit.xyz/api/v1/device/   ← 外部可点
  huawei_smarthome| https://huawei-smarthome.homeagent.jianfgit.xyz

发现端点(外部视角):
  url_portal = wss://homeagent.jianfgit.xyz/api/v1/device/ws   ← 外部可连

## 判据

+3 条:TestBaseURLOverridesRequestDerived(内网 Host 场景下必须用 base_url)、
TestEntryPrefersForwardedHeaders(XFF 优先于请求自身)、
TestBaseURLTolerant(尾斜杠/空格容错 —— 手填配置最常见的两种手误)。

另更新一条旧判据的期望值:外部入口是 portal.example.com 时,子域基名应取
**实际入口**而非本机配置的 localhost(后者对远程用户无意义)。
2026-09-26 13:49:59 +08:00
323dabb701 fix(webui): 服务入口 URL 端口必须恰好出现一次(生产部署后暴露)
生产部署后立刻暴露的真 bug:请求 Host 自带端口(实测 Host=127.0.0.1:8080),
而 url_portal 合成时无条件再追加监听端口,拼出
  http://127.0.0.1:8080:8080/api/v1/device/     ← 链接点不开

单测抓不到的原因:此前测试用的 Host 不含端口。真实服务器上 Host 一定带端口
(除非经 nginx 剥掉),所以这个 bug 必然出现在生产。

修法:抽出 portalHostWithPort(host, hostPort) 统一合成 ——
  - host 已含端口 → 原样(尊重调用方看到的真实入口)
  - host 不含端口 → 追加监听端口
两处调用点(服务清单 url_portal、发现端点 url_portal/url/http_url)共用它。

新增 3 条判据,都刻意用**自带端口**的 Host:
  TestPortalHostPortExactlyOnce(7 组输入,含带/不带端口、空值、无冒号端口)
  TestProxyServiceURLsWithPortInHost
  TestDeviceGatewayDiscoveryNoDuplicatePort
2026-09-26 13:49:59 +08:00
39cd42723c feat(clients): 设备桥自动链接改用服务端发现 + 路径挂载(无 DNS 依赖)
配套 webui 反代改造:网关现在可由 HomeAgent 反代出去,客户端不能再靠
「门户地址同 host 拼 /api/v1/device/ws」猜地址——基域名与子域标签都是
**服务端配置**,客户端无从得知。

## 服务端:/api/v1/device/gateway 发现端点

客户端问「网关在哪」是唯一不会漂移的做法:子域标签可改(插件声明)、
基域名可改(webui.base_domain)、实例可换形态,客户端都不用跟着改。

⚠️ **不返回设备令牌**:本端点用门户凭证鉴权,而设备令牌能执行设备命令;
把令牌塞进来等于「门户只读凭证 → 设备执行权」的越权。令牌仍由客户端
自配。已有判据钉住「不得泄漏凭证字段」。

## ★ 实测发现:*.localhost 只有浏览器能解析

这是本轮最重要的发现,直接决定了设计:

| 环境 | devices.localhost 解析 |
|---|---|
| 浏览器 | ✓(RFC 6761 内置) |
| curl | ✓(内置特例) |
| getent / Go / Node | ✗(系统 nsswitch 是 files,dns,无 nss-myhostname) |

设备客户端(waiter / GUI 主进程 / 嵌入式固件)用的正是系统解析器。
实测 waiter 报「lookup devices.localhost on 192.168.2.1:53」。

因此**两处**设计变更:
1. 发现端点同时返回两种形态,并标 preferred:
   - url(子域)—— 浏览器用
   - url_portal(门户同源,同一 host、同一端口,走路径挂载)—— 非浏览器用,
     无任何 DNS 依赖
2. SDK 的 ProxyDecl 新增 **Path**(路径挂载前缀):让同一服务同时挂到
   门户自身 host 的路径下。remotedevice 声明 Path="/api/v1/device",
   设备客户端因此能沿用**它已硬编码的路径**,不需要知道反代存在。

路径挂载语义:请求路径**原样保留**(不剥前缀),上游按真实路径注册即可。
边界卡在路径分隔符上(/api/v1/device 不匹配 /api/v1/devicefoo)。

## 客户端

- **waiter**:新增 discoverGateway(),仅在用户配了门户地址时尝试,失败回退
  自配地址(老版本 HomeAgent 无该端点)。抽出 normalizeGateway() 纯函数,
  显式钉住「已带子域/完整端点的地址不得被改写」。
- **GUI**:renderer 新增 loadDiscoveredGateway(),renderDeviceChannel 优先用
  发现值、回退旧口径。顺带修掉此前插入函数时 anchor 不匹配导致调用点
  找不到定义的问题。
- **鸿蒙**:discoverGateway() + resolveGatewayUrl(),优先 url_portal。
- 三者都**优先 url_portal**(system resolver 的现实约束)。

## 遗留路由鉴权修正

`/api/v1/device/` 的旧路径反代原被 requireAPI 包裹 —— 但其调用方是设备
(带设备令牌而非门户凭证),套上门户鉴权会把它们全挡在 401(**真实实测**:
waiter 经此路径升级握手 401)。去掉这层包装,鉴权交给上游 remotedevice
自己的 requireToken,安全性不降级。

## 判据

webui +6 条、waiter +7 条。

★ 其中一条是**真实回归**:/api/v1/device/gateway 曾被 Path="/api/v1/device"
的路径挂载接走(那服务 auth=none),于是发现请求被转给上游、回 401,
客户端再也发现不到网关。修法是发现端点先于路径挂载判定,并补判据
(走完整生产链,同时确认同前缀的真实设备路径仍归反代)。

## 真实验收(隔离实例,命名 netns + 独立 data + 18080)

真 waiter 客户端 + 真 remotedevice 网关:
  device gateway discovered: ws://127.0.0.1:18080/api/v1/device/ws
  device bridge active: waiter-mainserver authorized=true
  hello_ack / bind_ack 均经反代往返成功

说明:`bind_ack device=<nil>` 与在线列表为空的现象,**直连 9890 绕开反代
完全一致复现**,属 remotedevice 与 waiter 之间既有的握手细节,与本次
反代改造无关(反代侧职责已证:连接建立 + 双向帧往返都通)。
2026-09-26 13:49:59 +08:00
5e88865784 fix(webui): 反代两处真实故障 —— 凭证头按 auth 区分 + Host 分发先于门户路由
两处都是**隔离实例上跑真实端到端**才暴露的,单测(用不校验凭证的假上游、
直接调 serveProxyHost)全绿却线上出错。记录在此以免重蹈。

## 故障 1:auth=none 路由的凭证被无条件剥掉 ⇒ 设备链路全 401

Rewrite 里原本无条件 Del("Cookie"/"Authorization"/"X-API-Key")。但
auth=none 的语义是"请求原样交给上游",凭证本来就是给**上游**的——
remotedevice 的接入令牌正是走 X-API-Key 传的。

实测症状:带设备令牌经反代访问 /api/v1/device/online → 401;
直连 127.0.0.1:9890 → 200。差异极难定位,因为两侧状态码语义相同。

修法:按路由 auth 分流。
- auth=homeagent:凭证是门户的,剥掉(避免泄漏给插件)
- auth=none:保留(上游要用)

复验:经反代与直连**逐字节一致**(HTTP 200 / 17B,cmp 相同)。

## 故障 2:插件子域被门户路由截走 ⇒ 401 且响应体是门户的 JSON

原实现把 Host 分发放在 mux 的 "/" 兜底里。但 stdlib ServeMux 是**最长前缀
优先**:任何更具体的模式都先命中。插件子域上的 /api/v1/device/online 被门户
为「旧路径反代」注册的 /api/v1/device/ 接走(requireAPI 包裹)→ 401。

判据(响应体格式)是定位关键:
  webui requireAPI        → {"error":"unauthorized"} 25B  ← 实际拿到
  remotedevice requireToken → "unauthorized" text/plain 13B
看响应体格式就能区分是谁拒的,比看状态码有效。

修法:Host 分发提为**最外层中间件**,包在整个 mux 之外,先于任何路径匹配。

## 顺带:消除判据与生产接线错位的可能

新增 Handler.Handler() 返回生产用的完整链(Host 分发 → 日志 → mux),
plugin.go 与测试共用同一条。本次踩过:测试自己组装 mux、中间件却挂在
plugin.go,判据全绿而线上 401;共享同一条链可结构性避免。
(写这条判据时还发现测试里 h.mux 为 nil 导致 panic——也正是这种错位的表现。)

## 新增判据 2 条

- TestProxyCredentialHeadersDependOnAuth:auth=none 必须转发上游令牌、
  auth=homeagent 必须剥掉门户凭证(两个方向都钉)
- TestProxyHostTakesPrecedenceOverPortalRoutes:走**完整生产链**,确认
  插件子域上的 /api/v1/device/online、/api/v1/status、/api/v1/plugins/ 都
  归反代;同时确认门户自身的 /api/v1/status 仍返回门户 JSON(没被反代吞掉)

## 真实验收(隔离实例:命名 netns + 独立 data + 端口 18080)

- 设备网关:令牌经反代 200,与直连逐字节一致;WS 升级 101
- 未声明子域:404 且错误信息含具体标签
- huawei_smarthome(用新 hmapdev 重打包、真装载):
  匿名 401 + 可操作提示;带门户 key 拿到真实 UI(9444B,
  <title>华为智慧生活管家);页面内根绝对路径 /api/status 正确透传
2026-09-26 13:49:59 +08:00
c05d644dfe feat(webui): 通用反向代理 —— 插件声明服务,HomeAgent 按子域反代出去
用户要求:外部只装 HomeAgent 即可使用自带反代能力;用户只需穿透一个
webui 端口就能访问所有内部插件服务;认证与 WebSocket 支持都作为插件
的可声明项;插件 UI 要有可直接点击的入口。

实测 huawei_smarthome 插件的前端用**根绝对路径**(api('/api/status') →
fetch('/api/status'))。挂在 /p/<name>/ 这类路径前缀下,这些请求会打到
HomeAgent 自己的 /api/status —— 静默错路由;做 HTML/JS 内容重写对拼进
JS 字符串的绝对路径只是"按概率能用",会产生"页面能开、某个按钮就坏"的
静默故障。子域路由下根路径天然正确,**插件前端零改动**。

且它天然匹配"只穿透一个端口":webui 监听 0.0.0.0:8080 按 Host 分发,
外层 frp 单端口 TCP 隧道**一行都不用改**。

默认基座 localhost:RFC 6761 规定 *.localhost 强制解析到 loopback,
现代浏览器原生支持 ⇒ <标签>.localhost:8080 **零配置可用**,不需要 DNS、
证书、/etc/hosts。远程部署改 base_domain 即可。

- 外部插件 → plugin.json 的 proxies(静态可发现:插件没起来也能报
  "声明了 ui 但目标不可达",而不是静默 404)
- 内置插件 → s.DeclareProxy()(remotedevice 是内置的、没有 plugin.json,
  却最需要被反代出去)

反代层在 webui 侧读清单:webui 已能拿到插件目录(PluginManager.PluginDir),
因此**无需给内核接口加方法**。manifest 解析忽略未知字段,加 proxies 对
"旧内核读新插件"与"新内核读旧插件"都无害。

新增 sdk/ProxyDecl 与配套校验(ValidProxyAuth / ValidProxyHostLabel /
NormalizeProxyHost / ValidateProxyDecl);新增运行期 ProxyDeclarer 通道。
hmapdev 的 writePluginJSON 是**白名单 map 重建**——不同步加字段会让声明
被打包静默丢弃(插件作者本地正常、装上去失效),因此 PlgConfig 与
writePluginJSON 同时加,并在打包前校验声明(插件作者本地就能发现写错)。

auth=homeagent(默认,安全的默认):门户会话 / X-API-Key / ?__token=;
auth=none:信任上游自身鉴权,供设备与嵌入式客户端使用——它们不可能持有
浏览器会话,强制走门户鉴权会把设备链路挡死。remotedevice 声明 none,
因为它自身用 ws_token 强制校验。

未声明时升级请求**明确拒绝**(400 + 原因),而不是静默降级成普通请求
(后者表现为前端不断重连、日志看不出原因)。

1. 不跟随上游 3xx:旧实现用 http.DefaultClient(默认跟最多 10 跳),
   上游 302 到内网地址时反代自己跟过去、失败回 502 并把内网 URL 泄给
   客户端。httputil.ReverseProxy 默认不跟随,3xx 原样透传。
2. 逐帧 flush:旧实现 io.Copy 导致上游流式响应被缓冲到上游关闭才下发
   (实测 3 帧 200ms 间隔的流,客户端在 +600ms 一次性收到全部)。
   设 FlushInterval=-1。

另补齐 X-Forwarded-For/Host/Proto(旧实现完全不注入,上游无法判断真实
来源),并剥掉上游 Set-Cookie 的 Domain(防止插件 cookie 打到主门户域)。

插件页新增「服务入口」卡片:列出全部被反代的插件服务(含被拒条目与
不可达原因),点「打开」直接访问。链接带 ?__token=<api_key>,因为子域
与门户不同源、浏览器不会自动带会话 cookie。

webui +35 条、SDK +4 条、工具链 +4 条。关键几条:
- 根绝对路径必须原样到上游(选 Host 路由的核心理由)
- 上游 302 必须原样透传、且反代不得跟随(旧缺陷)
- 已知 Content-Length 的慢速响应必须逐帧到达(**这条经过变异验证**:
  把 FlushInterval 改回 0 后判据挂死 → FAIL,还原后回绿。
  说明:最初写的 SSE/chunked 版本是假判据——ReverseProxy 对
  text/event-stream 与 ContentLength=-1 会自动立即 flush,与
  FlushInterval 无关,变异抓不到,已改正)
- 子域标签冲突不得静默覆盖(后者保留可见并带原因)
- 非法声明不进路由但必须可见(配置页要能看到原因)
- 未声明 websocket 的升级请求必须 400
- auth 逐条生效:none 放行匿名、homeagent 与默认档 401 且给可操作提示
- 自动发现:显式 host 不得被自动编号覆盖(**测试抓到的真 bug**:
  remotedevice 声明的 "devices" 会被改成 "devices-2" 而静默失效)
- 真实端到端:生产实例 huawei_smarthome 的 UI(9444 字节)与其
  /api/status 经反代正确透传

go build ./... 通过;相关包全量测试通过。
internal/plugin/proc 的 TestStreaming_PublishLatencyFlatAcrossSubscribers
是**预存在的不稳定测试**(同一份代码 10 次跑 9 过 1 败,且本改动完全
未触及该包),非本次引入。
2026-09-26 13:49:59 +08:00