- 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 已存在、无路由)
297 lines
11 KiB
Go
297 lines
11 KiB
Go
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)
|
|
}
|
|
}
|