Merge branch 'feat/m42-gatewayhygiene-admin-memory-tier-log' into dev
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

This commit is contained in:
AskaEth committed 2026-10-26 14:08:57 +08:00
commit 6be76f1665
9 files changed
+831 -13

No files matched your search

+21 -1
View File
@@ -62,7 +62,27 @@ func IsSecretKey(key string) bool {
// to maxSummaryRunes, so a published string can never carry a secret or an
// unbounded blob.
func RedactText(s string) string {
return truncate(redactSecrets(s), maxSummaryRunes)
return RedactTextLimited(s, maxSummaryRunes)
}
// RedactTextLimited 是 RedactText 的**带上限**形态:同一套凭据形状清洗,截断长度
// 由调用方给。它存在的理由是两个读面要的不是同一种东西 ——
//
// - **出图 / 摘要面**(span props、事件正文、工具结果摘要)要的是摘要 ⇒
// `maxSummaryRunes`(80)足够,且保证一屏可读。这些调用方继续用 `RedactText`;
// - **日志面**(`GET /admin/observability/logs` 的一行)要的是**可排障的全文** ⇒
// 80 会把长错误行拦腰截断,等于丢掉诊断信息。
//
// 但它绝不接受「无上限散用」:一条日志可能是 `%v` 出来的 MiB 级对象,原样塞进
// JSON 响应会撑爆面板的帧与响应体。因此上限**必须由调用方显式给出**并写清理由
// (网关侧见 `maxLogMsgRunes`)。
//
// limit <= 0 表示**不截断**(只打码),留给「体积已由自己界定」的调用方。
//
// 🔴 清洗逻辑仍然只有一份(redactSecrets):上限是参数,不是第二条脱敏路径 ——
// 另写一套必然漂移,而漂移的方向总是「其中一面开始原样吐凭据」。
func RedactTextLimited(s string, limit int) string {
return truncate(redactSecrets(s), limit)
}
// RedactEvent returns ev with every string value — including those nested in maps
@@ -0,0 +1,99 @@
package gateway
// 本文件钉住**面板「人工加一条记忆」的留存档位**(M37 遗留的第三条入口,
// 主人 2026-10-26 裁定:「人工动作默认应为 long(人显式加一条记忆 = 长期意图,
// 不该被 7 天后静默清掉)」)。
//
// M37 已在 memory 包内钉过 `Store.RememberManual` 与 `ManualExtractor.Extract`;
// 面板这条路径(`adminAddMemory` → `Store.Add`)当时仍在外面,因此 panel 上最
// 典型的输入(短寒暄 / 一次性祈使 / 时效查询 / 句首时点 / 进行中)会被
// `resolveRetention` 的 N1~N7 规则判成 `temp` 并写 7 天有效期 —— 主人在面板上
// 亲手加的一条记忆会静默消失。
//
// 🔴 断言必须打在**落库值**上(`retention_tier` + `expires_at`),而不是只看响应
// 体:响应体里没有档位字段,只有表里的两列能证明「这条不会被清掉」。
// 判据内容刻意选 N2 情绪的典型句 —— 它是**规则真的会判 temp** 的输入,因此
// 「把 adminAddMemory 的 RetentionTier 摘掉」这条变异必然打红(M37 的教训:
// 空断言会给出假绿)。
import (
"context"
"encoding/json"
"net/http"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
)
// adminAddMemoryContent 是一条**自动抽取路径会被判 temp** 的面板典型输入
// (N2 情绪反例,memory 包的 extract_retention_test.go 钉着同一个判据)。
// 用它做夹具,「人工动作钉 long」这条实现一旦缺失就立刻现形。
const adminAddMemoryContent = "今天好累"
// TestAdminAddMemory_PinsLongRetentionTier 走真实 HTTP 面板入口 + 真实
// memory.Store + 真实 sqlite,断言落库两列同时满足长期层:`retention_tier='long'`
// 且 `expires_at` 为空串(长期层不过期 ⇒ 不会被到期清扫读走)。
func TestAdminAddMemory_PinsLongRetentionTier(t *testing.T) {
t.Parallel()
d, store := newMemoryStoreAndDBForGateway(t)
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{Memories: store})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodPost, ts.URL+"/api/v1/admin/memories",
map[string]any{"personaId": "cyrene", "content": adminAddMemoryContent, "scope": "persona"}, tok)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("add status = %d, want 201 (body=%s)", resp.StatusCode, body)
}
var added struct {
ID string `json:"id"`
}
if err := json.Unmarshal(decodeBody(t, body)["data"], &added); err != nil {
t.Fatalf("decode add: %v (body=%s)", err, body)
}
if added.ID == "" {
t.Fatalf("add returned no id (body=%s)", body)
}
var tier, expires string
if err := d.QueryRowContext(context.Background(),
`SELECT retention_tier, expires_at FROM memory_items WHERE id = ?`,
added.ID).Scan(&tier, &expires); err != nil {
t.Fatalf("read retention columns of %s: %v", added.ID, err)
}
if tier != string(memory.RetentionLong) {
t.Fatalf("面板人工加的记忆 retention_tier = %q, want %q —— 人工动作是长期意图,"+
"判成 temp 会在 7 天后从所有读面静默消失(内容=%q)",
tier, memory.RetentionLong, adminAddMemoryContent)
}
if expires != "" {
t.Fatalf("长期层不该有有效期,expires_at = %q(want 空串):带有效期的行会被"+
"到期清扫物理删除", expires)
}
}
// TestAdminAddMemory_ControlContentStillJudgedTemp 是上一条的**对照**:同一套
// 存储与判定链上,**自动抽取**那一侧(不传档位的 Store.Add)对同一句内容仍然判
// temp。它证明「面板钉 long」是这条入口的**特例**,而不是把整条判定链改宽
// —— M37 复审钉过「自动抽取路径不放宽」,这条用例就是那个口径的回归锚点。
func TestAdminAddMemory_ControlContentStillJudgedTemp(t *testing.T) {
t.Parallel()
store := newMemoryStoreForGateway(t)
item, err := store.Add(context.Background(), memory.MemoryItem{
PersonaID: "cyrene",
Content: adminAddMemoryContent,
Scope: memory.ScopePersona,
})
if err != nil {
t.Fatalf("Store.Add: %v", err)
}
if item.RetentionTier != memory.RetentionTemp {
t.Fatalf("未显式传档位的 Store.Add 对 %q 判成 %q, want %q —— 自动抽取的留存规则"+
"必须照旧生效(判据被放宽了?)",
adminAddMemoryContent, item.RetentionTier, memory.RetentionTemp)
}
if item.ExpiresAt.IsZero() {
t.Fatal("temp 档条目必须带有效期(temp ⇔ 有到期时刻),却拿到零值")
}
}
@@ -30,8 +30,9 @@ const (
)
// skillSource 是 Skill 定义的两种来源之一:内联 YAML 文本,或磁盘上的
// YAML 文件路径。fromFile 决定解析失败时能否回显 YAML 解码器的原始
// 错误(它可能带出被解析内容的前几个字符,见 adminInstallSkillPlugin)。
// YAML 文件路径。fromFile 只影响「失败时给面板的那句话怎么写」(文件来源额外
// 报出路径)——**两个来源都绝不回显解码器原文**(它会带出被解析内容的前几个
// 字符,见 admin_plugins_skill_error.go)。
type skillSource struct {
text string
path string
@@ -60,16 +61,22 @@ func (s *Server) adminInstallSkillPlugin(w http.ResponseWriter, r *http.Request,
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)
// 🔴 日志只留「类别 + 行列」(见 admin_plugins_skill_error.go):YAML 解码器
// 会把出错值的前几个字符嵌进 err.Error(),原样打日志 = 把上传内容写进
// 日志面(M38 #17)。位置足够定位,类别足够分流。
failure := skillParseFailureOf(err)
s.log.Error("skill install parse failed",
append([]any{"source", src.describe()}, failure.logArgs()...)...)
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))
// 内容由服务端从磁盘读出,因此连「类别 + 行号」都只给后端日志,
// 面板只拿到路径 + 同一份安全摘要。路径用 %s 而非 %q:Windows 路径经
// %q 会变成双反斜杠,回显给面板很难读。
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "skill_parse_failed",
"skill 文件「%s」解析失败(%s),详细原因见后端日志", src.path, failure))
return
}
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "skill_parse_failed", "skill YAML 解析失败:%v", err))
writeError(w, apperr.Wrap(apperr.ErrBadRequest, "skill_parse_failed",
"skill YAML 解析失败(%s)", failure))
return
}
if name := strings.TrimSpace(req.Name); name != "" && name != skill.Name() {
@@ -166,6 +173,10 @@ func skillSourceOf(req pluginInstallRequest) (skillSource, error) {
}
}
// errSkillSource 标记「来源本身不可用」(打不开 / 不是普通文件 / 超上限)——
// 与「YAML 内容解析失败」分开归类:前者与内容无关,后者一律只留类别+行列。
var errSkillSource = errors.New("gateway: skill source unusable")
// parseSkill 用 skills.Loader 把来源解析为一个 Skill。文件来源有大小上限,
// 且只接受普通文件(目录/设备文件不是 Skill 定义)。
func (s *Server) parseSkill(src skillSource) (*skills.Skill, error) {
@@ -187,10 +198,10 @@ func (s *Server) parseSkill(src skillSource) (*skills.Skill, error) {
return nil, fmt.Errorf("stat %q: %w", src.path, err)
}
if !info.Mode().IsRegular() {
return nil, fmt.Errorf("%q 不是普通文件", src.path)
return nil, fmt.Errorf("%q 不是普通文件: %w", src.path, errSkillSource)
}
if info.Size() > maxSkillYAMLBytes {
return nil, fmt.Errorf("%q 超过 %d 字节上限", src.path, maxSkillYAMLBytes)
return nil, fmt.Errorf("%q 超过 %d 字节上限: %w", src.path, maxSkillYAMLBytes, errSkillSource)
}
skill, err := loader.Load(io.LimitReader(f, maxSkillYAMLBytes))
if err != nil {
@@ -0,0 +1,147 @@
package gateway
// 本文件是「Skill 安装解析失败」的**安全投影**:失败只以「类别 + 行列」出现,
// 解码器回显的原文既不进日志、也不回显给面板。
//
// 为什么需要它(M38 真泄漏 #17 的形态):yaml.v3 的类型错误会把**出错值的前
// 7 个字符**嵌进错误串 ——
//
// cannot unmarshal !!str `LEAKMAR...` into []skills.yamlStep
//
// 也就是说「把 err.Error() 打进日志」等价于「把面板上传的那段内容写进日志面」。
// 而日志面正是 owner 之外的角色(viewer)也能读的只读监控面,所以这条路的
// 方向必须是**白名单式**:只允许「错误类别」与「行列号」这两类不含内容的字节
// 离开本文件。
//
// 与「Skill 安装」共用的还有一种情况:来源本身不可读(打不开 / 不是普通文件 /
// 超上限)。那类错误里只有路径,但路径已经在日志的 source 字段里,因此同样走
// 本投影、不留原文。
import (
"errors"
"io/fs"
"regexp"
"strconv"
"strings"
"gopkg.in/yaml.v3"
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/skills"
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
)
const (
// skillParseKindSyntax 是 YAML 语法层失败(缩进/引号/流式集未闭合…)。
skillParseKindSyntax = "yaml_syntax"
// skillParseKindType 是结构不匹配(把标量塞进 []step 之类)—— 正是解码器
// 会回显内容片段的那一类。
skillParseKindType = "yaml_type_mismatch"
// skillParseKindInvalid 是「YAML 解得动,但定义违反 Skill 结构约束」
// (步骤依赖缺失/成环、护栏 id 重复、枚举字段非法…)。这类错误里带的是
// 面板上传的步骤名/字段值,因此同样只留类别。
skillParseKindInvalid = "skill_invalid"
// skillParseKindSource 是来源不可用(打不开 / 不是普通文件 / 超大小上限)。
skillParseKindSource = "skill_source"
)
// yamlPositionPattern 从解码器错误串里**只抠两个数字**:行号,以及语法错误有时
// 会给出的列号(yaml.v3 的诊断形如 `line 2: cannot unmarshal ...` /
// `line 3, column 5: ...`)。匹配成功与否只影响这两个整数 —— 原文永远不离开
// 本文件。
var yamlPositionPattern = regexp.MustCompile(`line (\d+)(?:, column (\d+))?`)
// skillParseFailure 是一次解析失败的安全摘要:类别 + 位置,**没有原文**。
// 日志与 400 响应用同一份摘要,因此两面不可能给出不同口径。
type skillParseFailure struct {
// Kind 是上面四个类别之一。
Kind string
// Line / Column 是解码器给出的位置(0 = 该类别不带位置信息)。
Line int
Column int
}
// String 渲染成人读的一行,例如 `yaml_type_mismatch at line 2`。
func (f skillParseFailure) String() string {
var b strings.Builder
b.WriteString(f.Kind)
if f.Line > 0 {
b.WriteString(" at line ")
b.WriteString(strconv.Itoa(f.Line))
if f.Column > 0 {
b.WriteString(", column ")
b.WriteString(strconv.Itoa(f.Column))
}
}
return b.String()
}
// logArgs 返回 slog 键值对:类别必出,位置只在已知时出 —— 未知就整键省略,
// 免得日志里出现 `yaml_line=0` 这种「看起来像位置」的假值。
func (f skillParseFailure) logArgs() []any {
args := []any{"yaml_kind", f.Kind}
if f.Line > 0 {
args = append(args, "yaml_line", f.Line)
}
if f.Column > 0 {
args = append(args, "yaml_column", f.Column)
}
return args
}
// skillParseFailureOf 把 parseSkill 的错误折叠成安全摘要。nil 调用方不传,
// 但仍给出一个合法类别而不是空摘要。
func skillParseFailureOf(err error) skillParseFailure {
if err == nil {
return skillParseFailure{Kind: skillParseKindSyntax}
}
f := skillParseFailure{Kind: skillParseKindOf(err)}
if m := yamlPositionPattern.FindStringSubmatch(err.Error()); m != nil {
f.Line = digitsOr0(m[1])
f.Column = digitsOr0(m[2])
}
return f
}
// skillParseKindOf 给失败分类。判据是**类型与哨兵**,不是错误文字:文字只用来
// 抠行列号,绝不参与「这一条能不能打」的决定。
func skillParseKindOf(err error) string {
var typeErr *yaml.TypeError
if errors.As(err, &typeErr) {
return skillParseKindType
}
var pathErr *fs.PathError
if errors.As(err, &pathErr) || errors.Is(err, fs.ErrNotExist) || errors.Is(err, errSkillSource) {
return skillParseKindSource
}
for _, sentinel := range skillDefinitionErrors() {
if errors.Is(err, sentinel) {
return skillParseKindInvalid
}
}
// 剩下的只可能是解码器自己的语法错误(loader 把 decode 包成
// `loader: decode: yaml: ...`);兜底归到语法类而不是编一个新类别。
return skillParseKindSyntax
}
// skillDefinitionErrors 是「定义结构不合法」的哨兵集合(skills 包与 sdk 的
// 字段校验)。逐个 errors.Is,而不是比字符串,因此不会随措辞漂移。
func skillDefinitionErrors() []error {
return []error{
skills.ErrInvalidSkill,
skills.ErrStepDuplicate,
skills.ErrStepDependsMissing,
skills.ErrStepCycle,
skills.ErrToolConflict,
skills.ErrGuardrailDuplicate,
sdk.ErrInvalidSkillField,
}
}
// digitsOr0 把纯数字串折成整数(正则已保证只含数字;空串/溢出按「未知」处理)。
func digitsOr0(raw string) int {
n, err := strconv.Atoi(raw)
if err != nil || n < 0 {
return 0
}
return n
}
@@ -0,0 +1,177 @@
package gateway
// 本文件钉住 Skill 安装解析失败的**回显面**(M38 真泄漏 #17):
//
// - 后端日志只留「类别 + 行列」,**不含**解码器回显的内容片段;
// - 400 响应同样不含内容片段 —— 内联来源也不例外(这条路上客户端的报文
// 可能与文件来源一样敏感,判据只能是「不许回显」而不是「谁读的文件」)。
//
// 判据为什么会回显内容:yaml.v3 的类型错误把出错值的前 7 个字符嵌进错误串
// (`cannot unmarshal !!str \`SENTINE...\``)。夹具因此用一个**长于 10 字符**的
// 哨兵值 —— 短于 10 的值解码器会整段回显,断言用前 7 个字符就足以抓住两种形态。
//
// 🔴 断言打在**响应体与日志输出**上(不是函数的返回值):这两处才是泄漏面。
import (
"bytes"
"encoding/json"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
capreg "git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
)
// skillLeakSentinel 是塞进非法 YAML 的哨兵;`skillLeakPrefix` 是解码器会回显的
// 前 7 个字符 —— 断言「日志/响应里搜不到它」比断言整串更严格(整串只在短值时
// 才被完整回显)。
const (
skillLeakSentinel = "SENTINEL7SECRET"
skillLeakPrefix = "SENTINE"
)
// syncLogBuffer 是并发安全的日志汇聚点:handler goroutine 写、测试主 goroutine 读。
type syncLogBuffer struct {
mu sync.Mutex
buf bytes.Buffer
}
// Write 实现 io.Writer(slog 的 handler 用它落日志)。
func (b *syncLogBuffer) Write(p []byte) (int, error) {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.Write(p)
}
// String 返回已捕获的日志文本。
func (b *syncLogBuffer) String() string {
b.mu.Lock()
defer b.mu.Unlock()
return b.buf.String()
}
// newSkillInstallLogEnv 构建一个 Skill 安装环境,并把网关 logger 接到可读的
// 缓冲上(安装失败的那条 ERROR 就是断言对象)。返回注册表用于断言「失败不留
// 半安装状态」。
func newSkillInstallLogEnv(t *testing.T) (logs *syncLogBuffer, caps *capreg.Registry, base, tok string) {
t.Helper()
logs = &syncLogBuffer{}
caps = capreg.New()
_, ts, jwt := buildEnv(t, cfgDefault(), Deps{
Log: slog.New(slog.NewTextHandler(logs, nil)),
Registry: caps,
})
return logs, caps, ts.URL, signAny(t, jwt, "acct", auth.RoleOwner)
}
// assertNoLeakInSkillFailure 断言「响应体与日志都没有哨兵、但都留了类别与行号」。
func assertNoLeakInSkillFailure(t *testing.T, raw []byte, logs, wantKind string, wantLine bool) {
t.Helper()
if strings.Contains(string(raw), skillLeakPrefix) {
t.Fatalf("400 响应回显了 YAML 内容片段(%q):%s", skillLeakPrefix, raw)
}
if strings.Contains(logs, skillLeakPrefix) {
t.Fatalf("后端日志回显了 YAML 内容片段(%q):%s", skillLeakPrefix, logs)
}
var envErr errorEnvelope
if err := json.Unmarshal(raw, &envErr); err != nil {
t.Fatalf("decode error envelope: %v (%s)", err, raw)
}
if envErr.Error.Code != "skill_parse_failed" {
t.Fatalf("error code = %q, want skill_parse_failed (%s)", envErr.Error.Code, raw)
}
if !strings.Contains(envErr.Error.Message, wantKind) {
t.Fatalf("响应 message 缺错误类别 %q:%q", wantKind, envErr.Error.Message)
}
if !strings.Contains(logs, "yaml_kind="+wantKind) {
t.Fatalf("日志缺 yaml_kind=%s:%s", wantKind, logs)
}
if !wantLine {
return
}
if !strings.Contains(envErr.Error.Message, "at line 2") {
t.Fatalf("响应 message 缺行号:%q", envErr.Error.Message)
}
if !strings.Contains(logs, "yaml_line=2") {
t.Fatalf("日志缺 yaml_line=2:%s", logs)
}
}
// TestAdminInstallSkill_InlineParseFailureHidesContent 是 #17 的复现面:**内联**
// 来源的类型错误此前被原样打进日志、并原样回显给面板。现在两处都不含哨兵,
// 但都保留类别与行号(排障不能靠猜)。
func TestAdminInstallSkill_InlineParseFailureHidesContent(t *testing.T) {
logs, caps, base, tok := newSkillInstallLogEnv(t)
// 第 2 行把标量塞进 steps(期望 []step)⇒ yaml_type_mismatch,位置 line 2。
leakyYAML := "name: leaky\nsteps: \"" + skillLeakSentinel + "\"\n"
resp, raw := doReq(t, http.MethodPost, base+"/api/v1/admin/plugins/install",
map[string]any{"kind": "skill", "yaml": leakyYAML}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, raw)
}
assertNoLeakInSkillFailure(t, raw, logs.String(), skillParseKindType, true)
if n := len(caps.List(capreg.KindSkill)); n != 0 {
t.Fatalf("失败的安装留下了 %d 个 skill(不许半安装)", n)
}
}
// TestAdminInstallSkill_FileParseFailureHidesContent 是同一泄漏面的**文件**来源:
// 文件由服务端读取,客户端的报文里没有内容,所以这条更容易被忽视 ——
// 但日志面一样是 viewer 可读的监控面,口径必须与内联来源一致。
func TestAdminInstallSkill_FileParseFailureHidesContent(t *testing.T) {
logs, _, base, tok := newSkillInstallLogEnv(t)
path := filepath.Join(t.TempDir(), "leaky.yaml")
leakyYAML := "name: leaky\nsteps: \"" + skillLeakSentinel + "\"\n"
if err := os.WriteFile(path, []byte(leakyYAML), 0o600); err != nil {
t.Fatalf("write leaky skill: %v", err)
}
resp, raw := doReq(t, http.MethodPost, base+"/api/v1/admin/plugins/install",
map[string]any{"kind": "skill", "path": path}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, raw)
}
assertNoLeakInSkillFailure(t, raw, logs.String(), skillParseKindType, true)
// 路径断言走**解码后**的 message:JSON 会把 Windows 路径的反斜杠转义成 `\\`,
// 直接在原始字节里找会永远找不到。
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, path) {
t.Fatalf("文件来源的失败必须报出路径,message = %q", envErr.Error.Message)
}
}
// TestAdminInstallSkill_SyntaxFailureIsCategorised 钉住另一个类别:YAML 语法层
// 失败归 `yaml_syntax`(不是 type_mismatch),且同样不回显内容。
func TestAdminInstallSkill_SyntaxFailureIsCategorised(t *testing.T) {
logs, _, base, tok := newSkillInstallLogEnv(t)
// 未闭合的流式序列 ⇒ 解码器报语法错(`did not find expected ',' or ']'`)。
brokenYAML := "name: leaky\nsteps: [" + skillLeakSentinel + "\n"
resp, raw := doReq(t, http.MethodPost, base+"/api/v1/admin/plugins/install",
map[string]any{"kind": "skill", "yaml": brokenYAML}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, raw)
}
assertNoLeakInSkillFailure(t, raw, logs.String(), skillParseKindSyntax, false)
}
// TestAdminInstallSkill_SourceFailureIsCategorised 钉住第三个类别:来源不可用
// (路径不存在)归 `skill_source`,与「YAML 内容解析失败」分开 —— 运维据此
// 一眼分辨「文件没放对」还是「YAML 写错了」。
func TestAdminInstallSkill_SourceFailureIsCategorised(t *testing.T) {
logs, _, base, tok := newSkillInstallLogEnv(t)
resp, raw := doReq(t, http.MethodPost, base+"/api/v1/admin/plugins/install",
map[string]any{"kind": "skill", "path": filepath.Join(t.TempDir(), "nope.yaml")}, tok)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 (body=%s)", resp.StatusCode, raw)
}
if !strings.Contains(logs.String(), "yaml_kind="+skillParseKindSource) {
t.Fatalf("日志缺 yaml_kind=%s:%s", skillParseKindSource, logs.String())
}
}
@@ -27,6 +27,18 @@ type memoryAddRequest struct {
// 「添加记忆」路径:条目默认作用于 persona,因此行为与
// loop 已在写的「记住 X」路径一致。Scope 可省略(默认
// persona),或为 user|persona|project 之一;非法非空 scope 会被拒绝。
//
// 🔴 档位显式钉 `long`(主人 2026-10-26 裁定:「人工动作默认应为 `long`(人显式
// 加一条记忆 = 长期意图,不该被 7 天后静默清掉)」)。不传档位时 `memory.Store.Add`
// 会走 `resolveRetention` 的留存规则(N1~N7),面板最典型的输入恰恰落在反例里
// —— 短寒暄(「谢谢」)、一次性祈使、时效查询、句首时点、进行中状态都会被判
// `temp` 并写 7 天有效期,于是主人在面板上亲手加的一条记忆会静默消失。
// `resolveRetention` 对**显式档位**的处置是「以调用方为准」,因此这一位是真正的
// 钉死而不是提示。M37 已在包内钉过 `Store.RememberManual` 与
// `ManualExtractor.Extract` 两条入口;本处是同一裁定在**面板**这条入口上的落点。
//
// 只钉这一条入口:`Store.Add` 的默认值仍是「未分型 ⇒ 由规则判定」,自动抽取路径
// 绝不放宽(M37 复审口径)。
func (s *Server) adminAddMemory(w http.ResponseWriter, r *http.Request) {
var req memoryAddRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -59,6 +71,8 @@ func (s *Server) adminAddMemory(w http.ResponseWriter, r *http.Request) {
PersonaID: personaID,
Content: content,
Scope: memory.Scope(rawScope),
// 人工动作 = 长期意图,见上方注释:显式档位优先于留存规则。
RetentionTier: memory.RetentionLong,
})
if err != nil {
s.auditClaims(r, "memory.add", "error")
@@ -9,12 +9,24 @@ import (
apperr "git.yeij.top/AskaEth/Cyrene_v2/internal/errors"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// 日志尾部端点的调参。编译期常量(项目规范 §10)。
const (
defaultLogLimit = 200
maxLogLimit = 1000
// maxLogMsgRunes 是日志页**单行 msg** 的截断上限(M42 r2)。
//
// 🔴 为什么不是 80:`trace.RedactText` 的 80 rune 是**出图摘要**那一档(span
// props / 工具结果摘要要的是「一眼看完的摘要」),而日志页是**排障面** ——
// 把一条长错误行拦腰截断就是丢诊断信息(实测一条 500 rune 的上游报错会被切到
// 80 + 省略号,恰好切掉「谁拒了、为什么」)。
//
// 🔴 为什么也不是无上限:单条日志可能是 `%v` 一个大对象(MiB 级),面板一次
// 要 1000 条 ⇒ 原样透出会把 JSON 响应撑到 GB 量级。4096 rune 足够装下完整的
// 技术栈 / 长错误行 / 一次请求的回显,而 4096 × 1000 条仍只是几 MB 量级。
maxLogMsgRunes = 4096
)
// AdminLogEntry 是 GET /api/v1/admin/observability/logs 中一个条目的
@@ -91,12 +103,26 @@ func (s *Server) adminObservabilityLogs(w http.ResponseWriter, r *http.Request)
}
// adminLogOf 把捕获的日志条目映射为驼峰式管理线上结构。
//
// 🔴 读侧脱敏(M42 #3):`msg` 是日志正文,也是唯一可能夹带凭据/正文的字段
// (本仓有过「把上游报文整段打进日志」的先例),因此出面板前过一遍 trace 包的
// 共享清洗 —— 与写侧同一条路径,**不另写一套**:两套脱敏必然漂移,而漂移的方向
// 总是「其中一面开始原样吐凭据」。
//
// 🔴 上限与出图摘要**故意不同**(M42 r2,塔 2026-10-26 裁定):这里用
// `trace.RedactTextLimited(msg, maxLogMsgRunes)`(4096),而不是 `RedactText`
// 的 80 —— 日志页是排障面,80 会把长错误行拦腰截断(实质性 UX 回归)。出图那一侧
// (span props / 摘要)仍是 80,两边**不该被统一**:一个要摘要,一个要全文。
//
// 排障字段一律保住:`time`(RFC3339)、`level`、`source`(模块来源标签,编译期
// 常量构成,不含内容)原样透出,且 `msg` 的打码不改变条目条数、排序与级别过滤
// —— 面板因此照旧可诊断「谁在什么时候按什么级别报了哪一类错」。
func adminLogOf(e logger.Entry) AdminLogEntry {
return AdminLogEntry{
Time: e.Time.UTC().Format(time.RFC3339),
Level: e.Level,
Source: e.Source,
Msg: e.Msg,
Msg: trace.RedactTextLimited(e.Msg, maxLogMsgRunes),
}
}
@@ -0,0 +1,306 @@
package gateway
// 本文件钉住**可观测面板读侧的脱敏**(M42 #3 = M38 #1 的读侧放大面 / M39 遗留)。
//
// 写侧(stderr 的 JSONL)在 M39 已经有了 `trace.SanitizeRecord` / `trace.RedactText`;
// 但面板这两条读路径当时仍原样搬运:
//
// - 日志页 `GET /api/v1/admin/observability/logs`:`buf.Snapshot` → 原样出图;
// - trace 页 `GET /api/v1/admin/observability/trace`:环形缓冲持有**原始** props,
// 原样出图。
//
// 两条都在这里补上「读侧也走同一个 helper」——**不另写一套脱敏**:一套脱敏两处
// 实现必然漂移,而漂移的方向总是「其中一面开始原样吐凭据」。
//
// 🔴 同时保住**排障字段**:日志的行数/级别/时间戳/来源,trace 的 status/耗时/时间/
// 名字都必须原样透出。脱敏只动「内容面」(msg 正文、props 值),不动结构 ——
// 否则面板就变成不可诊断的空壳(这条是塔的明确要求)。
//
// 每条用例都能被一条变异打红:把读侧的 `trace.RedactText` / `trace.SanitizeRecord`
// 摘掉,哨兵与凭据就会原样出现在响应体里。
import (
"encoding/json"
"io"
"net/http"
"strings"
"testing"
"time"
"unicode/utf8"
"git.yeij.top/AskaEth/Cyrene_v2/internal/auth"
"git.yeij.top/AskaEth/Cyrene_v2/internal/logger"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/trace"
)
// obsLeakKey 是 `sk-` 形状的假凭据:trace 包的形状规则(`\bsk-[A-Za-z0-9_\-]{8,}`)
// 认它,因此它出现在响应体里就一定意味着读侧没过脱敏。
const obsLeakKey = "sk-live0000000000000000ABCD"
// obsLeakPrefix 是断言用的前缀(比整串更严格:打码后不该留下任何一段)。
const obsLeakPrefix = "sk-live0000"
// obsLeakBody 是哨兵正文:它没有凭据形状,只能靠「白名单/正文键」判据拦下 ——
// 因此它抓的是「正文原样出图」,与 obsLeakKey 抓的「凭据原样出图」互不替代。
const obsLeakBody = "SENTINEL_BODY_PAYLOAD_42"
// obsLogServer 构建一个注入了真实 logger 环形缓冲的网关。
func obsLogServer(t *testing.T) (base, tok string) {
t.Helper()
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
ring := logger.NewRingBuffer(16)
log := logger.New(logger.Options{Level: "debug", Format: "text", Out: io.Discard, Ring: ring})
log.Info("boot ok")
// 最新在前:错误放最后,因此它是 Data[0]。
logger.WithSource(log, "gateway/model").Error("upstream rejected " + obsLeakKey + " with 401")
srv.SetLogBuffer(ring)
return ts.URL, signAny(t, jwt, "acct", auth.RoleOwner)
}
// TestAdminObservabilityLogs_RedactsCredentialInMsg 钉住日志页读侧打码:
// 凭据形状被换成 `[redacted]`,而同一条 msg 的非敏感部分、以及 level/source/time
// 这些排障字段原样保留(一行的可诊断性不能被脱敏吃掉)。
func TestAdminObservabilityLogs_RedactsCredentialInMsg(t *testing.T) {
t.Parallel()
base, tok := obsLogServer(t)
resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/observability/logs", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if strings.Contains(string(body), obsLeakPrefix) {
t.Fatalf("日志页回显了凭据(%q):%s", obsLeakPrefix, body)
}
var env logEnvelope
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode: %v (body=%s)", err, body)
}
if len(env.Data) != 2 {
t.Fatalf("日志条数 = %d, want 2(脱敏不许改变条目数)", len(env.Data))
}
got := env.Data[0]
if !strings.Contains(got.Msg, "[redacted]") {
t.Fatalf("凭据位置应换成 [redacted],msg = %q", got.Msg)
}
// 排障字段与非敏感正文必须保住。
if got.Level != "error" || got.Source != "gateway/model" {
t.Fatalf("level/source 被改动:%+v", got)
}
if _, err := time.Parse(time.RFC3339, got.Time); err != nil {
t.Fatalf("time 不是 RFC3339:%q (%v)", got.Time, err)
}
if !strings.Contains(got.Msg, "upstream rejected") || !strings.Contains(got.Msg, "with 401") {
t.Fatalf("msg 的非敏感部分被脱敏吃掉:%q", got.Msg)
}
if env.Data[1].Msg != "boot ok" || env.Data[1].Level != "info" {
t.Fatalf("无关条目被改动:%+v", env.Data[1])
}
}
// obsTraceServer 构建一个网关并把一条**含正文与凭据**的 span 送进 trace 环形
// 缓冲(模拟生产:ObserveSpan 收的是 tracer 的原始记录)。
func obsTraceServer(t *testing.T) (base, tok string) {
t.Helper()
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
started := time.Date(2026, 10, 26, 3, 4, 5, 0, time.UTC)
srv.ObserveSpan(trace.SpanRecord{
ID: "span-42",
Name: "tool.execution",
Kind: trace.SpanKindTool,
Status: trace.SpanStatusOK,
StartedAt: started,
EndedAt: started.Add(25 * time.Millisecond),
Duration: 25 * time.Millisecond,
Props: map[string]any{
// 白名单键:保留,但值里的凭据必须打码。
"tool": "web_search",
"args_summary": "query=天气 " + obsLeakKey,
// 表外正文键:折成 omitted 里的 Digest(留指纹、去正文)。
"messages": obsLeakBody,
"result": obsLeakBody,
},
Events: []trace.Event{{
Time: started.Add(time.Millisecond),
Type: "tool.done",
Props: map[string]any{"text": obsLeakBody, "ok": true},
}},
})
return ts.URL, signAny(t, jwt, "acct", auth.RoleOwner)
}
// TestAdminObservabilityTrace_RedactsPropsAndEvents 钉住 trace 页读侧投影:
// props 与事件里的正文/凭据都出不来,而 span 的 status/时间/耗时/名字逐字保留。
//
// ⚠️ 这条用例同时是「有意改变 admin 响应内容」的证据(见 adminSpanOf 注释):
// props 从原始值变成白名单投影 + omitted 摘要,客户端不得依赖未声明的 props。
func TestAdminObservabilityTrace_RedactsPropsAndEvents(t *testing.T) {
t.Parallel()
base, tok := obsTraceServer(t)
resp, body := doReq(t, http.MethodGet, base+"/api/v1/admin/observability/trace", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
if strings.Contains(string(body), obsLeakBody) {
t.Fatalf("trace 页回显了正文哨兵(%q):%s", obsLeakBody, body)
}
if strings.Contains(string(body), obsLeakPrefix) {
t.Fatalf("trace 页回显了凭据(%q):%s", obsLeakPrefix, body)
}
var env struct {
Data []AdminTraceSpan `json:"data"`
}
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode: %v (body=%s)", err, body)
}
if len(env.Data) != 1 {
t.Fatalf("span 条数 = %d, want 1", len(env.Data))
}
sp := env.Data[0]
// 排障字段:status / 时间 / 耗时 / 名字 / kind 一个都不许动。
if sp.ID != "span-42" || sp.Name != "tool.execution" || sp.Kind != "tool_call" ||
sp.Status != "ok" || sp.DurationNs != int64(25*time.Millisecond) {
t.Fatalf("span 的排障字段被改动:%+v", sp)
}
if sp.StartedAt != "2026-10-26T03:04:05Z" || sp.EndedAt != "2026-10-26T03:04:05Z" {
t.Fatalf("时间字段被改动:startedAt=%q endedAt=%q", sp.StartedAt, sp.EndedAt)
}
// 白名单键保留(面板靠它分组),但值里的凭据已打码。
if tool, _ := sp.Props["tool"].(string); tool != "web_search" {
t.Fatalf("白名单 props.tool = %v, want web_search(脱敏不许把排障键也丢掉)", sp.Props["tool"])
}
summary, _ := sp.Props["args_summary"].(string)
if !strings.Contains(summary, "[redacted]") || strings.Contains(summary, obsLeakPrefix) {
t.Fatalf("白名单键的值没打码:args_summary = %q", summary)
}
// 表外正文键折成 omitted 里的 Digest:留键名(可判断「这次带了什么」),不留正文。
omitted, ok := sp.Props["omitted"].(map[string]any)
if !ok {
t.Fatalf("表外正文键应折成 props.omitted,实际 props = %+v", sp.Props)
}
for _, k := range []string{"messages", "result"} {
if _, present := omitted[k]; !present {
t.Fatalf("omitted 缺键 %q:%+v", k, omitted)
}
}
// 事件正文键折成 Digest,非正文键(ok)保留。
if len(sp.Events) != 1 {
t.Fatalf("events = %+v, want 1 条", sp.Events)
}
if _, leaked := sp.Events[0].Props["text"].(string); leaked {
t.Fatalf("事件正文键仍是字符串(未折成 Digest):%+v", sp.Events[0].Props)
}
if ok, _ := sp.Events[0].Props["ok"].(bool); !ok {
t.Fatalf("事件非正文键 ok 应原样保留:%+v", sp.Events[0].Props)
}
}
// obsLogTailMarker 是长日志行**末尾**的哨兵:按 80 rune 截断必然把它切掉,按
// 4096(`maxLogMsgRunes`)必然留下它 —— 它就是「这一行有没有被拦腰截断」的判据。
const obsLogTailMarker = "TAIL_OF_LONG_LOG_LINE"
// longLogMsg 造一条 ~500 rune 的日志行:凭据放在**行首**(所以 80 rune 截断也能
// 打码成功,红的是「截断」这一条而不是「没打码」),末尾带哨兵 —— 一旦被切到 80,
// 哨兵必然消失,判据因此指向问题本身。
// 「排障上下文一行 」是 8 rune,×60 = 480 rune ⇒ 远超 80 的摘要上限。
func longLogMsg() string {
return "upstream rejected " + obsLeakKey + " " +
strings.Repeat("排障上下文一行 ", 60) + obsLogTailMarker
}
// TestAdminObservabilityLogs_LongLineKeepsFullText 钉住日志页的**上限与出图不同**
// (M42 r2,塔 2026-10-26 裁定):日志页是排障面,一条 500 rune 的错误行必须完整
// 保留(凭据仍然打码),不能被 `maxSummaryRunes`(80)拦腰截断。
//
// 变异:把 `adminLogOf` 改回 `trace.RedactText`(80)⇒ 本用例必红(末尾哨兵消失、
// rune 数掉到 81)。
func TestAdminObservabilityLogs_LongLineKeepsFullText(t *testing.T) {
t.Parallel()
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
ring := logger.NewRingBuffer(8)
log := logger.New(logger.Options{Level: "debug", Format: "text", Out: io.Discard, Ring: ring})
msg := longLogMsg()
if utf8.RuneCountInString(msg) <= maxLogMsgRunes/8 {
t.Fatalf("夹具只有 %d rune,不足以证明「不被 80 截断」", utf8.RuneCountInString(msg))
}
log.Error(msg)
srv.SetLogBuffer(ring)
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/observability/logs", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var env logEnvelope
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode: %v (body=%s)", err, body)
}
if len(env.Data) != 1 {
t.Fatalf("日志条数 = %d, want 1", len(env.Data))
}
got := env.Data[0].Msg
if strings.Contains(got, obsLeakPrefix) {
t.Fatalf("长行里的凭据没打码(%q):%q", obsLeakPrefix, got)
}
if !strings.Contains(got, "[redacted]") {
t.Fatalf("长行里的凭据位置应换成 [redacted]:%q", got)
}
if !strings.Contains(got, obsLogTailMarker) {
t.Fatalf("日志页把 %d rune 的行截断了(末尾哨兵 %q 消失),拿到 %d rune:…%s",
utf8.RuneCountInString(msg), obsLogTailMarker, utf8.RuneCountInString(got),
string([]rune(got)[max(0, utf8.RuneCountInString(got)-20):]))
}
// 长度只允许差「凭据 → [redacted]」那一处替换,不许多掉任何东西。
wantLen := utf8.RuneCountInString(msg) - utf8.RuneCountInString(obsLeakKey) +
utf8.RuneCountInString("[redacted]")
if n := utf8.RuneCountInString(got); n != wantLen {
t.Fatalf("日志行 rune 数 = %d, want %d(只允许打码带来的差额,不许截断)", n, wantLen)
}
}
// TestAdminObservabilityTrace_PropsStayAtSummaryBound 是上一条的**对偶**:日志页
// 的上限调大到 4096 之后,出图这一侧(span props)不许被顺手统一 —— 那里要的是
// 摘要(`maxSummaryRunes` = 80),不是一个更长的全文。
//
// 变异:把 `maxSummaryRunes` 提到 4096(或让出图侧改用日志上限)⇒ 本用例必红。
func TestAdminObservabilityTrace_PropsStayAtSummaryBound(t *testing.T) {
t.Parallel()
srv, ts, jwt := buildEnv(t, cfgDefault(), Deps{})
started := time.Date(2026, 10, 26, 3, 4, 5, 0, time.UTC)
long := strings.Repeat("摘", 200)
srv.ObserveSpan(trace.SpanRecord{
ID: "span-bound",
Name: "tool.execution",
Kind: trace.SpanKindTool,
Status: trace.SpanStatusOK,
StartedAt: started,
EndedAt: started.Add(time.Millisecond),
Duration: time.Millisecond,
Props: map[string]any{"tool": "web_search", "args_summary": long},
})
tok := signAny(t, jwt, "acct", auth.RoleOwner)
resp, body := doReq(t, http.MethodGet, ts.URL+"/api/v1/admin/observability/trace", nil, tok)
if resp.StatusCode != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", resp.StatusCode, body)
}
var env struct {
Data []AdminTraceSpan `json:"data"`
}
if err := json.Unmarshal(body, &env); err != nil {
t.Fatalf("decode: %v (body=%s)", err, body)
}
if len(env.Data) != 1 {
t.Fatalf("span 条数 = %d, want 1", len(env.Data))
}
summary, _ := env.Data[0].Props["args_summary"].(string)
// 80 是 trace 包的摘要上限(`maxSummaryRunes`,未导出 ⇒ 这里按契约写常量)。
const summaryBound = 80
if n := utf8.RuneCountInString(summary); n != summaryBound+1 {
t.Fatalf("args_summary rune 数 = %d, want %d(%d rune 摘要 + 省略号)—— "+
"出图侧不许跟日志页一起放宽到 %d", n, summaryBound+1, summaryBound, maxLogMsgRunes)
}
if !strings.HasSuffix(summary, "…") {
t.Fatalf("截断结果应以省略号收尾:%q", summary)
}
}
@@ -161,7 +161,25 @@ func (m MultiTraceWriter) WriteSpan(rec trace.SpanRecord) error {
}
// adminSpanOf 把 trace.SpanRecord 映射为驼峰式管理线上结构。
//
// 🔴 读侧脱敏(M42 #3,M38 #1 的读侧放大面):入参先过 `trace.SanitizeRecord`
// —— 与写侧(stderr 的 JSONL,trace.JSONRecorder)**同一份投影**,不另写一套。
// 环形缓冲(ObserveSpan → traceBuf)持的是**原始**记录,因此 REST 尾部
// (GET /api/v1/admin/observability/trace)与 observability_trace 推送帧这两条
// 出图路径都必须在这里收口,否则面板就是「凭据与正文的最后一面」。
//
// ⚠️ 这是**有意的行为变更**(admin 面有意改变响应内容):`props` 从原始值变成
// 脱敏投影 —— 白名单(trace 包 safePropKeys)内的键逐值打码+截断,表外键折成
// `omitted` 里的 Digest(留键名、留体积指纹、去正文),事件里的正文键同样折成
// Digest。输出**字段名与帧形状不变**(id/parentId/name/kind/status/startedAt/
// endedAt/durationNs/props/events 一个不改),客户端因此**不得依赖未声明的
// props**(即不得假定某个表外键一定带原文)—— 契约口径本就是「props 只含已
// 声明键」。
//
// 排障字段一律保住:status / 时间 / durationNs / name / kind 都不是内容面,
// 原样透出;`omitted` 的存在还让「这次调用有多大输出」仍可回答。
func adminSpanOf(rec trace.SpanRecord) AdminTraceSpan {
rec = trace.SanitizeRecord(rec)
sp := AdminTraceSpan{
ID: rec.ID,
ParentID: rec.ParentID,