feat(sdk): 反代声明(ProxyDef / RegisterProxy)—— 插件声明服务,HomeAgent 反代出去

配套核心仓「webui 通用反向代理」。SDK 1.4.0 尚未发布,接口未冻结,
本次按开发期自由变更处理(正式发版时并入版本号推进)。

## 声明契约

plugin.json 的 proxies 字段(声明式,静态可发现)或 RegisterProxy
(运行期,供没有 plugin.json 的内置插件用):

    {"name":"ui","host":"myapp","path":"/p/myapp","strip_path":true,
     "target":"127.0.0.1:12100","auth":"homeagent"}

命名与既有能力对齐(ToolDef / ChannelDef / ConfigDef / RegisterTool /
ToolRegistrar)——第一版写成 ProxyDecl / DeclareProxy / ProxyDeclarer
被评审指出「跟 SDK 其他接口不是一个风格」,已全面改名。

## strip_path:Path 的两种语义

Path 不能一刀切成「原样保留」,真实需求有两种且**不能自动判定**
(同一个 path 在两种语义下都说得通,猜错即全部 404 且像上游故障):

  strip_path 缺省/false(别名模式)—— path 是上游真实路径的一部分
    /api/v1/device/ws + path=/api/v1/device → 上游收到原样
    适用:客户端**已硬编码**路径的机器接口(设备网关即如此)

  strip_path=true(前缀模式)—— path 只是门户上的挂载点
    /p/myapp/api/status + path=/p/myapp → 上游收到 /api/status
    适用:自带 UI 的服务(前端用相对路径)

非法组合(strip_path 而无 path)被 ValidateProxyDef 挡住。

## 单一入口原则(契约级要求)

一个声明 = 一个入口。两种挂载形态对「根路径」处理截然不同:
Host 形态下根路径是插件的根(fetch('/api/x') 天然正确);
Path 形态下根路径**属于门户**,同样代码会打到门户自己身上
(静默错路由:页面能开、功能全坏)。

故被反代的插件必须**一律使用相对路径**,绝不硬编码以 / 开头的绝对路径。
这样同一份前端在两种形态下都正确,插件不必知道自己被挂在哪,
反代层也能按外部条件(子域是否有证书/放行)自由选择形态。

## 判据

sdk/proxy_test.go:两种语义的映射、非法组合、单一入口原则的契约存在性。
hmapdev proxy_config_test.go:schema 漂移保护(新增字段忘了同步就判红)、
非法声明在**打包时**就被拒(不必装到 HomeAgent 才看到)。

两模块 go test 全绿;文档站已重新生成(ProxyDef 与单一入口原则进入
docs/api/misc.md 与 llms-full.txt)。

## 顺带修回的一处(此前随工作树丢失)

writePluginJSON 漏写 proxies 键 —— 漏写的话插件装得上、启动正常、
就是不出现,没有任何报错。该 bug 曾在核心仓侧出现过(判据抓到过),
这次移植时由 TestWritePluginJSONPreservesProxies 再次判红并修复。
This commit is contained in:
JianFeeeee
2026-09-26 14:08:30 +08:00
parent 255d6479ad
commit a176cc3e20
20 changed files with 1280 additions and 198 deletions

View File

@ -57,7 +57,7 @@ OutputChannelUnregistrar 注销一个输出通道。
func (s *PluginSDK) SetDocMemoryAPI(dm DocMemoryAPI)
```
<small>`plugin.go:614`</small>
<small>`plugin.go:619`</small>
### `PluginSDK.SetEventSubscriber`
@ -68,7 +68,7 @@ func (s *PluginSDK) SetDocMemoryAPI(dm DocMemoryAPI)
func (s *PluginSDK) SetEventSubscriber(es EventSubscriber)
```
<small>`plugin.go:638`</small>
<small>`plugin.go:643`</small>
### `PluginSDK.SetIOInjector`
@ -81,7 +81,7 @@ func (s *PluginSDK) SetIOInjector(io IOInjector)
SetIOInjector sets the IO injector (called by the core at startup).
<small>`plugin.go:595`</small>
<small>`plugin.go:600`</small>
### `PluginSDK.SetInputChannelRegistrar`
@ -94,7 +94,7 @@ func (s *PluginSDK) SetInputChannelRegistrar(r InputChannelRegistrar)
SetInputChannelRegistrar sets the input channel registrar (called by the core at startup).
<small>`plugin.go:588`</small>
<small>`plugin.go:593`</small>
### `PluginSDK.SetKnowledgeAPI`
@ -105,7 +105,7 @@ SetInputChannelRegistrar sets the input channel registrar (called by the core at
func (s *PluginSDK) SetKnowledgeAPI(kn KnowledgeAPI)
```
<small>`plugin.go:620`</small>
<small>`plugin.go:625`</small>
### `PluginSDK.SetLLMAPI`
@ -116,7 +116,7 @@ func (s *PluginSDK) SetKnowledgeAPI(kn KnowledgeAPI)
func (s *PluginSDK) SetLLMAPI(llm LLMAPI)
```
<small>`plugin.go:626`</small>
<small>`plugin.go:631`</small>
### `PluginSDK.SetMemoryAPI`
@ -129,7 +129,7 @@ func (s *PluginSDK) SetMemoryAPI(mem MemoryAPI)
SetMemoryAPI sets the memory API (called by the core at startup).
<small>`plugin.go:602`</small>
<small>`plugin.go:607`</small>
### `PluginSDK.SetOutputChannelRegistrar`
@ -142,7 +142,7 @@ func (s *PluginSDK) SetOutputChannelRegistrar(r OutputChannelRegistrar)
SetOutputChannelRegistrar sets the output channel registrar (called by the core at startup).
<small>`plugin.go:574`</small>
<small>`plugin.go:579`</small>
### `PluginSDK.SetOutputChannelUnregistrar`
@ -155,7 +155,7 @@ func (s *PluginSDK) SetOutputChannelUnregistrar(r OutputChannelUnregistrar)
SetOutputChannelUnregistrar sets the output channel unregistrar (called by the core at startup).
<small>`plugin.go:581`</small>
<small>`plugin.go:586`</small>
### `PluginSDK.SetPluginMgrAPI`
@ -168,7 +168,7 @@ func (s *PluginSDK) SetPluginMgrAPI(pm PluginMgrAPI)
SetPluginMgrAPI sets the plugin manager API (called by the bridge at startup).
<small>`plugin.go:645`</small>
<small>`plugin.go:650`</small>
### `PluginSDK.SetSocialAPI`
@ -179,7 +179,7 @@ SetPluginMgrAPI sets the plugin manager API (called by the bridge at startup).
func (s *PluginSDK) SetSocialAPI(social SocialAPI)
```
<small>`plugin.go:632`</small>
<small>`plugin.go:637`</small>
### `PluginSDK.SetTextMemoryAPI`
@ -190,7 +190,7 @@ func (s *PluginSDK) SetSocialAPI(social SocialAPI)
func (s *PluginSDK) SetTextMemoryAPI(tm TextMemoryAPI)
```
<small>`plugin.go:608`</small>
<small>`plugin.go:613`</small>
### `ToolRegistrar`

View File

@ -30,7 +30,7 @@ func (s *PluginSDK) Events() EventSubscriber
Events returns the event subscriber for listening to kernel events (may be nil if not available).
<small>`plugin.go:446`</small>
<small>`plugin.go:451`</small>
### `PluginSDK.SetEventSubscriber`
@ -41,7 +41,7 @@ Events returns the event subscriber for listening to kernel events (may be nil i
func (s *PluginSDK) SetEventSubscriber(es EventSubscriber)
```
<small>`plugin.go:638`</small>
<small>`plugin.go:643`</small>
### `PluginSDK.SetOutputChannelUnregistrar`
@ -54,7 +54,7 @@ func (s *PluginSDK) SetOutputChannelUnregistrar(r OutputChannelUnregistrar)
SetOutputChannelUnregistrar sets the output channel unregistrar (called by the core at startup).
<small>`plugin.go:581`</small>
<small>`plugin.go:586`</small>
### `PluginSDK.UnregisterOutputChannel`
@ -67,7 +67,7 @@ func (s *PluginSDK) UnregisterOutputChannel(name string) error
UnregisterOutputChannel 注销一个输出通道(动态通道随资源生灭时必须调用)。
<small>`plugin.go:539`</small>
<small>`plugin.go:544`</small>
### `EventSubscriber.Subscribe`

View File

@ -280,7 +280,7 @@ blocks 会落进媒体存储被记忆引用捕获,同时作为当前轮 conten
的「下一轮 tool message」语义。
等价于 InjectInputMediaOpts(..., InjectOptions{})。
<small>`plugin.go:701`</small>
<small>`plugin.go:706`</small>
### `PluginSDK.InjectInputMediaOpts`
@ -290,7 +290,7 @@ func (s *PluginSDK) InjectInputMediaOpts(source, channel, text string, blocks []
InjectInputMediaOpts 注入带媒体块的输入,并声明记忆/裁剪行为。
<small>`plugin.go:740`</small>
<small>`plugin.go:745`</small>
### `PluginSDK.InjectInputMediaSync`
@ -301,7 +301,7 @@ func (s *PluginSDK) InjectInputMediaSync(source, channel, text string, blocks []
InjectInputMediaSync 注入带媒体内容块的输入并同步等待 agent 回复。
等价于 InjectInputMediaSyncOpts(..., InjectOptions{})。
<small>`plugin.go:707`</small>
<small>`plugin.go:712`</small>
### `PluginSDK.InjectInputMediaSyncOpts`
@ -311,7 +311,7 @@ func (s *PluginSDK) InjectInputMediaSyncOpts(source, channel, text string, block
InjectInputMediaSyncOpts 注入带媒体块的输入并同步等待回复,同时声明记忆/裁剪行为。
<small>`plugin.go:747`</small>
<small>`plugin.go:752`</small>
### `PluginSDK.InjectInputSync`
@ -330,7 +330,7 @@ to the source channel by the caller.
| [`a2a`](../examples/index.md#a2a) | `example/a2a/plugin.go:345` | `reply := p.sdk.InjectInputSync(p.name, p.name,` |
| [`acp`](../examples/index.md#acp) | `example/acp/plugin.go:237` | `reply = p.sdk.InjectInputSync(p.name, p.name,` |
<small>`plugin.go:692`</small>
<small>`plugin.go:697`</small>
### `PluginSDK.InjectInputSyncOpts`
@ -340,7 +340,7 @@ func (s *PluginSDK) InjectInputSyncOpts(source, channel, text string, opts Injec
InjectInputSyncOpts 注入输入并同步等待回复,同时在这次注入上声明记忆/裁剪行为。
<small>`plugin.go:731`</small>
<small>`plugin.go:736`</small>
### `PluginSDK.InjectInterruptMedia`
@ -351,7 +351,7 @@ func (s *PluginSDK) InjectInterruptMedia(source, channel, text string, blocks []
InjectInterruptMedia 注入带媒体内容块的中断,可抢占当前 LLM 处理。
blocks 随中断消息一起发给模型。
<small>`plugin.go:764`</small>
<small>`plugin.go:769`</small>
### `PluginSDK.InjectInterruptMediaOpts`
@ -361,7 +361,7 @@ func (s *PluginSDK) InjectInterruptMediaOpts(source, channel, text string, block
InjectInterruptMediaOpts 注入带媒体块的中断,并声明记忆/裁剪行为。
<small>`plugin.go:756`</small>
<small>`plugin.go:761`</small>
### `PluginSDK.InjectInterruptText`
@ -372,7 +372,7 @@ func (s *PluginSDK) InjectInterruptText(source, channel, text string)
InjectInterruptText injects a text interrupt that can preempt current LLM processing.
等价于 InjectInterruptTextOpts(..., InjectOptions{}):记入记忆、不裁剪。
<small>`plugin.go:673`</small>
<small>`plugin.go:678`</small>
### `PluginSDK.InjectInterruptTextOpts`
@ -394,7 +394,7 @@ InjectInterruptTextOpts 注入可抢占当前处理的中断文本。
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:299` | `p.sdk.InjectInterruptTextOpts(p.name, p.name,` |
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:1493` | `p.sdk.InjectInterruptTextOpts(p.name, p.name, text, sdk.InjectOptions{` |
<small>`plugin.go:724`</small>
<small>`plugin.go:729`</small>
### `InjectOptions`
@ -435,7 +435,7 @@ func (s *PluginSDK) InjectText(source, channel, text string)
InjectText injects a text message into the agent pipeline.
等价于 InjectTextOpts(..., InjectOptions{}):记入记忆、不裁剪。
<small>`plugin.go:679`</small>
<small>`plugin.go:684`</small>
### `PluginSDK.InjectTextNoMemory`
@ -452,7 +452,7 @@ InjectTextNoMemory injects a text message without generating memory.
|---|---|---|
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:1114` | `p.sdk.InjectTextNoMemory(p.name, p.name, fmt.Sprintf("[浏览器 %s 已导航到 %s]", id, rawURL))` |
<small>`plugin.go:685`</small>
<small>`plugin.go:690`</small>
### `PluginSDK.InjectTextOpts`
@ -462,7 +462,7 @@ func (s *PluginSDK) InjectTextOpts(source, channel, text string, opts InjectOpti
InjectTextOpts 注入文本到 agent,并在这一次注入上声明记忆与裁剪行为。
<small>`plugin.go:714`</small>
<small>`plugin.go:719`</small>
### `PriorityL1`
@ -577,7 +577,7 @@ def.Cleaner: 计算层对输入文本清洗后(不改原文)再向量化/
| [`browser`](../examples/index.md#browser) | `example/browser/plugin.go:209` | `_ = s.RegisterInputChannel(p.name, sdk.ChannelDef{})` |
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:281` | `_ = s.RegisterInputChannel("calendar", sdk.ChannelDef{NoMemory: true})` |
<small>`plugin.go:561`</small>
<small>`plugin.go:566`</small>
### `PluginSDK.RegisterOutputChannel`
@ -614,7 +614,7 @@ handler: receives args map with keys: payload (string), type (string), meta (str
| [`qq`](../examples/index.md#qq) | `example/qq/plugin.go:411` | `s.RegisterOutputChannel("qq", sdk.CapText\|sdk.CapFile\|sdk.CapImage\|sdk.CapAudio,` |
| [`weather`](../examples/index.md#weather) | `example/weather/plugin.go:104` | `if err := s.RegisterOutputChannel(tp+"weather_out", 0, "push weather to user", sdk.ChannelDef{` |
<small>`plugin.go:528`</small>
<small>`plugin.go:533`</small>
### `PluginSDK.SetToolBlocks`
@ -625,7 +625,7 @@ func (s *PluginSDK) SetToolBlocks(blocks []ContentBlock)
SetToolBlocks 在工具处理函数内注入多模态内容块,内核在下一条 tool message
的 content 数组里带上它们。需要「本轮就让模型看到」时用 InjectInputMedia。
<small>`plugin.go:772`</small>
<small>`plugin.go:777`</small>
### `ValidContextPolicy`

View File

@ -70,3 +70,10 @@ SDK 里的取值枚举。其中带「仅内置」标注的取值在内核侧会
| `CapAudio` | |
| `CapStructured` | |
## ProxyAuthHomeAgent 等
| 名称 | 说明 |
|---|---|
| `ProxyAuthHomeAgent` | ProxyAuthHomeAgent 表示由 HomeAgent 统一保护:浏览器走门户会话 |
| `ProxyAuthNone` | ProxyAuthNone 表示不经 HomeAgent 鉴权,直接把请求转发给上游。 |

View File

@ -65,5 +65,5 @@ func (s *PluginSDK) Events() EventSubscriber
Events returns the event subscriber for listening to kernel events (may be nil if not available).
<small>`plugin.go:446`</small>
<small>`plugin.go:451`</small>

View File

@ -87,7 +87,7 @@ func (s *PluginSDK) AutoRestart() bool
AutoRestart 返回插件是否允许自动重启。
<small>`plugin.go:791`</small>
<small>`plugin.go:796`</small>
### `PluginSDK.PluginMgr`
@ -98,7 +98,7 @@ func (s *PluginSDK) PluginMgr() PluginMgrAPI
PluginMgr returns the plugin manager API (ReloadOne / ReloadPlugins / list).
May be nil if the host did not wire it.
<small>`plugin.go:653`</small>
<small>`plugin.go:658`</small>
### `PluginSDK.PluginName`
@ -108,7 +108,7 @@ func (s *PluginSDK) PluginName() string
PluginName returns the name of the plugin.
<small>`plugin.go:397`</small>
<small>`plugin.go:402`</small>
### `PluginSDK.RegisterOnRemoveHandler`
@ -129,7 +129,7 @@ RegisterOnRemoveHandler 注册插件被删除(卸载)时的清理回调。
| [`memo`](../examples/index.md#memo) | `example/memo/plugin.go:69` | `s.RegisterOnRemoveHandler(p.cleanupData)` |
| [`rss`](../examples/index.md#rss) | `example/rss/plugin.go:127` | `s.RegisterOnRemoveHandler(p.cleanupData)` |
<small>`plugin.go:826`</small>
<small>`plugin.go:831`</small>
### `PluginSDK.RegisterPluginAPI`
@ -139,7 +139,7 @@ func (s *PluginSDK) RegisterPluginAPI(name string) error
RegisterPluginAPI registers this plugin's API for access by other plugins.
<small>`plugin.go:502`</small>
<small>`plugin.go:507`</small>
### `PluginSDK.RegisterStopHandler`
@ -159,7 +159,7 @@ RegisterStopHandler 注册插件停止阶段的清理回调。
| [`calendar`](../examples/index.md#calendar) | `example/calendar/plugin.go:294` | `s.RegisterStopHandler(p.saveEvents)` |
| [`deepsearch`](../examples/index.md#deepsearch) | `example/deepsearch/plugin.go:695` | `s.RegisterStopHandler(func() { p.shutdownSearxng() })` |
<small>`plugin.go:801`</small>
<small>`plugin.go:806`</small>
### `PluginSDK.RunOnRemoveHandlers`
@ -170,7 +170,7 @@ func (s *PluginSDK) RunOnRemoveHandlers()
RunOnRemoveHandlers 执行全部已注册的 onRemove handler(后注册先执行,执行后清空,幂等)。
由内核在卸载插件(registry.RemovePlugin)时、插件 Stop() 之后执行。
<small>`plugin.go:837`</small>
<small>`plugin.go:842`</small>
### `PluginSDK.RunStopHandlers`
@ -181,7 +181,7 @@ func (s *PluginSDK) RunStopHandlers()
RunStopHandlers 执行全部已注册的 stop handler(后注册先执行,执行后清空,幂等)。
由内核(内置插件)或插件桥接层(外部插件 z_bridge 的 StopPlugin)在调用插件 Stop() 前执行。
<small>`plugin.go:812`</small>
<small>`plugin.go:817`</small>
### `PluginSDK.SetAutoRestart`
@ -205,5 +205,5 @@ SetAutoRestart 设置插件崩溃后内核是否自动重启它。
| [`ai_image`](../examples/index.md#ai_image) | `example/ai_image/plugin.go:110` | `s.SetAutoRestart(true)` |
| [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:25` | `s.SetAutoRestart(true)` |
<small>`plugin.go:784`</small>
<small>`plugin.go:789`</small>

View File

@ -46,5 +46,5 @@ func (s *PluginSDK) LLM() LLMAPI
LLM returns the LLM provider API (may be nil if not available).
<small>`plugin.go:432`</small>
<small>`plugin.go:437`</small>

View File

@ -236,7 +236,7 @@ func (s *PluginSDK) DocMemory() DocMemoryAPI
DocMemory returns the document memory API (may be nil if not available).
<small>`plugin.go:418`</small>
<small>`plugin.go:423`</small>
### `Entity`
@ -278,7 +278,7 @@ Knowledge returns the knowledge store API (may be nil if not available).
|---|---|---|
| [`recoverydiag`](../examples/index.md#recoverydiag) | `example/recoverydiag/plugin.go:978` | `if p.sdk != nil && p.sdk.Knowledge() != nil {` |
<small>`plugin.go:425`</small>
<small>`plugin.go:430`</small>
### `MediaAttachment`
@ -308,7 +308,7 @@ func (s *PluginSDK) Memory() MemoryAPI
Memory returns the graph memory API (may be nil if not available).
<small>`plugin.go:404`</small>
<small>`plugin.go:409`</small>
### `PersonProfile`
@ -338,7 +338,7 @@ func (s *PluginSDK) Social() SocialAPI
Social returns the social graph API (may be nil if not available).
<small>`plugin.go:439`</small>
<small>`plugin.go:444`</small>
### `SocialRelation`
@ -366,7 +366,7 @@ func (s *PluginSDK) TextMemory() TextMemoryAPI
TextMemory returns the text memory API (may be nil if not available).
<small>`plugin.go:411`</small>
<small>`plugin.go:416`</small>
### `Triple`

View File

@ -680,7 +680,17 @@ Append(evt TextEvent) error
type AudioURL struct { URL string `json:"url"` }
```
<small>`plugin.go:862`</small>
<small>`plugin.go:867`</small>
### `EffectiveProxyAuth`
```go
func EffectiveProxyAuth(auth string) string
```
EffectiveProxyAuth 返回生效的鉴权模式(空串归一化为 ProxyAuthHomeAgent)。
<small>`proxy.go:169`</small>
### `ImageURL`
@ -688,7 +698,7 @@ type AudioURL struct { URL string `json:"url"` }
type ImageURL struct { URL string `json:"url"` Detail string `json:"detail,omitempty"` }
```
<small>`plugin.go:857`</small>
<small>`plugin.go:862`</small>
### `MemItem`
@ -700,6 +710,19 @@ MemItem represents a memory item in stage context.
<small>`plugin.go:179`</small>
### `NormalizeProxyHost`
```go
func NormalizeProxyHost(pluginName string) string
```
NormalizeProxyHost 由插件名派生默认 Host 标签。
下划线转连字符:插件名允许下划线(huawei_smarthome),但 DNS label 不允许,
直接用会导致该子域名无法解析——这里统一转换,避免每个插件各自碰运气。
<small>`proxy.go:202`</small>
### `PluginSDK`
```go
@ -711,6 +734,139 @@ It wraps tool registration, settings, memory, knowledge, LLM, and IO injection.
<small>`plugin.go:337`</small>
### `ProxyAuthHomeAgent`
```go
const ProxyAuthHomeAgent
```
ProxyAuthHomeAgent 表示由 HomeAgent 统一保护:浏览器走门户会话
(homeagent_session cookie),非浏览器客户端走 X-API-Key。
两者都没有时返回 401,而不是把请求透传给上游。
<small>`proxy.go:149`</small>
### `ProxyAuthNone`
```go
const ProxyAuthNone
```
ProxyAuthNone 表示不经 HomeAgent 鉴权,直接把请求转发给上游。
适用场景:上游自己有鉴权且调用方不是浏览器(设备/嵌入式客户端),
或上游是刻意公开的服务。选用它意味着**信任上游自身的鉴权**,
且该服务在网络层可达范围内对所有人开放。
<small>`proxy.go:156`</small>
### `ProxyDef`
```go
type ProxyDef struct { // Name 是同一插件内多条声明的唯一标识(如 "ui"、"api")。 // 运行期由 RegisterProxy 的第一个参数填入;声明式由 …
```
反向代理声明:插件告诉 HomeAgent「我起了个 HTTP 服务,请把它反代出去」。
为什么需要:插件自带 Web UI / HTTP API 时,监听地址在插件自己的配置里
(如 127.0.0.1:12100),外部无从得知;而 webui 的对外端口通常只有一个
(默认 :8080,且常经 frp 单端口隧道穿透)。没有声明机制时,用户只能
「知道端口 + 自己配转发」,插件换端口就失效。
设计取舍——**声明式而非注册式**:声明写在 plugin.json 里,由 HomeAgent
在加载插件时读取聚合,而不是让插件在运行期调 API 注册。理由:
1. 静态可发现:未启动/已崩溃的插件,其服务声明依然可见(可给出准确报错
「插件 X 声明了 ui 但目标 127.0.0.1:12100 不可达」,而不是静默 404);
2. 可版本化:声明随插件包一起分发、可 diff、可审计;
3. 旧内核无害:manifest 解析忽略未知字段,未支持该能力的 HomeAgent 读旧
插件、或旧 HomeAgent 读新插件都不会报错。
与 ToolDef / ChannelDef / ConfigDef 同族:SDK 定义声明契约,内核实现行为。
声明方式与其它能力一致 —— 在 Start() 里调 RegisterProxy(name, def),
或写进 plugin.json 的 proxies 字段(外部插件两种都支持)。
安全性:**不声明 = 不被反代**。声明本身就是能力声明,因此不需要在
capabilities 里另外开一个开关——最小权限默认生效。
# 单一入口原则(强制要求)
**一个声明 = 一个入口**。被反代的插件必须让它的全部资源与接口都能从
该入口的一个基准路径出发访问到,不得依赖「入口之外的根路径」。
为什么强制:反代有两种挂载形态,而它们对「根路径」的处理截然不同——
Host 形态(host):插件独占 <标签>.<基域名>,根路径就是插件的根。
根绝对路径(fetch('/api/x'))**天然正确**。
Path 形态(path):插件挂在门户自身 host 的某个前缀下,根路径属于**门户**。
此时插件里的 fetch('/api/x') 会打到门户自己的 /api/x
—— 静默错路由,页面能开但功能全坏。
于是「同一个插件必须同时支持两种形态」这条要求,等价于:
**插件内部一律使用相对路径**(或基于 <base>/location 推导的路径),
绝不硬编码以 / 开头的绝对路径。
这样同一份前端在两种形态下都正确,插件作者也不必知道自己被挂在哪。
反代层据此可以:外部子域可用时给 Host 形态,子域不可用(证书/放行限制)
时给 Path 形态,**无需插件配合改动**。
自检(插件作者在本地就该做):把页面挂到 <门户>/<任意前缀>/ 下访问,
所有请求都必须仍然打到插件自己。
本项目实测案例:某插件前端写死 fetch('/api/status'),配在
/p/huawei/ 下会打到门户的 /api/status(404 或返回门户数据);
改成相对路径后两种形态同时可用。
ProxyDef 是一个服务的**反代声明体**。
与 ToolDef 同构:Name 同时出现在字段与 RegisterProxy 的第一个参数里
(ToolDef 也是这么做的 —— 字段供 plugin.json 序列化,参数供运行期调用)。
Name 只用于展示、日志与冲突提示,**不参与路由**(路由键是 Host 与 Path)。
<small>`proxy.go:64`</small>
### `ProxyRegistrar`
```go
type ProxyRegistrar func(name string, def ProxyDef)
```
ProxyRegistrar 由内核注入(与 ToolRegistrar / InputChannelRegistrar 同族)。
插件不直接调它,用 RegisterProxy。
为什么需要运行期注册(明明有 plugin.json 自动发现):**内置插件**
(编译进内核、没有独立插件目录与 plugin.json,如 remotedevice)扫不到;
而它们恰恰最需要被反代出去(设备网关就是内置的)。两种来源互补:
- 外部插件 → plugin.json 的 proxies(静态,未启动也可见)
- 内置插件 → RegisterProxy(运行期,随 Start 注册)
<small>`proxy.go:306`</small>
### `PluginSDK.RegisterProxy`
```go
func (s *PluginSDK) RegisterProxy(name string, def ProxyDef)
```
RegisterProxy 声明一个需要 HomeAgent 反代出去的服务。
与 RegisterTool / RegisterInputChannel / RegisterOutputChannel 同一风格:
显式给名字 + 声明体。名字用于展示、日志与冲突提示(不参与路由 —— 路由键是
def.Host / def.Path)。
用法(通常在 Start 里调用):
s.RegisterProxy("ui", sdk.ProxyDef{
Host: "myapp", Target: "127.0.0.1:12100",
})
声明立即生效(反代表在下一次请求时重建)。**不做去重**:同一 Host/Path
被两条声明占用时由反代层判定冲突并明确报错,而不是在这里静默吞掉 ——
插件作者需要看见冲突。
与 plugin.json 的 proxies 字段等价:写哪个都行,两者会合并(同名以本调用为准)。
<small>`proxy.go:335`</small>
### `SDKVersion`
```go
@ -721,6 +877,16 @@ SDKVersion 是对外暴露的 SDK 版本号。
<small>`plugin.go:10`</small>
### `PluginSDK.SetProxyRegistrar`
```go
func (s *PluginSDK) SetProxyRegistrar(r ProxyRegistrar)
```
SetProxyRegistrar 由内核注入。插件不直接调它(与 SetInputChannelRegistrar 同族)。
<small>`proxy.go:309`</small>
### `PluginSDK.UnregisterOutputChannel`
!!! warning "仅内核内置插件可用"
@ -732,5 +898,42 @@ func (s *PluginSDK) UnregisterOutputChannel(name string) error
UnregisterOutputChannel 注销一个输出通道(动态通道随资源生灭时必须调用)。
<small>`plugin.go:539`</small>
<small>`plugin.go:544`</small>
### `ValidProxyAuth`
```go
func ValidProxyAuth(auth string) bool
```
ValidProxyAuth 校验 Auth 取值;空串合法(等价 ProxyAuthHomeAgent)。
<small>`proxy.go:160`</small>
### `ValidProxyHostLabel`
```go
func ValidProxyHostLabel(label string) bool
```
ValidProxyHostLabel 校验子域名标签是否合法(DNS label 规则)。
独立成导出函数:插件作者在写声明时、HomeAgent 在加载时、工具链在打包时
都要用同一套规则判定,避免三处各写一份而互相不一致。
<small>`proxy.go:180`</small>
### `ValidateProxyDef`
```go
func ValidateProxyDef(d ProxyDef) string
```
ValidateProxyDef 校验一条反代声明,返回人类可读的错误说明(合法时为空)。
为什么要在 SDK 里做校验:HomeAgent 加载插件时必须能明确拒绝坏声明并说明
原因(而不是静默忽略导致用户以为配好了);插件作者也需要在本地就能查出
拼错的 Target/Host。同一套规则两端共用。
<small>`proxy.go:229`</small>

View File

@ -193,5 +193,5 @@ sett 在 New 时一次性写入且无 setter,故不需要加锁。
| [`ai_image`](../examples/index.md#ai_image) | `example/ai_image/plugin.go:114` | `s.Settings().RegisterDef(sdk.ConfigDef{` |
| [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:29` | `s.Settings().RegisterDef(sdk.ConfigDef{` |
<small>`plugin.go:401`</small>
<small>`plugin.go:406`</small>

View File

@ -83,7 +83,7 @@ RegisterStage registers a handler for a pipeline stage.
| [`sanitizer`](../examples/index.md#sanitizer) | `example/sanitizer/plugin.go:52` | `s.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {` |
| [`weather`](../examples/index.md#weather) | `example/weather/plugin.go:94` | `s.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error {` |
<small>`plugin.go:467`</small>
<small>`plugin.go:472`</small>
### `Stage`

View File

@ -14,7 +14,7 @@ ContentBlock 是多模态内容块(OpenAI 格式:text/image_url/audio_url)
插件工具返回结果时可用 PluginSDK.SetToolBlocks 注入,让下一轮 LLM
请求在 tool message 的 content 数组里带上图片/音频,实现"模型看图/听音频"。
<small>`plugin.go:850`</small>
<small>`plugin.go:855`</small>
### `PluginSDK.RegisterTool`
@ -33,7 +33,7 @@ RegisterTool registers a tool that the LLM can call.
| [`ai_image`](../examples/index.md#ai_image) | `example/ai_image/plugin.go:159` | `s.RegisterTool(tp+"generate", sdk.ToolDef{` |
| [`bili`](../examples/index.md#bili) | `example/bili/plugin.go:47` | `s.RegisterTool(tp+"video", sdk.ToolDef{` |
<small>`plugin.go:453`</small>
<small>`plugin.go:458`</small>
### `ToolCall`

View File

@ -47,7 +47,7 @@
"p": "misc",
"b": false,
"f": "plugin.go",
"l": 862
"l": 867
},
{
"n": "AutoRestart",
@ -58,7 +58,7 @@
"p": "lifecycle",
"b": false,
"f": "plugin.go",
"l": 791,
"l": 796,
"g": [
"自动重启",
"崩溃自愈",
@ -190,7 +190,7 @@
"p": "tools",
"b": false,
"f": "plugin.go",
"l": 850,
"l": 855,
"g": [
"多模态内容块",
"图片块",
@ -295,7 +295,7 @@
"p": "memory",
"b": false,
"f": "plugin.go",
"l": 418,
"l": 423,
"g": [
"记忆",
"内存",
@ -327,6 +327,17 @@
"导出配置"
]
},
{
"n": "EffectiveProxyAuth",
"s": "func EffectiveProxyAuth(auth string) string",
"d": "EffectiveProxyAuth 返回生效的鉴权模式(空串归一化为 ProxyAuthHomeAgent)。",
"k": "func",
"r": "",
"p": "misc",
"b": false,
"f": "proxy.go",
"l": 169
},
{
"n": "Entity",
"s": "type Entity struct { Name string `json:\"name\"` Type string `json:\"type\"` MentionCount int `json:\"mention_count\"` }",
@ -545,7 +556,7 @@
"p": "events",
"b": true,
"f": "plugin.go",
"l": 446,
"l": 451,
"g": [
"事件",
"订阅",
@ -671,7 +682,7 @@
"p": "misc",
"b": false,
"f": "plugin.go",
"l": 857
"l": 862
},
{
"n": "InjectInputMedia",
@ -682,7 +693,7 @@
"p": "channels",
"b": false,
"f": "plugin.go",
"l": 701,
"l": 706,
"g": [
"注入输入",
"投喂输入",
@ -713,14 +724,14 @@
},
{
"n": "InjectInputMediaOpts",
"s": "func (s *PluginSDK) InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)",
"d": "InjectInputMediaOpts 注入带媒体块的输入,并声明记忆/裁剪行为。",
"s": "InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)",
"d": "",
"k": "method",
"r": "PluginSDK",
"r": "IOInjector",
"p": "channels",
"b": false,
"f": "plugin.go",
"l": 740,
"l": 241,
"g": [
"注入输入",
"投喂输入",
@ -732,14 +743,14 @@
},
{
"n": "InjectInputMediaOpts",
"s": "InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)",
"d": "",
"s": "func (s *PluginSDK) InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)",
"d": "InjectInputMediaOpts 注入带媒体块的输入,并声明记忆/裁剪行为。",
"k": "method",
"r": "IOInjector",
"r": "PluginSDK",
"p": "channels",
"b": false,
"f": "plugin.go",
"l": 241,
"l": 745,
"g": [
"注入输入",
"投喂输入",
@ -777,7 +788,7 @@
"p": "channels",
"b": false,
"f": "plugin.go",
"l": 707,
"l": 712,
"g": [
"注入输入",
"投喂输入",
@ -796,7 +807,7 @@
"p": "channels",
"b": false,
"f": "plugin.go",
"l": 747,
"l": 752,
"g": [
"注入输入",
"投喂输入",
@ -850,7 +861,7 @@
"p": "channels",
"b": false,
"f": "plugin.go",
"l": 692,
"l": 697,
"g": [
"注入输入",
"投喂输入",
@ -882,7 +893,7 @@
"p": "channels",
"b": false,
"f": "plugin.go",
"l": 731,
"l": 736,
"g": [
"注入输入",
"投喂输入",
@ -918,27 +929,7 @@
"p": "channels",
"b": false,
"f": "plugin.go",
"l": 764,
"g": [
"中断注入",
"插话",
"打断",
"抢占",
"媒体",
"图片",
"音频"
]
},
{
"n": "InjectInterruptMediaOpts",
"s": "func (s *PluginSDK) InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)",
"d": "InjectInterruptMediaOpts 注入带媒体块的中断,并声明记忆/裁剪行为。",
"k": "method",
"r": "PluginSDK",
"p": "channels",
"b": false,
"f": "plugin.go",
"l": 756,
"l": 769,
"g": [
"中断注入",
"插话",
@ -970,20 +961,23 @@
]
},
{
"n": "InjectInterruptText",
"s": "func (s *PluginSDK) InjectInterruptText(source, channel, text string)",
"d": "InjectInterruptText injects a text interrupt that can preempt current LLM processing.",
"n": "InjectInterruptMediaOpts",
"s": "func (s *PluginSDK) InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)",
"d": "InjectInterruptMediaOpts 注入带媒体块的中断,并声明记忆/裁剪行为。",
"k": "method",
"r": "PluginSDK",
"p": "channels",
"b": false,
"f": "plugin.go",
"l": 673,
"l": 761,
"g": [
"中断注入",
"插话",
"打断",
"抢占"
"抢占",
"媒体",
"图片",
"音频"
]
},
{
@ -1003,6 +997,23 @@
"抢占"
]
},
{
"n": "InjectInterruptText",
"s": "func (s *PluginSDK) InjectInterruptText(source, channel, text string)",
"d": "InjectInterruptText injects a text interrupt that can preempt current LLM processing.",
"k": "method",
"r": "PluginSDK",
"p": "channels",
"b": false,
"f": "plugin.go",
"l": 678,
"g": [
"中断注入",
"插话",
"打断",
"抢占"
]
},
{
"n": "InjectInterruptTextOpts",
"s": "InjectInterruptTextOpts(source, channel, text string, opts InjectOptions)",
@ -1029,7 +1040,7 @@
"p": "channels",
"b": false,
"f": "plugin.go",
"l": 724,
"l": 729,
"g": [
"中断注入",
"插话",
@ -1079,7 +1090,7 @@
"p": "channels",
"b": false,
"f": "plugin.go",
"l": 679,
"l": 684,
"g": [
"注入文本",
"注入消息",
@ -1115,7 +1126,7 @@
"p": "channels",
"b": false,
"f": "plugin.go",
"l": 685,
"l": 690,
"g": [
"注入文本",
"注入消息",
@ -1125,6 +1136,23 @@
"内存"
]
},
{
"n": "InjectTextOpts",
"s": "func (s *PluginSDK) InjectTextOpts(source, channel, text string, opts InjectOptions)",
"d": "InjectTextOpts 注入文本到 agent,并在这一次注入上声明记忆与裁剪行为。",
"k": "method",
"r": "PluginSDK",
"p": "channels",
"b": false,
"f": "plugin.go",
"l": 719,
"g": [
"注入文本",
"注入消息",
"投喂输入",
"灌入内容"
]
},
{
"n": "InjectTextOpts",
"s": "InjectTextOpts(source, channel, text string, opts InjectOptions)",
@ -1142,23 +1170,6 @@
"灌入内容"
]
},
{
"n": "InjectTextOpts",
"s": "func (s *PluginSDK) InjectTextOpts(source, channel, text string, opts InjectOptions)",
"d": "InjectTextOpts 注入文本到 agent,并在这一次注入上声明记忆与裁剪行为。",
"k": "method",
"r": "PluginSDK",
"p": "channels",
"b": false,
"f": "plugin.go",
"l": 714,
"g": [
"注入文本",
"注入消息",
"投喂输入",
"灌入内容"
]
},
{
"n": "InputChannelRegistrar",
"s": "type InputChannelRegistrar func(name string, def ChannelDef) error",
@ -1245,6 +1256,21 @@
"f": "plugin.go",
"l": 172
},
{
"n": "Knowledge",
"s": "func (s *PluginSDK) Knowledge() KnowledgeAPI",
"d": "Knowledge returns the knowledge store API (may be nil if not available).",
"k": "method",
"r": "PluginSDK",
"p": "memory",
"b": false,
"f": "plugin.go",
"l": 430,
"g": [
"知识库",
"知识"
]
},
{
"n": "Knowledge",
"s": "type Knowledge struct { Name string `json:\"name\"` Content string `json:\"content\"` }",
@ -1260,21 +1286,6 @@
"知识"
]
},
{
"n": "Knowledge",
"s": "func (s *PluginSDK) Knowledge() KnowledgeAPI",
"d": "Knowledge returns the knowledge store API (may be nil if not available).",
"k": "method",
"r": "PluginSDK",
"p": "memory",
"b": false,
"f": "plugin.go",
"l": 425,
"g": [
"知识库",
"知识"
]
},
{
"n": "KnowledgeAPI",
"s": "type KnowledgeAPI interface",
@ -1295,7 +1306,7 @@
"p": "llm",
"b": false,
"f": "plugin.go",
"l": 432
"l": 437
},
{
"n": "LLMAPI",
@ -1308,20 +1319,6 @@
"f": "",
"l": 0
},
{
"n": "List",
"s": "List() ([]string, error)",
"d": "",
"k": "method",
"r": "KnowledgeAPI",
"p": "memory",
"b": false,
"f": "knowledge.go",
"l": 7,
"g": [
"列出"
]
},
{
"n": "List",
"s": "List(prefix string) ([]string, error)",
@ -1336,6 +1333,20 @@
"列出"
]
},
{
"n": "List",
"s": "List() ([]string, error)",
"d": "",
"k": "method",
"r": "KnowledgeAPI",
"p": "memory",
"b": false,
"f": "knowledge.go",
"l": 7,
"g": [
"列出"
]
},
{
"n": "ListCore",
"s": "ListCore(prefix string) ([]string, error)",
@ -1454,7 +1465,7 @@
"p": "memory",
"b": false,
"f": "plugin.go",
"l": 404,
"l": 409,
"g": [
"记忆",
"内存"
@ -1499,6 +1510,17 @@
"名称"
]
},
{
"n": "NormalizeProxyHost",
"s": "func NormalizeProxyHost(pluginName string) string",
"d": "NormalizeProxyHost 由插件名派生默认 Host 标签。",
"k": "func",
"r": "",
"p": "misc",
"b": false,
"f": "proxy.go",
"l": 202
},
{
"n": "OutputChannelRegistrar",
"s": "type OutputChannelRegistrar func(name string, caps int, desc string, def ChannelDef, handler ToolHandler) error",
@ -1563,7 +1585,7 @@
"p": "lifecycle",
"b": false,
"f": "plugin.go",
"l": 653,
"l": 658,
"g": [
"插件管理",
"重载插件",
@ -1591,7 +1613,7 @@
"p": "lifecycle",
"b": false,
"f": "plugin.go",
"l": 397,
"l": 402,
"g": [
"插件名"
]
@ -1681,6 +1703,50 @@
"中断级别"
]
},
{
"n": "ProxyAuthHomeAgent",
"s": "const ProxyAuthHomeAgent",
"d": "ProxyAuthHomeAgent 表示由 HomeAgent 统一保护:浏览器走门户会话",
"k": "const",
"r": "",
"p": "misc",
"b": false,
"f": "proxy.go",
"l": 149
},
{
"n": "ProxyAuthNone",
"s": "const ProxyAuthNone",
"d": "ProxyAuthNone 表示不经 HomeAgent 鉴权,直接把请求转发给上游。",
"k": "const",
"r": "",
"p": "misc",
"b": false,
"f": "proxy.go",
"l": 156
},
{
"n": "ProxyDef",
"s": "type ProxyDef struct { // Name 是同一插件内多条声明的唯一标识(如 \"ui\"、\"api\")。 // 运行期由 RegisterProxy 的第一个参数填入;声明式由 …",
"d": "反向代理声明:插件告诉 HomeAgent「我起了个 HTTP 服务,请把它反代出去」。",
"k": "type",
"r": "",
"p": "misc",
"b": false,
"f": "proxy.go",
"l": 64
},
{
"n": "ProxyRegistrar",
"s": "type ProxyRegistrar func(name string, def ProxyDef)",
"d": "ProxyRegistrar 由内核注入(与 ToolRegistrar / InputChannelRegistrar 同族)。",
"k": "type",
"r": "",
"p": "misc",
"b": false,
"f": "proxy.go",
"l": 306
},
{
"n": "Purge",
"s": "Purge(criteria map[string]string, mode string) (int, error)",
@ -1811,7 +1877,7 @@
"p": "channels",
"b": false,
"f": "plugin.go",
"l": 561,
"l": 566,
"g": [
"注册输入通道",
"收消息",
@ -1830,7 +1896,7 @@
"p": "lifecycle",
"b": false,
"f": "plugin.go",
"l": 826,
"l": 831,
"g": [
"卸载回调",
"删除清理",
@ -1847,7 +1913,7 @@
"p": "channels",
"b": false,
"f": "plugin.go",
"l": 528,
"l": 533,
"g": [
"注册输出通道",
"发消息",
@ -1866,12 +1932,23 @@
"p": "lifecycle",
"b": false,
"f": "plugin.go",
"l": 502,
"l": 507,
"g": [
"注册插件接口",
"暴露接口"
]
},
{
"n": "RegisterProxy",
"s": "func (s *PluginSDK) RegisterProxy(name string, def ProxyDef)",
"d": "RegisterProxy 声明一个需要 HomeAgent 反代出去的服务。",
"k": "method",
"r": "PluginSDK",
"p": "misc",
"b": false,
"f": "proxy.go",
"l": 335
},
{
"n": "RegisterStage",
"s": "func (s *PluginSDK) RegisterStage(stage Stage, handler StageHandler, scope ...StageScope)",
@ -1881,7 +1958,7 @@
"p": "stages",
"b": false,
"f": "plugin.go",
"l": 467,
"l": 472,
"g": [
"注册阶段",
"阶段钩子",
@ -1901,7 +1978,7 @@
"p": "lifecycle",
"b": false,
"f": "plugin.go",
"l": 801,
"l": 806,
"g": [
"停止回调",
"关闭清理",
@ -1917,7 +1994,7 @@
"p": "tools",
"b": false,
"f": "plugin.go",
"l": 453,
"l": 458,
"g": [
"注册工具",
"添加工具",
@ -1982,7 +2059,7 @@
"p": "lifecycle",
"b": false,
"f": "plugin.go",
"l": 837
"l": 842
},
{
"n": "RunStopHandlers",
@ -1993,7 +2070,7 @@
"p": "lifecycle",
"b": false,
"f": "plugin.go",
"l": 812
"l": 817
},
{
"n": "SDKVersion",
@ -2048,7 +2125,7 @@
"p": "lifecycle",
"b": false,
"f": "plugin.go",
"l": 784,
"l": 789,
"g": [
"设置",
"装配",
@ -2084,7 +2161,7 @@
"p": "bridge",
"b": false,
"f": "plugin.go",
"l": 614,
"l": 619,
"g": [
"设置",
"装配",
@ -2102,7 +2179,7 @@
"p": "bridge",
"b": true,
"f": "plugin.go",
"l": 638,
"l": 643,
"g": [
"设置",
"装配",
@ -2119,7 +2196,7 @@
"p": "bridge",
"b": false,
"f": "plugin.go",
"l": 595,
"l": 600,
"g": [
"设置",
"装配"
@ -2134,7 +2211,7 @@
"p": "bridge",
"b": false,
"f": "plugin.go",
"l": 588,
"l": 593,
"g": [
"设置",
"装配",
@ -2151,7 +2228,7 @@
"p": "bridge",
"b": false,
"f": "plugin.go",
"l": 620,
"l": 625,
"g": [
"设置",
"装配",
@ -2168,7 +2245,7 @@
"p": "bridge",
"b": false,
"f": "plugin.go",
"l": 626,
"l": 631,
"g": [
"设置",
"装配"
@ -2183,7 +2260,7 @@
"p": "bridge",
"b": false,
"f": "plugin.go",
"l": 602,
"l": 607,
"g": [
"设置",
"装配",
@ -2200,7 +2277,7 @@
"p": "bridge",
"b": false,
"f": "plugin.go",
"l": 574,
"l": 579,
"g": [
"设置",
"装配",
@ -2217,7 +2294,7 @@
"p": "bridge",
"b": true,
"f": "plugin.go",
"l": 581,
"l": 586,
"g": [
"设置",
"装配",
@ -2250,7 +2327,7 @@
"p": "bridge",
"b": false,
"f": "plugin.go",
"l": 645,
"l": 650,
"g": [
"设置",
"装配",
@ -2259,6 +2336,21 @@
"禁用插件"
]
},
{
"n": "SetProxyRegistrar",
"s": "func (s *PluginSDK) SetProxyRegistrar(r ProxyRegistrar)",
"d": "SetProxyRegistrar 由内核注入。插件不直接调它(与 SetInputChannelRegistrar 同族)。",
"k": "method",
"r": "PluginSDK",
"p": "misc",
"b": false,
"f": "proxy.go",
"l": 309,
"g": [
"设置",
"装配"
]
},
{
"n": "SetSocialAPI",
"s": "func (s *PluginSDK) SetSocialAPI(social SocialAPI)",
@ -2268,7 +2360,7 @@
"p": "bridge",
"b": false,
"f": "plugin.go",
"l": 632,
"l": 637,
"g": [
"设置",
"装配",
@ -2302,7 +2394,7 @@
"p": "bridge",
"b": false,
"f": "plugin.go",
"l": 608,
"l": 613,
"g": [
"设置",
"装配",
@ -2310,25 +2402,6 @@
"内存"
]
},
{
"n": "SetToolBlocks",
"s": "func (s *PluginSDK) SetToolBlocks(blocks []ContentBlock)",
"d": "SetToolBlocks 在工具处理函数内注入多模态内容块,内核在下一条 tool message",
"k": "method",
"r": "PluginSDK",
"p": "channels",
"b": false,
"f": "plugin.go",
"l": 772,
"g": [
"设置",
"装配",
"工具",
"工具返回图片",
"多模态返回",
"让模型看图"
]
},
{
"n": "SetToolBlocks",
"s": "SetToolBlocks(blocks []ContentBlock)",
@ -2348,6 +2421,25 @@
"让模型看图"
]
},
{
"n": "SetToolBlocks",
"s": "func (s *PluginSDK) SetToolBlocks(blocks []ContentBlock)",
"d": "SetToolBlocks 在工具处理函数内注入多模态内容块,内核在下一条 tool message",
"k": "method",
"r": "PluginSDK",
"p": "channels",
"b": false,
"f": "plugin.go",
"l": 777,
"g": [
"设置",
"装配",
"工具",
"工具返回图片",
"多模态返回",
"让模型看图"
]
},
{
"n": "Settings",
"s": "func (s *PluginSDK) Settings() SettingsAPI",
@ -2357,7 +2449,7 @@
"p": "settings",
"b": false,
"f": "plugin.go",
"l": 401,
"l": 406,
"g": [
"设置",
"装配"
@ -2383,7 +2475,7 @@
"p": "memory",
"b": false,
"f": "plugin.go",
"l": 439,
"l": 444,
"g": [
"社交",
"关系",
@ -2716,7 +2808,7 @@
"p": "memory",
"b": false,
"f": "plugin.go",
"l": 411,
"l": 416,
"g": [
"记忆",
"内存"
@ -2845,7 +2937,7 @@
"p": "misc",
"b": true,
"f": "plugin.go",
"l": 539,
"l": 544,
"g": [
"通道",
"频道",
@ -2868,6 +2960,28 @@
"校验裁剪策略"
]
},
{
"n": "ValidProxyAuth",
"s": "func ValidProxyAuth(auth string) bool",
"d": "ValidProxyAuth 校验 Auth 取值;空串合法(等价 ProxyAuthHomeAgent)。",
"k": "func",
"r": "",
"p": "misc",
"b": false,
"f": "proxy.go",
"l": 160
},
{
"n": "ValidProxyHostLabel",
"s": "func ValidProxyHostLabel(label string) bool",
"d": "ValidProxyHostLabel 校验子域名标签是否合法(DNS label 规则)。",
"k": "func",
"r": "",
"p": "misc",
"b": false,
"f": "proxy.go",
"l": 180
},
{
"n": "ValidRecallPolicy",
"s": "func ValidRecallPolicy(policy string) bool",
@ -2884,5 +2998,16 @@
"检索记忆",
"校验召回策略"
]
},
{
"n": "ValidateProxyDef",
"s": "func ValidateProxyDef(d ProxyDef) string",
"d": "ValidateProxyDef 校验一条反代声明,返回人类可读的错误说明(合法时为空)。",
"k": "func",
"r": "",
"p": "misc",
"b": false,
"f": "proxy.go",
"l": 229
}
]

View File

@ -353,6 +353,11 @@ type PluginSDK struct {
events EventSubscriber
plgMgr PluginMgrAPI
// proxyReg 是反代声明的注册回调(内置插件经 RegisterProxy 声明服务)。
// 与上面的 API 字段同受 apiMu 保护——写方是内核注入,读方是插件 Start
// 起的 goroutine。
proxyReg ProxyRegistrar
// apiMu 保护上面这些由内核注入的 API 字段,以及 autoRestart。
//
// 这些字段的写方与读方天然跨 goroutine:

345
sdk/proxy.go Normal file
View File

@ -0,0 +1,345 @@
package sdk
import (
"net"
"strconv"
"strings"
)
// 反向代理声明:插件告诉 HomeAgent「我起了个 HTTP 服务,请把它反代出去」。
//
// 为什么需要:插件自带 Web UI / HTTP API 时,监听地址在插件自己的配置里
// (如 127.0.0.1:12100),外部无从得知;而 webui 的对外端口通常只有一个
// (默认 :8080,且常经 frp 单端口隧道穿透)。没有声明机制时,用户只能
// 「知道端口 + 自己配转发」,插件换端口就失效。
//
// 设计取舍——**声明式而非注册式**:声明写在 plugin.json 里,由 HomeAgent
// 在加载插件时读取聚合,而不是让插件在运行期调 API 注册。理由:
// 1. 静态可发现:未启动/已崩溃的插件,其服务声明依然可见(可给出准确报错
// 「插件 X 声明了 ui 但目标 127.0.0.1:12100 不可达」,而不是静默 404);
// 2. 可版本化:声明随插件包一起分发、可 diff、可审计;
// 3. 旧内核无害:manifest 解析忽略未知字段,未支持该能力的 HomeAgent 读旧
// 插件、或旧 HomeAgent 读新插件都不会报错。
//
// 与 ToolDef / ChannelDef / ConfigDef 同族:SDK 定义声明契约,内核实现行为。
// 声明方式与其它能力一致 —— 在 Start() 里调 RegisterProxy(name, def),
// 或写进 plugin.json 的 proxies 字段(外部插件两种都支持)。
//
// 安全性:**不声明 = 不被反代**。声明本身就是能力声明,因此不需要在
// capabilities 里另外开一个开关——最小权限默认生效。
//
// # 单一入口原则(强制要求)
//
// **一个声明 = 一个入口**。被反代的插件必须让它的全部资源与接口都能从
// 该入口的一个基准路径出发访问到,不得依赖「入口之外的根路径」。
//
// 为什么强制:反代有两种挂载形态,而它们对「根路径」的处理截然不同——
//
// Host 形态(host):插件独占 <标签>.<基域名>,根路径就是插件的根。
// 根绝对路径(fetch('/api/x'))**天然正确**。
// Path 形态(path):插件挂在门户自身 host 的某个前缀下,根路径属于**门户**。
// 此时插件里的 fetch('/api/x') 会打到门户自己的 /api/x
// —— 静默错路由,页面能开但功能全坏。
//
// 于是「同一个插件必须同时支持两种形态」这条要求,等价于:
//
// **插件内部一律使用相对路径**(或基于 <base>/location 推导的路径),
// 绝不硬编码以 / 开头的绝对路径。
//
// 这样同一份前端在两种形态下都正确,插件作者也不必知道自己被挂在哪。
// 反代层据此可以:外部子域可用时给 Host 形态,子域不可用(证书/放行限制)
// 时给 Path 形态,**无需插件配合改动**。
//
// 自检(插件作者在本地就该做):把页面挂到 <门户>/<任意前缀>/ 下访问,
// 所有请求都必须仍然打到插件自己。
//
// 本项目实测案例:某插件前端写死 fetch('/api/status'),配在
// /p/huawei/ 下会打到门户的 /api/status(404 或返回门户数据);
// 改成相对路径后两种形态同时可用。
// ProxyDef 是一个服务的**反代声明体**。
//
// 与 ToolDef 同构:Name 同时出现在字段与 RegisterProxy 的第一个参数里
// (ToolDef 也是这么做的 —— 字段供 plugin.json 序列化,参数供运行期调用)。
// Name 只用于展示、日志与冲突提示,**不参与路由**(路由键是 Host 与 Path)。
type ProxyDef struct {
// Name 是同一插件内多条声明的唯一标识(如 "ui"、"api")。
// 运行期由 RegisterProxy 的第一个参数填入;声明式由 plugin.json 的
// name 键填入。省略时由 HomeAgent 兜底为 "service"。
Name string `json:"name,omitempty"`
// Host 是**子域名标签**(不含基域名),如 "huawei" 对应 huawei.<基域名>。
//
// 约束:仅小写字母、数字与连字符,不以连字符开头/结尾,长度 ≤ 63
// (DNS label 规则)。省略时默认取插件名(下划线转连字符,因为下划线
// 不是合法 DNS label 字符)。
//
// 冲突处理:两个插件声明同一 Host 时,HomeAgent 不做「后者覆盖前者」——
// 那样会让先声明者静默消失。冲突条目被拒绝并在反代表里记录原因。
Host string `json:"host,omitempty"`
// Target 是上游地址,形如 "127.0.0.1:12100" 或 "http://127.0.0.1:12100"。
// 可带路径前缀(如 "127.0.0.1:3000/base"),HomeAgent 转发时保留该前缀。
//
// 端口由插件自己填它**实际监听**的地址,避免「声明与实际漂移」。
Target string `json:"target"`
// WebSocket 表示该服务需要 WebSocket 升级透传(默认 false)。
//
// 为什么必须显式声明而不是「有 Upgrade 头就转」:WS 是长连接,会占用
// 反代侧连接与 goroutine,且绕过普通请求的响应缓冲/超时逻辑。默认关闭
// 让普通 HTTP 服务的失败模式保持简单;未声明时的升级请求会被明确拒绝,
// 而不是静默降级成普通请求(后者表现为前端一直重连、排查困难)。
WebSocket bool `json:"websocket,omitempty"`
// Path 是可选的**路径挂载前缀**(如 "/api/v1/device")。
//
// 为什么 Host 子域之外还需要它:子域形态依赖 DNS 解析,而 *.localhost
// 只有浏览器内置该特例(RFC 6761)—— 普通进程(设备客户端、固件、
// CLI)走系统解析器,实测解析不到,会以「no such host」失败。
// 路径形态挂在门户自身 host 下,**无任何 DNS 依赖**,是给非浏览器
// 客户端用的。
//
// 语义:请求路径**原样保留**(不做前缀剥除)——声明者按上游真实路径填写,
// 例如上游注册 /api/v1/device/ws,就声明 Path="/api/v1/device"。
// 这样设备客户端可以直接使用它已硬编码的路径,不需要知道反代的存在。
//
// 与 Host 形态的关系(见包注释的「单一入口原则」):声明的服务应当
// **同时**能被两种形态访问。因此 Path 形态下插件内部必须用相对路径,
// 否则它的前端会把请求打到门户自己身上。
//
// 留空 = 只提供子域形态(插件自带 UI 的常见情形:UI 与它自己的 API
// 同源,走子域天然正确)。
Path string `json:"path,omitempty"`
// StripPath 决定转发前是否**剥掉** Path 前缀。默认 false(原样保留)。
//
// 两种挂载语义真实不同,必须由声明者选,不能靠猜:
//
// false(别名模式):Path 就是上游真实路径的一部分。
// 请求 /api/v1/device/ws + Path="/api/v1/device"
// → 上游收到 /api/v1/device/ws(一模一样)。
// 适用:客户端**已硬编码**路径的机器接口(设备网关就是如此,
// 它按 /api/v1/device/ws 连接,不可能知道反代的存在)。
//
// true(前缀模式):Path 只是门户上的挂载点,上游不知道它。
// 请求 /p/myapp/api/status + Path="/p/myapp"
// → 上游收到 /api/status。
// 适用:自带 UI 的服务(前端用相对路径,被挂到哪里都对)。
//
// 为什么不能自动判定:同一个声明「Path=/api/v1/device」在两种语义下
// 都说得通,代理无从分辨 —— 猜错的结果是全部请求 404,且看起来像
// 上游故障。所以由声明者显式写清楚。
StripPath bool `json:"strip_path,omitempty"`
// Auth 决定这条反代由谁保护,取值见 ProxyAuthNone / ProxyAuthHomeAgent。
// 空串等价于 ProxyAuthHomeAgent(默认安全)。
//
// 为什么做成可声明项:设备网关(remotedevice)这类服务的调用方是**设备**,
// 它们不可能持有浏览器会话 cookie,而服务自身已有接入令牌(如 ws_token)。
// 强制走 HomeAgent 门户鉴权会把这类链路挡死;反过来,插件自带的 UI 若
// 声明 none,就等于把管理界面裸露给任何能访问该端口的人。
// 因此必须由插件**逐条**声明,而不是全局一刀切。
Auth string `json:"auth,omitempty"`
}
// ProxyAuth 取值。空串按 ProxyAuthHomeAgent 处理(安全的默认)。
const (
// ProxyAuthHomeAgent 表示由 HomeAgent 统一保护:浏览器走门户会话
// (homeagent_session cookie),非浏览器客户端走 X-API-Key。
// 两者都没有时返回 401,而不是把请求透传给上游。
ProxyAuthHomeAgent = "homeagent"
// ProxyAuthNone 表示不经 HomeAgent 鉴权,直接把请求转发给上游。
//
// 适用场景:上游自己有鉴权且调用方不是浏览器(设备/嵌入式客户端),
// 或上游是刻意公开的服务。选用它意味着**信任上游自身的鉴权**,
// 且该服务在网络层可达范围内对所有人开放。
ProxyAuthNone = "none"
)
// ValidProxyAuth 校验 Auth 取值;空串合法(等价 ProxyAuthHomeAgent)。
func ValidProxyAuth(auth string) bool {
switch auth {
case "", ProxyAuthHomeAgent, ProxyAuthNone:
return true
}
return false
}
// EffectiveProxyAuth 返回生效的鉴权模式(空串归一化为 ProxyAuthHomeAgent)。
func EffectiveProxyAuth(auth string) string {
if auth == "" {
return ProxyAuthHomeAgent
}
return auth
}
// ValidProxyHostLabel 校验子域名标签是否合法(DNS label 规则)。
//
// 独立成导出函数:插件作者在写声明时、HomeAgent 在加载时、工具链在打包时
// 都要用同一套规则判定,避免三处各写一份而互相不一致。
func ValidProxyHostLabel(label string) bool {
if label == "" || len(label) > 63 {
return false
}
if label[0] == '-' || label[len(label)-1] == '-' {
return false
}
for i := 0; i < len(label); i++ {
c := label[i]
switch {
case c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '-':
default:
return false
}
}
return true
}
// NormalizeProxyHost 由插件名派生默认 Host 标签。
//
// 下划线转连字符:插件名允许下划线(huawei_smarthome),但 DNS label 不允许,
// 直接用会导致该子域名无法解析——这里统一转换,避免每个插件各自碰运气。
func NormalizeProxyHost(pluginName string) string {
s := strings.ToLower(strings.TrimSpace(pluginName))
s = strings.ReplaceAll(s, "_", "-")
// 去掉其它非法字符,保证结果是合法 label(宁可退化成保守值也不产出非法域名)
var b strings.Builder
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '-':
b.WriteByte(c)
}
}
out := strings.Trim(b.String(), "-")
if out == "" {
return "plugin"
}
if len(out) > 63 {
out = strings.Trim(out[:63], "-")
}
return out
}
// ValidateProxyDef 校验一条反代声明,返回人类可读的错误说明(合法时为空)。
//
// 为什么要在 SDK 里做校验:HomeAgent 加载插件时必须能明确拒绝坏声明并说明
// 原因(而不是静默忽略导致用户以为配好了);插件作者也需要在本地就能查出
// 拼错的 Target/Host。同一套规则两端共用。
func ValidateProxyDef(d ProxyDef) string {
if strings.TrimSpace(d.Target) == "" {
return "target 为空:必须给出上游地址(如 127.0.0.1:12100 或 http://127.0.0.1:12100)"
}
if !ValidProxyAuth(d.Auth) {
return "auth 取值非法:" + d.Auth + "(只允许 \"\" / \"homeagent\" / \"none\")"
}
if d.Host != "" && !ValidProxyHostLabel(d.Host) {
return "host 不是合法的子域名标签(只允许小写字母/数字/连字符,且不以连字符开头结尾): " + d.Host
}
if p := strings.TrimSpace(d.Path); p != "" {
if !strings.HasPrefix(p, "/") {
return "path 必须以 / 开头: " + d.Path
}
if strings.HasSuffix(p, "/") {
return "path 不应以 / 结尾(它是前缀,不是目录): " + d.Path
}
if strings.Contains(p, "..") || strings.ContainsAny(p, " \t\r\n\x00?#") {
return "path 含非法字符: " + d.Path
}
}
// 前缀模式必须给出可剥的前缀。
// 注意 "/" 不需要单独判:它是前缀又同时以 "/" 结尾,已被上面的
// 「不应以 / 结尾」规则挡掉(挂到门户根会覆盖整站的意图因此无法达成)。
if d.StripPath && strings.TrimSpace(d.Path) == "" {
return "strip_path=true 时必须给出 path(否则没有可剥的前缀)"
}
// Target 的 host:port 部分必须可解析;路径前缀允许保留。
//
// 规则(刻意从严,因为地址写错是最常见的声明错误,而错误的反代会把
// 用户带到别处去):
// - 带 scheme 时(http://…)允许省略端口,由反代层按 scheme 补默认值;
// - 不带 scheme 时必须给出 host:port;
// - 端口必须是数字(SplitHostPort 本身不校验数字,"host:abc" 会通过)。
scheme := ""
raw := d.Target
if i := strings.Index(raw, "://"); i >= 0 {
scheme = strings.ToLower(raw[:i])
if scheme != "http" && scheme != "https" {
return "target scheme 只支持 http/https(WS 由 websocket 字段声明,不写 ws://): " + d.Target
}
raw = raw[i+3:]
}
if i := strings.IndexByte(raw, '/'); i >= 0 {
raw = raw[:i]
}
if raw == "" {
return "target 缺少主机部分: " + d.Target
}
host, port, err := net.SplitHostPort(raw)
if err != nil {
if scheme == "" {
return "target 必须给出 host:port(或带 http:// 前缀以便省略端口): " + d.Target
}
// 带 scheme 且解析失败:只剩主机名一种合法情形。
host, port = raw, ""
}
if host == "" {
return "target 缺少主机部分: " + d.Target
}
if port != "" {
n, err := strconv.Atoi(port)
if err != nil || n < 1 || n > 65535 {
return "target 端口非法(应为 1-65535 的数字): " + d.Target
}
}
return ""
}
// ProxyRegistrar 由内核注入(与 ToolRegistrar / InputChannelRegistrar 同族)。
// 插件不直接调它,用 RegisterProxy。
//
// 为什么需要运行期注册(明明有 plugin.json 自动发现):**内置插件**
// (编译进内核、没有独立插件目录与 plugin.json,如 remotedevice)扫不到;
// 而它们恰恰最需要被反代出去(设备网关就是内置的)。两种来源互补:
// - 外部插件 → plugin.json 的 proxies(静态,未启动也可见)
// - 内置插件 → RegisterProxy(运行期,随 Start 注册)
type ProxyRegistrar func(name string, def ProxyDef)
// SetProxyRegistrar 由内核注入。插件不直接调它(与 SetInputChannelRegistrar 同族)。
func (s *PluginSDK) SetProxyRegistrar(r ProxyRegistrar) {
if s == nil {
return
}
s.apiMu.Lock()
s.proxyReg = r
s.apiMu.Unlock()
}
// RegisterProxy 声明一个需要 HomeAgent 反代出去的服务。
//
// 与 RegisterTool / RegisterInputChannel / RegisterOutputChannel 同一风格:
// 显式给名字 + 声明体。名字用于展示、日志与冲突提示(不参与路由 —— 路由键是
// def.Host / def.Path)。
//
// 用法(通常在 Start 里调用):
//
// s.RegisterProxy("ui", sdk.ProxyDef{
// Host: "myapp", Target: "127.0.0.1:12100",
// })
//
// 声明立即生效(反代表在下一次请求时重建)。**不做去重**:同一 Host/Path
// 被两条声明占用时由反代层判定冲突并明确报错,而不是在这里静默吞掉 ——
// 插件作者需要看见冲突。
//
// 与 plugin.json 的 proxies 字段等价:写哪个都行,两者会合并(同名以本调用为准)。
func (s *PluginSDK) RegisterProxy(name string, def ProxyDef) {
if s == nil {
return
}
s.apiMu.RLock()
r := s.proxyReg
s.apiMu.RUnlock()
if r != nil {
r(name, def)
}
}

163
sdk/proxy_test.go Normal file
View File

@ -0,0 +1,163 @@
package sdk
import (
"os"
"strings"
"testing"
)
func TestProxyAuthDefaultsToHomeAgent(t *testing.T) {
// 空串必须归一化为「HomeAgent 统一保护」——这是安全默认。
// 若哪天有人把默认改成 none,这条会立刻红。
if got := EffectiveProxyAuth(""); got != ProxyAuthHomeAgent {
t.Fatalf("空 auth 应归一化为 %q,实际 %q", ProxyAuthHomeAgent, got)
}
if got := EffectiveProxyAuth(ProxyAuthNone); got != ProxyAuthNone {
t.Fatalf("显式 none 应保持 none,实际 %q", got)
}
for _, ok := range []string{"", ProxyAuthHomeAgent, ProxyAuthNone} {
if !ValidProxyAuth(ok) {
t.Errorf("%q 应合法", ok)
}
}
for _, bad := range []string{"nope", "HOMEAGENT", "None", "true"} {
if ValidProxyAuth(bad) {
t.Errorf("%q 应非法", bad)
}
}
}
func TestValidProxyHostLabel(t *testing.T) {
legit := []string{"huawei", "a", "a-b", "abc123", "0", "x" + string(make([]byte, 0)) + "yz"}
for _, s := range legit {
if !ValidProxyHostLabel(s) {
t.Errorf("%q 应为合法 label", s)
}
}
bad := []string{
"", "-a", "a-", "-", "a_b", "a.b", "A", "aB", "a b",
"a/b", "a:b", string(make([]byte, 64)), // 超长 63
}
for _, s := range bad {
if ValidProxyHostLabel(s) {
t.Errorf("%q 应为非法 label", s)
}
}
// 边界:恰好 63 合法,64 非法
l63 := ""
for i := 0; i < 63; i++ {
l63 += "a"
}
if !ValidProxyHostLabel(l63) {
t.Error("63 字符应为合法 label")
}
if ValidProxyHostLabel(l63 + "a") {
t.Error("64 字符应为非法 label")
}
}
func TestNormalizeProxyHost(t *testing.T) {
cases := map[string]string{
"huawei_smarthome": "huawei-smarthome", // 下划线不是合法 DNS label
"webui": "webui",
"UPPER_Case": "upper-case",
"a__b": "a--b",
"__x__": "x",
"---": "plugin", // 全非法 → 保守回退
"": "plugin",
"a.b.c": "abc",
}
for in, want := range cases {
if got := NormalizeProxyHost(in); got != want {
t.Errorf("NormalizeProxyHost(%q) = %q,期望 %q", in, got, want)
}
}
// 归一化结果必须自身合法(产物自洽)
for _, in := range []string{"huawei_smarthome", "UPPER_Case", "__x__", "a.b.c", "非常长的名字非常长的名字非常长的名字非常长的名字非常长的名字非常长的名字非常长的名字"} {
if got := NormalizeProxyHost(in); !ValidProxyHostLabel(got) {
t.Errorf("NormalizeProxyHost(%q) = %q 不合法", in, got)
}
}
}
func TestValidateProxyDef(t *testing.T) {
valid := []ProxyDef{
{Target: "127.0.0.1:12100"},
{Target: "http://127.0.0.1:12100"},
{Target: "127.0.0.1:12100", Host: "huawei"},
{Target: "127.0.0.1:12100", Auth: ProxyAuthNone},
{Target: "127.0.0.1:12100", Auth: ProxyAuthHomeAgent, WebSocket: true},
{Target: "127.0.0.1:3000/base", Host: "x"},
{Target: "https://example.com", Host: "ext"}, // 远程上游也允许(由 auth 决定安全性)
}
for _, d := range valid {
if msg := ValidateProxyDef(d); msg != "" {
t.Errorf("%+v 应合法,却报: %s", d, msg)
}
}
bad := []ProxyDef{
{}, // 无 target
{Target: " "}, // 空白 target
{Target: "127.0.0.1:12100", Auth: "yes"}, // auth 非法
{Target: "127.0.0.1:12100", Host: "a_b"}, // host 非法
{Target: "127.0.0.1:12100", Host: "-x"},
{Target: "127.0.0.1:12100", Host: "X"},
{Target: "://12100"}, // 无主机
{Target: "http:///path"}, // 无主机
{Target: "127.0.0.1:notaport"}, // 端口非数字
}
for _, d := range bad {
if msg := ValidateProxyDef(d); msg == "" {
t.Errorf("%+v 应被拒绝,却通过了", d)
}
}
}
// ---- 单一入口原则 ----
// 被反代的插件必须能同时适配 Host 形态与 Path 形态。这两条判据把
// 「插件内部不得用根绝对路径」这条契约钉在**可执行**的层面:
// 声明合法不代表它的资源能被两种形态访问到 —— 后者取决于插件前端的写法,
// 而 SDK 只能把要求写清楚并给出校验工具。
func TestSingleEntryPrincipleDocumented(t *testing.T) {
// Path 形态下插件前端必须用相对路径,否则请求会打到门户自己。
// 这是**文档级约定**,只能靠 review 与这份判据共同保证:
// 判据确保 SDK 里确实写明了这条要求(防止后来者删掉注释)。
src, err := os.ReadFile("proxy.go")
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"单一入口原则",
"相对路径",
"根绝对路径",
} {
if !strings.Contains(string(src), want) {
t.Errorf("SDK 文档缺少「%s」—— 单一入口原则是反代的硬要求,不能只存在于口头约定里", want)
}
}
}
// strip_path 的两种语义必须由声明者显式选,且非法组合要被挡住。
func TestStripPathValidation(t *testing.T) {
// 合法:两种模式
for _, d := range []ProxyDef{
{Target: "127.0.0.1:1", Path: "/p/app", StripPath: true},
{Target: "127.0.0.1:1", Path: "/api/v1/device", StripPath: false},
} {
if msg := ValidateProxyDef(d); msg != "" {
t.Errorf("应合法却被拒: %+v → %s", d, msg)
}
}
// 非法:strip_path 但没有 path(没有可剥的前缀)
if msg := ValidateProxyDef(ProxyDef{Target: "127.0.0.1:1", StripPath: true}); msg == "" {
t.Error("strip_path=true 而无 path 应被拒(没有可剥的前缀)")
}
// 非法:前缀模式挂到根会吞掉整个门户。
// 实际由「不应以 / 结尾」规则挡下("/" 同时是前缀又以 / 结尾),
// 这里断言的是**行为**:这种声明无论如何都不能通过。
if msg := ValidateProxyDef(ProxyDef{Target: "127.0.0.1:1", Path: "/", StripPath: true}); msg == "" {
t.Error("path=\"/\" + strip_path 应被拒(会覆盖整个门户)")
}
}

View File

@ -337,6 +337,12 @@ func writePluginJSON(plg *PlgConfig, platforms []string, entry string) {
if plg.ResolvedSDK != "" {
m["sdk"] = plg.ResolvedSDK
}
// 反代声明必须写进产物:内核靠读 plugin.json 的 proxies 才知道该把
// 哪个服务反向代理出去。漏写的话插件**装得上、启动正常、就是不出现** ——
// 没有任何报错,只有「访问不到」。这正是当初新增该字段时踩过的坑。
if len(plg.Proxies) > 0 {
m["proxies"] = plg.Proxies
}
data, _ := json.MarshalIndent(m, "", " ")
os.WriteFile("plugin.json", data, 0644)
}

View File

@ -2,9 +2,11 @@ package main
import (
"fmt"
"net"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"text/template"
)
@ -18,6 +20,67 @@ func (p *PlgConfig) ReplacesToSlice() []string {
return s
}
// validateProxies 在打包前校验反代声明,让插件作者**本地**就发现写错,
// 而不是装到 HomeAgent 上才看到「声明被拒」。
//
// 校验规则与 SDK 的 sdk.ValidateProxyDef 保持一致(同一套 DNS label / auth /
// target 规则);工具链不 import SDK 是为了保持"打包机只需工具链"的独立性,
// 两侧一致性由 SDK 仓与主仓的同名测试分别钉住。
func validateProxies(list []ProxyConfig) error {
seen := map[string]bool{}
for i, p := range list {
if strings.TrimSpace(p.Target) == "" {
return fmt.Errorf("proxies[%d] (%s): target 不能为空", i, p.Name)
}
switch p.Auth {
case "", "homeagent", "none":
default:
return fmt.Errorf("proxies[%d] (%s): auth 只允许 \"\"/\"homeagent\"/\"none\",得到 %q", i, p.Name, p.Auth)
}
if p.Host != "" {
if !validHostLabel(p.Host) {
return fmt.Errorf("proxies[%d] (%s): host %q 不是合法子域名标签(小写字母/数字/连字符,不以连字符开头结尾,≤63)", i, p.Name, p.Host)
}
if seen[p.Host] {
return fmt.Errorf("proxies[%d]: host %q 在同一声明里重复", i, p.Host)
}
seen[p.Host] = true
}
// 端口必须是数字:SplitHostPort 不校验数字,"host:abc" 会溜过去
raw := p.Target
if j := strings.Index(raw, "://"); j >= 0 {
raw = raw[j+3:]
}
if j := strings.IndexByte(raw, '/'); j >= 0 {
raw = raw[:j]
}
if h, port, err := net.SplitHostPort(raw); err == nil {
if h == "" {
return fmt.Errorf("proxies[%d] (%s): target %q 缺少主机", i, p.Name, p.Target)
}
if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 {
return fmt.Errorf("proxies[%d] (%s): target 端口非法(应为 1-65535): %q", i, p.Name, p.Target)
}
}
}
return nil
}
func validHostLabel(s string) bool {
if s == "" || len(s) > 63 || s[0] == '-' || s[len(s)-1] == '-' {
return false
}
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '-':
default:
return false
}
}
return true
}
type PlgConfig struct {
Name string `json:"name"`
NameZh string `json:"name_zh"`
@ -43,6 +106,13 @@ type PlgConfig struct {
// 补丁由工具链挑最新」(patch 只含工具链/打包修复,接口不变,见 README 版本语义)。
SDK string `json:"sdk,omitempty"`
// Proxies 声明本插件需要 HomeAgent 反代出去的服务(自带 Web UI / HTTP API)。
//
// 为什么声明在 plugin.json 而不是运行期注册:静态可发现(插件没起来时
// 也能报「声明了 ui 但目标不可达」,而不是静默 404)、可版本化、旧内核无害。
// 字段语义见 SDK 的 sdk.ProxyDef(工具链与内核共用同一套校验规则)。
Proxies []ProxyConfig `json:"proxies,omitempty"`
// ResolvedSDK 是本次构建实际选中的 SDK 版本(build 按 SDK 声明解析后回填),
// 只写进产物里的 plugin.json,便于事后追溯「这个 .hmap 是哪版 SDK 编的」。
ResolvedSDK string `json:"-"`

View File

@ -0,0 +1,22 @@
package main
// ProxyConfig 是 plugin.json 里的反代声明项(工具链侧镜像,对应 sdk.ProxyDef)。
//
// 为什么不直接 import SDK 的 sdk.ProxyDef:工具链是**独立可执行**,
// 打包机可能只装了工具链而没有 SDK 源码树(旧版本常见)。镜像一份最小结构
// 能让 hmapdev 单机可用;两者的字段名必须保持一致,由
// tools/hmapdev/proxy_config_test.go 的对照测试钉住,防止单边漂移。
type ProxyConfig struct {
Name string `json:"name,omitempty"`
Host string `json:"host,omitempty"`
Path string `json:"path,omitempty"`
StripPath bool `json:"strip_path,omitempty"`
Target string `json:"target"`
WebSocket bool `json:"websocket,omitempty"`
Auth string `json:"auth,omitempty"`
}
// proxySchemaKeys 是 Proxies 序列化后允许出现的 JSON 键集合。
// writePluginJSON 用的是 map 重建,这里列清楚是为了让
// 「新增字段忘了同步」这件事在测试里立刻暴露。
var proxySchemaKeys = []string{"name", "host", "path", "strip_path", "target", "websocket", "auth"}

View File

@ -0,0 +1,136 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
)
// writePluginJSON 是「白名单 map 重建」,新增字段若忘了同步进 map 会被静默丢弃。
// 本测试钉住这件事:声明了 proxies 的插件,产物 plugin.json 里必须还有 proxies。
func TestWritePluginJSONPreservesProxies(t *testing.T) {
dir := t.TempDir()
old, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
defer os.Chdir(old)
plg := &PlgConfig{
Name: "demo",
NameZh: "示例",
NameEn: "Demo",
Version: "0.1.0",
Author: "test",
Entry: "plugin.bin",
Tags: []string{"demo"},
Proxies: []ProxyConfig{
{Name: "ui", Host: "demo", Target: "127.0.0.1:12100"},
{Name: "gw", Host: "demo-gw", Target: "127.0.0.1:9890", WebSocket: true, Auth: "none"},
},
}
writePluginJSON(plg, []string{"linux"}, "plugin.bin")
raw, err := os.ReadFile(filepath.Join(dir, "plugin.json"))
if err != nil {
t.Fatal(err)
}
var got map[string]interface{}
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatal(err)
}
proxies, ok := got["proxies"].([]interface{})
if !ok {
t.Fatalf("产物 plugin.json 丢了 proxies 字段,内容:%s", raw)
}
if len(proxies) != 2 {
t.Fatalf("proxies 条数 = %d,期望 2", len(proxies))
}
first, _ := proxies[0].(map[string]interface{})
if first["target"] != "127.0.0.1:12100" || first["host"] != "demo" {
t.Errorf("第 1 条声明内容不对: %v", first)
}
second, _ := proxies[1].(map[string]interface{})
if second["websocket"] != true || second["auth"] != "none" {
t.Errorf("第 2 条声明丢了 websocket/auth: %v", second)
}
}
// 没声明 proxies 时不应凭空冒出该字段(保持旧产物的字段集合不变)。
func TestWritePluginJSONOmitsEmptyProxies(t *testing.T) {
dir := t.TempDir()
old, _ := os.Getwd()
if err := os.Chdir(dir); err != nil {
t.Fatal(err)
}
defer os.Chdir(old)
writePluginJSON(&PlgConfig{Name: "nop", Entry: "plugin.bin"}, nil, "plugin.bin")
raw, _ := os.ReadFile(filepath.Join(dir, "plugin.json"))
if strings.Contains(string(raw), "proxies") {
t.Errorf("未声明 proxies 却出现在产物里:%s", raw)
}
}
// 字段漂移守卫:ProxyConfig 的 JSON 键必须与 proxySchemaKeys 完全一致。
// 加字段时若只改结构体不改这个清单,测试会红,提醒同步内核侧解析与文档。
func TestProxyConfigSchemaKeys(t *testing.T) {
raw, err := json.Marshal(ProxyConfig{})
if err != nil {
t.Fatal(err)
}
var m map[string]interface{}
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
// 空结构体 + omitempty 会全部省略,所以改用非零值探测。
// 每个字段都必须出现在探测体里,否则「新增字段忘了同步」检测不出来。
raw, _ = json.Marshal(ProxyConfig{
Name: "n", Host: "h", Path: "/p", StripPath: true,
Target: "t", WebSocket: true, Auth: "a",
})
m = nil
_ = json.Unmarshal(raw, &m)
if len(m) != len(proxySchemaKeys) {
t.Fatalf("ProxyConfig 有 %d 个 JSON 键,proxySchemaKeys 列了 %d 个;请同步",
len(m), len(proxySchemaKeys))
}
for _, k := range proxySchemaKeys {
if _, ok := m[k]; !ok {
t.Errorf("proxySchemaKeys 列了 %q,但 ProxyConfig 序列化后没有该键", k)
}
}
}
// validateProxies 必须让插件作者在**打包时**就发现写错的声明。
func TestValidateProxies(t *testing.T) {
ok := [][]ProxyConfig{
{{Target: "127.0.0.1:12100"}},
{{Target: "http://127.0.0.1:12100", Host: "aaa"}},
{{Target: "127.0.0.1:9890", Host: "devices", WebSocket: true, Auth: "none"}},
{{Target: "https://example.com"}}, // 远程允许
}
for i, list := range ok {
if err := validateProxies(list); err != nil {
t.Errorf("合法集合 #%d 被拒: %v", i, err)
}
}
bad := [][]ProxyConfig{
{{Target: ""}},
{{Target: " "}},
{{Target: "127.0.0.1:1", Auth: "nope"}},
{{Target: "127.0.0.1:1", Host: "a_b"}},
{{Target: "127.0.0.1:1", Host: "-x"}},
{{Target: "127.0.0.1:1", Host: "X"}},
{{Target: "127.0.0.1:notaport"}},
{{Target: "127.0.0.1:99999"}},
{{Target: "127.0.0.1:1", Host: "dup"}, {Target: "127.0.0.1:2", Host: "dup"}}, // 同包内重复
}
for i, list := range bad {
if err := validateProxies(list); err == nil {
t.Errorf("非法集合 #%d 应被拒: %+v", i, list)
}
}
}