From 9150e6f90b7ed360467492c6102d971a97c8a59a Mon Sep 17 00:00:00 2001 From: weichen Date: Tue, 13 Oct 2026 03:40:25 +0000 Subject: [PATCH] Add system-managed setting scope contract to SettingDesc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Why: P2 受管设置机制需要 schema 声明写入归属——system 受管项仅管理员可写,user/project 层拒绝写入且生效链忽略存量覆盖。 Evidence: go test ./... 全绿(ManagedPaths/Touches/Strip 含祖先标量覆盖与整棵子树剥离用例)。 Impact: 新增 SettingScope 常量、SettingDesc.Scope 字段与三个纯函数;空 Scope 保持既有行为,向后兼容。 Refs: modules/documents/docs/proposals/2026-10-13-rdm-settings-members-duties-perms-increment.md P2 --- profile.go | 126 ++++++++++++++++++++++++++++++++++++++++- settings_scope_test.go | 85 +++++++++++++++++++++++++++ 2 files changed, 210 insertions(+), 1 deletion(-) create mode 100644 settings_scope_test.go diff --git a/profile.go b/profile.go index 6dfa9e1..5032d1c 100644 --- a/profile.go +++ b/profile.go @@ -1,6 +1,9 @@ package core -import "encoding/json" +import ( + "encoding/json" + "strings" +) // 智能体画像(4.7):核心定制机制,全部数据化、可导入导出。 type AgentProfile struct { @@ -107,6 +110,16 @@ const ( EffectRestart SettingEffect = "restart" // 需重启 ) +// SettingScope 声明设置项的写入归属(提案 2026-10-13-rdm-settings-members-duties-perms-increment P2)。 +// 空串 = 默认:user 与 project 层可写(向后兼容既有 schema)。 +type SettingScope string + +const ( + // SettingScopeSystem 受管项:仅系统管理面(admin)可写;user/project 写入被拒, + // 生效链忽略这两层的存量覆盖值。 + SettingScopeSystem SettingScope = "system" +) + // SettingDesc 描述一个设置项;设置页按此渲染并标注生效时机(schema 驱动)。 type SettingDesc struct { Key string `json:"key"` @@ -118,6 +131,117 @@ type SettingDesc struct { Options []string `json:"options,omitempty"` // enum Effect SettingEffect `json:"effect"` Secret bool `json:"secret,omitempty"` + Scope SettingScope `json:"scope,omitempty"` // 空 = user/project 可写;system = 受管 +} + +// ManagedPaths 返回 schema 中 scope=system 的键路径(点路径形式)。 +func ManagedPaths(descs []SettingDesc) []string { + out := make([]string, 0, len(descs)) + for _, d := range descs { + if d.Scope == SettingScopeSystem { + out = append(out, d.Key) + } + } + return out +} + +// FlattenSettingLeafPaths 列出 values 的全部叶子点路径。子树为标量或数组时该子树 +// 根即叶子。用于受管触碰检测:深合并语义下叶子决定最终覆盖面。 +func FlattenSettingLeafPaths(values map[string]any) []string { + var out []string + var walk func(prefix string, m map[string]any) + walk = func(prefix string, m map[string]any) { + for k, v := range m { + path := k + if prefix != "" { + path = prefix + "." + k + } + if vm, ok := v.(map[string]any); ok && len(vm) > 0 { + walk(path, vm) + continue + } + out = append(out, path) + } + } + walk("", values) + return out +} + +// pathTouchesManaged 判断 path 是否触碰 managed:等于受管路径、位于受管子树内 +// (受管路径是 path 的前缀),或整体覆盖受管子树(path 是受管路径的前缀—— +// 标量覆盖整棵受管 map 同样破坏受管值)。 +func pathTouchesManaged(path string, managed []string) bool { + for _, mp := range managed { + if path == mp || + strings.HasPrefix(path, mp+".") || + strings.HasPrefix(mp, path+".") { + return true + } + } + return false +} + +// TouchesManagedSettings 返回 values 中触碰受管路径的叶子;空切片 = 无触碰。 +// 调用方须传入已展开点路径的嵌套 map(normalize 后)。 +func TouchesManagedSettings(values map[string]any, managed []string) []string { + var hits []string + for _, p := range FlattenSettingLeafPaths(values) { + if pathTouchesManaged(p, managed) { + hits = append(hits, p) + } + } + return hits +} + +// StripManagedSettings 返回删除受管路径后的浅拷贝(不改入参)。受管路径的祖先 +// 若因此只剩空 map 一并删除。入参须为嵌套形式(点路径已展开)。 +func StripManagedSettings(values map[string]any, managed []string) map[string]any { + if len(values) == 0 || len(managed) == 0 { + return values + } + // 叶子命中或祖先整体命中都删;逐叶子重建最简单可靠。 + leaves := FlattenSettingLeafPaths(values) + keep := map[string]any{} + for _, leaf := range leaves { + if pathTouchesManaged(leaf, managed) { + continue + } + setPath(keep, leaf, lookupAny(values, leaf)) + } + return keep +} + +func setPath(m map[string]any, path string, val any) { + parts := strings.Split(path, ".") + cur := m + for i, p := range parts { + if i == len(parts)-1 { + cur[p] = val + return + } + next, ok := cur[p].(map[string]any) + if !ok { + next = map[string]any{} + cur[p] = next + } + cur = next + } +} + +func lookupAny(m map[string]any, path string) any { + parts := strings.Split(path, ".") + var cur any = m + for _, p := range parts { + cm, ok := cur.(map[string]any) + if !ok { + return nil + } + cur, ok = cm[p] + if !ok { + return nil + } + } + return cur } // EffectiveSettings 是覆盖链(system → user → project → conversation)深合并结果。 diff --git a/settings_scope_test.go b/settings_scope_test.go new file mode 100644 index 0000000..9233600 --- /dev/null +++ b/settings_scope_test.go @@ -0,0 +1,85 @@ +package core + +import ( + "reflect" + "sort" + "testing" +) + +func TestManagedPathsFiltersSystemScope(t *testing.T) { + descs := []SettingDesc{ + {Key: "models.chat.model", Scope: SettingScopeSystem}, + {Key: "ui.theme"}, + {Key: "tools.tavily.apiKeys", Scope: SettingScopeSystem}, + } + got := ManagedPaths(descs) + sort.Strings(got) + want := []string{"models.chat.model", "tools.tavily.apiKeys"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("ManagedPaths = %v, want %v", got, want) + } +} + +func TestTouchesManagedSettings(t *testing.T) { + managed := []string{"models.chat.model", "tools.tavily.apiKeys", "telemetry.otlpEndpoint"} + cases := []struct { + name string + in map[string]any + want []string + }{ + {"exact leaf", map[string]any{"models": map[string]any{"chat": map[string]any{"model": "x"}}}, []string{"models.chat.model"}}, + {"sibling untouched", map[string]any{"models": map[string]any{"chat": map[string]any{"baseUrl": "x"}}}, nil}, + {"ancestor scalar overwrite", map[string]any{"models": "oops"}, []string{"models"}}, + {"subtree inside managed", map[string]any{"telemetry": map[string]any{"otlpEndpoint": "http://x"}}, []string{"telemetry.otlpEndpoint"}}, + {"other group untouched", map[string]any{"ui": map[string]any{"theme": "light"}}, nil}, + {"empty map", map[string]any{}, nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := TouchesManagedSettings(tc.in, managed) + sort.Strings(got) + if tc.want == nil && len(got) != 0 { + t.Fatalf("TouchesManagedSettings = %v, want none", got) + } + if tc.want != nil && !reflect.DeepEqual(got, tc.want) { + t.Fatalf("TouchesManagedSettings = %v, want %v", got, tc.want) + } + }) + } +} + +func TestStripManagedSettings(t *testing.T) { + managed := []string{"models.chat.model", "models.chat.apiKey"} + in := map[string]any{ + "models": map[string]any{ + "chat": map[string]any{"model": "x", "baseUrl": "https://b"}, + "lightweight": map[string]any{"model": "y"}, + }, + "ui": map[string]any{"theme": "dark"}, + } + got := StripManagedSettings(in, managed) + want := map[string]any{ + "models": map[string]any{ + "chat": map[string]any{"baseUrl": "https://b"}, + "lightweight": map[string]any{"model": "y"}, + }, + "ui": map[string]any{"theme": "dark"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("StripManagedSettings = %v, want %v", got, want) + } + if in["models"].(map[string]any)["chat"].(map[string]any)["model"] != "x" { + t.Fatal("StripManagedSettings 不应修改入参") + } +} + +func TestStripManagedSettingsWholeSubtree(t *testing.T) { + managed := []string{"models.chat.model", "models.chat.baseUrl", "models.chat.apiKey", "models.chat.fallbacks"} + in := map[string]any{ + "models": map[string]any{"chat": map[string]any{"model": "x", "baseUrl": "b", "apiKey": "k"}}, + } + got := StripManagedSettings(in, managed) + if len(got) != 0 { + t.Fatalf("整棵受管子树应被剥除(含空父),got %v", got) + } +}