Compare commits

..
Author SHA1 Message Date
weichen 6d2caa43d6 Merge current account transaction backend into settings work
Why: Main now serializes account recovery and requires maintenance CLI commands to open existing storage, deliver credentials through protected files and use file/environment DSN sources ahead of the deprecated flag.

Evidence: SELF /tmp/project-settings-backend-account-sync-full-20261014-973d52c1.json covers the combined Go suite and vet; /tmp/project-settings-backend-merge-schema-red-20261014-973d52c1.json and /tmp/project-settings-backend-merge-schema-green-20261014-973d52c1.json preserve a schema-guidance regression and its fix.

Impact: Keep the U7 transaction contract and the approved settings ownership APIs together. PostgreSQL CLI errors hide connection details while schema mismatch retains a safe migration instruction.

Refs: P2/P8, Dev #486, Backend #185
2026-10-14 04:48:54 +00:00
weichen 8e80e2618e Merge pull request 'Serialize account recovery and session admission' (#185) from feature/u7-account-tx-audit into main
ci / vet-and-test (push) Successful in 49s
2026-10-14 04:14:47 +00:00
weichen a4e33f192f Serialize account credential and session changes
ci / vet-and-test (pull_request) Successful in 49s
Why: Concurrent password changes, recovery key reissue, and session admission could accept retired credentials or omit rejected audits.

Evidence: Local Go vet and full tests passed; real PostgreSQL store suite, deterministic interleavings, and two-process contention passed with SELF receipts.

Impact: Account writes and session admission serialize per owner; stale sessions fail closed; checked rejections carry an audit row.

Refs: Agent-System-Dev #486; Agent-System-Backend #185; merge queue Issue #518
2026-10-14 04:08:14 +00:00
weichen 76e7cc7c18 Deliver API keys through exclusive private files
Why: API-key issuance shares the account maintenance no-create and credential-output contract; the old CLI printed its key and lacked --out-file.

Evidence: The new subprocess test failed before the flag existed, then passed for private output and pre-mutation collision refusal; current go vet and all Go tests pass.

Impact: apikey create accepts protected --dsn-file and --out-file, preserves stdout default, and never overwrites an existing destination.

Refs: Backend PR #185; Dev PR #486; U7 KTD14
2026-10-14 03:47:11 +00:00
weichen 571e2bd28a Verify every maintenance entry refuses an absent database
Why: The safe open requirement covers user, apikey and identity verify, including default CLI paths.

Evidence: All three paths use private missing directories; previous full Go and PostgreSQL receipts cover the implementation, with a focused recheck before push.

Impact: Test coverage only; no runtime behavior or migration changes.

Refs: Backend PR #185; Dev PR #486
2026-10-14 03:42:57 +00:00
weichen 6dbe025ec1 Integrate atomic account maintenance with current authentication
Why: Deliver U7 account transactions and audit without losing main auth_version, restricted-session, project or migration behavior.

Evidence: SELF go vet and all Go tests passed; isolated PostgreSQL store tests, 20 repeated two-process admission rounds, and real SIGTERM/SIGKILL persistence passed. Original failures remain in receipts; exact-head CI and independent Review follow.

Impact: Adds schema v31, noncreating account CLI opens, preflight credential output, same-transaction audit, and bootstrap admission guards. No live database touched.

Refs: Backend PR #185; Dev PR #486; original U7 r2 account specification; 2026-10-14 Root takeover
2026-10-14 03:42:03 +00:00
weichen 5298103e1f Simplify the settled project settings implementation
Why: Keep the completed settings flows readable without adding unused API exports or duplicate helpers.

Evidence: SELF /tmp/project-settings-simplify-green-20261014-973d52c1.json records settings tests/vet, 284 Frontend tests, typecheck, the unchanged knip baseline and diff checks. Reuse and quality passes were independent read-only agents; efficiency was reviewed inline after the host rejected another review thread.

Impact: Preserve scope rules, write guards, DTOs, errors and ordering. Reuse Core ownership constants and path lookup; remove unused exports and keep permission selection in a typed local state.

Refs: P1/P7, docs/records/2026-10-14-rdm-settings-completion-contract.md
2026-10-14 02:51:51 +00:00
weichen f4ac28b18e Verify shared settings transactions on isolated PostgreSQL (P8)
Why: SQLite coverage cannot prove PostgreSQL CAS and multi-row migration rollback.

Evidence: SELF /tmp/project-settings-postgres-business-20261014-973d52c1.json ran two real PostgreSQL business tests in a fresh local cluster and stopped that cluster afterward.

Impact: Add opt-in isolated database checks for two-connection CAS, private settings boundaries, default restoration, injected migration rollback and idempotent replay. These tests do not select deployment credentials.

Refs: P8, docs/records/2026-10-14-rdm-settings-completion-contract.md
2026-10-14 02:38:37 +00:00
weichen 7bf8efdb2f Consume the explicit settings ownership contract (P1)
Why: Keep Backend's fixed Core object aligned with the verified Dev contract.

Evidence: SELF Core and Backend receipts; combination tests follow in the Dev task.

Impact: Pin-only composition update; no release or deployment is performed.

Refs: P1, docs/records/2026-10-14-rdm-settings-completion-contract.md
2026-10-14 02:29:06 +00:00
weichen f965c5877f Enforce settings ownership and versioned shared configuration
Why: Personal project rows and credentials could override system policy and split shared business configuration by actor.

Evidence: SELF receipts /tmp/project-settings-backend-full-20261014-973d52c1.json and /tmp/rdm-settings-backend-final-green-20261014-03.json preserve full commands, object snapshots and output. PostgreSQL and current-head CI remain NOT_RUN.

Impact: Add scope-aware APIs, a fail-closed optional project configuration authorizer, CAS updates and explicit atomic migration. Preserve legacy rows and private preferences; protected secrets and runtime model routes obey system ownership.

Refs: P1/P2/P8, docs/records/2026-10-14-rdm-settings-completion-contract.md
2026-10-14 02:28:41 +00:00
weichenandCursor 9754531cc9 Merge origin/main into the account transaction branch
ci / vet-and-test (pull_request) Successful in 51s
Why: identity open and the unknown-subcommand check landed on main, and this branch still has to refuse account migration while advertising capabilities.
Evidence: go test ./internal/store ./internal/app ./cmd/agent-system ./pkg/identity passed after the merge.
Impact: identity verify uses the no-migrate opener and returns the schema version. Account open still does not migrate. v28 is registered as readable by older artifacts.
Refs: cmd/agent-system/main.go, internal/app/app.go, internal/store/store.go, internal/store/migrate_compat.go
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 15:49:42 +00:00
weichenandCursor 545a75b54e Prove a password still verifies after the database is reopened
ci / vet-and-test (pull_request) Successful in 49s
Why: a commit that only checks the password in the same process does not show the hash was durable.
Evidence: SELF go test TestPasswordSurvivesReopen.
Impact: test only.
Refs: docs/svc-deploy/u7-account-tx-audit.md
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 14:52:21 +00:00
weichenandCursor f13e8b1ab7 Commit account changes and audits in one transaction
Why: creating an account and its activation key, and changing a password, were separate commits, so a crash could leave a user without a key or a new password with a still-valid login key.
Evidence: SELF go test ./internal/store including TestConcurrentCreateOneWinner, TestPasswordChangeIsAtomic, TestOpenExistingDoesNotMigrateOrCreate, and TestBootstrapRefusesExistingBusiness. PostgreSQL cases are NOT_RUN locally.
Impact: strict insert returns ErrConflict instead of overwriting. Account CLI opens an existing database without migrating. --dsn and --password still work and warn. capabilities --json does not touch the database.
Refs: docs/svc-deploy/u7-account-tx-audit.md
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 14:49:40 +00:00
58 changed files with 5058 additions and 443 deletions
+89
View File
@@ -0,0 +1,89 @@
package main
import (
"encoding/json"
"flag"
"fmt"
"io"
"os"
)
func runCapabilities(args []string) int {
fs := flag.NewFlagSet("capabilities", flag.ExitOnError)
asJSON := fs.Bool("json", false, "以 JSON 打印能力,不打开数据库")
_ = fs.Parse(args)
caps := map[string]any{
"accountTransaction": true,
"accountAudit": true,
"accountOpsOpen": true,
"passwordStdin": true,
"dsnFile": true,
"outFile": true,
}
if !*asJSON {
fmt.Fprintln(os.Stderr, "用法:agent-system capabilities --json")
return 2
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(caps); err != nil {
fmt.Fprintln(os.Stderr, "capabilities:", err)
return 1
}
return 0
}
func resolveAccountDSN(flagDSN, dsnFile string) (string, error) {
if flagDSN != "" {
fmt.Fprintln(os.Stderr, "warning: --dsn is deprecated; use AGENT_SYSTEM_DSN or --dsn-file")
}
if dsnFile != "" {
info, err := os.Stat(dsnFile)
if err != nil {
return "", err
}
if info.Mode().Perm() != 0o600 {
return "", fmt.Errorf("dsn file %s must be mode 0600", dsnFile)
}
raw, err := os.ReadFile(dsnFile)
if err != nil {
return "", err
}
return string(trimNewline(raw)), nil
}
if env := os.Getenv("AGENT_SYSTEM_DSN"); env != "" {
return env, nil
}
return flagDSN, nil
}
func resolveAccountPassword(flagPassword string, fromStdin bool) (string, error) {
if flagPassword != "" {
fmt.Fprintln(os.Stderr, "warning: --password is deprecated; use --password-stdin")
}
if !fromStdin {
return flagPassword, nil
}
raw, err := io.ReadAll(os.Stdin)
if err != nil {
return "", err
}
return string(trimNewline(raw)), nil
}
func writeExclusiveSecret(path, body string) error {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return err
}
defer f.Close()
_, err = io.WriteString(f, body+"\n")
return err
}
func trimNewline(raw []byte) []byte {
for len(raw) > 0 && (raw[len(raw)-1] == '\n' || raw[len(raw)-1] == '\r') {
raw = raw[:len(raw)-1]
}
return raw
}
+158
View File
@@ -0,0 +1,158 @@
package main
import (
"agent-system-backend/internal/store"
"bytes"
"io"
"os"
"os/exec"
"path/filepath"
"testing"
)
func accountCLI(t *testing.T, args ...string) (int, string) {
t.Helper()
out, err := os.CreateTemp(t.TempDir(), "capture-")
if err != nil {
t.Fatal(err)
}
defer out.Close()
oldOut, oldErr := os.Stdout, os.Stderr
os.Stdout = out
os.Stderr = out
defer func() { os.Stdout = oldOut; os.Stderr = oldErr }()
rc := runUser(args)
out.Seek(0, 0)
raw, err := io.ReadAll(out)
if err != nil {
t.Fatal(err)
}
return rc, string(raw)
}
func cliStore(t *testing.T) (string, *store.Store) {
t.Helper()
t.Setenv("AGENT_SYSTEM_DSN", "")
dir := t.TempDir()
s, e := store.OpenSQLite(filepath.Join(dir, "agent-system.db"))
if e != nil {
t.Fatal(e)
}
t.Cleanup(func() { s.Close() })
if e := s.EnsureSystemIdentity("fixture", "sqlite"); e != nil {
t.Fatal(e)
}
return dir, s
}
func TestOutputFileAppliesToEveryCredentialCommand(t *testing.T) {
for _, sub := range []string{"create", "reset-password", "issue-login-key", "bootstrap-admin"} {
t.Run(sub, func(t *testing.T) {
dir, s := cliStore(t)
if sub != "create" && sub != "bootstrap-admin" {
if e := s.CreateAccount("ada", "Ada", "", false); e != nil {
t.Fatal(e)
}
}
out := filepath.Join(t.TempDir(), "secret")
rc, _ := accountCLI(t, sub, "--data", dir, "--owner", "ada", "--out-file", out)
if rc != 0 {
t.Fatalf("credential command failed: %d", rc)
}
info, e := os.Stat(out)
if e != nil {
t.Fatal("credential output file missing")
}
if info.Mode().Perm() != 0600 {
t.Fatal("credential output permissions are not private")
}
})
}
}
func TestExistingOutputAndConflictingFormatsRefuseBeforeMutation(t *testing.T) {
for _, kind := range []string{"existing", "conflicting"} {
t.Run(kind, func(t *testing.T) {
dir, s := cliStore(t)
out := filepath.Join(t.TempDir(), "secret")
args := []string{"create", "--data", dir, "--owner", "ada", "--out-file", out}
if kind == "existing" {
if e := os.WriteFile(out, []byte("existing"), 0600); e != nil {
t.Fatal(e)
}
} else {
args = append(args, "--out", filepath.Join(t.TempDir(), "json-secret"))
}
rc, _ := accountCLI(t, args...)
if rc == 0 {
t.Fatal("unsafe output selection succeeded")
}
if _, e := s.GetUser("ada"); e == nil {
t.Fatal("output refusal happened after account mutation")
}
})
}
}
func TestAPIKeyCreateUsesExclusivePrivateOutput(t *testing.T) {
if dir := os.Getenv("U7_APIKEY_CHILD_DIR"); dir != "" {
rc := runAPIKey([]string{"create", "--data", dir, "--owner", "ada", "--name", "fixture", "--out-file", os.Getenv("U7_APIKEY_CHILD_OUT")})
if rc != 0 {
t.Fatal("apikey create rejected its private output destination")
}
return
}
for _, existing := range []bool{false, true} {
name := "new"
if existing {
name = "existing"
}
t.Run(name, func(t *testing.T) {
dir, st := cliStore(t)
if err := st.CreateAccount("ada", "Ada", "", false); err != nil {
t.Fatal(err)
}
out := filepath.Join(t.TempDir(), "credential")
if existing {
if err := os.WriteFile(out, []byte("preserve"), 0600); err != nil {
t.Fatal(err)
}
}
cmd := exec.Command(os.Args[0], "-test.run=^TestAPIKeyCreateUsesExclusivePrivateOutput$")
cmd.Env = append(os.Environ(), "U7_APIKEY_CHILD_DIR="+dir, "U7_APIKEY_CHILD_OUT="+out)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
if existing {
if err == nil {
t.Fatal("existing output was overwritten or mutation succeeded")
}
raw, readErr := os.ReadFile(out)
if readErr != nil || string(raw) != "preserve" {
t.Fatal("existing output was modified")
}
keys, listErr := st.ListAPIKeys("ada")
if listErr != nil || len(keys) != 0 {
t.Fatal("mutation happened before output preflight")
}
return
}
if err != nil {
t.Fatal("new private output was rejected")
}
info, statErr := os.Stat(out)
if statErr != nil || info.Mode().Perm() != 0600 {
t.Fatal("credential output missing or not private")
}
raw, readErr := os.ReadFile(out)
if readErr != nil || len(raw) == 0 {
t.Fatal("credential output empty")
}
if bytes.Contains(stdout.Bytes(), bytes.TrimSpace(raw)) {
t.Fatal("credential was written to stdout")
}
keys, listErr := st.ListAPIKeys("ada")
if listErr != nil || len(keys) != 1 {
t.Fatal("successful issue was not committed")
}
})
}
}
+90
View File
@@ -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)
}
}
+182 -33
View File
@@ -43,9 +43,11 @@ func main() {
os.Exit(runUser(os.Args[2:]))
case "identity":
os.Exit(runIdentity(os.Args[2:]))
case "capabilities":
os.Exit(runCapabilities(os.Args[2:]))
default:
fmt.Fprintf(os.Stderr, "agent-system: 未知子命令 %s\n", os.Args[1])
fmt.Fprintln(os.Stderr, "用法:agent-system <serve|version|identity|user|apikey|config> [参数]")
fmt.Fprintln(os.Stderr, "用法:agent-system <serve|version|identity|user|apikey|config|capabilities> [参数]")
os.Exit(2)
}
}
@@ -193,28 +195,49 @@ func runAPIKey(args []string) int {
sub := args[0]
fs := flag.NewFlagSet("apikey "+sub, flag.ExitOnError)
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文件")
owner := fs.String("owner", "", "租户名(owner)")
name := fs.String("name", "default", "密钥名称")
outFile := fs.String("out-file", "", "把明文密钥独占写入该文件(0600)")
fs.Parse(args[1:])
if *owner == "" {
fmt.Fprintln(os.Stderr, "apikey: --owner 必填")
return 2
}
if sub != "create" && *outFile != "" {
fmt.Fprintln(os.Stderr, "apikey: 此子命令不生成密钥")
return 2
}
resolvedDSN, err := resolveAccountDSN(*dsn, *dsnFile)
if err != nil {
fmt.Fprintln(os.Stderr, "apikey:", err)
return 1
}
*dsn = resolvedDSN
switch sub {
case "create":
key, err := app.CreateAPIKey(*dsn, *data, *owner, *name)
delivery, err := reserveCredentialOutput(*outFile, "")
if err != nil {
fmt.Fprintln(os.Stderr, "apikey create:", err)
return 1
}
defer delivery.abort()
key, err := app.CreateAPIKey(*dsn, *data, *owner, *name)
if err != nil {
fmt.Fprintln(os.Stderr, "apikey create:", identityCommandError(*dsn, err))
return 1
}
// 明文仅此一次输出;库中只存 SHA-256 摘要。
fmt.Println(key)
if err := delivery.deliver("api-key", *owner, key, nil); err != nil {
fmt.Fprintln(os.Stderr, "apikey create:", err)
return 1
}
return 0
case "list":
keys, err := app.ListAPIKeys(*dsn, *data, *owner)
if err != nil {
fmt.Fprintln(os.Stderr, "apikey list:", err)
fmt.Fprintln(os.Stderr, "apikey list:", identityCommandError(*dsn, err))
return 1
}
out, _ := json.MarshalIndent(keys, "", " ")
@@ -223,7 +246,7 @@ func runAPIKey(args []string) int {
case "revoke":
ok, err := app.RevokeAPIKey(*dsn, *data, *owner, *name)
if err != nil {
fmt.Fprintln(os.Stderr, "apikey revoke:", err)
fmt.Fprintln(os.Stderr, "apikey revoke:", identityCommandError(*dsn, err))
return 1
}
if !ok {
@@ -238,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 只报
// 属主/用途/文件路径不含明文;缺省沿用 stdout 一次输出。文件含 expiresAt 便于交接。
func deliverCredential(outPath, kind, owner, plaintext string, expiresAt any) error {
@@ -253,13 +290,91 @@ func deliverCredential(outPath, kind, owner, plaintext string, expiresAt any) er
if err != nil {
return err
}
if err := os.WriteFile(outPath, buf, 0o600); err != nil {
if err := writeExclusiveSecret(outPath, string(buf)); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "凭据已写入 %s(0600,仅此一次生成;勿入库勿入日志)\n", outPath)
return nil
}
type credentialOutput struct {
path string
file *os.File
json bool
mutated bool
}
// Reserve the destination before changing the account. O_EXCL also rejects an
// existing symlink, so an existing secret can never be overwritten.
func reserveCredentialOutput(rawPath, jsonPath string) (*credentialOutput, error) {
if rawPath != "" && jsonPath != "" {
return nil, fmt.Errorf("--out and --out-file are mutually exclusive")
}
out := &credentialOutput{}
out.path, out.json = rawPath, false
if jsonPath != "" {
out.path, out.json = jsonPath, true
}
if out.path == "" {
return out, nil
}
f, err := os.OpenFile(out.path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
if err != nil {
return nil, err
}
out.file = f
return out, nil
}
func (out *credentialOutput) abort() {
if out == nil || out.file == nil {
return
}
info, err := out.file.Stat()
_ = out.file.Close()
if out.mutated || err != nil {
return
}
pathInfo, pathErr := os.Stat(out.path)
if pathErr == nil && os.SameFile(info, pathInfo) {
_ = os.Remove(out.path)
}
}
func (out *credentialOutput) deliver(kind, owner, plaintext string, expiresAt any) error {
if out.file == nil {
return deliverCredential("", kind, owner, plaintext, expiresAt)
}
out.mutated = true // Keep the private recovery artifact if delivery fails after DB commit.
var body []byte
if out.json {
payload := map[string]any{"kind": kind, "owner": owner, "secret": plaintext}
if expiresAt != nil {
payload["expiresAt"] = expiresAt
}
var err error
body, err = json.MarshalIndent(payload, "", " ")
if err != nil {
return err
}
} else {
body = []byte(plaintext)
}
body = append(body, '\n')
if _, err := out.file.Write(body); err != nil {
return err
}
if err := out.file.Sync(); err != nil {
return err
}
if err := out.file.Close(); err != nil {
return err
}
out.file = nil
fmt.Fprintf(os.Stderr, "凭据已写入 %s(0600,仅此一次生成;勿入库勿入日志)\n", out.path)
return nil
}
// runUser:create / reset-password / list / issue-login-key / revoke-login-key / bootstrap-admin。
// 默认 create 无初始密码并签发激活 Key;--generate-password 兼容旧随机密码路径。
func runUser(args []string) int {
@@ -270,13 +385,17 @@ func runUser(args []string) int {
sub := args[0]
fs := flag.NewFlagSet("user "+sub, flag.ExitOnError)
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 权限的 DSN 文件")
owner := fs.String("owner", "", "用户名(owner)")
name := fs.String("name", "", "显示名")
password := fs.String("password", "", "密码(create 空=激活 Key;加 --generate-password 则随机)")
password := fs.String("password", "", "密码(已弃用;请用 --password-stdin)")
passwordStdin := fs.Bool("password-stdin", false, "从 stdin 读取密码")
outFile := fs.String("out-file", "", "把明文凭据独占写入该文件(0600)")
instanceID := fs.String("instance-id", "", "严格核验库内服务身份")
admin := fs.Bool("admin", false, "管理员")
role := fs.String("role", "user", "角色 user|admin")
openExisting := fs.Bool("open-existing", false, "只打开既有存储")
openExisting := fs.Bool("open-existing", false, "兼容旧参数;账户命令始终只打开既有存储")
generatePassword := fs.Bool("generate-password", false, "create 时生成随机初始密码")
purpose := fs.String("purpose", "recover", "issue-login-key:activate|recover")
ttlMin := fs.Int("ttl-minutes", 30, "登录 Key 有效分钟(5–120)")
@@ -284,6 +403,35 @@ func runUser(args []string) int {
keyID := fs.String("id", "", "revoke-login-key 凭据编号")
out := fs.String("out", "", "凭据交付文件(0600 JSON,代替 stdout 明文;非交互推荐)")
fs.Parse(args[1:])
resolvedDSN, err := resolveAccountDSN(*dsn, *dsnFile)
if err != nil {
fmt.Fprintln(os.Stderr, "user:", err)
return 1
}
resolvedPassword, err := resolveAccountPassword(*password, *passwordStdin)
if err != nil {
fmt.Fprintln(os.Stderr, "user:", err)
return 1
}
if *instanceID != "" {
_ = os.Setenv("AGENT_SYSTEM_INSTANCE_ID", *instanceID)
}
*dsn = resolvedDSN
*password = resolvedPassword
credentialAction := sub == "create" || sub == "reset-password" || sub == "issue-login-key" || sub == "bootstrap-admin"
if !credentialAction && (*outFile != "" || *out != "") {
fmt.Fprintln(os.Stderr, "user: 此子命令不生成凭据")
return 2
}
var delivery *credentialOutput
if credentialAction {
delivery, err = reserveCredentialOutput(*outFile, *out)
if err != nil {
fmt.Fprintln(os.Stderr, "user:", err)
return 2
}
defer delivery.abort()
}
switch sub {
case "create":
if *owner == "" {
@@ -299,16 +447,17 @@ func runUser(args []string) int {
fmt.Fprintf(os.Stderr, "user create: 未知角色 %q\n", *role)
return 2
}
pw, 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 {
fmt.Fprintln(os.Stderr, "user create:", identityCommandError(*dsn, err))
return 1
}
if err := delivery.deliver("login-key", *owner, pw, nil); err != nil {
fmt.Fprintln(os.Stderr, "user create:", err)
return 1
}
if err := deliverCredential(*out, "login-key", *owner, pw, nil); err != nil {
fmt.Fprintln(os.Stderr, "user create:", err)
return 1
}
if *out == "" {
fmt.Fprintf(os.Stderr, "operation_id=%s\n", opID)
if *out == "" && *outFile == "" {
if *password == "" && !*generatePassword {
fmt.Fprintln(os.Stderr, "一次性激活 Key 仅此一次输出;兑换后设置密码。")
} else if *password == "" {
@@ -323,21 +472,21 @@ func runUser(args []string) int {
}
pw, err := app.ResetUserPassword(*dsn, *data, *owner, *password, *openExisting)
if err != nil {
fmt.Fprintln(os.Stderr, "user reset-password:", identityCommandError(*dsn, err))
return 1
}
if err := delivery.deliver("password", *owner, pw, nil); err != nil {
fmt.Fprintln(os.Stderr, "user reset-password:", err)
return 1
}
if err := deliverCredential(*out, "password", *owner, pw, nil); err != nil {
fmt.Fprintln(os.Stderr, "user reset-password:", err)
return 1
}
if *out == "" && *password == "" {
if *out == "" && *outFile == "" && *password == "" {
fmt.Fprintln(os.Stderr, "新密码仅此一次输出;该用户下次登录须修改密码(其余会话已吊销)。")
}
return 0
case "list":
users, err := app.ListUserAccounts(*dsn, *data, *openExisting)
if err != nil {
fmt.Fprintln(os.Stderr, "user list:", err)
fmt.Fprintln(os.Stderr, "user list:", identityCommandError(*dsn, err))
return 1
}
out, _ := json.MarshalIndent(users, "", " ")
@@ -354,14 +503,14 @@ func runUser(args []string) int {
}
plain, err := app.IssueUserLoginKey(*dsn, *data, *owner, *purpose, *openExisting, *revokeAll, time.Duration(*ttlMin)*time.Minute)
if err != nil {
fmt.Fprintln(os.Stderr, "user issue-login-key:", identityCommandError(*dsn, err))
return 1
}
if err := delivery.deliver("login-key", *owner, plain, time.Now().Add(time.Duration(*ttlMin)*time.Minute).UTC()); err != nil {
fmt.Fprintln(os.Stderr, "user issue-login-key:", err)
return 1
}
if err := deliverCredential(*out, "login-key", *owner, plain, time.Now().Add(time.Duration(*ttlMin)*time.Minute).UTC()); err != nil {
fmt.Fprintln(os.Stderr, "user issue-login-key:", err)
return 1
}
if *out == "" {
if *out == "" && *outFile == "" {
fmt.Fprintln(os.Stderr, "一次性登录 Key 仅此一次输出。")
}
return 0
@@ -371,7 +520,7 @@ func runUser(args []string) int {
return 2
}
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
}
fmt.Println("revoked")
@@ -383,14 +532,14 @@ func runUser(args []string) int {
}
plain, err := app.BootstrapAdmin(*dsn, *data, *owner, *openExisting)
if err != nil {
fmt.Fprintln(os.Stderr, "user bootstrap-admin:", identityCommandError(*dsn, err))
return 1
}
if err := delivery.deliver("bootstrap-admin", *owner, plain, time.Now().Add(30*time.Minute).UTC()); err != nil {
fmt.Fprintln(os.Stderr, "user bootstrap-admin:", err)
return 1
}
if err := deliverCredential(*out, "bootstrap-admin", *owner, plain, time.Now().Add(30*time.Minute).UTC()); err != nil {
fmt.Fprintln(os.Stderr, "user bootstrap-admin:", err)
return 1
}
if *out == "" {
if *out == "" && *outFile == "" {
fmt.Fprintln(os.Stderr, "首位管理员激活 Key 仅此一次输出。")
}
return 0
+58
View File
@@ -0,0 +1,58 @@
package app
import (
"agent-system-backend/internal/store"
"os"
"path/filepath"
"testing"
)
func TestAccountMaintenanceDefaultsToNonCreatingOpen(t *testing.T) {
t.Setenv("AGENT_SYSTEM_DSN", "")
for _, tc := range []struct {
name string
run func(string) error
}{
{"user", func(dir string) error {
_, _, err := CreateUserAccount("", dir, "ada", "Ada", "", false, false, false)
return err
}},
{"apikey", func(dir string) error {
_, err := CreateAPIKey("", dir, "ada", "test")
return err
}},
{"identity verify", func(dir string) error {
_, err := VerifySystemIdentity("", dir, "fixture", "sqlite")
return err
}},
} {
t.Run(tc.name, func(t *testing.T) {
dir := filepath.Join(t.TempDir(), "absent")
if err := tc.run(dir); err == nil {
t.Error("maintenance created a missing database")
}
if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Error("failed maintenance created a directory")
}
})
}
}
func TestAccountMaintenanceCannotImplicitlyMigrate(t *testing.T) {
t.Setenv("AGENT_SYSTEM_DSN", "")
dir := t.TempDir()
st, err := store.OpenSQLite(filepath.Join(dir, "agent-system.db"))
if err != nil {
t.Fatal(err)
}
if err := st.EnsureSystemIdentity("test", "sqlite"); err != nil {
t.Fatal(err)
}
// Roll back the newest additive migration in this private fixture only.
if err := st.MigrateDown(store.SchemaTarget() - 1); err != nil {
t.Fatal(err)
}
st.Close()
if _, _, err := CreateUserAccount("", dir, "ada", "Ada", "", false, false, false); err == nil {
t.Error("account command implicitly migrated an older schema")
}
}
+84 -80
View File
@@ -292,17 +292,7 @@ func Run(opt Options) error {
if err := setSvc.SnapshotAndMigrateSecrets(); err != nil {
return fmt.Errorf("migrate secrets: %w", err)
}
runtime.SecretLookup = func(owner, service string) (string, bool) {
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)
}
runtime.SecretLookup = setSvc.LookupSecret
rag.SecretLookup = func(service, fallback string) string {
return runtime.LookupSecret(store.OwnerDefault, service, fallback)
}
@@ -312,7 +302,7 @@ func Run(opt Options) error {
if err := tools.RegisterLazySearchTools(reg, func() agenttools.SearchProvider {
effNow := setSvc.Effective(store.OwnerDefault, "")
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
}
keys := splitKeyPool(tavilyNow)
@@ -381,7 +371,7 @@ func Run(opt Options) error {
},
}
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
}
if keys := splitKeyPool(tavilyNow); len(keys) > 0 {
@@ -658,6 +648,10 @@ func Run(opt Options) error {
// EXT-8b:资源级适配器注入读面(未注册=server 回落 V1,行为不变)。
if authzSlot.hasV2() {
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:宿主可关闭上传自动索引(发布决定移交下游命令层)。
srv.DisableUploadIndexing = opt.DisableUploadIndexing
@@ -741,8 +735,8 @@ func Run(opt Options) error {
// CreateUserAccount CLI 建号。
// password 空且 generatePassword=false:无初始密码,同事务签发 activate Key,返回明文 Key。
// generatePassword=true 或显式 password:兼容旧路径(随机/给定密码 + must_change)。
func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmin, openExisting, generatePassword bool) (string, error) {
var out string
func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmin, openExisting, generatePassword bool) (string, string, error) {
var out, opID string
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
if password != "" || generatePassword {
pw := password
@@ -753,23 +747,29 @@ func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmi
if err != nil {
return err
}
if err := st.CreateAccount(owner, name, hash, isAdmin); err != nil {
outc, err := st.CreateActivatedAccount(store.AccountWrite{
Owner: owner, Name: name, PasswordHash: hash, IsAdmin: isAdmin, MustChange: true,
Actor: cliActor(), InstanceID: accountInstanceID(), Action: "account.create",
})
if err != nil {
return err
}
out = pw
opID = outc.OperationID
return nil
}
if err := st.CreateAccount(owner, name, "", isAdmin); err != nil {
return err
}
plain, _, err := st.IssueLoginKey(owner, store.LoginKeyPurposeActivate, "cli", 0)
outc, err := st.CreateActivatedAccount(store.AccountWrite{
Owner: owner, Name: name, IsAdmin: isAdmin, Purpose: store.LoginKeyPurposeActivate, CreatedBy: "cli",
Actor: cliActor(), InstanceID: accountInstanceID(), Action: "account.create",
})
if err != nil {
return err
}
out = plain
out = outc.Plain
opID = outc.OperationID
return nil
})
return out, err
return out, opID, err
}
// IssueUserLoginKey CLI 签发激活/恢复 Key。
@@ -777,10 +777,12 @@ func IssueUserLoginKey(storageDSN, dataDir, owner, purpose string, openExisting,
var plain string
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
if revokeAllCreds {
if err := st.RevokeAllAPIKeys(owner); err != nil {
return err
}
st.DeleteUserSessions(owner, "")
outc, err := st.RevokeAllAndIssue(store.AccountWrite{
Owner: owner, Purpose: purpose, CreatedBy: "cli", TTL: ttl,
Actor: cliActor(), InstanceID: accountInstanceID(),
})
plain = outc.Plain
return err
}
p, _, err := st.IssueLoginKey(owner, purpose, "cli", ttl)
plain = p
@@ -801,8 +803,10 @@ func RevokeUserLoginKey(storageDSN, dataDir, id string, openExisting bool) error
func BootstrapAdmin(storageDSN, dataDir, owner string, openExisting bool) (string, error) {
var plain string
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
p, err := st.BootstrapFirstAdmin(owner, owner, "cli-bootstrap")
plain = p
outc, err := st.BootstrapFirstAdminWrite(store.AccountWrite{
Owner: owner, Name: owner, Actor: cliActor(), InstanceID: accountInstanceID(),
})
plain = outc.Plain
return err
})
return plain, err
@@ -819,7 +823,7 @@ func ResetUserPassword(storageDSN, dataDir, owner, password string, openExisting
return "", err
}
if err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
return st.AdminResetUserPassword(owner, hash, "cli", "cli")
return st.AdminResetUserPassword(owner, hash, cliActor(), "cli")
}); err != nil {
return "", err
}
@@ -851,83 +855,75 @@ func withIdentityStore(storageDSN, dataDir string, fn func(*store.Store) error)
return fn(st)
}
func withStoreMode(storageDSN, dataDir string, openExisting bool, fn func(*store.Store) error) error {
func withStoreMode(storageDSN, dataDir string, _ bool, fn func(*store.Store) error) error {
var st *store.Store
var err error
// DSN 等价安全输入(KTD14):argv 缺省时回落环境变量——凭据不落 shell history。
if storageDSN == "" {
storageDSN = os.Getenv("AGENT_SYSTEM_DSN")
}
// Account maintenance never initializes or migrates storage; the retained
// CLI compatibility flag cannot opt this operation into service initialization.
if storageDSN != "" {
if openExisting {
st, err = store.OpenExistingPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
} else {
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
}
st, err = store.OpenExistingPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
} else {
dbPath := filepath.Join(dataDir, "agent-system.db")
if openExisting {
st, err = store.OpenExistingSQLite(dbPath)
} else {
st, err = store.OpenSQLite(dbPath)
}
st, err = store.OpenExistingSQLite(filepath.Join(dataDir, "agent-system.db"))
}
if err != nil {
return err
}
defer st.Close()
if id := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_INSTANCE_ID")); id != "" {
dbref := strings.TrimSpace(os.Getenv("AGENT_SYSTEM_DBREF"))
if dbref == "" {
if storageDSN != "" {
dbref = "pg"
} else {
dbref = "sqlite"
}
}
if err := st.VerifySystemIdentity(id, dbref); err != nil {
return err
}
}
return fn(st)
}
// CreateAPIKey 是 apikey 子命令的实现:在指定存储档上为 owner 创建密钥(明文仅打印一次)。
func CreateAPIKey(storageDSN, dataDir, owner, name string) (string, error) {
var st *store.Store
var err error
if storageDSN != "" {
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
} else {
st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db"))
}
if err != nil {
return "", err
}
defer st.Close()
return st.CreateAPIKey(owner, name)
var plain string
err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error {
p, e := st.CreateAPIKey(owner, name)
plain = p
return e
})
return plain, err
}
// ListAPIKeys 列出租户密钥(摘要视图)。
func ListAPIKeys(storageDSN, dataDir, owner string) ([]store.APIKeyInfo, error) {
var st *store.Store
var err error
if storageDSN != "" {
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
} else {
st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db"))
}
if err != nil {
return nil, err
}
defer st.Close()
if _, err := st.GetUser(owner); err != nil {
return nil, fmt.Errorf("owner %q 不存在(先 apikey create 创建)", owner)
}
return st.ListAPIKeys(owner)
var out []store.APIKeyInfo
err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error {
if _, err := st.GetUser(owner); err != nil {
return fmt.Errorf("owner %q 不存在(先 apikey create 创建)", owner)
}
var e error
out, e = st.ListAPIKeys(owner)
return e
})
return out, err
}
// RevokeAPIKey 撤销指定名称密钥。
func RevokeAPIKey(storageDSN, dataDir, owner, name string) (bool, error) {
var st *store.Store
var err error
if storageDSN != "" {
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
} else {
st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db"))
}
if err != nil {
return false, err
}
defer st.Close()
return st.RevokeAPIKeyByName(owner, name)
var ok bool
err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error {
var e error
ok, e = st.RevokeAPIKeyByName(owner, name)
return e
})
return ok, err
}
// EnsureSystemIdentity CLI:显式建立库内服务身份(svc init / I2→I3 引导)。
@@ -956,6 +952,14 @@ func VerifySystemIdentity(storageDSN, dataDir, instanceID, dbref string) (int, e
return schemaVersion, err
}
func cliActor() string {
return fmt.Sprintf("%d", os.Getuid())
}
func accountInstanceID() string {
return strings.TrimSpace(os.Getenv("AGENT_SYSTEM_INSTANCE_ID"))
}
// cachedInt 带短 TTL 的整型缓存读(限流值热读取免打库)。
func cachedInt(ttl time.Duration, fn func() int) func() int {
var mu sync.Mutex
+2
View File
@@ -15,6 +15,7 @@ type projectLifecycleSlot struct {
mu sync.Mutex
holder string
provider *extension.ProjectLifecycle
settings extension.ProjectSettingsAuthorizer
}
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 {
return inprocHost{caps: caps, owner: owner, actor: owner, pluginID: holder}
})
srv.SetProjectSettingsAuthorizer(slot.settings)
}
func (slot *projectLifecycleSlot) active() bool {
+35
View File
@@ -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)
}
+38
View File
@@ -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{}
}
+14 -1
View File
@@ -2,6 +2,7 @@
package profiles
import (
"errors"
"reflect"
"agent-system-backend/internal/store"
@@ -50,11 +51,20 @@ func (s *Service) EnsureDefault() error {
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) {
p, err := s.st.GetProfile(owner, id)
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
}
// 空或未知画像回落默认(T0 行为基线)。
@@ -65,6 +75,9 @@ func (s *Service) Save(owner string, p core.AgentProfile) error {
if p.ID == DefaultProfileID {
return errReadOnly("profiles: builtin profile is read-only; duplicate and modify")
}
if p.Model != (core.ModelRoute{}) {
return errors.New("模型路由由系统管理员配置;个人画像不能覆盖模型")
}
if p.Tools.Allow == nil {
p.Tools.Allow = []string{"*"}
}
+53
View File
@@ -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")
}
}
+76 -2
View File
@@ -1,6 +1,8 @@
package runmgr
import (
"crypto/sha256"
"encoding/json"
"fmt"
"os"
"path/filepath"
@@ -26,6 +28,11 @@ func StageWorkspaceInputs(root, owner, conversation string, files []WorkspaceFil
if err := os.MkdirAll(dir, 0o755); err != nil {
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
used := map[string]bool{}
for _, file := range files {
@@ -33,6 +40,9 @@ func StageWorkspaceInputs(root, owner, conversation string, files []WorkspaceFil
if !ok || len(file.Content) == 0 {
continue
}
if generatedTemplateName(name) {
name = "uploaded-" + name
}
name = uniqueInputName(name, used)
if err := os.WriteFile(filepath.Join(dir, name), file.Content, 0o644); err != nil {
return rels, err
@@ -47,14 +57,64 @@ func StageWorkspaceInputs(root, owner, conversation string, files []WorkspaceFil
ext = filepath.Ext(base)
}
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 {
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)
}
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,与原件并列。
// 后续轮次的上下文里原件只剩占位时,模型仍能读到可检索的原文文本——
// 「根据纪要制定方案」不再只能依赖上一轮产物(实测后续轮 0 次读原件)。
@@ -97,10 +157,17 @@ func ListWorkspaceInputs(root, owner, conversation string, staged []string) []st
return staged
}
var out []string
current := map[string]bool{}
for _, path := range staged {
current[path] = true
}
for _, e := range ents {
if e.IsDir() || strings.HasPrefix(e.Name(), ".") {
continue
}
if generatedTemplateName(e.Name()) && !current["inputs/"+e.Name()] {
continue
}
out = append(out, "inputs/"+e.Name())
}
if len(out) == 0 {
@@ -113,8 +180,8 @@ func (m *Manager) stageInputs(conv core.Conversation, eff core.EffectiveSettings
var templateName string
var template []byte
if id := strings.TrimSpace(eff.GetString("deliverables.templateArtifactId", "")); id != "" && m.st != nil {
if art, err := m.st.GetArtifact(conv.Owner, id); err == nil {
if body, err := m.st.ArtifactContent(conv.Owner, id); err == nil && len(body) > 0 {
if art, err := m.st.GetArtifactByID(id); err == nil && art.ProjectID == conv.ProjectID && m.templateAllowed(conv.Owner, art) {
if body, err := m.st.ArtifactContentByID(id); err == nil && len(body) > 0 && m.templateAllowed(conv.Owner, art) {
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)
}
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) {
base := filepath.Base(strings.TrimSpace(name))
if base == "" || base == "." || base == ".." || strings.HasPrefix(base, ".") {
+17 -1
View File
@@ -59,6 +59,7 @@ type Manager struct {
// projectID);nil = 未注册(现状:仅本人两路,不触碰共享通道)。
sharedKnowledgeAllow func(owner string, projectID core.ID) 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)
projectWorkAuthorization func(context.Context, string, core.ID) (bool, error)
// commands EXT-14:命令即工具的投影与分发源(nil = 未接线,命令不进工具清单)。
@@ -212,6 +213,12 @@ func (m *Manager) SetKnowledgeResourceAllow(fn func(string, core.KnowledgeDoc) b
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
// executing tools. Nil preserves the ordinary unregistered host behavior.
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()
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)
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)}
+97
View File
@@ -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")
}
}
+33
View File
@@ -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)
}
}
+10 -9
View File
@@ -6,7 +6,8 @@ import (
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)
// LookupSecret 给组合根以外的调用方使用。owner 为空时用 local。
@@ -21,15 +22,15 @@ func overlayKey(owner, service, fallback string) string {
if owner == "" {
owner = "local"
}
if v, ok := SecretLookup(owner, service); ok && strings.TrimSpace(v) != "" {
if v, ok := SecretLookup(owner, service); ok {
return v
}
return fallback
}
// 模型路由解析(4.7 / 4.9 覆盖链):设置层 defaults → system → user → project,
// 画像 ModelRoute 为会话内最高层覆盖(kind 为空回落设置层主模型)。
// 端点与密钥按 kind 独立配置,轻量档空则回落主档。
// Real Service.Effective snapshots carry the trusted managed-model policy and
// ignore legacy profile routes. Unmanaged standalone runtime snapshots retain
// their own profile semantics. Lightweight model/endpoint falls back to chat.
// ContextInjection 是一条条件注入,由 runmgr 从存储读出,组装时再转换成 ctxasm 的形状。
type ContextInjection struct {
@@ -50,10 +51,10 @@ type ResolvedModel struct {
Key string
}
// ResolveChatModel 解析主对话模型(画像 route > 设置层)。
// ResolveChatModel resolves system-managed chat settings in actual runs.
func ResolveChatModel(prof core.AgentProfile, eff core.EffectiveSettings) ResolvedModel {
m := eff.GetString("models.chat.model", "")
if prof.Model.Chat != "" {
if !eff.GetBool("_policy.managedModels", false) && prof.Model.Chat != "" {
m = prof.Model.Chat
}
return ResolvedModel{Kind: "chat", Model: m,
@@ -80,12 +81,12 @@ func ChatFallbacks(eff core.EffectiveSettings) []string {
return out
}
// ResolveLightweightModel 解析轻量模型:画像 route > 设置层轻量档 > 回落主档。
// ResolveLightweightModel resolves the managed lightweight route, then chat.
func ResolveLightweightModel(prof core.AgentProfile, eff core.EffectiveSettings) ResolvedModel {
m := eff.GetString("models.lightweight.model", "")
base := eff.GetString("models.lightweight.baseUrl", "")
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
}
if m == "" || base == "" {
+3 -2
View File
@@ -286,7 +286,8 @@ func (s *Server) listAdminUsers(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAdmin(w, r); !ok {
actor, ok := s.requireAdmin(w, r)
if !ok {
return
}
var req struct {
@@ -313,7 +314,7 @@ func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := s.st.CreateAccount(req.Owner, strings.TrimSpace(req.Name), hash, req.IsAdmin); err != nil {
if _, err := s.st.CreateActivatedAccount(store.AccountWrite{Owner: req.Owner, Name: strings.TrimSpace(req.Name), PasswordHash: hash, IsAdmin: req.IsAdmin, Actor: actor, Action: "account.create"}); err != nil {
writeStoreErr(w, err)
return
}
+1 -1
View File
@@ -589,7 +589,7 @@ func TestSystemSettingsAdminGate(t *testing.T) {
}
// 管理员:写入生效,系统读面掩码返回,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())
}
rec := do(keyBoss, "GET", "/api/settings/system", "")
+36 -1
View File
@@ -7,6 +7,7 @@ import (
"strings"
agentcommand "agent-command"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
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) {
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) {
@@ -28,6 +35,10 @@ func (s *Server) createSecret(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusBadRequest)
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)
if err != nil {
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) {
if !s.mutablePersonalSecret(w, r) {
return
}
writeOKOr(w, s.st.ActivateSecret(s.owner(r), r.PathValue("id")))
}
func (s *Server) renameSecret(w http.ResponseWriter, r *http.Request) {
if !s.mutablePersonalSecret(w, r) {
return
}
var req struct {
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) {
if !s.mutablePersonalSecret(w, r) {
return
}
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) {
q := r.URL.Query()
list, err := s.st.ListContextEntries(s.owner(r), q.Get("projectId"), q.Get("conversationId"))
+23 -8
View File
@@ -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) {
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
if body != nil {
json.NewEncoder(&buf).Encode(body)
@@ -1014,7 +1025,7 @@ func TestSquadOrchestration(t *testing.T) {
// TestModelRoutingOverrideChain:P2 受管语义下的模型配置链路——models.* 是系统
// 受管项,user/project 层整笔拒绝(含嵌套与点路径),仅系统层可写;画像层模型名
// 仍是最终覆盖。经由真实 run 的失败信息携带解析出的模型名取证。
// 存量画像层也不能覆盖。经由真实 run 的失败信息携带解析出的模型名取证。
func TestModelRoutingOverrideChain(t *testing.T) {
srv, st := newTestServer(t)
h := srv.Handler()
@@ -1061,19 +1072,22 @@ func TestModelRoutingOverrideChain(t *testing.T) {
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 {
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",
"model": map[string]any{"chat": "model-C"},
"tools": map[string]any{"allow": []string{"*"}},
"context": map[string]any{"maxHistoryMessages": 10},
"hitl": map[string]any{"askOnAmbiguity": true}, "budget": map[string]any{},
})
if prof["id"] != "prof_route_c" {
t.Fatalf("profile save: %v", prof)
if code != http.StatusBadRequest {
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"})
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"])
}
errMsg := terminal["error"].(map[string]any)["message"].(string)
if !strings.Contains(errMsg, `model-C`) {
t.Fatalf("run error should carry profile-routed model name: %q", errMsg)
if !strings.Contains(errMsg, `model-SYS`) || strings.Contains(errMsg, `model-C`) {
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" {
t.Fatalf("dotted write did not take effect: %q", got)
}
+213
View File
@@ -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
View File
@@ -42,26 +42,27 @@ import (
)
type Server struct {
projectLifecycle *extension.ProjectLifecycle
projectHost func(string) extension.Host
st *store.Store
Auth *Authenticator // API key 认证(nil/未启用 = 默认租户;组合根注入)
Retriever core.Retriever // 混合检索(nil = store 全文;组合根注入)
Indexer core.DocumentIndexer // 检索索引入口(nil = 整文档单块;组合根注入)
Limiter ratelimit.Limiter // 租户限流(nil = 不限;组合根注入)
LimitLookup func() int // 当前限流值(次/分;<=0 不限;组合根缓存注入)
mgr *runmgr.Manager
Cards *cardsvc.Service // 收件箱任务卡(nil = 不启用;组合根注入)
Surfaces *surfacesvc.Service // Generative UI Surface(nil = 不启用;EXT-17)
Bus *cmdbus.Bus // 命令总线(nil = 不启用;组合根注入;四入口同源 D27)
Gate NLIntentGate // 意图闸门(chat 入口同源判定;nil = 不接)
Workflows *workflow.Registry // 已装载工作流(nil = 不接)
Prompts *prompts.Service // 提示词库(nil = 不启用 API 与技能命令;执行器另有回退)
reg *tools.Registry
prof *profiles.Service
set *settings.Service
plugins *plugins.Host
static fs.FS
projectLifecycle *extension.ProjectLifecycle
projectSettingsAuthz extension.ProjectSettingsAuthorizer
projectHost func(string) extension.Host
st *store.Store
Auth *Authenticator // API key 认证(nil/未启用 = 默认租户;组合根注入)
Retriever core.Retriever // 混合检索(nil = store 全文;组合根注入)
Indexer core.DocumentIndexer // 检索索引入口(nil = 整文档单块;组合根注入)
Limiter ratelimit.Limiter // 租户限流(nil = 不限;组合根注入)
LimitLookup func() int // 当前限流值(次/分;<=0 不限;组合根缓存注入)
mgr *runmgr.Manager
Cards *cardsvc.Service // 收件箱任务卡(nil = 不启用;组合根注入)
Surfaces *surfacesvc.Service // Generative UI Surface(nil = 不启用;EXT-17)
Bus *cmdbus.Bus // 命令总线(nil = 不启用;组合根注入;四入口同源 D27)
Gate NLIntentGate // 意图闸门(chat 入口同源判定;nil = 不接)
Workflows *workflow.Registry // 已装载工作流(nil = 不接)
Prompts *prompts.Service // 提示词库(nil = 不启用 API 与技能命令;执行器另有回退)
reg *tools.Registry
prof *profiles.Service
set *settings.Service
plugins *plugins.Host
static fs.FS
// Subscribe 注入 bus 订阅(事件在线转发;重放走 store 游标)。
Subscribe func(conversationID string) (<-chan core.StreamEvent, func())
// DisableUploadIndexing EXT-9:true 时上传端点不再自动写检索索引(原件与
@@ -355,6 +356,11 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/settings", s.getSettings)
mux.HandleFunc("PUT /api/settings/user", s.putSettingsUser)
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("PUT /api/settings/system", s.putSettingsSystem)
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) {
owner := s.owner(r)
projectID := r.URL.Query().Get("project")
schema := append(settings.Schema(), s.plugins.SettingsSchema()...)
writeJSON(w, http.StatusOK, map[string]any{
"schema": schema,
"defaults": settings.Masked(settings.SystemDefaults(), schema),
"user": settings.Masked(mustUser(s.set, owner), schema),
"project": settings.Masked(mustScope(s.set, owner, projectID), schema),
s.set.SetExtraSchema(s.plugins.SettingsSchema())
if projectID == "" {
schema := s.set.SchemaFor(settings.UserSettingScope)
user, err := s.set.UserScope(owner)
if err != nil {
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) {
var values map[string]any
if err := json.NewDecoder(r.Body).Decode(&values); err != nil {
values, err := decodeSettingsObject(r)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
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) })
}
func (s *Server) putSettingsProject(w http.ResponseWriter, r *http.Request) {
var values map[string]any
if err := json.NewDecoder(r.Body).Decode(&values); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
var req struct {
Values map[string]any `json:"values"`
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
}
s.writeSettingsMerge(w, func() error {
return s.set.MergeProject(s.owner(r), r.PathValue("id"), values)
})
if err := decoder.Decode(new(any)); err != io.EOF {
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 并列出路径,
@@ -1714,6 +1779,11 @@ func (s *Server) writeSettingsMerge(w http.ResponseWriter, merge func() error) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var invalid *settings.ErrInvalidSetting
if errors.As(err, &invalid) {
http.Error(w, err.Error(), 400)
return
}
writeStoreErr(w, err)
}
@@ -1733,16 +1803,18 @@ func (s *Server) getSettingsSystem(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireSystemAdmin(w, r); !ok {
return
}
schema := append(settings.Schema(), s.plugins.SettingsSchema()...)
sys, err := s.set.SystemScope()
s.set.SetExtraSchema(s.plugins.SettingsSchema())
schema := s.set.SchemaFor(core.SettingScopeSystem)
sys, revision, err := s.set.SystemSnapshot()
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"schema": schema,
"defaults": settings.Masked(settings.SystemDefaults(), schema),
"system": settings.Masked(sys, schema),
"schema": schema,
"defaults": settings.Masked(s.set.DefaultsFor(core.SettingScopeSystem), 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 {
return
}
var values map[string]any
if err := json.NewDecoder(r.Body).Decode(&values); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
var req struct {
Values map[string]any `json:"values"`
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
}
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 工具注册)----
@@ -1790,7 +1880,25 @@ func (s *Server) createUserTool(w http.ResponseWriter, r *http.Request) {
http.Error(w, "v1 用户工具需为声明式 HTTP 工具(提供 http 段)", http.StatusBadRequest)
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 {
http.Error(w, err.Error(), http.StatusBadRequest)
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) {
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):展平全部编译插件的
+387
View File
@@ -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")
}
}
}
+111
View File
@@ -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
}
+226
View File
@@ -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
}
+93
View File
@@ -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")
}
}
+55
View File
@@ -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")
}
}
}
+153
View File
@@ -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")
}
}
+1 -4
View File
@@ -12,10 +12,7 @@ func (s *Service) MaskedProjectMetadata(values map[string]any) map[string]any {
if values == nil {
return map[string]any{}
}
schema := Schema()
if s != nil {
schema = append(schema, s.extraSchema...)
}
schema := s.Descriptors()
secrets := map[string]bool{}
for _, d := range schema {
if d.Type == core.SettingSecret || d.Secret {
+295
View File
@@ -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
}
+45
View File
@@ -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")
}
}
+79 -20
View File
@@ -19,8 +19,77 @@ var secretSpecs = []secretSpec{
{"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 {
refs, err := s.st.ListSettingsScopes()
if err != nil {
@@ -39,6 +108,11 @@ func (s *Service) SnapshotAndMigrateSecrets() error {
return err
}
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)
if !ok || val == MaskPlaceholder {
continue
@@ -52,13 +126,8 @@ func (s *Service) SnapshotAndMigrateSecrets() error {
return err
}
}
path := spec.path
if err := s.st.UpdateSettingsScope(ref.Owner, ref.Scope, func(cur map[string]any) (map[string]any, error) {
deletePath(cur, path)
return cur, nil
}); err != nil {
return err
}
// Preserve the source, including disagreements with older table rows.
// LookupSecret makes this explicit system value authoritative.
}
}
return nil
@@ -80,17 +149,7 @@ func (s *Service) snapshotThrottled(owner, scope string) {
}
func lookupString(m map[string]any, path string) (string, bool) {
var cur any = m
for _, p := range strings.Split(path, ".") {
obj, ok := cur.(map[string]any)
if !ok {
return "", false
}
cur, ok = obj[p]
if !ok {
return "", false
}
}
cur, _ := valueAtPath(m, path)
s, ok := cur.(string)
if !ok || strings.TrimSpace(s) == "" {
return "", false
+144 -18
View File
@@ -3,8 +3,10 @@
package settings
import (
"reflect"
"sort"
"strings"
"sync"
"agent-system-backend/internal/store"
core "agent-system-core"
@@ -88,7 +90,7 @@ func SystemDefaults() map[string]any {
// Schema 返回设置描述清单(设置页渲染依据;插件设置项由 pluginhost 追加)。
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.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},
@@ -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.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.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.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},
@@ -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.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.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.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.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.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 自动开启"},
@@ -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.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.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.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: "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;有预算与冷却,不会因页面修订自激"},
}
for i := range out {
out[i].Scope = builtinScope(out[i])
}
return out
}
// Service 提供有效设置计算与分层写入。
@@ -154,6 +171,7 @@ type Service struct {
// extraSchema 是插件设置声明(组合根经 SetExtraSchema 注入);
// 受管路径计算同时覆盖内置 schema 与插件声明。
extraSchema []core.SettingDesc
schemaMu sync.RWMutex
}
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 }
// 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 + 插件声明)。
func (s *Service) ManagedSettingPaths() []string {
paths := core.ManagedPaths(Schema())
if s != nil && len(s.extraSchema) > 0 {
paths = append(paths, core.ManagedPaths(s.extraSchema)...)
}
return paths
return append(core.ManagedPaths(s.Descriptors()), "models", "runtime", "tools.tavily", "tools.mcp")
}
// Effective 计算覆盖链结果:defaults → system(启动注入) → system(管理员落库)
// → user(租户) → project。受管项在 user/project 层的存量覆盖被剥除,不再生效。
// owner 是租户身份(认证中间件注入;未启用认证 = store.OwnerDefault)。
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 {
eff = core.MergeSettings(eff, s.system)
}
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()
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 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) {
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) {
return s.readScopeStripped(owner, ScopeProjectPrefix+projectID)
m, err := s.readScopeStripped(owner, ScopeProjectPrefix+projectID)
return s.ProjectValues(m, PersonalProjectScope), err
}
// readScopeStripped 读库并剥除受管路径:受管项对租户层不再生效,也不在设置页
@@ -216,23 +241,113 @@ func (s *Service) readScopeStripped(owner, scope string) (map[string]any, error)
// SystemScope 返回管理员落库的系统层(未脱敏;HTTP 读面自行 Masked)。
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 {
if err := s.ValidateWrite(values, UserSettingScope); err != nil {
return err
}
return s.mergeScope(owner, ScopeUser, values)
}
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)
}
// 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 {
if err := s.ValidateWrite(values, core.SettingScopeSystem); err != nil {
return err
}
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 先把库里和本次写入的点路径键展开成嵌套,再深合并。
// 租户层(user/project)触碰受管路径时整笔拒绝。掩码占位符视为未修改,不覆盖已存密钥。
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) {
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 {
return err
}
+3 -3
View File
@@ -178,7 +178,7 @@ func TestMergeUserDottedKeyMaskAndNormalizeStored(t *testing.T) {
t.Fatalf("mask path missing: %v", stats.MaskPaths)
}
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", ""))
}
// P2 受管:tools.tavily.apiKeys 属系统受管项,user 层存量覆盖不再生效。
@@ -193,10 +193,10 @@ func TestMergeUserDottedKeyMaskAndNormalizeStored(t *testing.T) {
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)
}
if svc.Effective("local", "").GetString("runtime.entry", "") != "direct" {
if svc.Effective("local", "").GetString("runtime.entry", "") != "squad" {
t.Fatal("dotted write did not take effect")
}
raw, err := svc.UserScope("local")
+72
View File
@@ -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
}
+73
View File
@@ -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")
}
}
+39
View File
@@ -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]
}
+55
View File
@@ -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")
}
}
+5
View File
@@ -64,6 +64,9 @@ func TestBootstrapFirstAdminSingleWinner(t *testing.T) {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
if err := st.EnsureSystemIdentity("bootstrap-test", "sqlite"); err != nil {
t.Fatal(err)
}
var wins, conflicts atomic.Int32
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
@@ -78,6 +81,8 @@ func TestBootstrapFirstAdminSingleWinner(t *testing.T) {
wins.Add(1)
} else if err == ErrConflict {
conflicts.Add(1)
} else {
t.Errorf("bootstrap contender: %v", err)
}
}(owner)
}
@@ -0,0 +1,104 @@
package store
import (
"bufio"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"testing"
"time"
)
func TestAccountPasswordSurvivesProcessTermination(t *testing.T) {
if mode := os.Getenv("U7_CRASH_CHILD_MODE"); mode != "" {
path := os.Getenv("U7_CRASH_CHILD_DB")
st, err := OpenExistingSQLite(path)
if err != nil {
t.Fatal(err)
}
h, err := HashPassword("private-fixture-password")
if err != nil {
t.Fatal(err)
}
_, err = st.CreateActivatedAccount(AccountWrite{Owner: "survivor", Name: "Survivor", PasswordHash: h, Action: "account.create", Actor: "fixture"})
if err != nil {
t.Fatal(err)
}
// Keep the database connection and process alive at the signal point;
// a graceful close would only test reopen, not abrupt termination.
fmt.Println("U7_COMMITTED")
select {}
}
for _, tc := range []struct {
name string
signal syscall.Signal
}{{"SIGTERM", syscall.SIGTERM}, {"SIGKILL", syscall.SIGKILL}} {
t.Run(tc.name, func(t *testing.T) {
path := filepath.Join(t.TempDir(), "accounts.db")
created, err := OpenSQLite(path)
if err != nil {
t.Fatal(err)
}
if err := created.Close(); err != nil {
t.Fatal(err)
}
child := exec.Command(os.Args[0], "-test.run=^TestAccountPasswordSurvivesProcessTermination$")
child.Env = append(os.Environ(), "U7_CRASH_CHILD_MODE="+tc.name, "U7_CRASH_CHILD_DB="+path)
stdout, err := child.StdoutPipe()
if err != nil {
t.Fatal(err)
}
child.Stderr = os.Stderr
if err := child.Start(); err != nil {
t.Fatal(err)
}
ready := make(chan error, 1)
go func() {
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
if strings.Contains(scanner.Text(), "U7_COMMITTED") {
ready <- nil
return
}
}
ready <- fmt.Errorf("child exited without committed receipt: %v", scanner.Err())
}()
select {
case err := <-ready:
if err != nil {
_ = child.Process.Kill()
_ = child.Wait()
t.Fatal(err)
}
case <-time.After(10 * time.Second):
_ = child.Process.Kill()
_ = child.Wait()
t.Fatal("child did not commit in time")
}
if err := child.Process.Signal(tc.signal); err != nil {
t.Fatal(err)
}
waitErr := child.Wait()
var exitErr *exec.ExitError
if !errors.As(waitErr, &exitErr) {
t.Fatalf("child was not terminated by %s: %v", tc.name, waitErr)
}
status, ok := exitErr.Sys().(syscall.WaitStatus)
if !ok || !status.Signaled() || status.Signal() != tc.signal {
t.Fatalf("child ended without requested %s: %v", tc.name, waitErr)
}
reopened, err := OpenExistingSQLite(path)
if err != nil {
t.Fatal(err)
}
defer reopened.Close()
if _, ok := reopened.VerifyLogin("survivor", "private-fixture-password"); !ok {
t.Fatal("committed password missing after real process termination")
}
})
}
}
@@ -0,0 +1,187 @@
package store
import (
"errors"
"testing"
"time"
)
func activeAccount(t *testing.T) *Store {
t.Helper()
s := openTestStore(t)
h, e := HashPassword("old-password")
if e != nil {
t.Fatal(e)
}
if e := s.CreateAccount("ada", "Ada", h, false); e != nil {
t.Fatal(e)
}
if e := s.ClearMustChangePassword("ada"); e != nil {
t.Fatal(e)
}
return s
}
func TestAccountMaintenanceInvalidatesVerifiedLogin(t *testing.T) {
for _, kind := range []string{"password", "revoke-all"} {
t.Run(kind, func(t *testing.T) {
s := activeAccount(t)
_, ver, ok := s.VerifyLoginWithVersion("ada", "old-password")
if !ok {
t.Fatal("healthy login failed")
}
var err error
if kind == "password" {
h, e := HashPassword("new-password")
if e != nil {
t.Fatal(e)
}
_, err = s.ApplyPasswordChange(AccountWrite{Owner: "ada", PasswordHash: h})
} else {
_, err = s.RevokeAllAndIssue(AccountWrite{Owner: "ada", Purpose: LoginKeyPurposeRecover, CreatedBy: "admin"})
}
if err != nil {
t.Fatal(err)
}
if _, err := s.CreateSessionVerified("ada", SessionScopeFull, time.Hour, ver); err == nil {
t.Fatal("stale in-flight login could create a session after credential replacement")
}
})
}
}
func TestActivatedAccountRetainsLoginKeySuccessEvent(t *testing.T) {
s := openTestStore(t)
if _, err := s.CreateActivatedAccount(AccountWrite{Owner: "ada", Purpose: LoginKeyPurposeActivate, CreatedBy: "admin", Actor: "admin"}); err != nil {
t.Fatal(err)
}
var n int
if err := s.db.QueryRow(s.q(`SELECT COUNT(*) FROM account_events WHERE owner=? AND action='login_key_issued'`), "ada").Scan(&n); err != nil {
t.Fatal(err)
}
if n != 1 {
t.Fatal("atomic issuance dropped main account event")
}
}
func TestCheckedPasswordMutationRollsBackAuditFailure(t *testing.T) {
s := openTestStore(t)
if err := s.CreateAccount("ada", "Ada", "", false); err != nil {
t.Fatal(err)
}
key, _, err := s.IssueLoginKey("ada", LoginKeyPurposeActivate, "admin", 0)
if err != nil {
t.Fatal(err)
}
token, _, err := s.RedeemLoginKey(key)
if err != nil {
t.Fatal(err)
}
h, err := HashPassword("new-password")
if err != nil {
t.Fatal(err)
}
s.SetAccountFault(ErrConflict)
if _, err := s.CompleteSetPasswordChecked(token, h); err == nil {
t.Fatal("checked password path bypassed the shared audited commit")
}
if a, err := s.GetUserAuthState("ada"); err != nil || a.HasPassword {
t.Fatal("failed checked change left a password behind")
}
if _, _, ok := s.SessionInfo(token); !ok {
t.Fatal("failed checked change consumed its session")
}
}
func TestRevokeAllRefusesMissingOwnerWithoutCredential(t *testing.T) {
s := openTestStore(t)
_, err := s.RevokeAllAndIssue(AccountWrite{Owner: "missing", Purpose: LoginKeyPurposeRecover, CreatedBy: "admin", Actor: "admin"})
if !errors.Is(err, ErrNotFound) {
t.Fatalf("missing owner must be rejected before issuing any key: %v", err)
}
var n int
if err := s.db.QueryRow(s.q(`SELECT COUNT(*) FROM login_keys WHERE owner=?`), "missing").Scan(&n); err != nil || n != 0 {
t.Fatalf("orphan credential rows=%d err=%v", n, err)
}
}
func TestActivePasswordChangeRevokesUnusedRecoveryKey(t *testing.T) {
s := activeAccount(t)
key, _, err := s.IssueLoginKey("ada", LoginKeyPurposeRecover, "admin", 0)
if err != nil {
t.Fatal(err)
}
session, err := s.CreateSession("ada")
if err != nil {
t.Fatal(err)
}
hash, err := HashPassword("replacement-password")
if err != nil {
t.Fatal(err)
}
if err := s.ChangeUserPasswordActive("ada", "old-password", hash, session); err != nil {
t.Fatal(err)
}
if _, _, err := s.RedeemLoginKey(key); err == nil {
t.Fatal("unused recovery key issued before active password change still redeems")
}
}
func TestRejectedActiveChangeIsAudited(t *testing.T) {
s := activeAccount(t)
h, err := HashPassword("next-password")
if err != nil {
t.Fatal(err)
}
err = s.ChangeUserPasswordActive("ada", "incorrect-current-password", h, "")
if !errors.Is(err, ErrBadCredentials) {
t.Fatalf("expected bad credentials, got %v", err)
}
count, err := s.CountAudit("ada", "rejected")
if err != nil {
t.Fatal(err)
}
if count != 1 {
t.Fatalf("rejected checked account mutation has %d rejected audit rows, want1", count)
}
}
func TestDelayedOldVersionSessionIsRejectedAtUse(t *testing.T) {
s := activeAccount(t)
_, ver, ok := s.VerifyLoginWithVersion("ada", "old-password")
if !ok {
t.Fatal("healthy verification missing")
}
h, err := HashPassword("new-password")
if err != nil {
t.Fatal(err)
}
if _, err := s.ApplyPasswordChange(AccountWrite{Owner: "ada", PasswordHash: h}); err != nil {
t.Fatal(err)
}
// Represents an insert racing after the replacement's earlier DELETE.
token := "sess_private_stale_fixture"
_, err = s.db.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version) VALUES(?,?,?,?,?,?,?)`),
hashKey(token), "ada", nowISO(), time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano), nowISO(), SessionScopeFull, ver)
if err != nil {
t.Fatal(err)
}
if _, _, ok := s.SessionInfo(token); ok {
t.Fatal("session carrying the retired auth_version became authorized")
}
}
func TestActivePasswordChangePreservesCurrentVersionedSession(t *testing.T) {
s := activeAccount(t)
token, err := s.CreateSession("ada")
if err != nil {
t.Fatal(err)
}
h, err := HashPassword("new-password")
if err != nil {
t.Fatal(err)
}
if err := s.ChangeUserPasswordActive("ada", "old-password", h, token); err != nil {
t.Fatal(err)
}
if owner, scope, ok := s.SessionInfo(token); !ok || owner != "ada" || scope != SessionScopeFull {
t.Fatal("active change did not retain its current full session")
}
}
@@ -0,0 +1,167 @@
package store
import (
"context"
"testing"
"time"
)
// The trigger holds a transaction after its authorization read. A second
// connection must wait for that owner's lock before replacing credentials.
func holdPGAccountWrite(t *testing.T, s *Store, table, event, suffix string) func() {
t.Helper()
lock := "u7_account_" + suffix
_, err := s.db.Exec(`CREATE FUNCTION u7_pause_account() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN PERFORM pg_advisory_xact_lock(hashtext(current_schema() || ':` + lock + `')); RETURN NEW; END $$`)
if err != nil {
t.Fatal(err)
}
_, err = s.db.Exec(`CREATE TRIGGER u7_pause_account BEFORE ` + event + ` ON ` + table + ` FOR EACH ROW EXECUTE FUNCTION u7_pause_account()`)
if err != nil {
t.Fatal(err)
}
conn, err := s.db.Conn(context.Background())
if err != nil {
t.Fatal(err)
}
if _, err := conn.ExecContext(context.Background(), `SELECT pg_advisory_lock(hashtext(current_schema() || ':`+lock+`'))`); err != nil {
conn.Close()
t.Fatal(err)
}
released := false
release := func() {
if !released {
released = true
_, _ = conn.ExecContext(context.Background(), `SELECT pg_advisory_unlock(hashtext(current_schema() || ':`+lock+`'))`)
_ = conn.Close()
}
}
t.Cleanup(release)
return release
}
func waitForPGAccountWrite(t *testing.T, s *Store, suffix string) {
t.Helper()
lock := "u7_account_" + suffix
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
var n int
err := s.db.QueryRow(`SELECT count(*) FROM pg_locks WHERE locktype='advisory' AND NOT granted
AND objid=(hashtext(current_schema() || ':` + lock + `')::bigint & 4294967295)::oid`).Scan(&n)
if err != nil {
t.Fatal(err)
}
if n > 0 {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("account write did not reach the deterministic PostgreSQL pause")
}
func awaitPGAccountResult[T any](t *testing.T, done <-chan T) T {
t.Helper()
select {
case result := <-done:
return result
case <-time.After(5 * time.Second):
t.Fatal("serialized account transaction did not finish")
var zero T
return zero
}
}
func TestPGVerifiedSessionAndPasswordChangeSerialize(t *testing.T) {
s := activeAccount(t)
if s.Dialect() != "postgres" {
t.Skip("real PostgreSQL required")
}
_, version, ok := s.VerifyLoginWithVersion("ada", "old-password")
if !ok {
t.Fatal("initial login failed")
}
release := holdPGAccountWrite(t, s, "sessions", "INSERT", "session")
type sessionResult struct {
token string
err error
}
sessionDone := make(chan sessionResult, 1)
go func() {
token, err := s.CreateSessionVerified("ada", SessionScopeFull, time.Hour, version)
sessionDone <- sessionResult{token, err}
}()
waitForPGAccountWrite(t, s, "session")
hash, err := HashPassword("replacement-password")
if err != nil {
t.Fatal(err)
}
changeDone := make(chan error, 1)
go func() {
_, err := s.ApplyPasswordChange(AccountWrite{Owner: "ada", PasswordHash: hash, Actor: "admin"})
changeDone <- err
}()
select {
case err := <-changeDone:
t.Fatalf("replacement committed ahead of the in-flight verified session: %v", err)
case <-time.After(100 * time.Millisecond):
}
release()
session := awaitPGAccountResult(t, sessionDone)
if session.err != nil {
t.Fatal(session.err)
}
if err := awaitPGAccountResult(t, changeDone); err != nil {
t.Fatal(err)
}
if _, _, ok := s.SessionInfo(session.token); ok {
t.Fatal("old verified session survived later password replacement")
}
}
func TestPGRestrictedPasswordSetAndKeyReissueSerialize(t *testing.T) {
s := openTestStore(t)
if s.Dialect() != "postgres" {
t.Skip("real PostgreSQL required")
}
if err := s.CreateAccount("ada", "Ada", "", false); err != nil {
t.Fatal(err)
}
key, _, err := s.IssueLoginKey("ada", LoginKeyPurposeRecover, "admin", 0)
if err != nil {
t.Fatal(err)
}
token, _, err := s.RedeemLoginKey(key)
if err != nil {
t.Fatal(err)
}
hash, err := HashPassword("replacement-password")
if err != nil {
t.Fatal(err)
}
release := holdPGAccountWrite(t, s, "users", "UPDATE", "password")
setDone := make(chan error, 1)
go func() {
_, err := s.CompleteSetPasswordChecked(token, hash)
setDone <- err
}()
waitForPGAccountWrite(t, s, "password")
issueDone := make(chan error, 1)
go func() {
_, _, err := s.IssueLoginKey("ada", LoginKeyPurposeRecover, "admin", 0)
issueDone <- err
}()
select {
case err := <-issueDone:
t.Fatalf("key replacement committed ahead of the in-flight password set: %v", err)
case <-time.After(100 * time.Millisecond):
}
release()
if err := awaitPGAccountResult(t, setDone); err != nil {
t.Fatal(err)
}
if err := awaitPGAccountResult(t, issueDone); err != nil {
t.Fatal(err)
}
if _, _, ok := s.SessionInfo(token); ok {
t.Fatal("old restricted session survived password set and key reissue")
}
}
@@ -0,0 +1,202 @@
package store
import (
"bytes"
"context"
"database/sql"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
// Two independent process opens share one isolated PostgreSQL schema. The
// barrier makes their account transactions genuinely concurrent.
func TestAccountTwoProcessPostgresAdmission(t *testing.T) {
if mode := os.Getenv("U7_ACCOUNT_CHILD_MODE"); mode != "" {
childDSN := os.Getenv("U7_ACCOUNT_CHILD_DSN")
barrier := os.Getenv("U7_ACCOUNT_CHILD_BARRIER")
st, err := OpenExistingPostgres(childDSN, os.Getenv("U7_ACCOUNT_CHILD_ARTIFACTS"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := os.WriteFile(os.Getenv("U7_ACCOUNT_CHILD_READY"), []byte("ready"), 0o600); err != nil {
t.Fatal(err)
}
for i := 0; i < 500; i++ {
if _, err := os.Stat(barrier); err == nil {
break
}
time.Sleep(5 * time.Millisecond)
}
if _, err := os.Stat(barrier); err != nil {
t.Fatal("two-process barrier missing")
}
var outcome AccountOutcome
switch mode {
case "create":
outcome, err = st.CreateActivatedAccount(AccountWrite{Owner: "same", Name: "Same", Purpose: LoginKeyPurposeActivate, CreatedBy: "fixture", Actor: "fixture", Action: "account.create"})
case "bootstrap":
outcome, err = st.BootstrapFirstAdminWrite(AccountWrite{Owner: os.Getenv("U7_ACCOUNT_CHILD_OWNER"), Name: "Bootstrap", CreatedBy: "fixture", Actor: "fixture"})
case "bootstrap-versus-create":
if os.Getenv("U7_ACCOUNT_CHILD_OWNER") == "bootstrap" {
outcome, err = st.BootstrapFirstAdminWrite(AccountWrite{Owner: "admin", Name: "Admin", CreatedBy: "fixture", Actor: "fixture"})
} else {
outcome, err = st.CreateActivatedAccount(AccountWrite{Owner: "ordinary", Name: "Ordinary", Purpose: LoginKeyPurposeActivate, CreatedBy: "fixture", Actor: "fixture", Action: "account.create"})
}
default:
t.Fatal("unknown child mode")
}
if err == nil {
if outcome.OperationID == "" || outcome.Plain == "" {
t.Fatal("committed account did not produce a complete receipt")
}
fmt.Println("U7_RESULT=success")
return
}
if errors.Is(err, ErrConflict) {
fmt.Println("U7_RESULT=conflict")
return
}
t.Fatalf("unexpected admission error: %v", err)
return
}
baseDSN := os.Getenv("AGENT_PG_TEST_DSN")
if baseDSN == "" {
t.Skip("AGENT_PG_TEST_DSN absent; Dev PG gate runs this against a real isolated schema")
}
for _, tc := range []struct {
mode string
owners [2]string
}{
{"create", [2]string{"one", "two"}},
{"bootstrap", [2]string{"admin-one", "admin-two"}},
{"bootstrap-versus-create", [2]string{"bootstrap", "ordinary"}},
} {
t.Run(tc.mode, func(t *testing.T) {
schema := fmt.Sprintf("u7_audit_%d", time.Now().UnixNano())
admin, err := sql.Open("pgx", baseDSN)
if err != nil {
t.Fatal(err)
}
defer admin.Close()
ensurePGTrgm(t, admin)
if _, err := admin.Exec(`CREATE SCHEMA ` + schema); err != nil {
t.Fatal(err)
}
defer admin.Exec(`DROP SCHEMA IF EXISTS ` + schema + ` CASCADE`)
dsn := withTestSearchPath(baseDSN, schema)
artifacts := filepath.Join(t.TempDir(), "artifacts")
st, err := OpenPostgres(dsn, artifacts)
if err != nil {
t.Fatal(err)
}
if err := st.EnsureSystemIdentity("pg-two-process", "pg"); err != nil {
t.Fatal(err)
}
st.Close()
// Hold the shared admission point so both independent processes
// are demonstrably contending before either may commit.
lockKey := "bootstrap:first-admin"
if tc.mode == "create" {
lockKey = "account:same"
}
lockConn, err := admin.Conn(context.Background())
if err != nil {
t.Fatal(err)
}
defer lockConn.Close()
if _, err := lockConn.ExecContext(context.Background(), `SELECT pg_advisory_lock(hashtext($1))`, lockKey); err != nil {
t.Fatal(err)
}
released := false
release := func() {
if !released {
released = true
_, _ = lockConn.ExecContext(context.Background(), `SELECT pg_advisory_unlock(hashtext($1))`, lockKey)
}
}
defer release()
barrier := filepath.Join(t.TempDir(), "go")
readyDir := t.TempDir()
var children [2]*exec.Cmd
var logs [2]bytes.Buffer
for i := range children {
readyPath := filepath.Join(readyDir, fmt.Sprintf("ready-%d", i))
cmd := exec.Command(os.Args[0], "-test.run=^TestAccountTwoProcessPostgresAdmission$", "-test.v")
cmd.Env = append(os.Environ(), "U7_ACCOUNT_CHILD_MODE="+tc.mode, "U7_ACCOUNT_CHILD_DSN="+dsn,
"U7_ACCOUNT_CHILD_OWNER="+tc.owners[i], "U7_ACCOUNT_CHILD_ARTIFACTS="+artifacts,
"U7_ACCOUNT_CHILD_BARRIER="+barrier, "U7_ACCOUNT_CHILD_READY="+readyPath)
cmd.Stdout = &logs[i]
cmd.Stderr = &logs[i]
if err := cmd.Start(); err != nil {
t.Fatal(err)
}
children[i] = cmd
}
deadline := time.Now().Add(10 * time.Second)
for i := range children {
readyPath := filepath.Join(readyDir, fmt.Sprintf("ready-%d", i))
for {
if _, err := os.Stat(readyPath); err == nil {
break
}
if time.Now().After(deadline) {
t.Fatalf("child %d never opened the shared database: %s", i, logs[i].String())
}
time.Sleep(5 * time.Millisecond)
}
}
if err := os.WriteFile(barrier, []byte("go"), 0o600); err != nil {
t.Fatal(err)
}
for {
var waiters int
err := admin.QueryRow(`SELECT count(*) FROM pg_locks WHERE locktype='advisory' AND NOT granted
AND objid=(hashtext($1)::bigint & 4294967295)::oid`, lockKey).Scan(&waiters)
if err != nil {
t.Fatal(err)
}
if waiters >= 2 {
break
}
if time.Now().After(deadline) {
t.Fatalf("only %d of 2 child processes reached the shared admission lock: %s | %s", waiters, logs[0].String(), logs[1].String())
}
time.Sleep(5 * time.Millisecond)
}
release()
wins, losses := 0, 0
for i, cmd := range children {
if err := cmd.Wait(); err != nil {
t.Fatalf("child %d failed: %v %s", i, err, logs[i].String())
}
out := logs[i].String()
if strings.Contains(out, "U7_RESULT=success") {
wins++
} else if strings.Contains(out, "U7_RESULT=conflict") {
losses++
} else {
t.Fatalf("child %d produced no outcome: %s", i, out)
}
}
if wins != 1 || losses != 1 {
t.Fatalf("admission wins=%d conflicts=%d, want 1/1", wins, losses)
}
reopened, err := OpenExistingPostgres(dsn, artifacts)
if err != nil {
t.Fatal(err)
}
defer reopened.Close()
users, err := reopened.ListUsersAdmin()
if err != nil || len(users) != 1 {
t.Fatalf("actual committed users=%d, err=%v", len(users), err)
}
})
}
}
+372
View File
@@ -0,0 +1,372 @@
package store
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"time"
)
// AccountWrite 是一次账户变更的调用参数。成功审计与变更同事务。
type AccountWrite struct {
Owner string
Name string
PasswordHash string
IsAdmin bool
MustChange bool
ExceptToken string
Purpose string
CreatedBy string
TTL time.Duration
RevokeAllKeys bool
Actor string
InstanceID string
Action string
}
// AccountOutcome 是成功提交后可以对外展示的标识。不含密码、哈希或明文。
type AccountOutcome struct {
Plain string
CredentialID string
OperationID string
}
// CreateActivatedAccount 严格插入账户。passwordHash 为空且 Purpose 非空时,同一事务签发登录 Key。
func (s *Store) CreateActivatedAccount(w AccountWrite) (AccountOutcome, error) {
if w.Action == "" {
w.Action = "account.create"
}
var out AccountOutcome
err := s.accountTx(w, func(tx *sql.Tx, opID string) error {
if s.dialect.name() == "postgres" {
// A first ordinary account and first-admin bootstrap use the same
// admission lock. If bootstrap commits while this call waits, the
// earlier empty-database observation cannot authorize a second
// competing first account.
var before, after int
if err := tx.QueryRow(s.q(`SELECT COUNT(*) FROM users`)).Scan(&before); err != nil {
return err
}
if before == 0 {
if err := s.dialect.lockBootstrap(tx); err != nil {
return err
}
if err := tx.QueryRow(s.q(`SELECT COUNT(*) FROM users`)).Scan(&after); err != nil {
return err
}
if after != 0 {
return ErrConflict
}
}
}
if err := insertAccount(s, tx, w); err != nil {
return err
}
if w.PasswordHash == "" && w.Purpose != "" {
plain, id, err := insertLoginKey(s, tx, w)
if err != nil {
return err
}
out.Plain, out.CredentialID = plain, id
}
out.OperationID = opID
return nil
})
return out, err
}
// ApplyPasswordChange 在同一事务里写哈希、更新强制改密标志、作废未使用登录 Key,并处理会话。
func (s *Store) ApplyPasswordChange(w AccountWrite) (AccountOutcome, error) {
if w.Action == "" {
w.Action = "account.password"
}
var out AccountOutcome
err := s.accountTx(w, func(tx *sql.Tx, opID string) error {
res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=?`),
w.PasswordHash, nowISO(), w.MustChange, w.Owner)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`),
LoginKeyStatusRevoked, w.Owner, LoginKeyStatusUnused); err != nil {
return err
}
if w.ExceptToken != "" {
_, err = tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND token_hash<>?`), w.Owner, hashKey(w.ExceptToken))
} else {
_, err = tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), w.Owner)
}
if err != nil {
return err
}
if w.ExceptToken != "" {
if _, err := tx.Exec(s.q(`UPDATE sessions SET auth_version=(SELECT auth_version FROM users WHERE owner=?) WHERE owner=? AND token_hash=?`),
w.Owner, w.Owner, hashKey(w.ExceptToken)); err != nil {
return err
}
}
out.OperationID = opID
return nil
})
return out, err
}
// BootstrapFirstAdmin 在立即事务(SQLite)或身份行锁(PostgreSQL)内复核空库后建首位管理员。
func (s *Store) BootstrapFirstAdminWrite(w AccountWrite) (AccountOutcome, error) {
w.IsAdmin = true
w.PasswordHash = ""
w.Purpose = LoginKeyPurposeActivate
if w.CreatedBy == "" {
w.CreatedBy = "cli-bootstrap"
}
if w.Action == "" {
w.Action = "account.bootstrap"
}
var out AccountOutcome
err := s.accountTx(w, func(tx *sql.Tx, opID string) error {
if err := s.dialect.lockBootstrap(tx); err != nil {
return err
}
if err := lockIdentity(s, tx); err != nil {
return err
}
if err := requireEmptyBusiness(s, tx); err != nil {
return err
}
if err := insertAccount(s, tx, w); err != nil {
return err
}
var users int
if err := tx.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&users); err != nil {
return err
}
if users != 1 {
return ErrConflict
}
if err := s.insertAccountEvent(tx, w.CreatedBy, entranceOf(w.CreatedBy), w.Owner, "bootstrap_admin", w.Owner); err != nil {
return err
}
plain, id, err := insertLoginKey(s, tx, w)
if err != nil {
return err
}
out.Plain, out.CredentialID, out.OperationID = plain, id, opID
return nil
})
return out, err
}
// RevokeAllAndIssue 把吊销长期密钥、删除会话、作废未使用登录 Key 和签发新 Key 放进一次提交。
func (s *Store) RevokeAllAndIssue(w AccountWrite) (AccountOutcome, error) {
if w.Action == "" {
w.Action = "account.revoke_credentials"
}
var out AccountOutcome
err := s.accountTx(w, func(tx *sql.Tx, opID string) error {
res, err := tx.Exec(s.q(`UPDATE users SET auth_version=auth_version+1 WHERE owner=?`), w.Owner)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err != nil {
return err
} else if n != 1 {
return ErrNotFound
}
if _, err := tx.Exec(s.q(`UPDATE api_keys SET revoked=1 WHERE owner=? AND revoked=0`), w.Owner); err != nil {
return err
}
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), w.Owner); err != nil {
return err
}
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`),
LoginKeyStatusRevoked, w.Owner, LoginKeyStatusUnused); err != nil {
return err
}
plain, id, err := insertLoginKey(s, tx, w)
if err != nil {
return err
}
out.Plain, out.CredentialID, out.OperationID = plain, id, opID
return nil
})
return out, err
}
func (s *Store) accountTx(w AccountWrite, fn func(*sql.Tx, string) error) error {
opID := newID("op")
tx, err := s.db.Begin()
if err != nil {
return err
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
if w.Owner != "" {
if err := s.dialect.lockAccount(tx, w.Owner); err != nil {
return err
}
}
if w.InstanceID == "" {
if err := tx.QueryRow(`SELECT instance_id FROM agent_system_identity`).Scan(&w.InstanceID); err != nil && !errors.Is(err, sql.ErrNoRows) {
return err
}
}
if err := fn(tx, opID); err != nil {
_ = tx.Rollback()
committed = true
s.recordRejected(w, opID)
return err
}
if s.accountFault != nil {
err = s.accountFault
_ = tx.Rollback()
committed = true
s.recordRejected(w, opID)
return err
}
if err := insertAudit(s, tx, w, opID, "success"); err != nil {
_ = tx.Rollback()
committed = true
s.recordRejected(w, opID)
return err
}
if err := tx.Commit(); err != nil {
// Commit may have reached the database even if the client lost its
// response. Recording a rejection here would assert an unknown outcome.
return err
}
committed = true
return nil
}
func (s *Store) recordRejected(w AccountWrite, opID string) {
_, _ = s.db.Exec(s.q(`INSERT INTO audit_events(time,owner,action,actor,target,entry,instance_id,operation_id,outcome) VALUES(?,?,?,?,?,?,?,?,?)`),
nowISO(), w.Owner, w.Action, w.Actor, w.Owner, "account", w.InstanceID, opID, "rejected")
}
func insertAudit(s *Store, tx *sql.Tx, w AccountWrite, opID, outcome string) error {
_, err := tx.Exec(s.q(`INSERT INTO audit_events(time,owner,action,actor,target,entry,instance_id,operation_id,outcome) VALUES(?,?,?,?,?,?,?,?,?)`),
nowISO(), w.Owner, w.Action, w.Actor, w.Owner, "account", w.InstanceID, opID, outcome)
return err
}
func insertAccount(s *Store, tx *sql.Tx, w AccountWrite) error {
mustChange := w.PasswordHash != "" || w.MustChange
setAt := ""
if w.PasswordHash != "" {
setAt = nowISO()
}
_, err := tx.Exec(s.q(`INSERT INTO users(id,owner,name,created_at,password_hash,password_set_at,must_change_password,is_admin) VALUES(?,?,?,?,?,?,?,?)`),
newID("usr"), w.Owner, w.Name, nowISO(), w.PasswordHash, setAt, mustChange, w.IsAdmin)
if isUniqueViolation(err) {
return ErrConflict
}
return err
}
func insertLoginKey(s *Store, tx *sql.Tx, w AccountWrite) (string, string, error) {
purpose := w.Purpose
if purpose != LoginKeyPurposeActivate && purpose != LoginKeyPurposeRecover {
return "", "", fmt.Errorf("login key: invalid purpose %q", purpose)
}
ttl := w.TTL
if ttl <= 0 {
ttl = defaultLoginKeyTTL
}
if ttl < minLoginKeyTTL {
ttl = minLoginKeyTTL
}
if ttl > maxLoginKeyTTL {
ttl = maxLoginKeyTTL
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", "", err
}
idRaw := make([]byte, 8)
if _, err := rand.Read(idRaw); err != nil {
return "", "", err
}
plain := "alk_" + hex.EncodeToString(raw)
id := "lk_" + hex.EncodeToString(idRaw)
now := time.Now().UTC()
if _, err := s.insertLoginKeyTx(tx, w.Owner, purpose, w.CreatedBy, plain, id, now, now.Add(ttl)); err != nil {
return "", "", err
}
_, err := tx.Exec(s.q(`UPDATE login_keys SET instance_id=? WHERE id=?`), w.InstanceID, id)
return plain, id, err
}
func lockIdentity(s *Store, tx *sql.Tx) error {
q := `SELECT instance_id FROM agent_system_identity`
if s.dialect.name() == "postgres" {
q += ` FOR UPDATE`
}
rows, err := tx.Query(q)
if err != nil {
return err
}
defer rows.Close()
n := 0
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return err
}
n++
}
if err := rows.Err(); err != nil {
return err
}
if n == 0 {
return fmt.Errorf("%w: agent_system_identity 无行", ErrSystemIdentityMissing)
}
return nil
}
func requireEmptyBusiness(s *Store, tx *sql.Tx) error {
for _, table := range []string{"users", "projects", "conversations", "messages", "runs", "api_keys", "sessions", "login_keys"} {
var n int
if err := tx.QueryRow(`SELECT COUNT(1) FROM ` + table).Scan(&n); err != nil {
return err
}
if n > 0 {
return ErrConflict
}
}
return nil
}
func (s *Store) requireCurrentSchema() error {
applied, err := s.MigrationStatus()
if err != nil || len(applied) == 0 {
return fmt.Errorf("schema 版本不可读;请运行 agent-system migrate,或先启动一次服务")
}
latest := migrations[len(migrations)-1].version
got := applied[len(applied)-1]
if got != latest {
return fmt.Errorf("schema v%d 与本二进制 v%d 不一致;请运行 agent-system migrate,或先启动一次服务", got, latest)
}
return nil
}
// SetAccountFault 让下一次账户事务在提交前失败,用于半态测试。
func (s *Store) SetAccountFault(err error) {
s.accountFault = err
}
// CountAudit 返回指定 outcome 的账户审计条数。
func (s *Store) CountAudit(owner, outcome string) (int, error) {
var n int
err := s.db.QueryRow(s.q(`SELECT COUNT(1) FROM audit_events WHERE owner=? AND entry=? AND outcome=?`), owner, "account", outcome).Scan(&n)
return n, err
}
+156
View File
@@ -0,0 +1,156 @@
package store
import (
"errors"
"fmt"
"os"
"path/filepath"
"sync"
"testing"
)
func TestConcurrentCreateOneWinner(t *testing.T) {
st := openLife(t)
const n = 8
var wg sync.WaitGroup
wins := make(chan error, n)
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
_, err := st.CreateActivatedAccount(AccountWrite{
Owner: "same", Name: "Same", Purpose: LoginKeyPurposeActivate, CreatedBy: "test",
Action: "account.create",
})
wins <- err
}()
}
wg.Wait()
close(wins)
okN, conflict := 0, 0
for err := range wins {
switch {
case err == nil:
okN++
case errors.Is(err, ErrConflict):
conflict++
default:
t.Fatal(err)
}
}
if okN != 1 || conflict != n-1 {
t.Fatalf("wins=%d conflicts=%d", okN, conflict)
}
}
func TestPasswordChangeIsAtomic(t *testing.T) {
st := openLife(t)
hash, err := HashPassword("old-password")
if err != nil {
t.Fatal(err)
}
if _, err := st.CreateActivatedAccount(AccountWrite{Owner: "ada", Name: "Ada", PasswordHash: hash, Action: "account.create"}); err != nil {
t.Fatal(err)
}
plain, _, err := st.IssueLoginKey("ada", LoginKeyPurposeActivate, "test", 0)
if err != nil {
t.Fatal(err)
}
st.SetAccountFault(fmt.Errorf("injected"))
next, err := HashPassword("new-password")
if err != nil {
t.Fatal(err)
}
if _, err := st.ApplyPasswordChange(AccountWrite{Owner: "ada", PasswordHash: next, Action: "account.password"}); err == nil {
t.Fatal("faulted change returned success")
}
st.SetAccountFault(nil)
if _, ok := st.VerifyLogin("ada", "old-password"); !ok {
t.Fatal("old password did not survive the failed change")
}
if _, err := st.ApplyPasswordChange(AccountWrite{Owner: "ada", PasswordHash: next, Action: "account.password"}); err != nil {
t.Fatal(err)
}
if _, _, err := st.RedeemLoginKey(plain); err == nil {
t.Fatal("old key redeemed after password change")
}
if _, ok := st.VerifyLogin("ada", "new-password"); !ok {
t.Fatal("new password missing")
}
n, err := st.CountAudit("ada", "rejected")
if err != nil || n == 0 {
t.Fatalf("rejected audit n=%d err=%v", n, err)
}
}
func TestOpenExistingDoesNotMigrateOrCreate(t *testing.T) {
missing := filepath.Join(t.TempDir(), "absent", "agent-system.db")
if _, err := OpenExistingSQLite(missing); err == nil {
t.Fatal("missing file opened")
}
if _, err := os.Stat(filepath.Dir(missing)); !os.IsNotExist(err) {
t.Fatalf("parent created: %v", err)
}
path := filepath.Join(t.TempDir(), "agent-system.db")
st, err := OpenSQLite(path)
if err != nil {
t.Fatal(err)
}
if err := st.Close(); err != nil {
t.Fatal(err)
}
again, err := OpenExistingSQLite(path)
if err != nil {
t.Fatal(err)
}
again.Close()
}
func TestBootstrapRefusesExistingBusiness(t *testing.T) {
st := openLife(t)
if err := st.EnsureSystemIdentity("inst", "sqlite"); err != nil {
t.Fatal(err)
}
if _, err := st.CreateProject("owner", "p"); err != nil {
t.Fatal(err)
}
if _, err := st.BootstrapFirstAdminWrite(AccountWrite{Owner: "root", Name: "root"}); err == nil {
t.Fatal("bootstrap accepted a database that already has a project")
}
}
func TestPasswordSurvivesReopen(t *testing.T) {
path := filepath.Join(t.TempDir(), "agent-system.db")
st, err := OpenSQLite(path)
if err != nil {
t.Fatal(err)
}
hash, err := HashPassword("keep-password")
if err != nil {
t.Fatal(err)
}
if _, err := st.CreateActivatedAccount(AccountWrite{Owner: "ada", Name: "Ada", PasswordHash: hash}); err != nil {
t.Fatal(err)
}
if err := st.Close(); err != nil {
t.Fatal(err)
}
st, err = OpenSQLite(path)
if err != nil {
t.Fatal(err)
}
defer st.Close()
if _, ok := st.VerifyLogin("ada", "keep-password"); !ok {
t.Fatal("password did not survive reopen")
}
}
func openLife(t *testing.T) *Store {
t.Helper()
st, err := OpenSQLite(filepath.Join(t.TempDir(), "agent-system.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
+34 -19
View File
@@ -223,19 +223,10 @@ func VerifyPassword(hash, password string) bool {
// (原子化:旧实现先查后插再 UPDATE,两个并发 create 同名会在 UPDATE 步互相覆盖
// 角色与密码——R5/KTD12 并发建号不覆盖。)
func (s *Store) CreateAccount(owner, name, passwordHash string, isAdmin bool) error {
mustChange := passwordHash != ""
setAt := ""
if passwordHash != "" {
setAt = nowISO()
}
// 布尔列一律绑 Go bool 参数、不写 1/0 字面量:SQLite 两侧都容忍,
// PostgreSQL 对 boolean 列收到 integer 会 42804(2026-10-05 user create 半失败根因)。
_, err := s.db.Exec(s.q(`INSERT INTO users(id,owner,name,created_at,password_hash,password_set_at,must_change_password,is_admin)
VALUES(?,?,?,?,?,?,?,?)`),
newID("usr"), owner, name, nowISO(), passwordHash, setAt, mustChange, isAdmin)
if isUniqueViolation(err) {
return ErrConflict
}
_, err := s.CreateActivatedAccount(AccountWrite{
Owner: owner, Name: name, PasswordHash: passwordHash, IsAdmin: isAdmin,
Action: "account.create",
})
return err
}
@@ -322,17 +313,32 @@ func (s *Store) CreateSessionScoped(owner, scope string, ttl time.Duration) (str
if ttl <= 0 {
ttl = sessionTTL
}
ver := 0
_ = s.db.QueryRow(s.q(`SELECT auth_version FROM users WHERE owner=?`), owner).Scan(&ver)
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
token := "sess_" + hex.EncodeToString(raw)
exp := time.Now().Add(ttl)
_, err := s.db.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version) VALUES(?,?,?,?,?,?,?)`),
tx, err := s.db.Begin()
if err != nil {
return "", err
}
defer tx.Rollback()
if err := s.dialect.lockAccount(tx, owner); err != nil {
return "", err
}
var ver int
if err := tx.QueryRow(s.q(`SELECT auth_version FROM users WHERE owner=?`), owner).Scan(&ver); errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
} else if err != nil {
return "", err
}
_, err = tx.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version) VALUES(?,?,?,?,?,?,?)`),
hashKey(token), owner, nowISO(), exp.UTC().Format(time.RFC3339Nano), nowISO(), scope, ver)
return token, err
if err != nil {
return "", err
}
return token, tx.Commit()
}
// CreateSessionVerified 建会话并绑定期望认证版本:事务内重读账户版本,与密码验证
@@ -355,6 +361,9 @@ func (s *Store) CreateSessionVerified(owner, scope string, ttl time.Duration, ex
return "", err
}
defer tx.Rollback()
if err := s.dialect.lockAccount(tx, owner); err != nil {
return "", err
}
var ver int
err = tx.QueryRow(s.q(`SELECT auth_version FROM users WHERE owner=?`), owner).Scan(&ver)
if errors.Is(err, sql.ErrNoRows) {
@@ -385,11 +394,17 @@ func (s *Store) SessionInfo(token string) (owner, scope string, ok bool) {
return "", "", false
}
var exp string
err := s.db.QueryRow(s.q(`SELECT owner,expires_at,COALESCE(scope,'full') FROM sessions WHERE token_hash=?`), hashKey(token)).
Scan(&owner, &exp, &scope)
var sessionVersion, accountVersion int
err := s.db.QueryRow(s.q(`SELECT s.owner,s.expires_at,COALESCE(s.scope,'full'),s.auth_version,u.auth_version
FROM sessions s JOIN users u ON u.owner=s.owner WHERE s.token_hash=?`), hashKey(token)).
Scan(&owner, &exp, &scope, &sessionVersion, &accountVersion)
if err != nil {
return "", "", false
}
if sessionVersion != accountVersion {
_, _ = s.db.Exec(s.q(`DELETE FROM sessions WHERE token_hash=? AND auth_version=?`), hashKey(token), sessionVersion)
return "", "", false
}
if expT, err := time.Parse(time.RFC3339Nano, exp); err != nil || time.Now().After(expT) {
s.db.Exec(s.q(`DELETE FROM sessions WHERE token_hash=?`), hashKey(token))
return "", "", false
+8
View File
@@ -20,6 +20,8 @@ type dialect interface {
// lockBootstrap 锁定首次管理员引导的「核验无用户→建号→激活 Key」串行段
//(KTD12:防两个进程并发引导出双管理员)。
lockBootstrap(tx *sql.Tx) error
// lockAccount serializes credential and session admission for one owner.
lockAccount(tx *sql.Tx, owner string) error
}
// ---- SQLite(嵌入式档)----
@@ -32,6 +34,7 @@ func (sqliteDialect) lockConversation(*sql.Tx, string) error { return nil }
func (sqliteDialect) lockScope(*sql.Tx, string, string) error { return nil }
func (sqliteDialect) lockAdmission(*sql.Tx) error { return nil }
func (sqliteDialect) lockBootstrap(*sql.Tx) error { return nil }
func (sqliteDialect) lockAccount(*sql.Tx, string) error { return nil }
// ---- PostgreSQL(多用户/多实例档)----
@@ -72,3 +75,8 @@ func (postgresDialect) lockBootstrap(tx *sql.Tx) error {
_, err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext($1))`, "bootstrap:first-admin")
return err
}
func (postgresDialect) lockAccount(tx *sql.Tx, owner string) error {
_, err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext($1))`, "account:"+owner)
return err
}
+151 -166
View File
@@ -112,6 +112,15 @@ func (s *Store) IssueLoginKey(owner, purpose, createdBy string, ttl time.Duratio
return "", meta, err
}
defer tx.Rollback()
if err := s.dialect.lockAccount(tx, owner); err != nil {
return "", meta, err
}
var exists int
if err := tx.QueryRow(s.q(`SELECT 1 FROM users WHERE owner=?`), owner).Scan(&exists); errors.Is(err, sql.ErrNoRows) {
return "", meta, ErrNotFound
} else if err != nil {
return "", meta, err
}
meta, err = s.insertLoginKeyTx(tx, owner, purpose, createdBy, plain, id, now, exp)
if err != nil {
return "", meta, err
@@ -147,59 +156,9 @@ func (s *Store) insertLoginKeyTx(tx *sql.Tx, owner, purpose, createdBy, plain, i
// BootstrapFirstAdmin 首位管理员引导单事务:咨询锁串行(PG)→ 核验库内无用户 →
// 建管理员行 + 激活 Key + 审计共同提交;任何用户已存在返回 ErrConflict(拒绝接管)。
// SQLite 无咨询锁:插入后复查用户数恰为 1,双引导并发时后提交者回滚。
func (s *Store) BootstrapFirstAdmin(owner, name, createdBy string) (plain string, err error) {
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
plain = "alk_" + hex.EncodeToString(raw)
idRaw := make([]byte, 8)
if _, err := rand.Read(idRaw); err != nil {
return "", err
}
id := "lk_" + hex.EncodeToString(idRaw)
now := time.Now().UTC()
exp := now.Add(defaultLoginKeyTTL)
tx, err := s.db.Begin()
if err != nil {
return "", err
}
defer tx.Rollback()
if err := s.dialect.lockBootstrap(tx); err != nil {
return "", err
}
var n int
if err := tx.QueryRow(s.q(`SELECT COUNT(1) FROM users`)).Scan(&n); err != nil {
return "", err
}
if n > 0 {
return "", ErrConflict // 有数据环境拒绝接管(R11)
}
if _, err := tx.Exec(s.q(`INSERT INTO users(id,owner,name,created_at,password_hash,password_set_at,must_change_password,is_admin)
VALUES(?,?,?,?,?,?,?,?)`),
newID("usr"), owner, name, nowISO(), "", "", false, true); err != nil {
if isUniqueViolation(err) {
return "", ErrConflict
}
return "", err
}
// SQLite 并发兜底:本事务内用户数必须恰为 1(另一引导已提交则回滚)。
if err := tx.QueryRow(s.q(`SELECT COUNT(1) FROM users`)).Scan(&n); err != nil {
return "", err
}
if n != 1 {
return "", ErrConflict
}
if _, err := s.insertLoginKeyTx(tx, owner, LoginKeyPurposeActivate, createdBy, plain, id, now, exp); err != nil {
return "", err
}
if err := s.insertAccountEvent(tx, createdBy, entranceOf(createdBy), owner, "bootstrap_admin", owner); err != nil {
return "", err
}
if err := tx.Commit(); err != nil {
return "", err
}
return plain, nil
func (s *Store) BootstrapFirstAdmin(owner, name, createdBy string) (string, error) {
out, err := s.BootstrapFirstAdminWrite(AccountWrite{Owner: owner, Name: name, CreatedBy: createdBy, Actor: createdBy})
return out.Plain, err
}
// RedeemBlocked 兑换入口跨实例退避:事务内清理窗口外失败记录,判定该来源 IP
@@ -254,11 +213,20 @@ func (s *Store) RevokeLoginKey(id string) error {
// RevokeLoginKeyCascade 撤销 Key 并作废该用户全部 set_password_only 会话(管理员
// HTTP 入口)。Key 已消费/过期时仍返回属主并作废会话;编号不存在才报 NotFound。
func (s *Store) RevokeLoginKeyCascade(id, actor string) (owner string, err error) {
if err := s.db.QueryRow(s.q(`SELECT owner FROM login_keys WHERE id=?`), id).Scan(&owner); errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
} else if err != nil {
return "", err
}
tx, err := s.db.Begin()
if err != nil {
return "", err
}
defer tx.Rollback()
if err := s.dialect.lockAccount(tx, owner); err != nil {
return "", err
}
lockedOwner := owner
err = tx.QueryRow(s.q(`SELECT owner FROM login_keys WHERE id=?`), id).Scan(&owner)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
@@ -266,6 +234,9 @@ func (s *Store) RevokeLoginKeyCascade(id, actor string) (owner string, err error
if err != nil {
return "", err
}
if owner != lockedOwner {
return "", ErrConflict
}
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE id=? AND status=?`),
LoginKeyStatusRevoked, id, LoginKeyStatusUnused); err != nil {
return "", err
@@ -329,11 +300,20 @@ func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err er
if plain == "" || len(plain) < 5 || plain[:4] != "alk_" {
return "", "", ErrNotFound
}
var lockOwner string
if err := s.db.QueryRow(s.q(`SELECT owner FROM login_keys WHERE key_hash=?`), hashKey(plain)).Scan(&lockOwner); errors.Is(err, sql.ErrNoRows) {
return "", "", ErrNotFound
} else if err != nil {
return "", "", err
}
tx, err := s.db.Begin()
if err != nil {
return "", "", err
}
defer tx.Rollback()
if err := s.dialect.lockAccount(tx, lockOwner); err != nil {
return "", "", err
}
var id, o, status, exp string
err = tx.QueryRow(s.q(`SELECT id,owner,status,expires_at FROM login_keys WHERE key_hash=?`), hashKey(plain)).
Scan(&id, &o, &status, &exp)
@@ -343,6 +323,9 @@ func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err er
if err != nil {
return "", "", err
}
if o != lockOwner {
return "", "", ErrConflict
}
expT, err := time.Parse(time.RFC3339Nano, exp)
if err != nil || time.Now().After(expT) {
_, _ = tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE id=? AND status=?`), LoginKeyStatusExpired, id, LoginKeyStatusUnused)
@@ -389,137 +372,139 @@ func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err er
// 写 hash、清 must_change、bump auth_version、作废未用登录 Key、删除全部会话、
// 写成功审计。会话或版本失效时返回 ErrConflict,不做任何写入。
func (s *Store) CompleteSetPasswordChecked(token, passwordHash string) (owner string, err error) {
tx, err := s.db.Begin()
// Discover only the lock key here. Authorization is checked again after
// acquiring the owner's transaction lock, alongside key reissue/revocation.
if err := s.db.QueryRow(s.q(`SELECT owner FROM sessions WHERE token_hash=?`), hashKey(token)).Scan(&owner); errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
} else if err != nil {
return "", err
}
lockOwner := owner
err = s.accountTx(AccountWrite{Owner: owner, Actor: owner, Action: "account.set_password"}, func(tx *sql.Tx, _ string) error {
var scope, sessExp, actualOwner string
var sessVer, userVer int
var mustChange bool
var hash sql.NullString
err := tx.QueryRow(s.q(`SELECT s.owner,s.scope,s.expires_at,s.auth_version,u.auth_version,u.must_change_password,u.password_hash
FROM sessions s JOIN users u ON u.owner=s.owner WHERE s.token_hash=?`), hashKey(token)).
Scan(&actualOwner, &scope, &sessExp, &sessVer, &userVer, &mustChange, &hash)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
if actualOwner != lockOwner {
return ErrConflict
}
if expT, perr := time.Parse(time.RFC3339Nano, sessExp); perr != nil || time.Now().After(expT) {
return ErrNotFound
}
if scope != SessionScopeSetPasswordOnly && !mustChange && hash.Valid && hash.String != "" {
return ErrConflict
}
if sessVer != userVer {
return ErrConflict
}
res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=?`),
passwordHash, nowISO(), false, lockOwner)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n != 1 {
return ErrNotFound
}
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`),
LoginKeyStatusRevoked, lockOwner, LoginKeyStatusUnused); err != nil {
return err
}
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), lockOwner); err != nil {
return err
}
entrance := "must-change"
if scope == SessionScopeSetPasswordOnly {
entrance = "recovery"
}
return s.insertAccountEvent(tx, lockOwner, entrance, lockOwner, "password_set", entrance)
})
if err != nil {
return "", err
}
defer tx.Rollback()
var scope, sessExp string
var sessVer, userVer int
var mustChange bool
var hash sql.NullString
err = tx.QueryRow(s.q(`SELECT s.owner,s.scope,s.expires_at,s.auth_version,u.auth_version,u.must_change_password,u.password_hash
FROM sessions s JOIN users u ON u.owner=s.owner WHERE s.token_hash=?`), hashKey(token)).
Scan(&owner, &scope, &sessExp, &sessVer, &userVer, &mustChange, &hash)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
if err != nil {
return "", err
}
if expT, perr := time.Parse(time.RFC3339Nano, sessExp); perr != nil || time.Now().After(expT) {
return "", ErrNotFound
}
// 受限会话、待改密、未设密三者之一才允许免旧密码设密;其余按主动改密路径走。
if scope != SessionScopeSetPasswordOnly && !mustChange && (hash.Valid && hash.String != "") {
return "", ErrConflict
}
// 会话创建后账户凭据已变(并发改密/重置):本次授权过期,拒绝继续。
if sessVer != userVer {
return "", ErrConflict
}
res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=?`),
passwordHash, nowISO(), false, owner)
if err != nil {
return "", err
}
if n, _ := res.RowsAffected(); n == 0 {
return "", ErrNotFound
}
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`),
LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil {
return "", err
}
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil {
return "", err
}
entrance := "must-change"
if scope == SessionScopeSetPasswordOnly {
entrance = "recovery"
}
if err := s.insertAccountEvent(tx, owner, entrance, owner, "password_set", entrance); err != nil {
return "", err
}
return owner, tx.Commit()
return lockOwner, nil
}
// ChangeUserPasswordActive 主动改密单事务:校验当前密码 → 更新(带认证版本乐观
// 检查)→ bump auth_version → 吊销其他会话(保留当前)→ 审计。中途失败无部分成功。
func (s *Store) ChangeUserPasswordActive(owner, current, passwordHash, keepToken string) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
var hash string
var ver int
var mustChange bool
err = tx.QueryRow(s.q(`SELECT password_hash,auth_version,must_change_password FROM users WHERE owner=?`), owner).
Scan(&hash, &ver, &mustChange)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
if mustChange || hash == "" {
// 强制态必须走受限设密路径,不在此处用旧密码换新密码。
return ErrConflict
}
if !VerifyPassword(hash, current) {
return ErrBadCredentials
}
res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=? AND auth_version=?`),
passwordHash, nowISO(), false, owner, ver)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrConflict // 并发改密:版本已前进
}
if keepToken != "" {
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND token_hash<>?`), owner, hashKey(keepToken)); err != nil {
return s.accountTx(AccountWrite{Owner: owner, Actor: owner, Action: "account.change_password"}, func(tx *sql.Tx, _ string) error {
var hash string
var ver int
var mustChange bool
err := tx.QueryRow(s.q(`SELECT password_hash,auth_version,must_change_password FROM users WHERE owner=?`), owner).
Scan(&hash, &ver, &mustChange)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
} else if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil {
return err
}
if err := s.insertAccountEvent(tx, owner, "settings", owner, "password_change", ""); err != nil {
return err
}
return tx.Commit()
if mustChange || hash == "" {
return ErrConflict
}
if !VerifyPassword(hash, current) {
return ErrBadCredentials
}
res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=? AND auth_version=?`),
passwordHash, nowISO(), false, owner, ver)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n != 1 {
return ErrConflict
}
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`),
LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil {
return err
}
if keepToken != "" {
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND token_hash<>?`), owner, hashKey(keepToken)); err != nil {
return err
}
if _, err := tx.Exec(s.q(`UPDATE sessions SET auth_version=? WHERE owner=? AND token_hash=?`),
ver+1, owner, hashKey(keepToken)); err != nil {
return err
}
} else if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil {
return err
}
return s.insertAccountEvent(tx, owner, "settings", owner, "password_change", "")
})
}
// AdminResetUserPassword 管理员/CLI 重置密码单事务:写 hash、must_change=true、
// bump auth_version、作废未用登录 Key、吊销全部会话、审计(actor 为操作者)。
func (s *Store) AdminResetUserPassword(owner, passwordHash, actor, entrance string) error {
tx, err := s.db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=?`),
passwordHash, nowISO(), true, owner)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`),
LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil {
return err
}
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil {
return err
}
if entrance == "" {
entrance = entranceOf(actor)
}
if err := s.insertAccountEvent(tx, actor, entrance, owner, "admin_password_reset", ""); err != nil {
return err
}
return tx.Commit()
return s.accountTx(AccountWrite{Owner: owner, Actor: actor, Action: "account.reset_password"}, func(tx *sql.Tx, _ string) error {
res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=?`),
passwordHash, nowISO(), true, owner)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n != 1 {
return ErrNotFound
}
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`),
LoginKeyStatusRevoked, owner, LoginKeyStatusUnused); err != nil {
return err
}
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), owner); err != nil {
return err
}
return s.insertAccountEvent(tx, actor, entrance, owner, "admin_password_reset", "")
})
}
// CountUnusedLoginKeys 未使用登录 Key 计数(list 展示)。
+23
View File
@@ -848,6 +848,29 @@ func init() {
},
down: map[string][]string{"sqlite": {`DROP TABLE IF EXISTS project_event_shares`}, "postgres": {`DROP TABLE IF EXISTS project_event_shares`}},
},
migration{version: 31, name: "account_audit",
up: map[string][]string{"sqlite": {
`ALTER TABLE audit_events ADD COLUMN entry TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE audit_events ADD COLUMN instance_id TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE audit_events ADD COLUMN operation_id TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE audit_events ADD COLUMN outcome TEXT NOT NULL DEFAULT ''`,
}, "postgres": {
`ALTER TABLE audit_events ADD COLUMN entry TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE audit_events ADD COLUMN instance_id TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE audit_events ADD COLUMN operation_id TEXT NOT NULL DEFAULT ''`,
`ALTER TABLE audit_events ADD COLUMN outcome TEXT NOT NULL DEFAULT ''`,
}},
down: map[string][]string{"sqlite": {
`ALTER TABLE audit_events DROP COLUMN outcome`,
`ALTER TABLE audit_events DROP COLUMN operation_id`,
`ALTER TABLE audit_events DROP COLUMN instance_id`,
`ALTER TABLE audit_events DROP COLUMN entry`,
}, "postgres": {
`ALTER TABLE audit_events DROP COLUMN outcome`,
`ALTER TABLE audit_events DROP COLUMN operation_id`,
`ALTER TABLE audit_events DROP COLUMN instance_id`,
`ALTER TABLE audit_events DROP COLUMN entry`,
}}},
)
}
+1
View File
@@ -35,6 +35,7 @@ var migrationBreaksOldReaders = map[int]bool{
28: false, // auth_version_account_events:仅加列与新表,旧读者不受影响
29: false, // redeem_attempts:新表,旧读者不受影响
30: false, // project_event_publication_proofs:新表,旧读者不受影响
31: false, // account_audit 给 audit_events 增加带默认值的列,旧制品按列名读取
}
// SchemaTarget 是本二进制会迁移到的 schema 版本。
+26
View File
@@ -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()
}
+91
View File
@@ -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()
}
+24 -12
View File
@@ -77,6 +77,7 @@ type Store struct {
migrateMu sync.Mutex
migrateFault error // 非空时 migratePass 在「DDL 已执行、版本未登记」处失败(原子性测试)
accountFault error // 非空时账户事务在提交前失败(半态测试)
vecPGOnce sync.Once // pgvector 列探测(U6-3:一次性缓存)
vecPG bool
@@ -87,7 +88,7 @@ func OpenSQLite(path string) (*Store, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
return openSQLiteFile(path, true)
return openSQLiteFile(path, true, true)
}
// OpenIdentity 打开既有库做身份 ensure/verify:不创建文件或目录,不执行迁移。
@@ -171,8 +172,8 @@ func (s *Store) SchemaVersion() (int, error) {
return int(v.Int64), nil
}
// OpenExistingSQLite 打开既有嵌入式库:数据文件不存在则失败,不创建空库或父目录。
// 仍执行兼容迁移(requireCompatible);用于账户运维,避免恢复操作顺带建库。
// OpenExistingSQLite 打开既有嵌入式库:数据文件不存在则失败,不创建空库或父目录,也不迁移。
// schema 与本二进制不一致时拒绝,并提示使用 migrate 或先启动服务。
func OpenExistingSQLite(path string) (*Store, error) {
if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) {
@@ -180,10 +181,10 @@ func OpenExistingSQLite(path string) (*Store, error) {
}
return nil, err
}
return openSQLiteFile(path, false)
return openSQLiteFile(path, false, false)
}
func openSQLiteFile(path string, createArtifacts bool) (*Store, error) {
func openSQLiteFile(path string, createArtifacts, migrateSchema bool) (*Store, error) {
// _txlock=immediate:写事务在 BEGIN 即取库写锁(而非首个写语句升级),
// 跨进程迁移/写路径依赖 busy_timeout 排队而不是中途 SQLITE_BUSY(KTD8)。
// busy_timeout 30s:迁移竞争测试在慢速 CI 盘上 4 个迁移者串行全程可超 5s
@@ -195,7 +196,7 @@ func openSQLiteFile(path string, createArtifacts bool) (*Store, error) {
}
// SQLite 单写者:串行化连接,语句级原子 + 版本化迁移事务。
db.SetMaxOpenConns(1)
return finishOpen(db, sqliteDialect{}, filepath.Join(filepath.Dir(path), "artifacts"), nil, PoolOptions{}, createArtifacts)
return finishOpenMode(db, sqliteDialect{}, filepath.Join(filepath.Dir(path), "artifacts"), nil, PoolOptions{}, createArtifacts, migrateSchema)
}
// OpenPostgres 打开 PostgreSQL 档存储并执行迁移(默认连接预算)。
@@ -214,15 +215,15 @@ func OpenPostgresWithBlobs(dsn, artifactsDir string, blobs blobstore.Store) (*St
// 以 statement_timeout 运行参数附着到 DSN(对 URL 与键值两种形式均生效)。
// blobs 为 nil(含带类型的空指针,例如未配置端点时的 (*blobstore.S3)(nil))时回落本地目录。
func OpenPostgresWithOptions(dsn, artifactsDir string, pool PoolOptions, blobs blobstore.Store) (*Store, error) {
return openPostgresWithOptions(dsn, artifactsDir, pool, blobs, true)
return openPostgresWithOptions(dsn, artifactsDir, pool, blobs, true, true)
}
// OpenExistingPostgres 打开既有 PG 库并执行兼容迁移;不创建 artifacts 目录。
// OpenExistingPostgres 打开既有 PG 库,不创建 artifacts 目录,也不迁移。
func OpenExistingPostgres(dsn, artifactsDir string) (*Store, error) {
return openPostgresWithOptions(dsn, artifactsDir, PoolOptions{}, nil, false)
return openPostgresWithOptions(dsn, artifactsDir, PoolOptions{}, nil, false, false)
}
func openPostgresWithOptions(dsn, artifactsDir string, pool PoolOptions, blobs blobstore.Store, createArtifacts bool) (*Store, error) {
func openPostgresWithOptions(dsn, artifactsDir string, pool PoolOptions, blobs blobstore.Store, createArtifacts, migrateSchema bool) (*Store, error) {
pool = pool.effective()
if pool.QueryTimeoutSec > 0 {
dsn = withPGRuntimeParam(dsn, "statement_timeout", strconv.Itoa(pool.QueryTimeoutSec*1000))
@@ -237,7 +238,7 @@ func openPostgresWithOptions(dsn, artifactsDir string, pool PoolOptions, blobs b
if pool.ConnMaxLifetimeSec > 0 {
db.SetConnMaxLifetime(time.Duration(pool.ConnMaxLifetimeSec) * time.Second)
}
return finishOpen(db, postgresDialect{}, artifactsDir, blobs, pool, createArtifacts)
return finishOpenMode(db, postgresDialect{}, artifactsDir, blobs, pool, createArtifacts, migrateSchema)
}
// withPGRuntimeParam 向 DSN 追加运行时参数(libpq 语义:URL 查询串或键值对)。
@@ -263,7 +264,13 @@ func AppendPGRuntimeParam(dsn, key, value string) string {
return dsn + sep + kv
}
// Preserve the existing initialization helper contract; maintenance opens select
// nonmigrating mode explicitly through finishOpenMode.
func finishOpen(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.Store, pool PoolOptions, createArtifacts bool) (*Store, error) {
return finishOpenMode(db, d, artifactsDir, blobs, pool, createArtifacts, true)
}
func finishOpenMode(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.Store, pool PoolOptions, createArtifacts, migrateSchema bool) (*Store, error) {
if blobstore.IsNil(blobs) {
var err error
if createArtifacts {
@@ -277,7 +284,12 @@ func finishOpen(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.Stor
}
}
s := &Store{db: db, dialect: d, dataDir: artifactsDir, blobs: blobs, pool: pool}
if err := s.migrate(); err != nil {
if migrateSchema {
if err := s.migrate(); err != nil {
db.Close()
return nil, err
}
} else if err := s.requireCurrentSchema(); err != nil {
db.Close()
return nil, err
}
+151
View File
@@ -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.
+3 -1
View File
@@ -34,7 +34,9 @@ import (
// 分区组件自身的命令面 fail-closed)。
// 2.12.0-experimental: optional ProjectHost/ProjectLifecycleRegistry and trusted
// 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 无法启动服务。
var ErrNotLinked = errors.New("extension: runner not linked; blank-import agent-system-backend/pkg/extension/link")
+17
View File
@@ -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
}
+3
View File
@@ -43,6 +43,9 @@ ProjectHost.RenameOwnedProject
ProjectLifecycle
ProjectLifecycleRegistry
ProjectLifecycleRegistry.RegisterProjectLifecycle
ProjectSettingsAuthorizer
ProjectSettingsRegistry
ProjectSettingsRegistry.RegisterProjectSettingsAuthorizer
RegisterCompiled
Registry
Registry.AddAdminZone