Merge branch 'feat/m42-gatewayhygiene-admin-memory-tier-log' into dev
This commit is contained in:
commit
6be76f1665
9 files changed
+831
-13
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user