Why: Personal project rows and credentials could override system policy and split shared business configuration by actor. Evidence: SELF receipts /tmp/project-settings-backend-full-20261014-973d52c1.json and /tmp/rdm-settings-backend-final-green-20261014-03.json preserve full commands, object snapshots and output. PostgreSQL and current-head CI remain NOT_RUN. Impact: Add scope-aware APIs, a fail-closed optional project configuration authorizer, CAS updates and explicit atomic migration. Preserve legacy rows and private preferences; protected secrets and runtime model routes obey system ownership. Refs: P1/P2/P8, docs/records/2026-10-14-rdm-settings-completion-contract.md
296 lines
8.3 KiB
Go
296 lines
8.3 KiB
Go
package settings
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"sort"
|
|
"strings"
|
|
|
|
core "agent-system-core"
|
|
)
|
|
|
|
const (
|
|
ScopeSharedProjectPrefix = "shared-project:"
|
|
PersonalProjectScope core.SettingScope = "project-personal"
|
|
UserSettingScope core.SettingScope = "user"
|
|
ProjectSettingScope core.SettingScope = "project"
|
|
)
|
|
|
|
func builtinScope(d core.SettingDesc) core.SettingScope {
|
|
if d.Scope == core.SettingScopeSystem || strings.HasPrefix(d.Key, "runtime.") || d.Key == "plugins.dir" || d.Key == "tools.mcp.overrides" {
|
|
return core.SettingScopeSystem
|
|
}
|
|
if strings.HasPrefix(d.Key, "deliverables.") {
|
|
return ProjectSettingScope
|
|
}
|
|
if d.Key == "crossSession.read" {
|
|
return PersonalProjectScope
|
|
}
|
|
return UserSettingScope
|
|
}
|
|
|
|
// Descriptors protects builtin declarations from plugin collisions. Legacy blank
|
|
// plugin scope means a personal user/project preference, never shared authority.
|
|
func (s *Service) Descriptors() []core.SettingDesc {
|
|
out := Schema()
|
|
known := map[string]bool{}
|
|
for _, d := range out {
|
|
known[d.Key] = true
|
|
}
|
|
if s != nil {
|
|
s.schemaMu.RLock()
|
|
defer s.schemaMu.RUnlock()
|
|
for _, d := range s.extraSchema {
|
|
// A plugin leaf must not replace a builtin namespace/object.
|
|
overlaps := false
|
|
for key := range known {
|
|
if strings.HasPrefix(key, d.Key+".") || strings.HasPrefix(d.Key, key+".") {
|
|
overlaps = true
|
|
break
|
|
}
|
|
}
|
|
if !known[d.Key] && !overlaps && validSettingPath(d.Key) {
|
|
if strings.HasPrefix(d.Key, "models.") || strings.HasPrefix(d.Key, "runtime.") || strings.HasPrefix(d.Key, "tools.tavily.") || strings.HasPrefix(d.Key, "tools.mcp.") {
|
|
d.Scope = core.SettingScopeSystem
|
|
}
|
|
out = append(out, d)
|
|
known[d.Key] = true
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Service) declaredDefaults() map[string]any {
|
|
out := map[string]any{}
|
|
for _, d := range s.Descriptors() {
|
|
if d.Default != nil && validateSettingType(d, d.Default) == nil {
|
|
out = core.MergeSettings(out, expandDotted(d.Key, d.Default))
|
|
}
|
|
}
|
|
return core.MergeSettings(out, SystemDefaults())
|
|
}
|
|
|
|
// EffectiveSettings getters use the JSON numeric representation. Code/plugin
|
|
// defaults must have the same float64 form as values read back from the DB.
|
|
func effectiveNumbers(values map[string]any) map[string]any {
|
|
out := make(map[string]any, len(values))
|
|
for key, value := range values {
|
|
switch v := value.(type) {
|
|
case map[string]any:
|
|
out[key] = effectiveNumbers(v)
|
|
case int:
|
|
out[key] = float64(v)
|
|
case int64:
|
|
out[key] = float64(v)
|
|
default:
|
|
out[key] = value
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func scopeAllows(d core.SettingDesc, scope core.SettingScope) bool {
|
|
if d.Scope == "" {
|
|
return scope == UserSettingScope || scope == PersonalProjectScope
|
|
}
|
|
return d.Scope == scope || (scope == PersonalProjectScope && d.Scope == UserSettingScope)
|
|
}
|
|
|
|
func (s *Service) SchemaFor(scope core.SettingScope) []core.SettingDesc {
|
|
out := []core.SettingDesc{}
|
|
for _, d := range s.Descriptors() {
|
|
if scopeAllows(d, scope) {
|
|
out = append(out, d)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ProjectValues contains only known leaves for the given authority scope. Old
|
|
// user/project copies of shared or managed configuration stay in storage but
|
|
// cannot re-enter the effective settings or HTTP responses.
|
|
func (s *Service) ProjectValues(values map[string]any, scope core.SettingScope) map[string]any {
|
|
values = normalizeKeys(values)
|
|
out := map[string]any{}
|
|
for _, d := range s.SchemaFor(scope) {
|
|
if v, ok := valueAtPath(values, d.Key); ok && validateSettingType(d, v) == nil {
|
|
out = core.MergeSettings(out, expandDotted(d.Key, normalizeValue(v)))
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Service) DefaultsFor(scope core.SettingScope) map[string]any {
|
|
out := map[string]any{}
|
|
for _, d := range s.SchemaFor(scope) {
|
|
out = core.MergeSettings(out, expandDotted(d.Key, d.Default))
|
|
}
|
|
return core.MergeSettings(out, s.ProjectValues(SystemDefaults(), scope))
|
|
}
|
|
|
|
type ErrInvalidSetting struct {
|
|
Path string
|
|
Reason string
|
|
}
|
|
|
|
func (e *ErrInvalidSetting) Error() string { return fmt.Sprintf("设置 %s: %s", e.Path, e.Reason) }
|
|
|
|
func validSettingPath(path string) bool {
|
|
if path == "" {
|
|
return false
|
|
}
|
|
root := strings.SplitN(path, ".", 2)[0]
|
|
if root == "_policy" || root == "_revision" {
|
|
return false
|
|
}
|
|
for _, segment := range strings.Split(path, ".") {
|
|
if segment == "" || strings.TrimSpace(segment) != segment || strings.ContainsAny(segment, "/\\\x00") {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// ValidateWrite validates raw structure before normalization, then known leaves
|
|
// and ownership. This prevents dotted/nested ambiguity and empty/scalar parents
|
|
// from silently erasing managed subtrees.
|
|
func (s *Service) ValidateWrite(values map[string]any, scope core.SettingScope) error {
|
|
if values == nil {
|
|
return &ErrInvalidSetting{Reason: "需要设置对象"}
|
|
}
|
|
if err := checkRawSettingPaths(values, ""); err != nil {
|
|
return err
|
|
}
|
|
if err := checkAmbiguousPaths(values, "", map[string]bool{}); err != nil {
|
|
return err
|
|
}
|
|
incoming := prepareWrite(values)
|
|
if scope != core.SettingScopeSystem {
|
|
if err := s.checkManagedWrite(incoming); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
desc := map[string]core.SettingDesc{}
|
|
for _, d := range s.Descriptors() {
|
|
desc[d.Key] = d
|
|
}
|
|
var walk func(map[string]any, string) error
|
|
walk = func(m map[string]any, prefix string) error {
|
|
keys := make([]string, 0, len(m))
|
|
for key := range m {
|
|
keys = append(keys, key)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, key := range keys {
|
|
path := key
|
|
if prefix != "" {
|
|
path = prefix + "." + key
|
|
}
|
|
value := m[key]
|
|
if d, known := desc[path]; known {
|
|
if !scopeAllows(d, scope) {
|
|
return &ErrInvalidSetting{Path: path, Reason: "不属于当前设置范围"}
|
|
}
|
|
if value == nil && (scope == UserSettingScope || scope == PersonalProjectScope) {
|
|
leaf := path[strings.LastIndex(path, ".")+1:]
|
|
if d.Secret || d.Type == core.SettingSecret || isSecretKey(leaf) {
|
|
return &ErrInvalidSetting{Path: path, Reason: "撤销密钥请明确保存空值,不能删除后回退旧凭据"}
|
|
}
|
|
continue
|
|
}
|
|
if err := validateSettingType(d, value); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
child, object := value.(map[string]any)
|
|
parent := false
|
|
for candidate := range desc {
|
|
if strings.HasPrefix(candidate, path+".") {
|
|
parent = true
|
|
break
|
|
}
|
|
}
|
|
if !object || !parent || len(child) == 0 {
|
|
return &ErrInvalidSetting{Path: path, Reason: "未知设置项或无效对象"}
|
|
}
|
|
if err := walk(child, path); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
return walk(normalizeKeys(values), "")
|
|
}
|
|
|
|
func checkAmbiguousPaths(values map[string]any, prefix string, seen map[string]bool) error {
|
|
for key, value := range values {
|
|
path := key
|
|
if prefix != "" {
|
|
path = prefix + "." + key
|
|
}
|
|
if child, ok := value.(map[string]any); ok && len(child) > 0 {
|
|
if err := checkAmbiguousPaths(child, path, seen); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
for previous := range seen {
|
|
if previous == path || strings.HasPrefix(previous, path+".") || strings.HasPrefix(path, previous+".") {
|
|
return &ErrInvalidSetting{Path: path, Reason: "嵌套和点路径配置重复或冲突"}
|
|
}
|
|
}
|
|
seen[path] = true
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func checkRawSettingPaths(values map[string]any, prefix string) error {
|
|
for key, value := range values {
|
|
path := key
|
|
if prefix != "" {
|
|
path = prefix + "." + key
|
|
}
|
|
if !validSettingPath(key) {
|
|
return &ErrInvalidSetting{Path: path, Reason: "路径格式无效"}
|
|
}
|
|
if object, ok := value.(map[string]any); ok {
|
|
if err := checkRawSettingPaths(object, path); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateSettingType(d core.SettingDesc, value any) error {
|
|
valid := false
|
|
switch d.Type {
|
|
case core.SettingBool:
|
|
_, valid = value.(bool)
|
|
case core.SettingString, core.SettingSecret:
|
|
_, valid = value.(string)
|
|
case core.SettingEnum:
|
|
if text, ok := value.(string); ok {
|
|
for _, option := range d.Options {
|
|
if text == option {
|
|
valid = true
|
|
break
|
|
}
|
|
}
|
|
}
|
|
case core.SettingNumber:
|
|
switch number := value.(type) {
|
|
case float64:
|
|
valid = !math.IsNaN(number) && !math.IsInf(number, 0) && number >= 0 && number <= 1e9 && number == math.Trunc(number)
|
|
case int:
|
|
valid = number >= 0 && number <= 1e9
|
|
}
|
|
}
|
|
if !valid {
|
|
return &ErrInvalidSetting{Path: d.Key, Reason: "值类型或选项无效"}
|
|
}
|
|
return nil
|
|
}
|