Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d2caa43d6 | ||
|
|
5298103e1f | ||
|
|
f4ac28b18e | ||
|
|
7bf8efdb2f | ||
|
|
f965c5877f |
@@ -0,0 +1,90 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"agent-system-backend/internal/store"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func captureIdentityCLI(t *testing.T, run func() int) (int, string) {
|
||||||
|
t.Helper()
|
||||||
|
file, err := os.CreateTemp(t.TempDir(), "identity-output")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
stdout, stderr := os.Stdout, os.Stderr
|
||||||
|
os.Stdout, os.Stderr = file, file
|
||||||
|
defer func() { os.Stdout, os.Stderr = stdout, stderr }()
|
||||||
|
code := run()
|
||||||
|
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
raw, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return code, string(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentityCommandPGSchemaErrorKeepsRecoveryInstructionWithoutDSN(t *testing.T) {
|
||||||
|
const dsn = "postgres://fixture:dsn-private-fixture@127.0.0.1/db"
|
||||||
|
message := identityCommandError(dsn, errors.New("schema v3 与本二进制 v4 不一致;请运行 agent-system migrate,或先启动一次服务"))
|
||||||
|
if !strings.Contains(message, "agent-system migrate") || strings.Contains(message, "dsn-private-fixture") || strings.Contains(message, "postgres://") {
|
||||||
|
t.Fatalf("schema recovery instruction or secret boundary lost: %q", message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentityCommandsUseEnvironmentDSNWithoutPrintingCredentials(t *testing.T) {
|
||||||
|
const privateFixture = "dsn-private-fixture"
|
||||||
|
t.Setenv("AGENT_SYSTEM_DSN", "postgres://fixture:"+privateFixture+"@%invalid/db")
|
||||||
|
for _, sub := range []string{"user", "apikey"} {
|
||||||
|
data := t.TempDir()
|
||||||
|
local, err := store.OpenSQLite(filepath.Join(data, "agent-system.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := local.EnsureUser("alice", "Alice fixture"); err != nil {
|
||||||
|
local.Close()
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
local.Close()
|
||||||
|
code, out := captureIdentityCLI(t, func() int {
|
||||||
|
if sub == "user" {
|
||||||
|
return runUser([]string{"list", "--data", data})
|
||||||
|
}
|
||||||
|
return runAPIKey([]string{"list", "--owner", "alice", "--data", data})
|
||||||
|
})
|
||||||
|
if code != 1 {
|
||||||
|
t.Fatalf("%s ignored configured PG binding and fell back to SQLite: code=%d", sub, code)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, privateFixture) || strings.Contains(out, "postgres://") {
|
||||||
|
t.Fatal("identity diagnostic disclosed DSN credentials")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentityCommandDSNSourcePrecedence(t *testing.T) {
|
||||||
|
t.Setenv("AGENT_SYSTEM_DSN", "environment-fixture")
|
||||||
|
if got, err := resolveAccountDSN("deprecated-flag-fixture", ""); err != nil || got != "environment-fixture" {
|
||||||
|
t.Fatalf("configured environment lost to deprecated flag: %q %v", got, err)
|
||||||
|
}
|
||||||
|
path := filepath.Join(t.TempDir(), "dsn")
|
||||||
|
if err := os.WriteFile(path, []byte("protected-file-fixture\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, err := resolveAccountDSN("deprecated-flag-fixture", path); err != nil || got != "protected-file-fixture" {
|
||||||
|
t.Fatalf("protected DSN file lost precedence: %q %v", got, err)
|
||||||
|
}
|
||||||
|
t.Setenv("AGENT_SYSTEM_DSN", "")
|
||||||
|
if got, err := resolveAccountDSN("deprecated-flag-fixture", ""); err != nil || got != "deprecated-flag-fixture" {
|
||||||
|
t.Fatalf("deprecated flag compatibility lost: %q %v", got, err)
|
||||||
|
}
|
||||||
|
if got, err := resolveAccountDSN("", ""); err != nil || got != "" {
|
||||||
|
t.Fatalf("unset sources did not choose embedded storage: %q %v", got, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
-11
@@ -195,7 +195,7 @@ func runAPIKey(args []string) int {
|
|||||||
sub := args[0]
|
sub := args[0]
|
||||||
fs := flag.NewFlagSet("apikey "+sub, flag.ExitOnError)
|
fs := flag.NewFlagSet("apikey "+sub, flag.ExitOnError)
|
||||||
data := fs.String("data", "./data", "数据目录")
|
data := fs.String("data", "./data", "数据目录")
|
||||||
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式档)")
|
dsn := fs.String("dsn", "", "PostgreSQL DSN(已弃用;优先使用 --dsn-file 或 AGENT_SYSTEM_DSN)")
|
||||||
dsnFile := fs.String("dsn-file", "", "0600权限的PostgreSQL DSN文件")
|
dsnFile := fs.String("dsn-file", "", "0600权限的PostgreSQL DSN文件")
|
||||||
owner := fs.String("owner", "", "租户名(owner)")
|
owner := fs.String("owner", "", "租户名(owner)")
|
||||||
name := fs.String("name", "default", "密钥名称")
|
name := fs.String("name", "default", "密钥名称")
|
||||||
@@ -225,7 +225,7 @@ func runAPIKey(args []string) int {
|
|||||||
defer delivery.abort()
|
defer delivery.abort()
|
||||||
key, err := app.CreateAPIKey(*dsn, *data, *owner, *name)
|
key, err := app.CreateAPIKey(*dsn, *data, *owner, *name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "apikey create:", err)
|
fmt.Fprintln(os.Stderr, "apikey create:", identityCommandError(*dsn, err))
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
// 明文仅此一次输出;库中只存 SHA-256 摘要。
|
// 明文仅此一次输出;库中只存 SHA-256 摘要。
|
||||||
@@ -237,7 +237,7 @@ func runAPIKey(args []string) int {
|
|||||||
case "list":
|
case "list":
|
||||||
keys, err := app.ListAPIKeys(*dsn, *data, *owner)
|
keys, err := app.ListAPIKeys(*dsn, *data, *owner)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "apikey list:", err)
|
fmt.Fprintln(os.Stderr, "apikey list:", identityCommandError(*dsn, err))
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
out, _ := json.MarshalIndent(keys, "", " ")
|
out, _ := json.MarshalIndent(keys, "", " ")
|
||||||
@@ -246,7 +246,7 @@ func runAPIKey(args []string) int {
|
|||||||
case "revoke":
|
case "revoke":
|
||||||
ok, err := app.RevokeAPIKey(*dsn, *data, *owner, *name)
|
ok, err := app.RevokeAPIKey(*dsn, *data, *owner, *name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "apikey revoke:", err)
|
fmt.Fprintln(os.Stderr, "apikey revoke:", identityCommandError(*dsn, err))
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -261,6 +261,20 @@ func runAPIKey(args []string) int {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PostgreSQL driver errors can contain connection strings. Account CLI errors
|
||||||
|
// never echo a configured DSN, regardless of which protected input supplied it.
|
||||||
|
func identityCommandError(dsn string, err error) string {
|
||||||
|
if dsn != "" {
|
||||||
|
message := err.Error()
|
||||||
|
if strings.HasPrefix(message, "schema 版本不可读") ||
|
||||||
|
(strings.HasPrefix(message, "schema v") && strings.Contains(message, "请运行 agent-system migrate")) {
|
||||||
|
return "账户存储 schema 与本二进制不一致;请运行 agent-system migrate,或先启动一次服务"
|
||||||
|
}
|
||||||
|
return "身份存储操作失败;PostgreSQL 连接详情已隐藏,请核对受保护的连接配置"
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
// deliverCredential 凭据单次交付(KTD14):--out 时写 0600 JSON 文件,stdout 只报
|
// deliverCredential 凭据单次交付(KTD14):--out 时写 0600 JSON 文件,stdout 只报
|
||||||
// 属主/用途/文件路径不含明文;缺省沿用 stdout 一次输出。文件含 expiresAt 便于交接。
|
// 属主/用途/文件路径不含明文;缺省沿用 stdout 一次输出。文件含 expiresAt 便于交接。
|
||||||
func deliverCredential(outPath, kind, owner, plaintext string, expiresAt any) error {
|
func deliverCredential(outPath, kind, owner, plaintext string, expiresAt any) error {
|
||||||
@@ -371,7 +385,7 @@ func runUser(args []string) int {
|
|||||||
sub := args[0]
|
sub := args[0]
|
||||||
fs := flag.NewFlagSet("user "+sub, flag.ExitOnError)
|
fs := flag.NewFlagSet("user "+sub, flag.ExitOnError)
|
||||||
data := fs.String("data", "./data", "数据目录")
|
data := fs.String("data", "./data", "数据目录")
|
||||||
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式档;已弃用,优先 AGENT_SYSTEM_DSN 或 --dsn-file)")
|
dsn := fs.String("dsn", "", "PostgreSQL DSN(已弃用;优先使用 --dsn-file 或 AGENT_SYSTEM_DSN)")
|
||||||
dsnFile := fs.String("dsn-file", "", "0600 权限的 DSN 文件")
|
dsnFile := fs.String("dsn-file", "", "0600 权限的 DSN 文件")
|
||||||
owner := fs.String("owner", "", "用户名(owner)")
|
owner := fs.String("owner", "", "用户名(owner)")
|
||||||
name := fs.String("name", "", "显示名")
|
name := fs.String("name", "", "显示名")
|
||||||
@@ -435,7 +449,7 @@ func runUser(args []string) int {
|
|||||||
}
|
}
|
||||||
pw, opID, err := app.CreateUserAccount(*dsn, *data, *owner, *name, *password, isAdmin, *openExisting, *generatePassword)
|
pw, opID, err := app.CreateUserAccount(*dsn, *data, *owner, *name, *password, isAdmin, *openExisting, *generatePassword)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "user create:", err)
|
fmt.Fprintln(os.Stderr, "user create:", identityCommandError(*dsn, err))
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
if err := delivery.deliver("login-key", *owner, pw, nil); err != nil {
|
if err := delivery.deliver("login-key", *owner, pw, nil); err != nil {
|
||||||
@@ -458,7 +472,7 @@ func runUser(args []string) int {
|
|||||||
}
|
}
|
||||||
pw, err := app.ResetUserPassword(*dsn, *data, *owner, *password, *openExisting)
|
pw, err := app.ResetUserPassword(*dsn, *data, *owner, *password, *openExisting)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "user reset-password:", err)
|
fmt.Fprintln(os.Stderr, "user reset-password:", identityCommandError(*dsn, err))
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
if err := delivery.deliver("password", *owner, pw, nil); err != nil {
|
if err := delivery.deliver("password", *owner, pw, nil); err != nil {
|
||||||
@@ -472,7 +486,7 @@ func runUser(args []string) int {
|
|||||||
case "list":
|
case "list":
|
||||||
users, err := app.ListUserAccounts(*dsn, *data, *openExisting)
|
users, err := app.ListUserAccounts(*dsn, *data, *openExisting)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "user list:", err)
|
fmt.Fprintln(os.Stderr, "user list:", identityCommandError(*dsn, err))
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
out, _ := json.MarshalIndent(users, "", " ")
|
out, _ := json.MarshalIndent(users, "", " ")
|
||||||
@@ -489,7 +503,7 @@ func runUser(args []string) int {
|
|||||||
}
|
}
|
||||||
plain, err := app.IssueUserLoginKey(*dsn, *data, *owner, *purpose, *openExisting, *revokeAll, time.Duration(*ttlMin)*time.Minute)
|
plain, err := app.IssueUserLoginKey(*dsn, *data, *owner, *purpose, *openExisting, *revokeAll, time.Duration(*ttlMin)*time.Minute)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "user issue-login-key:", err)
|
fmt.Fprintln(os.Stderr, "user issue-login-key:", identityCommandError(*dsn, err))
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
if err := delivery.deliver("login-key", *owner, plain, time.Now().Add(time.Duration(*ttlMin)*time.Minute).UTC()); err != nil {
|
if err := delivery.deliver("login-key", *owner, plain, time.Now().Add(time.Duration(*ttlMin)*time.Minute).UTC()); err != nil {
|
||||||
@@ -506,7 +520,7 @@ func runUser(args []string) int {
|
|||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
if err := app.RevokeUserLoginKey(*dsn, *data, *keyID, *openExisting); err != nil {
|
if err := app.RevokeUserLoginKey(*dsn, *data, *keyID, *openExisting); err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "user revoke-login-key:", err)
|
fmt.Fprintln(os.Stderr, "user revoke-login-key:", identityCommandError(*dsn, err))
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
fmt.Println("revoked")
|
fmt.Println("revoked")
|
||||||
@@ -518,7 +532,7 @@ func runUser(args []string) int {
|
|||||||
}
|
}
|
||||||
plain, err := app.BootstrapAdmin(*dsn, *data, *owner, *openExisting)
|
plain, err := app.BootstrapAdmin(*dsn, *data, *owner, *openExisting)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "user bootstrap-admin:", err)
|
fmt.Fprintln(os.Stderr, "user bootstrap-admin:", identityCommandError(*dsn, err))
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
if err := delivery.deliver("bootstrap-admin", *owner, plain, time.Now().Add(30*time.Minute).UTC()); err != nil {
|
if err := delivery.deliver("bootstrap-admin", *owner, plain, time.Now().Add(30*time.Minute).UTC()); err != nil {
|
||||||
|
|||||||
Vendored
+1
-1
Submodule deps/agent-system-core updated: 5773e74086...38fe861bca
+7
-13
@@ -292,17 +292,7 @@ func Run(opt Options) error {
|
|||||||
if err := setSvc.SnapshotAndMigrateSecrets(); err != nil {
|
if err := setSvc.SnapshotAndMigrateSecrets(); err != nil {
|
||||||
return fmt.Errorf("migrate secrets: %w", err)
|
return fmt.Errorf("migrate secrets: %w", err)
|
||||||
}
|
}
|
||||||
runtime.SecretLookup = func(owner, service string) (string, bool) {
|
runtime.SecretLookup = setSvc.LookupSecret
|
||||||
if owner == "" {
|
|
||||||
owner = store.OwnerDefault
|
|
||||||
}
|
|
||||||
if v, ok := st.ActiveSecret(owner, service); ok {
|
|
||||||
return v, true
|
|
||||||
}
|
|
||||||
// P2 受管:密钥统一迁入 secrets 表后,系统层受管密钥挂在系统行
|
|
||||||
//(owner=settings.OwnerSystemRow);租户未单独配置时回退系统密钥。
|
|
||||||
return st.ActiveSecret(settings.OwnerSystemRow, service)
|
|
||||||
}
|
|
||||||
rag.SecretLookup = func(service, fallback string) string {
|
rag.SecretLookup = func(service, fallback string) string {
|
||||||
return runtime.LookupSecret(store.OwnerDefault, service, fallback)
|
return runtime.LookupSecret(store.OwnerDefault, service, fallback)
|
||||||
}
|
}
|
||||||
@@ -312,7 +302,7 @@ func Run(opt Options) error {
|
|||||||
if err := tools.RegisterLazySearchTools(reg, func() agenttools.SearchProvider {
|
if err := tools.RegisterLazySearchTools(reg, func() agenttools.SearchProvider {
|
||||||
effNow := setSvc.Effective(store.OwnerDefault, "")
|
effNow := setSvc.Effective(store.OwnerDefault, "")
|
||||||
tavilyNow := effNow.GetString("tools.tavily.apiKeys", "")
|
tavilyNow := effNow.GetString("tools.tavily.apiKeys", "")
|
||||||
if active, ok := st.ActiveSecret(store.OwnerDefault, "tools.tavily"); ok {
|
if active, ok := setSvc.LookupSecret(store.OwnerDefault, "tools.tavily"); ok {
|
||||||
tavilyNow = active
|
tavilyNow = active
|
||||||
}
|
}
|
||||||
keys := splitKeyPool(tavilyNow)
|
keys := splitKeyPool(tavilyNow)
|
||||||
@@ -381,7 +371,7 @@ func Run(opt Options) error {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
tavilyNow := effNow.GetString("tools.tavily.apiKeys", "")
|
tavilyNow := effNow.GetString("tools.tavily.apiKeys", "")
|
||||||
if active, ok := st.ActiveSecret(store.OwnerDefault, "tools.tavily"); ok {
|
if active, ok := setSvc.LookupSecret(store.OwnerDefault, "tools.tavily"); ok {
|
||||||
tavilyNow = active
|
tavilyNow = active
|
||||||
}
|
}
|
||||||
if keys := splitKeyPool(tavilyNow); len(keys) > 0 {
|
if keys := splitKeyPool(tavilyNow); len(keys) > 0 {
|
||||||
@@ -658,6 +648,10 @@ func Run(opt Options) error {
|
|||||||
// EXT-8b:资源级适配器注入读面(未注册=server 回落 V1,行为不变)。
|
// EXT-8b:资源级适配器注入读面(未注册=server 回落 V1,行为不变)。
|
||||||
if authzSlot.hasV2() {
|
if authzSlot.hasV2() {
|
||||||
srv.SetResourceAuthorizerV2(authzSlot.AuthorizeResource)
|
srv.SetResourceAuthorizerV2(authzSlot.AuthorizeResource)
|
||||||
|
mgr.SetArtifactResourceAllow(func(actor string, artifact core.Artifact) bool {
|
||||||
|
ok, err := authzSlot.AuthorizeResource(extension.ResourceAuthzQuery{Actor: actor, Action: "model-context", ResourceType: "artifact", ResourceID: artifact.ID, Owner: artifact.Owner, ProjectID: artifact.ProjectID})
|
||||||
|
return err == nil && ok
|
||||||
|
})
|
||||||
}
|
}
|
||||||
// EXT-9:宿主可关闭上传自动索引(发布决定移交下游命令层)。
|
// EXT-9:宿主可关闭上传自动索引(发布决定移交下游命令层)。
|
||||||
srv.DisableUploadIndexing = opt.DisableUploadIndexing
|
srv.DisableUploadIndexing = opt.DisableUploadIndexing
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ type projectLifecycleSlot struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
holder string
|
holder string
|
||||||
provider *extension.ProjectLifecycle
|
provider *extension.ProjectLifecycle
|
||||||
|
settings extension.ProjectSettingsAuthorizer
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r compiledRegistry) RegisterProjectLifecycle(extension.ProjectLifecycle) error {
|
func (r compiledRegistry) RegisterProjectLifecycle(extension.ProjectLifecycle) error {
|
||||||
@@ -53,6 +54,7 @@ func (slot *projectLifecycleSlot) wire(srv *server.Server, caps hostcaps) {
|
|||||||
srv.SetProjectLifecycle(*slot.provider, func(owner string) extension.Host {
|
srv.SetProjectLifecycle(*slot.provider, func(owner string) extension.Host {
|
||||||
return inprocHost{caps: caps, owner: owner, actor: owner, pluginID: holder}
|
return inprocHost{caps: caps, owner: owner, actor: owner, pluginID: holder}
|
||||||
})
|
})
|
||||||
|
srv.SetProjectSettingsAuthorizer(slot.settings)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (slot *projectLifecycleSlot) active() bool {
|
func (slot *projectLifecycleSlot) active() bool {
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"agent-system-backend/pkg/extension"
|
||||||
|
"errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (r compiledRegistry) RegisterProjectSettingsAuthorizer(extension.ProjectSettingsAuthorizer) error {
|
||||||
|
return errors.New("extension: declarative plugins cannot register project settings authorization")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r pluginRegistry) RegisterProjectSettingsAuthorizer(fn extension.ProjectSettingsAuthorizer) error {
|
||||||
|
if r.caps.projects == nil || r.pluginID == "" || fn == nil {
|
||||||
|
return errors.New("extension: project settings authorization unavailable")
|
||||||
|
}
|
||||||
|
slot := r.caps.projects
|
||||||
|
slot.mu.Lock()
|
||||||
|
defer slot.mu.Unlock()
|
||||||
|
if slot.settings != nil {
|
||||||
|
return errors.New("extension: project settings authorization already registered")
|
||||||
|
}
|
||||||
|
if slot.provider == nil || slot.holder != r.pluginID {
|
||||||
|
return errors.New("extension: project settings authorizer requires the registered project provider")
|
||||||
|
}
|
||||||
|
slot.settings = fn
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r recordRegistry) RegisterProjectSettingsAuthorizer(fn extension.ProjectSettingsAuthorizer) error {
|
||||||
|
optional, ok := r.Registry.(extension.ProjectSettingsRegistry)
|
||||||
|
if !ok {
|
||||||
|
return errors.New("extension: project settings authorization unavailable")
|
||||||
|
}
|
||||||
|
return optional.RegisterProjectSettingsAuthorizer(fn)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"agent-system-backend/pkg/extension"
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProjectSettingsOptionalRegistrationEnforcesProviderOwnership(t *testing.T) {
|
||||||
|
fn := func(context.Context, string, string, bool) error { return nil }
|
||||||
|
slot := &projectLifecycleSlot{}
|
||||||
|
reg := pluginRegistry{pluginID: "rdm", caps: hostcaps{projects: slot}}
|
||||||
|
if err := reg.RegisterProjectSettingsAuthorizer(fn); err == nil {
|
||||||
|
t.Fatal("settings authorizer accepted without project provider")
|
||||||
|
}
|
||||||
|
if err := reg.RegisterProjectLifecycle(completeLifecycle()); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := (pluginRegistry{pluginID: "another", caps: hostcaps{projects: slot}}).RegisterProjectSettingsAuthorizer(fn); err == nil {
|
||||||
|
t.Fatal("another provider took settings authority")
|
||||||
|
}
|
||||||
|
optional, ok := any(recordRegistry{Registry: reg}).(interface {
|
||||||
|
RegisterProjectSettingsAuthorizer(func(context.Context, string, string, bool) error) error
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("optional callback is not compatible with old-pin anonymous assertion")
|
||||||
|
}
|
||||||
|
if err := optional.RegisterProjectSettingsAuthorizer(fn); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := reg.RegisterProjectSettingsAuthorizer(fn); err == nil {
|
||||||
|
t.Fatal("duplicate settings authorizer accepted")
|
||||||
|
}
|
||||||
|
if err := (compiledRegistry{}).RegisterProjectSettingsAuthorizer(fn); err == nil {
|
||||||
|
t.Fatal("declarative plugin took configuration authority")
|
||||||
|
}
|
||||||
|
var _ extension.ProjectSettingsRegistry = recordRegistry{}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
package profiles
|
package profiles
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"reflect"
|
"reflect"
|
||||||
|
|
||||||
"agent-system-backend/internal/store"
|
"agent-system-backend/internal/store"
|
||||||
@@ -50,11 +51,20 @@ func (s *Service) EnsureDefault() error {
|
|||||||
return s.st.SaveProfile(DefaultProfile())
|
return s.st.SaveProfile(DefaultProfile())
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) List(owner string) ([]core.AgentProfile, error) { return s.st.ListProfiles(owner) }
|
func (s *Service) List(owner string) ([]core.AgentProfile, error) {
|
||||||
|
profiles, err := s.st.ListProfiles(owner)
|
||||||
|
for i := range profiles {
|
||||||
|
profiles[i].Model = core.ModelRoute{}
|
||||||
|
}
|
||||||
|
return profiles, err
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) Get(owner, id string) (core.AgentProfile, error) {
|
func (s *Service) Get(owner, id string) (core.AgentProfile, error) {
|
||||||
p, err := s.st.GetProfile(owner, id)
|
p, err := s.st.GetProfile(owner, id)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
// Preserve old rows for migration, but never advertise obsolete personal
|
||||||
|
// model overrides as available configuration or pass them to a real run.
|
||||||
|
p.Model = core.ModelRoute{}
|
||||||
return p, nil
|
return p, nil
|
||||||
}
|
}
|
||||||
// 空或未知画像回落默认(T0 行为基线)。
|
// 空或未知画像回落默认(T0 行为基线)。
|
||||||
@@ -65,6 +75,9 @@ func (s *Service) Save(owner string, p core.AgentProfile) error {
|
|||||||
if p.ID == DefaultProfileID {
|
if p.ID == DefaultProfileID {
|
||||||
return errReadOnly("profiles: builtin profile is read-only; duplicate and modify")
|
return errReadOnly("profiles: builtin profile is read-only; duplicate and modify")
|
||||||
}
|
}
|
||||||
|
if p.Model != (core.ModelRoute{}) {
|
||||||
|
return errors.New("模型路由由系统管理员配置;个人画像不能覆盖模型")
|
||||||
|
}
|
||||||
if p.Tools.Allow == nil {
|
if p.Tools.Allow == nil {
|
||||||
p.Tools.Allow = []string{"*"}
|
p.Tools.Allow = []string{"*"}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package profiles
|
||||||
|
|
||||||
|
import (
|
||||||
|
"agent-system-backend/internal/store"
|
||||||
|
core "agent-system-core"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSaveRejectsPersonalModelRoute(t *testing.T) {
|
||||||
|
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "profiles.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { st.Close() })
|
||||||
|
s := New(st)
|
||||||
|
for _, route := range []core.ModelRoute{{Chat: "personal"}, {Lightweight: "personal"}, {Embedding: "personal"}, {Rerank: "personal"}} {
|
||||||
|
if err := s.Save("alice", core.AgentProfile{ID: "personal", Model: route}); err == nil {
|
||||||
|
t.Fatal("system managed route accepted in personal profile")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.Save("alice", core.AgentProfile{ID: "legal", Name: "Personal assistant"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLegacyProfileRoutesRemainStoredButAreNotProjected(t *testing.T) {
|
||||||
|
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "legacy.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { st.Close() })
|
||||||
|
legacy := core.AgentProfile{ID: "old", Owner: "alice", Name: "Legacy", Model: core.ModelRoute{Chat: "old-model"}}
|
||||||
|
if err := st.SaveProfile(legacy); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := New(st)
|
||||||
|
p, err := s.Get("alice", "old")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if p.Model != (core.ModelRoute{}) {
|
||||||
|
t.Fatal("obsolete model-route projection claims personal override remains available")
|
||||||
|
}
|
||||||
|
list, err := s.List("alice")
|
||||||
|
if err != nil || len(list) != 1 || list[0].Model != (core.ModelRoute{}) {
|
||||||
|
t.Fatal("obsolete route exposed in profile list")
|
||||||
|
}
|
||||||
|
raw, err := st.GetProfile("alice", "old")
|
||||||
|
if err != nil || raw.Model.Chat != "old-model" {
|
||||||
|
t.Fatal("legacy recovery route was deleted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package runmgr
|
package runmgr
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -26,6 +28,11 @@ func StageWorkspaceInputs(root, owner, conversation string, files []WorkspaceFil
|
|||||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
// Only remove a copy whose protected provenance still matches its content.
|
||||||
|
// Unknown legacy files and user-modified originals are never deleted by name.
|
||||||
|
if err := clearGeneratedTemplate(dir); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
var rels []string
|
var rels []string
|
||||||
used := map[string]bool{}
|
used := map[string]bool{}
|
||||||
for _, file := range files {
|
for _, file := range files {
|
||||||
@@ -33,6 +40,9 @@ func StageWorkspaceInputs(root, owner, conversation string, files []WorkspaceFil
|
|||||||
if !ok || len(file.Content) == 0 {
|
if !ok || len(file.Content) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if generatedTemplateName(name) {
|
||||||
|
name = "uploaded-" + name
|
||||||
|
}
|
||||||
name = uniqueInputName(name, used)
|
name = uniqueInputName(name, used)
|
||||||
if err := os.WriteFile(filepath.Join(dir, name), file.Content, 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(dir, name), file.Content, 0o644); err != nil {
|
||||||
return rels, err
|
return rels, err
|
||||||
@@ -47,14 +57,64 @@ func StageWorkspaceInputs(root, owner, conversation string, files []WorkspaceFil
|
|||||||
ext = filepath.Ext(base)
|
ext = filepath.Ext(base)
|
||||||
}
|
}
|
||||||
name := "_default-template" + ext
|
name := "_default-template" + ext
|
||||||
|
existing := map[string]bool{}
|
||||||
|
entries, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
return rels, err
|
||||||
|
}
|
||||||
|
for _, entry := range entries {
|
||||||
|
existing[entry.Name()] = true
|
||||||
|
}
|
||||||
|
name = uniqueInputName(name, existing)
|
||||||
if err := os.WriteFile(filepath.Join(dir, name), template, 0o644); err != nil {
|
if err := os.WriteFile(filepath.Join(dir, name), template, 0o644); err != nil {
|
||||||
return rels, err
|
return rels, err
|
||||||
}
|
}
|
||||||
|
manifest, _ := json.Marshal(templateProvenance{Name: name, Hash: fmt.Sprintf("%x", sha256.Sum256(template))})
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, templateProvenanceFile), manifest, 0600); err != nil {
|
||||||
|
return rels, err
|
||||||
|
}
|
||||||
rels = append(rels, "inputs/"+name)
|
rels = append(rels, "inputs/"+name)
|
||||||
}
|
}
|
||||||
return rels, nil
|
return rels, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const templateProvenanceFile = ".default-template-origin.json"
|
||||||
|
|
||||||
|
type templateProvenance struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Hash string `json:"hash"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func generatedTemplateName(name string) bool {
|
||||||
|
return name == "_default-template" || strings.HasPrefix(name, "_default-template.") || strings.HasPrefix(name, "_default-template-")
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearGeneratedTemplate(dir string) error {
|
||||||
|
path := filepath.Join(dir, templateProvenanceFile)
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if os.IsNotExist(err) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var provenance templateProvenance
|
||||||
|
if json.Unmarshal(raw, &provenance) == nil {
|
||||||
|
if name, valid := safeInputName(provenance.Name); valid && name == provenance.Name && generatedTemplateName(name) {
|
||||||
|
body, err := os.ReadFile(filepath.Join(dir, name))
|
||||||
|
if err != nil && !os.IsNotExist(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err == nil && fmt.Sprintf("%x", sha256.Sum256(body)) == provenance.Hash {
|
||||||
|
if err := os.Remove(filepath.Join(dir, name)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return os.Remove(path)
|
||||||
|
}
|
||||||
|
|
||||||
// PersistInputTexts 把附件解析出的纯文本写成 inputs/<原名>.md,与原件并列。
|
// PersistInputTexts 把附件解析出的纯文本写成 inputs/<原名>.md,与原件并列。
|
||||||
// 后续轮次的上下文里原件只剩占位时,模型仍能读到可检索的原文文本——
|
// 后续轮次的上下文里原件只剩占位时,模型仍能读到可检索的原文文本——
|
||||||
// 「根据纪要制定方案」不再只能依赖上一轮产物(实测后续轮 0 次读原件)。
|
// 「根据纪要制定方案」不再只能依赖上一轮产物(实测后续轮 0 次读原件)。
|
||||||
@@ -97,10 +157,17 @@ func ListWorkspaceInputs(root, owner, conversation string, staged []string) []st
|
|||||||
return staged
|
return staged
|
||||||
}
|
}
|
||||||
var out []string
|
var out []string
|
||||||
|
current := map[string]bool{}
|
||||||
|
for _, path := range staged {
|
||||||
|
current[path] = true
|
||||||
|
}
|
||||||
for _, e := range ents {
|
for _, e := range ents {
|
||||||
if e.IsDir() || strings.HasPrefix(e.Name(), ".") {
|
if e.IsDir() || strings.HasPrefix(e.Name(), ".") {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if generatedTemplateName(e.Name()) && !current["inputs/"+e.Name()] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
out = append(out, "inputs/"+e.Name())
|
out = append(out, "inputs/"+e.Name())
|
||||||
}
|
}
|
||||||
if len(out) == 0 {
|
if len(out) == 0 {
|
||||||
@@ -113,8 +180,8 @@ func (m *Manager) stageInputs(conv core.Conversation, eff core.EffectiveSettings
|
|||||||
var templateName string
|
var templateName string
|
||||||
var template []byte
|
var template []byte
|
||||||
if id := strings.TrimSpace(eff.GetString("deliverables.templateArtifactId", "")); id != "" && m.st != nil {
|
if id := strings.TrimSpace(eff.GetString("deliverables.templateArtifactId", "")); id != "" && m.st != nil {
|
||||||
if art, err := m.st.GetArtifact(conv.Owner, id); err == nil {
|
if art, err := m.st.GetArtifactByID(id); err == nil && art.ProjectID == conv.ProjectID && m.templateAllowed(conv.Owner, art) {
|
||||||
if body, err := m.st.ArtifactContent(conv.Owner, id); err == nil && len(body) > 0 {
|
if body, err := m.st.ArtifactContentByID(id); err == nil && len(body) > 0 && m.templateAllowed(conv.Owner, art) {
|
||||||
templateName, template = art.Name, body
|
templateName, template = art.Name, body
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -122,6 +189,13 @@ func (m *Manager) stageInputs(conv core.Conversation, eff core.EffectiveSettings
|
|||||||
return StageWorkspaceInputs(m.workspaceRoot, conv.Owner, conv.ID, files, templateName, template)
|
return StageWorkspaceInputs(m.workspaceRoot, conv.Owner, conv.ID, files, templateName, template)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) templateAllowed(owner string, artifact core.Artifact) bool {
|
||||||
|
if m.artifactResourceAllow != nil {
|
||||||
|
return m.artifactResourceAllow(owner, artifact)
|
||||||
|
}
|
||||||
|
return artifact.Owner == owner
|
||||||
|
}
|
||||||
|
|
||||||
func safeInputName(name string) (string, bool) {
|
func safeInputName(name string) (string, bool) {
|
||||||
base := filepath.Base(strings.TrimSpace(name))
|
base := filepath.Base(strings.TrimSpace(name))
|
||||||
if base == "" || base == "." || base == ".." || strings.HasPrefix(base, ".") {
|
if base == "" || base == "." || base == ".." || strings.HasPrefix(base, ".") {
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ type Manager struct {
|
|||||||
// projectID);nil = 未注册(现状:仅本人两路,不触碰共享通道)。
|
// projectID);nil = 未注册(现状:仅本人两路,不触碰共享通道)。
|
||||||
sharedKnowledgeAllow func(owner string, projectID core.ID) bool
|
sharedKnowledgeAllow func(owner string, projectID core.ID) bool
|
||||||
knowledgeResourceAllow func(owner string, doc core.KnowledgeDoc) bool
|
knowledgeResourceAllow func(owner string, doc core.KnowledgeDoc) bool
|
||||||
|
artifactResourceAllow func(owner string, artifact core.Artifact) bool
|
||||||
knowledgeProjectScope func(context.Context, string, core.ID) (core.ID, error)
|
knowledgeProjectScope func(context.Context, string, core.ID) (core.ID, error)
|
||||||
projectWorkAuthorization func(context.Context, string, core.ID) (bool, error)
|
projectWorkAuthorization func(context.Context, string, core.ID) (bool, error)
|
||||||
// commands EXT-14:命令即工具的投影与分发源(nil = 未接线,命令不进工具清单)。
|
// commands EXT-14:命令即工具的投影与分发源(nil = 未接线,命令不进工具清单)。
|
||||||
@@ -212,6 +213,12 @@ func (m *Manager) SetKnowledgeResourceAllow(fn func(string, core.KnowledgeDoc) b
|
|||||||
m.knowledgeResourceAllow = fn
|
m.knowledgeResourceAllow = fn
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetArtifactResourceAllow makes the registered resource policy authoritative
|
||||||
|
// when a project default template enters model context, including owner matches.
|
||||||
|
func (m *Manager) SetArtifactResourceAllow(fn func(string, core.Artifact) bool) {
|
||||||
|
m.artifactResourceAllow = fn
|
||||||
|
}
|
||||||
|
|
||||||
// SetProjectWorkAuthorization is the live domain guard for starting runs and
|
// SetProjectWorkAuthorization is the live domain guard for starting runs and
|
||||||
// executing tools. Nil preserves the ordinary unregistered host behavior.
|
// executing tools. Nil preserves the ordinary unregistered host behavior.
|
||||||
func (m *Manager) SetProjectWorkAuthorization(fn func(context.Context, string, core.ID) (bool, error)) {
|
func (m *Manager) SetProjectWorkAuthorization(fn func(context.Context, string, core.ID) (bool, error)) {
|
||||||
@@ -1377,7 +1384,16 @@ func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Messa
|
|||||||
m.mu.Unlock()
|
m.mu.Unlock()
|
||||||
|
|
||||||
runner := selectConversationRunner(m, eff, launch)
|
runner := selectConversationRunner(m, eff, launch)
|
||||||
inputPaths, _ := m.stageInputs(conv, eff, inputs)
|
inputPaths, stageErr := m.stageInputs(conv, eff, inputs)
|
||||||
|
if stageErr != nil {
|
||||||
|
cancel()
|
||||||
|
m.mu.Lock()
|
||||||
|
delete(m.active, run.ID)
|
||||||
|
m.mu.Unlock()
|
||||||
|
m.releaseConversation(conv.ID)
|
||||||
|
_ = m.st.UpdateRun(&core.Run{ID: run.ID, Status: core.RunFailed, Error: &core.ErrorPart{Code: "run.input_staging_failed", Message: "运行输入准备失败"}})
|
||||||
|
return run, fmt.Errorf("stage run inputs: %w", stageErr)
|
||||||
|
}
|
||||||
PersistInputTexts(m.workspaceRoot, conv.Owner, conv.ID, attachments)
|
PersistInputTexts(m.workspaceRoot, conv.Owner, conv.ID, attachments)
|
||||||
inputPaths = ListWorkspaceInputs(m.workspaceRoot, conv.Owner, conv.ID, inputPaths)
|
inputPaths = ListWorkspaceInputs(m.workspaceRoot, conv.Owner, conv.ID, inputPaths)
|
||||||
rc := &runCtx{mgr: m, run: run, prof: prof, eff: eff, ctx: ctx, h: h, userMessage: userMessage, attachments: attachments, inputPaths: inputPaths, launch: launch, files: filestate.New(), permissionMode: conversationMode(m, run.Owner, run.ConversationID)}
|
rc := &runCtx{mgr: m, run: run, prof: prof, eff: eff, ctx: ctx, h: h, userMessage: userMessage, attachments: attachments, inputPaths: inputPaths, launch: launch, files: filestate.New(), permissionMode: conversationMode(m, run.Owner, run.ConversationID)}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package runmgr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"agent-system-backend/internal/store"
|
||||||
|
core "agent-system-core"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProjectTemplateCannotReadAnotherProjectsArtifact(t *testing.T) {
|
||||||
|
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "templates.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { st.Close() })
|
||||||
|
p, _ := st.CreateProject("alice", "Current")
|
||||||
|
other, _ := st.CreateProject("alice", "Other")
|
||||||
|
artifact := core.Artifact{Owner: "alice", ProjectID: other.ID, Name: "private-template.docx"}
|
||||||
|
if err := st.CreateArtifact(&artifact, []byte("other project private template")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m := &Manager{st: st, workspaceRoot: t.TempDir()}
|
||||||
|
paths, err := m.stageInputs(core.Conversation{ID: "conversation", Owner: "alice", ProjectID: p.ID}, core.EffectiveSettings{"deliverables": map[string]any{"templateArtifactId": artifact.ID}}, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(paths) != 0 {
|
||||||
|
t.Fatal("template settings read another project's artifact")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSharedTemplateUsesLiveResourceAuthorityAndClearsRevokedCopy(t *testing.T) {
|
||||||
|
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "templates.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { st.Close() })
|
||||||
|
p, _ := st.CreateProject("alice", "Shared")
|
||||||
|
artifact := core.Artifact{Owner: "alice", ProjectID: p.ID, Name: "template.docx"}
|
||||||
|
if err := st.CreateArtifact(&artifact, []byte("shared template")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m := &Manager{st: st, workspaceRoot: t.TempDir()}
|
||||||
|
allowed := true
|
||||||
|
m.SetArtifactResourceAllow(func(actor string, a core.Artifact) bool { return allowed && actor == "bob" && a.ID == artifact.ID })
|
||||||
|
conv := core.Conversation{ID: "bob-private", Owner: "bob", ProjectID: p.ID}
|
||||||
|
eff := core.EffectiveSettings{"deliverables": map[string]any{"templateArtifactId": artifact.ID}}
|
||||||
|
paths, err := m.stageInputs(conv, eff, []WorkspaceFile{{Name: "original.txt", Content: []byte("private original")}})
|
||||||
|
if err != nil || len(paths) != 2 {
|
||||||
|
t.Fatalf("authorized template: %v %v", paths, err)
|
||||||
|
}
|
||||||
|
allowed = false
|
||||||
|
paths, err = m.stageInputs(conv, eff, nil)
|
||||||
|
if err != nil || len(paths) != 0 {
|
||||||
|
t.Fatalf("revoked template: %v %v", paths, err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(m.workspaceRoot, "bob", conv.ID, "inputs", "_default-template.docx")); !os.IsNotExist(err) {
|
||||||
|
t.Fatal("revoked cached template still available")
|
||||||
|
}
|
||||||
|
if body, err := os.ReadFile(filepath.Join(m.workspaceRoot, "bob", conv.ID, "inputs", "original.txt")); err != nil || string(body) != "private original" {
|
||||||
|
t.Fatal("revocation removed private uploaded original")
|
||||||
|
}
|
||||||
|
// Matching ownership also goes through the registered resource policy.
|
||||||
|
conv.Owner = "alice"
|
||||||
|
if paths, err := m.stageInputs(conv, eff, nil); err != nil || len(paths) != 0 {
|
||||||
|
t.Fatal("owner bypassed live template resource policy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTemplateStagingPreservesUntrackedLegacyOriginal(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
dir := filepath.Join(root, "alice", "conversation", "inputs")
|
||||||
|
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
legacy := filepath.Join(dir, "_default-template.docx")
|
||||||
|
if err := os.WriteFile(legacy, []byte("private original with legacy reserved name"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
paths, err := StageWorkspaceInputs(root, "alice", "conversation", nil, "new-template.docx", []byte("authorized generated template"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if body, err := os.ReadFile(legacy); err != nil || string(body) != "private original with legacy reserved name" {
|
||||||
|
t.Fatal("unknown legacy file deleted or overwritten")
|
||||||
|
}
|
||||||
|
if len(paths) != 1 || paths[0] == "inputs/_default-template.docx" {
|
||||||
|
t.Fatal("new generated file reused unknown original path")
|
||||||
|
}
|
||||||
|
if _, err := StageWorkspaceInputs(root, "alice", "conversation", nil, "", nil); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if body, err := os.ReadFile(legacy); err != nil || string(body) != "private original with legacy reserved name" {
|
||||||
|
t.Fatal("revocation removed unknown original")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package runtime
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agent-system-backend/internal/settings"
|
||||||
|
"agent-system-backend/internal/store"
|
||||||
|
core "agent-system-core"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestManagedModelsIgnorePersistedProfileRoutes(t *testing.T) {
|
||||||
|
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "models.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { st.Close() })
|
||||||
|
svc := settings.NewService(st)
|
||||||
|
if err := svc.MergeSystem(map[string]any{"models": map[string]any{
|
||||||
|
"chat": map[string]any{"model": "system-chat", "baseUrl": "https://system.example", "apiKey": "system-key"},
|
||||||
|
"lightweight": map[string]any{"model": "system-lite", "baseUrl": "https://lite.example", "apiKey": "lite-key"},
|
||||||
|
}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
prof := core.AgentProfile{Owner: "alice", Model: core.ModelRoute{Chat: "old-personal-chat", Lightweight: "old-personal-lite"}}
|
||||||
|
eff := svc.Effective("alice", "project")
|
||||||
|
if got := ResolveChatModel(prof, eff); got.Model != "system-chat" || got.Key != "system-key" {
|
||||||
|
t.Fatalf("personal chat route still effective: %+v", got)
|
||||||
|
}
|
||||||
|
if got := ResolveLightweightModel(prof, eff); got.Model != "system-lite" || got.Key != "lite-key" {
|
||||||
|
t.Fatalf("personal lightweight route still effective: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,8 @@ import (
|
|||||||
core "agent-system-core"
|
core "agent-system-core"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SecretLookup 由组合根注入。返回该服务当前生效的明文;没有独立密钥时 ok 为 false,调用方用设置里的旧字段。
|
// SecretLookup is wired by the composition root. Protected services resolve only
|
||||||
|
// system authority; ok=true with an empty value means explicit revocation.
|
||||||
var SecretLookup func(owner, service string) (string, bool)
|
var SecretLookup func(owner, service string) (string, bool)
|
||||||
|
|
||||||
// LookupSecret 给组合根以外的调用方使用。owner 为空时用 local。
|
// LookupSecret 给组合根以外的调用方使用。owner 为空时用 local。
|
||||||
@@ -21,15 +22,15 @@ func overlayKey(owner, service, fallback string) string {
|
|||||||
if owner == "" {
|
if owner == "" {
|
||||||
owner = "local"
|
owner = "local"
|
||||||
}
|
}
|
||||||
if v, ok := SecretLookup(owner, service); ok && strings.TrimSpace(v) != "" {
|
if v, ok := SecretLookup(owner, service); ok {
|
||||||
return v
|
return v
|
||||||
}
|
}
|
||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
// 模型路由解析(4.7 / 4.9 覆盖链):设置层 defaults → system → user → project,
|
// Real Service.Effective snapshots carry the trusted managed-model policy and
|
||||||
// 画像 ModelRoute 为会话内最高层覆盖(kind 为空回落设置层主模型)。
|
// ignore legacy profile routes. Unmanaged standalone runtime snapshots retain
|
||||||
// 端点与密钥按 kind 独立配置,轻量档空则回落主档。
|
// their own profile semantics. Lightweight model/endpoint falls back to chat.
|
||||||
|
|
||||||
// ContextInjection 是一条条件注入,由 runmgr 从存储读出,组装时再转换成 ctxasm 的形状。
|
// ContextInjection 是一条条件注入,由 runmgr 从存储读出,组装时再转换成 ctxasm 的形状。
|
||||||
type ContextInjection struct {
|
type ContextInjection struct {
|
||||||
@@ -50,10 +51,10 @@ type ResolvedModel struct {
|
|||||||
Key string
|
Key string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveChatModel 解析主对话模型(画像 route > 设置层)。
|
// ResolveChatModel resolves system-managed chat settings in actual runs.
|
||||||
func ResolveChatModel(prof core.AgentProfile, eff core.EffectiveSettings) ResolvedModel {
|
func ResolveChatModel(prof core.AgentProfile, eff core.EffectiveSettings) ResolvedModel {
|
||||||
m := eff.GetString("models.chat.model", "")
|
m := eff.GetString("models.chat.model", "")
|
||||||
if prof.Model.Chat != "" {
|
if !eff.GetBool("_policy.managedModels", false) && prof.Model.Chat != "" {
|
||||||
m = prof.Model.Chat
|
m = prof.Model.Chat
|
||||||
}
|
}
|
||||||
return ResolvedModel{Kind: "chat", Model: m,
|
return ResolvedModel{Kind: "chat", Model: m,
|
||||||
@@ -80,12 +81,12 @@ func ChatFallbacks(eff core.EffectiveSettings) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveLightweightModel 解析轻量模型:画像 route > 设置层轻量档 > 回落主档。
|
// ResolveLightweightModel resolves the managed lightweight route, then chat.
|
||||||
func ResolveLightweightModel(prof core.AgentProfile, eff core.EffectiveSettings) ResolvedModel {
|
func ResolveLightweightModel(prof core.AgentProfile, eff core.EffectiveSettings) ResolvedModel {
|
||||||
m := eff.GetString("models.lightweight.model", "")
|
m := eff.GetString("models.lightweight.model", "")
|
||||||
base := eff.GetString("models.lightweight.baseUrl", "")
|
base := eff.GetString("models.lightweight.baseUrl", "")
|
||||||
key := overlayKey(prof.Owner, "models.lightweight", eff.GetString("models.lightweight.apiKey", ""))
|
key := overlayKey(prof.Owner, "models.lightweight", eff.GetString("models.lightweight.apiKey", ""))
|
||||||
if prof.Model.Lightweight != "" {
|
if !eff.GetBool("_policy.managedModels", false) && prof.Model.Lightweight != "" {
|
||||||
m = prof.Model.Lightweight
|
m = prof.Model.Lightweight
|
||||||
}
|
}
|
||||||
if m == "" || base == "" {
|
if m == "" || base == "" {
|
||||||
|
|||||||
@@ -589,7 +589,7 @@ func TestSystemSettingsAdminGate(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 管理员:写入生效,系统读面掩码返回,dave 的有效设置拿到系统值。
|
// 管理员:写入生效,系统读面掩码返回,dave 的有效设置拿到系统值。
|
||||||
if rec := do(keyBoss, "PUT", "/api/settings/system", `{"models":{"chat":{"model":"m-boss","apiKey":"sk-boss-secret"}}}`); rec.Code != http.StatusOK {
|
if rec := do(keyBoss, "PUT", "/api/settings/system", `{"values":{"models":{"chat":{"model":"m-boss","apiKey":"sk-boss-secret"}}},"expectedRevision":0}`); rec.Code != http.StatusOK {
|
||||||
t.Fatalf("boss put system: %d %s", rec.Code, rec.Body.String())
|
t.Fatalf("boss put system: %d %s", rec.Code, rec.Body.String())
|
||||||
}
|
}
|
||||||
rec := do(keyBoss, "GET", "/api/settings/system", "")
|
rec := do(keyBoss, "GET", "/api/settings/system", "")
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
agentcommand "agent-command"
|
agentcommand "agent-command"
|
||||||
|
"agent-system-backend/internal/settings"
|
||||||
"agent-system-backend/internal/store"
|
"agent-system-backend/internal/store"
|
||||||
core "agent-system-core"
|
core "agent-system-core"
|
||||||
)
|
)
|
||||||
@@ -15,7 +16,13 @@ var userCommandSlug = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
|||||||
|
|
||||||
func (s *Server) listSecrets(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) listSecrets(w http.ResponseWriter, r *http.Request) {
|
||||||
list, err := s.st.ListSecrets(s.owner(r))
|
list, err := s.st.ListSecrets(s.owner(r))
|
||||||
writeJSONOr(w, list, err)
|
filtered := make([]store.Secret, 0, len(list))
|
||||||
|
for _, secret := range list {
|
||||||
|
if !settings.ProtectedSecretService(secret.Service) {
|
||||||
|
filtered = append(filtered, secret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSONOr(w, filtered, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) createSecret(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) createSecret(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -28,6 +35,10 @@ func (s *Server) createSecret(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if settings.ProtectedSecretService(strings.TrimSpace(req.Service)) {
|
||||||
|
http.Error(w, "模型和搜索服务密钥只能在系统设置中配置", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
sec, err := s.st.AddSecret(s.owner(r), req.Service, req.Label, req.Value)
|
sec, err := s.st.AddSecret(s.owner(r), req.Service, req.Label, req.Value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
@@ -37,10 +48,16 @@ func (s *Server) createSecret(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) activateSecret(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) activateSecret(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.mutablePersonalSecret(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
writeOKOr(w, s.st.ActivateSecret(s.owner(r), r.PathValue("id")))
|
writeOKOr(w, s.st.ActivateSecret(s.owner(r), r.PathValue("id")))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) renameSecret(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) renameSecret(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.mutablePersonalSecret(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
}
|
}
|
||||||
@@ -52,9 +69,27 @@ func (s *Server) renameSecret(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) deleteSecret(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) deleteSecret(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if !s.mutablePersonalSecret(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
writeOKOr(w, s.st.DeleteSecret(s.owner(r), r.PathValue("id")))
|
writeOKOr(w, s.st.DeleteSecret(s.owner(r), r.PathValue("id")))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) mutablePersonalSecret(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
list, err := s.st.ListSecrets(s.owner(r))
|
||||||
|
if err != nil {
|
||||||
|
writeStoreErr(w, err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, secret := range list {
|
||||||
|
if secret.ID == r.PathValue("id") && settings.ProtectedSecretService(secret.Service) {
|
||||||
|
http.Error(w, "旧模型和搜索服务密钥已退出生效链;请在系统设置中配置", http.StatusBadRequest)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) listContextEntries(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) listContextEntries(w http.ResponseWriter, r *http.Request) {
|
||||||
q := r.URL.Query()
|
q := r.URL.Query()
|
||||||
list, err := s.st.ListContextEntries(s.owner(r), q.Get("projectId"), q.Get("conversationId"))
|
list, err := s.st.ListContextEntries(s.owner(r), q.Get("projectId"), q.Get("conversationId"))
|
||||||
|
|||||||
@@ -132,6 +132,17 @@ func stubTargets(prompt string) []string {
|
|||||||
|
|
||||||
func doJSON(t *testing.T, h http.Handler, method, path string, body any) (int, map[string]any) {
|
func doJSON(t *testing.T, h http.Handler, method, path string, body any) (int, map[string]any) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
// Existing fixture callers provide desired values; public writes carry the
|
||||||
|
// freshly read revision. Tests of stale/legacy requests use raw HTTP directly.
|
||||||
|
if method == "PUT" && path == "/api/settings/system" {
|
||||||
|
if values, ok := body.(map[string]any); ok && values["values"] == nil {
|
||||||
|
code, current := doJSON(t, h, "GET", path, nil)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatalf("read system revision: %d", code)
|
||||||
|
}
|
||||||
|
body = map[string]any{"values": values, "expectedRevision": current["systemRevision"]}
|
||||||
|
}
|
||||||
|
}
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
if body != nil {
|
if body != nil {
|
||||||
json.NewEncoder(&buf).Encode(body)
|
json.NewEncoder(&buf).Encode(body)
|
||||||
@@ -1014,7 +1025,7 @@ func TestSquadOrchestration(t *testing.T) {
|
|||||||
|
|
||||||
// TestModelRoutingOverrideChain:P2 受管语义下的模型配置链路——models.* 是系统
|
// TestModelRoutingOverrideChain:P2 受管语义下的模型配置链路——models.* 是系统
|
||||||
// 受管项,user/project 层整笔拒绝(含嵌套与点路径),仅系统层可写;画像层模型名
|
// 受管项,user/project 层整笔拒绝(含嵌套与点路径),仅系统层可写;画像层模型名
|
||||||
// 仍是最终覆盖。经由真实 run 的失败信息携带解析出的模型名取证。
|
// 存量画像层也不能覆盖。经由真实 run 的失败信息携带解析出的模型名取证。
|
||||||
func TestModelRoutingOverrideChain(t *testing.T) {
|
func TestModelRoutingOverrideChain(t *testing.T) {
|
||||||
srv, st := newTestServer(t)
|
srv, st := newTestServer(t)
|
||||||
h := srv.Handler()
|
h := srv.Handler()
|
||||||
@@ -1061,19 +1072,22 @@ func TestModelRoutingOverrideChain(t *testing.T) {
|
|||||||
t.Fatalf("non-managed user write rejected")
|
t.Fatalf("non-managed user write rejected")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 画像层:model-C 为最终覆盖。关 mock(系统层)→ 发消息 → run 失败信息携带解析结果。
|
// 新画像路由被拒,存量画像路由保留原数据但退出生效链。
|
||||||
if code, _ := doJSON(t, h, "PUT", "/api/settings/system", map[string]any{"runtime": map[string]any{"mock": false}}); code != 200 {
|
if code, _ := doJSON(t, h, "PUT", "/api/settings/system", map[string]any{"runtime": map[string]any{"mock": false}}); code != 200 {
|
||||||
t.Fatal("disable mock via system layer failed")
|
t.Fatal("disable mock via system layer failed")
|
||||||
}
|
}
|
||||||
prof := mustCode(t, h, "POST", "/api/profiles", map[string]any{
|
code, _ = doJSON(t, h, "POST", "/api/profiles", map[string]any{
|
||||||
"id": "prof_route_c", "name": "路由C", "systemPrompt": "x",
|
"id": "prof_route_c", "name": "路由C", "systemPrompt": "x",
|
||||||
"model": map[string]any{"chat": "model-C"},
|
"model": map[string]any{"chat": "model-C"},
|
||||||
"tools": map[string]any{"allow": []string{"*"}},
|
"tools": map[string]any{"allow": []string{"*"}},
|
||||||
"context": map[string]any{"maxHistoryMessages": 10},
|
"context": map[string]any{"maxHistoryMessages": 10},
|
||||||
"hitl": map[string]any{"askOnAmbiguity": true}, "budget": map[string]any{},
|
"hitl": map[string]any{"askOnAmbiguity": true}, "budget": map[string]any{},
|
||||||
})
|
})
|
||||||
if prof["id"] != "prof_route_c" {
|
if code != http.StatusBadRequest {
|
||||||
t.Fatalf("profile save: %v", prof)
|
t.Fatalf("personal profile model accepted: %d", code)
|
||||||
|
}
|
||||||
|
if err := st.SaveProfile(core.AgentProfile{ID: "prof_route_c", Owner: store.OwnerDefault, Name: "Legacy route", Model: core.ModelRoute{Chat: "model-C"}, Tools: core.ToolPolicy{Allow: []string{"*"}}}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
mustCode(t, h, "PATCH", "/api/conversations/"+convID, map[string]any{"profileId": "prof_route_c"})
|
mustCode(t, h, "PATCH", "/api/conversations/"+convID, map[string]any{"profileId": "prof_route_c"})
|
||||||
runID := postMessage(t, h, convID, "触发一次真实模型调用(预期失败并携带解析出的模型名)")
|
runID := postMessage(t, h, convID, "触发一次真实模型调用(预期失败并携带解析出的模型名)")
|
||||||
@@ -1082,8 +1096,8 @@ func TestModelRoutingOverrideChain(t *testing.T) {
|
|||||||
t.Fatalf("expected failed run (no baseUrl), got %v", terminal["status"])
|
t.Fatalf("expected failed run (no baseUrl), got %v", terminal["status"])
|
||||||
}
|
}
|
||||||
errMsg := terminal["error"].(map[string]any)["message"].(string)
|
errMsg := terminal["error"].(map[string]any)["message"].(string)
|
||||||
if !strings.Contains(errMsg, `model-C`) {
|
if !strings.Contains(errMsg, `model-SYS`) || strings.Contains(errMsg, `model-C`) {
|
||||||
t.Fatalf("run error should carry profile-routed model name: %q", errMsg)
|
t.Fatalf("run error should carry system-routed model name: %q", errMsg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1247,7 +1261,8 @@ func TestSettingsDottedKeyAndSecretMask(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
set(map[string]any{"runtime.entry": "squad"})
|
setSys(map[string]any{"runtime.entry": "squad"})
|
||||||
|
set(map[string]any{"ui.theme": "light"})
|
||||||
if got := svc.Effective(store.OwnerDefault, "").GetString("runtime.entry", ""); got != "squad" {
|
if got := svc.Effective(store.OwnerDefault, "").GetString("runtime.entry", ""); got != "squad" {
|
||||||
t.Fatalf("dotted write did not take effect: %q", got)
|
t.Fatalf("dotted write did not take effect: %q", got)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,213 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"agent-system-backend/internal/settings"
|
||||||
|
"agent-system-backend/internal/store"
|
||||||
|
"agent-system-backend/pkg/extension"
|
||||||
|
core "agent-system-core"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Server) SetProjectSettingsAuthorizer(fn extension.ProjectSettingsAuthorizer) {
|
||||||
|
s.projectSettingsAuthz = fn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) settingsProject(ctx context.Context, actor, id string, write bool) (core.Project, error) {
|
||||||
|
p, err := s.st.ProjectMetadata(id)
|
||||||
|
if err != nil {
|
||||||
|
return core.Project{}, err
|
||||||
|
}
|
||||||
|
if s.projectSettingsAuthz != nil {
|
||||||
|
if err := s.projectSettingsAuthz(ctx, actor, p.ID, write); err != nil {
|
||||||
|
return core.Project{}, err
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
// A domain lifecycle can revoke even the project creator. Missing settings
|
||||||
|
// capability fails closed; read/work authorization cannot grant configuration.
|
||||||
|
if s.projectLifecycle != nil {
|
||||||
|
return core.Project{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
if p.Owner != actor {
|
||||||
|
return core.Project{}, store.ErrNotFound
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeSettingsObject(r *http.Request) (map[string]any, error) {
|
||||||
|
var values map[string]any
|
||||||
|
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
||||||
|
if err := decoder.Decode(&values); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if values == nil {
|
||||||
|
return nil, errors.New("需要设置对象")
|
||||||
|
}
|
||||||
|
if err := decoder.Decode(new(any)); err != io.EOF {
|
||||||
|
return nil, errors.New("设置请求必须只包含一个对象")
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeStrictSettingsRequest(r *http.Request, target any) error {
|
||||||
|
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(target); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := decoder.Decode(new(any)); err != io.EOF {
|
||||||
|
return errors.New("请求只能包含一个对象")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) previewSettingsMigration(w http.ResponseWriter, r *http.Request) {
|
||||||
|
actor := s.owner(r)
|
||||||
|
project, err := s.settingsProject(r.Context(), actor, r.PathValue("id"), true)
|
||||||
|
if err != nil {
|
||||||
|
writeProjectErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Choices []settings.MigrationChoice `json:"choices"`
|
||||||
|
ExpectedRevision *int64 `json:"expectedRevision"`
|
||||||
|
}
|
||||||
|
if err := decodeStrictSettingsRequest(r, &req); err != nil || req.ExpectedRevision == nil || *req.ExpectedRevision < 0 {
|
||||||
|
http.Error(w, "需要 choices 和 expectedRevision", 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, choice := range req.Choices {
|
||||||
|
if _, err := s.st.GetUser(choice.SourceOwner); err != nil && !(s.Auth == nil && choice.SourceOwner == actor) {
|
||||||
|
writeStoreErr(w, store.ErrNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.settingsProject(r.Context(), choice.SourceOwner, project.ID, false); err != nil {
|
||||||
|
writeProjectErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.set.SetExtraSchema(s.plugins.SettingsSchema())
|
||||||
|
result, err := s.set.PreviewProjectMigration(actor, project.ID, req.Choices, *req.ExpectedRevision)
|
||||||
|
if err != nil {
|
||||||
|
s.writeMigrationError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) applySettingsMigration(w http.ResponseWriter, r *http.Request) {
|
||||||
|
actor := s.owner(r)
|
||||||
|
project, err := s.settingsProject(r.Context(), actor, r.PathValue("id"), true)
|
||||||
|
if err != nil {
|
||||||
|
writeProjectErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req struct {
|
||||||
|
Observation string `json:"observation"`
|
||||||
|
ExpectedRevision *int64 `json:"expectedRevision"`
|
||||||
|
}
|
||||||
|
if err := decodeStrictSettingsRequest(r, &req); err != nil || req.ExpectedRevision == nil || *req.ExpectedRevision < 0 {
|
||||||
|
http.Error(w, "需要 observation 和 expectedRevision", 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.set.SetExtraSchema(s.plugins.SettingsSchema())
|
||||||
|
selected, choices, err := s.set.MigrationSelection(actor, project.ID, req.Observation, *req.ExpectedRevision)
|
||||||
|
if err != nil {
|
||||||
|
s.writeMigrationError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, choice := range choices {
|
||||||
|
if _, err := s.st.GetUser(choice.SourceOwner); err != nil && !(s.Auth == nil && choice.SourceOwner == actor) {
|
||||||
|
writeStoreErr(w, store.ErrNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.settingsProject(r.Context(), choice.SourceOwner, project.ID, false); err != nil {
|
||||||
|
writeProjectErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.validateProjectTemplate(actor, project, selected); err != nil {
|
||||||
|
writeProjectErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := s.settingsProject(r.Context(), actor, project.ID, true); err != nil {
|
||||||
|
writeProjectErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := s.set.ApplyProjectMigration(actor, project.ID, req.Observation, *req.ExpectedRevision, selected)
|
||||||
|
if err != nil {
|
||||||
|
s.writeMigrationError(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) writeMigrationError(w http.ResponseWriter, err error) {
|
||||||
|
if errors.Is(err, settings.ErrSettingsConflict) {
|
||||||
|
http.Error(w, err.Error(), 409)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var invalid *settings.ErrInvalidSetting
|
||||||
|
if errors.As(err, &invalid) {
|
||||||
|
http.Error(w, err.Error(), 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeProjectErr(w, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) putSettingsPersonalProject(w http.ResponseWriter, r *http.Request) {
|
||||||
|
owner := s.owner(r)
|
||||||
|
p, err := s.settingsProject(r.Context(), owner, r.PathValue("id"), false)
|
||||||
|
if err != nil {
|
||||||
|
writeProjectErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
values, err := decodeSettingsObject(r)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.set.ValidateWrite(values, settings.PersonalProjectScope); err != nil {
|
||||||
|
http.Error(w, err.Error(), 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.writeSettingsMerge(w, func() error { return s.set.MergePersonalProject(owner, p.ID, values) })
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) validateProjectTemplate(actor string, p core.Project, values map[string]any) error {
|
||||||
|
projected := s.set.ProjectValues(values, settings.ProjectSettingScope)
|
||||||
|
id := core.EffectiveSettings(projected).GetString("deliverables.templateArtifactId", "")
|
||||||
|
if id == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
a, err := s.st.GetArtifactByID(id)
|
||||||
|
if err != nil || a.ProjectID != p.ID {
|
||||||
|
return store.ErrNotFound
|
||||||
|
}
|
||||||
|
if s.ProjectAuthzV2 != nil {
|
||||||
|
if !s.authorizeSharedResource(actor, "model-context", "artifact", a.ID, a.Owner, a.ProjectID) {
|
||||||
|
return store.ErrNotFound
|
||||||
|
}
|
||||||
|
} else if a.Owner != actor {
|
||||||
|
return store.ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) getSettingsMigration(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if _, ok := s.requireSystemAdmin(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.URL.Query().Get("dryRun") != "true" {
|
||||||
|
http.Error(w, "迁移清单仅支持 dryRun=true;不执行自动迁移", 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.set.SetExtraSchema(s.plugins.SettingsSchema())
|
||||||
|
view, err := s.set.MigrationInventory()
|
||||||
|
writeJSONOr(w, view, err)
|
||||||
|
}
|
||||||
+189
-61
@@ -42,26 +42,27 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type Server struct {
|
type Server struct {
|
||||||
projectLifecycle *extension.ProjectLifecycle
|
projectLifecycle *extension.ProjectLifecycle
|
||||||
projectHost func(string) extension.Host
|
projectSettingsAuthz extension.ProjectSettingsAuthorizer
|
||||||
st *store.Store
|
projectHost func(string) extension.Host
|
||||||
Auth *Authenticator // API key 认证(nil/未启用 = 默认租户;组合根注入)
|
st *store.Store
|
||||||
Retriever core.Retriever // 混合检索(nil = store 全文;组合根注入)
|
Auth *Authenticator // API key 认证(nil/未启用 = 默认租户;组合根注入)
|
||||||
Indexer core.DocumentIndexer // 检索索引入口(nil = 整文档单块;组合根注入)
|
Retriever core.Retriever // 混合检索(nil = store 全文;组合根注入)
|
||||||
Limiter ratelimit.Limiter // 租户限流(nil = 不限;组合根注入)
|
Indexer core.DocumentIndexer // 检索索引入口(nil = 整文档单块;组合根注入)
|
||||||
LimitLookup func() int // 当前限流值(次/分;<=0 不限;组合根缓存注入)
|
Limiter ratelimit.Limiter // 租户限流(nil = 不限;组合根注入)
|
||||||
mgr *runmgr.Manager
|
LimitLookup func() int // 当前限流值(次/分;<=0 不限;组合根缓存注入)
|
||||||
Cards *cardsvc.Service // 收件箱任务卡(nil = 不启用;组合根注入)
|
mgr *runmgr.Manager
|
||||||
Surfaces *surfacesvc.Service // Generative UI Surface(nil = 不启用;EXT-17)
|
Cards *cardsvc.Service // 收件箱任务卡(nil = 不启用;组合根注入)
|
||||||
Bus *cmdbus.Bus // 命令总线(nil = 不启用;组合根注入;四入口同源 D27)
|
Surfaces *surfacesvc.Service // Generative UI Surface(nil = 不启用;EXT-17)
|
||||||
Gate NLIntentGate // 意图闸门(chat 入口同源判定;nil = 不接)
|
Bus *cmdbus.Bus // 命令总线(nil = 不启用;组合根注入;四入口同源 D27)
|
||||||
Workflows *workflow.Registry // 已装载工作流(nil = 不接)
|
Gate NLIntentGate // 意图闸门(chat 入口同源判定;nil = 不接)
|
||||||
Prompts *prompts.Service // 提示词库(nil = 不启用 API 与技能命令;执行器另有回退)
|
Workflows *workflow.Registry // 已装载工作流(nil = 不接)
|
||||||
reg *tools.Registry
|
Prompts *prompts.Service // 提示词库(nil = 不启用 API 与技能命令;执行器另有回退)
|
||||||
prof *profiles.Service
|
reg *tools.Registry
|
||||||
set *settings.Service
|
prof *profiles.Service
|
||||||
plugins *plugins.Host
|
set *settings.Service
|
||||||
static fs.FS
|
plugins *plugins.Host
|
||||||
|
static fs.FS
|
||||||
// Subscribe 注入 bus 订阅(事件在线转发;重放走 store 游标)。
|
// Subscribe 注入 bus 订阅(事件在线转发;重放走 store 游标)。
|
||||||
Subscribe func(conversationID string) (<-chan core.StreamEvent, func())
|
Subscribe func(conversationID string) (<-chan core.StreamEvent, func())
|
||||||
// DisableUploadIndexing EXT-9:true 时上传端点不再自动写检索索引(原件与
|
// DisableUploadIndexing EXT-9:true 时上传端点不再自动写检索索引(原件与
|
||||||
@@ -355,6 +356,11 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("GET /api/settings", s.getSettings)
|
mux.HandleFunc("GET /api/settings", s.getSettings)
|
||||||
mux.HandleFunc("PUT /api/settings/user", s.putSettingsUser)
|
mux.HandleFunc("PUT /api/settings/user", s.putSettingsUser)
|
||||||
mux.HandleFunc("PUT /api/settings/project/{id}", s.putSettingsProject)
|
mux.HandleFunc("PUT /api/settings/project/{id}", s.putSettingsProject)
|
||||||
|
mux.HandleFunc("PUT /api/settings/project/{id}/personal", s.putSettingsPersonalProject)
|
||||||
|
mux.HandleFunc("GET /api/settings/migration", s.getSettingsMigration)
|
||||||
|
mux.HandleFunc("POST /api/settings/project/{id}/migration/preview", s.previewSettingsMigration)
|
||||||
|
mux.HandleFunc("POST /api/settings/project/{id}/migration/apply", s.applySettingsMigration)
|
||||||
|
mux.HandleFunc("POST /api/settings/migration/system", s.putSettingsSystem)
|
||||||
mux.HandleFunc("GET /api/settings/system", s.getSettingsSystem)
|
mux.HandleFunc("GET /api/settings/system", s.getSettingsSystem)
|
||||||
mux.HandleFunc("PUT /api/settings/system", s.putSettingsSystem)
|
mux.HandleFunc("PUT /api/settings/system", s.putSettingsSystem)
|
||||||
mux.HandleFunc("GET /api/tools", s.listTools)
|
mux.HandleFunc("GET /api/tools", s.listTools)
|
||||||
@@ -1656,49 +1662,108 @@ func (s *Server) deleteProfile(w http.ResponseWriter, r *http.Request) {
|
|||||||
func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
|
||||||
owner := s.owner(r)
|
owner := s.owner(r)
|
||||||
projectID := r.URL.Query().Get("project")
|
projectID := r.URL.Query().Get("project")
|
||||||
schema := append(settings.Schema(), s.plugins.SettingsSchema()...)
|
s.set.SetExtraSchema(s.plugins.SettingsSchema())
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
if projectID == "" {
|
||||||
"schema": schema,
|
schema := s.set.SchemaFor(settings.UserSettingScope)
|
||||||
"defaults": settings.Masked(settings.SystemDefaults(), schema),
|
user, err := s.set.UserScope(owner)
|
||||||
"user": settings.Masked(mustUser(s.set, owner), schema),
|
if err != nil {
|
||||||
"project": settings.Masked(mustScope(s.set, owner, projectID), schema),
|
writeStoreErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, map[string]any{"schema": schema, "defaults": settings.Masked(s.set.DefaultsFor(settings.UserSettingScope), schema), "user": settings.Masked(user, schema), "project": map[string]any{}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
p, err := s.settingsProject(r.Context(), owner, projectID, false)
|
||||||
|
if err != nil {
|
||||||
|
writeProjectErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shared, revision, err := s.set.SharedProjectScope(p.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeStoreErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
schema := s.set.SchemaFor(settings.ProjectSettingScope)
|
||||||
|
personalSchema := s.set.SchemaFor(settings.PersonalProjectScope)
|
||||||
|
personalProject, err := s.set.ProjectScope(owner, p.ID)
|
||||||
|
if err != nil {
|
||||||
|
writeStoreErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
personalUser, err := s.set.UserScope(owner)
|
||||||
|
if err != nil {
|
||||||
|
writeStoreErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, configureErr := s.settingsProject(r.Context(), owner, p.ID, true)
|
||||||
|
writeJSON(w, 200, map[string]any{
|
||||||
|
"schema": schema, "defaults": settings.Masked(s.set.DefaultsFor(settings.ProjectSettingScope), schema), "project": settings.Masked(shared, schema),
|
||||||
|
"user": map[string]any{}, "projectRevision": revision, "projectCanConfigure": configureErr == nil,
|
||||||
|
"personalSchema": personalSchema, "personalDefaults": settings.Masked(s.set.DefaultsFor(settings.PersonalProjectScope), personalSchema),
|
||||||
|
"personalProject": settings.Masked(personalProject, personalSchema), "personalUser": settings.Masked(personalUser, personalSchema),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func mustUser(s *settings.Service, owner string) map[string]any {
|
|
||||||
m, _ := s.UserScope(owner)
|
|
||||||
if m == nil {
|
|
||||||
return map[string]any{}
|
|
||||||
}
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustScope(s *settings.Service, owner, projectID string) map[string]any {
|
|
||||||
if projectID == "" {
|
|
||||||
return map[string]any{}
|
|
||||||
}
|
|
||||||
m, _ := s.ProjectScope(owner, projectID)
|
|
||||||
return m
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Server) putSettingsUser(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) putSettingsUser(w http.ResponseWriter, r *http.Request) {
|
||||||
var values map[string]any
|
values, err := decodeSettingsObject(r)
|
||||||
if err := json.NewDecoder(r.Body).Decode(&values); err != nil {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
s.set.SetExtraSchema(s.plugins.SettingsSchema())
|
||||||
|
if err := s.set.ValidateWrite(values, settings.UserSettingScope); err != nil {
|
||||||
|
http.Error(w, err.Error(), 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
s.writeSettingsMerge(w, func() error { return s.set.MergeUser(s.owner(r), values) })
|
s.writeSettingsMerge(w, func() error { return s.set.MergeUser(s.owner(r), values) })
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) putSettingsProject(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) putSettingsProject(w http.ResponseWriter, r *http.Request) {
|
||||||
var values map[string]any
|
var req struct {
|
||||||
if err := json.NewDecoder(r.Body).Decode(&values); err != nil {
|
Values map[string]any `json:"values"`
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
ExpectedRevision *int64 `json:"expectedRevision"`
|
||||||
|
Unset []string `json:"unset,omitempty"`
|
||||||
|
}
|
||||||
|
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(&req); err != nil || req.Values == nil || req.ExpectedRevision == nil || *req.ExpectedRevision < 0 {
|
||||||
|
http.Error(w, "需要 values 和 expectedRevision", 400)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
s.writeSettingsMerge(w, func() error {
|
if err := decoder.Decode(new(any)); err != io.EOF {
|
||||||
return s.set.MergeProject(s.owner(r), r.PathValue("id"), values)
|
http.Error(w, "请求只能包含一个对象", 400)
|
||||||
})
|
return
|
||||||
|
}
|
||||||
|
owner := s.owner(r)
|
||||||
|
p, err := s.settingsProject(r.Context(), owner, r.PathValue("id"), true)
|
||||||
|
if err != nil {
|
||||||
|
writeProjectErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.set.SetExtraSchema(s.plugins.SettingsSchema())
|
||||||
|
if err := s.set.ValidateWrite(req.Values, settings.ProjectSettingScope); err != nil {
|
||||||
|
http.Error(w, err.Error(), 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.validateProjectTemplate(owner, p, req.Values); err != nil {
|
||||||
|
writeProjectErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Recheck authority after resource validation and immediately before persistence.
|
||||||
|
if _, err := s.settingsProject(r.Context(), owner, p.ID, true); err != nil {
|
||||||
|
writeProjectErr(w, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
revision, err := s.set.MergeSharedProject(p.ID, req.Values, *req.ExpectedRevision, req.Unset)
|
||||||
|
if errors.Is(err, settings.ErrSettingsConflict) {
|
||||||
|
http.Error(w, err.Error(), 409)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.writeSettingsMerge(w, func() error { return err })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, map[string]any{"ok": true, "projectRevision": revision})
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeSettingsMerge 统一处理设置写入结果:触碰系统受管项返回 400 并列出路径,
|
// writeSettingsMerge 统一处理设置写入结果:触碰系统受管项返回 400 并列出路径,
|
||||||
@@ -1714,6 +1779,11 @@ func (s *Server) writeSettingsMerge(w http.ResponseWriter, merge func() error) {
|
|||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
var invalid *settings.ErrInvalidSetting
|
||||||
|
if errors.As(err, &invalid) {
|
||||||
|
http.Error(w, err.Error(), 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1733,16 +1803,18 @@ func (s *Server) getSettingsSystem(w http.ResponseWriter, r *http.Request) {
|
|||||||
if _, ok := s.requireSystemAdmin(w, r); !ok {
|
if _, ok := s.requireSystemAdmin(w, r); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
schema := append(settings.Schema(), s.plugins.SettingsSchema()...)
|
s.set.SetExtraSchema(s.plugins.SettingsSchema())
|
||||||
sys, err := s.set.SystemScope()
|
schema := s.set.SchemaFor(core.SettingScopeSystem)
|
||||||
|
sys, revision, err := s.set.SystemSnapshot()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeStoreErr(w, err)
|
writeStoreErr(w, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
"schema": schema,
|
"schema": schema,
|
||||||
"defaults": settings.Masked(settings.SystemDefaults(), schema),
|
"defaults": settings.Masked(s.set.DefaultsFor(core.SettingScopeSystem), schema),
|
||||||
"system": settings.Masked(sys, schema),
|
"system": settings.Masked(s.set.ProjectValues(sys, core.SettingScopeSystem), schema),
|
||||||
|
"systemRevision": revision,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1750,12 +1822,30 @@ func (s *Server) putSettingsSystem(w http.ResponseWriter, r *http.Request) {
|
|||||||
if _, ok := s.requireSystemAdmin(w, r); !ok {
|
if _, ok := s.requireSystemAdmin(w, r); !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var values map[string]any
|
var req struct {
|
||||||
if err := json.NewDecoder(r.Body).Decode(&values); err != nil {
|
Values map[string]any `json:"values"`
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
ExpectedRevision *int64 `json:"expectedRevision"`
|
||||||
|
Unset []string `json:"unset,omitempty"`
|
||||||
|
}
|
||||||
|
if err := decodeStrictSettingsRequest(r, &req); err != nil || req.Values == nil || req.ExpectedRevision == nil || *req.ExpectedRevision < 0 {
|
||||||
|
http.Error(w, "需要 values 和 expectedRevision;旧系统写入格式请先读取 systemRevision 再按新格式保存", 400)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeOKOr(w, s.set.MergeSystem(values))
|
s.set.SetExtraSchema(s.plugins.SettingsSchema())
|
||||||
|
if err := s.set.ValidateWrite(req.Values, core.SettingScopeSystem); err != nil {
|
||||||
|
http.Error(w, err.Error(), 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
revision, err := s.set.MergeSystemAtRevision(req.Values, *req.ExpectedRevision, req.Unset)
|
||||||
|
if errors.Is(err, settings.ErrSettingsConflict) {
|
||||||
|
http.Error(w, err.Error(), 409)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
s.writeSettingsMerge(w, func() error { return err })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, 200, map[string]any{"ok": true, "systemRevision": revision})
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- 工具(用户声明式 HTTP 工具注册)----
|
// ---- 工具(用户声明式 HTTP 工具注册)----
|
||||||
@@ -1790,7 +1880,25 @@ func (s *Server) createUserTool(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "v1 用户工具需为声明式 HTTP 工具(提供 http 段)", http.StatusBadRequest)
|
http.Error(w, "v1 用户工具需为声明式 HTTP 工具(提供 http 段)", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
lookup := func(key string) string { return s.set.Effective(s.owner(r), "").GetString(key, "") }
|
s.set.SetExtraSchema(s.plugins.SettingsSchema())
|
||||||
|
if !s.set.PersonalCredentialReferenceAllowed(desc.HTTP.AuthRef) {
|
||||||
|
http.Error(w, "个人 HTTP 工具只能引用个人凭据,不能导出系统模型、搜索或环境密钥", 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
owner := s.owner(r)
|
||||||
|
lookup := func(key string) string {
|
||||||
|
if !s.set.PersonalCredentialReferenceAllowed(key) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if value, ok := s.st.ActiveSecret(owner, key); ok {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
personal, err := s.set.UserScope(owner)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return core.EffectiveSettings(personal).GetString(key, "")
|
||||||
|
}
|
||||||
if err := s.reg.Register(desc, tools.HTTPExecutor(*desc.HTTP, lookup)); err != nil {
|
if err := s.reg.Register(desc, tools.HTTPExecutor(*desc.HTTP, lookup)); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
@@ -1878,7 +1986,27 @@ func (s *Server) reloadPlugins(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Server) pluginSettingsSchema(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) pluginSettingsSchema(w http.ResponseWriter, r *http.Request) {
|
||||||
writeJSON(w, http.StatusOK, s.plugins.SettingsSchema())
|
scope := core.SettingScope(r.URL.Query().Get("scope"))
|
||||||
|
if scope == "" {
|
||||||
|
scope = settings.UserSettingScope
|
||||||
|
}
|
||||||
|
if scope == core.SettingScopeSystem {
|
||||||
|
if _, ok := s.requireSystemAdmin(w, r); !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if scope != core.SettingScopeSystem && scope != settings.UserSettingScope && scope != settings.ProjectSettingScope && scope != settings.PersonalProjectScope {
|
||||||
|
http.Error(w, "设置范围无效", 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.set.SetExtraSchema(s.plugins.SettingsSchema())
|
||||||
|
out := []core.SettingDesc{}
|
||||||
|
for _, d := range s.set.SchemaFor(scope) {
|
||||||
|
if strings.HasPrefix(d.Group, "plugin:") {
|
||||||
|
out = append(out, d)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// listAdminZoneSections 管理区声明分区清单(WP3-I3):展平全部编译插件的
|
// listAdminZoneSections 管理区声明分区清单(WP3-I3):展平全部编译插件的
|
||||||
|
|||||||
@@ -0,0 +1,387 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
"agent-system-backend/internal/settings"
|
||||||
|
"agent-system-backend/pkg/extension"
|
||||||
|
core "agent-system-core"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSharedProjectSettingsRealRequestsAndRevocation(t *testing.T) {
|
||||||
|
s, st := newAuthTestServer(t)
|
||||||
|
alice, _ := st.CreateAPIKey("alice", "test")
|
||||||
|
bob, _ := st.CreateAPIKey("bob", "test")
|
||||||
|
mallory, _ := st.CreateAPIKey("mallory", "test")
|
||||||
|
p, _ := st.CreateProject("alice", "Shared business")
|
||||||
|
other, _ := st.CreateProject("alice", "Other")
|
||||||
|
revoked := false
|
||||||
|
s.SetProjectSettingsAuthorizer(func(_ context.Context, actor, id string, write bool) error {
|
||||||
|
if id != p.ID || revoked || actor == "mallory" {
|
||||||
|
return lifecycleStatusError(404)
|
||||||
|
}
|
||||||
|
if write && actor != "alice" {
|
||||||
|
return lifecycleStatusError(403)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
write := func(key, body string) int {
|
||||||
|
return lifecycleRequest(t, s, key, "PUT", "/api/settings/project/"+p.ID, body).Code
|
||||||
|
}
|
||||||
|
if code := write(alice, `{"values":{"deliverables.style":"formal"},"expectedRevision":0}`); code != 200 {
|
||||||
|
t.Fatalf("save=%d", code)
|
||||||
|
}
|
||||||
|
w := lifecycleRequest(t, s, bob, "GET", "/api/settings?project="+p.ID, "")
|
||||||
|
var view map[string]any
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &view)
|
||||||
|
if w.Code != 200 || view["projectRevision"] != float64(1) || view["projectCanConfigure"] != false || !strings.Contains(w.Body.String(), "formal") {
|
||||||
|
t.Fatalf("member shared read=%d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if code := write(bob, `{"values":{"deliverables.style":"minimal"},"expectedRevision":1}`); code != 403 {
|
||||||
|
t.Fatalf("work/member treated as configuration authority=%d", code)
|
||||||
|
}
|
||||||
|
if code := write(alice, `{"values":{"deliverables.style":"minimal"},"expectedRevision":0}`); code != 409 {
|
||||||
|
t.Fatalf("stale save=%d", code)
|
||||||
|
}
|
||||||
|
for _, id := range []string{other.ID, "missing"} {
|
||||||
|
if w := lifecycleRequest(t, s, bob, "GET", "/api/settings?project="+id, ""); w.Code != 404 {
|
||||||
|
t.Fatalf("cross project read=%d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if w := lifecycleRequest(t, s, mallory, "GET", "/api/settings?project="+p.ID, ""); w.Code != 404 {
|
||||||
|
t.Fatalf("outsider read=%d", w.Code)
|
||||||
|
}
|
||||||
|
if w := lifecycleRequest(t, s, alice, "PUT", "/api/settings/project/"+p.ID+"/personal", `{"crossSession.read":true,"ui.theme":"light","context.userInstructions":"Alice only"}`); w.Code != 200 {
|
||||||
|
t.Fatalf("personal save=%d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
w = lifecycleRequest(t, s, bob, "GET", "/api/settings?project="+p.ID, "")
|
||||||
|
if strings.Contains(w.Body.String(), "Alice only") || s.set.Effective("bob", p.ID).GetBool("crossSession.read", false) {
|
||||||
|
t.Fatal("personal state shared with another member")
|
||||||
|
}
|
||||||
|
revoked = true
|
||||||
|
if w := lifecycleRequest(t, s, alice, "GET", "/api/settings?project="+p.ID, ""); w.Code != 404 {
|
||||||
|
t.Fatalf("creator bypassed revocation=%d", w.Code)
|
||||||
|
}
|
||||||
|
if code := write(alice, `{"values":{"deliverables.style":"minimal"},"expectedRevision":1}`); code != 404 {
|
||||||
|
t.Fatalf("revoked write=%d", code)
|
||||||
|
}
|
||||||
|
if got := s.set.Effective("alice", p.ID).GetString("deliverables.style", ""); got != "formal" {
|
||||||
|
t.Fatalf("denied save changed data: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSharedProjectSettingsConcurrentRevision(t *testing.T) {
|
||||||
|
s, st := newAuthTestServer(t)
|
||||||
|
key, _ := st.CreateAPIKey("alice", "test")
|
||||||
|
p, _ := st.CreateProject("alice", "Concurrent")
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
codes := make(chan int, 2)
|
||||||
|
for _, style := range []string{"formal", "minimal"} {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(style string) {
|
||||||
|
defer wg.Done()
|
||||||
|
body := fmt.Sprintf(`{"values":{"deliverables.style":%q},"expectedRevision":0}`, style)
|
||||||
|
codes <- lifecycleRequest(t, s, key, "PUT", "/api/settings/project/"+p.ID, body).Code
|
||||||
|
}(style)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
close(codes)
|
||||||
|
counts := map[int]int{}
|
||||||
|
for code := range codes {
|
||||||
|
counts[code]++
|
||||||
|
}
|
||||||
|
if counts[200] != 1 || counts[409] != 1 {
|
||||||
|
t.Fatalf("concurrent save outcomes=%v", counts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectWorkCapabilityCannotConfigureSettings(t *testing.T) {
|
||||||
|
s, st := newAuthTestServer(t)
|
||||||
|
key, _ := st.CreateAPIKey("alice", "test")
|
||||||
|
p, _ := st.CreateProject("alice", "Capability")
|
||||||
|
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) { return true, nil }}, nil)
|
||||||
|
if w := lifecycleRequest(t, s, key, "PUT", "/api/settings/project/"+p.ID, `{"values":{"deliverables.style":"formal"},"expectedRevision":0}`); w.Code != 404 {
|
||||||
|
t.Fatalf("missing configure capability allowed write: %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrationInventoryProtectedAndNonDestructive(t *testing.T) {
|
||||||
|
s, st := newAuthTestServer(t)
|
||||||
|
key, _ := st.CreateAPIKey("alice", "test")
|
||||||
|
if w := lifecycleRequest(t, s, key, "GET", "/api/settings/migration?dryRun=true", ""); w.Code != 403 {
|
||||||
|
t.Fatalf("nonadmin inventory=%d", w.Code)
|
||||||
|
}
|
||||||
|
s.Auth = nil
|
||||||
|
_ = st.MergeSettingsScope("alice", "project:p", map[string]any{"deliverables.style": "formal", "models.chat.apiKey": "sensitive-source-fixture"})
|
||||||
|
_ = st.MergeSettingsScope("bob", "project:p", map[string]any{"deliverables.style": "minimal"})
|
||||||
|
_, _ = st.AddSecret("alice", "models.chat", "Old", "sensitive-secret-fixture")
|
||||||
|
w := lifecycleRequest(t, s, "", "GET", "/api/settings/migration?dryRun=true", "")
|
||||||
|
if w.Code != 200 || !strings.Contains(w.Body.String(), `"conflict"`) || strings.Contains(w.Body.String(), "sensitive-") {
|
||||||
|
t.Fatalf("inventory invalid=%d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
raw, _ := st.GetSettingsScope("alice", "project:p")
|
||||||
|
encoded, _ := json.Marshal(raw)
|
||||||
|
if !strings.Contains(string(encoded), "sensitive-source-fixture") {
|
||||||
|
t.Fatal("dry run removed recovery value")
|
||||||
|
}
|
||||||
|
if s.set.Effective("bob", "p").GetString("deliverables.style", "") != "modern" {
|
||||||
|
t.Fatal("conflicting legacy values automatically promoted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPersonalSecretsRejectSystemManagedServices(t *testing.T) {
|
||||||
|
s, st := newAuthTestServer(t)
|
||||||
|
key, err := st.CreateAPIKey("alice", "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, service := range []string{"models.chat", "models.lightweight", "models.embedding", "models.rerank", "tools.tavily"} {
|
||||||
|
body, _ := json.Marshal(map[string]string{"service": service, "value": "fixture-key"})
|
||||||
|
w := lifecycleRequest(t, s, key, "POST", "/api/secrets", string(body))
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("personal %s accepted: %d %s", service, w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
old, err := st.AddSecret("alice", "models.chat", "old", "old-fixture-key")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, action := range []struct{ method, path, body string }{{"POST", "/api/secrets/" + old.ID + "/activate", ""}, {"PATCH", "/api/secrets/" + old.ID, `{"label":"new"}`}, {"DELETE", "/api/secrets/" + old.ID, ""}} {
|
||||||
|
if w := lifecycleRequest(t, s, key, action.method, action.path, action.body); w.Code != 400 {
|
||||||
|
t.Fatalf("old protected key mutable: %s %d", action.method, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if w := lifecycleRequest(t, s, key, "GET", "/api/secrets", ""); w.Code != 200 || strings.Contains(w.Body.String(), old.ID) {
|
||||||
|
t.Fatal("retired protected key displayed as personal configuration")
|
||||||
|
}
|
||||||
|
if w := lifecycleRequest(t, s, key, "POST", "/api/secrets", `{"service":"custom.personal","value":"fixture"}`); w.Code != 200 {
|
||||||
|
t.Fatal("unrelated personal credentials blocked")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsReadProjectsOnlyRequestedScopes(t *testing.T) {
|
||||||
|
s, st := newAuthTestServer(t)
|
||||||
|
key, _ := st.CreateAPIKey("alice", "test")
|
||||||
|
p, _ := st.CreateProject("alice", "Settings project")
|
||||||
|
w := lifecycleRequest(t, s, key, "GET", "/api/settings?project="+p.ID, "")
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("read: %d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(w.Body.String(), "models.chat") || strings.Contains(w.Body.String(), "主模型") {
|
||||||
|
t.Fatal("system schema exposed in project settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsRejectUnknownAndMalformedPaths(t *testing.T) {
|
||||||
|
s, st := newAuthTestServer(t)
|
||||||
|
key, _ := st.CreateAPIKey("alice", "test")
|
||||||
|
for _, body := range []string{`{"unknown":{"key":true}}`, `{"unknown":"••••••"}`, `{"ui":{"theme":{"nested":"light"}}}`, `{"ui":{}}`, `{"ui..theme":"light"}`, `{"ui":{"theme":"invalid"}}`, `{"ui.theme":"invalid","ui":{"theme":"light"}}`, `null`} {
|
||||||
|
w := lifecycleRequest(t, s, key, "PUT", "/api/settings/user", body)
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("malformed accepted %s: %d", body, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectSettingsExplicitMigrationSourceAndRetry(t *testing.T) {
|
||||||
|
s, st := newAuthTestServer(t)
|
||||||
|
key, _ := st.CreateAPIKey("alice", "test")
|
||||||
|
p, _ := st.CreateProject("alice", "Migration")
|
||||||
|
if err := st.MergeSettingsScope("alice", "project:"+p.ID, map[string]any{"deliverables.style": "formal", "ui.theme": "light", "context.userInstructions": "private-original"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := st.MergeSettingsScope("bob", "project:"+p.ID, map[string]any{"deliverables.style": "minimal"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
preview := func() string {
|
||||||
|
w := lifecycleRequest(t, s, key, "POST", "/api/settings/project/"+p.ID+"/migration/preview", `{"choices":[{"path":"deliverables.style","sourceOwner":"alice"}],"expectedRevision":0}`)
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("preview=%d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if strings.Contains(w.Body.String(), "private-original") || strings.Contains(w.Body.String(), "formal") {
|
||||||
|
t.Fatal("preview disclosed source values")
|
||||||
|
}
|
||||||
|
var response struct {
|
||||||
|
Observation string `json:"observation"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &response)
|
||||||
|
return response.Observation
|
||||||
|
}
|
||||||
|
apply := func(observation string) *httptest.ResponseRecorder {
|
||||||
|
body, _ := json.Marshal(map[string]any{"observation": observation, "expectedRevision": 0})
|
||||||
|
return lifecycleRequest(t, s, key, "POST", "/api/settings/project/"+p.ID+"/migration/apply", string(body))
|
||||||
|
}
|
||||||
|
old := preview()
|
||||||
|
_ = st.MergeSettingsScope("alice", "project:"+p.ID, map[string]any{"deliverables.style": "modern"})
|
||||||
|
if w := apply(old); w.Code != 409 {
|
||||||
|
t.Fatalf("changed source accepted=%d", w.Code)
|
||||||
|
}
|
||||||
|
if got := s.set.Effective("alice", p.ID).GetString("deliverables.style", ""); got != "modern" {
|
||||||
|
t.Fatalf("stale apply changed target=%s", got)
|
||||||
|
}
|
||||||
|
_ = st.MergeSettingsScope("alice", "project:"+p.ID, map[string]any{"deliverables.style": "formal"})
|
||||||
|
observation := preview()
|
||||||
|
if w := apply(observation); w.Code != 200 {
|
||||||
|
t.Fatalf("apply=%d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := apply(observation); w.Code != 200 {
|
||||||
|
t.Fatalf("retry=%d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if got := s.set.Effective("bob", p.ID).GetString("deliverables.style", ""); got != "formal" {
|
||||||
|
t.Fatalf("explicit selected source not applied=%s", got)
|
||||||
|
}
|
||||||
|
raw, _ := st.GetSettingsScope("alice", "project:"+p.ID)
|
||||||
|
encoded, _ := json.Marshal(raw)
|
||||||
|
if !strings.Contains(string(encoded), "private-original") || !strings.Contains(string(encoded), "formal") {
|
||||||
|
t.Fatal("migration changed original/private source")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExplicitSystemMigrationCASAndCredentialRevocation(t *testing.T) {
|
||||||
|
s, st := newTestServer(t)
|
||||||
|
_, err := st.AddSecret(settings.OwnerSystemRow, "models.chat", "old", "old-system-fixture")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
code, view := doJSON(t, s.Handler(), "GET", "/api/settings/system", nil)
|
||||||
|
if code != 200 {
|
||||||
|
t.Fatal(code)
|
||||||
|
}
|
||||||
|
revision := int64(view["systemRevision"].(float64))
|
||||||
|
save := func(path, value string, expected int64) *httptest.ResponseRecorder {
|
||||||
|
body, _ := json.Marshal(map[string]any{"values": map[string]any{"models.chat.apiKey": value}, "expectedRevision": expected})
|
||||||
|
return lifecycleRequest(t, s, "", "POST", path, string(body))
|
||||||
|
}
|
||||||
|
if w := save("/api/settings/migration/system", "new-system-fixture", revision); w.Code != 200 {
|
||||||
|
t.Fatalf("explicit provided system values=%d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if got, _ := s.set.LookupSecret("alice", "models.chat"); got != "new-system-fixture" {
|
||||||
|
t.Fatal("old active table credential shadowed explicit new value")
|
||||||
|
}
|
||||||
|
if w := save("/api/settings/migration/system", "wrong-stale-fixture", revision); w.Code != 409 {
|
||||||
|
t.Fatalf("stale system migration=%d", w.Code)
|
||||||
|
}
|
||||||
|
if w := save("/api/settings/migration/system", "", revision+1); w.Code != 200 {
|
||||||
|
t.Fatalf("revoke=%d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if got, _ := s.set.LookupSecret("alice", "models.chat"); got != "" {
|
||||||
|
t.Fatal("revocation fell back to old credential")
|
||||||
|
}
|
||||||
|
if w := lifecycleRequest(t, s, "", "PUT", "/api/settings/system", `{"models.chat.model":"legacy-format"}`); w.Code != 400 {
|
||||||
|
t.Fatal("legacy unversioned write silently accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrationRejectsUntrustedSourcesTokensAndRevokedActor(t *testing.T) {
|
||||||
|
s, st := newAuthTestServer(t)
|
||||||
|
key, _ := st.CreateAPIKey("alice", "test")
|
||||||
|
bob, _ := st.CreateAPIKey("bob", "test")
|
||||||
|
p, _ := st.CreateProject("alice", "Trusted")
|
||||||
|
other, _ := st.CreateProject("alice", "Other")
|
||||||
|
_ = st.MergeSettingsScope("alice", "project:"+p.ID, map[string]any{"deliverables.style": "formal"})
|
||||||
|
_ = st.MergeSettingsScope("bob", "project:"+p.ID, map[string]any{"deliverables.style": "minimal"})
|
||||||
|
preview := func(source string) *httptest.ResponseRecorder {
|
||||||
|
body, _ := json.Marshal(map[string]any{"choices": []map[string]string{{"path": "deliverables.style", "sourceOwner": source}}, "expectedRevision": 0})
|
||||||
|
return lifecycleRequest(t, s, key, "POST", "/api/settings/project/"+p.ID+"/migration/preview", string(body))
|
||||||
|
}
|
||||||
|
if w := preview("bob"); w.Code != 404 {
|
||||||
|
t.Fatalf("forged scope granted source authority=%d", w.Code)
|
||||||
|
}
|
||||||
|
w := preview("alice")
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
var response struct {
|
||||||
|
Observation string `json:"observation"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &response)
|
||||||
|
body, _ := json.Marshal(map[string]any{"observation": response.Observation, "expectedRevision": 0})
|
||||||
|
if w := lifecycleRequest(t, s, key, "POST", "/api/settings/project/"+other.ID+"/migration/apply", string(body)); w.Code != 404 {
|
||||||
|
t.Fatal("token reused for another project")
|
||||||
|
}
|
||||||
|
if w := lifecycleRequest(t, s, bob, "POST", "/api/settings/project/"+p.ID+"/migration/apply", string(body)); w.Code != 404 {
|
||||||
|
t.Fatal("token reused by another actor")
|
||||||
|
}
|
||||||
|
s.SetProjectSettingsAuthorizer(func(context.Context, string, string, bool) error { return lifecycleStatusError(403) })
|
||||||
|
if w := lifecycleRequest(t, s, key, "POST", "/api/settings/project/"+p.ID+"/migration/apply", string(body)); w.Code != 403 {
|
||||||
|
t.Fatal("revoked actor migration applied")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPersonalHTTPToolsCannotExportSystemCredential(t *testing.T) {
|
||||||
|
s, st := newAuthTestServer(t)
|
||||||
|
key, _ := st.CreateAPIKey("alice", "test")
|
||||||
|
for _, ref := range []string{"models.chat.apiKey", "tools.tavily.apiKeys", "env:MODEL_API_KEY"} {
|
||||||
|
body, _ := json.Marshal(map[string]any{"name": "private.export", "http": map[string]any{"method": "GET", "url": "https://user-controlled.invalid", "authRef": ref}})
|
||||||
|
if w := lifecycleRequest(t, s, key, "POST", "/api/tools", string(body)); w.Code != 400 {
|
||||||
|
t.Fatalf("personal tool can refer to protected credential namespace %s: %d", ref, w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicProjectAndSystemUnsetEnvelope(t *testing.T) {
|
||||||
|
s, st := newTestServer(t)
|
||||||
|
p, _ := st.CreateProject("local", "Defaults")
|
||||||
|
w := lifecycleRequest(t, s, "", "PUT", "/api/settings/project/"+p.ID, `{"values":{"deliverables.style":"formal"},"expectedRevision":0}`)
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
w = lifecycleRequest(t, s, "", "PUT", "/api/settings/project/"+p.ID, `{"values":{},"unset":["deliverables.style"],"expectedRevision":1}`)
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if s.set.Effective("local", p.ID).GetString("deliverables.style", "") != "modern" {
|
||||||
|
t.Fatal("project unset did not restore inherited default")
|
||||||
|
}
|
||||||
|
if w := lifecycleRequest(t, s, "", "PUT", "/api/settings/project/"+p.ID, `{"values":{"deliverables.style":"formal"},"unset":["deliverables.style"],"expectedRevision":2}`); w.Code != 400 {
|
||||||
|
t.Fatal("overlap request accepted")
|
||||||
|
}
|
||||||
|
revision, _ := s.set.SystemRevision()
|
||||||
|
body, _ := json.Marshal(map[string]any{"values": map[string]any{}, "unset": []string{"runtime.mock"}, "expectedRevision": revision})
|
||||||
|
if w := lifecycleRequest(t, s, "", "PUT", "/api/settings/system", string(body)); w.Code != 200 {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if s.set.Effective("local", "").GetBool("runtime.mock", true) {
|
||||||
|
t.Fatal("system unset did not restore default")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicPersonalMergePatchDeletesOnlyKnownNonsecretLeaves(t *testing.T) {
|
||||||
|
s, st := newAuthTestServer(t)
|
||||||
|
key, _ := st.CreateAPIKey("alice", "test")
|
||||||
|
p, _ := st.CreateProject("alice", "Personal reset")
|
||||||
|
if w := lifecycleRequest(t, s, key, "PUT", "/api/settings/user", `{"ui.theme":"light","ui.cardflow":false,"retrieval.topK":12}`); w.Code != 200 {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if w := lifecycleRequest(t, s, key, "PUT", "/api/settings/user", `{"ui.theme":null,"ui.cardflow":null,"retrieval.topK":null}`); w.Code != 200 {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
w := lifecycleRequest(t, s, key, "GET", "/api/settings", "")
|
||||||
|
var view struct {
|
||||||
|
User map[string]any `json:"user"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal(w.Body.Bytes(), &view)
|
||||||
|
if w.Code != 200 || view.User["ui"] != nil || view.User["retrieval"] != nil {
|
||||||
|
t.Fatal("deleted preferences still read back as overrides")
|
||||||
|
}
|
||||||
|
if w := lifecycleRequest(t, s, key, "PUT", "/api/settings/project/"+p.ID+"/personal", `{"crossSession.read":true}`); w.Code != 200 {
|
||||||
|
t.Fatal(w.Code)
|
||||||
|
}
|
||||||
|
if w := lifecycleRequest(t, s, key, "PUT", "/api/settings/project/"+p.ID+"/personal", `{"crossSession.read":null}`); w.Code != 200 {
|
||||||
|
t.Fatal(w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if s.set.Effective("alice", p.ID).GetBool("crossSession.read", true) {
|
||||||
|
t.Fatal("personal project reset stayed active")
|
||||||
|
}
|
||||||
|
for _, body := range []string{`{"ui":null}`, `{"unknown":null}`, `{"models.chat.apiKey":null}`, `{"deliverables.style":null}`, `{"_policy.managedModels":null}`} {
|
||||||
|
if w := lifecycleRequest(t, s, key, "PUT", "/api/settings/user", body); w.Code != 400 {
|
||||||
|
t.Fatal("invalid personal reset accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
core "agent-system-core"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MigrationEntry deliberately includes no values, masks, or value digests. Raw
|
||||||
|
// recovery data stays in the existing protected DB rows/snapshots.
|
||||||
|
type MigrationEntry struct {
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
LastSnapshotAt string `json:"lastSnapshotAt,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MigrationInventory struct {
|
||||||
|
DryRun bool `json:"dryRun"`
|
||||||
|
Entries []MigrationEntry `json:"entries"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MigrationInventory is read-only. Settings rows have no trustworthy per-key
|
||||||
|
// modification timestamps, so snapshot time is only a recovery reference. It
|
||||||
|
// never guesses the newest owner or promotes a personal credential/configuration.
|
||||||
|
func (s *Service) MigrationInventory() (MigrationInventory, error) {
|
||||||
|
out := MigrationInventory{DryRun: true, Entries: []MigrationEntry{}}
|
||||||
|
refs, err := s.st.ListSettingsScopes()
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
type candidate struct {
|
||||||
|
index int
|
||||||
|
value any
|
||||||
|
}
|
||||||
|
groups := map[string][]candidate{}
|
||||||
|
for _, ref := range refs {
|
||||||
|
if ref.Owner == OwnerSystemRow || (ref.Scope != ScopeUser && !strings.HasPrefix(ref.Scope, ScopeProjectPrefix)) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
values, err := s.st.GetSettingsScope(ref.Owner, ref.Scope)
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
values = normalizeKeys(values)
|
||||||
|
stamp := ""
|
||||||
|
if at, ok := s.st.LatestSnapshotAt(ref.Owner, ref.Scope); ok {
|
||||||
|
stamp = at.UTC().Format("2006-01-02T15:04:05.999999999Z07:00")
|
||||||
|
}
|
||||||
|
for _, d := range s.Descriptors() {
|
||||||
|
value, present := valueAtPath(values, d.Key)
|
||||||
|
if !present {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
entry := MigrationEntry{Owner: ref.Owner, Scope: ref.Scope, Path: d.Key, LastSnapshotAt: stamp}
|
||||||
|
if d.Scope == core.SettingScopeSystem {
|
||||||
|
entry.Status = "quarantined"
|
||||||
|
entry.Reason = "系统受管旧覆盖已退出生效链,原值保留;禁止自动提升为系统配置"
|
||||||
|
} else if d.Scope == ProjectSettingScope && strings.HasPrefix(ref.Scope, ScopeProjectPrefix) {
|
||||||
|
entry.Status = "requires-decision"
|
||||||
|
entry.Reason = "旧项目业务副本保留;按键选择权威来源后才能迁移,快照时间不等于修改时间"
|
||||||
|
group := ref.Scope + ":" + d.Key
|
||||||
|
groups[group] = append(groups[group], candidate{len(out.Entries), value})
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out.Entries = append(out.Entries, entry)
|
||||||
|
}
|
||||||
|
known := map[string]bool{}
|
||||||
|
for _, d := range s.Descriptors() {
|
||||||
|
known[d.Key] = true
|
||||||
|
}
|
||||||
|
for _, path := range core.TouchesManagedSettings(values, s.ManagedSettingPaths()) {
|
||||||
|
if !known[path] {
|
||||||
|
out.Entries = append(out.Entries, MigrationEntry{Owner: ref.Owner, Scope: ref.Scope, Path: path, Status: "quarantined", Reason: "系统受管命名空间的旧未知项已退出生效链,原值保留", LastSnapshotAt: stamp})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, group := range groups {
|
||||||
|
if len(group) < 2 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, item := range group[1:] {
|
||||||
|
if !reflect.DeepEqual(group[0].value, item.value) {
|
||||||
|
for _, candidate := range group {
|
||||||
|
out.Entries[candidate.index].Status = "conflict"
|
||||||
|
out.Entries[candidate.index].Reason = "同项目同键的多 owner 副本值不同;必须由项目负责人逐键裁定,禁止自动合并"
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
secrets, err := s.st.SecretInventory()
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
for _, entry := range secrets {
|
||||||
|
if entry.Owner != "" && ProtectedSecretService(entry.Service) {
|
||||||
|
out.Entries = append(out.Entries, MigrationEntry{Owner: entry.Owner, Scope: "secret_keys", Path: entry.Service, Status: "quarantined", Reason: fmt.Sprintf("%d 条个人密钥已退出生效链,原记录保留;系统凭据须单独配置", entry.Count)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Slice(out.Entries, func(i, j int) bool {
|
||||||
|
a, b := out.Entries[i], out.Entries[j]
|
||||||
|
return a.Scope+"\x00"+a.Path+"\x00"+a.Owner < b.Scope+"\x00"+b.Path+"\x00"+b.Owner
|
||||||
|
})
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"reflect"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"agent-system-backend/internal/store"
|
||||||
|
core "agent-system-core"
|
||||||
|
)
|
||||||
|
|
||||||
|
const observationPrefix = "migration-observation:"
|
||||||
|
|
||||||
|
type MigrationChoice struct {
|
||||||
|
Path string `json:"path"`
|
||||||
|
SourceOwner string `json:"sourceOwner"`
|
||||||
|
}
|
||||||
|
type MigrationPreview struct {
|
||||||
|
Observation string `json:"observation"`
|
||||||
|
ProjectRevision int64 `json:"projectRevision"`
|
||||||
|
Conflicts []string `json:"conflicts"`
|
||||||
|
}
|
||||||
|
type MigrationApplyResult struct {
|
||||||
|
ProjectRevision int64 `json:"projectRevision"`
|
||||||
|
Replayed bool `json:"replayed"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) migrationChoices(choices []MigrationChoice) error {
|
||||||
|
if len(choices) == 0 || len(choices) > 100 {
|
||||||
|
return &ErrInvalidSetting{Path: "choices", Reason: "请选择 1 到 100 个明确来源"}
|
||||||
|
}
|
||||||
|
descriptors := map[string]core.SettingDesc{}
|
||||||
|
for _, d := range s.SchemaFor(ProjectSettingScope) {
|
||||||
|
descriptors[d.Key] = d
|
||||||
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, choice := range choices {
|
||||||
|
d, ok := descriptors[choice.Path]
|
||||||
|
leaf := choice.Path[strings.LastIndex(choice.Path, ".")+1:]
|
||||||
|
if !ok || choice.SourceOwner == "" || seen[choice.Path] {
|
||||||
|
return &ErrInvalidSetting{Path: choice.Path, Reason: "未知、重复或缺少来源的项目业务项"}
|
||||||
|
}
|
||||||
|
if d.Secret || d.Type == core.SettingSecret || isSecretKey(leaf) {
|
||||||
|
return &ErrInvalidSetting{Path: choice.Path, Reason: "不迁移个人密钥;请明确配置新的共享凭据"}
|
||||||
|
}
|
||||||
|
seen[choice.Path] = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrationSources projects business values only; an observation never copies
|
||||||
|
// another actor's personal preferences or credentials into its recovery record.
|
||||||
|
func (s *Service) migrationSources(rows map[store.SettingsScopeRef]map[string]any, projectID string) map[string]any {
|
||||||
|
sources := map[string]any{}
|
||||||
|
for ref, row := range rows {
|
||||||
|
if ref.Owner != "" && ref.Scope == ScopeProjectPrefix+projectID {
|
||||||
|
values := s.ProjectValues(row, ProjectSettingScope)
|
||||||
|
for _, d := range s.SchemaFor(ProjectSettingScope) {
|
||||||
|
if d.Secret || d.Type == core.SettingSecret || isSecretKey(d.Key[strings.LastIndex(d.Key, ".")+1:]) {
|
||||||
|
deletePath(values, d.Key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sources[ref.Owner] = values
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sources
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) chosenMigrationValues(sources map[string]any, choices []MigrationChoice) (map[string]any, error) {
|
||||||
|
values := map[string]any{}
|
||||||
|
for _, choice := range choices {
|
||||||
|
row, _ := sources[choice.SourceOwner].(map[string]any)
|
||||||
|
value, ok := valueAtPath(row, choice.Path)
|
||||||
|
if !ok {
|
||||||
|
return nil, &ErrInvalidSetting{Path: choice.Path, Reason: "观察的来源中缺少有效值"}
|
||||||
|
}
|
||||||
|
values = core.MergeSettings(values, expandDotted(choice.Path, value))
|
||||||
|
}
|
||||||
|
return values, s.ValidateWrite(values, ProjectSettingScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
func migrationConflicts(sources map[string]any, choices []MigrationChoice) []string {
|
||||||
|
out := []string{}
|
||||||
|
for _, choice := range choices {
|
||||||
|
var first any
|
||||||
|
seen := false
|
||||||
|
for _, row := range sources {
|
||||||
|
values, _ := row.(map[string]any)
|
||||||
|
value, ok := valueAtPath(values, choice.Path)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !seen {
|
||||||
|
first = value
|
||||||
|
seen = true
|
||||||
|
} else if !reflect.DeepEqual(first, value) {
|
||||||
|
out = append(out, choice.Path)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) PreviewProjectMigration(actor, projectID string, choices []MigrationChoice, expectedRevision int64) (MigrationPreview, error) {
|
||||||
|
result := MigrationPreview{Conflicts: []string{}}
|
||||||
|
if actor == "" || projectID == "" {
|
||||||
|
return result, &ErrInvalidSetting{Path: "project", Reason: "需要受信项目和身份"}
|
||||||
|
}
|
||||||
|
if err := s.migrationChoices(choices); err != nil {
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
target := store.SettingsScopeRef{Owner: OwnerSystemRow, Scope: ScopeSharedProjectPrefix + projectID}
|
||||||
|
observation := store.SettingsScopeRef{Owner: OwnerSystemRow, Scope: observationPrefix + store.NewID("obs")}
|
||||||
|
err := s.st.UpdateSettingsScopes([]store.SettingsScopeRef{target, observation}, ScopeProjectPrefix+projectID, func(rows map[store.SettingsScopeRef]map[string]any) (map[store.SettingsScopeRef]map[string]any, error) {
|
||||||
|
revision := sharedRevision(rows[target])
|
||||||
|
if revision != expectedRevision {
|
||||||
|
return nil, ErrSettingsConflict
|
||||||
|
}
|
||||||
|
sources := s.migrationSources(rows, projectID)
|
||||||
|
if _, err := s.chosenMigrationValues(sources, choices); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result.Observation = strings.TrimPrefix(observation.Scope, observationPrefix)
|
||||||
|
result.ProjectRevision = revision
|
||||||
|
result.Conflicts = migrationConflicts(sources, choices)
|
||||||
|
return map[store.SettingsScopeRef]map[string]any{observation: {"actor": actor, "projectId": projectID, "expectedRevision": revision, "choices": choices, "sources": sources, "createdAt": time.Now().UTC().Format(time.RFC3339Nano)}}, nil
|
||||||
|
})
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) MigrationSelection(actor, projectID, token string, expectedRevision int64) (map[string]any, []MigrationChoice, error) {
|
||||||
|
if !validObservationToken(token) {
|
||||||
|
return nil, nil, store.ErrNotFound
|
||||||
|
}
|
||||||
|
row, err := s.st.GetSettingsScope(OwnerSystemRow, observationPrefix+token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if row["actor"] != actor || row["projectId"] != projectID {
|
||||||
|
return nil, nil, store.ErrNotFound
|
||||||
|
}
|
||||||
|
number, _ := row["expectedRevision"].(float64)
|
||||||
|
if int64(number) != expectedRevision {
|
||||||
|
return nil, nil, ErrSettingsConflict
|
||||||
|
}
|
||||||
|
var choices []MigrationChoice
|
||||||
|
encoded, _ := json.Marshal(row["choices"])
|
||||||
|
if err := json.Unmarshal(encoded, &choices); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
if err := s.migrationChoices(choices); err != nil {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
|
sources, _ := row["sources"].(map[string]any)
|
||||||
|
values, err := s.chosenMigrationValues(sources, choices)
|
||||||
|
return values, choices, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func validObservationToken(token string) bool {
|
||||||
|
return strings.HasPrefix(token, "obs_") && len(token) <= 100 && !strings.ContainsAny(token, "/\\\x00")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) ApplyProjectMigration(actor, projectID, token string, expectedRevision int64, prevalidated map[string]any) (MigrationApplyResult, error) {
|
||||||
|
result := MigrationApplyResult{}
|
||||||
|
if actor == "" || projectID == "" || !validObservationToken(token) {
|
||||||
|
return result, store.ErrNotFound
|
||||||
|
}
|
||||||
|
target := store.SettingsScopeRef{Owner: OwnerSystemRow, Scope: ScopeSharedProjectPrefix + projectID}
|
||||||
|
observation := store.SettingsScopeRef{Owner: OwnerSystemRow, Scope: observationPrefix + token}
|
||||||
|
err := s.st.UpdateSettingsScopes([]store.SettingsScopeRef{target, observation}, ScopeProjectPrefix+projectID, func(rows map[store.SettingsScopeRef]map[string]any) (map[store.SettingsScopeRef]map[string]any, error) {
|
||||||
|
observed := rows[observation]
|
||||||
|
if observed["actor"] != actor || observed["projectId"] != projectID {
|
||||||
|
return nil, store.ErrNotFound
|
||||||
|
}
|
||||||
|
observedRevision := int64(0)
|
||||||
|
if number, ok := observed["expectedRevision"].(float64); ok {
|
||||||
|
observedRevision = int64(number)
|
||||||
|
}
|
||||||
|
if observedRevision != expectedRevision {
|
||||||
|
return nil, ErrSettingsConflict
|
||||||
|
}
|
||||||
|
if number, applied := observed["appliedRevision"].(float64); applied {
|
||||||
|
result.ProjectRevision = int64(number)
|
||||||
|
result.Replayed = true
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if sharedRevision(rows[target]) != expectedRevision {
|
||||||
|
return nil, ErrSettingsConflict
|
||||||
|
}
|
||||||
|
previous, _ := observed["sources"].(map[string]any)
|
||||||
|
current := s.migrationSources(rows, projectID)
|
||||||
|
if !reflect.DeepEqual(previous, current) {
|
||||||
|
return nil, ErrSettingsConflict
|
||||||
|
}
|
||||||
|
var choices []MigrationChoice
|
||||||
|
encoded, _ := json.Marshal(observed["choices"])
|
||||||
|
if err := json.Unmarshal(encoded, &choices); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := s.migrationChoices(choices); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
selected, err := s.chosenMigrationValues(current, choices)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if prevalidated == nil || !reflect.DeepEqual(selected, prevalidated) {
|
||||||
|
return nil, ErrSettingsConflict
|
||||||
|
}
|
||||||
|
row := rows[target]
|
||||||
|
existing, _ := row["values"].(map[string]any)
|
||||||
|
next := core.MergeSettings(s.ProjectValues(existing, ProjectSettingScope), selected)
|
||||||
|
revision := sharedRevision(row)
|
||||||
|
if !reflect.DeepEqual(existing, next) {
|
||||||
|
revision++
|
||||||
|
}
|
||||||
|
result.ProjectRevision = revision
|
||||||
|
completed := core.MergeSettings(observed, map[string]any{"appliedRevision": revision, "appliedAt": time.Now().UTC().Format(time.RFC3339Nano)})
|
||||||
|
return map[store.SettingsScopeRef]map[string]any{target: {"revision": revision, "values": next, "updatedAt": time.Now().UTC().Format(time.RFC3339Nano)}, observation: completed}, nil
|
||||||
|
})
|
||||||
|
return result, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"agent-system-backend/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMigrationApplyRollbackAndIdempotentRetry(t *testing.T) {
|
||||||
|
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "migration.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { st.Close() })
|
||||||
|
s := NewService(st)
|
||||||
|
if err := st.MergeSettingsScope("alice", ScopeProjectPrefix+"p1", map[string]any{"deliverables.style": "formal", "ui.theme": "light"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
preview, err := s.PreviewProjectMigration("alice", "p1", []MigrationChoice{{Path: "deliverables.style", SourceOwner: "alice"}}, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
selected, _, err := s.MigrationSelection("alice", "p1", preview.Observation, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Observation is sorted before target, so failure of this later write must
|
||||||
|
// roll back the previously attempted observation completion as well.
|
||||||
|
if _, err := st.DB().Exec(`CREATE TRIGGER reject_migration BEFORE INSERT ON settings WHEN NEW.scope='shared-project:p1' BEGIN SELECT RAISE(ABORT,'fixture migration target failure'); END`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := s.ApplyProjectMigration("alice", "p1", preview.Observation, 0, selected); err == nil {
|
||||||
|
t.Fatal("injected target failure succeeded")
|
||||||
|
}
|
||||||
|
row, _ := st.GetSettingsScope(OwnerSystemRow, observationPrefix+preview.Observation)
|
||||||
|
if _, exists := row["appliedRevision"]; exists {
|
||||||
|
t.Fatal("failed migration left completed observation")
|
||||||
|
}
|
||||||
|
_, revision, err := s.SharedProjectScope("p1")
|
||||||
|
if err != nil || revision != 0 {
|
||||||
|
t.Fatal("failed migration changed target")
|
||||||
|
}
|
||||||
|
if _, err := st.DB().Exec(`DROP TRIGGER reject_migration`); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := s.ApplyProjectMigration("alice", "p1", preview.Observation, 0, selected)
|
||||||
|
if err != nil || result.ProjectRevision != 1 || result.Replayed {
|
||||||
|
t.Fatalf("retry=%+v %v", result, err)
|
||||||
|
}
|
||||||
|
result, err = s.ApplyProjectMigration("alice", "p1", preview.Observation, 0, selected)
|
||||||
|
if err != nil || result.ProjectRevision != 1 || !result.Replayed {
|
||||||
|
t.Fatalf("idempotent replay=%+v %v", result, err)
|
||||||
|
}
|
||||||
|
original, _ := st.GetSettingsScope("alice", ScopeProjectPrefix+"p1")
|
||||||
|
if original["ui.theme"] != "light" || original["deliverables.style"] != "formal" {
|
||||||
|
t.Fatal("migration rewrote source/personal rows")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrationRejectsChangedSourceSetTargetAndPrivateCredentials(t *testing.T) {
|
||||||
|
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "migration.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { st.Close() })
|
||||||
|
s := NewService(st)
|
||||||
|
_ = st.MergeSettingsScope("alice", ScopeProjectPrefix+"p1", map[string]any{"deliverables.style": "formal"})
|
||||||
|
choices := []MigrationChoice{{Path: "deliverables.style", SourceOwner: "alice"}}
|
||||||
|
preview, err := s.PreviewProjectMigration("alice", "p1", choices, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
selected, _, _ := s.MigrationSelection("alice", "p1", preview.Observation, 0)
|
||||||
|
_ = st.MergeSettingsScope("new-owner", ScopeProjectPrefix+"p1", map[string]any{"deliverables.style": "minimal"})
|
||||||
|
if _, err := s.ApplyProjectMigration("alice", "p1", preview.Observation, 0, selected); err != ErrSettingsConflict {
|
||||||
|
t.Fatalf("changed conflict/source set=%v", err)
|
||||||
|
}
|
||||||
|
preview, err = s.PreviewProjectMigration("alice", "p1", choices, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
selected, _, _ = s.MigrationSelection("alice", "p1", preview.Observation, 0)
|
||||||
|
if _, err := s.MergeSharedProject("p1", map[string]any{"deliverables.style": "modern"}, 0); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := s.ApplyProjectMigration("alice", "p1", preview.Observation, 0, selected); err != ErrSettingsConflict {
|
||||||
|
t.Fatalf("changed target=%v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.PreviewProjectMigration("alice", "p1", []MigrationChoice{{Path: "models.chat.apiKey", SourceOwner: "other"}}, 1); err == nil {
|
||||||
|
t.Fatal("private credential source accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"agent-system-backend/internal/store"
|
||||||
|
core "agent-system-core"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPersonalMergePatchNullDeletesNonsecretLeafAndFollowsDefaults(t *testing.T) {
|
||||||
|
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "personal.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { st.Close() })
|
||||||
|
s := NewService(st)
|
||||||
|
s.SetExtraSchema([]core.SettingDesc{{Key: "plugins.example.personalNumber", Type: core.SettingNumber, Default: 3}})
|
||||||
|
if err := s.MergeUser("alice", map[string]any{"ui.theme": "light", "ui.cardflow": false, "retrieval.topK": 12, "plugins.example.personalNumber": 9}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.MergeUser("alice", map[string]any{"ui.theme": nil, "ui.cardflow": nil, "retrieval.topK": nil, "plugins.example.personalNumber": nil}); err != nil {
|
||||||
|
t.Fatalf("valid personal reset rejected: %v", err)
|
||||||
|
}
|
||||||
|
raw, err := st.GetSettingsScope("alice", ScopeUser)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, path := range []string{"ui.theme", "ui.cardflow", "retrieval.topK", "plugins.example.personalNumber"} {
|
||||||
|
if _, exists := valueAtPath(raw, path); exists {
|
||||||
|
t.Fatalf("null persisted instead of deleting %s", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
eff := s.Effective("alice", "")
|
||||||
|
if eff.GetString("ui.theme", "") != "dark" || !eff.GetBool("ui.cardflow", false) || eff.GetNumber("retrieval.topK", 0) != 5 {
|
||||||
|
t.Fatalf("personal defaults not restored: theme=%q cardflow=%v topK=%v", eff.GetString("ui.theme", ""), eff.GetBool("ui.cardflow", false), eff.GetNumber("retrieval.topK", 0))
|
||||||
|
}
|
||||||
|
s.SetExtraSchema([]core.SettingDesc{{Key: "plugins.example.personalNumber", Type: core.SettingNumber, Default: 7}})
|
||||||
|
if got := s.Effective("alice", "").GetNumber("plugins.example.personalNumber", 0); got != 7 {
|
||||||
|
t.Fatal("null reset copied old default instead of inheriting future default")
|
||||||
|
}
|
||||||
|
if err := s.MergePersonalProject("alice", "p", map[string]any{"crossSession.read": true}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.MergePersonalProject("alice", "p", map[string]any{"crossSession.read": nil}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.Effective("alice", "p").GetBool("crossSession.read", true) {
|
||||||
|
t.Fatal("personal project boolean reset not effective")
|
||||||
|
}
|
||||||
|
for _, values := range []map[string]any{{"ui": nil}, {"unknown": nil}, {"models.chat.apiKey": nil}, {"_policy.managedModels": nil}, {"deliverables.style": nil}} {
|
||||||
|
if err := s.MergeUser("alice", values); err == nil {
|
||||||
|
t.Fatal("unknown/ancestor/managed/private/wrong scope reset accepted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"agent-system-backend/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These checks require a disposable database. They never select a deployment DSN.
|
||||||
|
func isolatedSettingsPostgres(t *testing.T) *store.Store {
|
||||||
|
t.Helper()
|
||||||
|
dsn := os.Getenv("AGENT_SETTINGS_PG_TEST_DSN")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("NOT_RUN: AGENT_SETTINGS_PG_TEST_DSN requires an isolated PostgreSQL fixture")
|
||||||
|
}
|
||||||
|
if os.Getenv("AGENT_SETTINGS_PG_ISOLATED") != "1" {
|
||||||
|
t.Fatal("refusing PostgreSQL settings tests without an isolated fixture marker")
|
||||||
|
}
|
||||||
|
probe, err := sql.Open("pgx", dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("cannot open isolated PostgreSQL fixture")
|
||||||
|
}
|
||||||
|
var database string
|
||||||
|
err = probe.QueryRow("SELECT current_database()").Scan(&database)
|
||||||
|
probe.Close()
|
||||||
|
if err != nil || !strings.HasPrefix(database, "settings_") {
|
||||||
|
t.Fatal("PostgreSQL settings fixture database must have the settings_ prefix")
|
||||||
|
}
|
||||||
|
st, err := store.OpenPostgres(dsn, t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal("cannot initialize isolated PostgreSQL fixture")
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { st.Close() })
|
||||||
|
return st
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresSharedSettingsConcurrentCASAndPersonalBoundary(t *testing.T) {
|
||||||
|
first, second := isolatedSettingsPostgres(t), isolatedSettingsPostgres(t)
|
||||||
|
a, b := NewService(first), NewService(second)
|
||||||
|
project := fmt.Sprintf("pg-cas-%d", time.Now().UnixNano())
|
||||||
|
start := make(chan struct{})
|
||||||
|
results := make(chan error, 2)
|
||||||
|
var writers sync.WaitGroup
|
||||||
|
for i, service := range []*Service{a, b} {
|
||||||
|
writers.Add(1)
|
||||||
|
go func(service *Service, style string) {
|
||||||
|
defer writers.Done()
|
||||||
|
<-start
|
||||||
|
_, err := service.MergeSharedProject(project, map[string]any{"deliverables.style": style}, 0)
|
||||||
|
results <- err
|
||||||
|
}(service, []string{"formal", "minimal"}[i])
|
||||||
|
}
|
||||||
|
close(start)
|
||||||
|
writers.Wait()
|
||||||
|
close(results)
|
||||||
|
passed, conflicts := 0, 0
|
||||||
|
for err := range results {
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
passed++
|
||||||
|
case errors.Is(err, ErrSettingsConflict):
|
||||||
|
conflicts++
|
||||||
|
default:
|
||||||
|
t.Fatalf("unexpected concurrent write result: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if passed != 1 || conflicts != 1 {
|
||||||
|
t.Fatalf("CAS admitted %d writers with %d conflicts", passed, conflicts)
|
||||||
|
}
|
||||||
|
values, revision, err := b.SharedProjectScope(project)
|
||||||
|
if err != nil || revision != 1 {
|
||||||
|
t.Fatalf("second connection sees revision %d: %v", revision, err)
|
||||||
|
}
|
||||||
|
if err := first.MergeSettingsScope("alice", ScopeProjectPrefix+project, map[string]any{"context.userInstructions": "private", "models.chat.model": "legacy"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if b.Effective("bob", project).GetString("context.userInstructions", "") != "" || b.Effective("alice", project).GetString("models.chat.model", "") != "" {
|
||||||
|
t.Fatal("legacy model override or another member's preferences became effective")
|
||||||
|
}
|
||||||
|
if got := b.Effective("bob", project).GetString("deliverables.style", ""); got != a.Effective("alice", project).GetString("deliverables.style", "") || len(values) == 0 {
|
||||||
|
t.Fatal("shared project configuration diverged across actors/connections")
|
||||||
|
}
|
||||||
|
if _, err := a.MergeSharedProject(project, map[string]any{}, revision, []string{"deliverables.style"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := b.Effective("bob", project).GetString("deliverables.style", ""); got != "modern" {
|
||||||
|
t.Fatalf("removing the override did not restore the default: %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPostgresProjectMigrationRollbackAndReplay(t *testing.T) {
|
||||||
|
st := isolatedSettingsPostgres(t)
|
||||||
|
s := NewService(st)
|
||||||
|
project := fmt.Sprintf("pg-migration-%d", time.Now().UnixNano())
|
||||||
|
if err := st.MergeSettingsScope("alice", ScopeProjectPrefix+project, map[string]any{"deliverables.style": "formal", "context.userInstructions": "private"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
preview, err := s.PreviewProjectMigration("alice", project, []MigrationChoice{{Path: "deliverables.style", SourceOwner: "alice"}}, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
selected, _, err := s.MigrationSelection("alice", project, preview.Observation, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Abort after the observation has been updated, proving that both rows roll back.
|
||||||
|
function := fmt.Sprintf("settings_reject_%d", time.Now().UnixNano())
|
||||||
|
trigger := function + "_trigger"
|
||||||
|
ddl := fmt.Sprintf(`CREATE FUNCTION %s() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.scope='%s' THEN RAISE EXCEPTION 'fixture migration target failure'; END IF; RETURN NEW; END $$`, function, ScopeSharedProjectPrefix+project)
|
||||||
|
if _, err := st.DB().Exec(ddl); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
st.DB().Exec("DROP TRIGGER IF EXISTS " + trigger + " ON settings")
|
||||||
|
st.DB().Exec("DROP FUNCTION IF EXISTS " + function + "()")
|
||||||
|
})
|
||||||
|
if _, err := st.DB().Exec(fmt.Sprintf("CREATE TRIGGER %s BEFORE INSERT OR UPDATE ON settings FOR EACH ROW EXECUTE FUNCTION %s()", trigger, function)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := s.ApplyProjectMigration("alice", project, preview.Observation, 0, selected); err == nil {
|
||||||
|
t.Fatal("injected PostgreSQL target failure succeeded")
|
||||||
|
}
|
||||||
|
observation, err := st.GetSettingsScope(OwnerSystemRow, observationPrefix+preview.Observation)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, done := observation["appliedRevision"]; done {
|
||||||
|
t.Fatal("rollback retained a completed observation")
|
||||||
|
}
|
||||||
|
if _, revision, err := s.SharedProjectScope(project); err != nil || revision != 0 {
|
||||||
|
t.Fatal("failed migration changed the project target")
|
||||||
|
}
|
||||||
|
if _, err := st.DB().Exec("DROP TRIGGER " + trigger + " ON settings"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for attempt := 0; attempt < 2; attempt++ {
|
||||||
|
out, err := s.ApplyProjectMigration("alice", project, preview.Observation, 0, selected)
|
||||||
|
if err != nil || out.ProjectRevision != 1 || out.Replayed != (attempt == 1) {
|
||||||
|
t.Fatalf("apply/replay attempt %d: %+v, %v", attempt, out, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
old, err := st.GetSettingsScope("alice", ScopeProjectPrefix+project)
|
||||||
|
if err != nil || old["context.userInstructions"] != "private" || old["deliverables.style"] != "formal" {
|
||||||
|
t.Fatal("migration changed a legacy or private source row")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,10 +12,7 @@ func (s *Service) MaskedProjectMetadata(values map[string]any) map[string]any {
|
|||||||
if values == nil {
|
if values == nil {
|
||||||
return map[string]any{}
|
return map[string]any{}
|
||||||
}
|
}
|
||||||
schema := Schema()
|
schema := s.Descriptors()
|
||||||
if s != nil {
|
|
||||||
schema = append(schema, s.extraSchema...)
|
|
||||||
}
|
|
||||||
secrets := map[string]bool{}
|
secrets := map[string]bool{}
|
||||||
for _, d := range schema {
|
for _, d := range schema {
|
||||||
if d.Type == core.SettingSecret || d.Secret {
|
if d.Type == core.SettingSecret || d.Secret {
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"math"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
core "agent-system-core"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ScopeSharedProjectPrefix = "shared-project:"
|
||||||
|
PersonalProjectScope core.SettingScope = core.SettingScopeProjectPersonal
|
||||||
|
UserSettingScope core.SettingScope = core.SettingScopeUser
|
||||||
|
ProjectSettingScope core.SettingScope = core.SettingScopeProject
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
core "agent-system-core"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPluginSettingScopesAndReservedModelNamespace(t *testing.T) {
|
||||||
|
s := NewService(nil)
|
||||||
|
s.SetExtraSchema([]core.SettingDesc{
|
||||||
|
{Key: "plugins.example.shared", Type: core.SettingString, Scope: ProjectSettingScope},
|
||||||
|
{Key: "plugins.example.personal", Type: core.SettingString},
|
||||||
|
{Key: "plugins.example.system", Type: core.SettingString, Scope: core.SettingScopeSystem},
|
||||||
|
{Key: "models.custom.apiKey", Type: core.SettingSecret},
|
||||||
|
{Key: "models.chat.model", Type: core.SettingString},
|
||||||
|
})
|
||||||
|
if err := s.ValidateWrite(map[string]any{"plugins.example.shared": "team"}, ProjectSettingScope); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, scope := range []core.SettingScope{UserSettingScope, PersonalProjectScope} {
|
||||||
|
if err := s.ValidateWrite(map[string]any{"plugins.example.shared": "team"}, scope); err == nil {
|
||||||
|
t.Fatal("shared plugin accepted personal override")
|
||||||
|
}
|
||||||
|
if err := s.ValidateWrite(map[string]any{"plugins.example.personal": "mine"}, scope); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := s.ValidateWrite(map[string]any{"models.custom.apiKey": "personal"}, scope); err == nil {
|
||||||
|
t.Fatal("plugin opened the reserved managed model namespace")
|
||||||
|
}
|
||||||
|
if err := s.ValidateWrite(map[string]any{"models.chat.model": "personal"}, scope); err == nil {
|
||||||
|
t.Fatal("plugin replaced builtin authority")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPluginCannotPublishPrivateRuntimePolicySetting(t *testing.T) {
|
||||||
|
s := NewService(nil)
|
||||||
|
s.SetExtraSchema([]core.SettingDesc{{Key: "_policy.managedModels", Type: core.SettingBool, Scope: core.SettingScopeSystem}})
|
||||||
|
if err := s.ValidateWrite(map[string]any{"_policy.managedModels": false}, core.SettingScopeSystem); err == nil {
|
||||||
|
t.Fatal("private runtime policy became editable through plugin descriptor")
|
||||||
|
}
|
||||||
|
if err := s.validateUnset(map[string]any{}, []string{"_policy.managedModels"}, core.SettingScopeSystem); err == nil {
|
||||||
|
t.Fatal("private runtime policy accepted unset")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,8 +19,77 @@ var secretSpecs = []secretSpec{
|
|||||||
{"tools.tavily.apiKeys", "tools.tavily"},
|
{"tools.tavily.apiKeys", "tools.tavily"},
|
||||||
}
|
}
|
||||||
|
|
||||||
// SnapshotAndMigrateSecrets 先为每个已存 scope 留快照,再把明文密钥迁入独立表并从表单设置里清掉。
|
// ProtectedSecretService is the system-owned credential namespace. Unknown
|
||||||
// 该服务已经有密钥行时不再迁入,避免重复。掩码占位符不迁。
|
// model kinds are protected too; callers cannot evade policy by using a new key.
|
||||||
|
func ProtectedSecretService(service string) bool {
|
||||||
|
service = strings.TrimSpace(service)
|
||||||
|
return service == "models" || strings.HasPrefix(service, "models.") || service == "tools.tavily" || strings.HasPrefix(service, "tools.tavily.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// PersonalCredentialReferenceAllowed protects global credentials from arbitrary
|
||||||
|
// user-declared HTTP destinations. Environment references are process authority,
|
||||||
|
// while unrelated personal service secrets may still be used by that user.
|
||||||
|
func (s *Service) PersonalCredentialReferenceAllowed(ref string) bool {
|
||||||
|
ref = strings.TrimSpace(ref)
|
||||||
|
if strings.HasPrefix(ref, "env:") || ProtectedSecretService(ref) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, d := range s.Descriptors() {
|
||||||
|
if d.Scope == "system" && (d.Secret || d.Type == "secret") && (ref == d.Key || strings.HasPrefix(ref, d.Key+".")) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// LookupSecret resolves protected services only from system authority. A newly
|
||||||
|
// saved system form value (including explicit empty/revoked) supersedes legacy
|
||||||
|
// secret-table rows. Personal credentials remain usable for unrelated services.
|
||||||
|
func (s *Service) LookupSecret(owner, service string) (string, bool) {
|
||||||
|
if ProtectedSecretService(service) {
|
||||||
|
for _, spec := range secretSpecs {
|
||||||
|
if spec.service != service {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
system, err := s.SystemScope()
|
||||||
|
if err != nil {
|
||||||
|
return "", true
|
||||||
|
}
|
||||||
|
if value, exists := valueAtPath(normalizeKeys(system), spec.path); exists {
|
||||||
|
text, _ := value.(string)
|
||||||
|
if text == MaskPlaceholder {
|
||||||
|
return "", true
|
||||||
|
}
|
||||||
|
return text, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s.st.ActiveSecret(OwnerSystemRow, service)
|
||||||
|
}
|
||||||
|
if owner == "" {
|
||||||
|
owner = "local"
|
||||||
|
}
|
||||||
|
return s.st.ActiveSecret(owner, service)
|
||||||
|
}
|
||||||
|
|
||||||
|
func valueAtPath(values map[string]any, path string) (any, bool) {
|
||||||
|
var current any = values
|
||||||
|
for _, key := range strings.Split(path, ".") {
|
||||||
|
object, ok := current.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
current, ok = object[key]
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return current, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// SnapshotAndMigrateSecrets preserves protected snapshots and imports only an
|
||||||
|
// explicitly system-owned credential when its independent row is absent. It
|
||||||
|
// retains source values and disagreements for review; personal keys are not
|
||||||
|
// promoted. Mask placeholders are never usable credentials.
|
||||||
func (s *Service) SnapshotAndMigrateSecrets() error {
|
func (s *Service) SnapshotAndMigrateSecrets() error {
|
||||||
refs, err := s.st.ListSettingsScopes()
|
refs, err := s.st.ListSettingsScopes()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -39,6 +108,11 @@ func (s *Service) SnapshotAndMigrateSecrets() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
for _, spec := range secretSpecs {
|
for _, spec := range secretSpecs {
|
||||||
|
// Never promote a personal/project credential to shared authority.
|
||||||
|
// Keep those rows intact for the protected migration inventory.
|
||||||
|
if ref.Owner != OwnerSystemRow || ref.Scope != ScopeSystem {
|
||||||
|
continue
|
||||||
|
}
|
||||||
val, ok := lookupString(cur, spec.path)
|
val, ok := lookupString(cur, spec.path)
|
||||||
if !ok || val == MaskPlaceholder {
|
if !ok || val == MaskPlaceholder {
|
||||||
continue
|
continue
|
||||||
@@ -52,13 +126,8 @@ func (s *Service) SnapshotAndMigrateSecrets() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
path := spec.path
|
// Preserve the source, including disagreements with older table rows.
|
||||||
if err := s.st.UpdateSettingsScope(ref.Owner, ref.Scope, func(cur map[string]any) (map[string]any, error) {
|
// LookupSecret makes this explicit system value authoritative.
|
||||||
deletePath(cur, path)
|
|
||||||
return cur, nil
|
|
||||||
}); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -80,17 +149,7 @@ func (s *Service) snapshotThrottled(owner, scope string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func lookupString(m map[string]any, path string) (string, bool) {
|
func lookupString(m map[string]any, path string) (string, bool) {
|
||||||
var cur any = m
|
cur, _ := valueAtPath(m, path)
|
||||||
for _, p := range strings.Split(path, ".") {
|
|
||||||
obj, ok := cur.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
cur, ok = obj[p]
|
|
||||||
if !ok {
|
|
||||||
return "", false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s, ok := cur.(string)
|
s, ok := cur.(string)
|
||||||
if !ok || strings.TrimSpace(s) == "" {
|
if !ok || strings.TrimSpace(s) == "" {
|
||||||
return "", false
|
return "", false
|
||||||
|
|||||||
+144
-18
@@ -3,8 +3,10 @@
|
|||||||
package settings
|
package settings
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"reflect"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"agent-system-backend/internal/store"
|
"agent-system-backend/internal/store"
|
||||||
core "agent-system-core"
|
core "agent-system-core"
|
||||||
@@ -88,7 +90,7 @@ func SystemDefaults() map[string]any {
|
|||||||
|
|
||||||
// Schema 返回设置描述清单(设置页渲染依据;插件设置项由 pluginhost 追加)。
|
// Schema 返回设置描述清单(设置页渲染依据;插件设置项由 pluginhost 追加)。
|
||||||
func Schema() []core.SettingDesc {
|
func Schema() []core.SettingDesc {
|
||||||
return []core.SettingDesc{
|
out := []core.SettingDesc{
|
||||||
{Key: "models.chat.baseUrl", Group: "models", Label: "主模型 Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem, Description: "OpenAI 兼容端点,如 https://api.openai.com/v1"},
|
{Key: "models.chat.baseUrl", Group: "models", Label: "主模型 Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem, Description: "OpenAI 兼容端点,如 https://api.openai.com/v1"},
|
||||||
{Key: "models.chat.apiKey", Group: "models", Label: "主模型 API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem},
|
{Key: "models.chat.apiKey", Group: "models", Label: "主模型 API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem},
|
||||||
{Key: "models.chat.model", Group: "models", Label: "主模型名称", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Scope: core.SettingScopeSystem},
|
{Key: "models.chat.model", Group: "models", Label: "主模型名称", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Scope: core.SettingScopeSystem},
|
||||||
@@ -104,6 +106,7 @@ func Schema() []core.SettingDesc {
|
|||||||
{Key: "models.embedding.baseUrl", Group: "models", Label: "Embedding Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem, Description: "RAG/记忆检索用(向量管线远期启用)"},
|
{Key: "models.embedding.baseUrl", Group: "models", Label: "Embedding Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem, Description: "RAG/记忆检索用(向量管线远期启用)"},
|
||||||
{Key: "models.embedding.apiKey", Group: "models", Label: "Embedding API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem},
|
{Key: "models.embedding.apiKey", Group: "models", Label: "Embedding API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem},
|
||||||
{Key: "models.embedding.model", Group: "models", Label: "Embedding 模型", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem},
|
{Key: "models.embedding.model", Group: "models", Label: "Embedding 模型", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem},
|
||||||
|
{Key: "models.embedding.provider", Group: "models", Label: "Embedding 提供方式", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem},
|
||||||
{Key: "models.rerank.baseUrl", Group: "models", Label: "Reranker Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem},
|
{Key: "models.rerank.baseUrl", Group: "models", Label: "Reranker Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem},
|
||||||
{Key: "models.rerank.apiKey", Group: "models", Label: "Reranker API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem},
|
{Key: "models.rerank.apiKey", Group: "models", Label: "Reranker API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem},
|
||||||
{Key: "models.rerank.model", Group: "models", Label: "Reranker 模型", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem},
|
{Key: "models.rerank.model", Group: "models", Label: "Reranker 模型", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem},
|
||||||
@@ -124,10 +127,19 @@ func Schema() []core.SettingDesc {
|
|||||||
{Key: "runtime.composeFinal", Group: "runtime", Label: "终轮整理回复", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "用过工具的回合结束后,再用主模型重写一段最终回复。默认关闭(裁定 4:转录权威后工作模型已看到完整证据,重写只见过 160 字摘要、实测会虚构章节与承诺未做之事);异常收尾时另有兜底"},
|
{Key: "runtime.composeFinal", Group: "runtime", Label: "终轮整理回复", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "用过工具的回合结束后,再用主模型重写一段最终回复。默认关闭(裁定 4:转录权威后工作模型已看到完整证据,重写只见过 160 字摘要、实测会虚构章节与承诺未做之事);异常收尾时另有兜底"},
|
||||||
{Key: "runtime.intentSemantic", Group: "runtime", Label: "意图语义裁决(慢通道)", Type: core.SettingBool, Default: false, Effect: core.EffectRestart, Description: "规则歧义时用 embedding 语义相似度裁决。需已配置 embedding 端点。默认关闭(行为与 I1 一致)"},
|
{Key: "runtime.intentSemantic", Group: "runtime", Label: "意图语义裁决(慢通道)", Type: core.SettingBool, Default: false, Effect: core.EffectRestart, Description: "规则歧义时用 embedding 语义相似度裁决。需已配置 embedding 端点。默认关闭(行为与 I1 一致)"},
|
||||||
{Key: "runtime.intentGenerator", Group: "runtime", Label: "意图命令生成(慢通道)", Type: core.SettingBool, Default: false, Effect: core.EffectRestart, Description: "规则与语义都未命中时用轻量模型生成意图命令(幻觉 fail-closed 回退)。默认关闭(行为与 I1 一致)"},
|
{Key: "runtime.intentGenerator", Group: "runtime", Label: "意图命令生成(慢通道)", Type: core.SettingBool, Default: false, Effect: core.EffectRestart, Description: "规则与语义都未命中时用轻量模型生成意图命令(幻觉 fail-closed 回退)。默认关闭(行为与 I1 一致)"},
|
||||||
|
{Key: "runtime.exec", Group: "runtime", Label: "执行命令工具", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun},
|
||||||
|
{Key: "runtime.fsTools", Group: "runtime", Label: "文件工具", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun},
|
||||||
|
{Key: "runtime.workflowScripts", Group: "runtime", Label: "工作流脚本执行", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun},
|
||||||
|
{Key: "runtime.execBudgetGlobal", Group: "runtime", Label: "全局同时执行上限", Type: core.SettingNumber, Default: 0, Effect: core.EffectHot},
|
||||||
|
{Key: "runtime.execBudgetPerUser", Group: "runtime", Label: "每用户同时执行上限", Type: core.SettingNumber, Default: 0, Effect: core.EffectHot},
|
||||||
|
{Key: "runtime.execQueueMax", Group: "runtime", Label: "执行等待队列上限", Type: core.SettingNumber, Default: 200, Effect: core.EffectRestart},
|
||||||
|
{Key: "runtime.toolSelection", Group: "runtime", Label: "工具选择方式", Type: core.SettingString, Default: "", Effect: core.EffectNewRun},
|
||||||
{Key: "telemetry.otlpEndpoint", Group: "telemetry", Label: "OTLP 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "OTel HTTP JSON 上报端点(如 http://localhost:4318)。空 = 不导出(默认);环境变量 OTEL_EXPORTER_OTLP_ENDPOINT 作为后备"},
|
{Key: "telemetry.otlpEndpoint", Group: "telemetry", Label: "OTLP 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "OTel HTTP JSON 上报端点(如 http://localhost:4318)。空 = 不导出(默认);环境变量 OTEL_EXPORTER_OTLP_ENDPOINT 作为后备"},
|
||||||
{Key: "telemetry.serviceName", Group: "telemetry", Label: "遥测 service.name", Type: core.SettingString, Default: "agent-system-backend", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "OTLP resource 的 service.name"},
|
{Key: "telemetry.serviceName", Group: "telemetry", Label: "遥测 service.name", Type: core.SettingString, Default: "agent-system-backend", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "OTLP resource 的 service.name"},
|
||||||
{Key: "context.maxHistoryMessages", Group: "context", Label: "历史窗口条数", Type: core.SettingNumber, Default: 40, Effect: core.EffectNewRun},
|
{Key: "context.maxHistoryMessages", Group: "context", Label: "历史窗口条数", Type: core.SettingNumber, Default: 40, Effect: core.EffectNewRun},
|
||||||
{Key: "context.maxToolResultChars", Group: "context", Label: "工具结果上限(字符)", Type: core.SettingNumber, Default: 20000, Effect: core.EffectNewRun},
|
{Key: "context.maxToolResultChars", Group: "context", Label: "工具结果上限(字符)", Type: core.SettingNumber, Default: 20000, Effect: core.EffectNewRun},
|
||||||
|
{Key: "context.maxAttachmentChars", Group: "context", Label: "附件上下文上限(字符)", Type: core.SettingNumber, Default: 0, Effect: core.EffectNewRun},
|
||||||
|
{Key: "context.maxCardChars", Group: "context", Label: "卡片上下文上限(字符)", Type: core.SettingNumber, Default: 0, Effect: core.EffectNewRun},
|
||||||
{Key: "context.userInstructions", Group: "context", Label: "个人指令", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "每次对话附在上下文末尾,不改系统前缀"},
|
{Key: "context.userInstructions", Group: "context", Label: "个人指令", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "每次对话附在上下文末尾,不改系统前缀"},
|
||||||
{Key: "context.contextWindow", Group: "context", Label: "上下文窗口(估算 token)", Type: core.SettingNumber, Default: 32000, Effect: core.EffectNewRun, Description: "超出时只丢掉最早的历史。估算为 UTF-8 字节 / 3.35,并用上一请求的真实用量校准"},
|
{Key: "context.contextWindow", Group: "context", Label: "上下文窗口(估算 token)", Type: core.SettingNumber, Default: 32000, Effect: core.EffectNewRun, Description: "超出时只丢掉最早的历史。估算为 UTF-8 字节 / 3.35,并用上一请求的真实用量校准"},
|
||||||
{Key: "context.thinkReplay", Group: "context", Label: "思考内容回传", Type: core.SettingEnum, Default: "auto", Options: []string{"auto", "on", "off"}, Effect: core.EffectNewRun, Description: "交错思考模型(如 MiniMax)要求历史 assistant 消息原样带 <think> 块,丢弃会掉性能。auto=按模型名识别 MiniMax 自动开启"},
|
{Key: "context.thinkReplay", Group: "context", Label: "思考内容回传", Type: core.SettingEnum, Default: "auto", Options: []string{"auto", "on", "off"}, Effect: core.EffectNewRun, Description: "交错思考模型(如 MiniMax)要求历史 assistant 消息原样带 <think> 块,丢弃会掉性能。auto=按模型名识别 MiniMax 自动开启"},
|
||||||
@@ -139,12 +151,17 @@ func Schema() []core.SettingDesc {
|
|||||||
{Key: "tools.tavily.apiKeys", Group: "tools", Label: "Tavily API Keys(池)", Type: core.SettingSecret, Default: "", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "逗号分隔;配置后注册 tavily_search/web_search 工具(429/401/403 自动轮换下一把)"},
|
{Key: "tools.tavily.apiKeys", Group: "tools", Label: "Tavily API Keys(池)", Type: core.SettingSecret, Default: "", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "逗号分隔;配置后注册 tavily_search/web_search 工具(429/401/403 自动轮换下一把)"},
|
||||||
{Key: "tools.tavily.endpoint", Group: "tools", Label: "Tavily 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "空 = 官方 https://api.tavily.com/search;可指向自建代理"},
|
{Key: "tools.tavily.endpoint", Group: "tools", Label: "Tavily 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "空 = 官方 https://api.tavily.com/search;可指向自建代理"},
|
||||||
{Key: "tools.mcp.overrides", Group: "tools", Label: "MCP 项目覆盖", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "JSON 对象。键是服务器名,值可含 enabled 与 exposure(direct 或 deferred)"},
|
{Key: "tools.mcp.overrides", Group: "tools", Label: "MCP 项目覆盖", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "JSON 对象。键是服务器名,值可含 enabled 与 exposure(direct 或 deferred)"},
|
||||||
|
{Key: "tools.mcp.servers", Group: "tools", Label: "MCP 服务配置", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Scope: core.SettingScopeSystem},
|
||||||
{Key: "deliverables.style", Group: "deliverables", Label: "交付物默认样式", Type: core.SettingEnum, Default: "modern", Options: []string{"modern", "formal", "minimal"}, Effect: core.EffectNewRun, Description: "modern 现代专业 / formal 公文 / minimal 极简。本轮指令、上传模板或参考文档优先于这项"},
|
{Key: "deliverables.style", Group: "deliverables", Label: "交付物默认样式", Type: core.SettingEnum, Default: "modern", Options: []string{"modern", "formal", "minimal"}, Effect: core.EffectNewRun, Description: "modern 现代专业 / formal 公文 / minimal 极简。本轮指令、上传模板或参考文档优先于这项"},
|
||||||
{Key: "deliverables.accentColor", Group: "deliverables", Label: "交付物强调色", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "6 位十六进制,如 1B3A4B。空 = 使用样式自带主色"},
|
{Key: "deliverables.accentColor", Group: "deliverables", Label: "交付物强调色", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "6 位十六进制,如 1B3A4B。空 = 使用样式自带主色"},
|
||||||
{Key: "deliverables.templateArtifactId", Group: "deliverables", Label: "默认模板工件 ID", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "填写已上传模板的工件 ID。每次运行会复制到会话 inputs/_default-template"},
|
{Key: "deliverables.templateArtifactId", Group: "deliverables", Label: "默认模板工件 ID", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "填写已上传模板的工件 ID。每次运行会复制到会话 inputs/_default-template"},
|
||||||
{Key: "plugins.dir", Group: "plugins", Label: "插件目录", Type: core.SettingString, Default: "./plugins", Effect: core.EffectRestart},
|
{Key: "plugins.dir", Group: "plugins", Label: "插件目录", Type: core.SettingString, Default: "./plugins", Effect: core.EffectRestart},
|
||||||
{Key: "plugins.generative-ui.autoPresent", Group: "plugins", Label: "Generative UI 自动建议页面", Type: core.SettingBool, Default: false, Effect: core.EffectHot, Description: "开启后在任务阶段变化或材料/工件完成时建议调用 generative-ui.present(默认关)。须同时启用该插件的 Surface activation;有预算与冷却,不会因页面修订自激"},
|
{Key: "plugins.generative-ui.autoPresent", Group: "plugins", Label: "Generative UI 自动建议页面", Type: core.SettingBool, Default: false, Effect: core.EffectHot, Description: "开启后在任务阶段变化或材料/工件完成时建议调用 generative-ui.present(默认关)。须同时启用该插件的 Surface activation;有预算与冷却,不会因页面修订自激"},
|
||||||
}
|
}
|
||||||
|
for i := range out {
|
||||||
|
out[i].Scope = builtinScope(out[i])
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// Service 提供有效设置计算与分层写入。
|
// Service 提供有效设置计算与分层写入。
|
||||||
@@ -154,6 +171,7 @@ type Service struct {
|
|||||||
// extraSchema 是插件设置声明(组合根经 SetExtraSchema 注入);
|
// extraSchema 是插件设置声明(组合根经 SetExtraSchema 注入);
|
||||||
// 受管路径计算同时覆盖内置 schema 与插件声明。
|
// 受管路径计算同时覆盖内置 schema 与插件声明。
|
||||||
extraSchema []core.SettingDesc
|
extraSchema []core.SettingDesc
|
||||||
|
schemaMu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(st *store.Store) *Service { return &Service{st: st} }
|
func NewService(st *store.Store) *Service { return &Service{st: st} }
|
||||||
@@ -162,46 +180,53 @@ func NewService(st *store.Store) *Service { return &Service{st: st} }
|
|||||||
func (s *Service) SetSystemOverride(values map[string]any) { s.system = values }
|
func (s *Service) SetSystemOverride(values map[string]any) { s.system = values }
|
||||||
|
|
||||||
// SetExtraSchema 注入插件设置声明(受管标记由插件按需声明)。
|
// SetExtraSchema 注入插件设置声明(受管标记由插件按需声明)。
|
||||||
func (s *Service) SetExtraSchema(descs []core.SettingDesc) { s.extraSchema = descs }
|
func (s *Service) SetExtraSchema(descs []core.SettingDesc) {
|
||||||
|
s.schemaMu.Lock()
|
||||||
|
defer s.schemaMu.Unlock()
|
||||||
|
s.extraSchema = append([]core.SettingDesc(nil), descs...)
|
||||||
|
}
|
||||||
|
|
||||||
// ManagedSettingPaths 返回全部受管键路径(内置 schema + 插件声明)。
|
// ManagedSettingPaths 返回全部受管键路径(内置 schema + 插件声明)。
|
||||||
func (s *Service) ManagedSettingPaths() []string {
|
func (s *Service) ManagedSettingPaths() []string {
|
||||||
paths := core.ManagedPaths(Schema())
|
return append(core.ManagedPaths(s.Descriptors()), "models", "runtime", "tools.tavily", "tools.mcp")
|
||||||
if s != nil && len(s.extraSchema) > 0 {
|
|
||||||
paths = append(paths, core.ManagedPaths(s.extraSchema)...)
|
|
||||||
}
|
|
||||||
return paths
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Effective 计算覆盖链结果:defaults → system(启动注入) → system(管理员落库)
|
// Effective 计算覆盖链结果:defaults → system(启动注入) → system(管理员落库)
|
||||||
// → user(租户) → project。受管项在 user/project 层的存量覆盖被剥除,不再生效。
|
// → user(租户) → project。受管项在 user/project 层的存量覆盖被剥除,不再生效。
|
||||||
// owner 是租户身份(认证中间件注入;未启用认证 = store.OwnerDefault)。
|
// owner 是租户身份(认证中间件注入;未启用认证 = store.OwnerDefault)。
|
||||||
func (s *Service) Effective(owner, projectID string) core.EffectiveSettings {
|
func (s *Service) Effective(owner, projectID string) core.EffectiveSettings {
|
||||||
eff := core.MergeSettings(SystemDefaults(), map[string]any{})
|
eff := core.MergeSettings(s.declaredDefaults(), map[string]any{})
|
||||||
if s.system != nil {
|
if s.system != nil {
|
||||||
eff = core.MergeSettings(eff, s.system)
|
eff = core.MergeSettings(eff, s.system)
|
||||||
}
|
}
|
||||||
if sysdb, err := s.st.GetSettingsScope(OwnerSystemRow, ScopeSystem); err == nil {
|
if sysdb, err := s.st.GetSettingsScope(OwnerSystemRow, ScopeSystem); err == nil {
|
||||||
eff = core.MergeSettings(eff, sysdb)
|
eff = core.MergeSettings(eff, s.ProjectValues(sysdb, core.SettingScopeSystem))
|
||||||
}
|
}
|
||||||
managed := s.ManagedSettingPaths()
|
managed := s.ManagedSettingPaths()
|
||||||
if user, err := s.st.GetSettingsScope(owner, ScopeUser); err == nil {
|
if user, err := s.st.GetSettingsScope(owner, ScopeUser); err == nil {
|
||||||
eff = core.MergeSettings(eff, core.StripManagedSettings(normalizeKeys(user), managed))
|
eff = core.MergeSettings(eff, s.ProjectValues(core.StripManagedSettings(normalizeKeys(user), managed), UserSettingScope))
|
||||||
}
|
}
|
||||||
if projectID != "" {
|
if projectID != "" {
|
||||||
if proj, err := s.st.GetSettingsScope(owner, ScopeProjectPrefix+projectID); err == nil {
|
if proj, err := s.st.GetSettingsScope(owner, ScopeProjectPrefix+projectID); err == nil {
|
||||||
eff = core.MergeSettings(eff, core.StripManagedSettings(normalizeKeys(proj), managed))
|
eff = core.MergeSettings(eff, s.ProjectValues(core.StripManagedSettings(normalizeKeys(proj), managed), PersonalProjectScope))
|
||||||
|
}
|
||||||
|
if shared, _, err := s.SharedProjectScope(projectID); err == nil {
|
||||||
|
eff = core.MergeSettings(eff, shared)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return eff
|
// Trusted runtime policy metadata, never included in editable schema/read projections.
|
||||||
|
eff["_policy"] = map[string]any{"managedModels": true}
|
||||||
|
return core.EffectiveSettings(effectiveNumbers(eff))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) UserScope(owner string) (map[string]any, error) {
|
func (s *Service) UserScope(owner string) (map[string]any, error) {
|
||||||
return s.readScopeStripped(owner, ScopeUser)
|
m, err := s.readScopeStripped(owner, ScopeUser)
|
||||||
|
return s.ProjectValues(m, UserSettingScope), err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ProjectScope(owner, projectID string) (map[string]any, error) {
|
func (s *Service) ProjectScope(owner, projectID string) (map[string]any, error) {
|
||||||
return s.readScopeStripped(owner, ScopeProjectPrefix+projectID)
|
m, err := s.readScopeStripped(owner, ScopeProjectPrefix+projectID)
|
||||||
|
return s.ProjectValues(m, PersonalProjectScope), err
|
||||||
}
|
}
|
||||||
|
|
||||||
// readScopeStripped 读库并剥除受管路径:受管项对租户层不再生效,也不在设置页
|
// readScopeStripped 读库并剥除受管路径:受管项对租户层不再生效,也不在设置页
|
||||||
@@ -216,23 +241,113 @@ func (s *Service) readScopeStripped(owner, scope string) (map[string]any, error)
|
|||||||
|
|
||||||
// SystemScope 返回管理员落库的系统层(未脱敏;HTTP 读面自行 Masked)。
|
// SystemScope 返回管理员落库的系统层(未脱敏;HTTP 读面自行 Masked)。
|
||||||
func (s *Service) SystemScope() (map[string]any, error) {
|
func (s *Service) SystemScope() (map[string]any, error) {
|
||||||
return s.st.GetSettingsScope(OwnerSystemRow, ScopeSystem)
|
values, err := s.st.GetSettingsScope(OwnerSystemRow, ScopeSystem)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
values = normalizeKeys(values)
|
||||||
|
for _, spec := range secretSpecs {
|
||||||
|
if _, exists := valueAtPath(values, spec.path); exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if active, ok := s.st.ActiveSecret(OwnerSystemRow, spec.service); ok {
|
||||||
|
values = core.MergeSettings(values, expandDotted(spec.path, active))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) MergeUser(owner string, values map[string]any) error {
|
func (s *Service) MergeUser(owner string, values map[string]any) error {
|
||||||
|
if err := s.ValidateWrite(values, UserSettingScope); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return s.mergeScope(owner, ScopeUser, values)
|
return s.mergeScope(owner, ScopeUser, values)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) MergeProject(owner, projectID string, values map[string]any) error {
|
func (s *Service) MergeProject(owner, projectID string, values map[string]any) error {
|
||||||
|
// Internal trusted callers can merge project business values. Public APIs use
|
||||||
|
// MergeSharedProject with a required revision and live authorization.
|
||||||
|
if err := s.checkManagedWrite(prepareWrite(values)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
shared := s.ProjectValues(values, ProjectSettingScope)
|
||||||
|
if len(shared) > 0 {
|
||||||
|
if _, err := s.mergeSharedProject(projectID, shared, nil); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
personal := s.ProjectValues(values, PersonalProjectScope)
|
||||||
|
if len(personal) > 0 {
|
||||||
|
return s.MergePersonalProject(owner, projectID, personal)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) MergePersonalProject(owner, projectID string, values map[string]any) error {
|
||||||
|
if err := s.ValidateWrite(values, PersonalProjectScope); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return s.mergeScope(owner, ScopeProjectPrefix+projectID, values)
|
return s.mergeScope(owner, ScopeProjectPrefix+projectID, values)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MergeSystem 写管理员系统层(仅系统管理 API 调用;不做受管拦截——系统层
|
// MergeSystem is a trusted atomic startup merge of declared system fields.
|
||||||
// 就是受管项的权威写入面;非受管项写入形成全局默认覆盖,用户层仍可再覆盖)。
|
// Public administrators use MergeSystemAtRevision and strict versioned payloads.
|
||||||
func (s *Service) MergeSystem(values map[string]any) error {
|
func (s *Service) MergeSystem(values map[string]any) error {
|
||||||
|
if err := s.ValidateWrite(values, core.SettingScopeSystem); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return s.mergeScope(OwnerSystemRow, ScopeSystem, values)
|
return s.mergeScope(OwnerSystemRow, ScopeSystem, values)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func systemRevision(row map[string]any) int64 {
|
||||||
|
switch value := row["_revision"].(type) {
|
||||||
|
case float64:
|
||||||
|
return int64(value)
|
||||||
|
case int64:
|
||||||
|
return value
|
||||||
|
case int:
|
||||||
|
return int64(value)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SystemRevision() (int64, error) {
|
||||||
|
row, err := s.st.GetSettingsScope(OwnerSystemRow, ScopeSystem)
|
||||||
|
return systemRevision(row), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) SystemSnapshot() (map[string]any, int64, error) {
|
||||||
|
values, err := s.SystemScope()
|
||||||
|
return values, systemRevision(values), err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) MergeSystemAtRevision(values map[string]any, expectedRevision int64, unset ...[]string) (int64, error) {
|
||||||
|
if err := s.ValidateWrite(values, core.SettingScopeSystem); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
paths := optionalUnset(unset)
|
||||||
|
if err := s.validateUnset(values, paths, core.SettingScopeSystem); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
var revision int64
|
||||||
|
err := s.st.UpdateSettingsScope(OwnerSystemRow, ScopeSystem, func(current map[string]any) (map[string]any, error) {
|
||||||
|
revision = systemRevision(current)
|
||||||
|
if revision != expectedRevision {
|
||||||
|
return nil, ErrSettingsConflict
|
||||||
|
}
|
||||||
|
next := core.MergeSettings(normalizeKeys(current), prepareWrite(values))
|
||||||
|
for _, path := range paths {
|
||||||
|
deletePath(next, path)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(normalizeKeys(current), next) {
|
||||||
|
revision++
|
||||||
|
next["_revision"] = revision
|
||||||
|
}
|
||||||
|
return next, nil
|
||||||
|
})
|
||||||
|
return revision, err
|
||||||
|
}
|
||||||
|
|
||||||
// mergeScope 先把库里和本次写入的点路径键展开成嵌套,再深合并。
|
// mergeScope 先把库里和本次写入的点路径键展开成嵌套,再深合并。
|
||||||
// 租户层(user/project)触碰受管路径时整笔拒绝。掩码占位符视为未修改,不覆盖已存密钥。
|
// 租户层(user/project)触碰受管路径时整笔拒绝。掩码占位符视为未修改,不覆盖已存密钥。
|
||||||
func (s *Service) mergeScope(owner, scope string, values map[string]any) error {
|
func (s *Service) mergeScope(owner, scope string, values map[string]any) error {
|
||||||
@@ -243,7 +358,18 @@ func (s *Service) mergeScope(owner, scope string, values map[string]any) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := s.st.UpdateSettingsScope(owner, scope, func(cur map[string]any) (map[string]any, error) {
|
if err := s.st.UpdateSettingsScope(owner, scope, func(cur map[string]any) (map[string]any, error) {
|
||||||
return core.MergeSettings(normalizeKeys(cur), incoming), nil
|
next := core.MergeSettings(normalizeKeys(cur), incoming)
|
||||||
|
if scope != ScopeSystem {
|
||||||
|
for _, path := range core.FlattenSettingLeafPaths(incoming) {
|
||||||
|
if value, present := valueAtPath(incoming, path); present && value == nil {
|
||||||
|
deletePath(next, path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if scope == ScopeSystem && !reflect.DeepEqual(normalizeKeys(cur), next) {
|
||||||
|
next["_revision"] = systemRevision(cur) + 1
|
||||||
|
}
|
||||||
|
return next, nil
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -178,7 +178,7 @@ func TestMergeUserDottedKeyMaskAndNormalizeStored(t *testing.T) {
|
|||||||
t.Fatalf("mask path missing: %v", stats.MaskPaths)
|
t.Fatalf("mask path missing: %v", stats.MaskPaths)
|
||||||
}
|
}
|
||||||
eff := svc.Effective("local", "p1")
|
eff := svc.Effective("local", "p1")
|
||||||
if eff.GetString("runtime.entry", "") != "squad" {
|
if eff.GetString("runtime.entry", "") != "direct" {
|
||||||
t.Fatalf("entry = %q", eff.GetString("runtime.entry", ""))
|
t.Fatalf("entry = %q", eff.GetString("runtime.entry", ""))
|
||||||
}
|
}
|
||||||
// P2 受管:tools.tavily.apiKeys 属系统受管项,user 层存量覆盖不再生效。
|
// P2 受管:tools.tavily.apiKeys 属系统受管项,user 层存量覆盖不再生效。
|
||||||
@@ -193,10 +193,10 @@ func TestMergeUserDottedKeyMaskAndNormalizeStored(t *testing.T) {
|
|||||||
t.Fatalf("second pass = %+v err=%v", again, err)
|
t.Fatalf("second pass = %+v err=%v", again, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := svc.MergeUser("local", map[string]any{"runtime.entry": "direct"}); err != nil {
|
if err := svc.MergeSystem(map[string]any{"runtime.entry": "squad"}); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if svc.Effective("local", "").GetString("runtime.entry", "") != "direct" {
|
if svc.Effective("local", "").GetString("runtime.entry", "") != "squad" {
|
||||||
t.Fatal("dotted write did not take effect")
|
t.Fatal("dotted write did not take effect")
|
||||||
}
|
}
|
||||||
raw, err := svc.UserScope("local")
|
raw, err := svc.UserScope("local")
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
core "agent-system-core"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrSettingsConflict = errors.New("项目设置已被其他成员修改,请重新读取后保存")
|
||||||
|
|
||||||
|
// SharedProjectScope reads only the project-owned namespace. Old per-owner
|
||||||
|
// business copies are never silently promoted; migration inventory tracks them.
|
||||||
|
func (s *Service) SharedProjectScope(projectID string) (map[string]any, int64, error) {
|
||||||
|
row, err := s.st.GetSettingsScope(OwnerSystemRow, ScopeSharedProjectPrefix+projectID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
values, _ := row["values"].(map[string]any)
|
||||||
|
return s.ProjectValues(values, ProjectSettingScope), sharedRevision(row), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sharedRevision(row map[string]any) int64 {
|
||||||
|
switch value := row["revision"].(type) {
|
||||||
|
case float64:
|
||||||
|
return int64(value)
|
||||||
|
case int64:
|
||||||
|
return value
|
||||||
|
case int:
|
||||||
|
return int64(value)
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) MergeSharedProject(projectID string, values map[string]any, expectedRevision int64, unset ...[]string) (int64, error) {
|
||||||
|
if err := s.ValidateWrite(values, ProjectSettingScope); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
paths := optionalUnset(unset)
|
||||||
|
if err := s.validateUnset(values, paths, ProjectSettingScope); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return s.mergeSharedProject(projectID, prepareWrite(values), &expectedRevision, paths)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) mergeSharedProject(projectID string, values map[string]any, expectedRevision *int64, unset ...[]string) (int64, error) {
|
||||||
|
if projectID == "" {
|
||||||
|
return 0, &ErrInvalidSetting{Path: "project", Reason: "需要项目标识"}
|
||||||
|
}
|
||||||
|
var revision int64
|
||||||
|
err := s.st.UpdateSettingsScope(OwnerSystemRow, ScopeSharedProjectPrefix+projectID, func(row map[string]any) (map[string]any, error) {
|
||||||
|
revision = sharedRevision(row)
|
||||||
|
if expectedRevision != nil && revision != *expectedRevision {
|
||||||
|
return nil, ErrSettingsConflict
|
||||||
|
}
|
||||||
|
current, _ := row["values"].(map[string]any)
|
||||||
|
if current == nil {
|
||||||
|
current = map[string]any{}
|
||||||
|
}
|
||||||
|
next := core.MergeSettings(s.ProjectValues(current, ProjectSettingScope), values)
|
||||||
|
for _, path := range optionalUnset(unset) {
|
||||||
|
deletePath(next, path)
|
||||||
|
}
|
||||||
|
if reflect.DeepEqual(current, next) {
|
||||||
|
return row, nil
|
||||||
|
}
|
||||||
|
revision++
|
||||||
|
return map[string]any{"revision": revision, "values": next, "updatedAt": time.Now().UTC().Format(time.RFC3339Nano)}, nil
|
||||||
|
})
|
||||||
|
return revision, err
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"agent-system-backend/internal/store"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestProjectBusinessSettingsSharedButPersonalPreferencesPrivate(t *testing.T) {
|
||||||
|
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "shared.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { st.Close() })
|
||||||
|
s := NewService(st)
|
||||||
|
if err := s.MergeProject("alice", "p1", map[string]any{"deliverables.style": "formal"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := s.Effective("bob", "p1").GetString("deliverables.style", ""); got != "formal" {
|
||||||
|
t.Fatalf("same project's second member got %q", got)
|
||||||
|
}
|
||||||
|
if got := s.Effective("alice", "p2").GetString("deliverables.style", ""); got != "modern" {
|
||||||
|
t.Fatalf("settings crossed project: %q", got)
|
||||||
|
}
|
||||||
|
// Personal project preferences and old managed settings stay isolated.
|
||||||
|
if err := st.MergeSettingsScope("alice", ScopeProjectPrefix+"p1", map[string]any{"crossSession.read": true, "ui.theme": "light", "context.userInstructions": "private", "models.chat.model": "legacy"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if s.Effective("bob", "p1").GetBool("crossSession.read", false) || s.Effective("bob", "p1").GetString("context.userInstructions", "") != "" {
|
||||||
|
t.Fatal("personal project preferences leaked across actors")
|
||||||
|
}
|
||||||
|
if s.Effective("alice", "p1").GetString("models.chat.model", "") != "" {
|
||||||
|
t.Fatal("legacy managed model override remains effective")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProtectedCredentialsIgnoreOldPersonalRows(t *testing.T) {
|
||||||
|
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "secrets.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { st.Close() })
|
||||||
|
s := NewService(st)
|
||||||
|
for _, spec := range secretSpecs {
|
||||||
|
if _, err := st.AddSecret("alice", spec.service, "legacy", "personal-fixture"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := st.AddSecret(OwnerSystemRow, spec.service, "system", "system-fixture"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, ok := s.LookupSecret("alice", spec.service); !ok || got != "system-fixture" {
|
||||||
|
t.Fatalf("personal credential won for %s", spec.service)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := s.MergeSystem(map[string]any{"models.chat.apiKey": "rotated-fixture"}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, _ := s.LookupSecret("alice", "models.chat"); got != "rotated-fixture" {
|
||||||
|
t.Fatal("new system form lost to old secret table row")
|
||||||
|
}
|
||||||
|
if err := s.SnapshotAndMigrateSecrets(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, _ := s.LookupSecret("alice", "models.chat"); got != "rotated-fixture" {
|
||||||
|
t.Fatal("restart migration revived old credential")
|
||||||
|
}
|
||||||
|
if err := s.MergeSystem(map[string]any{"models.chat.apiKey": ""}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, _ := s.LookupSecret("alice", "models.chat"); got != "" {
|
||||||
|
t.Fatal("revoked credential revived")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
core "agent-system-core"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (s *Service) validateUnset(values map[string]any, paths []string, scope core.SettingScope) error {
|
||||||
|
descriptors := map[string]core.SettingDesc{}
|
||||||
|
for _, d := range s.Descriptors() {
|
||||||
|
descriptors[d.Key] = d
|
||||||
|
}
|
||||||
|
touched := core.FlattenSettingLeafPaths(normalizeKeys(values))
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, path := range paths {
|
||||||
|
d, known := descriptors[path]
|
||||||
|
if !known || !validSettingPath(path) || !scopeAllows(d, scope) || seen[path] {
|
||||||
|
return &ErrInvalidSetting{Path: path, Reason: "只能恢复当前范围内明确、唯一的设置叶子默认值"}
|
||||||
|
}
|
||||||
|
leaf := path[strings.LastIndex(path, ".")+1:]
|
||||||
|
if d.Secret || d.Type == core.SettingSecret || isSecretKey(leaf) {
|
||||||
|
return &ErrInvalidSetting{Path: path, Reason: "撤销密钥请明确保存空值,不能通过恢复默认回退旧凭据"}
|
||||||
|
}
|
||||||
|
for _, written := range touched {
|
||||||
|
if written == path || strings.HasPrefix(written, path+".") || strings.HasPrefix(path, written+".") {
|
||||||
|
return &ErrInvalidSetting{Path: path, Reason: "同一项不能同时保存值与恢复默认"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
seen[path] = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func optionalUnset(paths [][]string) []string {
|
||||||
|
if len(paths) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return paths[0]
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package settings
|
||||||
|
|
||||||
|
import (
|
||||||
|
"agent-system-backend/internal/store"
|
||||||
|
core "agent-system-core"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVersionedUnsetRestoresInheritanceWithoutRevivingSecret(t *testing.T) {
|
||||||
|
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "unset.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { st.Close() })
|
||||||
|
s := NewService(st)
|
||||||
|
s.SetExtraSchema([]core.SettingDesc{{Key: "plugins.example.projectPreference", Type: core.SettingString, Scope: ProjectSettingScope, Default: "first-default"}})
|
||||||
|
revision, err := s.MergeSharedProject("p", map[string]any{"plugins.example.projectPreference": "override"}, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := s.Effective("bob", "p").GetString("plugins.example.projectPreference", ""); got != "override" {
|
||||||
|
t.Fatal(got)
|
||||||
|
}
|
||||||
|
revision, err = s.MergeSharedProject("p", map[string]any{}, revision, []string{"plugins.example.projectPreference"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s.SetExtraSchema([]core.SettingDesc{{Key: "plugins.example.projectPreference", Type: core.SettingString, Scope: ProjectSettingScope, Default: "changed-default"}})
|
||||||
|
if got := s.Effective("bob", "p").GetString("plugins.example.projectPreference", ""); got != "changed-default" {
|
||||||
|
t.Fatalf("unset stored a copy of old default: %s", got)
|
||||||
|
}
|
||||||
|
for _, paths := range [][]string{{"plugins.example"}, {"_policy.managedModels"}, {"unknown"}, {"ui.theme"}} {
|
||||||
|
if _, err := s.MergeSharedProject("p", map[string]any{}, revision, paths); err == nil {
|
||||||
|
t.Fatalf("invalid unset accepted: %v", paths)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := s.MergeSharedProject("p", map[string]any{"plugins.example.projectPreference": "other"}, revision, []string{"plugins.example.projectPreference"}); err == nil {
|
||||||
|
t.Fatal("value and unset overlap accepted")
|
||||||
|
}
|
||||||
|
if _, err := s.MergeSharedProject("p", map[string]any{}, 0, []string{"plugins.example.projectPreference"}); err != ErrSettingsConflict {
|
||||||
|
t.Fatal("stale unset accepted")
|
||||||
|
}
|
||||||
|
_, _ = st.AddSecret(OwnerSystemRow, "models.chat", "Old", "old-secret-fixture")
|
||||||
|
systemRevision, _ := s.SystemRevision()
|
||||||
|
if _, err := s.MergeSystemAtRevision(map[string]any{}, systemRevision, []string{"models.chat.apiKey"}); err == nil {
|
||||||
|
t.Fatal("secret unset would revive deployment credential")
|
||||||
|
}
|
||||||
|
if _, err := s.MergeSystemAtRevision(map[string]any{"models.chat.apiKey": ""}, systemRevision); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if value, _ := s.LookupSecret("alice", "models.chat"); value != "" {
|
||||||
|
t.Fatal("explicit revoke revived old credential")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
type SecretInventoryEntry struct {
|
||||||
|
Owner string
|
||||||
|
Service string
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecretInventory is a metadata-only administration/migration read, not a
|
||||||
|
// credential export. It never selects value, suffix, or a value-derived digest.
|
||||||
|
func (s *Store) SecretInventory() ([]SecretInventoryEntry, error) {
|
||||||
|
rows, err := s.db.Query(`SELECT owner,service,COUNT(*) FROM secret_keys GROUP BY owner,service ORDER BY owner,service`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := []SecretInventoryEntry{}
|
||||||
|
for rows.Next() {
|
||||||
|
var entry SecretInventoryEntry
|
||||||
|
if err := rows.Scan(&entry.Owner, &entry.Service, &entry.Count); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, entry)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package store
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"sort"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UpdateSettingsScopes applies a migration's observation/result and target in
|
||||||
|
// one transaction. additionalScope captures the complete legacy source set;
|
||||||
|
// serializable isolation protects its predicate against concurrent legacy writes.
|
||||||
|
// Unreturned rows remain untouched, so sources can be checked without rewriting.
|
||||||
|
func (s *Store) UpdateSettingsScopes(refs []SettingsScopeRef, additionalScope string, update func(map[SettingsScopeRef]map[string]any) (map[SettingsScopeRef]map[string]any, error)) error {
|
||||||
|
tx, err := s.db.BeginTx(context.Background(), &sql.TxOptions{Isolation: sql.LevelSerializable})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer tx.Rollback()
|
||||||
|
unique := map[SettingsScopeRef]bool{}
|
||||||
|
for _, ref := range refs {
|
||||||
|
unique[ref] = true
|
||||||
|
}
|
||||||
|
if additionalScope != "" {
|
||||||
|
rows, err := tx.Query(s.q(`SELECT owner,scope FROM settings WHERE scope=? AND owner<>? ORDER BY owner`), additionalScope, "")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for rows.Next() {
|
||||||
|
var ref SettingsScopeRef
|
||||||
|
if err := rows.Scan(&ref.Owner, &ref.Scope); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
unique[ref] = true
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
}
|
||||||
|
ordered := make([]SettingsScopeRef, 0, len(unique))
|
||||||
|
for ref := range unique {
|
||||||
|
ordered = append(ordered, ref)
|
||||||
|
}
|
||||||
|
sort.Slice(ordered, func(i, j int) bool {
|
||||||
|
return ordered[i].Owner+"\x00"+ordered[i].Scope < ordered[j].Owner+"\x00"+ordered[j].Scope
|
||||||
|
})
|
||||||
|
current := map[SettingsScopeRef]map[string]any{}
|
||||||
|
for _, ref := range ordered {
|
||||||
|
if err := s.dialect.lockScope(tx, ref.Owner, ref.Scope); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var payload string
|
||||||
|
err := tx.QueryRow(s.q(`SELECT "values" FROM settings WHERE owner=? AND scope=?`), ref.Owner, ref.Scope).Scan(&payload)
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
value := map[string]any{}
|
||||||
|
if payload != "" {
|
||||||
|
value, err = unmarshalJSON[map[string]any](payload)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if value == nil {
|
||||||
|
value = map[string]any{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current[ref] = value
|
||||||
|
}
|
||||||
|
next, err := update(current)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for ref := range next {
|
||||||
|
if !unique[ref] {
|
||||||
|
return errors.New("settings transaction cannot write an unlocked scope")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, ref := range ordered {
|
||||||
|
value, write := next[ref]
|
||||||
|
if !write || marshalJSON(value) == marshalJSON(current[ref]) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(s.q(`INSERT INTO settings(owner,scope,"values") VALUES(?,?,?) ON CONFLICT(owner,scope) DO UPDATE SET "values"=excluded."values"`), ref.Owner, ref.Scope, marshalJSON(value)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
# Project settings capability (2.13.0-experimental)
|
||||||
|
|
||||||
|
This source extension is implemented locally. A published base version, updated
|
||||||
|
downstream pins, and composition verification are separate delivery gates.
|
||||||
|
|
||||||
|
The project lifecycle provider may optionally register an authorizer after its
|
||||||
|
`RegisterProjectLifecycle` call:
|
||||||
|
|
||||||
|
```go
|
||||||
|
optional, ok := registry.(interface {
|
||||||
|
RegisterProjectSettingsAuthorizer(func(context.Context, string, string, bool) error) error
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
The function arguments are trusted context, authenticated actor, stored base
|
||||||
|
project ID, and whether the operation changes shared business configuration.
|
||||||
|
Return `nil` only when the actor currently has the requested right. Errors may
|
||||||
|
provide `StatusCode() int` with 400, 403, 404, 409, or 503. The registration belongs
|
||||||
|
to the lifecycle provider; another plugin, a duplicate registration, a nil
|
||||||
|
callback, and declarative registration are rejected. `ProjectSettingsAuthorizer`
|
||||||
|
is an alias, so an older-pin provider can use the anonymous optional interface.
|
||||||
|
|
||||||
|
Membership/read authorization and configuration authority must be separate.
|
||||||
|
Conversation work/creation, resource read authorization, project ownership,
|
||||||
|
professional roles, and system administrator identity do not implicitly grant
|
||||||
|
domain configuration authority. The provider must apply current membership,
|
||||||
|
state, and permission rules even for the creator. A lifecycle provider without
|
||||||
|
this optional capability cannot use the shared settings HTTP surface. Base-only
|
||||||
|
projects without a lifecycle provider remain configurable by their owner.
|
||||||
|
|
||||||
|
## Settings ownership and HTTP shape
|
||||||
|
|
||||||
|
Descriptor scope is `system`, `user`, `project`, or `project-personal`. A legacy
|
||||||
|
blank plugin scope remains a personal user/project preference. Builtin model,
|
||||||
|
runtime, Tavily, MCP, and plugin-directory settings belong to the system. Plugins
|
||||||
|
cannot redeclare builtin keys or turn these reserved namespaces into personal
|
||||||
|
configuration. `deliverables.*` is shared project business configuration.
|
||||||
|
`crossSession.read`, theme, personal instructions, and other personal preferences
|
||||||
|
retain owner isolation. Shared settings never widen conversation, upload,
|
||||||
|
artifact, or draft authorization.
|
||||||
|
|
||||||
|
- `GET /api/settings`: user-only `schema`, `defaults`, `user`; empty `project`.
|
||||||
|
- `GET /api/settings?project=<base-project-id>`: shared `schema`, `defaults`,
|
||||||
|
`project`, `projectRevision`, `projectCanConfigure`; separate `personalSchema`,
|
||||||
|
`personalDefaults`, `personalUser`, `personalProject`; empty main `user`.
|
||||||
|
- `PUT /api/settings/project/{id}`: `{ "values": {...}, "expectedRevision": N }`.
|
||||||
|
It returns `{ "ok": true, "projectRevision": N }`. A stale revision returns
|
||||||
|
409. Unknown keys, malformed objects, wrong types/scopes, and ambiguous dotted
|
||||||
|
versus nested keys return 400 and do not change the project.
|
||||||
|
- System and project versioned writes also accept optional `unset:["exact.leaf"]`
|
||||||
|
to delete a nonsecret override and inherit future defaults. Unknown keys,
|
||||||
|
ancestors, other scopes, private policy paths, duplicate paths and value/unset
|
||||||
|
overlap are rejected atomically. Secret revocation uses an explicit empty
|
||||||
|
value; secret unset is forbidden because it could revive an older credential.
|
||||||
|
- `PUT /api/settings/project/{id}/personal`: raw personal preference map. It
|
||||||
|
requires current project settings read membership and remains owner-scoped.
|
||||||
|
- Raw personal/user merge patches accept `null` only at an exact declared
|
||||||
|
nonsecret leaf, deleting its override so future defaults are inherited. Unknown
|
||||||
|
keys, ancestors, wrong scopes, private policy paths and secret deletion are
|
||||||
|
rejected. Secret revocation remains explicit `""`; project/system deletion
|
||||||
|
uses the versioned `unset` field.
|
||||||
|
- `GET /api/settings/system`: system administrator surface, with only system
|
||||||
|
descriptors/values and `systemRevision` from the same stored snapshot. Secret
|
||||||
|
values are masked and mask writeback is unchanged.
|
||||||
|
- `PUT /api/settings/system`: strict `{ "values": {...}, "expectedRevision": N }`;
|
||||||
|
returns `{ "ok": true, "systemRevision": N }`; stale writes return 409 and old
|
||||||
|
unversioned callers get an explicit 400 migration instruction. The trusted
|
||||||
|
internal startup merge remains atomic and also increments the stored revision.
|
||||||
|
- `GET /api/plugins/settings-schema?scope=...`: the same scope projection;
|
||||||
|
system scope requires the administrator gate.
|
||||||
|
|
||||||
|
Shared rows use the existing transactional settings store with `owner=""` and
|
||||||
|
`scope="shared-project:<trusted-base-id>"`, carrying `{revision, values,
|
||||||
|
updatedAt}`. PostgreSQL uses the existing scope advisory transaction lock and
|
||||||
|
SQLite serializes its write transaction. No schema mutation or production
|
||||||
|
migration is performed by this code change. The server resolves the project from
|
||||||
|
stored metadata and rechecks authorization before persistence; submitted owner
|
||||||
|
or domain-mapping fields never select the row.
|
||||||
|
|
||||||
|
A shared template ID must resolve to an artifact in that stored project and pass
|
||||||
|
the caller's resource `model-context` authorization. It grants no template access
|
||||||
|
to other members. The runtime repeats live authorization before/after reading
|
||||||
|
each member's template content, including owner matches. Generated prior default
|
||||||
|
template copies with matching provenance are cleared for the next run. Unknown
|
||||||
|
legacy files and user-modified originals are preserved and omitted from automatic
|
||||||
|
template context; new generated copies use a free name. Private originals remain.
|
||||||
|
|
||||||
|
## Legacy recovery and migration inventory
|
||||||
|
|
||||||
|
`GET /api/settings/migration?dryRun=true` is administrator-only and read-only. It
|
||||||
|
contains owner, scope, setting path, status, reason, and optional snapshot time.
|
||||||
|
It never exports values, secret suffixes, or value digests. Status `quarantined`
|
||||||
|
means a managed old personal/project value or credential is retained in protected
|
||||||
|
storage but ignored by the effective policy. `requires-decision` means an old
|
||||||
|
project business copy awaits per-key source selection. Differing copies for the
|
||||||
|
same project and key are marked `conflict` for every source.
|
||||||
|
|
||||||
|
The settings table has no reliable per-key write timestamps. Snapshot time is a
|
||||||
|
recovery reference and must not be represented as the latest-write winner. An
|
||||||
|
operator must preserve the existing DB/credential recovery material, adjudicate
|
||||||
|
the sources with the project owner, and explicitly save chosen business values
|
||||||
|
through the authorized versioned project API. Do not promote personal model
|
||||||
|
credentials or an arbitrary owner's configuration to system/project authority.
|
||||||
|
The normal API preserves the source rows; a conflict or rejected save leaves the
|
||||||
|
shared row unchanged. Production cutover requires its own approved migration
|
||||||
|
record and validation, and is not executed by this local implementation.
|
||||||
|
|
||||||
|
Explicit migration uses existing settings rows and these bounded APIs:
|
||||||
|
|
||||||
|
- `POST /api/settings/project/{id}/migration/preview` accepts
|
||||||
|
`{choices:[{path,sourceOwner}],expectedRevision}` and returns opaque
|
||||||
|
`observation`, `projectRevision`, and conflict paths. **Preview creates a
|
||||||
|
protected observation record** bound to authenticated actor and trusted project.
|
||||||
|
Selected owners must be existing authorized project sources; an old forgeable
|
||||||
|
`project:<id>` row alone is insufficient. Values, hashes, secret suffixes, and
|
||||||
|
unrelated personal settings do not enter the reply.
|
||||||
|
- `POST /api/settings/project/{id}/migration/apply` accepts
|
||||||
|
`{observation,expectedRevision}`. It reauthorizes current configuration rights,
|
||||||
|
source membership and selected template access. Inside a serializable settings
|
||||||
|
transaction it compares the complete observed business-source set, selected
|
||||||
|
values and target revision, then writes target and completion atomically. A
|
||||||
|
later write failure rolls both back. Reusing a completed observation returns
|
||||||
|
the recorded revision with `replayed:true`; cross-project/actor reuse is denied.
|
||||||
|
- `POST /api/settings/migration/system` accepts new administrator-provided
|
||||||
|
`{values,expectedRevision}` with the same validation/revision policy as normal
|
||||||
|
system PUT. It never accepts an implicit personal credential source.
|
||||||
|
|
||||||
|
Template/source ACL callbacks run immediately before the base settings
|
||||||
|
transaction because they may read another domain database or the base store's
|
||||||
|
single SQLite connection. The transaction requires the exact prevalidated
|
||||||
|
selected values and unchanged sources/target; it does not claim cross-database
|
||||||
|
authorization atomicity. Runtime template reads repeat live resource checks.
|
||||||
|
|
||||||
|
Protected services always resolve credentials from system authority. A new system
|
||||||
|
form value, including an explicit empty revocation, supersedes an older active
|
||||||
|
system secret-table entry. Startup snapshots preserve sources; personal secrets
|
||||||
|
are never automatically promoted. Personal protected-service create/activate/
|
||||||
|
rename/delete endpoints reject new changes and omit retired credentials from
|
||||||
|
their configuration list. Legacy profile model routes remain in raw recovery
|
||||||
|
rows but are not projected or used; new personal model routes are rejected.
|
||||||
|
Personal HTTP tools also reject system credential/environment references and
|
||||||
|
resolve allowed references from personal authority, so user-controlled
|
||||||
|
destinations cannot export shared model/search credentials.
|
||||||
|
|
||||||
|
Identity seed callers can supply `AGENT_SYSTEM_DSN` to both `user` and `apikey`
|
||||||
|
CLI commands. Explicit nonempty `--dsn` takes priority, otherwise the environment
|
||||||
|
is used; embedded SQLite is selected only when both are empty. Resolution occurs
|
||||||
|
after parsing so help does not print an environment DSN as a flag default.
|
||||||
|
PostgreSQL error details are suppressed by these CLI commands to avoid credential
|
||||||
|
disclosure. Generated credential delivery remains explicitly requested and must
|
||||||
|
be captured in protected files by noninteractive callers.
|
||||||
@@ -34,7 +34,9 @@ import (
|
|||||||
// 分区组件自身的命令面 fail-closed)。
|
// 分区组件自身的命令面 fail-closed)。
|
||||||
// 2.12.0-experimental: optional ProjectHost/ProjectLifecycleRegistry and trusted
|
// 2.12.0-experimental: optional ProjectHost/ProjectLifecycleRegistry and trusted
|
||||||
// ResourceAuthzQuery.Source for per-document publication authorization.
|
// ResourceAuthzQuery.Source for per-document publication authorization.
|
||||||
const SurfaceVersion = "2.12.0-experimental"
|
// 2.13.0-experimental: optional ProjectSettingsRegistry distinguishes project
|
||||||
|
// member settings reads from business configuration authority.
|
||||||
|
const SurfaceVersion = "2.13.0-experimental"
|
||||||
|
|
||||||
// ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。
|
// ErrNotLinked 表示进程没有空白导入 link 包,Main 无法启动服务。
|
||||||
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link")
|
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link")
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package extension
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
// ProjectSettingsAuthorizer authorizes project-owned business configuration.
|
||||||
|
// actor is authenticated; projectID is resolved from trusted project metadata.
|
||||||
|
// write=false means member-visible settings, write=true means configuration
|
||||||
|
// authority. Private conversation creation/work never implies configuration.
|
||||||
|
// The alias permits a provider built against older pins to assert the optional
|
||||||
|
// anonymous interface without depending on a new named function type.
|
||||||
|
type ProjectSettingsAuthorizer = func(context.Context, string, string, bool) error
|
||||||
|
|
||||||
|
// ProjectSettingsRegistry is optional and separate from read-only resource
|
||||||
|
// authorization. Only the registered in-process project provider can own it.
|
||||||
|
type ProjectSettingsRegistry interface {
|
||||||
|
RegisterProjectSettingsAuthorizer(ProjectSettingsAuthorizer) error
|
||||||
|
}
|
||||||
@@ -43,6 +43,9 @@ ProjectHost.RenameOwnedProject
|
|||||||
ProjectLifecycle
|
ProjectLifecycle
|
||||||
ProjectLifecycleRegistry
|
ProjectLifecycleRegistry
|
||||||
ProjectLifecycleRegistry.RegisterProjectLifecycle
|
ProjectLifecycleRegistry.RegisterProjectLifecycle
|
||||||
|
ProjectSettingsAuthorizer
|
||||||
|
ProjectSettingsRegistry
|
||||||
|
ProjectSettingsRegistry.RegisterProjectSettingsAuthorizer
|
||||||
RegisterCompiled
|
RegisterCompiled
|
||||||
Registry
|
Registry
|
||||||
Registry.AddAdminZone
|
Registry.AddAdminZone
|
||||||
|
|||||||
Reference in New Issue
Block a user