feat(gateway,admin-web): Skill 运行时安装 + backthink 打扰原因 + 建提醒入口(P0-19/P0-20/P0-22,审计 §4.3)
- P0-19:POST /admin/plugins/install 支持 `kind:"skill"`(此前一律 400)——yaml 或 path 二选一、 name 一致性校验、显式错误码(bad_request/name_mismatch/skill_parse_failed/name_conflict)、 装后以 kind:"skill" 出现在 GET /admin/plugins;**文件来源解析错误绝不回显文件内容**(变异验证实测确认 该泄露向量真实存在:yaml 解码器会带出出错值前几字符 ⇒ 只回显路径 + 通用原因,细节进日志) - P0-20:AdminBackthinkItem 补 route/detail/bypassed(DB 早已存,零迁移),面板 Backthink 展示 - P0-22:新增 POST /admin/automations/reminders(owner-only + 审计),at/message 必填、RFC3339 校验、 409 already_exists;过去时间不作 400(下次轮询立即触发) - 顺手修真缺陷:新建 id 原用 time.Now().UnixNano(),Windows 时钟粒度粗到毫秒 ⇒ 并行创建撞 id 得假 409; 改用 crypto/rand 128 位 hex(仓库既有 skills/store.go 范式) - 复核:gateway 包 build/vet/测试绿 + 前端 tsc 0 错;新增 12 用例(含 8 条非法输入显式码); **4 条变异反证**均已回滚 - 未做(登记):WS 帧侧的 route/detail 未在前端展示(`lib/wsParse.ts` 3 行,需放行);skill 未落 skills.Store(重启后消失,注入点在 cmd/cyrene);提醒无删除入口(CancelReminder 已存在、无路由)
This commit is contained in:
1 parent
3638ed7708
commit
decf59c1b4
12 files changed
+956
-24
No files matched your search
@@ -10,6 +10,22 @@ import { adminBackthink } from "../../lib/api";
|
||||
import { useAsyncData } from "../../lib/useAsyncData";
|
||||
import { BACKTHINK_SESSION_ID } from "../../store/sessionCache";
|
||||
|
||||
/** 推送路由的中文标签(后端 PushResult.Route 的取值域)。未知取值原样显示,
|
||||
* 绝不猜一个更好听的词。 */
|
||||
const PUSH_ROUTE_LABEL: Record<string, string> = {
|
||||
active_client: "活跃客户端",
|
||||
im_bridge: "IM 桥",
|
||||
rate_limited: "限频拦截",
|
||||
none: "无投递目标",
|
||||
};
|
||||
|
||||
/** 推送路由的色调:真的送出去了才用 ok,被限频拦住是 warn。 */
|
||||
function pushRouteTone(route: string): "ok" | "warn" | "neutral" {
|
||||
if (route === "active_client" || route === "im_bridge") return "ok";
|
||||
if (route === "rate_limited") return "warn";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function BackthinkRow({ item }: { item: BackthinkItem }): React.ReactNode {
|
||||
const markPushed = useAppStore((s) => s.markBackthinkPushed);
|
||||
// 裁决 16.4.2:这条思考的链路图取自**后台思考会话**(`sess-backthink`,design/19 §1
|
||||
@@ -26,6 +42,8 @@ function BackthinkRow({ item }: { item: BackthinkItem }): React.ReactNode {
|
||||
<span className="font-medium">{item.trigger}</span>
|
||||
<Tag tone={item.shouldNotify ? "warn" : "neutral"}>{item.shouldNotify ? "需要打扰" : "静默"}</Tag>
|
||||
<Tag tone={item.pushed ? "ok" : "neutral"}>{item.pushed ? "已推送" : "未推送"}</Tag>
|
||||
{item.route ? <Tag tone={pushRouteTone(item.route)}>{PUSH_ROUTE_LABEL[item.route] ?? item.route}</Tag> : null}
|
||||
{item.bypassed ? <Tag tone="accent">事件直发</Tag> : null}
|
||||
<span className="ml-auto">
|
||||
<Button size="sm" variant="ghost" disabled={item.pushed} onClick={() => markPushed(item.id)}>
|
||||
{item.pushed ? "已推送" : "推送结果"}
|
||||
@@ -37,6 +55,9 @@ function BackthinkRow({ item }: { item: BackthinkItem }): React.ReactNode {
|
||||
{item.conclusion}
|
||||
</div>
|
||||
) : null}
|
||||
{item.detail ? (
|
||||
<div className="text-xs text-[var(--mu-text-lo)]">推送说明:{item.detail}</div>
|
||||
) : null}
|
||||
<button
|
||||
className="text-xs text-[var(--mu-accent-hi)]"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
@@ -117,7 +138,7 @@ export function BackthinkModule(): React.ReactNode {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Notice>数据源:/ws/chat 下行 backthink / thinking 事件(实时,主驱动)+ 打开时一次拉取 GET /api/v1/admin/backthink 历史,两者按 id 去重、按时间倒序,新记录增量插入(不整页重渲)。链路导图取自后台思考会话(sess-backthink)的 graph_update 快照 —— 后端完整链路图始终发布,与管理端 Flow 页的「当前会话」图相互独立(裁决 16.4.2)。干预为后端 M10+ 提供,当前为只读展示。</Notice>
|
||||
<Notice>数据源:/ws/chat 下行 backthink / thinking 事件(实时,主驱动)+ 打开时一次拉取 GET /api/v1/admin/backthink 历史,两者按 id 去重、按时间倒序,新记录增量插入(不整页重渲)。「推送结果」的 route / detail / bypassed 由这次历史拉取带回(后端 PushResult 的如实投影);实时帧里同一信息裹在原始 PushResult 内、前端解析器尚未读取,因此本次会话新到的行可能暂时只显示「已推送/未推送」。链路导图取自后台思考会话(sess-backthink)的 graph_update 快照 —— 后端完整链路图始终发布,与管理端 Flow 页的「当前会话」图相互独立(裁决 16.4.2)。干预为后端 M10+ 提供,当前为只读展示。</Notice>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -37,5 +37,12 @@ export interface BackthinkItem {
|
||||
conclusion?: string;
|
||||
shouldNotify?: boolean;
|
||||
pushed?: boolean;
|
||||
/** 投递路由(后端 PushResult.Route):none | rate_limited | active_client | im_bridge。
|
||||
* 缺席 = 后端没报告该字段(例如实时帧未经解析),不等于「无路由」。 */
|
||||
route?: string;
|
||||
/** 打扰决策或投递的简短原因(后端 PushResult.Detail),例如 quiet hours、无活跃客户端。 */
|
||||
detail?: string;
|
||||
/** 事件驱动推送绕过了限频闸(后端 PushResult.Bypassed,"该发就发")。 */
|
||||
bypassed?: boolean;
|
||||
at: number;
|
||||
}
|
||||
@@ -114,13 +114,24 @@ type AdminUserRecord struct {
|
||||
|
||||
// AdminBackthinkItem 是管理监控列表中一条后台思考记录的
|
||||
// 线上结构。
|
||||
//
|
||||
// Pushed 只回答「发没发出」;Route/Detail/Bypassed(design/12 §4、
|
||||
// design/10 §3 #12)回答「走了哪条路、为什么打扰/为什么不打扰」——
|
||||
// 它们直接来自后端已存的 PushResult,因此面板不必靠猜来解释
|
||||
// 一次静默。
|
||||
type AdminBackthinkItem struct {
|
||||
ID string `json:"id"`
|
||||
Trigger string `json:"trigger"`
|
||||
Conclusion string `json:"conclusion,omitempty"`
|
||||
ShouldNotify bool `json:"shouldNotify"`
|
||||
Pushed bool `json:"pushed"`
|
||||
At int64 `json:"at"`
|
||||
// Route 是投递路由:none | rate_limited | active_client | im_bridge。
|
||||
Route string `json:"route,omitempty"`
|
||||
// Detail 是打扰决策或投递的简短原因(如 quiet hours、无活跃客户端)。
|
||||
Detail string `json:"detail,omitempty"`
|
||||
// Bypassed 表示这是事件驱动的推送、绕过了限频闸("该发就发")。
|
||||
Bypassed bool `json:"bypassed"`
|
||||
At int64 `json:"at"`
|
||||
}
|
||||
|
||||
// AdminRagDoc 是管理 RAG 列表中一个知识库文档(项目索引
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/backthink"
|
||||
)
|
||||
|
||||
// listBackthinkItems 调用管理端点并把线上结构解码回来。
|
||||
func listBackthinkItems(t *testing.T, ts *httptest.Server, tok string) (int, []byte, []AdminBackthinkItem) {
|
||||
t.Helper()
|
||||
resp, raw := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/backthink", nil, tok)
|
||||
var list struct {
|
||||
Data []AdminBackthinkItem `json:"data"`
|
||||
}
|
||||
if resp.StatusCode == http.StatusOK {
|
||||
if err := json.Unmarshal(raw, &list); err != nil {
|
||||
t.Fatalf("decode: %v (%s)", err, raw)
|
||||
}
|
||||
}
|
||||
return resp.StatusCode, raw, list.Data
|
||||
}
|
||||
|
||||
// TestAdminBackthink_ExposesPushRouteDetailBypass 是 P0-20 的真断言:
|
||||
// GET /api/v1/admin/backthink 必须把后端已存的 PushResult 里
|
||||
// route/detail/bypassed 真的送到客户端 —— 面板才能解释「为什么打扰 /
|
||||
// 为什么没打扰」。断言同时落在原始 JSON 与解码后的值上。
|
||||
func TestAdminBackthink_ExposesPushRouteDetailBypass(t *testing.T) {
|
||||
t.Parallel()
|
||||
feed := &stubBackthinkFeed{records: []backthink.Record{
|
||||
{
|
||||
ID: 11, Trigger: "event", At: time.UnixMilli(1700), Conclusion: "服务器告警",
|
||||
ShouldPush: true,
|
||||
PushResult: backthink.PushResult{Pushed: true, Route: backthink.RouteActiveClient, Bypassed: true, Detail: "2 active clients"},
|
||||
},
|
||||
{
|
||||
ID: 10, Trigger: "heartbeat", At: time.UnixMilli(1600), Conclusion: "只是想了一下",
|
||||
ShouldPush: false,
|
||||
PushResult: backthink.PushResult{Pushed: false, Route: backthink.RouteRateLimited, Detail: "proactive push rate limited"},
|
||||
},
|
||||
}}
|
||||
_, ts, jwt := buildEnv(t, nil, Deps{Backthink: feed})
|
||||
status, raw, items := listBackthinkItems(t, ts, signAny(t, jwt, "acct", auth.RoleOwner))
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", status, raw)
|
||||
}
|
||||
for _, want := range []string{
|
||||
`"route":"active_client"`,
|
||||
`"route":"rate_limited"`,
|
||||
`"detail":"proactive push rate limited"`,
|
||||
`"bypassed":true`,
|
||||
} {
|
||||
if !strings.Contains(string(raw), want) {
|
||||
t.Fatalf("response is missing %s: %s", want, raw)
|
||||
}
|
||||
}
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("items = %d, want 2", len(items))
|
||||
}
|
||||
got := items[0]
|
||||
if got.ID != "11" || got.Route != string(backthink.RouteActiveClient) || got.Detail != "2 active clients" || !got.Bypassed || !got.Pushed {
|
||||
t.Fatalf("item[0] = %+v", got)
|
||||
}
|
||||
silent := items[1]
|
||||
if silent.Route != string(backthink.RouteRateLimited) || silent.Detail == "" || silent.Bypassed || silent.Pushed {
|
||||
t.Fatalf("item[1] = %+v", silent)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminBackthink_OmitsEmptyRouteAndDetail 钉住「没有值就不编」:未尝试
|
||||
// 推送(零值 PushResult)的记录不带 route/detail 键,而 bypassed 作为
|
||||
// 布尔始终显式出现 —— 面板因此能区分「这次没有路由」与「字段没接线」。
|
||||
func TestAdminBackthink_OmitsEmptyRouteAndDetail(t *testing.T) {
|
||||
t.Parallel()
|
||||
feed := &stubBackthinkFeed{records: []backthink.Record{
|
||||
{ID: 9, Trigger: "idle", At: time.UnixMilli(900), Conclusion: "什么都没有", ShouldPush: false},
|
||||
}}
|
||||
_, ts, jwt := buildEnv(t, nil, Deps{Backthink: feed})
|
||||
status, raw, items := listBackthinkItems(t, ts, signAny(t, jwt, "acct", auth.RoleOwner))
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("status = %d, body = %s", status, raw)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("items = %d, want 1", len(items))
|
||||
}
|
||||
it := items[0]
|
||||
if it.Route != "" || it.Detail != "" || it.Bypassed || it.Pushed || it.ShouldNotify {
|
||||
t.Fatalf("item = %+v, want zero push fields", it)
|
||||
}
|
||||
if strings.Contains(string(raw), `"route"`) || strings.Contains(string(raw), `"detail"`) {
|
||||
t.Fatalf("empty route/detail must be omitted: %s", raw)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"bypassed":false`) {
|
||||
t.Fatalf("bypassed must always be present: %s", raw)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,17 @@
|
||||
package gateway
|
||||
|
||||
// 管理侧插件安装/卸载与已安装插件列表。v1 中的运行时
|
||||
// 「插件」是一个外部 MCP server:安装即连接它、把它的
|
||||
// 工具热注册进能力注册表(从而无需重启就注入下一次
|
||||
// 模型请求),把其配置持久化到被 gitignore 的
|
||||
// plugins.json,并持有活着的 server 以便之后刷新/卸载
|
||||
// (design/09 插件生态 §1)。在单静态二进制中安装编译好的 Go 插件
|
||||
// 不可能,因此不提供该方式。写操作仅 owner 且带审计;
|
||||
// API key 会被持久化,但绝不返回,也绝不原样记日志。
|
||||
// 管理侧插件安装/卸载与已安装插件列表。运行时「插件」按 design/09
|
||||
// §2.1 有两类,都经同一个 install 端点、由 body 的 `kind` 分派:
|
||||
//
|
||||
// - MCP server(本文件):安装即连接它、把它的工具热注册进能力注册表
|
||||
// (从而无需重启就注入下一次模型请求),把其配置持久化到被 gitignore
|
||||
// 的 plugins.json,并持有活着的 server 以便之后刷新/卸载。
|
||||
// - Skill(admin_plugins_skill.go):安装即解析一份工作流 YAML 并
|
||||
// RegisterSkill 进注册表。
|
||||
//
|
||||
// 在单静态二进制中安装编译好的 Go 插件不可能,因此不提供该方式。
|
||||
// 写操作仅 owner 且带审计;API key 会被持久化,但绝不返回,
|
||||
// 也绝不原样记日志。
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -36,11 +40,16 @@ type pluginActionRequest struct {
|
||||
|
||||
// pluginInstallRequest 是 POST /api/v1/admin/plugins/install 的请求体。Config
|
||||
// 承载 MCP server 连接字段;顶层的 name/kind 被单独
|
||||
// 保留,使该请求读起来与其他管理写一致。
|
||||
// 保留,使该请求读起来与其他管理写一致。YAML/Path 是
|
||||
// kind="skill" 的两种来源(内联内容或磁盘路径,二选一)。
|
||||
type pluginInstallRequest struct {
|
||||
Kind string `json:"kind"`
|
||||
Name string `json:"name"`
|
||||
Config pluginInstallConfig `json:"config"`
|
||||
// YAML 是 Skill 定义的 YAML 文本(kind="skill")。
|
||||
YAML string `json:"yaml"`
|
||||
// Path 是 Skill 定义的 YAML 文件路径(kind="skill"),与 YAML 互斥。
|
||||
Path string `json:"path"`
|
||||
}
|
||||
|
||||
// pluginInstallConfig 是 MCP server 连接块。Endpoint 是 http/sse
|
||||
@@ -75,10 +84,9 @@ type AdminInstalledPlugin struct {
|
||||
ToolCount int `json:"toolCount"`
|
||||
}
|
||||
|
||||
// adminPluginInstall 安装(或刷新)一个 MCP server 插件:它校验
|
||||
// 请求、连接并发现该 server 的工具,然后持久化配置。
|
||||
// connect/discover/register 失败会返回 502 且不持久化任何东西,
|
||||
// 因此插件绝不会处于半安装状态。
|
||||
// adminPluginInstall 安装(或刷新)一个运行时插件。它先按 body 的
|
||||
// kind 分派(缺省为 mcp),两类的失败语义一致:校验或接入失败即返回
|
||||
// 400/502/409 且不留下任何半安装状态。
|
||||
func (s *Server) adminPluginInstall(w http.ResponseWriter, r *http.Request) {
|
||||
var req pluginInstallRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
@@ -86,19 +94,30 @@ func (s *Server) adminPluginInstall(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
s.auditClaims(r, "plugin.install", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name is required"))
|
||||
return
|
||||
}
|
||||
kind := strings.TrimSpace(req.Kind)
|
||||
if kind == "" {
|
||||
kind = runtimeconfig.PluginKindMCP
|
||||
}
|
||||
if kind != runtimeconfig.PluginKindMCP {
|
||||
switch kind {
|
||||
case runtimeconfig.PluginKindMCP:
|
||||
s.adminInstallMCPPlugin(w, r, req)
|
||||
case pluginKindSkill:
|
||||
s.adminInstallSkillPlugin(w, r, req)
|
||||
default:
|
||||
s.auditClaims(r, "plugin.install", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "kind %q is not installable; only %s", kind, runtimeconfig.PluginKindMCP))
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "kind %q is not installable; only %s and %s", kind, runtimeconfig.PluginKindMCP, pluginKindSkill))
|
||||
}
|
||||
}
|
||||
|
||||
// adminInstallMCPPlugin 安装(或刷新)一个 MCP server 插件:它校验
|
||||
// 请求、连接并发现该 server 的工具,然后持久化配置。
|
||||
// connect/discover/register 失败会返回 502 且不持久化任何东西,
|
||||
// 因此插件绝不会处于半安装状态。
|
||||
func (s *Server) adminInstallMCPPlugin(w http.ResponseWriter, r *http.Request, req pluginInstallRequest) {
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
s.auditClaims(r, "plugin.install", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "name is required"))
|
||||
return
|
||||
}
|
||||
if s.plugins == nil {
|
||||
@@ -108,7 +127,7 @@ func (s *Server) adminPluginInstall(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
cfg := runtimeconfig.PluginConfig{
|
||||
Name: name,
|
||||
Kind: kind,
|
||||
Kind: runtimeconfig.PluginKindMCP,
|
||||
Transport: strings.TrimSpace(req.Config.Transport),
|
||||
Endpoint: firstNonEmpty(req.Config.Endpoint, req.Config.URL),
|
||||
Command: strings.TrimSpace(req.Config.Command),
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
package gateway
|
||||
|
||||
// Skill 运行时安装(design/09 §2.1):POST /api/v1/admin/plugins/install 的
|
||||
// kind="skill" 分支。单二进制红线不允许热装编译好的 Go 程序,因此「安装
|
||||
// Skill」= 解析一份 Skill 工作流 YAML(内联内容或磁盘路径)并
|
||||
// RegisterSkill 进能力注册表。效果与 MCP 安装同范式:注册即刻可见
|
||||
// (GET /api/v1/admin/plugins 按 kind="skill" 列出、下一轮模型请求即可枚举),
|
||||
// 无需重启。执行侧仍按设计后置(Runner.Run 返回 ErrRunPending,M12 交付)。
|
||||
// 仅 owner 且带审计;解析失败 400 显式作答,绝不产生半安装状态。
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/skills"
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
// pluginKindSkill 是 install 端点接受的第二类插件(design/09 §0/§2.1)。
|
||||
pluginKindSkill = "skill"
|
||||
// maxSkillYAMLBytes 限制内联 YAML 与磁盘文件的大小,避免把任意巨型
|
||||
// 文件读进内存。Skill 定义是提示词 + 步骤表,1 MiB 已足够宽裕。
|
||||
maxSkillYAMLBytes = 1 << 20
|
||||
)
|
||||
|
||||
// skillSource 是 Skill 定义的两种来源之一:内联 YAML 文本,或磁盘上的
|
||||
// YAML 文件路径。fromFile 决定解析失败时能否回显 YAML 解码器的原始
|
||||
// 错误(它可能带出被解析内容的前几个字符,见 adminInstallSkillPlugin)。
|
||||
type skillSource struct {
|
||||
text string
|
||||
path string
|
||||
fromFile bool
|
||||
}
|
||||
|
||||
// describe 返回审计/日志用的来源标签,绝不包含 YAML 内容。
|
||||
func (s skillSource) describe() string {
|
||||
if s.fromFile {
|
||||
return "file:" + s.path
|
||||
}
|
||||
return "inline"
|
||||
}
|
||||
|
||||
// adminInstallSkillPlugin 安装(或刷新)一个 Skill:校验来源 →
|
||||
// 解析 YAML → 注册进能力注册表。同名已注册的 *Skill* 视为刷新
|
||||
// (与 MCP 安装「重复安装同名 = 刷新」同语义);同名已被其他 kind
|
||||
// 的能力占用则 409 显式失败,绝不静默顶掉别人。
|
||||
func (s *Server) adminInstallSkillPlugin(w http.ResponseWriter, r *http.Request, req pluginInstallRequest) {
|
||||
src, err := skillSourceOf(req)
|
||||
if err != nil {
|
||||
s.auditClaims(r, "plugin.install", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "%v", err))
|
||||
return
|
||||
}
|
||||
skill, err := s.parseSkill(src)
|
||||
if err != nil {
|
||||
s.auditClaims(r, "plugin.install", "error")
|
||||
s.log.Error("skill install parse failed", "source", src.describe(), "error", err)
|
||||
if src.fromFile {
|
||||
// 内容由服务端从磁盘读出:YAML 解码错误会带出内容片段(实测
|
||||
// `cannot unmarshal !!str \`LEAKMAR...\`` 会泄露前 7 个字符),
|
||||
// 因此绝不回显给客户端(完整原因在后端日志里)。路径用 %s 而非
|
||||
// %q:Windows 路径经 %q 会变成双反斜杠,回显给面板很难读。
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "skill_parse_failed", "skill 文件「%s」解析失败,详细原因见后端日志", src.path))
|
||||
return
|
||||
}
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "skill_parse_failed", "skill YAML 解析失败:%v", err))
|
||||
return
|
||||
}
|
||||
if name := strings.TrimSpace(req.Name); name != "" && name != skill.Name() {
|
||||
s.auditClaims(r, "plugin.install", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "name_mismatch", "name %q 与 YAML 中的 skill 名 %q 不一致", name, skill.Name()))
|
||||
return
|
||||
}
|
||||
reg, ok := s.caps.(*capreg.Registry)
|
||||
if !ok {
|
||||
s.auditClaims(r, "plugin.install", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "plugins_unavailable", "capability registry is not configured"))
|
||||
return
|
||||
}
|
||||
refreshed, err := registerSkillForInstall(reg, skill)
|
||||
if err != nil {
|
||||
s.auditClaims(r, "plugin.install", "error")
|
||||
s.log.Error("skill install register failed", "skill", skill.Name(), "error", err)
|
||||
var conflict *skillNameConflict
|
||||
if errors.As(err, &conflict) {
|
||||
writeError(w, apperr.Wrap(apperr.ErrConflict, "name_conflict", "%v", conflict))
|
||||
return
|
||||
}
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "plugin_install_failed", "skill %q 注册失败", skill.Name()))
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, "plugin.install", "ok")
|
||||
s.log.Info("skill installed", "skill", skill.Name(), "source", src.describe(), "refreshed", refreshed, "steps", len(skill.Steps()))
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"installed": true,
|
||||
"name": skill.Name(),
|
||||
"kind": pluginKindSkill,
|
||||
"refreshed": refreshed,
|
||||
"steps": len(skill.Steps()),
|
||||
"guardrails": len(skill.Guardrails()),
|
||||
})
|
||||
}
|
||||
|
||||
// skillNameConflict 表示要安装的 skill 名已被另一类能力占用(409 显式
|
||||
// 作答,绝不静默替换)。它是错误值,使 handler 能把「名字冲突」与
|
||||
// 「注册表故障」分开映射到 409 与 500。
|
||||
type skillNameConflict struct {
|
||||
name string
|
||||
occupiedBy string
|
||||
occupiedK string
|
||||
}
|
||||
|
||||
// Error 实现 error。
|
||||
func (c *skillNameConflict) Error() string {
|
||||
return fmt.Sprintf("能力名 %q 已被 kind=%s 的能力(%s)占用,不能用作 skill 名;请换名或先卸载该能力", c.name, c.occupiedK, c.occupiedBy)
|
||||
}
|
||||
|
||||
// registerSkillForInstall 把 skill 注册进 reg 并报告是否发生了刷新:
|
||||
// - 名字空闲 → 直接注册;
|
||||
// - 名字已被**同类**(KindSkill)占用 → 先移除再注册(MCP 安装的
|
||||
// 「重复安装同名 = 刷新」同语义;注册表已落盘的策略标记会被重新套上);
|
||||
// - 名字被**其他 kind** 占用 → 返回 *skillNameConflict,绝不删除别人。
|
||||
func registerSkillForInstall(reg *capreg.Registry, skill *skills.Skill) (bool, error) {
|
||||
existing, err := reg.Get(skill.Name())
|
||||
switch {
|
||||
case err == nil && existing.Kind != capreg.KindSkill:
|
||||
return false, &skillNameConflict{name: skill.Name(), occupiedBy: existing.Name, occupiedK: string(existing.Kind)}
|
||||
case err == nil:
|
||||
if rerr := reg.Remove(existing.Name); rerr != nil {
|
||||
return false, fmt.Errorf("gateway: refresh skill %q: %w", skill.Name(), rerr)
|
||||
}
|
||||
if rerr := skills.Register(reg, skill); rerr != nil {
|
||||
return false, fmt.Errorf("gateway: refresh skill %q: %w", skill.Name(), rerr)
|
||||
}
|
||||
return true, nil
|
||||
case errors.Is(err, capreg.ErrNotFound):
|
||||
if rerr := skills.Register(reg, skill); rerr != nil {
|
||||
return false, fmt.Errorf("gateway: register skill %q: %w", skill.Name(), rerr)
|
||||
}
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("gateway: lookup skill %q: %w", skill.Name(), err)
|
||||
}
|
||||
}
|
||||
|
||||
// skillSourceOf 从安装请求体取出 Skill 来源,强制 YAML 与 Path 二选一
|
||||
// (都空或都给都是显式 400 —— 后者会让人无法判断到底装的是哪一份)。
|
||||
func skillSourceOf(req pluginInstallRequest) (skillSource, error) {
|
||||
text := strings.TrimSpace(req.YAML)
|
||||
path := strings.TrimSpace(req.Path)
|
||||
switch {
|
||||
case text != "" && path != "":
|
||||
return skillSource{}, errors.New("yaml 与 path 互斥,只能给其中一个")
|
||||
case path != "":
|
||||
return skillSource{path: path, fromFile: true}, nil
|
||||
case text != "":
|
||||
return skillSource{text: text}, nil
|
||||
default:
|
||||
return skillSource{}, errors.New("kind=\"skill\" 需要 yaml(YAML 内容)或 path(YAML 文件路径)")
|
||||
}
|
||||
}
|
||||
|
||||
// parseSkill 用 skills.Loader 把来源解析为一个 Skill。文件来源有大小上限,
|
||||
// 且只接受普通文件(目录/设备文件不是 Skill 定义)。
|
||||
func (s *Server) parseSkill(src skillSource) (*skills.Skill, error) {
|
||||
loader := skills.NewLoader(s.log)
|
||||
if !src.fromFile {
|
||||
skill, err := loader.Load(strings.NewReader(src.text))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("skill yaml: %w", err)
|
||||
}
|
||||
return skill, nil
|
||||
}
|
||||
f, err := os.Open(src.path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open %q: %w", src.path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
info, err := f.Stat()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("stat %q: %w", src.path, err)
|
||||
}
|
||||
if !info.Mode().IsRegular() {
|
||||
return nil, fmt.Errorf("%q 不是普通文件", src.path)
|
||||
}
|
||||
if info.Size() > maxSkillYAMLBytes {
|
||||
return nil, fmt.Errorf("%q 超过 %d 字节上限", src.path, maxSkillYAMLBytes)
|
||||
}
|
||||
skill, err := loader.Load(io.LimitReader(f, maxSkillYAMLBytes))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("skill file %q: %w", src.path, err)
|
||||
}
|
||||
return skill, nil
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
||||
)
|
||||
|
||||
// dailyBriefYAML 是一份合法的 Skill 定义:两步工作流 + 一条引用 +
|
||||
// 一条护栏,用来断言注册进注册表的确实是「工作流」而不是空壳。
|
||||
const dailyBriefYAML = `name: daily-brief
|
||||
description: 每日简报
|
||||
prompt: 你是简报助手
|
||||
steps:
|
||||
- name: gather
|
||||
prompt: 收集今日要点
|
||||
- name: summarize
|
||||
prompt: 归纳成 3 条
|
||||
depends: [gather]
|
||||
refs:
|
||||
- name: calendar
|
||||
type: tool
|
||||
value: calendar
|
||||
guardrails:
|
||||
- id: no-pii
|
||||
message: 不要泄露隐私
|
||||
enforced: true
|
||||
`
|
||||
|
||||
// skillInstallResult 是 install 响应中的 skill 分支字段。
|
||||
type skillInstallResult struct {
|
||||
Installed bool `json:"installed"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Refreshed bool `json:"refreshed"`
|
||||
Steps int `json:"steps"`
|
||||
Guardrails int `json:"guardrails"`
|
||||
}
|
||||
|
||||
// errorEnvelope 是 {error:{code,message}} 信封的最小投影。
|
||||
type errorEnvelope struct {
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
|
||||
// installSkill 发起一次 skill 安装并返回状态码与原始响应体。
|
||||
func installSkill(t *testing.T, env *pluginTestEnv, body any) (int, []byte) {
|
||||
t.Helper()
|
||||
resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/install", body, env.token)
|
||||
return resp.StatusCode, raw
|
||||
}
|
||||
|
||||
// TestAdminPluginInstallSkill_RegistersWorkflowCapability 是本条的真断言:
|
||||
// POST kind="skill" 之后能力注册表里真的出现该 Skill(kind=skill),
|
||||
// 且其步骤/引用/护栏按 YAML 落地 —— 而不是只回一个 200。
|
||||
func TestAdminPluginInstallSkill_RegistersWorkflowCapability(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newPluginTestEnv(t, nil)
|
||||
|
||||
status, raw := installSkill(t, env, map[string]any{"kind": "skill", "yaml": dailyBriefYAML})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("install status = %d, body = %s", status, raw)
|
||||
}
|
||||
var envelope struct {
|
||||
Data skillInstallResult `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
t.Fatalf("decode: %v (%s)", err, raw)
|
||||
}
|
||||
if !envelope.Data.Installed || envelope.Data.Kind != "skill" || envelope.Data.Name != "daily-brief" || envelope.Data.Refreshed {
|
||||
t.Fatalf("install data = %+v", envelope.Data)
|
||||
}
|
||||
if envelope.Data.Steps != 2 || envelope.Data.Guardrails != 1 {
|
||||
t.Fatalf("install data counts = %+v, want 2 steps / 1 guardrail", envelope.Data)
|
||||
}
|
||||
|
||||
cap, err := env.caps.Get("daily-brief")
|
||||
if err != nil {
|
||||
t.Fatalf("registry lookup: %v", err)
|
||||
}
|
||||
if cap.Kind != capreg.KindSkill {
|
||||
t.Fatalf("capability kind = %q, want %q", cap.Kind, capreg.KindSkill)
|
||||
}
|
||||
skill, ok := cap.Value.(sdk.Skill)
|
||||
if !ok {
|
||||
t.Fatalf("capability value = %T, want sdk.Skill", cap.Value)
|
||||
}
|
||||
if got := skill.Steps(); len(got) != 2 || got[1].Name != "summarize" || len(got[1].Depends) != 1 {
|
||||
t.Fatalf("steps = %+v", got)
|
||||
}
|
||||
if got := skill.Refs(); len(got) != 1 || got[0].Value != "calendar" {
|
||||
t.Fatalf("refs = %+v", got)
|
||||
}
|
||||
if got := skill.Guardrails(); len(got) != 1 || !got[0].Enforced || got[0].Message != "不要泄露隐私" {
|
||||
t.Fatalf("guardrails = %+v", got)
|
||||
}
|
||||
|
||||
// 管理读面必须看得见它(面板靠 GET /admin/plugins 列出 kind=skill)。
|
||||
resp, raw := doReq(t, http.MethodGet, env.ts.URL+"/api/v1/admin/plugins", nil, env.token)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("plugins list status = %d, body = %s", resp.StatusCode, raw)
|
||||
}
|
||||
var list struct {
|
||||
Data []PluginSummary `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &list); err != nil {
|
||||
t.Fatalf("decode list: %v (%s)", err, raw)
|
||||
}
|
||||
found := false
|
||||
for _, it := range list.Data {
|
||||
if it.Name == "daily-brief" && it.Kind == "skill" && it.Description == "每日简报" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("skill not visible in /admin/plugins: %+v", list.Data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminPluginInstallSkill_ReinstallRefreshesSameName 钉住「重复安装同名 =
|
||||
// 刷新」语义:第二次带新描述的 YAML 必须换掉旧定义,而不是报冲突、
|
||||
// 更不是留下旧定义(否则面板「保存成功」是假的)。
|
||||
func TestAdminPluginInstallSkill_ReinstallRefreshesSameName(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newPluginTestEnv(t, nil)
|
||||
if status, raw := installSkill(t, env, map[string]any{"kind": "skill", "yaml": dailyBriefYAML}); status != http.StatusOK {
|
||||
t.Fatalf("first install status = %d, body = %s", status, raw)
|
||||
}
|
||||
updated := strings.Replace(dailyBriefYAML, "description: 每日简报", "description: 每日简报 v2", 1)
|
||||
status, raw := installSkill(t, env, map[string]any{"kind": "skill", "yaml": updated})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("refresh status = %d, body = %s", status, raw)
|
||||
}
|
||||
var envelope struct {
|
||||
Data skillInstallResult `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
t.Fatalf("decode: %v (%s)", err, raw)
|
||||
}
|
||||
if !envelope.Data.Refreshed {
|
||||
t.Fatalf("refreshed = false, want true for same-name reinstall: %s", raw)
|
||||
}
|
||||
cap, err := env.caps.Get("daily-brief")
|
||||
if err != nil {
|
||||
t.Fatalf("registry lookup: %v", err)
|
||||
}
|
||||
if cap.Description != "每日简报 v2" {
|
||||
t.Fatalf("description after refresh = %q, want 每日简报 v2", cap.Description)
|
||||
}
|
||||
if n := len(env.caps.List(capreg.KindSkill)); n != 1 {
|
||||
t.Fatalf("skill count = %d, want 1 (refresh must not duplicate)", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminPluginInstallSkill_FromFile 覆盖 design/09 §2.1 的第二种来源:
|
||||
// 磁盘 YAML 路径。同时验证卸载沿用通用能力移除路径。
|
||||
func TestAdminPluginInstallSkill_FromFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newPluginTestEnv(t, nil)
|
||||
path := filepath.Join(t.TempDir(), "daily-brief.yaml")
|
||||
if err := os.WriteFile(path, []byte(dailyBriefYAML), 0o600); err != nil {
|
||||
t.Fatalf("write skill file: %v", err)
|
||||
}
|
||||
status, raw := installSkill(t, env, map[string]any{"kind": "skill", "path": path})
|
||||
if status != http.StatusOK {
|
||||
t.Fatalf("install status = %d, body = %s", status, raw)
|
||||
}
|
||||
if _, err := env.caps.Get("daily-brief"); err != nil {
|
||||
t.Fatalf("skill not registered from file: %v", err)
|
||||
}
|
||||
|
||||
resp, raw := doReq(t, http.MethodPost, env.ts.URL+"/api/v1/admin/plugins/uninstall", map[string]any{"name": "daily-brief"}, env.token)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("uninstall status = %d, body = %s", resp.StatusCode, raw)
|
||||
}
|
||||
if _, err := env.caps.Get("daily-brief"); err == nil {
|
||||
t.Fatal("skill still registered after uninstall")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminPluginInstallSkill_RejectsNameConflictWithOtherKind 证明刷新语义
|
||||
// 不会越界:同名已被一个 tool 占用时返回 409,且那个 tool 必须原样存活
|
||||
// (绝不先删后装)。
|
||||
func TestAdminPluginInstallSkill_RejectsNameConflictWithOtherKind(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newPluginTestEnv(t, nil)
|
||||
if err := env.caps.RegisterTool(stubTool{}); err != nil { // 名为 "echo" 的工具
|
||||
t.Fatalf("register tool: %v", err)
|
||||
}
|
||||
conflicting := strings.Replace(dailyBriefYAML, "name: daily-brief", "name: echo", 1)
|
||||
status, raw := installSkill(t, env, map[string]any{"kind": "skill", "yaml": conflicting})
|
||||
if status != http.StatusConflict {
|
||||
t.Fatalf("status = %d, want 409, body = %s", status, raw)
|
||||
}
|
||||
var envErr errorEnvelope
|
||||
if err := json.Unmarshal(raw, &envErr); err != nil {
|
||||
t.Fatalf("decode error envelope: %v (%s)", err, raw)
|
||||
}
|
||||
if envErr.Error.Code != "name_conflict" {
|
||||
t.Fatalf("error code = %q, want name_conflict (%s)", envErr.Error.Code, raw)
|
||||
}
|
||||
cap, err := env.caps.Get("echo")
|
||||
if err != nil {
|
||||
t.Fatalf("tool echo disappeared after a rejected skill install: %v", err)
|
||||
}
|
||||
if cap.Kind != capreg.KindTool {
|
||||
t.Fatalf("capability echo kind = %q, want %q", cap.Kind, capreg.KindTool)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminPluginInstallSkill_InvalidRequestsReturn400 逐条钉住非法输入的
|
||||
// 显式 400(不静默、不留半安装状态)。
|
||||
func TestAdminPluginInstallSkill_InvalidRequestsReturn400(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newPluginTestEnv(t, nil)
|
||||
dir := t.TempDir()
|
||||
leaky := filepath.Join(dir, "leaky.yaml")
|
||||
// 类型不匹配会让 yaml 解码器把出错值的前几个字符写进错误信息,
|
||||
// 因此「文件来源」这条必须只回显通用原因(见下一条用例)。
|
||||
if err := os.WriteFile(leaky, []byte("name: leaky\nsteps: \"LEAKMARKER_SECRET\"\n"), 0o600); err != nil {
|
||||
t.Fatalf("write leaky skill: %v", err)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
wantCode string
|
||||
}{
|
||||
{"neither yaml nor path", map[string]any{"kind": "skill"}, "bad_request"},
|
||||
{"yaml and path", map[string]any{"kind": "skill", "yaml": dailyBriefYAML, "path": leaky}, "bad_request"},
|
||||
{"name mismatch", map[string]any{"kind": "skill", "name": "other", "yaml": dailyBriefYAML}, "name_mismatch"},
|
||||
{"blank name in yaml", map[string]any{"kind": "skill", "yaml": "description: 无名字\n"}, "skill_parse_failed"},
|
||||
{"not yaml at all", map[string]any{"kind": "skill", "yaml": "]{ not yaml"}, "skill_parse_failed"},
|
||||
{"missing file", map[string]any{"kind": "skill", "path": filepath.Join(dir, "nope.yaml")}, "skill_parse_failed"},
|
||||
{"path is a directory", map[string]any{"kind": "skill", "path": dir}, "skill_parse_failed"},
|
||||
{"bad yaml file", map[string]any{"kind": "skill", "path": leaky}, "skill_parse_failed"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
status, raw := installSkill(t, env, tc.body)
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400, body = %s", status, raw)
|
||||
}
|
||||
var envErr errorEnvelope
|
||||
if err := json.Unmarshal(raw, &envErr); err != nil {
|
||||
t.Fatalf("decode error envelope: %v (%s)", err, raw)
|
||||
}
|
||||
if envErr.Error.Code != tc.wantCode {
|
||||
t.Fatalf("error code = %q, want %q (%s)", envErr.Error.Code, tc.wantCode, raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
if n := len(env.caps.List(capreg.KindSkill)); n != 0 {
|
||||
t.Fatalf("skills registered after rejected installs = %d, want 0", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminPluginInstallSkill_FileParseErrorDoesNotEchoContent 是
|
||||
// 上一条的安全面:文件由服务端读取,解析错误可能带出文件片段
|
||||
// (yaml 解码器会打印出错值的前 7 个字符),因此响应体绝不回显
|
||||
// 内容,只给通用原因 + 路径。
|
||||
func TestAdminPluginInstallSkill_FileParseErrorDoesNotEchoContent(t *testing.T) {
|
||||
t.Parallel()
|
||||
env := newPluginTestEnv(t, nil)
|
||||
path := filepath.Join(t.TempDir(), "leaky.yaml")
|
||||
if err := os.WriteFile(path, []byte("name: leaky\nsteps: \"LEAKMARKER_SECRET\"\n"), 0o600); err != nil {
|
||||
t.Fatalf("write leaky skill: %v", err)
|
||||
}
|
||||
status, raw := installSkill(t, env, map[string]any{"kind": "skill", "path": path})
|
||||
if status != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want 400, body = %s", status, raw)
|
||||
}
|
||||
// "LEAKMAR" 正是解码器会带出的前 7 个字符;回显内联内容时它必现,
|
||||
// 因此这一断言能真正抓住「把文件内容透给客户端」的回归。
|
||||
if strings.Contains(string(raw), "LEAKMAR") {
|
||||
t.Fatalf("file-sourced parse error echoed file content: %s", raw)
|
||||
}
|
||||
var envErr errorEnvelope
|
||||
if err := json.Unmarshal(raw, &envErr); err != nil {
|
||||
t.Fatalf("decode error envelope: %v (%s)", err, raw)
|
||||
}
|
||||
if strings.Contains(envErr.Error.Message, "LEAKMAR") {
|
||||
t.Fatalf("decoded message leaked file content: %s", envErr.Error.Message)
|
||||
}
|
||||
if !strings.Contains(envErr.Error.Message, path) {
|
||||
t.Fatalf("error should name the file path, got %q", envErr.Error.Message)
|
||||
}
|
||||
}
|
||||
@@ -377,6 +377,9 @@ func (s *Server) adminListBackthink(w http.ResponseWriter, r *http.Request) {
|
||||
Conclusion: rec.Conclusion,
|
||||
ShouldNotify: rec.ShouldPush,
|
||||
Pushed: rec.PushResult.Pushed,
|
||||
Route: string(rec.PushResult.Route),
|
||||
Detail: rec.PushResult.Detail,
|
||||
Bypassed: rec.PushResult.Bypassed,
|
||||
At: rec.At.UnixMilli(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
|
||||
@@ -185,3 +189,82 @@ func (s *Server) adminDeleteAutomation(w http.ResponseWriter, r *http.Request) {
|
||||
s.auditClaims(r, "automation.delete", "ok")
|
||||
writeJSON(w, http.StatusOK, map[string]any{"id": id, "deleted": true})
|
||||
}
|
||||
|
||||
// reminderCreateRequest 是 POST /api/v1/admin/automations/reminders 的请求体。
|
||||
// At 是 RFC3339(带小数秒亦可);ID 省略时由后端生成,使面板无需
|
||||
// 自己保证唯一。
|
||||
type reminderCreateRequest struct {
|
||||
ID string `json:"id"`
|
||||
At string `json:"at"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// adminCreateReminder 创建一条真实的定时提醒(design/10 §3 #12「提醒」)。
|
||||
// 提醒进 ReminderStore 后由既有的轮询循环到点发布 reminder.trigger
|
||||
// 到进程内 bus(P0-22 之前该 Store 只有测试调用,生产没有创建入口,
|
||||
// 因此提醒功能实际不可达)。at 若已是过去时刻,按 Store 语义会在
|
||||
// 下一次轮询立即触发 —— 这是真实行为,不是假成功。
|
||||
func (s *Server) adminCreateReminder(w http.ResponseWriter, r *http.Request) {
|
||||
var req reminderCreateRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "invalid request body"))
|
||||
return
|
||||
}
|
||||
message := strings.TrimSpace(req.Message)
|
||||
rawAt := strings.TrimSpace(req.At)
|
||||
if message == "" || rawAt == "" {
|
||||
s.auditClaims(r, "automation.reminder.create", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "bad_request", "at 与 message 均为必填"))
|
||||
return
|
||||
}
|
||||
at, err := time.Parse(time.RFC3339, rawAt)
|
||||
if err != nil {
|
||||
s.auditClaims(r, "automation.reminder.create", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "invalid_at", "at 必须是 RFC3339 时间戳(如 2026-10-05T09:30:00+08:00)"))
|
||||
return
|
||||
}
|
||||
auto, ok := s.automations.(*automations.Service)
|
||||
if !ok {
|
||||
s.auditClaims(r, "automation.reminder.create", "error")
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "automation_unavailable", "自动化服务未配置"))
|
||||
return
|
||||
}
|
||||
id := strings.TrimSpace(req.ID)
|
||||
if id == "" {
|
||||
id = newReminderID()
|
||||
}
|
||||
rem := automations.Reminder{ID: id, At: at, Message: message}
|
||||
if err := auto.AddReminder(r.Context(), rem); err != nil {
|
||||
s.auditClaims(r, "automation.reminder.create", "error")
|
||||
if errors.Is(err, automations.ErrDuplicate) {
|
||||
writeError(w, apperr.Wrap(apperr.ErrConflict, "already_exists", "提醒 %q 已存在", id))
|
||||
return
|
||||
}
|
||||
if errors.Is(err, automations.ErrInvalidRule) {
|
||||
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "invalid_rule", "提醒 id 或时间非法"))
|
||||
return
|
||||
}
|
||||
writeError(w, apperr.Wrap(apperr.ErrInternal, "automation_failed", "提醒创建失败"))
|
||||
return
|
||||
}
|
||||
s.auditClaims(r, "automation.reminder.create", "ok")
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"id": rem.ID,
|
||||
"at": rem.At.Format(time.RFC3339),
|
||||
"message": rem.Message,
|
||||
"kind": "reminder",
|
||||
})
|
||||
}
|
||||
|
||||
// newReminderID 生成面板省略 id 时使用的提醒 id。它用 crypto/rand 的
|
||||
// 128 位十六进制随机值(与 skills.Store 的 newID 同范式),而不是
|
||||
// UnixNano:Windows 的 time.Now 粒度可粗到毫秒,同一刻度内连创两条
|
||||
// 会撞 id,被 ReminderStore 判为 ErrDuplicate(假失败)。
|
||||
func newReminderID() string {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
// crypto/rand 实际不会失败;兜底也在前缀上与随机形式同形。
|
||||
return "rem-" + strconv.FormatInt(time.Now().UnixNano(), 10)
|
||||
}
|
||||
return "rem-" + hex.EncodeToString(b[:])
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/services/automations"
|
||||
)
|
||||
|
||||
// reminderTestEnv 构建一个注入了真实 automations.Service 的网关
|
||||
// (P0-22 之前该服务在生产里没有任何创建入口,提醒因此不可达)。
|
||||
func reminderTestEnv(t *testing.T) (*httptest.Server, *automations.Service, string) {
|
||||
t.Helper()
|
||||
auto, err := automations.NewService(automations.Config{}, automations.Deps{Bus: bus.New()})
|
||||
if err != nil {
|
||||
t.Fatalf("new automations service: %v", err)
|
||||
}
|
||||
_, ts, jwt := buildEnv(t, nil, Deps{Automations: auto})
|
||||
return ts, auto, signAny(t, jwt, "acct", auth.RoleOwner)
|
||||
}
|
||||
|
||||
// postReminder 调用提醒创建端点并返回状态码与原始响应体。
|
||||
func postReminder(t *testing.T, ts *httptest.Server, tok string, body any) (int, []byte) {
|
||||
t.Helper()
|
||||
resp, raw := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/automations/reminders", body, tok)
|
||||
return resp.StatusCode, raw
|
||||
}
|
||||
|
||||
// TestAdminCreateReminder_AppearsInServiceList 是 P0-22 的真断言:
|
||||
// POST /api/v1/admin/automations/reminders 之后,该提醒真的出现在
|
||||
// ReminderStore(Service.Reminders / List)里,且时间与文案一致 ——
|
||||
// 而不是只回一个 201。
|
||||
func TestAdminCreateReminder_AppearsInServiceList(t *testing.T) {
|
||||
t.Parallel()
|
||||
ts, auto, tok := reminderTestEnv(t)
|
||||
at := time.Now().Add(2 * time.Hour).Truncate(time.Second)
|
||||
|
||||
status, raw := postReminder(t, ts, tok, map[string]any{"id": "rem-drink", "at": at.Format(time.RFC3339), "message": "提醒主人喝水"})
|
||||
if status != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201, body = %s", status, raw)
|
||||
}
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
ID string `json:"id"`
|
||||
At string `json:"at"`
|
||||
Message string `json:"message"`
|
||||
Kind string `json:"kind"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
t.Fatalf("decode: %v (%s)", err, raw)
|
||||
}
|
||||
if envelope.Data.ID != "rem-drink" || envelope.Data.Kind != "reminder" || envelope.Data.Message != "提醒主人喝水" {
|
||||
t.Fatalf("created reminder = %+v", envelope.Data)
|
||||
}
|
||||
if envelope.Data.At != at.Format(time.RFC3339) {
|
||||
t.Fatalf("at = %q, want %q", envelope.Data.At, at.Format(time.RFC3339))
|
||||
}
|
||||
|
||||
list := auto.Reminders()
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("Reminders() = %+v, want exactly the created one", list)
|
||||
}
|
||||
if list[0].ID != "rem-drink" || list[0].Message != "提醒主人喝水" || !list[0].At.Equal(at) {
|
||||
t.Fatalf("reminder in store = %+v", list[0])
|
||||
}
|
||||
|
||||
// 管理读面(面板的自动化页)也必须看到它,且 kind 为 reminder。
|
||||
resp, raw := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/automations", nil, tok)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("automations list status = %d, body = %s", resp.StatusCode, raw)
|
||||
}
|
||||
var listEnv struct {
|
||||
Data []AdminAutomationRecord `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &listEnv); err != nil {
|
||||
t.Fatalf("decode automations list: %v (%s)", err, raw)
|
||||
}
|
||||
found := false
|
||||
for _, rec := range listEnv.Data {
|
||||
if rec.ID == "rem-drink" && rec.Kind == "reminder" && rec.Name == "提醒主人喝水" {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("reminder not visible in /admin/automations: %+v", listEnv.Data)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminCreateReminder_GeneratesIDWhenOmitted 证明面板不必自己保证
|
||||
// id 唯一:省略 id 时后端生成,且两次创建得到两条不同提醒。
|
||||
func TestAdminCreateReminder_GeneratesIDWhenOmitted(t *testing.T) {
|
||||
t.Parallel()
|
||||
ts, auto, tok := reminderTestEnv(t)
|
||||
at := time.Now().Add(time.Hour).Truncate(time.Second).Format(time.RFC3339)
|
||||
ids := make([]string, 0, 2)
|
||||
for i := 0; i < 2; i++ {
|
||||
status, raw := postReminder(t, ts, tok, map[string]any{"at": at, "message": fmt.Sprintf("第 %d 次", i)})
|
||||
if status != http.StatusCreated {
|
||||
t.Fatalf("status = %d, want 201, body = %s", status, raw)
|
||||
}
|
||||
var envelope struct {
|
||||
Data struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &envelope); err != nil {
|
||||
t.Fatalf("decode: %v (%s)", err, raw)
|
||||
}
|
||||
if !strings.HasPrefix(envelope.Data.ID, "rem-") {
|
||||
t.Fatalf("generated id = %q, want rem- prefix", envelope.Data.ID)
|
||||
}
|
||||
ids = append(ids, envelope.Data.ID)
|
||||
}
|
||||
if ids[0] == ids[1] {
|
||||
t.Fatalf("generated ids collide: %q", ids[0])
|
||||
}
|
||||
if n := len(auto.Reminders()); n != 2 {
|
||||
t.Fatalf("Reminders() = %d, want 2", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAdminCreateReminder_InvalidInputReturns400 逐条钉住非法输入的显式
|
||||
// 400;重复 id 是 409(已有同名提醒),绝不静默覆盖。
|
||||
func TestAdminCreateReminder_InvalidInputReturns400(t *testing.T) {
|
||||
t.Parallel()
|
||||
ts, auto, tok := reminderTestEnv(t)
|
||||
at := time.Now().Add(time.Hour).Truncate(time.Second)
|
||||
cases := []struct {
|
||||
name string
|
||||
body map[string]any
|
||||
want int
|
||||
code string
|
||||
}{
|
||||
{"missing message", map[string]any{"at": at.Format(time.RFC3339)}, http.StatusBadRequest, "bad_request"},
|
||||
{"missing at", map[string]any{"message": "x"}, http.StatusBadRequest, "bad_request"},
|
||||
{"at not rfc3339", map[string]any{"at": "明天早上", "message": "x"}, http.StatusBadRequest, "invalid_at"},
|
||||
{"at unix millis", map[string]any{"at": "1760000000000", "message": "x"}, http.StatusBadRequest, "invalid_at"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
status, raw := postReminder(t, ts, tok, tc.body)
|
||||
if status != tc.want {
|
||||
t.Fatalf("status = %d, want %d, body = %s", status, tc.want, raw)
|
||||
}
|
||||
var envErr errorEnvelope
|
||||
if err := json.Unmarshal(raw, &envErr); err != nil {
|
||||
t.Fatalf("decode error envelope: %v (%s)", err, raw)
|
||||
}
|
||||
if envErr.Error.Code != tc.code {
|
||||
t.Fatalf("error code = %q, want %q (%s)", envErr.Error.Code, tc.code, raw)
|
||||
}
|
||||
})
|
||||
}
|
||||
if n := len(auto.Reminders()); n != 0 {
|
||||
t.Fatalf("reminders after rejected writes = %d, want 0", n)
|
||||
}
|
||||
|
||||
body := map[string]any{"id": "dup", "at": at.Format(time.RFC3339), "message": "第一次"}
|
||||
if status, raw := postReminder(t, ts, tok, body); status != http.StatusCreated {
|
||||
t.Fatalf("first status = %d, body = %s", status, raw)
|
||||
}
|
||||
status, raw := postReminder(t, ts, tok, body)
|
||||
if status != http.StatusConflict {
|
||||
t.Fatalf("duplicate status = %d, want 409, body = %s", status, raw)
|
||||
}
|
||||
var envErr errorEnvelope
|
||||
if err := json.Unmarshal(raw, &envErr); err != nil {
|
||||
t.Fatalf("decode duplicate envelope: %v (%s)", err, raw)
|
||||
}
|
||||
if envErr.Error.Code != "already_exists" {
|
||||
t.Fatalf("duplicate code = %q, want already_exists (%s)", envErr.Error.Code, raw)
|
||||
}
|
||||
if n := len(auto.Reminders()); n != 1 {
|
||||
t.Fatalf("reminders = %d, want 1 (duplicate must not overwrite)", n)
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,7 @@ var ownerOnlyRoutes = []rbacRoute{
|
||||
{"iot/scenarios", http.MethodPost, "/api/v1/admin/iot/scenarios"},
|
||||
{"iot/rules", http.MethodPost, "/api/v1/admin/iot/rules"},
|
||||
{"automations POST", http.MethodPost, "/api/v1/admin/automations"},
|
||||
{"automations/reminders POST", http.MethodPost, "/api/v1/admin/automations/reminders"},
|
||||
{"automations/{id}/run", http.MethodPost, "/api/v1/admin/automations/a1/run"},
|
||||
{"automations/{id} DELETE", http.MethodDelete, "/api/v1/admin/automations/a1"},
|
||||
{"llm POST", http.MethodPost, "/api/v1/admin/llm"},
|
||||
|
||||
@@ -191,6 +191,10 @@ func (s *Server) registerAdminWriteRoutes(mux *http.ServeMux, ownerAuth func(htt
|
||||
mux.Handle("POST /api/v1/admin/iot/scenarios", ownerAuth(http.HandlerFunc(s.adminIoTScenario)))
|
||||
mux.Handle("POST /api/v1/admin/iot/rules", ownerAuth(http.HandlerFunc(s.adminIoTRule)))
|
||||
mux.Handle("POST /api/v1/admin/automations", ownerAuth(http.HandlerFunc(s.adminCreateAutomation)))
|
||||
// 定时提醒(P0-22):cron/event 之外的第三种自动化,到点把
|
||||
// reminder.trigger 发布到进程内 bus。它是真实写入(进 ReminderStore
|
||||
// 的轮询集合),因此与其他自动化写同门同审计。
|
||||
mux.Handle("POST /api/v1/admin/automations/reminders", ownerAuth(http.HandlerFunc(s.adminCreateReminder)))
|
||||
mux.Handle("POST /api/v1/admin/automations/{id}/run", ownerAuth(http.HandlerFunc(s.adminRunAutomation)))
|
||||
mux.Handle("DELETE /api/v1/admin/automations/{id}", ownerAuth(http.HandlerFunc(s.adminDeleteAutomation)))
|
||||
mux.Handle("POST /api/v1/admin/llm", ownerAuth(http.HandlerFunc(s.adminAddLlmProvider)))
|
||||
|
||||
Reference in new issue
Block a user