Compare commits

..
Author SHA1 Message Date
weichenandCursor 0c7961839f Include agent_system_identity in storage-migrate copyOrder
ci / vet-and-test (pull_request) Successful in 50s
Why: TestCopyOrderCoversAllFixtureTables fails when new schema tables
are omitted from the migrate tool inventory.
Evidence: go test ./cmd/storage-migrate/ -run CopyOrder
Impact: copyOrder gains agent_system_identity (no FK deps).
Refs: U1 I3; Backend PR #170

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 04:04:39 +00:00
weichenandCursor dc47c10d68 Add agent_system_identity marker with ensure/verify CLI
Why: U1 I3 needs a DB-side instance identity anchor matching
manifest.instance_id so wrong/missing DB blocks startup (I4).
Evidence: go test ./internal/store/ ./internal/app/; CLI smoke
ensure/verify on SQLite temp dir.
Impact: migration v26; store Ensure/Verify; identity subcommand;
serve checks AGENT_SYSTEM_INSTANCE_ID when set.
Refs: docs/recovery/u1-identity-manifest.md §5.2; Dev feature/recovery-u1-db-identity

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-12 04:04:26 +00:00
111 changed files with 533 additions and 12235 deletions
-89
View File
@@ -1,89 +0,0 @@
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
@@ -1,158 +0,0 @@
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")
}
})
}
}
-28
View File
@@ -1,28 +0,0 @@
package main
import (
"os"
"os/exec"
"strings"
"testing"
)
func TestUnknownSubcommandExits(t *testing.T) {
if os.Getenv("DISPATCH_CHILD") == "1" {
os.Args = []string{"agent-system", "not-a-command"}
main()
return
}
cmd := exec.Command(os.Args[0], "-test.run=TestUnknownSubcommandExits")
cmd.Env = append(os.Environ(), "DISPATCH_CHILD=1")
out, err := cmd.CombinedOutput()
if err == nil {
t.Fatalf("未知子命令应非零退出:%s", out)
}
if !strings.Contains(string(out), "未知子命令") {
t.Fatalf("输出:%s", out)
}
if strings.Contains(string(out), "agent-system starting") || strings.Contains(string(out), "serving") {
t.Fatalf("未知子命令落入了服务路径:%s", out)
}
}
-90
View File
@@ -1,90 +0,0 @@
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)
}
}
+64 -287
View File
@@ -9,28 +9,18 @@ import (
"flag"
"fmt"
"os"
"strings"
"time"
"agent-system-backend/internal/app"
"agent-system-backend/internal/config"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/identity"
)
var version = "0.1.0-dev"
func main() {
if len(os.Args) > 1 && !strings.HasPrefix(os.Args[1], "-") {
if len(os.Args) > 1 {
switch os.Args[1] {
case "version", "--version":
if len(os.Args) > 2 && os.Args[2] == "--json" {
if err := identity.WriteDocument(os.Stdout, "agent-system", version); err != nil {
fmt.Fprintln(os.Stderr, "version:", err)
os.Exit(1)
}
return
}
fmt.Println("agent-system", version)
return
case "serve":
@@ -43,12 +33,6 @@ 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|capabilities> [参数]")
os.Exit(2)
}
}
fs := flag.NewFlagSet("serve", flag.ExitOnError)
@@ -195,49 +179,28 @@ 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-file 或 AGENT_SYSTEM_DSN)")
dsnFile := fs.String("dsn-file", "", "0600权限的PostgreSQL DSN文件")
dsn := fs.String("dsn", "", "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":
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))
fmt.Fprintln(os.Stderr, "apikey create:", err)
return 1
}
// 明文仅此一次输出;库中只存 SHA-256 摘要。
if err := delivery.deliver("api-key", *owner, key, nil); err != nil {
fmt.Fprintln(os.Stderr, "apikey create:", err)
return 1
}
fmt.Println(key)
return 0
case "list":
keys, err := app.ListAPIKeys(*dsn, *data, *owner)
if err != nil {
fmt.Fprintln(os.Stderr, "apikey list:", identityCommandError(*dsn, err))
fmt.Fprintln(os.Stderr, "apikey list:", err)
return 1
}
out, _ := json.MarshalIndent(keys, "", " ")
@@ -246,7 +209,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:", identityCommandError(*dsn, err))
fmt.Fprintln(os.Stderr, "apikey revoke:", err)
return 1
}
if !ok {
@@ -261,208 +224,36 @@ 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 {
if outPath == "" {
fmt.Println(plaintext)
return nil
}
payload := map[string]any{"kind": kind, "owner": owner, "secret": plaintext}
if expiresAt != nil {
payload["expiresAt"] = expiresAt
}
buf, err := json.MarshalIndent(payload, "", " ")
if err != nil {
return err
}
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 兼容旧随机密码路径。
// runUser:用户账号管理子命令(管理员发放制,R2)——create / reset-password / list。
// 密码未显式给定时生成随机值,明文仅此一次输出;创建/重置后首次登录强制改密。
func runUser(args []string) int {
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "用法:agent-system user <create|reset-password|list|issue-login-key|revoke-login-key|bootstrap-admin> [flags]")
fmt.Fprintln(os.Stderr, "用法:agent-system user <create|reset-password|list> [--owner x --name y --admin --password p]")
return 2
}
sub := args[0]
fs := flag.NewFlagSet("user "+sub, flag.ExitOnError)
data := fs.String("data", "./data", "数据目录")
dsn := fs.String("dsn", "", "PostgreSQL DSN(已弃用;优先使用 --dsn-file 或 AGENT_SYSTEM_DSN)")
dsnFile := fs.String("dsn-file", "", "0600 权限的 DSN 文件")
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式档)")
owner := fs.String("owner", "", "用户名(owner)")
name := fs.String("name", "", "显示名")
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, "兼容旧参数;账户命令始终只打开既有存储")
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)")
revokeAll := fs.Bool("revoke-all-credentials", false, "签发时撤销全部长期 API Key 与会话")
keyID := fs.String("id", "", "revoke-login-key 凭据编号")
out := fs.String("out", "", "凭据交付文件(0600 JSON,代替 stdout 明文;非交互推荐)")
password := fs.String("password", "", "密码(空 = 随机生成并打印一次)")
admin := fs.Bool("admin", false, "管理员(可管理用户与密钥)")
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 == "" {
fmt.Fprintln(os.Stderr, "user create: --owner 必填")
return 2
}
isAdmin := *admin
switch *role {
case "user":
case "admin":
isAdmin = true
default:
fmt.Fprintf(os.Stderr, "user create: 未知角色 %q\n", *role)
return 2
}
pw, opID, err := app.CreateUserAccount(*dsn, *data, *owner, *name, *password, isAdmin, *openExisting, *generatePassword)
pw, err := app.CreateUserAccount(*dsn, *data, *owner, *name, *password, *admin)
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
}
fmt.Fprintf(os.Stderr, "operation_id=%s\n", opID)
if *out == "" && *outFile == "" {
if *password == "" && !*generatePassword {
fmt.Fprintln(os.Stderr, "一次性激活 Key 仅此一次输出;兑换后设置密码。")
} else if *password == "" {
fmt.Fprintln(os.Stderr, "初始密码仅此一次输出;该用户首次登录须修改密码。")
}
fmt.Println(pw)
if *password == "" {
fmt.Fprintln(os.Stderr, "初始密码仅此一次输出;该用户首次登录须修改密码。")
}
return 0
case "reset-password":
@@ -470,79 +261,25 @@ func runUser(args []string) int {
fmt.Fprintln(os.Stderr, "user reset-password: --owner 必填")
return 2
}
pw, err := app.ResetUserPassword(*dsn, *data, *owner, *password, *openExisting)
pw, err := app.ResetUserPassword(*dsn, *data, *owner, *password)
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 *out == "" && *outFile == "" && *password == "" {
fmt.Println(pw)
if *password == "" {
fmt.Fprintln(os.Stderr, "新密码仅此一次输出;该用户下次登录须修改密码(其余会话已吊销)。")
}
return 0
case "list":
users, err := app.ListUserAccounts(*dsn, *data, *openExisting)
users, err := app.ListUserAccounts(*dsn, *data)
if err != nil {
fmt.Fprintln(os.Stderr, "user list:", identityCommandError(*dsn, err))
fmt.Fprintln(os.Stderr, "user list:", err)
return 1
}
out, _ := json.MarshalIndent(users, "", " ")
fmt.Println(string(out))
return 0
case "issue-login-key":
if *owner == "" {
fmt.Fprintln(os.Stderr, "user issue-login-key: --owner 必填")
return 2
}
if *purpose != store.LoginKeyPurposeActivate && *purpose != store.LoginKeyPurposeRecover {
fmt.Fprintf(os.Stderr, "user issue-login-key: 无效 purpose %q\n", *purpose)
return 2
}
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 *out == "" && *outFile == "" {
fmt.Fprintln(os.Stderr, "一次性登录 Key 仅此一次输出。")
}
return 0
case "revoke-login-key":
if *keyID == "" {
fmt.Fprintln(os.Stderr, "user revoke-login-key: --id 必填")
return 2
}
if err := app.RevokeUserLoginKey(*dsn, *data, *keyID, *openExisting); err != nil {
fmt.Fprintln(os.Stderr, "user revoke-login-key:", identityCommandError(*dsn, err))
return 1
}
fmt.Println("revoked")
return 0
case "bootstrap-admin":
if *owner == "" {
fmt.Fprintln(os.Stderr, "user bootstrap-admin: --owner 必填")
return 2
}
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 *out == "" && *outFile == "" {
fmt.Fprintln(os.Stderr, "首位管理员激活 Key 仅此一次输出。")
}
return 0
default:
fmt.Fprintln(os.Stderr, "user: 未知子命令", sub)
return 2
@@ -550,8 +287,48 @@ func runUser(args []string) int {
}
// runIdentity:U1 I3 服务实例库内身份(manifest.instance_id ↔ agent_system_identity)。
// 与 serve --instance(runs 进程标识)无关。实现在 pkg/identity(公共化:下游
// 组合仓共用同一 CLI,未知子命令 usage 退出、不落服务路径——2026-10-12 裁定)。
// 与 serve --instance(runs 进程标识)无关。
func runIdentity(args []string) int {
return identity.Run(args)
if len(args) == 0 {
fmt.Fprintln(os.Stderr, "用法:agent-system identity <ensure|verify> --instance-id ID [--dbref REF] [--data DIR] [--dsn DSN]")
return 2
}
sub := args[0]
fs := flag.NewFlagSet("identity "+sub, flag.ExitOnError)
data := fs.String("data", "./data", "数据目录(SQLite 路径 <data>/agent-system.db)")
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式 SQLite)")
instanceID := fs.String("instance-id", "", "服务实例 ID(manifest.instance_id,形如 svc-<group>-…)")
dbref := fs.String("dbref", "", "数据库锚点(空 = sqlite 或 pg)")
fs.Parse(args[1:])
if *instanceID == "" {
fmt.Fprintln(os.Stderr, "identity: --instance-id 必填")
return 2
}
ref := *dbref
if ref == "" {
if *dsn != "" {
ref = "pg"
} else {
ref = "sqlite"
}
}
switch sub {
case "ensure":
if err := app.EnsureSystemIdentity(*dsn, *data, *instanceID, ref); err != nil {
fmt.Fprintln(os.Stderr, "identity ensure:", err)
return 1
}
fmt.Printf("ensured instance_id=%s dbref=%s\n", *instanceID, ref)
return 0
case "verify":
if err := app.VerifySystemIdentity(*dsn, *data, *instanceID, ref); err != nil {
fmt.Fprintln(os.Stderr, "identity verify:", err)
return 1
}
fmt.Printf("verified instance_id=%s dbref=%s\n", *instanceID, ref)
return 0
default:
fmt.Fprintln(os.Stderr, "identity: 未知子命令", sub)
return 2
}
}
+2 -6
View File
@@ -284,19 +284,15 @@ var copyOrder = []string{
"projects", "users", "profiles", "settings", "plugins",
"conversations", "runs", "messages", "events", "artifacts",
"hitl_cards", "plans", "todos", "knowledge", "knowledge_vectors",
"activity_cards", "project_event_shares", "command_audit", "prompt_modules",
"activity_cards", "command_audit", "prompt_modules",
"sessions", "api_keys",
"plugin_grants", "plugin_schema", "plugin_kv", "background_tasks",
"memory_entries", "audit_events", "pending_confirms", "llm_usage_daily",
"secret_keys", "settings_snapshots", "regen_pending", "context_entries",
"user_commands", "pending_notices", "recovery_holds",
"surfaces", "surface_revisions", "surface_actions", "plugin_activation",
// v26 login_keys(#171 合入 main 时漏登记;本 PR 一并补齐)
"login_keys",
// U1 I3:服务实例 DB 身份(无外键;迁移须保留以免目标库丢锚)
"agent_system_identity",
"account_events",
"redeem_attempts",
}
const controlDDL = `
@@ -784,7 +780,7 @@ func advanceSequences(db *sql.DB, table string) error {
// checkOwners:业务行引用的 owner 必须存在于 users(单租户默认 ”/'local'
// 除外)。未知 owner 阻塞迁移(U4/R7:不静默带过未知归属)。
func checkOwners(db *sql.DB, src *sql.DB) error {
ownerTables := []string{"projects", "conversations", "runs", "artifacts", "knowledge", "hitl_cards", "profiles", "activity_cards", "project_event_shares", "command_audit"}
ownerTables := []string{"projects", "conversations", "runs", "artifacts", "knowledge", "hitl_cards", "profiles", "activity_cards", "command_audit"}
for _, t := range ownerTables {
if !sqliteHasTable(src, t) {
continue
-108
View File
@@ -1,108 +0,0 @@
package app
// EXT-19 消费测试:inprocHost.ListAccounts——最小目录(owner/valid)、游标分页、
// limit 钳制(1-200,缺省 50)。数据来自 users 表(真实 store,非桩)。
import (
"testing"
"agent-system-backend/internal/store"
)
func TestInprocHostListAccounts(t *testing.T) {
dir := t.TempDir()
st, err := store.OpenSQLite(dir + "/test.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
for _, o := range []string{"carol", "alice", "bob", "dave"} {
if _, err := st.CreateAPIKey(o, "e2e"); err != nil {
t.Fatal(err)
}
}
h := inprocHost{caps: hostcaps{st: st}}
// 首页(limit=2):alice/bob,next=bob。
page, next, err := h.ListAccounts("", 2)
if err != nil {
t.Fatal(err)
}
if len(page) != 2 || page[0].Owner != "alice" || page[1].Owner != "bob" {
t.Fatalf("首页不符: %+v", page)
}
for _, e := range page {
if !e.Valid {
t.Fatalf("现有账号应 valid: %+v", e)
}
}
if next != "bob" {
t.Fatalf("next 应为 bob, got %q", next)
}
// 次页:carol/dave,next=""(末页)。
page2, next2, _ := h.ListAccounts(next, 2)
if len(page2) != 2 || page2[0].Owner != "carol" || page2[1].Owner != "dave" || next2 != "" {
t.Fatalf("次页不符: %+v next=%q", page2, next2)
}
// limit 钳制:0 → 50 缺省;超 200 → 200(不炸)。
if p, _, _ := h.ListAccounts("", 0); len(p) != 4 {
t.Fatalf("limit=0 应取缺省(4 账号全量), got %d", len(p))
}
if _, _, err := h.ListAccounts("", 999); err != nil {
t.Fatalf("limit 超限应钳制不报错: %v", err)
}
}
// TestInprocHostSearchAccounts:EXT-19 扩展 SearchAccounts——账号/姓名子串
// 检索、大小写不敏感、空 query 首页、limit 钳制(P3,2026-10-13)。
func TestInprocHostSearchAccounts(t *testing.T) {
dir := t.TempDir()
st, err := store.OpenSQLite(dir + "/test.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
hash, err := store.HashPassword("init-pass-01")
if err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("alice", "王小明", hash, false); err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("bob", "李大明", hash, false); err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("carol", "", hash, false); err != nil {
t.Fatal(err)
}
h := inprocHost{caps: hostcaps{st: st}}
// 姓名子串(同名区分场景):两个"大明/小明"都以"明"命中。
got, err := h.SearchAccounts("明", 50)
if err != nil {
t.Fatal(err)
}
if len(got) != 2 || got[0].Owner != "alice" || got[1].Owner != "bob" {
t.Fatalf("姓名子串命中不符: %+v", got)
}
if got[0].Name != "王小明" {
t.Fatalf("目录行应携带姓名: %+v", got[0])
}
// 账号子串、大小写不敏感。
got, _ = h.SearchAccounts("AL", 50)
if len(got) != 1 || got[0].Owner != "alice" {
t.Fatalf("账号子串应命中 alice: %+v", got)
}
// 未命中。
if got, _ = h.SearchAccounts("不存在", 50); len(got) != 0 {
t.Fatalf("未命中应为空: %+v", got)
}
// 空 query=首页候选(owner 升序);姓名为空的账号 Name 为空串。
if got, _ = h.SearchAccounts("", 2); len(got) != 2 || got[0].Owner != "alice" || got[1].Name != "李大明" {
t.Fatalf("空 query 首页不符: %+v", got)
}
// ListAccounts 回归:分页行同样携带姓名。
page, _, _ := h.ListAccounts("", 2)
if page[0].Name != "王小明" {
t.Fatalf("ListAccounts 应携带姓名: %+v", page[0])
}
}
-58
View File
@@ -1,58 +0,0 @@
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")
}
}
+95 -211
View File
@@ -55,7 +55,6 @@ import (
"agent-system-backend/internal/surfacesvc"
"agent-system-backend/internal/tools"
"agent-system-backend/internal/workflow"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
@@ -292,7 +291,12 @@ func Run(opt Options) error {
if err := setSvc.SnapshotAndMigrateSecrets(); err != nil {
return fmt.Errorf("migrate secrets: %w", err)
}
runtime.SecretLookup = setSvc.LookupSecret
runtime.SecretLookup = func(owner, service string) (string, bool) {
if owner == "" {
owner = store.OwnerDefault
}
return st.ActiveSecret(owner, service)
}
rag.SecretLookup = func(service, fallback string) string {
return runtime.LookupSecret(store.OwnerDefault, service, fallback)
}
@@ -302,7 +306,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 := setSvc.LookupSecret(store.OwnerDefault, "tools.tavily"); ok {
if active, ok := st.ActiveSecret(store.OwnerDefault, "tools.tavily"); ok {
tavilyNow = active
}
keys := splitKeyPool(tavilyNow)
@@ -371,7 +375,7 @@ func Run(opt Options) error {
},
}
tavilyNow := effNow.GetString("tools.tavily.apiKeys", "")
if active, ok := setSvc.LookupSecret(store.OwnerDefault, "tools.tavily"); ok {
if active, ok := st.ActiveSecret(store.OwnerDefault, "tools.tavily"); ok {
tavilyNow = active
}
if keys := splitKeyPool(tavilyNow); len(keys) > 0 {
@@ -428,10 +432,7 @@ func Run(opt Options) error {
return fmt.Errorf("default profile: %w", err)
}
if opt.ForceMock {
// runtime.* 自 #188 起为系统受管项:启动选项属受信操作员输入,
// 播种走系统层(对所有租户生效);用户层写入会被受管拦截拒绝,
// 导致 serve --mock 直接启动失败(multi-instance 集成回归)。
if err := setSvc.MergeSystem(map[string]any{"runtime": map[string]any{"mock": true}}); err != nil {
if err := setSvc.MergeUser(store.OwnerDefault, map[string]any{"runtime": map[string]any{"mock": true}}); err != nil {
return err
}
}
@@ -485,9 +486,6 @@ func Run(opt Options) error {
logger.Error("plugin load failed", perr, "plugin", id, "code", string(errcode.SysUnavailable))
}
}
// P2 受管:插件声明里带 scope=system 的设置项同样纳入受管路径
//(user/project 写入拒绝、生效链剥除)。
setSvc.SetExtraSchema(pluginHost.SettingsSchema())
// 检索管线(4.12):分块+全文+(配置时)向量混合与 rerank;未配置自动退化为全文。
pipe := rag.NewPipeline(st, setSvc, nil, nil)
@@ -518,7 +516,6 @@ func Run(opt Options) error {
// 收件箱任务卡:事件走会话事件管道(持久化/seq/重放);关闭即 T0 基线。
cardSvc := cardsvc.New(st, mgr.EmitCardEvent)
cardSvc.SetProjectSharedEmitter(mgr.EmitProjectSharedCardEvent)
surfaceSvc := surfacesvc.New(st, mgr.EmitCardEvent)
go func() { // 启动恢复:重发「快照已落盘、事件未发出」的待恢复投影(KTD)。
if n, err := surfaceSvc.RecoverPendingEvents(context.Background()); err == nil && n > 0 {
@@ -553,11 +550,7 @@ func Run(opt Options) error {
if resp.Receipt.State != "" {
state = string(resp.Receipt.State)
}
cardID := ""
if resp.Card != nil {
cardID = string(resp.Card.ID)
}
return surfacesvc.ActionOutcome{State: state, CardID: cardID, Receipt: resp.Receipt, Payload: resp.Payload}, nil
return surfacesvc.ActionOutcome{State: state, CardID: resp.Card.ID, Receipt: resp.Receipt, Payload: resp.Payload}, nil
})
// U3:generative-ui 工具注入闸——activation 台账关闭时命令不投影给该 owner
// 的模型(禁用即不注入能力;执行端守卫仍在 surfacesvc)。
@@ -586,8 +579,7 @@ func Run(opt Options) error {
pluginHost.OnCommands = bus.RegisterPluginCommands
// hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。
authzSlot := &projectAuthz{}
projectSlot := &projectLifecycleSlot{}
caps := hostcaps{projects: projectSlot, st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc}
caps := hostcaps{st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc}
// EXT-13:回合内检索(knowledge_search / @knowledge 注入)与 HTTP/Host 检索面
// 同一适配器授权;槽为共享指针,扩展在 applyCompiled 期间注册后即生效。
mgr.SetSharedKnowledgeAllow(func(owner string, projectID core.ID) bool {
@@ -600,16 +592,6 @@ func Run(opt Options) error {
// EXT-11:公开受限 Host 工厂(下游恢复路径经 extension.NewScopedHost 重建句柄)。
wireScopedHostFactory(caps)
compiledSummary := applyCompiled(skillsRoot, bus, caps)
mgr.SetProjectWorkAuthorization(projectSlot.work())
mgr.SetKnowledgeProjectScope(func(ctx context.Context, actor string, id core.ID) (core.ID, error) {
return projectSlot.resolve(ctx, caps, actor, id)
})
if authzSlot.hasV2() {
mgr.SetKnowledgeResourceAllow(func(actor string, d core.KnowledgeDoc) bool {
ok, err := authzSlot.AuthorizeResource(extension.ResourceAuthzQuery{Actor: actor, Action: "model-context", ResourceType: "knowledge", ResourceID: d.ID, Owner: d.Owner, ProjectID: d.ProjectID, Source: d.Source})
return err == nil && ok
})
}
mgr.OnUsage = func(owner string, tokens int) {
budget := int(setSvc.Effective(owner, "").GetNumber("llm.dailyTokenBudget", 0))
status, aerr := st.AccountTokens(owner, tokens, budget)
@@ -634,25 +616,26 @@ func Run(opt Options) error {
}
// EXT-8 余项:hostapi 检索面同样吃适配器共享(本人 + 放行的他人项目知识)。
hostSrv.SharedSearch = func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error) {
return caps.searchDefault(ctx, owner, text)
own, err := caps.searchDefault(ctx, owner, text)
if err != nil {
return nil, err
}
shared, err := st.SearchKnowledgeShared(owner, core.RetrievalQuery{Text: text, TopK: 5}, func(docOwner, projectID core.ID) bool {
ok, aerr := authzSlot.Authorize(owner, projectID)
return aerr == nil && ok
})
if err != nil {
return nil, err
}
return append(own, shared...), nil
}
hostSrv.Complete = func(ctx context.Context, owner, prompt string) (string, error) {
return caps.complete(ctx, owner, prompt)
}
srv := server.New(st, mgr, reg, profSvc, setSvc, pluginHost)
projectSlot.wire(srv, caps)
// EXT-8:装载期注册的资源授权适配器注入读取入口(未注册=默认拒绝,行为不变)。
srv.SetProjectAuthorizer(authzSlot.Authorize)
// 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
// EXT-12 最小开关:会话卡不镜像进项目公共流(cardSvc 在上方已建)。
@@ -732,89 +715,8 @@ func Run(opt Options) error {
return nil
}
// 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, string, error) {
var out, opID string
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
if password != "" || generatePassword {
pw := password
if pw == "" {
pw = server.RandomPassword()
}
hash, err := store.HashPassword(pw)
if err != nil {
return err
}
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
}
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 = outc.Plain
opID = outc.OperationID
return nil
})
return out, opID, err
}
// IssueUserLoginKey CLI 签发激活/恢复 Key。
func IssueUserLoginKey(storageDSN, dataDir, owner, purpose string, openExisting, revokeAllCreds bool, ttl time.Duration) (string, error) {
var plain string
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
if revokeAllCreds {
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
return err
})
return plain, err
}
// RevokeUserLoginKey CLI 撤销未使用登录 Key。
func RevokeUserLoginKey(storageDSN, dataDir, id string, openExisting bool) error {
return withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
return st.RevokeLoginKey(id)
})
}
// BootstrapAdmin 首位管理员引导:单事务串行(KTD12——PG 咨询锁/SQLite 插入后复查),
// 建号、激活 Key 与审计共同提交;库内已有用户即拒绝接管。
func BootstrapAdmin(storageDSN, dataDir, owner string, openExisting bool) (string, error) {
var plain string
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
outc, err := st.BootstrapFirstAdminWrite(store.AccountWrite{
Owner: owner, Name: owner, Actor: cliActor(), InstanceID: accountInstanceID(),
})
plain = outc.Plain
return err
})
return plain, err
}
// ResetUserPassword CLI 重置密码:单事务写 must_change=1、bump 认证版本、
// 作废未用登录 Key、吊销全部会话并审计。
func ResetUserPassword(storageDSN, dataDir, owner, password string, openExisting bool) (string, error) {
// CreateUserAccount CLI 建号(管理员发放制):未给密码则生成随机 12 位并返回明文(仅此一次输出)。
func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmin bool) (string, error) {
if password == "" {
password = server.RandomPassword()
}
@@ -822,8 +724,29 @@ func ResetUserPassword(storageDSN, dataDir, owner, password string, openExisting
if err != nil {
return "", err
}
if err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
return st.AdminResetUserPassword(owner, hash, cliActor(), "cli")
if err := withStore(storageDSN, dataDir, func(st *store.Store) error {
return st.CreateAccount(owner, name, hash, isAdmin)
}); err != nil {
return "", err
}
return password, nil
}
// ResetUserPassword CLI 重置密码:must_change=1(首登强制改密),吊销全部会话。
func ResetUserPassword(storageDSN, dataDir, owner, password string) (string, error) {
if password == "" {
password = server.RandomPassword()
}
hash, err := store.HashPassword(password)
if err != nil {
return "", err
}
if err := withStore(storageDSN, dataDir, func(st *store.Store) error {
if err := st.SetUserPassword(owner, hash); err != nil {
return err
}
st.DeleteUserSessions(owner, "")
return nil
}); err != nil {
return "", err
}
@@ -831,9 +754,9 @@ func ResetUserPassword(storageDSN, dataDir, owner, password string, openExisting
}
// ListUserAccounts CLI 用户列表。
func ListUserAccounts(storageDSN, dataDir string, openExisting bool) ([]store.AdminUser, error) {
func ListUserAccounts(storageDSN, dataDir string) ([]store.AdminUser, error) {
var out []store.AdminUser
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
err := withStore(storageDSN, dataDir, func(st *store.Store) error {
var e error
out, e = st.ListUsersAdmin()
return e
@@ -842,122 +765,83 @@ func ListUserAccounts(storageDSN, dataDir string, openExisting bool) ([]store.Ad
}
func withStore(storageDSN, dataDir string, fn func(*store.Store) error) error {
return withStoreMode(storageDSN, dataDir, false, fn)
}
// withIdentityStore 只打开既有库:不建文件、不建目录、不迁移。
func withIdentityStore(storageDSN, dataDir string, fn func(*store.Store) error) error {
st, err := store.OpenIdentity(storageDSN, dataDir)
if err != nil {
return err
}
defer st.Close()
return fn(st)
}
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 != "" {
st, err = store.OpenExistingPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
} else {
st, err = store.OpenExistingSQLite(filepath.Join(dataDir, "agent-system.db"))
st, err = store.OpenSQLite(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 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
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)
}
// ListAPIKeys 列出租户密钥(摘要视图)。
func ListAPIKeys(storageDSN, dataDir, owner string) ([]store.APIKeyInfo, error) {
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
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)
}
// RevokeAPIKey 撤销指定名称密钥。
func RevokeAPIKey(storageDSN, dataDir, owner, name string) (bool, error) {
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
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)
}
// EnsureSystemIdentity CLI:显式建立库内服务身份(svc init / I2→I3 引导)。
// 只打开既有库,不建库、不迁移。
func EnsureSystemIdentity(storageDSN, dataDir, instanceID, dbref string) error {
return withIdentityStore(storageDSN, dataDir, func(st *store.Store) error {
return withStore(storageDSN, dataDir, func(st *store.Store) error {
return st.EnsureSystemIdentity(instanceID, dbref)
})
}
// VerifySystemIdentity CLI:校验库内服务身份(svc start 前;失败不插入)。
// 只打开既有库,不建库、不迁移。schemaVersion 取身份行上的值。
func VerifySystemIdentity(storageDSN, dataDir, instanceID, dbref string) (int, error) {
var schemaVersion int
err := withIdentityStore(storageDSN, dataDir, func(st *store.Store) error {
if err := st.VerifySystemIdentity(instanceID, dbref); err != nil {
return err
}
row, err := st.GetSystemIdentity()
if err != nil {
return err
}
schemaVersion = row.SchemaVersion
return nil
func VerifySystemIdentity(storageDSN, dataDir, instanceID, dbref string) error {
return withStore(storageDSN, dataDir, func(st *store.Store) error {
return st.VerifySystemIdentity(instanceID, dbref)
})
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 的整型缓存读(限流值热读取免打库)。
-14
View File
@@ -107,11 +107,6 @@ func (r compiledRegistry) AddCommand(spec json.RawMessage) error {
}
// RegisterResourceAuthorizer 声明式扩展无宿主进程内代码,不能提供回调(fail-closed)。
// RegisterResourceAuthorizerV2 声明式扩展同样不能提供资源级回调(fail-closed)。
func (r compiledRegistry) RegisterResourceAuthorizerV2(extension.ResourceAuthorizerV2) error {
return errors.New("extension: declarative plugins cannot register resource authorizers")
}
func (r compiledRegistry) RegisterResourceAuthorizer(extension.ResourceAuthorizer) error {
return errors.New("extension: declarative plugins cannot register resource authorizers")
}
@@ -129,15 +124,6 @@ func (r pluginRegistry) RegisterResourceAuthorizer(fn extension.ResourceAuthoriz
return nil
}
// RegisterResourceAuthorizerV2 进程内扩展:填充 EXT-8b 资源级适配器槽(首注册生效)。
func (r pluginRegistry) RegisterResourceAuthorizerV2(fn extension.ResourceAuthorizerV2) error {
if fn == nil {
return errors.New("extension: " + r.pluginID + ": nil resource authorizer v2")
}
r.caps.authz.setV2(r.pluginID, fn)
return nil
}
func (r compiledRegistry) Note(kind, name string, payload json.RawMessage) error {
log.Printf("extension: note %s %s (%d bytes)", kind, name, len(payload))
return nil
+11 -158
View File
@@ -4,8 +4,6 @@ import (
"agent-system-backend/pkg/extension"
"context"
"fmt"
"sort"
"strings"
"sync"
"agent-system-backend/internal/ingest"
@@ -21,7 +19,6 @@ import (
// 同一实现同时服务 hostapi.Server 的 Search/Complete 闭包与进程内
// inprocHost(EXT-1 批注 1:复用现有 owner 作用域函数,不新造句柄)。
type hostcaps struct {
projects *projectLifecycleSlot
st *store.Store
pipe *rag.Pipeline
set *settings.Service
@@ -31,11 +28,9 @@ type hostcaps struct {
// projectAuthz EXT-8 资源授权适配器槽:进程内扩展注册,组合根装配进 Server。
type projectAuthz struct {
mu sync.Mutex
holder string // 持有者 pluginID(首个注册者生效)
fn extension.ResourceAuthorizer
holder2 string // EXT-8b 资源级持有者(首个注册者生效)
fn2 extension.ResourceAuthorizerV2
mu sync.Mutex
holder string // 持有者 pluginID(首个注册者生效)
fn extension.ResourceAuthorizer
}
func (a *projectAuthz) set(pluginID string, fn extension.ResourceAuthorizer) {
@@ -47,41 +42,7 @@ func (a *projectAuthz) set(pluginID string, fn extension.ResourceAuthorizer) {
a.holder, a.fn = pluginID, fn
}
func (a *projectAuthz) setV2(pluginID string, fn extension.ResourceAuthorizerV2) {
a.mu.Lock()
defer a.mu.Unlock()
if a.fn2 != nil {
return
}
a.holder2, a.fn2 = pluginID, fn
}
// AuthorizeResource EXT-8b 资源级判定(U9):V2 注册时按查询六要素咨询(含
// owner 自有命中与共享读取,fail-closed);未注册回落 V1 的 owner×project
// 布尔(兼容现行行为)。查询字段由执行端受信构造。
func (a *projectAuthz) AuthorizeResource(q extension.ResourceAuthzQuery) (bool, error) {
a.mu.Lock()
fn2, fn1 := a.fn2, a.fn
a.mu.Unlock()
if fn2 != nil {
if q.Actor == "" || q.Action == "" || q.ResourceType == "" || q.ResourceID == "" {
return false, nil // 受信上下文字段缺失=不可判定=拒绝
}
return fn2(q)
}
if fn1 == nil || q.Owner == "" || q.ProjectID == "" {
return false, nil
}
return fn1(q.Actor, q.ProjectID)
}
// Authorize owner×project 共享读取判定;未注册一律 false(默认拒绝)。
func (a *projectAuthz) hasV2() bool {
a.mu.Lock()
defer a.mu.Unlock()
return a.fn2 != nil
}
func (a *projectAuthz) Authorize(owner string, projectID core.ID) (bool, error) {
a.mu.Lock()
fn := a.fn
@@ -110,19 +71,6 @@ type sharedRetriever interface {
func (c hostcaps) search(ctx context.Context, owner string, projectID core.ID, text string, topK int) ([]core.RetrievalHit, error) {
q := core.RetrievalQuery{ProjectID: projectID, Text: text, TopK: topK}
if c.projects != nil && projectID != "" {
id, err := c.projects.resolve(ctx, c, owner, projectID)
if err != nil {
return nil, err
}
q.ProjectID = id
}
if c.authz != nil && c.authz.hasV2() {
return c.pipe.RetrieveAuthorized(ctx, core.ID(owner), q, func(d core.KnowledgeDoc) bool {
ok, err := c.authz.AuthorizeResource(extension.ResourceAuthzQuery{Actor: owner, Action: "search", ResourceType: "knowledge", ResourceID: d.ID, Owner: d.Owner, ProjectID: d.ProjectID, Source: d.Source})
return err == nil && ok
})
}
// EXT-13:进程内 Host 检索面与回合内检索同一授权口径——适配器已注册时走
// 共享通道(本人两路如常 + 放行的他人项目知识,与 HTTP 检索面一致)。
if c.authz != nil {
@@ -193,75 +141,6 @@ func (h inprocHost) IsAdmin() bool {
return state.IsAdmin
}
// ListAccounts 实现 EXT-19 AccountLister(最小账号目录):仅授权管理读面用。
// 有效态=账号存在且非停用(当前 users 表无停用位——must_change_password 不影响
// 有效性;后续加停用位在此映射)。P3(2026-10-13):携带 Name 供同名区分。
// 游标=owner 升序偏移。
func (h inprocHost) ListAccounts(cursor string, limit int) ([]extension.AccountEntry, string, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
users, err := h.caps.st.ListUsersAdmin()
if err != nil {
return nil, "", err
}
entries := make([]extension.AccountEntry, 0, len(users))
for _, u := range users {
entries = append(entries, extension.AccountEntry{Owner: u.Owner, Name: u.Name, Valid: true})
}
// 排序+游标(owner 升序;cursor 为上一页末 owner,空=首页)。
for i := 0; i < len(entries); i++ {
for j := i + 1; j < len(entries); j++ {
if entries[j].Owner < entries[i].Owner {
entries[i], entries[j] = entries[j], entries[i]
}
}
}
start := 0
if cursor != "" {
for start < len(entries) && entries[start].Owner <= cursor {
start++
}
}
end := start + limit
if end > len(entries) {
end = len(entries)
}
next := ""
if end < len(entries) {
next = entries[end-1].Owner
}
return entries[start:end], next, nil
}
// SearchAccounts 实现 EXT-19 扩展 AccountSearcher(P3,2026-10-13):账号或
// 姓名子串检索(大小写不敏感;空 query = 首页候选)。结果按 owner 升序,
// 有效态口径与 ListAccounts 一致。
func (h inprocHost) SearchAccounts(query string, limit int) ([]extension.AccountEntry, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
users, err := h.caps.st.ListUsersAdmin()
if err != nil {
return nil, err
}
q := strings.ToLower(strings.TrimSpace(query))
entries := make([]extension.AccountEntry, 0, len(users))
for _, u := range users {
if q != "" &&
!strings.Contains(strings.ToLower(u.Owner), q) &&
!strings.Contains(strings.ToLower(u.Name), q) {
continue
}
entries = append(entries, extension.AccountEntry{Owner: u.Owner, Name: u.Name, Valid: true})
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Owner < entries[j].Owner })
if len(entries) > limit {
entries = entries[:limit]
}
return entries, nil
}
func (h inprocHost) KVPut(key, value string) error {
return h.caps.st.PutPluginKV(h.pluginID, h.owner, key, value)
}
@@ -285,20 +164,6 @@ func (h inprocHost) KnowledgeSearch(ctx context.Context, projectID core.ID, text
return h.caps.search(ctx, h.owner, projectID, text, topK)
}
// knowledgeSource preserves ordinary host Source semantics. A lifecycle provider
// introduces shared publication provenance, so plugins may only assert their own
// namespace; another plugin cannot impersonate an RDM published row.
func (h inprocHost) knowledgeSource(source string) error {
if h.caps.projects == nil || !h.caps.projects.active() {
return nil
}
prefix := "plugin:" + h.pluginID
if source == prefix || strings.HasPrefix(source, prefix+":") {
return nil
}
return fmt.Errorf("extension: knowledge source must belong to plugin %q", h.pluginID)
}
// KnowledgeWrite 强制 owner 归属:Owner 改写为本次分发 owner,Source 为空补
// "plugin:<id>";embed=false 只写全文行,embed=true 走 IndexChunks 建向量
// (embedding 失败降级纯全文,不阻断写入)。
@@ -312,9 +177,6 @@ func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc
if docs[i].Source == "" {
docs[i].Source = src
}
if err := h.knowledgeSource(docs[i].Source); err != nil {
return err
}
}
if !embed {
return h.caps.st.AddKnowledge(docs...)
@@ -322,40 +184,31 @@ func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc
return h.caps.pipe.IndexChunks(ctx, core.ID(h.owner), docs)
}
// KnowledgePublish replaces refs and their vectors within the publication scope.
// A registered lifecycle scopes replacement to the exact owner/project/plugin;
// ordinary unregistered Hosts retain the legacy cross-source ref semantics.
// KnowledgePublish 幂等发布(EXT-9):按 docs 的 ref 整批替换——先清该 owner
// 下同 ref 旧行与旧向量(跨来源也清:ref 是发布单元),再走 KnowledgeWrite
// 语义(Owner/Source 强制同彼)。重试产生相同结果。
func (h inprocHost) KnowledgePublish(ctx context.Context, docs []core.KnowledgeDoc, embed bool) error {
if len(docs) == 0 {
return nil
}
type publicationRef struct{ projectID, ref string }
refs := map[publicationRef]bool{}
refs := map[string]bool{}
for i := range docs {
docs[i].Owner = core.ID(h.owner)
if docs[i].Source == "" {
docs[i].Source = "plugin:" + h.pluginID
}
if err := h.knowledgeSource(docs[i].Source); err != nil {
return err
}
if docs[i].Ref != "" {
refs[publicationRef{docs[i].ProjectID, docs[i].Ref}] = true
refs[docs[i].Ref] = true
}
}
scoped := h.caps.projects != nil && h.caps.projects.active()
for ref := range refs {
var err error
if scoped {
err = h.caps.st.DeletePluginProjectKnowledgeByRef(ctx, h.owner, ref.projectID, ref.ref, h.pluginID)
} else {
err = h.caps.st.DeleteProjectKnowledgeByRef(ctx, h.owner, ref.projectID, ref.ref)
if err := h.caps.st.DeleteKnowledgeByRef(h.owner, ref); err != nil {
return err
}
if err != nil {
if err := h.caps.st.DeleteKnowledgeVectors(ctx, h.owner, ref); err != nil {
return err
}
}
return h.KnowledgeWrite(ctx, docs, embed)
}
@@ -1,166 +0,0 @@
package app
import (
"agent-system-backend/internal/rag"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
"context"
"testing"
)
func TestHostKnowledgeV2AndProjectReplacement(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
pipe := rag.NewPipeline(st, settings.NewService(st), nil, nil)
auth := &projectAuthz{}
alice := inprocHost{owner: "alice", pluginID: "rdm", caps: hostcaps{st: st, pipe: pipe, authz: auth}}
first := []core.KnowledgeDoc{{ID: "slice-1", ProjectID: "domain-1", Ref: "notes.md", Chunk: "sharedneedle one"}}
second := []core.KnowledgeDoc{{ID: "slice-2", ProjectID: "domain-2", Ref: "notes.md", Chunk: "sharedneedle two"}}
for _, ds := range [][]core.KnowledgeDoc{first, second, second} {
if err := alice.KnowledgePublish(context.Background(), ds, false); err != nil {
t.Fatal(err)
}
}
own, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "sharedneedle"})
if err != nil || len(own) != 2 {
t.Fatalf("cross project publish replaced first: %+v %v", own, err)
}
auth.set("rdm", func(string, core.ID) (bool, error) { return true, nil })
auth.setV2("rdm", func(q extension.ResourceAuthzQuery) (bool, error) {
return q.ResourceType == "project" || (q.ResourceType == "knowledge" && q.ResourceID == "slice-1" && q.ProjectID == "domain-1" && q.Actor == "bob" && q.Owner == "alice"), nil
})
bob := inprocHost{owner: "bob", pluginID: "rdm", caps: alice.caps}
hits, err := bob.KnowledgeSearch(context.Background(), "", "sharedneedle", 5)
if err != nil || len(hits) != 1 || hits[0].Doc.ID != "slice-1" {
t.Fatalf("host V2=%+v %v", hits, err)
}
}
func TestHostKnowledgeResolvesTrustedProjectScope(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.AddKnowledge(core.KnowledgeDoc{ID: "slice-current", Owner: "alice", ProjectID: "domain-current", Chunk: "scopeneedle current"}, core.KnowledgeDoc{ID: "slice-other", Owner: "alice", ProjectID: "domain-other", Chunk: "scopeneedle other"}); err != nil {
t.Fatal(err)
}
slot := &projectLifecycleSlot{}
provider := completeLifecycle()
provider.List = func(_ context.Context, h extension.Host) ([]core.Project, error) {
if h.Owner() != "bob" {
t.Fatalf("wrong scope actor %q", h.Owner())
}
return []core.Project{{ID: "host-current", Settings: map[string]any{"rdm.projectId": "domain-current", "rdm.state": "ready"}}}, nil
}
caps := hostcaps{st: st, pipe: rag.NewPipeline(st, settings.NewService(st), nil, nil), authz: &projectAuthz{}, projects: slot}
if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(provider); err != nil {
t.Fatal(err)
}
caps.authz.setV2("rdm", func(extension.ResourceAuthzQuery) (bool, error) { return true, nil })
h := inprocHost{owner: "bob", pluginID: "rdm", caps: caps}
hits, err := h.KnowledgeSearch(context.Background(), "host-current", "scopeneedle", 5)
if err != nil || len(hits) != 1 || hits[0].Doc.ID != "slice-current" {
t.Fatalf("host scope: %+v %v", hits, err)
}
}
func TestRegisteredProjectHostRejectsForeignKnowledgeSource(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
slot := &projectLifecycleSlot{}
caps := hostcaps{st: st, projects: slot}
if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(completeLifecycle()); err != nil {
t.Fatal(err)
}
h := inprocHost{owner: "alice", pluginID: "other", caps: caps}
for _, publish := range []bool{false, true} {
docs := []core.KnowledgeDoc{{ID: "claimed-published", ProjectID: "domain", Source: "plugin:rdm:published", Ref: "ref", Chunk: "spoofneedle"}}
var err error
if publish {
err = h.KnowledgePublish(context.Background(), docs, false)
} else {
err = h.KnowledgeWrite(context.Background(), docs, false)
}
if err == nil {
t.Fatalf("foreign namespace accepted publish=%v", publish)
}
}
if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "spoofneedle"}); err != nil || len(hits) != 0 {
t.Fatalf("foreign source wrote data: %+v %v", hits, err)
}
// Existing ordinary Hosts preserve their documented caller-provided Source.
h.caps.projects = nil
if err := h.KnowledgeWrite(context.Background(), []core.KnowledgeDoc{{Source: "manual", Chunk: "ordinaryneedle"}}, false); err != nil {
t.Fatalf("ordinary source semantics changed: %v", err)
}
}
func TestProjectPublishPreservesForeignPluginAndScope(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
slot := &projectLifecycleSlot{}
caps := hostcaps{st: st, projects: slot}
if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(completeLifecycle()); err != nil {
t.Fatal(err)
}
original := core.KnowledgeDoc{ID: "rdm-published", Owner: "alice", ProjectID: "domain", Source: "plugin:rdm:published", Ref: "notes.md", Chunk: "originalneedle"}
if err := st.AddKnowledge(original); err != nil {
t.Fatal(err)
}
if err := st.UpsertKnowledgeVector(context.Background(), "alice", original.ID, original.Ref, []float32{1, 0}); err != nil {
t.Fatal(err)
}
h := inprocHost{owner: "alice", pluginID: "other", caps: caps}
replacement := []core.KnowledgeDoc{{ID: "other", ProjectID: "domain", Source: "plugin:other", Ref: "notes.md", Chunk: "replacementneedle"}}
for i := 0; i < 2; i++ {
if err := h.KnowledgePublish(context.Background(), replacement, false); err != nil {
t.Fatal(err)
}
}
if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "originalneedle"}); err != nil || len(hits) != 1 {
t.Fatalf("foreign plugin deleted RDM publication: %+v %v", hits, err)
}
if hits, err := st.SearchKnowledgeVectors(context.Background(), "alice", []float32{1, 0}, 5, core.RetrievalQuery{ProjectID: "domain"}); err != nil || len(hits) != 1 || hits[0].Doc.ID != original.ID {
t.Fatalf("foreign plugin deleted RDM vector: %+v %v", hits, err)
}
h.pluginID = "rdm"
if err := h.KnowledgePublish(context.Background(), []core.KnowledgeDoc{{ID: "unscoped", Source: "plugin:rdm", Ref: "notes.md", Chunk: "privateneedle"}}, false); err != nil {
t.Fatal(err)
}
if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "originalneedle", ProjectID: "domain"}); err != nil || len(hits) != 1 {
t.Fatalf("unscoped publication deleted scoped RDM data: %+v %v", hits, err)
}
}
func TestUnregisteredPublishRetainsLegacyRefScope(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.AddKnowledge(core.KnowledgeDoc{ID: "legacy", Owner: "alice", ProjectID: "old-project", Source: "manual", Ref: "same", Chunk: "legacyneedle"}); err != nil {
t.Fatal(err)
}
h := inprocHost{owner: "alice", pluginID: "other", caps: hostcaps{st: st}}
if err := h.KnowledgePublish(context.Background(), []core.KnowledgeDoc{{ID: "replacement", Source: "manual", Ref: "same", Chunk: "replacementneedle"}}, false); err != nil {
t.Fatal(err)
}
if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "legacyneedle"}); err != nil || len(hits) != 0 {
t.Fatalf("unregistered legacy scope changed: %+v %v", hits, err)
}
if hits, err := st.SearchKnowledge("alice", core.RetrievalQuery{Text: "replacementneedle"}); err != nil || len(hits) != 1 {
t.Fatalf("unregistered replacement missing: %+v %v", hits, err)
}
}
-126
View File
@@ -1,126 +0,0 @@
package app
import (
"agent-system-backend/internal/projectscope"
"agent-system-backend/internal/server"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
"context"
"errors"
"sync"
)
type projectLifecycleSlot struct {
mu sync.Mutex
holder string
provider *extension.ProjectLifecycle
settings extension.ProjectSettingsAuthorizer
}
func (r compiledRegistry) RegisterProjectLifecycle(extension.ProjectLifecycle) error {
return errors.New("extension: declarative plugins cannot register project lifecycle")
}
func (r pluginRegistry) RegisterProjectLifecycle(p extension.ProjectLifecycle) error {
if r.caps.projects == nil || r.pluginID == "" {
return errors.New("extension: project lifecycle slot unavailable")
}
if p.List == nil || p.Create == nil || p.Rename == nil || p.Archive == nil || p.Work == nil {
return errors.New("extension: project lifecycle requires all callbacks")
}
slot := r.caps.projects
slot.mu.Lock()
defer slot.mu.Unlock()
if slot.provider != nil {
return errors.New("extension: project lifecycle already registered")
}
slot.holder, slot.provider = r.pluginID, &p
return nil
}
func (r recordRegistry) RegisterProjectLifecycle(p extension.ProjectLifecycle) error {
reg, ok := r.Registry.(extension.ProjectLifecycleRegistry)
if !ok {
return errors.New("extension: project lifecycle registration unavailable")
}
return reg.RegisterProjectLifecycle(p)
}
func (slot *projectLifecycleSlot) wire(srv *server.Server, caps hostcaps) {
slot.mu.Lock()
defer slot.mu.Unlock()
if slot.provider == nil {
return
}
holder := slot.holder
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 {
slot.mu.Lock()
defer slot.mu.Unlock()
return slot.provider != nil
}
func (slot *projectLifecycleSlot) work() func(context.Context, string, core.ID) (bool, error) {
slot.mu.Lock()
defer slot.mu.Unlock()
if slot.provider == nil {
return nil
}
return slot.provider.Work
}
func (slot *projectLifecycleSlot) resolve(ctx context.Context, caps hostcaps, owner string, id core.ID) (core.ID, error) {
slot.mu.Lock()
if slot.provider == nil {
slot.mu.Unlock()
return id, nil
}
p, holder := *slot.provider, slot.holder
slot.mu.Unlock()
return projectscope.Resolve(ctx, inprocHost{caps: caps, owner: owner, actor: owner, pluginID: holder}, p, id)
}
var _ extension.ProjectHost = inprocHost{}
func (h inprocHost) OwnedProjects() ([]core.Project, error) {
ps, err := h.caps.st.ListProjects(h.owner)
for i := range ps {
ps[i].Owner = h.owner
}
return ps, err
}
func (h inprocHost) OwnedProject(id string) (core.Project, error) {
return h.caps.st.GetProject(h.owner, id)
}
func (h inprocHost) ProjectMetadata(id string) (core.Project, error) {
p, err := h.caps.st.ProjectMetadata(id)
if err != nil {
return core.Project{}, err
}
if h.caps.projects != nil && h.caps.projects.active() {
p.Settings = h.caps.set.MaskedProjectMetadata(p.Settings)
}
if p.Owner == h.owner {
return p, nil
}
if h.caps.authz == nil {
return core.Project{}, store.ErrNotFound
}
ok, err := h.caps.authz.AuthorizeResource(extension.ResourceAuthzQuery{Actor: h.owner, Action: "read", ResourceType: "project", ResourceID: core.ID(id), Owner: p.Owner, ProjectID: core.ID(id)})
if err != nil || !ok {
return core.Project{}, store.ErrNotFound
}
return p, nil
}
func (h inprocHost) EnsureProject(id, name string) (core.Project, error) {
return h.caps.st.EnsureProject(h.owner, id, name)
}
func (h inprocHost) RenameOwnedProject(id, name string) (core.Project, error) {
return h.caps.st.RenameOwnedProject(h.owner, id, name)
}
func (h inprocHost) ProjectNameAvailable(name, exceptID string) (bool, error) {
return h.caps.st.ProjectNameAvailable(name, exceptID)
}
-163
View File
@@ -1,163 +0,0 @@
package app
import (
"agent-system-backend/internal/server"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
"bytes"
"context"
"encoding/json"
"errors"
"net/http/httptest"
"testing"
)
func completeLifecycle() extension.ProjectLifecycle {
return extension.ProjectLifecycle{
List: func(context.Context, extension.Host) ([]core.Project, error) { return nil, nil },
Create: func(context.Context, extension.Host, string) (core.Project, error) { return core.Project{}, nil },
Rename: func(context.Context, extension.Host, string, string) (core.Project, error) {
return core.Project{}, nil
},
Archive: func(context.Context, extension.Host, string) error { return nil },
Work: func(context.Context, string, core.ID) (bool, error) { return true, nil },
}
}
func TestProjectLifecycleOptionalRegistration(t *testing.T) {
slot := &projectLifecycleSlot{}
reg := pluginRegistry{pluginID: "rdm", caps: hostcaps{projects: slot}}
if err := reg.RegisterProjectLifecycle(extension.ProjectLifecycle{}); err == nil {
t.Fatal("incomplete registration accepted")
}
if err := reg.RegisterProjectLifecycle(completeLifecycle()); err != nil {
t.Fatal(err)
}
if err := reg.RegisterProjectLifecycle(completeLifecycle()); err == nil {
t.Fatal("duplicate registration accepted")
}
if err := (compiledRegistry{}).RegisterProjectLifecycle(completeLifecycle()); err == nil {
t.Fatal("declarative callback accepted")
}
rr := recordRegistry{Registry: pluginRegistry{pluginID: "other", caps: hostcaps{projects: &projectLifecycleSlot{}}}}
optional, ok := any(rr).(extension.ProjectLifecycleRegistry)
if !ok {
t.Fatal("record registry hides optional registration")
}
if err := optional.RegisterProjectLifecycle(completeLifecycle()); err != nil {
t.Fatal(err)
}
}
func TestProjectHostMetadataDoesNotGrantMutation(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
alice := inprocHost{owner: "alice", caps: hostcaps{st: st}}
p, err := alice.EnsureProject("stable", "Shared")
if err != nil {
t.Fatal(err)
}
auth := &projectAuthz{}
auth.set("rdm", func(owner string, id core.ID) (bool, error) { return owner == "bob" && id == core.ID(p.ID), nil })
bob := inprocHost{owner: "bob", caps: hostcaps{st: st, authz: auth}}
if _, err := bob.ProjectMetadata(p.ID); err != nil {
t.Fatal(err)
}
if _, err := bob.OwnedProject(p.ID); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("owned lookup=%v", err)
}
if _, err := bob.RenameOwnedProject(p.ID, "Other"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("shared rename=%v", err)
}
if _, err := bob.EnsureProject(p.ID, p.Name); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("shared ensure=%v", err)
}
auth.setV2("rdm", func(q extension.ResourceAuthzQuery) (bool, error) { return false, nil })
if _, err := bob.ProjectMetadata(p.ID); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("V2 denial ignored=%v", err)
}
}
func TestProjectHostMetadataMasksSharedSecretsWithoutMutation(t *testing.T) {
st, err := store.OpenSQLite(t.TempDir() + "/t.db")
if err != nil {
t.Fatal(err)
}
defer st.Close()
p, err := st.CreateProject("alice", "Shared")
if err != nil {
t.Fatal(err)
}
raw := map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": "fixture-nested-secret"}}, "models.embedding.apiKey": "fixture-flat-secret", "legacy.token": "fixture-legacy-secret", "plugins.private": map[string]any{"credential": "fixture-schema-secret"}, "display": map[string]any{"caption": "Shared configuration"}, "rdm.projectId": "canonical", "rdm.state": "ready"}
if err := st.UpdateProjectSettings("alice", p.ID, raw); err != nil {
t.Fatal(err)
}
svc := settings.NewService(st)
svc.SetExtraSchema([]core.SettingDesc{{Key: "plugins.private.credential", Secret: true}})
slot := &projectLifecycleSlot{}
auth := &projectAuthz{}
caps := hostcaps{st: st, set: svc, projects: slot, authz: auth}
provider := completeLifecycle()
provider.List = func(_ context.Context, h extension.Host) ([]core.Project, error) {
project, err := h.(extension.ProjectHost).ProjectMetadata(p.ID)
return []core.Project{project}, err
}
if err := (pluginRegistry{pluginID: "rdm", caps: caps}).RegisterProjectLifecycle(provider); err != nil {
t.Fatal(err)
}
auth.setV2("rdm", func(q extension.ResourceAuthzQuery) (bool, error) {
return q.Actor == "bob" && q.ResourceType == "project" && q.ResourceID == p.ID, nil
})
for _, actor := range []string{"bob", "alice"} {
h := inprocHost{owner: actor, caps: caps}
view, err := h.ProjectMetadata(p.ID)
if err != nil {
t.Fatal(err)
}
encoded, _ := json.Marshal(view.Settings)
for _, value := range []string{"fixture-nested-secret", "fixture-flat-secret", "fixture-legacy-secret", "fixture-schema-secret"} {
if bytes.Contains(encoded, []byte(value)) {
t.Fatalf("%s metadata exposed a fixture credential", actor)
}
}
if view.Settings["rdm.projectId"] != "canonical" || view.Settings["rdm.state"] != "ready" || view.Settings["display"].(map[string]any)["caption"] != "Shared configuration" {
t.Fatal("allowed service/configuration metadata changed")
}
}
// Exercise the real registry -> actor-scoped Host -> public HTTP list chain.
srv := server.New(st, nil, nil, nil, svc, nil)
srv.Auth = server.NewAuthenticator(st, true)
slot.wire(srv, caps)
key, err := st.CreateAPIKey("bob", "test")
if err != nil {
t.Fatal(err)
}
request := httptest.NewRequest("GET", "/api/projects", nil)
request.Header.Set("Authorization", "Bearer "+key)
response := httptest.NewRecorder()
srv.Handler().ServeHTTP(response, request)
if response.Code != 200 {
t.Fatalf("member list=%d", response.Code)
}
for _, value := range []string{"fixture-nested-secret", "fixture-flat-secret", "fixture-legacy-secret", "fixture-schema-secret"} {
if bytes.Contains(response.Body.Bytes(), []byte(value)) {
t.Fatal("actor-scoped lifecycle list exposed a fixture credential")
}
}
if !bytes.Contains(response.Body.Bytes(), []byte(`"rdm.projectId":"canonical"`)) {
t.Fatal("actor-scoped list lost canonical metadata")
}
owned, err := (inprocHost{owner: "alice", caps: caps}).OwnedProject(p.ID)
if err != nil {
t.Fatal(err)
}
before, _ := json.Marshal(raw)
after, _ := json.Marshal(owned.Settings)
if !bytes.Equal(before, after) {
t.Fatal("metadata read mutated persisted owner settings")
}
}
-35
View File
@@ -1,35 +0,0 @@
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
@@ -1,38 +0,0 @@
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{}
}
-73
View File
@@ -1,73 +0,0 @@
package app
// EXT-8b 资源级授权槽位测试(U9 首切片):
// 1. V2 未注册 → AuthorizeResource 回落 V1(owner×project 兼容现行);
// 2. V2 注册 → 按查询六要素咨询;受信字段缺失(actor/action/type/id 空)=拒绝;
// 3. V2 返回 error = 拒绝(fail-closed);
// 4. 首注册者生效(后来者忽略);
// 5. compiledRegistry 的 V2 注册被拒(声明式无宿主回调)。
import (
"errors"
"testing"
"agent-system-backend/pkg/extension"
)
func TestAuthorizeResourceV2FallbackAndQuery(t *testing.T) {
var a projectAuthz
// 未注册任何适配器 → 拒绝。
if ok, err := a.AuthorizeResource(extension.ResourceAuthzQuery{
Actor: "u1", Action: "download", ResourceType: "artifact", ResourceID: "a1",
Owner: "u2", ProjectID: "p1"}); ok || err != nil {
t.Fatalf("未注册应拒绝, got %v %v", ok, err)
}
// 仅 V1:回落 owner×project 布尔。
a.set("p1", func(owner string, projectID string) (bool, error) { return true, nil })
if ok, _ := a.AuthorizeResource(extension.ResourceAuthzQuery{
Actor: "u1", Action: "download", ResourceType: "artifact", ResourceID: "a1",
Owner: "u2", ProjectID: "p1"}); !ok {
t.Fatal("V1 允许时回落应放行")
}
// V1 路径要求 owner/project 非空。
if ok, _ := a.AuthorizeResource(extension.ResourceAuthzQuery{
Actor: "u1", Action: "download", ResourceType: "artifact", ResourceID: "a1"}); ok {
t.Fatal("V1 回落在无项目私域应拒绝")
}
// 注册 V2 → 按六要素咨询。
a.setV2("p1", func(q extension.ResourceAuthzQuery) (bool, error) {
return q.Actor == "u1" && q.Action == "download" && q.ResourceType == "artifact" &&
q.ResourceID == "a1" && q.Owner == "u2" && q.ProjectID == "p1", nil
})
if ok, _ := a.AuthorizeResource(extension.ResourceAuthzQuery{
Actor: "u1", Action: "download", ResourceType: "artifact", ResourceID: "a1",
Owner: "u2", ProjectID: "p1"}); !ok {
t.Fatal("V2 六要素齐备应放行")
}
// 六要素缺一(action 空)=拒绝(不可判定)。
if ok, _ := a.AuthorizeResource(extension.ResourceAuthzQuery{
Actor: "u1", ResourceType: "artifact", ResourceID: "a1", Owner: "u2", ProjectID: "p1"}); ok {
t.Fatal("受信字段缺失应拒绝")
}
// V2 出错=拒绝(fail-closed)。
a2 := projectAuthz{}
a2.setV2("p1", func(extension.ResourceAuthzQuery) (bool, error) { return false, errors.New("boom") })
if ok, err := a2.AuthorizeResource(extension.ResourceAuthzQuery{
Actor: "u", Action: "read", ResourceType: "x", ResourceID: "1"}); ok || err == nil {
t.Fatal("V2 error 应拒绝")
}
// 首注册者生效。
a3 := projectAuthz{}
a3.setV2("first", func(extension.ResourceAuthzQuery) (bool, error) { return true, nil })
a3.setV2("second", func(extension.ResourceAuthzQuery) (bool, error) { return false, nil })
if ok, _ := a3.AuthorizeResource(extension.ResourceAuthzQuery{
Actor: "u", Action: "read", ResourceType: "x", ResourceID: "1"}); !ok {
t.Fatal("首个 V2 注册者应生效")
}
}
func TestCompiledRegistryRejectsV2(t *testing.T) {
r := compiledRegistry{}
if err := r.RegisterResourceAuthorizerV2(func(extension.ResourceAuthzQuery) (bool, error) { return true, nil }); err == nil {
t.Fatal("声明式扩展不得注册资源级授权回调(fail-closed)")
}
}
-22
View File
@@ -7,7 +7,6 @@ import (
"fmt"
"os"
"path/filepath"
"reflect"
"strings"
"sync"
)
@@ -20,21 +19,6 @@ type Store interface {
Delete(ref string) error
}
// IsNil 判断对象存储是否未配置。nil 接口,以及放进接口后的带类型空指针
// (例如未设置端点时的 (*S3)(nil)),都算未配置。已构造的实现返回 false。
func IsNil(s Store) bool {
if s == nil {
return true
}
v := reflect.ValueOf(s)
switch v.Kind() {
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
return v.IsNil()
default:
return false
}
}
// Local 是本地目录实现(data/artifacts;多实例时把目录指向共享卷即可)。
type Local struct {
dir string
@@ -48,12 +32,6 @@ func NewLocal(dir string) (*Local, error) {
return &Local{dir: dir}, nil
}
// OpenLocalExisting 绑定既有目录,不 MkdirAll。目录缺失时仍返回可用句柄
// (账户运维只碰 DB;Put 时若目录不存在会失败)。
func OpenLocalExisting(dir string) (*Local, error) {
return &Local{dir: dir}, nil
}
// validID 只接受纯文件名形态的对象 id:非空、不含路径分隔符、非 . / ..。
// Put 的 id 可能是上层透传的用户输入(如工件路径参数),先在此挡住路径穿越。
func validID(id string) bool {
-29
View File
@@ -1,29 +0,0 @@
package blobstore
import "testing"
func TestIsNil(t *testing.T) {
var unset Store
if !IsNil(unset) {
t.Fatal("nil interface must count as unset")
}
var typed *S3
var asStore Store = typed
if !IsNil(asStore) {
t.Fatal("typed nil *S3 must count as unset")
}
local, err := NewLocal(t.TempDir())
if err != nil {
t.Fatal(err)
}
if IsNil(local) {
t.Fatal("local store must count as configured")
}
s3, err := NewS3("http://127.0.0.1:9/bucket", "us-east-1", "access", "secret", "art/")
if err != nil {
t.Fatal(err)
}
if IsNil(s3) {
t.Fatal("configured S3 must count as configured")
}
}
+2 -39
View File
@@ -27,9 +27,8 @@ var (
const cardAppendWindow = 250 * time.Millisecond
type Service struct {
st *store.Store
emit Emitter
projectSharedEmit ProjectSharedEmitter
st *store.Store
emit Emitter
// MirrorConvCards EXT-12:会话卡是否镜像进项目公共流(缺省 true=现状全量
// 镜像;false 时会话卡只进本人会话流,项目级卡不受影响)。
MirrorConvCards bool
@@ -42,42 +41,6 @@ func New(st *store.Store, emit Emitter) *Service {
return &Service{st: st, emit: emit, now: time.Now, lastEmit: map[string]time.Time{}, MirrorConvCards: true}
}
// ProjectSharedEmitter is an internal trusted publication adapter. It is never
// bound to model arguments or exposed as a mandatory extension capability.
type ProjectSharedEmitter func(owner, producer, channel, runID string, typ core.StreamEventType, payload any) error
func (s *Service) SetProjectSharedEmitter(emit ProjectSharedEmitter) { s.projectSharedEmit = emit }
// PublishProjectCard explicitly publishes a project-level structural snapshot.
// Ordinary Create/Update remain private unless this entry issues a new proof.
func (s *Service) PublishProjectCard(owner, producer string, card core.ActivityCard, ref string) (core.ActivityCard, error) {
if s.projectSharedEmit == nil || owner == "" || producer == "" || card.ProjectID == "" || card.ConversationID != "" {
return card, errors.New("card: trusted project publisher required")
}
if card.ID != "" {
current, err := s.st.GetCard(owner, card.ID)
if err != nil {
return card, err
}
if current.ProjectID != card.ProjectID || current.ConversationID != "" {
return card, store.ErrNotFound
}
} else {
card.ID = store.NewID("card")
}
now := s.now().UTC()
card.CreatedAt = now
card.UpdatedAt = now
if card.Status == "" {
card.Status = core.CardActive
}
if err := s.st.SaveCard(owner, &card, ref); err != nil {
return card, err
}
err := s.projectSharedEmit(owner, producer, string(core.ProjectChannelKey(card.ProjectID)), card.SourceRunID, core.EvCardCreated, core.CardPayload{Card: card})
return card, err
}
// emitCard 按卡归属发布卡片事件(D50):
// - 会话卡 → 会话流;同时镜像项目流(伪会话键 "project:<projectId>")——
// 项目收件箱聚合视图(项目级卡 ∪ 各会话未决卡)的实时性依赖它;卡片事件
+1 -5
View File
@@ -222,11 +222,7 @@ func (r *fakeRegistry) Note(kind, name string, p json.RawMessage) error { return
func (r *fakeRegistry) RegisterResourceAuthorizer(fn extension.ResourceAuthorizer) error {
return nil
}
func (r *fakeRegistry) RegisterResourceAuthorizerV2(fn extension.ResourceAuthorizerV2) error {
return nil
}
func (r *fakeRegistry) AddIntent(spec json.RawMessage) error { return nil }
func (r *fakeRegistry) AddIntent(spec json.RawMessage) error { return nil }
func (r *fakeRegistry) AddAdminZone(spec json.RawMessage) error { return nil }
func TestPresentRejectsUnknownActionIDs(t *testing.T) {
+1 -14
View File
@@ -2,7 +2,6 @@
package profiles
import (
"errors"
"reflect"
"agent-system-backend/internal/store"
@@ -51,20 +50,11 @@ func (s *Service) EnsureDefault() error {
return s.st.SaveProfile(DefaultProfile())
}
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) List(owner string) ([]core.AgentProfile, error) { return s.st.ListProfiles(owner) }
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 行为基线)。
@@ -75,9 +65,6 @@ 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
@@ -1,53 +0,0 @@
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")
}
}
-35
View File
@@ -1,35 +0,0 @@
// Package projectscope resolves host retrieval filters using service-provided
// lifecycle metadata. Stored/client settings never supply an authority mapping.
package projectscope
import (
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
"context"
)
func Resolve(ctx context.Context, host extension.Host, p extension.ProjectLifecycle, id core.ID) (core.ID, error) {
if id == "" {
return id, nil
}
if p.List == nil {
return "", store.ErrNotFound
}
projects, err := p.List(ctx, host)
if err != nil {
return "", err
}
for _, project := range projects {
domainID, _ := project.Settings["rdm.projectId"].(string)
if project.ID != id && domainID != id {
continue
}
state, _ := project.Settings["rdm.state"].(string)
if domainID == "" || state != "ready" {
return "", store.ErrNotFound
}
return core.ID(domainID), nil
}
return "", store.ErrNotFound
}
+4 -52
View File
@@ -174,66 +174,19 @@ func (p *Pipeline) RetrieveShared(ctx context.Context, owner core.ID, q core.Ret
return merged, nil
}
// RetrieveAuthorized is the resource-level path for HTTP, Host and model
// context. It filters owned and shared candidates before external reranking.
func (p *Pipeline) RetrieveAuthorized(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) {
own, err := p.retrieve(ctx, owner, q, allow)
if err != nil {
return nil, err
}
shared, err := p.st.SearchKnowledgeSharedAuthorized(string(owner), q, allow)
if err != nil {
return nil, err
}
if p.embedder != nil {
if vectors, e := p.embedder.Embed(ctx, []string{q.Text}); e == nil && len(vectors) > 0 && len(vectors[0]) > 0 {
topK := q.TopK
if topK <= 0 {
topK = 5
}
vs, e := p.st.SearchKnowledgeVectorsSharedAuthorized(ctx, string(owner), embedderModel(p.embedder), vectors[0], topK, q, allow)
if e == nil {
shared = rrfMerge(vs, shared)
}
}
}
merged := rrfMerge(own, shared)
if limit := q.TopK; limit > 0 && len(merged) > limit {
merged = merged[:limit]
}
return merged, nil
}
func authorizedHits(hits []core.RetrievalHit, allow func(core.KnowledgeDoc) bool) []core.RetrievalHit {
if allow == nil {
return hits
}
out := make([]core.RetrievalHit, 0, len(hits))
for _, h := range hits {
if allow(h.Doc) {
out = append(out, h)
}
}
return out
}
// Retrieve 统一检索(core.Retriever)。
func (p *Pipeline) Retrieve(ctx context.Context, owner core.ID, q core.RetrievalQuery) ([]core.RetrievalHit, error) {
return p.retrieve(ctx, owner, q, nil)
}
func (p *Pipeline) retrieve(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) {
topK := q.TopK
if topK <= 0 {
topK = 5
}
if p.embedder == nil {
hits, err := p.st.SearchKnowledge(owner, q)
return authorizedHits(hits, allow), err
return p.st.SearchKnowledge(owner, q)
}
route := p.embeddingRoute(ctx)
offline, _ := p.embedder.(interface{ Offline() bool })
if !route.OK() && (offline == nil || !offline.Offline()) {
hits, err := p.st.SearchKnowledge(owner, q)
return authorizedHits(hits, allow), err
return p.st.SearchKnowledge(owner, q)
}
qvecs, err := p.embedder.Embed(ctx, []string{q.Text})
if err != nil || len(qvecs) == 0 || len(qvecs[0]) == 0 {
@@ -241,8 +194,7 @@ func (p *Pipeline) retrieve(ctx context.Context, owner core.ID, q core.Retrieval
if err != nil {
log.Printf("[rag] query embedding fallback to fulltext: %v", err)
}
hits, err := p.st.SearchKnowledge(owner, q)
return authorizedHits(hits, allow), err
return p.st.SearchKnowledge(owner, q)
}
q.TopK = topK * 2 // 每路多取,融合后再裁剪
vhits, err := p.st.SearchKnowledgeVectorsModel(ctx, owner, embedderModel(p.embedder), qvecs[0], topK*2, q)
@@ -253,7 +205,7 @@ func (p *Pipeline) retrieve(ctx context.Context, owner core.ID, q core.Retrieval
if err != nil {
return nil, err
}
merged := authorizedHits(rrfMerge(vhits, fhits), allow)
merged := rrfMerge(vhits, fhits)
if p.reranker != nil && p.rerankRoute(ctx).OK() && len(merged) > 0 {
docs := make([]core.KnowledgeDoc, len(merged))
for i, h := range merged {
+1 -2
View File
@@ -162,9 +162,8 @@ func TestRetrieveFallbackAndHybrid(t *testing.T) {
}
// 有 embedder 且路由已配置:查询向量 A 类 → A 文档排前(RRF 向量路主导)。
// P2 受管:models.* 是系统受管项,配置写入系统层行(owner=OwnerSystemRow)。
pipe2, st2 := newTestPipeline(t, fakeEmbedder{}, nil)
if err := st2.MergeSettingsScope(settings.OwnerSystemRow, settings.ScopeSystem, map[string]any{
if err := st2.MergeSettingsScope(store.OwnerDefault, settings.ScopeUser, map[string]any{
"models": map[string]any{"embedding": map[string]any{"baseUrl": "http://fake.internal", "model": "e-test"}},
}); err != nil {
t.Fatal(err)
+2 -51
View File
@@ -120,67 +120,18 @@ func TestBackgroundCardLifecycleOnFailure(t *testing.T) {
t.Fatalf("cards = %d, want 1", len(cards))
}
card := cards[0]
if card.Status != core.CardFailed || card.Tone != "danger" {
t.Fatalf("status = %s tone = %s, want failed/danger", card.Status, card.Tone)
if card.Status != core.CardDone || card.Tone != "danger" {
t.Fatalf("status = %s tone = %s, want done/danger", card.Status, card.Tone)
}
var md string
statusLabel := ""
for _, n := range card.Body {
if n.Type == core.UIMarkdown {
md = n.Markdown
}
if n.Type == core.UIKV {
for _, kv := range n.KV {
if kv[0] == core.TaskSummaryKVStatus {
statusLabel = kv[1]
}
}
}
}
if !strings.Contains(md, "执行失败") {
t.Fatalf("failure body = %q", md)
}
if statusLabel != core.TaskSummaryStatusFailed {
t.Fatalf("status kv = %q, want %q", statusLabel, core.TaskSummaryStatusFailed)
}
}
func TestBackgroundCardCreateUsesL1SkeletonNotRawTask(t *testing.T) {
runner := &holdRunner{release: make(chan struct{})}
c, _ := newCardSpawnEnv(t, runner)
c.mgr.runner = noopRunner{}
raw := "逐页解析上传材料并对照验收条件" + strings.Repeat("LONGTASK", 40)
if _, err := c.SpawnBackground("agent.sub.general", "深度解析员", raw, ""); err != nil {
t.Fatal(err)
}
waitUntil(t, func() bool {
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
return err == nil && len(cards) == 1
})
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
if err != nil {
t.Fatal(err)
}
card := cards[0]
var md string
for _, n := range card.Body {
if n.Type == core.UIMarkdown {
md = n.Markdown
}
}
if strings.Contains(md, "LONGTASK") || strings.Contains(md, raw[:32]) {
t.Fatalf("create body still embeds raw task: %q", md)
}
if md != core.TaskSummaryNoFindingsYet {
t.Fatalf("create markdown = %q, want honest placeholder", md)
}
close(runner.release)
waitUntil(t, func() bool {
c.mgr.convMu.Lock()
busy := c.mgr.convActive[c.run.ConversationID]
c.mgr.convMu.Unlock()
return !busy
})
}
// TestBackgroundCardSkippedWithoutInbox:收件箱未启用(cards==nil)时后台派发
-28
View File
@@ -116,34 +116,6 @@ func (eb *eventBuffer) emit(conversationID, runID string, typ core.StreamEventTy
return 0, nil
}
// emitProjectShared persists an explicit internal publication and its proof
// before fan-out. A client-visible payload cannot opt itself into this path.
func (eb *eventBuffer) emitProjectShared(owner, producer, channel, runID string, typ core.StreamEventType, payload any) error {
project, ok := core.ProjectFromChannel(channel)
if !ok {
return store.ErrNotFound
}
encoded, err := json.Marshal(payload)
if err != nil {
return err
}
event := core.StreamEvent{ConversationID: channel, ProjectID: project, RunID: runID, Type: typ, Payload: encoded, TS: time.Now().UTC().Format(time.RFC3339Nano), V: core.ProtocolVersion}
eb.flushConversation(channel)
eb.flushing.Lock()
defer eb.flushing.Unlock()
events := []core.StreamEvent{event}
if _, err := eb.store.AppendProjectSharedEvents(owner, producer, channel, events); err != nil {
return err
}
for _, event := range events {
eb.bus.Publish(event)
}
if eb.onPersisted != nil {
eb.onPersisted(channel, events)
}
return nil
}
// append 落盘并按 seq 顺序发布。
func (eb *eventBuffer) append(conversationID string, evs []core.StreamEvent) error {
eb.flushing.Lock()
+2 -76
View File
@@ -1,8 +1,6 @@
package runmgr
import (
"crypto/sha256"
"encoding/json"
"fmt"
"os"
"path/filepath"
@@ -28,11 +26,6 @@ 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 {
@@ -40,9 +33,6 @@ 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
@@ -57,64 +47,14 @@ 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 次读原件)。
@@ -157,17 +97,10 @@ 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 {
@@ -180,8 +113,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.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) {
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 {
templateName, template = art.Name, body
}
}
@@ -189,13 +122,6 @@ 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, ".") {
-143
View File
@@ -1,143 +0,0 @@
package runmgr
import (
"agent-system-backend/internal/rag"
"agent-system-backend/internal/store"
core "agent-system-core"
"context"
"encoding/json"
"errors"
"testing"
)
func TestRunKnowledgeResourceAuthorization(t *testing.T) {
rc, st := newExecEnv(t)
if err := st.AddKnowledge(core.KnowledgeDoc{ID: "allowed-slice", Owner: "bob", ProjectID: rc.run.ProjectID, Chunk: "resourcequery published"}, core.KnowledgeDoc{ID: "private-slice", Owner: "bob", ProjectID: rc.run.ProjectID, Chunk: "resourcequery private"}, core.KnowledgeDoc{ID: "restricted-self", Owner: "alice", ProjectID: rc.run.ProjectID, Chunk: "resourcequery self"}); err != nil {
t.Fatal(err)
}
rc.mgr.SetRetriever(rag.NewPipeline(st, rc.mgr.settings, nil, nil))
rc.mgr.SetSharedKnowledgeAllow(func(string, core.ID) bool { return true })
rc.mgr.SetKnowledgeResourceAllow(func(actor string, d core.KnowledgeDoc) bool {
return actor == "alice" && d.ID == "allowed-slice" && d.Owner == "bob"
})
hits, err := rc.Retrieve(core.RetrievalQuery{Text: "resourcequery"})
if err != nil || len(hits) != 1 || hits[0].Doc.ID != "allowed-slice" {
t.Fatalf("model-context resources: %+v %v", hits, err)
}
}
func TestProjectWorkRevocationGuardsDirectExecution(t *testing.T) {
rc, st := newExecEnv(t)
rc.mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return false, nil })
conv, err := st.GetConversation("alice", rc.run.ConversationID)
if err != nil {
t.Fatal(err)
}
if _, err := rc.mgr.StartConversationRun(conv, core.Message{}, nil, nil); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("direct run should reject: %v", err)
}
res := rc.ExecuteTool(core.ToolCallRequest{Name: "write_artifact", Arguments: json.RawMessage(`{"name":"forbidden.md","content":"x"}`)})
if res.Status != "error" {
t.Fatalf("revoked execution: %+v", res)
}
artifacts, err := st.ListArtifacts("alice", rc.run.ProjectID)
if err != nil || len(artifacts) != 0 {
t.Fatalf("revoked tool wrote: %+v %v", artifacts, err)
}
}
func TestRunKnowledgeResolvesTrustedProjectScope(t *testing.T) {
rc, st := newExecEnv(t)
if err := st.AddKnowledge(core.KnowledgeDoc{ID: "slice-current", Owner: "bob", ProjectID: "domain-current", Chunk: "scopeneedle current"}, core.KnowledgeDoc{ID: "slice-other", Owner: "bob", ProjectID: "domain-other", Chunk: "scopeneedle other"}); err != nil {
t.Fatal(err)
}
rc.mgr.SetRetriever(rag.NewPipeline(st, rc.mgr.settings, nil, nil))
rc.mgr.SetKnowledgeResourceAllow(func(string, core.KnowledgeDoc) bool { return true })
rc.mgr.SetKnowledgeProjectScope(func(_ context.Context, actor string, id core.ID) (core.ID, error) {
if actor != "alice" || id != rc.run.ProjectID {
t.Fatalf("untrusted scope actor=%s project=%s", actor, id)
}
return "domain-current", nil
})
hits, err := rc.Retrieve(core.RetrievalQuery{Text: "scopeneedle"})
if err != nil || len(hits) != 1 || hits[0].Doc.ID != "slice-current" {
t.Fatalf("model current scope: %+v %v", hits, err)
}
}
func TestProjectWorkRevocationBlocksQueuedHITL(t *testing.T) {
c, st := newExecEnv(t)
c.run.ID = "revoked-run"
c.run.Status = core.RunWaitingHITL
if err := st.CreateRun(&c.run); err != nil {
t.Fatal(err)
}
h := &runHandle{hitlCh: make(chan hitlAnswer, 1)}
c.mgr.active = map[core.ID]*runHandle{c.run.ID: h}
card := core.HITLCard{ID: "revoked-card", RunID: c.run.ID, ConversationID: c.run.ConversationID}
if err := st.SaveHITLCard("alice", &card); err != nil {
t.Fatal(err)
}
c.mgr.SetProjectWorkAuthorization(func(_ context.Context, owner string, id core.ID) (bool, error) {
if owner != "alice" || id != c.run.ProjectID {
t.Fatalf("untrusted resolution identity owner=%s project=%s", owner, id)
}
return false, nil
})
if _, _, err := c.mgr.ResolveHITL("alice", card.ID, core.HITLResponse{ChoiceID: "yes"}); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("revoked confirmation should reject: %v", err)
}
persisted, err := st.GetHITLCard("alice", card.ID)
if err != nil || persisted.Resolved || len(h.hitlCh) != 0 {
t.Fatalf("revoked confirmation changed gate: card=%+v channel=%d error=%v", persisted, len(h.hitlCh), err)
}
run, err := st.GetRunByID(c.run.ID)
if err != nil || run.Status != core.RunWaitingHITL {
t.Fatalf("revoked confirmation resumed run: %+v %v", run, err)
}
payload, _ := json.Marshal(HITLCommandPayload{Owner: "alice", CardID: card.ID, Kind: "yes"})
c.mgr.applyHITLCommand(ownedCommand{CommandID: "queued-approval", Payload: string(payload)})
if len(h.hitlCh) != 0 {
t.Fatal("queued approval delivered a revoked answer")
}
run, err = st.GetRunByID(c.run.ID)
if err != nil || run.Status != core.RunWaitingHITL {
t.Fatalf("queued approval resumed run: %+v %v", run, err)
}
}
func TestProjectWorkRevocationAfterToolConfirmation(t *testing.T) {
for _, choice := range []string{"yes", "session"} {
t.Run(choice, func(t *testing.T) {
c, st := newExecEnv(t)
c.h = &runHandle{hitlCh: make(chan hitlAnswer, 1)}
c.h.hitlCh <- hitlAnswer{resp: core.HITLResponse{ChoiceID: choice}}
c.prof.Tools.Confirm = []string{"write_artifact"}
ready := true
c.mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { ok := ready; ready = false; return ok, nil })
res := c.ExecuteTool(core.ToolCallRequest{CallID: "revoked-call", Name: "write_artifact", Arguments: json.RawMessage(`{"name":"revoked.txt","content":"x"}`)})
artifacts, err := st.ListArtifacts("alice", c.run.ProjectID)
if err != nil {
t.Fatal(err)
}
if res.Status != "error" || len(artifacts) != 0 {
t.Fatalf("tool executed after revoked confirmation: status=%s artifacts=%d", res.Status, len(artifacts))
}
if c.mgr.sessionAllows(c.run.ConversationID, "write_artifact") {
t.Fatal("revoked confirmation granted session permission")
}
})
}
}
func TestProjectWorkRevocationBlocksDirectArtifactWrite(t *testing.T) {
c, st := newExecEnv(t)
c.mgr.SetProjectWorkAuthorization(func(context.Context, string, core.ID) (bool, error) { return false, nil })
if _, err := c.WriteArtifact("revoked.txt", "data", "text/plain", []byte("private")); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("direct artifact should reject: %v", err)
}
artifacts, err := st.ListArtifacts("alice", c.run.ProjectID)
if err != nil || len(artifacts) != 0 {
t.Fatalf("revoked artifact was written: %+v %v", artifacts, err)
}
}
+2 -18
View File
@@ -79,23 +79,7 @@ func TestMaterialIntakeAttachmentOnlyLifecycle(t *testing.T) {
}
close(hold.release)
// 等完成去重门禁本身(CardDone + materialActive 已释放),不要等 notice
// 副作用——deliverNotices 早于 defer materialKeyRelease,systemTexts 先可见会 race。
key := materialKey(materialDocs())
waitUntil(t, func() bool {
cards, err := c.mgr.cards.List(c.run.Owner, c.run.ConversationID)
if err != nil || len(cards) != 1 {
return false
}
card := cards[0]
if card.Title != materialCardTitle || card.Status != core.CardDone || card.Tone == "danger" {
return false
}
c.mgr.materialMu.Lock()
active := c.mgr.materialActive != nil && c.mgr.materialActive[string(c.run.ConversationID)][key]
c.mgr.materialMu.Unlock()
return !active
})
waitUntil(t, func() bool { return len(systemTexts(t, c)) >= 1 })
// 完成后同批再发:已完成说明,同样不重跑;卡片回投保留材料键。
c3 := secondRunCtx(c, materialDocs(), "")
@@ -275,7 +259,7 @@ func TestMaterialQueuedWhenFull(t *testing.T) {
return false
}
for _, card := range cards {
if card.Title == materialCardTitle && card.SourceRunID != "" && strings.Contains(cardBodyText(card), core.TaskSummaryStatusRunning) {
if card.Title == materialCardTitle && card.SourceRunID != "" && strings.Contains(cardBodyText(card), "执行中") {
return true
}
}
+20 -152
View File
@@ -57,11 +57,7 @@ type Manager struct {
retriever core.Retriever // 可选混合检索(nil = store 全文)
// sharedKnowledgeAllow EXT-13:回合内检索的共享授权谓词(请求 owner × 知识所属
// 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)
sharedKnowledgeAllow func(owner string, projectID core.ID) bool
// commands EXT-14:命令即工具的投影与分发源(nil = 未接线,命令不进工具清单)。
commands CommandSource
indexer core.DocumentIndexer // 可选索引入口(nil = store 整文档单块)
@@ -180,12 +176,6 @@ type IntentGate interface {
Match(ctx context.Context, input string) (branch, topID string)
}
// EmitProjectSharedCardEvent is an internal producer-authorized structural
// publication entry, separate from the ordinary private card event emitter.
func (m *Manager) EmitProjectSharedCardEvent(owner, producer, channel, runID string, typ core.StreamEventType, payload any) error {
return m.events.emitProjectShared(owner, producer, channel, runID, typ, payload)
}
// SetGate 注入意图闸门(装配期一次;插件意图 overlay 经 pluginhost 回调重建)。
func (m *Manager) SetGate(g IntentGate) { m.gate = g }
@@ -207,43 +197,6 @@ func (m *Manager) SetSharedKnowledgeAllow(fn func(owner string, projectID core.I
m.sharedKnowledgeAllow = fn
}
// SetKnowledgeResourceAllow makes resource-level authorization authoritative for
// model context, including owned rows. The callback receives stored identities.
func (m *Manager) SetKnowledgeResourceAllow(fn func(string, core.KnowledgeDoc) bool) {
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)) {
m.projectWorkAuthorization = fn
}
func (m *Manager) authorizeProjectWork(ctx context.Context, owner string, id core.ID) error {
if m.projectWorkAuthorization == nil {
return nil
}
ok, err := m.projectWorkAuthorization(ctx, owner, id)
if err != nil {
return err
}
if !ok {
return store.ErrNotFound
}
return nil
}
// SetKnowledgeProjectScope binds host project filters to the provider's trusted
// canonical domain metadata without widening the current conversation scope.
func (m *Manager) SetKnowledgeProjectScope(fn func(context.Context, string, core.ID) (core.ID, error)) {
m.knowledgeProjectScope = fn
}
// SetCommandSource 注入命令即工具源(EXT-14;组合根以命令总线适配)。nil = 现状。
func (m *Manager) SetCommandSource(src CommandSource) { m.commands = src }
@@ -643,7 +596,7 @@ func (c *runCtx) SetAssistantParts(parts []core.Part) error {
return nil
}
func (c *runCtx) AskHITL(card core.HITLCard) (answer core.HITLResponse, err error) {
func (c *runCtx) AskHITL(card core.HITLCard) (core.HITLResponse, error) {
card.ID = store.NewID("hitl")
card.RunID = c.run.ID
card.ConversationID = c.run.ConversationID
@@ -685,13 +638,8 @@ func (c *runCtx) AskHITL(card core.HITLCard) (answer core.HITLResponse, err erro
// 排队,取消语境下重取失败由取消路径收口)。
c.mgr.adm.release(c.mgr.st, string(c.run.ID))
defer func() {
if acquireErr := c.mgr.adm.acquire(c.ctx, c.mgr.st, string(c.run.ID), c.run.Owner); acquireErr != nil {
log.Printf("[runmgr] admission reacquire after HITL %s: %v", c.run.ID, acquireErr)
}
// Permission can change during both confirmation and admission waiting.
// Every HITL caller receives the live decision before it resumes work.
if err == nil {
err = c.mgr.authorizeProjectWork(c.ctx, c.run.Owner, c.run.ProjectID)
if err := c.mgr.adm.acquire(c.ctx, c.mgr.st, string(c.run.ID), c.run.Owner); err != nil {
log.Printf("[runmgr] admission reacquire after HITL %s: %v", c.run.ID, err)
}
}()
return waitHITL(c.ctx, c.h.hitlCh, timeout)
@@ -894,9 +842,6 @@ func (c *runCtx) Artifact(id string) (core.Artifact, []byte, bool) {
}
func (c *runCtx) WriteArtifact(name, kind, mime string, content []byte) (core.Artifact, error) {
if err := c.mgr.authorizeProjectWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
return core.Artifact{}, err
}
art := core.Artifact{ProjectID: c.run.ProjectID, Owner: c.run.Owner, Name: name, Kind: kind, MIME: mime, SourceRun: c.run.ID}
if err := c.mgr.st.CreateArtifact(&art, content); err != nil {
return art, err
@@ -957,40 +902,6 @@ func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) {
if q.ProjectID == "" {
q.ProjectID = c.run.ProjectID
}
if c.mgr.knowledgeProjectScope != nil {
id, err := c.mgr.knowledgeProjectScope(c.ctx, c.run.Owner, q.ProjectID)
if err != nil {
return nil, err
}
q.ProjectID = id
}
if c.mgr.knowledgeResourceAllow != nil {
allow := func(d core.KnowledgeDoc) bool { return c.mgr.knowledgeResourceAllow(string(c.run.Owner), d) }
if rs, ok := c.mgr.retriever.(interface {
RetrieveAuthorized(context.Context, core.ID, core.RetrievalQuery, func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error)
}); ok {
return rs.RetrieveAuthorized(c.ctx, c.run.Owner, q, allow)
}
hits, err := c.mgr.st.SearchKnowledge(c.run.Owner, q)
if err != nil {
return nil, err
}
out := []core.RetrievalHit{}
for _, h := range hits {
if allow(h.Doc) {
out = append(out, h)
}
}
shared, err := c.mgr.st.SearchKnowledgeSharedAuthorized(c.run.Owner, q, allow)
if err != nil {
return nil, err
}
out = append(out, shared...)
if len(out) > q.TopK {
out = out[:q.TopK]
}
return out, nil
}
if c.mgr.retriever != nil {
if c.mgr.sharedKnowledgeAllow != nil {
if rs, ok := c.mgr.retriever.(sharedRetriever); ok {
@@ -1006,9 +917,6 @@ func (c *runCtx) Retrieve(q core.RetrievalQuery) ([]core.RetrievalHit, error) {
// ExecuteTool 执行画像策略:deny→错误;confirm→HITL confirm 卡片;执行→截断转工件。
func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
if err := c.mgr.authorizeProjectWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()}
}
policy := c.prof.Tools
if !policy.ToolAllowed(call.Name) {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: fmt.Sprintf("tool %q denied by profile policy", call.Name)}
@@ -1029,18 +937,12 @@ func (c *runCtx) ExecuteTool(call core.ToolCallRequest) core.ToolCallResult {
if err != nil {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "hitl aborted: " + err.Error()}
}
if err := c.mgr.authorizeProjectWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()}
}
if resp.ChoiceID == "session" {
c.mgr.rememberSessionAllow(c.run.ConversationID, call.Name)
} else if resp.ChoiceID != "yes" {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: "用户取消了本次工具调用"}
}
}
if err := c.mgr.authorizeProjectWork(c.ctx, c.run.Owner, c.run.ProjectID); err != nil {
return core.ToolCallResult{CallID: call.CallID, Status: "error", Output: err.Error()}
}
// EXT-14:命令即工具——命中命令投影时经命令总分发(Entrance=agent、
// Actor/Owner=会话 owner、带会话上下文),同审计信封与高危确认卡收口。
if c.mgr.commands != nil {
@@ -1239,9 +1141,6 @@ func selectConversationRunner(m *Manager, eff core.EffectiveSettings, launch *wo
// attachments 为已解析的附件文档(server 层经 ingest 处理)。
// inputs 是同一批附件的原件,落到会话工作区 inputs/,供模板和仿制使用。
func (m *Manager) StartConversationRun(conv core.Conversation, userMessage core.Message, attachments []core.ParsedDocument, inputs []WorkspaceFile) (core.Run, error) {
if err := m.authorizeProjectWork(context.Background(), conv.Owner, conv.ProjectID); err != nil {
return core.Run{}, err
}
if err := m.claimConversation(conv.ID); err != nil {
return core.Run{}, err
}
@@ -1251,9 +1150,6 @@ func (m *Manager) StartConversationRun(conv core.Conversation, userMessage core.
// StartWorkflowRun 启动一条绑定工作流的会话 run。定义在这里拷贝,
// 之后插件卸载不会改到已经在跑的这份。
func (m *Manager) StartWorkflowRun(conv core.Conversation, userMessage core.Message, def core.WorkflowDef, slots map[string]string, missing []workflow.SlotQuestion) (core.Run, error) {
if err := m.authorizeProjectWork(context.Background(), conv.Owner, conv.ProjectID); err != nil {
return core.Run{}, err
}
if err := m.claimConversation(conv.ID); err != nil {
return core.Run{}, err
}
@@ -1363,10 +1259,6 @@ func (m *Manager) claimConversation(conversationID string) error {
}
func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Message, attachments []core.ParsedDocument, inputs []WorkspaceFile, launch *workflow.Launch) (core.Run, error) {
if err := m.authorizeProjectWork(context.Background(), conv.Owner, conv.ProjectID); err != nil {
m.releaseConversation(conv.ID)
return core.Run{}, err
}
prof, _ := m.profiles.Get(conv.Owner, conv.ProfileID)
eff := m.settings.Effective(conv.Owner, conv.ProjectID)
run := core.Run{
@@ -1384,16 +1276,7 @@ func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Messa
m.mu.Unlock()
runner := selectConversationRunner(m, eff, launch)
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)
}
inputPaths, _ := m.stageInputs(conv, eff, inputs)
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)}
@@ -1698,12 +1581,6 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv
if err != nil {
return "", false, err
}
if m.projectWorkAuthorization != nil && (run.Owner != owner || run.ConversationID != card.ConversationID) {
return "", false, store.ErrNotFound
}
if err := m.authorizeProjectWork(context.Background(), run.Owner, run.ProjectID); err != nil {
return "", false, err
}
// U7 双进程验收实测缺陷修复:run 不在本进程执行时不得先落 resolve——
// 卡一旦标记 resolved,另一实例 server 层的命令 fallback(resolveHITL 的
// !card.Resolved 判定)即被跳过,执行实例的 runner 永远等不到答案,
@@ -1748,9 +1625,6 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv
}
}
if pivot {
if err := m.authorizeProjectWork(context.Background(), run.Owner, run.ProjectID); err != nil {
return "", false, err
}
_ = m.CancelRun(run.ID)
m.st.ResolveHITLCard(owner, cardID, core.HITLResolvedPayload{CardID: core.ID(cardID), Kind: "cancelled", Answer: answer})
m.emit(run.ConversationID, run.ID, core.EvHITLResolved, core.HITLResolvedPayload{CardID: core.ID(cardID), Kind: "cancelled", Answer: answer})
@@ -1761,9 +1635,6 @@ func (m *Manager) ResolveHITL(owner, cardID string, resp core.HITLResponse) (piv
return trimmed, true, nil
}
}
if err := m.authorizeProjectWork(context.Background(), run.Owner, run.ProjectID); err != nil {
return "", false, err
}
resolution := core.HITLResolvedPayload{CardID: core.ID(cardID), Answer: answer, Kind: kind}
if err := m.st.ResolveHITLCard(owner, cardID, resolution); err != nil {
return "", false, err
@@ -2076,14 +1947,13 @@ func (m *Manager) createBackgroundCard(parent core.Run, childID core.ID, name, t
if title == "" {
title = "后台任务"
}
_ = task // 派发说明保留入参兼容;用户可见正文用 L1 骨架,不回填原始指令。
statusLabel := core.TaskSummaryStatusRunning
if status != "" {
statusLabel = status
if status == "" {
status = "执行中,完成后自动回投结果"
}
body := []core.UINode{
{Type: core.UIMarkdown, Markdown: truncate(strings.TrimSpace(task), 400)},
{Type: core.UIKV, KV: append(append([][2]string(nil), kv...), [2]string{"通道", "慢通道 · 后台执行"}, [2]string{"状态", status})},
}
focus := "正在执行:" + title
extra := append(append([][2]string(nil), kv...), [2]string{"通道", "慢通道 · 后台执行"})
body := (core.TaskSummaryView{Focus: focus}).RunningBody(statusLabel, extra)
card, err := m.cards.Create(parent.Owner, core.ActivityCard{
Kind: core.CardKindProgress, Title: title, Status: core.CardActive, Tone: "info",
ConversationID: parent.ConversationID, ProjectID: parent.ProjectID, SourceRunID: childID,
@@ -2106,9 +1976,9 @@ func (m *Manager) promoteQueuedCard(owner string, card core.ActivityCard, childI
if node.Type != core.UIKV {
continue
}
for j, pair := range node.KV {
if pair[0] == core.TaskSummaryKVStatus || pair[0] == "状态" {
card.Body[i].KV[j][1] = core.TaskSummaryStatusRunning
for j, kv := range node.KV {
if kv[0] == "状态" {
card.Body[i].KV[j][1] = "执行中,完成后自动回投结果"
}
}
}
@@ -2118,19 +1988,15 @@ func (m *Manager) promoteQueuedCard(owner string, card core.ActivityCard, childI
}
// finishBackgroundCard 把后台子代理终态写回进度卡:成功=结果摘要(含工件行),
// 失败=错误说明并置 CardFailed(U1:失败不得显示完成灯)。kv 原样保留。
// 失败=错误说明;状态统一置 done,tone 区分成败。kv 原样保留(材料键等持久凭据)。
func (m *Manager) finishBackgroundCard(owner string, card core.ActivityCard, payload core.AgentResultPayload, err error, kv [][2]string) {
if m.cards == nil || card.ID == "" {
return
}
summary := strings.TrimSpace(payload.Summary)
tone := "ok"
status := core.CardDone
statusLabel := core.TaskSummaryStatusDone
if err != nil {
tone = "danger"
status = core.CardFailed
statusLabel = core.TaskSummaryStatusFailed
if summary == "" {
summary = err.Error()
}
@@ -2139,12 +2005,14 @@ func (m *Manager) finishBackgroundCard(owner string, card core.ActivityCard, pay
if summary == "" {
summary = "(无结果摘要)"
}
extra := append(append([][2]string(nil), kv...), [2]string{"通道", "慢通道 · 后台执行"})
card.Body = (core.TaskSummaryView{OutcomeLine: summary}).FinishedBody(statusLabel, extra)
card.Body = []core.UINode{
{Type: core.UIMarkdown, Markdown: summary},
{Type: core.UIKV, KV: append(append([][2]string(nil), kv...), [2]string{"通道", "慢通道 · 后台执行"}, [2]string{"状态", "已完成"})},
}
if uerr := m.cards.Update(owner, card); uerr != nil {
log.Printf("[runmgr] background card update: %v", uerr)
}
if _, serr := m.cards.SetStatus(owner, card.ID, status, tone, ""); serr != nil {
if _, serr := m.cards.SetStatus(owner, card.ID, core.CardDone, tone, ""); serr != nil {
log.Printf("[runmgr] background card status: %v", serr)
}
}
-97
View File
@@ -1,97 +0,0 @@
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")
}
}
+1 -1
View File
@@ -45,7 +45,7 @@ type fakeRunCtx struct {
memEntries []memEntry // 非空时 StoreMemory 返回之(N3 WP5 记忆注入断言用)
usageTotal core.Usage // AddUsage 累计(N3 WP6 记账断言用)
bgCalls int
runtimeExtra map[string]any // 覆盖 runtime 组设置(双速提醒阈值等测试用)
runtimeExtra map[string]any // 覆盖 runtime 组设置(双速提醒阈值等测试用)
attachments []core.ParsedDocument // Attachments 返回值(双速入口前置提示测试用)
spawnAgentFn func(agentID, name, task, hint string) (core.AgentResultPayload, error)
execStatus string // 非空时覆盖 ExecuteTool 状态(R4 失败链测试)
-33
View File
@@ -1,33 +0,0 @@
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)
}
}
+9 -10
View File
@@ -6,8 +6,7 @@ import (
core "agent-system-core"
)
// SecretLookup is wired by the composition root. Protected services resolve only
// system authority; ok=true with an empty value means explicit revocation.
// SecretLookup 由组合根注入。返回该服务当前生效的明文;没有独立密钥时 ok 为 false,调用方用设置里的旧字段。
var SecretLookup func(owner, service string) (string, bool)
// LookupSecret 给组合根以外的调用方使用。owner 为空时用 local。
@@ -22,15 +21,15 @@ func overlayKey(owner, service, fallback string) string {
if owner == "" {
owner = "local"
}
if v, ok := SecretLookup(owner, service); ok {
if v, ok := SecretLookup(owner, service); ok && strings.TrimSpace(v) != "" {
return v
}
return fallback
}
// 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.
// 模型路由解析(4.7 / 4.9 覆盖链):设置层 defaults → system → user → project,
// 画像 ModelRoute 为会话内最高层覆盖(kind 为空回落设置层主模型)。
// 端点与密钥按 kind 独立配置,轻量档空则回落主档。
// ContextInjection 是一条条件注入,由 runmgr 从存储读出,组装时再转换成 ctxasm 的形状。
type ContextInjection struct {
@@ -51,10 +50,10 @@ type ResolvedModel struct {
Key string
}
// ResolveChatModel resolves system-managed chat settings in actual runs.
// ResolveChatModel 解析主对话模型(画像 route > 设置层)。
func ResolveChatModel(prof core.AgentProfile, eff core.EffectiveSettings) ResolvedModel {
m := eff.GetString("models.chat.model", "")
if !eff.GetBool("_policy.managedModels", false) && prof.Model.Chat != "" {
if prof.Model.Chat != "" {
m = prof.Model.Chat
}
return ResolvedModel{Kind: "chat", Model: m,
@@ -81,12 +80,12 @@ func ChatFallbacks(eff core.EffectiveSettings) []string {
return out
}
// ResolveLightweightModel resolves the managed lightweight route, then chat.
// ResolveLightweightModel 解析轻量模型:画像 route > 设置层轻量档 > 回落主档。
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 !eff.GetBool("_policy.managedModels", false) && prof.Model.Lightweight != "" {
if prof.Model.Lightweight != "" {
m = prof.Model.Lightweight
}
if m == "" || base == "" {
+29 -177
View File
@@ -9,7 +9,6 @@ import (
"encoding/hex"
"encoding/json"
"errors"
"io"
"net/http"
"strings"
"sync"
@@ -61,73 +60,20 @@ func loginClear(owner string) {
delete(loginGuard.fails, owner)
}
// 兑换入口退避:共享库窗口计数(v29 redeem_attempts),多实例共担同一限流;
// Key 无属主可先行归因,只能按来源 IP 限流。
const (
redeemMaxFails = 10
redeemWindow = loginLockWindow
)
// requestSecure 判断请求是否经 HTTPS(直连 TLS 或反代 X-Forwarded-Proto 标头)。
func requestSecure(r *http.Request) bool {
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}
func setSessionCookie(w http.ResponseWriter, r *http.Request, token string, maxAge int) {
func setSessionCookie(w http.ResponseWriter, token string, maxAge int) {
http.SetCookie(w, &http.Cookie{
Name: SessionCookie, Value: token, Path: "/", HttpOnly: true,
SameSite: http.SameSiteLaxMode, MaxAge: maxAge, Secure: requestSecure(r),
SameSite: http.SameSiteLaxMode, MaxAge: maxAge,
})
}
func authStateJSON(st store.UserAuthState) map[string]any {
return map[string]any{
"owner": st.Owner, "name": st.Name, "isAdmin": st.IsAdmin,
"mustChangePassword": st.MustChangePassword, "passwordSet": st.HasPassword,
"auth": true,
"mustChangePassword": st.MustChangePassword, "auth": true,
}
}
// redeemLoginKey:一次性登录 Key → set_password_only 会话(专用 body,不经 Bearer)。
// 请求只有 Key:身份由服务端绑定记录确定;按来源 IP 退避防爆破。
func (s *Server) redeemLoginKey(w http.ResponseWriter, r *http.Request) {
if s.Auth == nil || !s.Auth.Enabled {
http.Error(w, "auth disabled (embedded single-tenant mode)", http.StatusPreconditionFailed)
return
}
ip, _, _ := strings.Cut(r.RemoteAddr, ":")
if xf := r.Header.Get("X-Forwarded-For"); xf != "" {
ip = strings.TrimSpace(strings.Split(xf, ",")[0])
}
if blocked, err := s.st.RedeemBlocked(ip, redeemMaxFails, redeemWindow); err == nil && blocked {
http.Error(w, "too many failed attempts; retry later", http.StatusTooManyRequests)
return
}
var req struct {
Key string `json:"key"`
}
if err := decodeJSON(r, &req); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
token, owner, err := s.st.RedeemLoginKey(strings.TrimSpace(req.Key))
if err != nil {
_ = s.st.RecordRedeemFailure(ip) // 尽力记录;限流判定的权威在 RedeemBlocked
if errors.Is(err, store.ErrConflict) {
http.Error(w, "login key already used", http.StatusConflict)
return
}
http.Error(w, "invalid or expired login key", http.StatusUnauthorized)
return
}
setSessionCookie(w, r, token, int(10*60))
st, _ := s.st.GetUserAuthState(owner)
out := authStateJSON(st)
out["sessionScope"] = store.SessionScopeSetPasswordOnly
out["reloginRequired"] = false
writeJSON(w, http.StatusOK, out)
}
// login:用户名+密码 → 会话。失败统一 401(不区分账号是否存在);
// 连续失败触发进程内退避(429)。
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
@@ -152,34 +98,20 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
http.Error(w, "too many failed attempts; retry later", http.StatusTooManyRequests)
return
}
st, ver, ok := s.st.VerifyLoginWithVersion(owner, req.Password)
st, ok := s.st.VerifyLogin(owner, req.Password)
if !ok {
loginFail(owner)
http.Error(w, "invalid owner or password", http.StatusUnauthorized)
return
}
loginClear(owner)
// 待改密/未设密:建受限会话(只能设密);否则正常会话。会话创建绑定
// 认证版本——密码验证在途期间账户被恢复/重置时本请求不得再建会话。
scope := store.SessionScopeFull
maxAge := 7 * 24 * 3600
if st.MustChangePassword || !st.HasPassword {
scope = store.SessionScopeSetPasswordOnly
maxAge = 10 * 60
}
token, err := s.st.CreateSessionVerified(owner, scope, time.Duration(maxAge)*time.Second, ver)
token, err := s.st.CreateSession(owner)
if err != nil {
if errors.Is(err, store.ErrConflict) {
http.Error(w, "credentials changed concurrently; retry login", http.StatusUnauthorized)
return
}
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
setSessionCookie(w, r, token, maxAge)
out := authStateJSON(st)
out["sessionScope"] = scope
writeJSON(w, http.StatusOK, out)
setSessionCookie(w, token, 7*24*3600)
writeJSON(w, http.StatusOK, authStateJSON(st))
}
// logout:注销当前会话。
@@ -187,21 +119,19 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
if tok := sessionToken(r); tok != "" {
s.st.DeleteSession(tok)
}
setSessionCookie(w, r, "", -1)
setSessionCookie(w, "", -1)
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// changePassword:会话登录者改密。
// - 受限会话或 must_change/未设密:不要求 current;事务内复验会话与认证版本,
// 完成后销毁全部会话,要求重新密码登录(reloginRequired=true)。
// - 普通主动改密:要求 current;单事务完成校验、更新、吊销其他会话(保留当前)。
// changePassword:会话登录者改密。校验当前密码 → 新哈希落库 → 清 must_change →
// 吊销其余会话(保留当前)。重置后的首登改密走同一路径。
func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
tok := sessionToken(r)
if tok == "" {
http.Error(w, "session login required (API keys do not own passwords)", http.StatusUnauthorized)
return
}
owner, scope, ok := s.st.SessionInfo(tok)
owner, ok := s.st.SessionOwner(tok)
if !ok {
http.Error(w, "session expired", http.StatusUnauthorized)
return
@@ -214,6 +144,10 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if _, ok := s.st.VerifyLogin(owner, req.Current); !ok {
http.Error(w, "current password incorrect", http.StatusUnauthorized)
return
}
if err := validPassword(req.Next); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
@@ -223,36 +157,18 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
st0, _ := s.st.GetUserAuthState(owner)
forceSet := scope == store.SessionScopeSetPasswordOnly || st0.MustChangePassword || !st0.HasPassword
if forceSet {
if _, err := s.st.CompleteSetPasswordChecked(tok, hash); err != nil {
if errors.Is(err, store.ErrNotFound) {
http.Error(w, "session expired", http.StatusUnauthorized)
return
}
writeStoreErr(w, err)
return
}
setSessionCookie(w, r, "", -1)
st, _ := s.st.GetUserAuthState(owner)
out := authStateJSON(st)
out["reloginRequired"] = true
writeJSON(w, http.StatusOK, out)
return
}
if err := s.st.ChangeUserPasswordActive(owner, req.Current, hash, tok); err != nil {
if errors.Is(err, store.ErrBadCredentials) {
http.Error(w, "current password incorrect", http.StatusUnauthorized)
return
}
if err := s.st.SetUserPassword(owner, hash); err != nil {
writeStoreErr(w, err)
return
}
// SetUserPassword 置了 must_change=1(重置语义);本人主动改密后即清除。
if err := s.st.ClearMustChangePassword(owner); err != nil {
writeStoreErr(w, err)
return
}
s.st.DeleteUserSessions(owner, tok)
st, _ := s.st.GetUserAuthState(owner)
out := authStateJSON(st)
out["reloginRequired"] = false
writeJSON(w, http.StatusOK, out)
writeJSON(w, http.StatusOK, authStateJSON(st))
}
func validPassword(pw string) error {
@@ -286,8 +202,7 @@ func (s *Server) listAdminUsers(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAdmin(w, r)
if !ok {
if _, ok := s.requireAdmin(w, r); !ok {
return
}
var req struct {
@@ -314,7 +229,7 @@ func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
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 {
if err := s.st.CreateAccount(req.Owner, strings.TrimSpace(req.Name), hash, req.IsAdmin); err != nil {
writeStoreErr(w, err)
return
}
@@ -325,12 +240,9 @@ func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) {
})
}
// resetAdminUserPassword:管理员直接重置密码(兼容保留的既有入口;常规恢复
// 走签发一次性登录 Key)。单事务:must_change=1、bump auth_version、作废未用
// 登录 Key、吊销全部会话、审计。
// resetAdminUserPassword:管理员重置密码 → must_change=1,吊销该用户全部会话。
func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAdmin(w, r)
if !ok {
if _, ok := s.requireAdmin(w, r); !ok {
return
}
owner := r.PathValue("owner")
@@ -350,77 +262,17 @@ func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request)
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
if err := s.st.AdminResetUserPassword(owner, hash, actor, "admin-http"); err != nil {
if err := s.st.SetUserPassword(owner, hash); err != nil {
writeStoreErr(w, err)
return
}
s.st.DeleteUserSessions(owner, "")
st, _ := s.st.GetUserAuthState(owner)
writeJSON(w, http.StatusOK, map[string]any{
"owner": st.Owner, "mustChangePassword": st.MustChangePassword,
})
}
// ---- 管理员:一次性登录 Key(恢复流程主入口)----
// issueAdminLoginKey:为指定既有用户签发恢复 Key(明文仅本次响应返回)。
// 同事务撤销旧恢复 Key 及其受限会话;不改密码、不吊销正常会话、不动业务数据。
func (s *Server) issueAdminLoginKey(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAdmin(w, r)
if !ok {
return
}
owner := strings.TrimSpace(r.PathValue("owner"))
var req struct {
Purpose string `json:"purpose"`
TTLMinutes int `json:"ttlMinutes"`
}
if err := decodeJSON(r, &req); err != nil && !errors.Is(err, io.EOF) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
purpose := req.Purpose
if purpose == "" {
purpose = store.LoginKeyPurposeRecover
}
if purpose != store.LoginKeyPurposeRecover && purpose != store.LoginKeyPurposeActivate {
http.Error(w, "purpose must be recover or activate", http.StatusBadRequest)
return
}
ttl := time.Duration(req.TTLMinutes) * time.Minute
plain, meta, err := s.st.IssueLoginKey(owner, purpose, actor, ttl)
if err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"id": meta.ID, "owner": meta.Owner, "purpose": meta.Purpose,
"status": meta.Status, "expiresAt": meta.ExpiresAt, "createdBy": meta.CreatedBy,
"key": plain, // 明文仅此一次;关闭弹层后不可再取
})
}
// listAdminLoginKeys:指定用户的 Key 状态与有效期(不返回明文)。
func (s *Server) listAdminLoginKeys(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireAdmin(w, r); !ok {
return
}
keys, err := s.st.ListLoginKeys(r.PathValue("owner"))
writeJSONOr(w, map[string]any{"keys": keys}, err)
}
// revokeAdminLoginKey:撤销 Key 并作废其关联受限会话。
func (s *Server) revokeAdminLoginKey(w http.ResponseWriter, r *http.Request) {
actor, ok := s.requireAdmin(w, r)
if !ok {
return
}
if _, err := s.st.RevokeLoginKeyCascade(r.PathValue("id"), actor); err != nil {
writeStoreErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// RandomPassword 生成随机初始密码(管理员/CLI 未显式给定密码时)。
func RandomPassword() string {
raw := make([]byte, 9)
+18 -82
View File
@@ -14,16 +14,13 @@ type ownerCtxKey struct{}
// authViaCtxKey 携带凭证通道(AuthViaKey / AuthViaSession;未启用认证与豁免路径为空)。
type authViaCtxKey struct{}
// sessionScopeCtxKey 携带会话 scope(full / set_password_only)。
type sessionScopeCtxKey struct{}
// 凭证通道值:/api/me 的 authVia 字段;前端据此区分「改密门」只对会话登录者放行。
const (
AuthViaKey = "key"
AuthViaSession = "session"
)
// Authenticator 是 API key 认证(阶段 1:owner 即租户)。
// Authenticator 是 API key 认证(阶段 1:user 即租户)。
// Enabled=false(嵌入式档默认,回环本地单用户)时全部请求回落默认租户。
// SSE 的 EventSource 无法带 Authorization 头,允许 ?key= 查询参数兜底。
type Authenticator struct {
@@ -44,94 +41,41 @@ func (a *Authenticator) Middleware(next http.Handler) http.Handler {
next.ServeHTTP(w, r.WithContext(withOwner(r, store.OwnerDefault)))
return
}
// 豁免:静态前端(登录页须可加载)与登录入口本身。
p := r.URL.Path
// 豁免:静态前端、登录、一次性 Key 兑换(body 专用,不走 Bearer)。
if !strings.HasPrefix(p, "/api/") || p == "/api/auth/login" || p == "/api/auth/login-key/redeem" {
if !strings.HasPrefix(p, "/api/") || p == "/api/auth/login" {
next.ServeHTTP(w, r)
return
}
// 凭证二选一:Authorization: Bearer <api key>(程序化;?key= 兼容 SSE 老客户端)
// 或会话 cookie(浏览器登录;2026-09-30 R2 管理员发放制)。
// 失效 key 不一票否决:浏览器 localStorage 可能残留已吊销 key(前端对所有请求
// 附加 Bearer 头),会话 cookie 仍有效时回落会话;程序化客户端不带 cookie,
// 行为不变(两者都无效才 401)。
key := bearerKey(r)
if key == "" {
key = r.URL.Query().Get("key")
}
// 一次性登录 Key 不得经 Bearer / ?key= 使用。
if strings.HasPrefix(key, "alk_") {
http.Error(w, "unauthorized: one-time login keys must use /api/auth/login-key/redeem", http.StatusUnauthorized)
return
}
var owner string
var via string
var scope string
ok := false
if key != "" {
if o, keyOK := a.st.AuthOwner(key); keyOK {
owner, via, scope, ok = o, AuthViaKey, store.SessionScopeFull, true
} else if o, sc, sessOK := cookieSession(r, a.st); sessOK {
owner, via, scope, ok = o, AuthViaSession, sc, true
} else {
http.Error(w, "unauthorized: invalid or revoked API key", http.StatusUnauthorized)
if owner, ok := a.st.AuthOwner(key); ok {
next.ServeHTTP(w, r.WithContext(withAuthVia(withOwner(r, owner), AuthViaKey)))
return
}
} else if o, sc, sessOK := cookieSession(r, a.st); sessOK {
owner, via, scope, ok = o, AuthViaSession, sc, true
}
if !ok {
http.Error(w, "unauthorized: login required", http.StatusUnauthorized)
if owner, ok := cookieOwner(r, a.st); ok {
next.ServeHTTP(w, r.WithContext(withAuthVia(withOwner(r, owner), AuthViaSession)))
return
}
http.Error(w, "unauthorized: invalid or revoked API key", http.StatusUnauthorized)
return
}
ctx := withAuthVia(withOwner(r, owner), via)
ctx = context.WithValue(ctx, sessionScopeCtxKey{}, scope)
if err := a.enforcePasswordGate(w, r, owner, via, scope, p); err != nil {
if owner, ok := cookieOwner(r, a.st); ok {
next.ServeHTTP(w, r.WithContext(withAuthVia(withOwner(r, owner), AuthViaSession)))
return
}
next.ServeHTTP(w, r.WithContext(ctx))
http.Error(w, "unauthorized: login required", http.StatusUnauthorized)
})
}
func (a *Authenticator) enforcePasswordGate(w http.ResponseWriter, r *http.Request, owner, via, scope, path string) error {
allow := path == "/api/me" || path == "/api/auth/password" || path == "/api/auth/logout" ||
path == "/api/health"
if scope == store.SessionScopeSetPasswordOnly {
if !allow {
http.Error(w, "forbidden: set-password session only", http.StatusForbidden)
return errGate
}
return nil
}
st, err := a.st.GetUserAuthState(owner)
if err != nil {
return nil
}
// 长期 API Key:仅当 must_change(已有密码待改)时拒绝业务,防止绕过改密门;
// 无密码的纯集成账户仍可走 Key(与历史 apikey 语义兼容)。
if via == AuthViaKey {
if st.MustChangePassword && !allow {
http.Error(w, "forbidden: password change required", http.StatusForbidden)
return errGate
}
return nil
}
// 会话:待改密或尚未设密 → 只允许身份/设密/退出。
if (st.MustChangePassword || !st.HasPassword) && !allow {
http.Error(w, "forbidden: password setup required", http.StatusForbidden)
return errGate
}
return nil
}
var errGate = errorsGate{}
type errorsGate struct{}
func (errorsGate) Error() string { return "gate" }
func cookieSession(r *http.Request, st *store.Store) (owner, scope string, ok bool) {
if tok := sessionToken(r); tok != "" {
return st.SessionInfo(tok)
}
return "", "", false
}
// cookieOwner 会话 cookie 有效时返回其 owner。
func cookieOwner(r *http.Request, st *store.Store) (string, bool) {
if tok := sessionToken(r); tok != "" {
@@ -164,14 +108,6 @@ func AuthVia(r *http.Request) string {
return ""
}
// SessionScope 返回当前会话 scope(非会话为空)。
func SessionScope(r *http.Request) string {
if v, ok := r.Context().Value(sessionScopeCtxKey{}).(string); ok {
return v
}
return ""
}
func bearerKey(r *http.Request) string {
h := r.Header.Get("Authorization")
if strings.HasPrefix(h, "Bearer ") {
+9 -119
View File
@@ -321,29 +321,10 @@ func TestAdminAccountFlow(t *testing.T) {
return rec
}
// /api/me 允许 must_change;业务 API 拒绝(U4 设密门)。
// 会话可访问 API;/api/me 携带身份与管理员位。
if rec := session("GET", "/api/me", ""); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"isAdmin":true`) {
t.Fatalf("me via session: %d %s", rec.Code, rec.Body.String())
}
if rec := session("POST", "/api/admin/users", `{"owner":"alice","name":"爱丽丝","password":"alice-init-01"}`); rec.Code != http.StatusForbidden {
t.Fatalf("must_change admin must be gated before create: %d %s", rec.Code, rec.Body.String())
}
// 首登改密:不要求 current;成功后销毁会话,须重新密码登录。
if rec := session("POST", "/api/auth/password", `{"next":"admin-final-9"}`); rec.Code != http.StatusOK ||
!strings.Contains(rec.Body.String(), `"reloginRequired":true`) {
t.Fatalf("admin force set-password: %d %s", rec.Code, rec.Body.String())
}
if rec := session("GET", "/api/me", ""); rec.Code != http.StatusUnauthorized {
t.Fatalf("admin session must die after set-password: %d", rec.Code)
}
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"admin","password":"admin-final-9"}`))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) {
t.Fatalf("admin relogin after set-password: %d %s", rec.Code, rec.Body.String())
}
tok = strings.Split(strings.Split(rec.Header().Get("Set-Cookie"), SessionCookie+"=")[1], ";")[0]
// 管理员建号(普通用户,初始密码)。
if rec := session("POST", "/api/admin/users", `{"owner":"alice","name":"爱丽丝","password":"alice-init-01"}`); rec.Code != http.StatusOK {
@@ -354,7 +335,7 @@ func TestAdminAccountFlow(t *testing.T) {
t.Fatalf("duplicate owner must 409: %d", rec.Code)
}
// alice 首登:mustChangePassword=true;业务 API 被设密门拒绝。
// alice 首登:mustChangePassword=true;改密后清除。
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-init-01"}`))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
@@ -377,19 +358,16 @@ func TestAdminAccountFlow(t *testing.T) {
if !strings.Contains(rec.Body.String(), `"mustChangePassword":true`) {
t.Fatalf("alice first login must force change: %s", rec.Body.String())
}
// alice 非管理员:建号被拒 403。
if rec := alice("POST", "/api/admin/users", `{"owner":"bob","password":"bob-init-001"}`); rec.Code != http.StatusForbidden {
t.Fatalf("must_change non-admin create must 403: %d", rec.Code)
t.Fatalf("non-admin create must 403: %d", rec.Code)
}
// must_change 改密忽略 current;短密码拒绝;成功后 reloginRequired。
if rec := alice("POST", "/api/auth/password", `{"next":"short"}`); rec.Code != http.StatusBadRequest {
t.Fatalf("short password must 400: %d", rec.Code)
// alice 改密(错误当前密码 → 401;成功 → must_change 清除)。
if rec := alice("POST", "/api/auth/password", `{"current":"bad","next":"alice-final-9"}`); rec.Code != http.StatusUnauthorized {
t.Fatalf("wrong current password: %d", rec.Code)
}
if rec := alice("POST", "/api/auth/password", `{"current":"ignored","next":"alice-final-9"}`); rec.Code != http.StatusOK ||
!strings.Contains(rec.Body.String(), `"reloginRequired":true`) {
t.Fatalf("alice force set-password: %d %s", rec.Code, rec.Body.String())
}
if rec := alice("GET", "/api/me", ""); rec.Code != http.StatusUnauthorized {
t.Fatalf("alice session must die after set-password: %d", rec.Code)
if rec := alice("POST", "/api/auth/password", `{"current":"alice-init-01","next":"alice-final-9"}`); rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) {
t.Fatalf("alice change password: %d %s", rec.Code, rec.Body.String())
}
// 旧密码不能再登录;新密码可以且无需改密。
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-init-01"}`))
@@ -399,18 +377,6 @@ func TestAdminAccountFlow(t *testing.T) {
if rec.Code != http.StatusUnauthorized {
t.Fatalf("old password must fail after change: %d", rec.Code)
}
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-final-9"}`))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) {
t.Fatalf("alice relogin: %d %s", rec.Code, rec.Body.String())
}
aliceTok = strings.Split(strings.Split(rec.Header().Get("Set-Cookie"), SessionCookie+"=")[1], ";")[0]
// 已设密普通用户:建号仍 403(非管理员)。
if rec := alice("POST", "/api/admin/users", `{"owner":"bob","password":"bob-init-001"}`); rec.Code != http.StatusForbidden {
t.Fatalf("non-admin create must 403: %d", rec.Code)
}
// 管理员重置 alice 密码 → 其旧会话吊销 + 首登再改密。
if rec := session("POST", "/api/admin/users/alice/password", `{"password":"reset-init-02"}`); rec.Code != http.StatusOK {
@@ -527,79 +493,3 @@ func TestStaleKeySessionFallback(t *testing.T) {
t.Fatalf("me via session: %d %s", rec.Code, rec.Body.String())
}
}
// TestSystemSettingsAdminGate:P2 系统设置面按管理员准入——普通账号 403,
// 管理员可读写且值对所有租户生效;普通账号写受管项仍 400。
func TestSystemSettingsAdminGate(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
hash, err := store.HashPassword("init-pass-01")
if err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("boss", "管理员甲", hash, true); err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("dave", "普通乙", hash, false); err != nil {
t.Fatal(err)
}
keyBoss, err := st.CreateAPIKey("boss", "cli")
if err != nil {
t.Fatal(err)
}
keyDave, err := st.CreateAPIKey("dave", "cli")
if err != nil {
t.Fatal(err)
}
// 初始密码改密门不参与本测试对象(受管拦截语义),先解除。
if err := st.ClearMustChangePassword("boss"); err != nil {
t.Fatal(err)
}
if err := st.ClearMustChangePassword("dave"); err != nil {
t.Fatal(err)
}
do := func(key, method, path string, body string) *httptest.ResponseRecorder {
var rd io.Reader
if body != "" {
rd = strings.NewReader(body)
}
req := httptest.NewRequest(method, path, rd)
req.Header.Set("Authorization", "Bearer "+key)
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec
}
// 普通账号:读写系统面均 403;写受管项走 user 面仍是 400(先于管理员判定也成立)。
if rec := do(keyDave, "GET", "/api/settings/system", ""); rec.Code != http.StatusForbidden {
t.Fatalf("dave get system: %d", rec.Code)
}
if rec := do(keyDave, "PUT", "/api/settings/system", `{"models":{"chat":{"model":"m-dave"}}}`); rec.Code != http.StatusForbidden {
t.Fatalf("dave put system: %d", rec.Code)
}
if rec := do(keyDave, "PUT", "/api/settings/user", `{"models":{"chat":{"model":"m-dave"}}}`); rec.Code != http.StatusBadRequest {
t.Fatalf("dave managed via user: %d body=%s", rec.Code, rec.Body.String())
}
if rec := do(keyDave, "PUT", "/api/settings/user", `{"ui":{"theme":"light"}}`); rec.Code != http.StatusOK {
t.Fatalf("dave non-managed via user: %d body=%s", rec.Code, rec.Body.String())
}
// 管理员:写入生效,系统读面掩码返回,dave 的有效设置拿到系统值。
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", "")
if rec.Code != http.StatusOK {
t.Fatalf("boss get system: %d", rec.Code)
}
if strings.Contains(rec.Body.String(), "sk-boss-secret") {
t.Fatalf("system read leaked secret key")
}
if eff := settings.NewService(st).Effective("dave", ""); eff.GetString("models.chat.model", "") != "m-boss" {
t.Fatalf("system value not effective for dave: %q", eff.GetString("models.chat.model", ""))
}
}
+1 -40
View File
@@ -7,7 +7,6 @@ import (
"strings"
agentcommand "agent-command"
"agent-system-backend/internal/settings"
"agent-system-backend/internal/store"
core "agent-system-core"
)
@@ -16,13 +15,7 @@ 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))
filtered := make([]store.Secret, 0, len(list))
for _, secret := range list {
if !settings.ProtectedSecretService(secret.Service) {
filtered = append(filtered, secret)
}
}
writeJSONOr(w, filtered, err)
writeJSONOr(w, list, err)
}
func (s *Server) createSecret(w http.ResponseWriter, r *http.Request) {
@@ -35,10 +28,6 @@ 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)
@@ -48,16 +37,10 @@ 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"`
}
@@ -69,27 +52,9 @@ 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"))
@@ -199,10 +164,6 @@ func (s *Server) regenerateMessage(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
if err := s.projectWork(r.Context(), owner, conv.ProjectID); err != nil {
writeProjectErr(w, err)
return
}
var req struct {
MessageID string `json:"messageId"`
}
+50 -115
View File
@@ -48,14 +48,12 @@ func newTestServer(t *testing.T) (*Server, *store.Store) {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
_ = st.MergeSettingsScope(store.OwnerDefault, settings.ScopeUser, map[string]any{"runtime": map[string]any{"mock": true}})
b := bus.New()
reg := tools.NewRegistry()
reg.RegisterBuiltin()
setSvc := settings.NewService(st)
// P2 受管:runtime.mock 是系统受管项,mock 驱动改经系统层写入(对所有租户生效)。
if err := setSvc.MergeSystem(map[string]any{"runtime": map[string]any{"mock": true}}); err != nil {
t.Fatal(err)
}
profSvc := profiles.New(st)
profSvc.EnsureDefault()
mgr := runmgr.New(st, b, reg, profSvc, setSvc, &runtime.LLMRunner{}, &runtime.SubLLMRunner{}, &runtime.MockRunner{}, &runtime.MockSubagent{})
@@ -132,17 +130,6 @@ 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)
@@ -1023,71 +1010,50 @@ func TestSquadOrchestration(t *testing.T) {
t.Fatal("squad deliverable artifact not found")
}
// TestModelRoutingOverrideChain:P2 受管语义下的模型配置链路——models.* 是系统
// 受管项,user/project 层整笔拒绝(含嵌套与点路径),仅系统层可写;画像层模型名
// 存量画像层也不能覆盖。经由真实 run 的失败信息携带解析出的模型名取证。
// TestModelRoutingOverrideChain:设置覆盖链集成(defaults → user → project)
// 与画像层最终覆盖——经由真实 run 的失败信息携带解析出的模型名取证。
func TestModelRoutingOverrideChain(t *testing.T) {
srv, st := newTestServer(t)
h := srv.Handler()
projectID, convID := setupConversation(t, h)
// user/project 层写受管项 → 400(含点路径与整体子树覆盖)。
for _, tc := range []struct {
name string
path string
body map[string]any
}{
{"nested user", "/api/settings/user", map[string]any{"models": map[string]any{"chat": map[string]any{"model": "model-A"}}}},
{"dotted user", "/api/settings/user", map[string]any{"models.chat.model": "model-A"}},
{"nested project", "/api/settings/project/" + projectID, map[string]any{"models": map[string]any{"chat": map[string]any{"model": "model-B"}}}},
{"subtree smash user", "/api/settings/user", map[string]any{"models": "smash"}},
} {
code, out := doJSON(t, h, "PUT", tc.path, tc.body)
if code != http.StatusBadRequest {
t.Fatalf("%s: PUT %s = %d %v,want 400", tc.name, tc.path, code, out)
set := func(body map[string]any, path string) {
code, out := doJSON(t, h, "PUT", path, body)
if code != 200 {
t.Fatalf("PUT %s: %d %v", path, code, out)
}
}
// 系统层(无认证测试档 = 本机管理员)写入生效。
code, out := doJSON(t, h, "PUT", "/api/settings/system", map[string]any{
"models": map[string]any{"chat": map[string]any{"model": "model-SYS"}},
})
// user 层:model-A。
set(map[string]any{"models": map[string]any{"chat": map[string]any{"model": "model-A"}}}, "/api/settings/user")
eff := settings.NewService(st).Effective(store.OwnerDefault, projectID)
if got := eff.GetString("models.chat.model", ""); got != "model-A" {
t.Fatalf("user layer: %q", got)
}
// project 层:model-B(覆盖 user)。
set(map[string]any{"models": map[string]any{"chat": map[string]any{"model": "model-B"}}}, "/api/settings/project/"+projectID)
eff = settings.NewService(st).Effective(store.OwnerDefault, projectID)
if got := eff.GetString("models.chat.model", ""); got != "model-B" {
t.Fatalf("project layer should win: %q", got)
}
// 设置 API 呈现两层。
code, out := doJSON(t, h, "GET", "/api/settings?project="+projectID, nil)
if code != 200 {
t.Fatalf("system write: %d %v", code, out)
}
if eff := settings.NewService(st).Effective(store.OwnerDefault, projectID); eff.GetString("models.chat.model", "") != "model-SYS" {
t.Fatalf("system layer not effective: %q", eff.GetString("models.chat.model", ""))
}
// 系统 GET 面:schema 携带 scope,系统值脱敏返回。
code, out = doJSON(t, h, "GET", "/api/settings/system", nil)
if code != 200 {
t.Fatalf("get system: %d %v", code, out)
}
sysView := out
if sysView["system"] == nil || sysView["schema"] == nil {
t.Fatalf("system view malformed: %#v", sysView)
}
// 非受管项仍可走 user 层(回归)。
if code, _ := doJSON(t, h, "PUT", "/api/settings/user", map[string]any{"ui": map[string]any{"theme": "light"}}); code != 200 {
t.Fatalf("non-managed user write rejected")
t.Fatal("get settings failed")
}
_ = out
// 新画像路由被拒,存量画像路由保留原数据但退出生效链。
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")
}
code, _ = doJSON(t, h, "POST", "/api/profiles", map[string]any{
// 画像层:model-C 为最终覆盖。关 mock → 发消息 → run 失败信息携带解析结果。
set(map[string]any{"runtime": map[string]any{"mock": false}}, "/api/settings/user")
prof := mustCode(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 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)
if prof["id"] != "prof_route_c" {
t.Fatalf("profile save: %v", prof)
}
mustCode(t, h, "PATCH", "/api/conversations/"+convID, map[string]any{"profileId": "prof_route_c"})
runID := postMessage(t, h, convID, "触发一次真实模型调用(预期失败并携带解析出的模型名)")
@@ -1096,8 +1062,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-SYS`) || strings.Contains(errMsg, `model-C`) {
t.Fatalf("run error should carry system-routed model name: %q", errMsg)
if !strings.Contains(errMsg, `model-C`) {
t.Fatalf("run error should carry profile-routed model name: %q", errMsg)
}
}
@@ -1239,30 +1205,20 @@ func TestProjectInbox(t *testing.T) {
}
}
// TestSettingsDottedKeyAndSecretMask:点路径写入生效、受管密钥归系统层、
// 掩码不回写、user/system 读面均不回明文、user 面不回显受管项(P2)。
// TestSettingsDottedKeyAndSecretMask:点路径写入生效、嵌套胜出、掩码不回写、GET 不回明文。
func TestSettingsDottedKeyAndSecretMask(t *testing.T) {
srv, st := newTestServer(t)
h := srv.Handler()
svc := settings.NewService(st)
set := func(body map[string]any) {
t.Helper()
code, out := doJSON(t, h, "PUT", "/api/settings/user", body)
if code != 200 {
t.Fatalf("PUT user: %d %v", code, out)
}
}
setSys := func(body map[string]any) {
t.Helper()
code, out := doJSON(t, h, "PUT", "/api/settings/system", body)
if code != 200 {
t.Fatalf("PUT system: %d %v", code, out)
t.Fatalf("PUT: %d %v", code, out)
}
}
svc := settings.NewService(st)
setSys(map[string]any{"runtime.entry": "squad"})
set(map[string]any{"ui.theme": "light"})
set(map[string]any{"runtime.entry": "squad"})
if got := svc.Effective(store.OwnerDefault, "").GetString("runtime.entry", ""); got != "squad" {
t.Fatalf("dotted write did not take effect: %q", got)
}
@@ -1276,25 +1232,21 @@ func TestSettingsDottedKeyAndSecretMask(t *testing.T) {
const pool = "tvly-should-not-leak"
const modelKey = "sk-should-not-leak"
// P2 受管:密钥项 user 层整笔拒绝,系统层写入生效。
if code, _ := doJSON(t, h, "PUT", "/api/settings/user", map[string]any{
"tools": map[string]any{"tavily": map[string]any{"apiKeys": pool}},
}); code != http.StatusBadRequest {
t.Fatalf("managed pool write via user should 400, got %d", code)
}
setSys(map[string]any{"tools": map[string]any{"tavily": map[string]any{"apiKeys": pool}}})
setSys(map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": modelKey}}})
// 掩码回写经系统层:不覆盖已存明文。
setSys(map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": settings.MaskPlaceholder}}})
set(map[string]any{"tools": map[string]any{"tavily": map[string]any{"apiKeys": pool}}})
set(map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": modelKey}}})
set(map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": settings.MaskPlaceholder}}})
set(map[string]any{
"tools": map[string]any{"tavily": map[string]any{"apiKeys": "nested-wins"}},
"tools.tavily.apiKeys": "flat-loses",
})
eff := svc.Effective(store.OwnerDefault, "")
if got := eff.GetString("models.chat.apiKey", ""); got != modelKey {
t.Fatalf("mask writeback overwrote key: %q", got)
}
if got := eff.GetString("tools.tavily.apiKeys", ""); got != pool {
t.Fatalf("system pool not effective: %q", got)
if got := eff.GetString("tools.tavily.apiKeys", ""); got != "nested-wins" {
t.Fatalf("nested form should win, got %q", got)
}
// 读面:user 面不回显受管项;系统面返回掩码;两个面都不泄漏明文。
code, out := doJSON(t, h, "GET", "/api/settings", nil)
if code != 200 {
t.Fatalf("GET settings: %d", code)
@@ -1303,36 +1255,19 @@ func TestSettingsDottedKeyAndSecretMask(t *testing.T) {
if err != nil {
t.Fatal(err)
}
for _, secret := range []string{pool, modelKey} {
for _, secret := range []string{pool, modelKey, "nested-wins", "flat-loses"} {
if strings.Contains(string(blob), secret) {
t.Fatalf("GET /api/settings leaked %q", secret)
t.Fatalf("GET leaked %q", secret)
}
}
user := out["user"].(map[string]any)
if user["models"] != nil || user["tools"] != nil {
t.Fatalf("managed subtrees exposed in user scope: %#v", user)
}
code, sys := doJSON(t, h, "GET", "/api/settings/system", nil)
if code != 200 {
t.Fatalf("GET system: %d", code)
}
sysBlob, err := json.Marshal(sys)
if err != nil {
t.Fatal(err)
}
for _, secret := range []string{pool, modelKey} {
if strings.Contains(string(sysBlob), secret) {
t.Fatalf("GET /api/settings/system leaked %q", secret)
}
}
sysValues := sys["system"].(map[string]any)
tavily := sysValues["tools"].(map[string]any)["tavily"].(map[string]any)
tavily := user["tools"].(map[string]any)["tavily"].(map[string]any)
if tavily["apiKeys"] != settings.MaskPlaceholder {
t.Fatalf("system apiKeys mask = %v", tavily["apiKeys"])
t.Fatalf("apiKeys mask = %v", tavily["apiKeys"])
}
chat := sysValues["models"].(map[string]any)["chat"].(map[string]any)
chat := user["models"].(map[string]any)["chat"].(map[string]any)
if chat["apiKey"] != settings.MaskPlaceholder {
t.Fatalf("system apiKey mask = %v", chat["apiKey"])
t.Fatalf("apiKey mask = %v", chat["apiKey"])
}
}
-65
View File
@@ -1,65 +0,0 @@
package server
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"agent-system-backend/internal/store"
)
func TestRedeemLoginKeyRestrictedSession(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
if err := st.CreateAccount("carol", "", "", false); err != nil {
t.Fatal(err)
}
plain, _, err := st.IssueLoginKey("carol", store.LoginKeyPurposeActivate, "test", 0)
if err != nil {
t.Fatal(err)
}
body, _ := json.Marshal(map[string]string{"key": plain})
req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", bytes.NewReader(body))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("redeem: %d %s", rec.Code, rec.Body.String())
}
cookie := rec.Result().Cookies()
var sess string
for _, c := range cookie {
if c.Name == SessionCookie {
sess = c.Value
}
}
if sess == "" {
t.Fatal("missing session cookie")
}
// 业务拒绝
req = httptest.NewRequest("GET", "/api/projects", nil)
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: sess})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Fatalf("projects want 403, got %d", rec.Code)
}
// me 允许
req = httptest.NewRequest("GET", "/api/me", nil)
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: sess})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("me: %d", rec.Code)
}
// 设密后要求重新登录
pwBody, _ := json.Marshal(map[string]string{"next": "carol-final-99"})
req = httptest.NewRequest("POST", "/api/auth/password", bytes.NewReader(pwBody))
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: sess})
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !bytes.Contains(rec.Body.Bytes(), []byte(`"reloginRequired":true`)) {
t.Fatalf("set password: %d %s", rec.Code, rec.Body.String())
}
}
-311
View File
@@ -1,311 +0,0 @@
package server
// 登录恢复流程 HTTP 合同(2026-10-13):管理员签发一次性登录 Key → 用户兑换 →
// 受限会话强制设密 → 重新密码登录;must-change 首登同样建受限会话。
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"agent-system-backend/internal/store"
)
// bootAdmin:建管理员并完成首登改密,返回已放行的会话 cookie 值。
func bootAdmin(t *testing.T, h http.Handler, st *store.Store) string {
t.Helper()
bootHash, err := store.HashPassword("boot-pass-00")
if err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("admin", "", bootHash, true); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"admin","password":"boot-pass-00"}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("boot login: %d %s", rec.Code, rec.Body.String())
}
tok := cookieOf(rec)
if tok == "" {
t.Fatal("missing session cookie")
}
req = httptest.NewRequest("POST", "/api/auth/password", strings.NewReader(`{"next":"admin-final-9"}`))
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: tok})
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("boot set password: %d %s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"admin","password":"admin-final-9"}`))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("relogin: %d %s", rec.Code, rec.Body.String())
}
return cookieOf(rec)
}
func cookieOf(rec *httptest.ResponseRecorder) string {
for _, ck := range rec.Result().Cookies() {
if ck.Name == SessionCookie {
return ck.Value
}
}
return ""
}
func reqWithCookie(method, path, body, cookie string) *http.Request {
var rdr *bytes.Reader
if body != "" {
rdr = bytes.NewReader([]byte(body))
} else {
rdr = bytes.NewReader(nil)
}
req := httptest.NewRequest(method, path, rdr)
if cookie != "" {
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: cookie})
}
if body != "" {
req.Header.Set("Content-Type", "application/json")
}
return req
}
func do(h http.Handler, method, path, body, cookie string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
h.ServeHTTP(rec, reqWithCookie(method, path, body, cookie))
return rec
}
// 管理员签发 → 列表无明文 → 兑换 → 受限 → 设密 → 新密码登录。
func TestAdminLoginKeyRecoveryFlow(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
adminTok := bootAdmin(t, h, st)
if err := st.CreateAccount("mallory", "", "", false); err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("pleb", "", "", false); err != nil {
t.Fatal(err)
}
// 匿名/无会话不能签发。
if rec := do(h, "POST", "/api/admin/users/mallory/login-keys", `{}`, "not-a-session"); rec.Code != http.StatusUnauthorized {
t.Fatalf("anonymous issue: %d", rec.Code)
}
// 管理员签发:返回明文一次 + 元数据。
rec := do(h, "POST", "/api/admin/users/mallory/login-keys", `{}`, adminTok)
if rec.Code != http.StatusOK {
t.Fatalf("issue: %d %s", rec.Code, rec.Body.String())
}
var issued struct {
ID string `json:"id"`
Key string `json:"key"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &issued); err != nil {
t.Fatal(err)
}
if !strings.HasPrefix(issued.Key, "alk_") || issued.ID == "" {
t.Fatalf("issue payload: %+v", issued)
}
// 列表:状态可见、无明文。
rec = do(h, "GET", "/api/admin/users/mallory/login-keys", "", adminTok)
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), "alk_") {
t.Fatalf("list must not leak plaintext: %d %s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), `"status":"unused"`) {
t.Fatalf("list should show unused status: %s", rec.Body.String())
}
// 用户兑换:只提交 Key,身份由服务端绑定决定。
req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+issued.Key+`"}`))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"owner":"mallory"`) ||
!strings.Contains(rec.Body.String(), `"sessionScope":"set_password_only"`) {
t.Fatalf("redeem: %d %s", rec.Code, rec.Body.String())
}
userTok := cookieOf(rec)
// 重复兑换失败。
req = httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+issued.Key+`"}`))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusConflict {
t.Fatalf("replay redeem: %d", rec.Code)
}
// 受限会话:业务与管理员入口均拒绝。
if rec := do(h, "GET", "/api/projects", "", userTok); rec.Code != http.StatusForbidden {
t.Fatalf("business api: %d", rec.Code)
}
if rec := do(h, "POST", "/api/admin/users/pleb/login-keys", `{}`, userTok); rec.Code != http.StatusForbidden {
t.Fatalf("restricted session must not issue keys: %d", rec.Code)
}
// 设密(无 current)→ reloginRequired → 新密码登录。
rec = do(h, "POST", "/api/auth/password", `{"next":"mallory-final-9"}`, userTok)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"reloginRequired":true`) {
t.Fatalf("set password: %d %s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"mallory","password":"mallory-final-9"}`))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"mustChangePassword":true`) {
t.Fatalf("login with new password: %d %s", rec.Code, rec.Body.String())
}
}
// 补发与撤销:旧 Key 作废、旧受限会话失效。
func TestAdminLoginKeyReissueAndRevoke(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
adminTok := bootAdmin(t, h, st)
if err := st.CreateAccount("nia", "", "", false); err != nil {
t.Fatal(err)
}
issue := func() (string, string) {
rec := do(h, "POST", "/api/admin/users/nia/login-keys", `{}`, adminTok)
if rec.Code != http.StatusOK {
t.Fatalf("issue: %d %s", rec.Code, rec.Body.String())
}
var out struct {
ID string `json:"id"`
Key string `json:"key"`
}
json.Unmarshal(rec.Body.Bytes(), &out)
return out.ID, out.Key
}
oldID, oldKey := issue()
_ = oldID
// 兑换旧 Key 得受限会话;补发后该会话失效、旧 Key 不可再用。
req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+oldKey+`"}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
oldSess := cookieOf(rec)
if oldSess == "" {
t.Fatal("redeem old key failed")
}
issue()
if rec := do(h, "GET", "/api/me", "", oldSess); rec.Code != http.StatusUnauthorized {
t.Fatalf("restricted session must die after reissue: %d", rec.Code)
}
req = httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+oldKey+`"}`))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code == http.StatusOK {
t.Fatal("consumed old key must not redeem again")
}
// 撤销端点:未用 Key 撤销后不可兑换。
newID, newKey := issue()
if rec := do(h, "DELETE", "/api/admin/users/nia/login-keys/"+newID, "", adminTok); rec.Code != http.StatusOK {
t.Fatalf("revoke: %d %s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+newKey+`"}`))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code == http.StatusOK {
t.Fatal("revoked key must not redeem")
}
// 不存在的编号 404。
if rec := do(h, "DELETE", "/api/admin/users/nia/login-keys/lk_nope", "", adminTok); rec.Code != http.StatusNotFound {
t.Fatalf("unknown key revoke: %d", rec.Code)
}
}
// must-change 首登:登录即建受限会话(sessionScope 回传),设密后重新登录为 full。
func TestLoginMustChangeCreatesRestrictedSession(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
bootHash, err := store.HashPassword("temp-pass-00")
if err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("oscar", "", bootHash, false); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"oscar","password":"temp-pass-00"}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"sessionScope":"set_password_only"`) {
t.Fatalf("must-change login must create restricted session: %d %s", rec.Code, rec.Body.String())
}
tok := cookieOf(rec)
if rec := do(h, "GET", "/api/projects", "", tok); rec.Code != http.StatusForbidden {
t.Fatalf("business api before set: %d", rec.Code)
}
rec = do(h, "POST", "/api/auth/password", `{"next":"oscar-final-9"}`, tok)
if rec.Code != http.StatusOK {
t.Fatalf("set password: %d %s", rec.Code, rec.Body.String())
}
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"oscar","password":"oscar-final-9"}`))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), `"sessionScope":"full"`) {
t.Fatalf("full login after set: %d %s", rec.Code, rec.Body.String())
}
}
// 主动改密:必须带正确 current;成功保留当前会话。
func TestActiveChangePasswordRequiresCurrent(t *testing.T) {
srv, st := newAuthTestServer(t)
h := srv.Handler()
_ = srv
if err := st.CreateAccount("petra", "", "", false); err != nil {
t.Fatal(err)
}
plain, _, err := st.IssueLoginKey("petra", store.LoginKeyPurposeActivate, "test", 0)
if err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"`+plain+`"}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
tok := cookieOf(rec)
rec = do(h, "POST", "/api/auth/password", `{"next":"petra-first-8"}`, tok)
if rec.Code != http.StatusOK {
t.Fatalf("initial set: %d %s", rec.Code, rec.Body.String())
}
// 重新登录得 full 会话。
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"petra","password":"petra-first-8"}`))
req.Header.Set("Content-Type", "application/json")
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
tok = cookieOf(rec)
// 缺 current / 错 current 拒绝。
if rec := do(h, "POST", "/api/auth/password", `{"next":"petra-second-9"}`, tok); rec.Code != http.StatusUnauthorized {
t.Fatalf("active change without current: %d", rec.Code)
}
if rec := do(h, "POST", "/api/auth/password", `{"current":"wrong-current","next":"petra-second-9"}`, tok); rec.Code != http.StatusUnauthorized {
t.Fatalf("active change with wrong current: %d", rec.Code)
}
// 正确 current 成功且当前会话保留。
rec = do(h, "POST", "/api/auth/password", `{"current":"petra-first-8","next":"petra-second-9"}`, tok)
if rec.Code != http.StatusOK || strings.Contains(rec.Body.String(), `"reloginRequired":true`) {
t.Fatalf("active change: %d %s", rec.Code, rec.Body.String())
}
if rec := do(h, "GET", "/api/me", "", tok); rec.Code != http.StatusOK {
t.Fatalf("current session must survive active change: %d", rec.Code)
}
}
-14
View File
@@ -273,20 +273,6 @@ func (s *Server) submitSurfaceAction(w http.ResponseWriter, r *http.Request) {
http.Error(w, "surface capability not enabled", http.StatusNotImplemented)
return
}
if s.projectLifecycle != nil {
snap, err := s.Surfaces.Get(r.Context(), s.owner(r), core.ID(r.PathValue("id")))
if err != nil {
s.writeSurfaceError(w, err)
return
}
if snap.Identity.ProjectID != "" {
if err := s.projectWork(r.Context(), s.owner(r), snap.Identity.ProjectID); err != nil {
writeProjectErr(w, err)
return
}
}
}
// 注意:本端点不绑定任何 confirmed 类字段——高危确认只能经既有确认卡回道
// (bus.Confirm)置位,客户端/模型无法伪造(U5 提案第二节第 2 条红测)。
var body struct {
-30
View File
@@ -1,30 +0,0 @@
package server
import (
core "agent-system-core"
)
// projectStreamMembership consults the registered resource authorizer using the
// physical project's trusted owner, never the event publisher or payload claims.
func (s *Server) projectStreamMembership(actor, project string) bool {
p, err := s.st.ProjectMetadata(project)
if err != nil {
return false
}
return s.authorizeSharedResource(actor, "stream", "project", project, p.Owner, project)
}
func (s *Server) projectEventVisible(actor string, event core.StreamEvent) bool {
if s.projectLifecycle == nil {
return true
}
access, err := s.st.ResolveProjectEventAccess(event)
if err != nil || access.Owner == "" {
return false
}
if access.Owner == actor {
return true
}
project, ok := core.ProjectFromChannel(event.ConversationID)
return ok && access.Shared && s.projectStreamMembership(actor, project)
}
-163
View File
@@ -1,163 +0,0 @@
package server
import (
"agent-system-backend/internal/cardsvc"
"agent-system-backend/pkg/extension"
core "agent-system-core"
"bufio"
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
)
func projectStreamReplay(t *testing.T, s *Server, key, project string) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest("GET", "/api/events?scope=project&projectId="+project+"&since=0", nil)
r.Header.Set("Authorization", "Bearer "+key)
ctx, cancel := context.WithTimeout(r.Context(), 200*time.Millisecond)
defer cancel()
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, r.WithContext(ctx))
return w
}
func projectEventFixture(t *testing.T) (*Server, string, string, string, *atomic.Bool) {
t.Helper()
s, st := newAuthTestServer(t)
alice, _ := st.CreateAPIKey("alice", "test")
bob, _ := st.CreateAPIKey("bob", "test")
p, _ := st.CreateProject("alice", "Project")
s.Cards = cardsvc.New(st, s.mgr.EmitCardEvent)
s.Cards.SetProjectSharedEmitter(s.mgr.EmitProjectSharedCardEvent)
member := &atomic.Bool{}
member.Store(true)
s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
return q.ResourceType == "project" && q.ProjectID == p.ID && (q.Actor == "alice" || (q.Actor == "bob" && member.Load())), nil
})
return s, alice, bob, p.ID, member
}
func TestProjectEventsLegacyReplayIsPrivate(t *testing.T) {
s, alice, bob, p, _ := projectEventFixture(t)
s.DisableUploadIndexing = true
if w := lifecycleUpload(t, s, alice, p, "legacy-owner-private.txt", "PRIVATE"); w.Code != 200 {
t.Fatal(w.Code)
}
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) { return true, nil }}, nil)
member := projectStreamReplay(t, s, bob, p)
if member.Code != 200 || bytes.Contains(member.Body.Bytes(), []byte("legacy-owner-private.txt")) || bytes.Contains(member.Body.Bytes(), []byte("builtin:txt")) {
t.Fatal("legacy private upload leaked to member")
}
owner := projectStreamReplay(t, s, alice, p)
if owner.Code != 200 || !bytes.Contains(owner.Body.Bytes(), []byte("legacy-owner-private.txt")) {
t.Fatal("owner lost private history")
}
}
func TestProjectEventsSharedProofAndPrivatePagination(t *testing.T) {
s, _, bob, p, _ := projectEventFixture(t)
s.SetProjectLifecycle(extension.ProjectLifecycle{}, nil)
card, err := s.Cards.Create("alice", core.ActivityCard{ProjectID: p, Title: "PRIVATE HISTORY"}, "")
if err != nil {
t.Fatal(err)
}
payload, _ := json.Marshal(map[string]any{"card": card, "owner": "bob", "shared": true, "producer": "plugin:rdm:structure"})
private := make([]core.StreamEvent, 1001)
for i := range private {
private[i] = core.StreamEvent{Type: core.EvCardUpdated, Payload: payload, TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1}
}
if _, err := s.st.AppendEvents(core.ProjectChannelKey(p), private); err != nil {
t.Fatal(err)
}
if _, err := s.Cards.PublishProjectCard("alice", "plugin:rdm:structure", core.ActivityCard{ProjectID: p, Title: "EXPLICIT SHARED"}, ""); err != nil {
t.Fatal(err)
}
w := projectStreamReplay(t, s, bob, p)
if w.Code != 200 || bytes.Contains(w.Body.Bytes(), []byte("PRIVATE HISTORY")) || !bytes.Contains(w.Body.Bytes(), []byte("EXPLICIT SHARED")) {
t.Fatal("private pagination/spoof proof failed")
}
}
func TestProjectEventsLivePrivacyAndRevocation(t *testing.T) {
s, _, bob, p, member := projectEventFixture(t)
s.SetProjectLifecycle(extension.ProjectLifecycle{}, nil)
httpServer := httptest.NewServer(s.Handler())
defer httpServer.Close()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, "GET", httpServer.URL+"/api/events?scope=project&projectId="+p, nil)
req.Header.Set("Authorization", "Bearer "+bob)
response, err := httpServer.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
if _, err := s.Cards.Create("alice", core.ActivityCard{ProjectID: p, Title: "LIVE PRIVATE"}, ""); err != nil {
t.Fatal(err)
}
if _, err := s.Cards.PublishProjectCard("alice", "plugin:rdm:structure", core.ActivityCard{ProjectID: p, Title: "LIVE SHARED"}, ""); err != nil {
t.Fatal(err)
}
reader := bufio.NewReader(response.Body)
var before bytes.Buffer
for {
line, err := reader.ReadString('\n')
if err != nil {
t.Fatalf("live shared event did not arrive: %v", err)
}
before.WriteString(line)
if bytes.Contains([]byte(line), []byte("LIVE SHARED")) {
break
}
}
if bytes.Contains(before.Bytes(), []byte("LIVE PRIVATE")) {
t.Fatal("live private frame leaked before revocation")
}
member.Store(false)
if _, err := s.Cards.PublishProjectCard("alice", "plugin:rdm:structure", core.ActivityCard{ProjectID: p, Title: "AFTER REMOVAL"}, ""); err != nil {
t.Fatal(err)
}
body, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("revoked stream did not stop: %v", err)
}
if bytes.Contains(body, []byte("LIVE PRIVATE")) || bytes.Contains(body, []byte("AFTER REMOVAL")) {
t.Fatal("live private/revoked event leaked")
}
}
func TestUnregisteredProjectEventsRetainLegacyReads(t *testing.T) {
s, _, bob, p, _ := projectEventFixture(t)
if _, err := s.Cards.Create("alice", core.ActivityCard{ProjectID: p, Title: "LEGACY BASE"}, ""); err != nil {
t.Fatal(err)
}
w := projectStreamReplay(t, s, bob, p)
if w.Code != 200 || !bytes.Contains(w.Body.Bytes(), []byte("LEGACY BASE")) {
t.Fatal("unregistered project stream changed")
}
}
func TestProjectEventsIdleStreamStopsAfterMembershipRemoval(t *testing.T) {
previous := sseAuthRecheck
sseAuthRecheck = 20 * time.Millisecond
defer func() { sseAuthRecheck = previous }()
s, _, bob, p, member := projectEventFixture(t)
s.SetProjectLifecycle(extension.ProjectLifecycle{}, nil)
httpServer := httptest.NewServer(s.Handler())
defer httpServer.Close()
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
request, _ := http.NewRequestWithContext(ctx, "GET", httpServer.URL+"/api/events?scope=project&projectId="+p, nil)
request.Header.Set("Authorization", "Bearer "+bob)
response, err := httpServer.Client().Do(request)
if err != nil {
t.Fatal(err)
}
defer response.Body.Close()
member.Store(false)
body, err := io.ReadAll(response.Body)
if err != nil || !bytes.Contains(body, []byte("project-access-revoked")) {
t.Fatal("idle project stream kept revoked member connected")
}
}
-82
View File
@@ -1,82 +0,0 @@
package server
import (
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
"context"
"errors"
"net/http"
)
// SetProjectLifecycle is wired only for a registered in-process provider. The
// optional host factory constructs a capability scoped to this request's actor.
func (s *Server) SetProjectLifecycle(p extension.ProjectLifecycle, host func(string) extension.Host) {
s.projectLifecycle = &p
s.projectHost = host
}
func (s *Server) lifecycleHost(owner string) extension.Host {
if s.projectHost == nil {
return nil
}
return s.projectHost(owner)
}
func (s *Server) projectWork(ctx context.Context, owner, projectID string) error {
if s.projectLifecycle == nil {
return nil
}
if s.projectLifecycle.Work == nil {
return store.ErrNotFound
}
ok, err := s.projectLifecycle.Work(ctx, owner, core.ID(projectID))
if err != nil {
return err
}
if !ok {
return store.ErrNotFound
}
return nil
}
// createPrivateConversation is shared by the API and /new. Only live work
// authorization grants member creation; the resulting conversation stays owned.
func (s *Server) createPrivateConversation(ctx context.Context, owner, projectID, title, profile string) (core.Conversation, error) {
if s.projectLifecycle != nil {
if err := s.projectWork(ctx, owner, projectID); err != nil {
return core.Conversation{}, err
}
return s.st.CreateConversationInProject(owner, projectID, title, profile)
}
c, err := s.st.CreateConversation(owner, projectID, title, profile)
if errors.Is(err, store.ErrNotFound) && s.authorizeSharedResource(owner, "read", "project", core.ID(projectID), "", core.ID(projectID)) {
return s.st.CreateConversationInProject(owner, projectID, title, profile)
}
return c, err
}
func writeProjectErr(w http.ResponseWriter, err error) {
var coded interface{ StatusCode() int }
if errors.As(err, &coded) {
switch code := coded.StatusCode(); code {
case 400, 403, 404, 409, 503:
http.Error(w, err.Error(), code)
return
}
}
writeStoreErr(w, err)
}
func writeProjectJSON(w http.ResponseWriter, p any, err error) {
if err != nil {
writeProjectErr(w, err)
return
}
writeJSON(w, http.StatusOK, p)
}
// publicProjectMetadata also masks provider-returned metadata, since providers
// may obtain raw owner-management rows before producing a shared public view.
func (s *Server) publicProjectMetadata(p core.Project) core.Project {
if s.projectLifecycle != nil {
p.Settings = s.set.MaskedProjectMetadata(p.Settings)
}
return p
}
-504
View File
@@ -1,504 +0,0 @@
package server
import (
"agent-system-backend/internal/cardsvc"
"agent-system-backend/internal/cmdbus"
"agent-system-backend/internal/surfacesvc"
"agent-system-backend/pkg/extension"
core "agent-system-core"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"time"
)
type lifecycleStatusError int
func (e lifecycleStatusError) Error() string { return "provider refusal" }
func (e lifecycleStatusError) StatusCode() int { return int(e) }
func lifecycleRequest(t *testing.T, s *Server, key, method, path, body string) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest(method, path, bytes.NewBufferString(body))
r.Header.Set("Authorization", "Bearer "+key)
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, r)
return w
}
func TestProjectLifecycleDelegatesAndPreservesArchiveHistory(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := st.CreateAPIKey("alice", "test")
p, _ := st.CreateProject("alice", "Original")
c, _ := st.CreateConversation("alice", p.ID, "Private", "")
calls := []string{}
s.SetProjectLifecycle(extension.ProjectLifecycle{
List: func(context.Context, extension.Host) ([]core.Project, error) {
calls = append(calls, "list")
return []core.Project{{ID: p.ID, Owner: "alice", Name: "Decorated", Settings: map[string]any{"rdm.state": "ready"}}}, nil
},
Create: func(_ context.Context, _ extension.Host, name string) (core.Project, error) {
calls = append(calls, "create")
return core.Project{ID: "provider-created", Name: name}, nil
},
Rename: func(_ context.Context, _ extension.Host, id, name string) (core.Project, error) {
calls = append(calls, "rename")
return core.Project{ID: id, Name: name}, nil
},
Archive: func(context.Context, extension.Host, string) error { calls = append(calls, "archive"); return nil },
Work: func(context.Context, string, core.ID) (bool, error) { return true, nil },
}, nil)
for _, q := range []struct{ method, path, body string }{{"GET", "/api/projects", ""}, {"POST", "/api/projects", `{"name":" New "}`}, {"PATCH", "/api/projects/" + p.ID, `{"name":"Renamed"}`}, {"DELETE", "/api/projects/" + p.ID, ""}} {
w := lifecycleRequest(t, s, key, q.method, q.path, q.body)
if w.Code != 200 {
t.Fatalf("%s: %d %s", q.method, w.Code, w.Body.String())
}
}
if len(calls) != 4 || calls[0] != "list" || calls[1] != "create" || calls[2] != "rename" || calls[3] != "archive" {
t.Fatalf("calls=%v", calls)
}
if _, err := st.GetConversation("alice", c.ID); err != nil {
t.Fatalf("archive removed history: %v", err)
}
s.SetProjectLifecycle(extension.ProjectLifecycle{Create: func(context.Context, extension.Host, string) (core.Project, error) {
return core.Project{}, fmtWrap(lifecycleStatusError(409))
}}, nil)
if w := lifecycleRequest(t, s, key, "POST", "/api/projects", `{"name":"dup"}`); w.Code != 409 {
t.Fatalf("trusted status=%d %s", w.Code, w.Body.String())
}
}
func fmtWrap(err error) error { return errors.Join(errors.New("wrapped"), err) }
func TestProjectLifecycleMemberPrivateWork(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")
ac, _ := st.CreateConversation("alice", p.ID, "Alice private", "")
ready := true
s.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil })
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(_ context.Context, actor string, id core.ID) (bool, error) {
return ready && (actor == "alice" || actor == "bob") && id == core.ID(p.ID), nil
}}, nil)
w := lifecycleRequest(t, s, bob, "POST", "/api/projects/"+p.ID+"/conversations", `{"title":"Bob private"}`)
if w.Code != 200 {
t.Fatalf("member create=%d %s", w.Code, w.Body.String())
}
var bc core.Conversation
if err := json.Unmarshal(w.Body.Bytes(), &bc); err != nil {
t.Fatal(err)
}
w = lifecycleRequest(t, s, bob, "GET", "/api/projects/"+p.ID+"/conversations", "")
var cs []core.Conversation
_ = json.Unmarshal(w.Body.Bytes(), &cs)
if w.Code != 200 || len(cs) != 1 || cs[0].ID != bc.ID {
t.Fatalf("member list=%d %+v", w.Code, cs)
}
if w := lifecycleRequest(t, s, bob, "GET", "/api/conversations/"+ac.ID, ""); w.Code != 404 {
t.Fatalf("private history=%d", w.Code)
}
if w := lifecycleRequest(t, s, mallory, "POST", "/api/projects/"+p.ID+"/conversations", `{}`); w.Code != 404 {
t.Fatalf("nonmember create=%d", w.Code)
}
if w := lifecycleRequest(t, s, mallory, "GET", "/api/projects/"+p.ID+"/conversations", ""); w.Code != 404 {
t.Fatalf("nonmember list=%d", w.Code)
}
s.Bus = cmdbus.New(st, nil, func() time.Time { return time.Now() })
s.WireBuiltinCommands()
w = lifecycleRequest(t, s, bob, "POST", "/api/conversations/"+bc.ID+"/messages", `{"text":"/new"}`)
if w.Code != 200 || !bytes.Contains(w.Body.Bytes(), []byte(`"conversation"`)) {
t.Fatalf("/new=%d %s", w.Code, w.Body.String())
}
ready = false
for _, q := range []struct{ key, method, path, body string }{{bob, "POST", "/api/projects/" + p.ID + "/conversations", `{}`}, {bob, "GET", "/api/projects/" + p.ID + "/conversations", ""}, {alice, "POST", "/api/conversations/" + ac.ID + "/messages", `{"text":"new work"}`}, {bob, "POST", "/api/commands/conversation.create", `{"conversationId":"` + bc.ID + `"}`}} {
w := lifecycleRequest(t, s, q.key, q.method, q.path, q.body)
if w.Code != 404 {
t.Fatalf("revoked %s=%d %s", q.path, w.Code, w.Body.String())
}
}
if w := lifecycleRequest(t, s, bob, "GET", "/api/conversations/"+bc.ID, ""); w.Code != 200 {
t.Fatalf("owned revoked history=%d", w.Code)
}
}
func TestProjectLifecycleReadOnlyAuthorizerDoesNotGrantWrite(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := st.CreateAPIKey("bob", "test")
p, _ := st.CreateProject("alice", "Shared")
s.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil })
s.SetProjectLifecycle(extension.ProjectLifecycle{}, nil)
if w := lifecycleRequest(t, s, key, http.MethodPost, "/api/projects/"+p.ID+"/conversations", `{}`); w.Code != 404 {
t.Fatalf("read-only write=%d %s", w.Code, w.Body.String())
}
if cs, err := st.ListConversations("bob", p.ID); err != nil || len(cs) != 0 {
t.Fatalf("denial wrote state: %v %v", cs, err)
}
}
func TestProjectLifecycleRevocationGuardsExecutionResumption(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := st.CreateAPIKey("alice", "test")
p, _ := st.CreateProject("alice", "Project")
conv, _ := st.CreateConversation("alice", p.ID, "Private", "")
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) { return false, nil }}, nil)
card := core.HITLCard{ID: "hitl-revoked", ConversationID: conv.ID, RunID: "run-revoked", Kind: "confirm"}
if err := st.SaveHITLCard("alice", &card); err != nil {
t.Fatal(err)
}
for _, q := range []struct{ path, body string }{{"/api/conversations/" + conv.ID + "/regenerate", `{}`}, {"/api/hitl/" + card.ID + "/resolve", `{"choiceId":"yes"}`}} {
w := lifecycleRequest(t, s, key, "POST", q.path, q.body)
if w.Code != 404 {
t.Fatalf("resumption %s=%d %s", q.path, w.Code, w.Body.String())
}
}
}
func TestProjectLifecycleRevocationGuardsSurfaceAction(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := st.CreateAPIKey("alice", "test")
p, _ := st.CreateProject("alice", "Project")
conv, _ := st.CreateConversation("alice", p.ID, "Private", "")
svc := surfacesvc.New(st, nil)
s.Surfaces = svc
if _, err := svc.SetActivation(context.Background(), "alice", "generative-ui", true); err != nil {
t.Fatal(err)
}
snap, err := svc.Submit(context.Background(), "alice", "generative-ui", surfacesvc.SubmitRequest{ConversationID: conv.ID, Spec: json.RawMessage(`{"title":"Page","nodes":[{"type":"actions","id":"a1","actions":[{"id":"generative-ui.submit","label":"Submit"}]}]}`)})
if err != nil {
t.Fatal(err)
}
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) { return false, nil }}, nil)
w := lifecycleRequest(t, s, key, "POST", "/api/surfaces/"+snap.Identity.SurfaceID+"/actions", `{"actionId":"generative-ui.submit","revision":1,"requestId":"request"}`)
if w.Code != 404 {
t.Fatalf("revoked surface action=%d %s", w.Code, w.Body.String())
}
}
func TestProjectLifecycleSettingsRetainServiceMetadata(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := st.CreateAPIKey("alice", "test")
p, _ := st.CreateProject("alice", "Project")
s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) {
current, err := st.GetProject("alice", p.ID)
if err != nil {
return nil, err
}
if current.Settings == nil {
current.Settings = map[string]any{}
}
current.Settings["rdm.projectId"] = "canonical"
current.Settings["rdm.state"] = "ready"
return []core.Project{current}, nil
}}, nil)
w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, `{"settings":{"rdm.projectId":"forged","example":true}}`)
var got core.Project
_ = json.Unmarshal(w.Body.Bytes(), &got)
if w.Code != 200 || got.Settings["rdm.projectId"] != "canonical" || got.Settings["example"] != true {
t.Fatalf("settings lost service metadata: %d %s", w.Code, w.Body.String())
}
}
func TestProjectLifecyclePublicMetadataMasksSecrets(t *testing.T) {
s, st := newAuthTestServer(t)
alice, _ := st.CreateAPIKey("alice", "test")
bob, _ := st.CreateAPIKey("bob", "test")
p, _ := st.CreateProject("alice", "Shared")
raw := map[string]any{"models.chat.apiKey": "fixture-key", "custom": map[string]any{"token": "fixture-token"}, "custom.api_key": "fixture-flat-token", "plugin.private.credential": "fixture-schema-token", "display": map[string]any{"caption": "Shared"}, "rdm.projectId": "canonical", "rdm.state": "ready"}
if err := st.UpdateProjectSettings("alice", p.ID, raw); err != nil {
t.Fatal(err)
}
s.set.SetExtraSchema([]core.SettingDesc{{Key: "plugin.private.credential", Secret: true}})
read := func() (core.Project, error) { return st.GetProject("alice", p.ID) }
s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) {
current, err := read()
return []core.Project{current}, err
}, Create: func(context.Context, extension.Host, string) (core.Project, error) { return read() }, Rename: func(context.Context, extension.Host, string, string) (core.Project, error) { return read() }}, nil)
for _, q := range []struct{ key, method, path, body string }{{bob, "GET", "/api/projects", ""}, {alice, "GET", "/api/projects", ""}, {alice, "POST", "/api/projects", `{"name":"New"}`}, {alice, "PATCH", "/api/projects/" + p.ID, `{"name":"Renamed"}`}, {alice, "PATCH", "/api/projects/" + p.ID, `{"settings":{"display":{"caption":"Updated"}}}`}} {
w := lifecycleRequest(t, s, q.key, q.method, q.path, q.body)
if w.Code != 200 {
t.Fatalf("public metadata %s=%d", q.method, w.Code)
}
for _, secret := range []string{"fixture-key", "fixture-token", "fixture-flat-token", "fixture-schema-token"} {
if bytes.Contains(w.Body.Bytes(), []byte(secret)) {
t.Fatalf("%s %s leaked a fixture credential", q.method, q.path)
}
}
if !bytes.Contains(w.Body.Bytes(), []byte(`"rdm.projectId":"canonical"`)) || !bytes.Contains(w.Body.Bytes(), []byte(`"rdm.state":"ready"`)) {
t.Fatal("service metadata lost")
}
}
owned, err := read()
if err != nil {
t.Fatal(err)
}
if owned.Settings["models.chat.apiKey"] != "fixture-key" || owned.Settings["custom"].(map[string]any)["token"] != "fixture-token" {
t.Fatal("public metadata masking changed stored credentials")
}
}
func TestGenericProjectPatchRejectsManagedSettingsAtomically(t *testing.T) {
for _, registered := range []bool{false, true} {
t.Run(fmt.Sprint(registered), func(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := st.CreateAPIKey("alice", "test")
p, _ := st.CreateProject("alice", "Original")
s.set.SetExtraSchema([]core.SettingDesc{{Key: "plugins.private.managed", Scope: core.SettingScopeSystem}})
if registered {
s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) {
current, err := st.GetProject("alice", p.ID)
return []core.Project{current}, err
}, Rename: func(_ context.Context, _ extension.Host, id, name string) (core.Project, error) {
if err := st.RenameProject("alice", id, name); err != nil {
return core.Project{}, err
}
return st.GetProject("alice", id)
}}, nil)
}
for _, body := range []string{`{"name":"Changed","settings":{"models":{"chat":{"apiKey":"fixture-key"}},"display":{"caption":"Forbidden partial write"}}}`, `{"settings":{"models.chat.apiKey":"fixture-key"}}`, `{"settings":{"models":"smash"}}`, `{"settings":{"plugins.private.managed":"override"}}`} {
w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, body)
if w.Code != 400 {
t.Fatalf("managed patch=%d", w.Code)
}
current, err := st.GetProject("alice", p.ID)
if err != nil {
t.Fatal(err)
}
if current.Name != "Original" || len(current.Settings) != 0 {
t.Fatal("rejected managed patch applied a partial mutation")
}
}
w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, `{"settings":{"custom.caption":"Allowed"}}`)
if w.Code != 200 {
t.Fatalf("allowed patch=%d", w.Code)
}
current, err := st.GetProject("alice", p.ID)
if err != nil || current.Settings["custom.caption"] != "Allowed" {
t.Fatal("allowed metadata shape changed")
}
})
}
}
func TestGenericProjectPatchAcceptsNestedMaskedWriteback(t *testing.T) {
for _, registered := range []bool{false, true} {
t.Run(fmt.Sprint(registered), func(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := st.CreateAPIKey("alice", "test")
p, _ := st.CreateProject("alice", "Project")
if err := st.UpdateProjectSettings("alice", p.ID, map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": "fixture-persisted-key"}}, "display": map[string]any{"caption": "Original"}}); err != nil {
t.Fatal(err)
}
if registered {
s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) {
current, err := st.GetProject("alice", p.ID)
return []core.Project{current}, err
}}, nil)
}
for _, tc := range []struct{ body, caption string }{
{`{"settings":{"models":{"chat":{"apiKey":"••••••"}},"display":{"caption":"Nested updated"}}}`, "Nested updated"},
{`{"settings":{"models.chat":{"apiKey":"••••••"},"display":{"caption":"Mixed updated"}}}`, "Mixed updated"},
{`{"settings":{"models":{"chat.apiKey":"••••••"},"display":{"caption":"Mixed leaf updated"}}}`, "Mixed leaf updated"},
} {
w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, tc.body)
if w.Code != 200 {
t.Fatalf("masked writeback rejected legal edit: %d", w.Code)
}
current, err := st.GetProject("alice", p.ID)
if err != nil {
t.Fatal(err)
}
if current.Settings["models"].(map[string]any)["chat"].(map[string]any)["apiKey"] != "fixture-persisted-key" || current.Settings["display"].(map[string]any)["caption"] != tc.caption {
t.Fatalf("masked writeback secret preserved=%v caption=%q expected=%q", current.Settings["models"].(map[string]any)["chat"].(map[string]any)["apiKey"] == "fixture-persisted-key", current.Settings["display"].(map[string]any)["caption"], tc.caption)
}
}
for _, body := range []string{`{"settings":{"models":{}}}`, `{"settings":{"models":{"chat":{}}}}`, `{"settings":{"models.chat":{}}}`, `{"settings":{"models":{"chat":{"apiKey":"••••••","model":"Overwrite"}}}}`} {
if w := lifecycleRequest(t, s, key, "PATCH", "/api/projects/"+p.ID, body); w.Code != 400 {
t.Fatalf("explicit empty/real managed write accepted: %d", w.Code)
}
}
})
}
}
func lifecycleUpload(t *testing.T, s *Server, key, projectID, name, content string) *httptest.ResponseRecorder {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
file, err := writer.CreateFormFile("file", name)
if err != nil {
t.Fatal(err)
}
if _, err := file.Write([]byte(content)); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest("POST", "/api/projects/"+projectID+"/uploads", &body)
req.Header.Set("Authorization", "Bearer "+key)
req.Header.Set("Content-Type", writer.FormDataContentType())
rec := httptest.NewRecorder()
s.Handler().ServeHTTP(rec, req)
return rec
}
func TestProjectLifecycleMemberUploadStaysPrivate(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")
ready := true
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(_ context.Context, actor string, id core.ID) (bool, error) {
return ready && id == p.ID && (actor == "alice" || actor == "bob"), nil
}}, nil)
s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
if q.ResourceType == "artifact" {
return q.Actor == q.Owner, nil
}
return q.ResourceType == "project" && q.ProjectID == p.ID && (q.Actor == "alice" || q.Actor == "bob"), nil
})
s.DisableUploadIndexing = true
w := lifecycleUpload(t, s, bob, p.ID, "member-private.txt", "QA_MEMBER_PRIVATE_ORIGINAL")
if w.Code != 200 {
t.Fatalf("member upload=%d", w.Code)
}
var info core.UploadInfo
if err := json.Unmarshal(w.Body.Bytes(), &info); err != nil {
t.Fatal(err)
}
artifact, err := st.GetArtifact("bob", info.ArtifactID)
if err != nil || artifact.ProjectID != p.ID || artifact.Owner != "bob" {
t.Fatalf("upload ownership/target incorrect: %+v %v", artifact, err)
}
if w := lifecycleRequest(t, s, bob, "GET", "/api/artifacts/"+info.ArtifactID+"/content", ""); w.Code != 200 || w.Body.String() != "QA_MEMBER_PRIVATE_ORIGINAL" {
t.Fatalf("owner original read=%d", w.Code)
}
for _, key := range []string{alice, mallory} {
for _, suffix := range []string{"", "/content"} {
if w := lifecycleRequest(t, s, key, "GET", "/api/artifacts/"+info.ArtifactID+suffix, ""); w.Code != 404 {
t.Fatalf("other actor read private original=%d", w.Code)
}
}
}
if w := lifecycleUpload(t, s, mallory, p.ID, "forbidden.txt", "DENIED"); w.Code != 404 {
t.Fatalf("nonmember upload=%d", w.Code)
}
ready = false
if w := lifecycleUpload(t, s, bob, p.ID, "revoked.txt", "DENIED"); w.Code != 404 {
t.Fatalf("revoked upload=%d", w.Code)
}
artifacts, err := st.ListProjectArtifactsAll(p.ID)
if err != nil || len(artifacts) != 1 {
t.Fatalf("denied upload wrote artifacts: count=%d error=%v", len(artifacts), err)
}
}
func TestProjectLifecycleUploadSuppressesPrivateProjectCards(t *testing.T) {
s, st := newAuthTestServer(t)
alice, _ := st.CreateAPIKey("alice", "test")
bob, _ := st.CreateAPIKey("bob", "test")
p, _ := st.CreateProject("alice", "Shared")
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(_ context.Context, actor string, id core.ID) (bool, error) {
return id == p.ID && (actor == "alice" || actor == "bob"), nil
}}, nil)
s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
return q.ResourceType == "project" && q.ProjectID == p.ID && (q.Actor == "alice" || q.Actor == "bob"), nil
})
s.Cards = cardsvc.New(st, s.mgr.EmitCardEvent)
s.Cards.SetProjectSharedEmitter(s.mgr.EmitProjectSharedCardEvent)
s.DisableUploadIndexing = true
if _, err := s.Cards.PublishProjectCard("alice", "plugin:rdm:structure", core.ActivityCard{ProjectID: p.ID, Title: "Shared structure", Kind: core.CardKindProgress}, ""); err != nil {
t.Fatal(err)
}
// The owner path already admitted upload before the member authorization fix.
if w := lifecycleUpload(t, s, alice, p.ID, "owner-private-filename.txt", "PRIVATE ORIGINAL"); w.Code != 200 {
t.Fatalf("owner upload=%d", w.Code)
}
if w := lifecycleUpload(t, s, bob, p.ID, "member-private-filename.txt", "MEMBER PRIVATE ORIGINAL"); w.Code != 200 {
t.Fatalf("member upload=%d", w.Code)
}
for _, owner := range []string{"alice", "bob"} {
cards, err := st.ListProjectCards(owner, p.ID)
if err != nil {
t.Fatal(err)
}
expected := 0
if owner == "alice" {
expected = 1
}
if len(cards) != expected {
t.Fatal("private upload added project card history")
}
for _, card := range cards {
if card.Title != "Shared structure" {
t.Fatal("private filename/parser card entered project history")
}
}
}
events, err := st.EventsAfter(core.ProjectChannelKey(p.ID), 0, 100)
if err != nil {
t.Fatal(err)
}
if len(events) != 1 {
t.Fatal("private upload added project events")
}
for _, event := range events {
if bytes.Contains(event.Payload, []byte("owner-private-filename.txt")) || bytes.Contains(event.Payload, []byte("member-private-filename.txt")) {
t.Fatal("private upload metadata entered persisted project stream")
}
}
request := httptest.NewRequest("GET", "/api/events?scope=project&projectId="+p.ID, nil)
request.Header.Set("Authorization", "Bearer "+bob)
ctx, cancel := context.WithTimeout(request.Context(), 20*time.Millisecond)
defer cancel()
request = request.WithContext(ctx)
response := httptest.NewRecorder()
s.Handler().ServeHTTP(response, request)
if response.Code != 200 || !bytes.Contains(response.Body.Bytes(), []byte("Shared structure")) {
t.Fatalf("member structural stream did not replay: %d", response.Code)
}
if bytes.Contains(response.Body.Bytes(), []byte("owner-private-filename.txt")) || bytes.Contains(response.Body.Bytes(), []byte("member-private-filename.txt")) {
t.Fatal("another member's SSE exposed private upload metadata")
}
}
func TestUnregisteredUploadKeepsLegacyCardsAndValidation(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := st.CreateAPIKey("alice", "test")
p, _ := st.CreateProject("alice", "Private")
s.Cards = cardsvc.New(st, s.mgr.EmitCardEvent)
s.DisableUploadIndexing = true
if w := lifecycleUpload(t, s, key, p.ID, "legacy-upload.txt", "Original"); w.Code != 200 {
t.Fatalf("unregistered owner upload=%d", w.Code)
}
cards, err := st.ListProjectCards("alice", p.ID)
if err != nil || len(cards) != 1 || cards[0].Title != "legacy-upload.txt" {
t.Fatalf("unregistered ingestion card changed: count=%d error=%v", len(cards), err)
}
if w := lifecycleRequest(t, s, key, "POST", "/api/projects/missing/uploads", "not multipart"); w.Code != 400 {
t.Fatalf("legacy missing-file validation changed: %d", w.Code)
}
}
func TestProjectLifecycleUploadRechecksBeforePersistence(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := st.CreateAPIKey("bob", "test")
p, _ := st.CreateProject("alice", "Shared")
ready := true
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) {
allowed := ready
ready = false
return allowed, nil
}}, nil)
if w := lifecycleUpload(t, s, key, p.ID, "revoked-during-upload.txt", "PRIVATE"); w.Code != 404 {
t.Fatalf("upload persisted after authorization changed: %d", w.Code)
}
artifacts, err := st.ListProjectArtifactsAll(p.ID)
if err != nil || len(artifacts) != 0 {
t.Fatal("revoked upload created an artifact")
}
}
@@ -1,100 +0,0 @@
package server
import (
"agent-system-backend/pkg/extension"
core "agent-system-core"
"context"
"encoding/json"
"testing"
)
func TestKnowledgeV2UsesDocumentIdentity(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := st.CreateAPIKey("bob", "test")
docs := []core.KnowledgeDoc{{ID: "published-slice", Owner: "alice", ProjectID: "domain-p", Chunk: "sharedneedle published"}, {ID: "private-draft", Owner: "alice", ProjectID: "domain-p", Chunk: "sharedneedle draft"}, {ID: "self-denied", Owner: "bob", ProjectID: "domain-p", Chunk: "sharedneedle own restricted"}, {ID: "self-allowed", Owner: "bob", ProjectID: "domain-p", Chunk: "sharedneedle own"}}
if err := st.AddKnowledge(docs...); err != nil {
t.Fatal(err)
}
s.SetProjectAuthorizer(func(string, core.ID) (bool, error) { return true, nil })
seen := map[string]extension.ResourceAuthzQuery{}
s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
seen[q.ResourceID] = q
return q.ResourceType == "project" || q.ResourceID == "published-slice" || q.ResourceID == "self-allowed", nil
})
w := lifecycleRequest(t, s, key, "GET", "/api/knowledge/search?q=sharedneedle", "")
var hits []core.RetrievalHit
_ = json.Unmarshal(w.Body.Bytes(), &hits)
if w.Code != 200 || len(hits) != 2 {
t.Fatalf("V2 filtered hits=%d %s", w.Code, w.Body.String())
}
for _, hit := range hits {
if hit.Doc.ID != "published-slice" && hit.Doc.ID != "self-allowed" {
t.Fatalf("private leak: %+v", hit.Doc)
}
}
q := seen["published-slice"]
if q.ResourceType != "knowledge" || q.Actor != "bob" || q.Action != "search" || q.Owner != "alice" || q.ProjectID != "domain-p" {
t.Fatalf("trusted doc query: %+v", q)
}
}
func TestArtifactListV2FiltersEachResource(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := st.CreateAPIKey("bob", "test")
p, _ := st.CreateProject("alice", "Project")
private := core.Artifact{Owner: "alice", ProjectID: p.ID, Name: "Private", MIME: "text/plain"}
public := core.Artifact{Owner: "alice", ProjectID: p.ID, Name: "Shared", MIME: "text/plain"}
if err := st.CreateArtifact(&private, []byte("private")); err != nil {
t.Fatal(err)
}
if err := st.CreateArtifact(&public, []byte("shared")); err != nil {
t.Fatal(err)
}
s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
return q.ResourceType == "project" || (q.ResourceType == "artifact" && q.Action == "list" && q.ResourceID == public.ID && q.Owner == "alice"), nil
})
w := lifecycleRequest(t, s, key, "GET", "/api/projects/"+p.ID+"/artifacts", "")
var as []core.Artifact
_ = json.Unmarshal(w.Body.Bytes(), &as)
if w.Code != 200 || len(as) != 1 || as[0].ID != public.ID {
t.Fatalf("artifact list=%d %s", w.Code, w.Body.String())
}
}
func TestKnowledgeV2ResolvesTrustedProjectScope(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := st.CreateAPIKey("bob", "test")
if err := st.AddKnowledge(core.KnowledgeDoc{ID: "slice-current", Owner: "alice", ProjectID: "domain-current", Chunk: "scopeneedle current"}, core.KnowledgeDoc{ID: "slice-other", Owner: "alice", ProjectID: "domain-other", Chunk: "scopeneedle other"}); err != nil {
t.Fatal(err)
}
s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) { return q.ResourceType == "knowledge", nil })
s.SetProjectLifecycle(extension.ProjectLifecycle{List: func(context.Context, extension.Host) ([]core.Project, error) {
return []core.Project{{ID: "host-current", Settings: map[string]any{"rdm.projectId": "domain-current", "rdm.state": "ready"}}, {ID: "host-other", Settings: map[string]any{"rdm.projectId": "domain-other", "rdm.state": "ready"}}}, nil
}}, nil)
w := lifecycleRequest(t, s, key, "GET", "/api/knowledge/search?q=scopeneedle&project=host-current", "")
var hits []core.RetrievalHit
_ = json.Unmarshal(w.Body.Bytes(), &hits)
if w.Code != 200 || len(hits) != 1 || hits[0].Doc.ID != "slice-current" {
t.Fatalf("current scope=%d %s", w.Code, w.Body.String())
}
if w := lifecycleRequest(t, s, key, "GET", "/api/knowledge/search?q=scopeneedle&project=unknown", ""); w.Code != 404 {
t.Fatalf("unknown scope=%d %s", w.Code, w.Body.String())
}
}
func TestKnowledgeV2UsesStoredPublicationSource(t *testing.T) {
s, st := newAuthTestServer(t)
key, _ := st.CreateAPIKey("bob", "test")
// A known published identity replaced by a private row must lose sharing.
if err := st.AddKnowledge(core.KnowledgeDoc{ID: "known-published-id", Owner: "alice", ProjectID: "domain", Source: "manual", Chunk: "sourceneedle private"}, core.KnowledgeDoc{ID: "real-published-id", Owner: "alice", ProjectID: "domain", Source: "plugin:rdm:published", Chunk: "sourceneedle public"}); err != nil {
t.Fatal(err)
}
s.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
return q.ResourceType == "knowledge" && q.Source == "plugin:rdm:published", nil
})
w := lifecycleRequest(t, s, key, "GET", "/api/knowledge/search?q=sourceneedle", "")
var hits []core.RetrievalHit
_ = json.Unmarshal(w.Body.Bytes(), &hits)
if w.Code != 200 || len(hits) != 1 || hits[0].Doc.ID != "real-published-id" {
t.Fatalf("actual stored source=%d %s", w.Code, w.Body.String())
}
}
-213
View File
@@ -1,213 +0,0 @@
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)
}
@@ -1,129 +0,0 @@
package server
// EXT-8b 读面消费红测(方案 r1 U9 第二切片):五处共享读路径在 V2 注册后按
// 资源级查询咨询(Actor/Action/ResourceType/ResourceID/Owner/ProjectID 由
// 受信上下文构造);V2 拒绝压过 V1 放行(资源级权威);V2 未注册时五处全部
// 回落 V1(兼容现行,RDM 现网行为不变)。红:当前 server 尚无 V2 槽位与
// AuthorizeResource 消费——注册接口不存在,编译红先行。
import (
"net/http"
"net/http/httptest"
"testing"
"agent-system-backend/internal/store"
"agent-system-backend/pkg/extension"
core "agent-system-core"
)
func newV2CaptureServer(t *testing.T) (*Server, *store.Store, string, string, string) {
t.Helper()
srv, st := newAuthTestServer(t)
alice, err := st.CreateAPIKey("alice", "e2e")
if err != nil {
t.Fatal(err)
}
bob, err := st.CreateAPIKey("bob", "e2e")
if err != nil {
t.Fatal(err)
}
pj, err := st.CreateProject("alice", "资源级项目")
if err != nil {
t.Fatal(err)
}
return srv, st, alice, bob, pj.ID
}
func TestSharedReadPathsConsultV2WithResourceContext(t *testing.T) {
srv, st, alice, bob, pid := newV2CaptureServer(t)
_ = alice
// 项目内造一个工件(供 download 路径取元数据)。
art := &core.Artifact{ProjectID: core.ID(pid), Owner: "alice", Name: "res.txt", Kind: "file", MIME: "text/plain"}
if err := st.CreateArtifact(art, []byte("body")); err != nil {
t.Fatal(err)
}
var got []extension.ResourceAuthzQuery
srv.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
got = append(got, q)
return true, nil
})
h := srv.Handler()
call := func(method, path string) int {
req := httptest.NewRequest(method, path, nil)
req.Header.Set("Authorization", "Bearer "+bob)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
return rec.Code
}
// ① 工件下载(owner 自有未命中 → 资源级咨询)。
code := call(http.MethodGet, "/api/artifacts/"+string(art.ID)+"/content?download=1")
if code != http.StatusOK {
t.Fatalf("download: %d", code)
}
// ② 项目工件清单。
call(http.MethodGet, "/api/projects/"+pid+"/artifacts")
// ③ 共享项目建会话。
call(http.MethodPost, "/api/projects/"+pid+"/conversations")
byAction := map[string]extension.ResourceAuthzQuery{}
for _, q := range got {
byAction[q.Action] = q
}
dl := byAction["download"]
if dl.Actor != "bob" || dl.ResourceType != "artifact" || dl.ResourceID != art.ID || dl.ProjectID != pid || dl.Owner != "alice" {
t.Fatalf("download 查询六要素不符: %+v", dl)
}
ls := byAction["list"]
if ls.Actor != "bob" || ls.ResourceType != "artifact" || ls.ResourceID != art.ID || ls.ProjectID != pid || ls.Owner != "alice" {
t.Fatalf("list 查询不符: %+v", ls)
}
rd := byAction["read"]
if rd.Actor != "bob" || rd.ResourceType != "project" || rd.ResourceID != pid {
t.Fatalf("read(conversation) 查询不符: %+v", rd)
}
}
func TestV2DenyOverridesV1Allow(t *testing.T) {
srv, st, alice, bob, pid := newV2CaptureServer(t)
_ = alice
art := &core.Artifact{ProjectID: core.ID(pid), Owner: "alice", Name: "d.txt", Kind: "file"}
if err := st.CreateArtifact(art, []byte("body")); err != nil {
t.Fatal(err)
}
// V1 全放行、V2 全拒绝——资源级权威必须压过 V1。
srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) {
return true, nil
})
srv.SetResourceAuthorizerV2(func(q extension.ResourceAuthzQuery) (bool, error) {
return false, nil
})
req := httptest.NewRequest(http.MethodGet, "/api/artifacts/"+string(art.ID)+"/content?download=1", nil)
req.Header.Set("Authorization", "Bearer "+bob)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("V2 拒绝须压过 V1 放行并保持 404(不泄露存在性), got %d", rec.Code)
}
}
func TestV2AbsentFallsBackToV1(t *testing.T) {
srv, st, alice, bob, pid := newV2CaptureServer(t)
_ = alice
art := &core.Artifact{ProjectID: core.ID(pid), Owner: "alice", Name: "f.txt", Kind: "file"}
if err := st.CreateArtifact(art, []byte("body")); err != nil {
t.Fatal(err)
}
srv.SetProjectAuthorizer(func(owner string, projectID core.ID) (bool, error) {
return owner == "bob" && string(projectID) == pid, nil
})
req := httptest.NewRequest(http.MethodGet, "/api/artifacts/"+string(art.ID)+"/content?download=1", nil)
req.Header.Set("Authorization", "Bearer "+bob)
rec := httptest.NewRecorder()
srv.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("V2 未注册应回落 V1(现行行为), got %d", rec.Code)
}
}
+88 -603
View File
@@ -26,7 +26,6 @@ import (
"agent-system-backend/internal/intentgate"
"agent-system-backend/internal/plugins"
"agent-system-backend/internal/profiles"
"agent-system-backend/internal/projectscope"
"agent-system-backend/internal/prompts"
"agent-system-backend/internal/ratelimit"
"agent-system-backend/internal/route"
@@ -42,27 +41,24 @@ import (
)
type Server struct {
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
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 时上传端点不再自动写检索索引(原件与
@@ -71,9 +67,6 @@ type Server struct {
// ProjectAuthz EXT-8 资源授权适配器(组合根注入;nil=未注册,读取入口
// owner 自有判定未命中即 NotFound——与未扩展基座行为一致)。
ProjectAuthz func(owner string, projectID core.ID) (bool, error)
// ProjectAuthzV2 EXT-8b 资源级授权适配器(U9;组合根注入;nil=未注册,
// 读面回落 ProjectAuthz 兼容)。查询六要素由本包受信构造。
ProjectAuthzV2 func(q extension.ResourceAuthzQuery) (bool, error)
// ProjectAuthzActions 动作级授权适配器(协作存储 U3,KTD13):注册后对
// 全部动作权威;未注册时 read 回落 ProjectAuthz(兼容),其余动作一律
// 默认拒绝——旧只读适配器不得自动获得写入能力。公开契约的版本化扩展
@@ -101,11 +94,6 @@ func (s *Server) SetProjectAuthorizer(fn func(owner string, projectID core.ID) (
s.ProjectAuthz = fn
}
// SetResourceAuthorizerV2 组合根在装载扩展后注入 EXT-8b 资源级适配器。
func (s *Server) SetResourceAuthorizerV2(fn func(q extension.ResourceAuthzQuery) (bool, error)) {
s.ProjectAuthzV2 = fn
}
// SetProjectActionAuthorizer 组合根注入 KTD13 动作级适配器(版本化公开契约
// 扩展落地前,宿主侧先以本槽位提供矩阵语义)。
func (s *Server) SetProjectActionAuthorizer(fn func(owner string, projectID core.ID, action ProjectAction) (bool, error)) {
@@ -122,32 +110,13 @@ func (s *Server) authorizeProjectAction(owner string, projectID core.ID, action
return err == nil && ok
}
if action == ProjectRead {
return s.authorizeSharedResource(owner, "read", "project", projectID, "", projectID)
return s.authorizeSharedProject(owner, projectID)
}
return false
}
// authorizeSharedResource EXT-8b 资源级读判定(U9):V2 注册时按六要素咨询
// (Actor=认证会话、Action/ResourceType/ResourceID 由调用点受信构造、
// Owner/ProjectID 从受信存储元数据解析;出错/字段缺失一律 false,fail-closed
// 且不泄露存在性);V2 未注册回落 authorizeSharedProject(现行兼容)。
func (s *Server) authorizeSharedResource(actor, action, resourceType string, resourceID, owner, projectID core.ID) bool {
if s.ProjectAuthzV2 == nil {
return s.authorizeSharedProject(actor, projectID)
}
if actor == "" || action == "" || resourceType == "" || resourceID == "" {
return false
}
ok, err := s.ProjectAuthzV2(extension.ResourceAuthzQuery{
Actor: actor, Action: action, ResourceType: resourceType,
ResourceID: resourceID, Owner: owner, ProjectID: projectID,
})
return err == nil && ok
}
// authorizeSharedProject 读取路径的共享判定(V1):适配器未注册或拒绝/出错一律
// false(fail-closed;错误不区分,避免泄露存在性)。EXT-8b 落地后仅作 V2 缺席
// 时的兼容回落——新读面一律走 authorizeSharedResource。
// authorizeSharedProject 读取路径的共享判定:适配器未注册或拒绝/出错一律 false
// (fail-closed;错误不区分,避免泄露存在性)。
func (s *Server) authorizeSharedProject(owner string, projectID core.ID) bool {
if s.ProjectAuthz == nil {
return false
@@ -210,7 +179,7 @@ func (s *Server) WireBuiltinCommands() {
if err != nil {
return nil, err
}
c, err := s.createPrivateConversation(ctx, conv.Owner, conv.ProjectID, "新会话", conv.ProfileID)
c, err := s.st.CreateConversation(conv.Owner, conv.ProjectID, "新会话", conv.ProfileID)
if err != nil {
return nil, err
}
@@ -282,15 +251,11 @@ func (s *Server) Handler() http.Handler {
// ---- 认证与会话(管理员发放制,R2)----
mux.HandleFunc("POST /api/auth/login", s.login)
mux.HandleFunc("POST /api/auth/login-key/redeem", s.redeemLoginKey)
mux.HandleFunc("POST /api/auth/logout", s.logout)
mux.HandleFunc("POST /api/auth/password", s.changePassword)
mux.HandleFunc("GET /api/admin/users", s.listAdminUsers)
mux.HandleFunc("POST /api/admin/users", s.createAdminUser)
mux.HandleFunc("POST /api/admin/users/{owner}/password", s.resetAdminUserPassword)
mux.HandleFunc("POST /api/admin/users/{owner}/login-keys", s.issueAdminLoginKey)
mux.HandleFunc("GET /api/admin/users/{owner}/login-keys", s.listAdminLoginKeys)
mux.HandleFunc("DELETE /api/admin/users/{owner}/login-keys/{id}", s.revokeAdminLoginKey)
// ---- 项目 / 会话 ----
mux.HandleFunc("GET /api/projects", s.listProjects)
@@ -356,13 +321,6 @@ 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)
mux.HandleFunc("GET /api/url-preview", s.urlPreview)
mux.HandleFunc("POST /api/tools", s.createUserTool)
@@ -429,15 +387,9 @@ func (s *Server) Handler() http.Handler {
const sseRetryMillis = 1000
// sseAuthRecheck:会话凭证的 SSE 长连接复验周期。密码写入(恢复/改密/重置)
// 会删除全部会话并 bump auth_version;已建立的流最多在此窗口内继续收事件,
// 心跳点复验失效即断流(EventSource 重连时由中间件拒绝)。测试可缩短。
var sseAuthRecheck = 15 * time.Second
func (s *Server) events(w http.ResponseWriter, r *http.Request) {
conversationID := r.URL.Query().Get("conversationId")
isProject := false
streamProject := ""
// 项目流(D50):scope=project&projectId=<id> 订阅项目频道——伪会话键
// "project:<id>" 复用会话流的持久化/seq/重放机制;租户边界按项目校验。
if r.URL.Query().Get("scope") == "project" {
@@ -447,15 +399,14 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
return
}
if _, err := s.st.GetProject(s.owner(r), pid); err != nil {
// EXT-8b:资源级咨询(stream×project);V2 缺席回落 V1。
if !s.authorizeSharedResource(s.owner(r), "stream", "project", core.ID(pid), "", core.ID(pid)) {
// EXT-8 切片 2:owner 自有未命中时咨询适配器(放行才可订阅项目流)。
if !s.authorizeSharedProject(s.owner(r), core.ID(pid)) {
writeStoreErr(w, err)
return
}
}
conversationID = string(core.ProjectChannelKey(core.ID(pid)))
isProject = true
streamProject = pid
} else if conversationID == "" {
http.Error(w, "conversationId required", http.StatusBadRequest)
return
@@ -487,96 +438,45 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
since, _ = strconv.ParseInt(q, 10, 64)
}
// 长连接凭证复验(login 恢复合同 §6.7):会话凭证查会话行(密码写入即删除);
// API key 凭证查撤销位(RevokeAllAPIKeys/失陷撤销后断流)。两种凭证都在
// 复验周期内核验属主未变。
sessionTok, keyCred := "", ""
switch AuthVia(r) {
case AuthViaSession:
sessionTok = sessionToken(r)
case AuthViaKey:
keyCred = bearerKey(r)
if keyCred == "" {
keyCred = r.URL.Query().Get("key")
}
}
wantOwner := s.owner(r)
stillAuthed := func() bool {
if sessionTok != "" {
o, _, ok := s.st.SessionInfo(sessionTok)
return ok && o == wantOwner
}
if keyCred != "" {
o, ok := s.st.AuthOwner(keyCred)
return ok && o == wantOwner
}
return true
}
// 先订阅再重放,避免重放与订阅间隙丢事件;按 seq 去重。
live, stop := s.Subscribe(conversationID)
defer stop()
lastVisible := since
send := func(e core.StreamEvent) bool {
if e.Seq <= lastVisible {
return true
}
if isProject && !s.projectEventVisible(wantOwner, e) {
return r.Context().Err() == nil
}
buf, _ := json.Marshal(e)
fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", e.Seq, e.Type, buf)
flusher.Flush()
lastVisible = e.Seq
return r.Context().Err() == nil
}
// Seen progresses even when a private frame is filtered. Visible only tracks
// frames delivered to this actor, preserving authoritative seq and resumption.
lastSeen := since
lastSent := since
for {
replay, err := s.st.EventsAfter(conversationID, lastSeen, 1000)
replay, err := s.st.EventsAfter(conversationID, lastSent, 1000)
if err != nil || len(replay) == 0 {
break
}
for _, e := range replay {
if e.Seq <= lastSeen {
if e.Seq <= lastSent {
continue
}
if !send(e) {
stop()
return
}
lastSeen = e.Seq
lastSent = e.Seq
}
if len(replay) < 1000 {
break
}
}
// 心跳 + 在线转发。通道关闭表示缓冲溢出,结束响应让 EventSource 带 Last-Event-ID 重连。
// 会话凭证在心跳点复验:失效(改密/恢复/重置已删会话)即终止响应。
heartbeat := time.NewTicker(15 * time.Second)
defer heartbeat.Stop()
recheck := time.NewTicker(sseAuthRecheck)
defer recheck.Stop()
for {
select {
case <-r.Context().Done():
stop()
return
case <-recheck.C:
if isProject && s.projectLifecycle != nil && !s.projectStreamMembership(wantOwner, streamProject) {
fmt.Fprint(w, ": project-access-revoked\n\n")
flusher.Flush()
return
}
if !stillAuthed() {
fmt.Fprint(w, ": session-expired\n\n")
flusher.Flush()
stop()
return
}
case <-heartbeat.C:
fmt.Fprint(w, ": ping\n\n")
flusher.Flush()
@@ -585,19 +485,14 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
stop()
return
}
if e.ConversationID != conversationID || e.Seq <= lastSeen {
if e.ConversationID != conversationID || e.Seq <= lastSent {
continue
}
if isProject && s.projectLifecycle != nil && !s.projectStreamMembership(wantOwner, streamProject) {
fmt.Fprint(w, ": project-access-revoked\n\n")
flusher.Flush()
return
}
if !send(e) {
stop()
return
}
lastSeen = e.Seq
lastSent = e.Seq
}
}
}
@@ -643,23 +538,6 @@ type projectView struct {
func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) {
owner := s.owner(r)
if s.projectLifecycle != nil {
if s.projectLifecycle.List == nil {
writeProjectErr(w, store.ErrNotFound)
return
}
ps, err := s.projectLifecycle.List(r.Context(), s.lifecycleHost(owner))
if err != nil {
writeProjectErr(w, err)
return
}
out := make([]projectView, 0, len(ps))
for _, p := range ps {
out = append(out, projectView{Project: s.publicProjectMetadata(p), Shared: p.Owner != owner})
}
writeJSON(w, http.StatusOK, out)
return
}
ps, err := s.st.ListProjects(owner)
if err != nil {
writeStoreErr(w, err)
@@ -696,15 +574,6 @@ func (s *Server) createProject(w http.ResponseWriter, r *http.Request) {
http.Error(w, "name required", http.StatusBadRequest)
return
}
if s.projectLifecycle != nil {
if s.projectLifecycle.Create == nil {
writeProjectErr(w, store.ErrNotFound)
return
}
p, err := s.projectLifecycle.Create(r.Context(), s.lifecycleHost(s.owner(r)), strings.TrimSpace(req.Name))
writeProjectJSON(w, s.publicProjectMetadata(p), err)
return
}
p, err := s.st.CreateProject(s.owner(r), strings.TrimSpace(req.Name))
writeJSONOr(w, p, err)
}
@@ -719,58 +588,6 @@ func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if req.Settings != nil {
values, err := s.set.PrepareProjectMetadataWrite(req.Settings)
if err != nil {
s.writeSettingsMerge(w, func() error { return err })
return
}
req.Settings = values
}
if s.projectLifecycle != nil {
var p core.Project
var err error
if req.Name != nil {
if strings.TrimSpace(*req.Name) == "" {
http.Error(w, "name required", http.StatusBadRequest)
return
}
if s.projectLifecycle.Rename == nil {
writeProjectErr(w, store.ErrNotFound)
return
}
p, err = s.projectLifecycle.Rename(r.Context(), s.lifecycleHost(s.owner(r)), id, strings.TrimSpace(*req.Name))
if err != nil {
writeProjectErr(w, err)
return
}
}
if req.Settings != nil {
if err := s.st.UpdateProjectSettings(s.owner(r), id, req.Settings); err != nil {
writeStoreErr(w, err)
return
}
}
if req.Name == nil || req.Settings != nil {
if s.projectLifecycle.List == nil {
writeProjectErr(w, store.ErrNotFound)
return
}
var projects []core.Project
projects, err = s.projectLifecycle.List(r.Context(), s.lifecycleHost(s.owner(r)))
if err == nil {
err = store.ErrNotFound
for _, candidate := range projects {
if candidate.ID == id {
p, err = candidate, nil
break
}
}
}
}
writeProjectJSON(w, s.publicProjectMetadata(p), err)
return
}
if req.Name != nil {
if err := s.st.RenameProject(s.owner(r), id, *req.Name); err != nil {
writeStoreErr(w, err)
@@ -788,29 +605,12 @@ func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) {
if s.projectLifecycle != nil {
if s.projectLifecycle.Archive == nil {
writeProjectErr(w, store.ErrNotFound)
return
}
if err := s.projectLifecycle.Archive(r.Context(), s.lifecycleHost(s.owner(r)), r.PathValue("id")); err != nil {
writeProjectErr(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
return
}
writeOKOr(w, s.st.DeleteProject(s.owner(r), r.PathValue("id")))
}
func (s *Server) listConversations(w http.ResponseWriter, r *http.Request) {
owner := s.owner(r)
if s.projectLifecycle != nil {
if err := s.projectWork(r.Context(), owner, r.PathValue("id")); err != nil {
writeProjectErr(w, err)
return
}
} else if _, err := s.st.GetProject(owner, r.PathValue("id")); err != nil {
if _, err := s.st.GetProject(owner, r.PathValue("id")); err != nil {
writeStoreErr(w, err)
return
}
@@ -834,13 +634,9 @@ func (s *Server) me(w http.ResponseWriter, r *http.Request) {
name = u.Name
}
out := map[string]any{"owner": owner, "name": name, "auth": s.Auth != nil && s.Auth.Enabled, "authVia": AuthVia(r)}
if sc := SessionScope(r); sc != "" {
out["sessionScope"] = sc
}
if st, err := s.st.GetUserAuthState(owner); err == nil {
out["isAdmin"] = st.IsAdmin
out["mustChangePassword"] = st.MustChangePassword
out["passwordSet"] = st.HasPassword
}
writeJSON(w, http.StatusOK, out)
}
@@ -854,8 +650,13 @@ func (s *Server) createConversation(w http.ResponseWriter, r *http.Request) {
if req.Title == "" {
req.Title = "新会话"
}
c, err := s.createPrivateConversation(r.Context(), s.owner(r), r.PathValue("id"), req.Title, req.ProfileID)
writeProjectJSON(w, c, err)
c, err := s.st.CreateConversation(s.owner(r), r.PathValue("id"), req.Title, req.ProfileID)
// EXT-15:自有未命中且适配器放行(成员视角的共享项目)→ 会话仍归属创建者,
// 只是落在他人项目下;撤销授权后此路径立即 404(不泄露存在性)。
if errors.Is(err, store.ErrNotFound) && s.authorizeSharedProject(s.owner(r), core.ID(r.PathValue("id"))) {
c, err = s.st.CreateConversationInProject(s.owner(r), r.PathValue("id"), req.Title, req.ProfileID)
}
writeJSONOr(w, c, err)
}
// conversationDetail 终态对账权威:消息树 + runs + 活动 HITL 卡片。
@@ -934,10 +735,6 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
writeStoreErr(w, err)
return
}
if err := s.projectWork(r.Context(), owner, conv.ProjectID); err != nil {
writeProjectErr(w, err)
return
}
var req struct {
Text string `json:"text"`
AttachmentIDs []string `json:"attachmentIds"`
@@ -1093,7 +890,7 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
}
if startErr != nil {
s.mgr.ReleaseConversation(convID)
writeProjectErr(w, startErr)
writeStoreErr(w, startErr)
return
}
_ = s.st.SetMessageRunID(msg.ID, run.ID)
@@ -1198,21 +995,10 @@ func (s *Server) execCommand(w http.ResponseWriter, r *http.Request) {
}
var hostProject string
if body.ConversationID != "" {
conv, err := s.st.GetConversation(s.owner(r), body.ConversationID)
if err != nil {
if s.projectLifecycle != nil {
writeStoreErr(w, err)
return
}
} else {
if conv, err := s.st.GetConversation(s.owner(r), body.ConversationID); err == nil {
hostProject = string(conv.ProjectID)
if err := s.projectWork(r.Context(), s.owner(r), hostProject); err != nil {
writeProjectErr(w, err)
return
}
}
}
resp := s.Bus.Dispatch(r.Context(), cmdbus.Request{
ID: r.PathValue("id"), Params: body.Params, Entrance: body.Entrance, Actor: s.owner(r),
Owner: s.owner(r), ConversationID: body.ConversationID, AnchorMessageID: body.AnchorMessageID,
@@ -1291,22 +1077,6 @@ func (s *Server) resolveHITL(w http.ResponseWriter, r *http.Request) {
return
}
resp.CardID = r.PathValue("id")
if s.projectLifecycle != nil {
card, err := s.st.GetHITLCard(s.owner(r), resp.CardID)
if err != nil {
writeStoreErr(w, err)
return
}
conv, err := s.st.GetConversation(s.owner(r), card.ConversationID)
if err != nil {
writeStoreErr(w, err)
return
}
if err := s.projectWork(r.Context(), s.owner(r), conv.ProjectID); err != nil {
writeProjectErr(w, err)
return
}
}
pivotText, pivoted, err := s.mgr.ResolveHITL(s.owner(r), string(resp.CardID), resp)
if err != nil && s.st.Dialect() == "postgres" {
// 跨实例 HITL(U5/KTD5):本地无执行句柄——写持久命令,执行实例
@@ -1373,28 +1143,6 @@ func (s *Server) cardToProject(w http.ResponseWriter, r *http.Request) {
// cardAction 处理卡上按钮:resolved + 回执消息进对话时间线(确认即留痕)。
func (s *Server) cardAction(w http.ResponseWriter, r *http.Request) {
owner := s.owner(r)
if s.projectLifecycle != nil {
card, err := s.st.GetCard(owner, r.PathValue("id"))
if err != nil {
writeStoreErr(w, err)
return
}
pid := card.ProjectID
if card.ConversationID != "" {
conv, err := s.st.GetConversation(owner, card.ConversationID)
if err != nil {
writeStoreErr(w, err)
return
}
pid = conv.ProjectID
}
if pid != "" {
if err := s.projectWork(r.Context(), owner, pid); err != nil {
writeProjectErr(w, err)
return
}
}
}
var body struct {
FreeText string `json:"freeText,omitempty"`
}
@@ -1441,17 +1189,6 @@ func (s *Server) cardDismiss(w http.ResponseWriter, r *http.Request) {
func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
projectID := r.PathValue("id")
owner := s.owner(r)
if s.projectLifecycle != nil {
if err := s.projectWork(r.Context(), owner, projectID); err != nil {
writeProjectErr(w, err)
return
}
if _, err := s.st.ProjectMetadata(projectID); err != nil {
writeStoreErr(w, err)
return
}
}
r.ParseMultipartForm(64 << 20)
file, header, err := r.FormFile("file")
if err != nil {
@@ -1464,18 +1201,10 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
if s.projectLifecycle == nil {
if _, err := s.st.GetProject(owner, projectID); err != nil {
writeStoreErr(w, err)
return
}
}
if s.projectLifecycle != nil {
// Request-body reading can outlast the membership/ready decision.
if err := s.projectWork(r.Context(), owner, projectID); err != nil {
writeProjectErr(w, err)
return
}
owner := s.owner(r)
if _, err := s.st.GetProject(owner, projectID); err != nil {
writeStoreErr(w, err)
return
}
mime := header.Header.Get("Content-Type")
art := core.Artifact{ProjectID: projectID, Owner: owner, Name: header.Filename, Kind: kindOf(header.Filename), MIME: mime}
@@ -1490,14 +1219,11 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
Status: core.CardActive, Tone: "info",
Body: []core.UINode{{Type: core.UIProgress, Progress: &core.UIProgressSpec{Label: "解析中", Value: 0.3}}},
}
// This endpoint has no private conversation stream. Registered uploads keep
// original filename/parser metadata out of shared project cards and events.
showIngestCard := s.Cards != nil && s.projectLifecycle == nil
if showIngestCard {
if s.Cards != nil {
ingestCard, _ = s.Cards.Create(owner, ingestCard, "")
}
doc := ingest.Parse(art.Name, mime, content)
if showIngestCard {
if s.Cards != nil {
ingestCard.Status = core.CardDone
ingestCard.Tone = "ok"
body := []core.UINode{
@@ -1541,32 +1267,20 @@ func kindOf(filename string) string {
}
func (s *Server) listArtifacts(w http.ResponseWriter, r *http.Request) {
owner, pid := s.owner(r), r.PathValue("id")
var as []core.Artifact
var err error
if _, ownedErr := s.st.GetProject(owner, pid); ownedErr != nil {
if !s.authorizeSharedResource(owner, "list", "project", core.ID(pid), "", core.ID(pid)) {
writeStoreErr(w, ownedErr)
owner := s.owner(r)
pid := r.PathValue("id")
if _, err := s.st.GetProject(owner, pid); err != nil {
// EXT-8:owner 自有未命中时问适配器;放行则返回项目全部工件(只读共享)。
if !s.authorizeSharedProject(owner, core.ID(pid)) {
writeStoreErr(w, err)
return
}
as, err = s.st.ListProjectArtifactsAll(pid)
} else {
as, err = s.st.ListArtifacts(owner, pid)
}
if err != nil {
writeStoreErr(w, err)
as, err := s.st.ListProjectArtifactsAll(pid)
writeJSONOr(w, as, err)
return
}
if s.ProjectAuthzV2 != nil {
filtered := make([]core.Artifact, 0, len(as))
for _, a := range as {
if s.authorizeSharedResource(owner, "list", "artifact", a.ID, a.Owner, a.ProjectID) {
filtered = append(filtered, a)
}
}
as = filtered
}
writeJSON(w, http.StatusOK, as)
as, err := s.st.ListArtifacts(owner, pid)
writeJSONOr(w, as, err)
}
// sharedArtifactGet EXT-8 工件读取回退:owner 自有未命中 → 取元数据问适配器,
@@ -1576,8 +1290,7 @@ func (s *Server) sharedArtifactGet(owner, id string) (core.Artifact, bool) {
if err != nil {
return core.Artifact{}, false
}
// EXT-8b:工件级六要素(Owner=工件属主,受信元数据解析)。
if !s.authorizeSharedResource(owner, "download", "artifact", a.ID, a.Owner, a.ProjectID) {
if !s.authorizeSharedProject(owner, a.ProjectID) {
return core.Artifact{}, false
}
return a, true
@@ -1662,190 +1375,47 @@ 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")
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),
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),
})
}
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) {
values, err := decodeSettingsObject(r)
if err != nil {
var values map[string]any
if err := json.NewDecoder(r.Body).Decode(&values); 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) })
writeOKOr(w, s.set.MergeUser(s.owner(r), values))
}
func (s *Server) putSettingsProject(w http.ResponseWriter, r *http.Request) {
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
}
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 并列出路径,
// 其余错误按 writeOKOr 既有语义处理。
func (s *Server) writeSettingsMerge(w http.ResponseWriter, merge func() error) {
err := merge()
if err == nil {
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
return
}
var mp *settings.ErrManagedPath
if errors.As(err, &mp) {
var values map[string]any
if err := json.NewDecoder(r.Body).Decode(&values); err != nil {
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)
}
// ---- 系统设置(管理员;P2 受管项的唯一写入面)----
// requireSystemAdmin 系统设置面的准入:认证档要求基座管理员;无认证单机档
// (Auth 未启用)本机即管理员——该档全部 API 本就无认证,锁系统层只会破坏
// 嵌入式部署的配置播种,不增加安全边界。
func (s *Server) requireSystemAdmin(w http.ResponseWriter, r *http.Request) (string, bool) {
if s.Auth == nil {
return s.owner(r), true
}
return s.requireAdmin(w, r)
}
func (s *Server) getSettingsSystem(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireSystemAdmin(w, r); !ok {
return
}
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(s.set.DefaultsFor(core.SettingScopeSystem), schema),
"system": settings.Masked(s.set.ProjectValues(sys, core.SettingScopeSystem), schema),
"systemRevision": revision,
})
}
func (s *Server) putSettingsSystem(w http.ResponseWriter, r *http.Request) {
if _, ok := s.requireSystemAdmin(w, r); !ok {
return
}
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
}
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})
writeOKOr(w, s.set.MergeProject(s.owner(r), r.PathValue("id"), values))
}
// ---- 工具(用户声明式 HTTP 工具注册)----
@@ -1880,25 +1450,7 @@ func (s *Server) createUserTool(w http.ResponseWriter, r *http.Request) {
http.Error(w, "v1 用户工具需为声明式 HTTP 工具(提供 http 段)", http.StatusBadRequest)
return
}
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, "")
}
lookup := func(key string) string { return s.set.Effective(s.owner(r), "").GetString(key, "") }
if err := s.reg.Register(desc, tools.HTTPExecutor(*desc.HTTP, lookup)); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
@@ -1986,27 +1538,7 @@ func (s *Server) reloadPlugins(w http.ResponseWriter, r *http.Request) {
}
func (s *Server) pluginSettingsSchema(w http.ResponseWriter, r *http.Request) {
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)
writeJSON(w, http.StatusOK, s.plugins.SettingsSchema())
}
// listAdminZoneSections 管理区声明分区清单(WP3-I3):展平全部编译插件的
@@ -2177,53 +1709,10 @@ func (s *Server) searchKnowledge(w http.ResponseWriter, r *http.Request) {
topK, _ := strconv.Atoi(r.URL.Query().Get("topK"))
query := core.RetrievalQuery{ProjectID: r.URL.Query().Get("project"), Text: q, TopK: topK}
owner := s.owner(r)
if s.projectLifecycle != nil && query.ProjectID != "" {
id, err := projectscope.Resolve(r.Context(), s.lifecycleHost(owner), *s.projectLifecycle, query.ProjectID)
if err != nil {
writeProjectErr(w, err)
return
}
query.ProjectID = id
}
if s.ProjectAuthzV2 != nil {
allow := func(d core.KnowledgeDoc) bool {
ok, err := s.ProjectAuthzV2(extension.ResourceAuthzQuery{Actor: owner, Action: "search", ResourceType: "knowledge", ResourceID: d.ID, Owner: d.Owner, ProjectID: d.ProjectID, Source: d.Source})
return err == nil && ok
}
if rs, ok := s.Retriever.(interface {
RetrieveAuthorized(context.Context, core.ID, core.RetrievalQuery, func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error)
}); ok {
hits, err := rs.RetrieveAuthorized(r.Context(), owner, query, allow)
writeJSONOr(w, hits, err)
return
}
hits, err := s.st.SearchKnowledge(owner, query)
if err != nil {
writeStoreErr(w, err)
return
}
filtered := make([]core.RetrievalHit, 0, len(hits))
for _, h := range hits {
if allow(h.Doc) {
filtered = append(filtered, h)
}
}
shared, err := s.st.SearchKnowledgeSharedAuthorized(owner, query, allow)
if err != nil {
writeStoreErr(w, err)
return
}
filtered = append(filtered, shared...)
if query.TopK > 0 && len(filtered) > query.TopK {
filtered = filtered[:query.TopK]
}
writeJSON(w, http.StatusOK, filtered)
return
}
// EXT-8 余项:Retriever 支持共享版(向量+全文两路追加)时整体走共享版;
// 否则维持切片 2 行为(本人检索 + 全文共享追加)。
allow := func(docOwner, projectID core.ID) bool {
return s.authorizeSharedResource(owner, "search", "project", projectID, docOwner, projectID)
return s.authorizeSharedProject(owner, projectID)
}
if rs, ok := s.Retriever.(interface {
RetrieveShared(ctx context.Context, owner core.ID, q core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error)
@@ -2288,10 +1777,6 @@ func writeStoreErr(w http.ResponseWriter, err error) {
http.Error(w, err.Error(), http.StatusConflict)
return
}
if errors.Is(err, store.ErrBadCredentials) {
http.Error(w, "current password incorrect", http.StatusUnauthorized)
return
}
if errors.Is(err, runmgr.ErrConversationBusy) {
http.Error(w, err.Error(), http.StatusConflict)
return
-387
View File
@@ -1,387 +0,0 @@
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")
}
}
}
-206
View File
@@ -1,206 +0,0 @@
package server
// SSE 长连接会话复验(登录恢复合同 §6.7):密码写入删除会话行后,
// 已建立的流在复验周期内终止,不再继续接收私有事件。
import (
"bufio"
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"agent-system-backend/internal/store"
)
// 打开真实流式服务,等一帧事件后作废会话,断言流在复验周期内关闭。
func TestSSESessionInvalidationClosesStream(t *testing.T) {
old := sseAuthRecheck
sseAuthRecheck = 300 * time.Millisecond
t.Cleanup(func() { sseAuthRecheck = old })
srv, st := newAuthTestServer(t)
h := srv.Handler()
hash, err := store.HashPassword("sse-old-pass-1")
if err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("tia", "", hash, false); err != nil {
t.Fatal(err)
}
if err := st.ClearMustChangePassword("tia"); err != nil {
t.Fatal(err)
}
tok, err := st.CreateSession("tia")
if err != nil {
t.Fatal(err)
}
proj, err := st.CreateProject("tia", "sse-proj")
if err != nil {
t.Fatal(err)
}
conv, err := st.CreateConversation("tia", proj.ID, "", "")
if err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(h)
t.Cleanup(ts.Close)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
req, err := http.NewRequestWithContext(ctx, "GET", ts.URL+"/api/events?conversationId="+conv.ID, nil)
if err != nil {
t.Fatal(err)
}
req.AddCookie(&http.Cookie{Name: SessionCookie, Value: tok})
type result struct {
lastLine string
closed bool
err error
}
resCh := make(chan result, 1)
go func() {
resp, err := ts.Client().Do(req)
if err != nil {
resCh <- result{err: err}
return
}
defer resp.Body.Close()
sc := bufio.NewScanner(resp.Body)
last := ""
for sc.Scan() {
if line := sc.Text(); line != "" {
last = line
}
}
resCh <- result{lastLine: last, closed: true, err: sc.Err()}
}()
// 等重连提示帧出现,确认流已建立。
deadline := time.Now().Add(3 * time.Second)
established := false
for !established && time.Now().Before(deadline) {
if len(resCh) > 0 {
break
}
// 无阻塞探测:向会话写事件驱动首帧即可省略;此处直接进入作废。
established = true
}
// 作废会话(管理员重置语义:删除全部会话 + bump 版本)。
newHash, err := store.HashPassword("sse-new-pass-1")
if err != nil {
t.Fatal(err)
}
if err := st.AdminResetUserPassword("tia", newHash, "test", "test"); err != nil {
t.Fatal(err)
}
select {
case r := <-resCh:
if r.err != nil {
t.Fatalf("stream read error: %v", r.err)
}
if !strings.Contains(r.lastLine, "session-expired") {
t.Fatalf("stream should end with session-expired marker, got %q", r.lastLine)
}
case <-time.After(3 * time.Second):
t.Fatal("stream not closed within recheck window after session invalidation")
}
}
// API key 凭证的流:撤销后同样在复验周期内断流(合同 §6.7 补齐)。
func TestSSEAPIKeyRevocationClosesStream(t *testing.T) {
old := sseAuthRecheck
sseAuthRecheck = 300 * time.Millisecond
t.Cleanup(func() { sseAuthRecheck = old })
srv, st := newAuthTestServer(t)
h := srv.Handler()
if err := st.EnsureUser("kia", ""); err != nil {
t.Fatal(err)
}
key, err := st.CreateAPIKey("kia", "sse")
if err != nil {
t.Fatal(err)
}
proj, err := st.CreateProject("kia", "sse-proj")
if err != nil {
t.Fatal(err)
}
conv, err := st.CreateConversation("kia", proj.ID, "", "")
if err != nil {
t.Fatal(err)
}
ts := httptest.NewServer(h)
t.Cleanup(ts.Close)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
req, err := http.NewRequestWithContext(ctx, "GET", ts.URL+"/api/events?conversationId="+conv.ID+"&key="+key, nil)
if err != nil {
t.Fatal(err)
}
resCh := make(chan string, 1)
go func() {
resp, err := ts.Client().Do(req)
if err != nil {
resCh <- "err:" + err.Error()
return
}
defer resp.Body.Close()
sc := bufio.NewScanner(resp.Body)
last := ""
for sc.Scan() {
if line := sc.Text(); line != "" {
last = line
}
}
resCh <- last
}()
// 等首帧(retry 行)出现后撤销该 Key。
time.Sleep(300 * time.Millisecond)
if _, err := st.RevokeAPIKeyByName("kia", "sse"); err != nil {
t.Fatal(err)
}
select {
case got := <-resCh:
if !strings.Contains(got, "session-expired") {
t.Fatalf("stream should end with session-expired marker, got %q", got)
}
case <-time.After(3 * time.Second):
t.Fatal("stream not closed within recheck window after key revocation")
}
}
// 兑换入口限流:同源连续失败达上限后返回 429(跨实例共担由 store 测试覆盖)。
func TestRedeemRateLimited(t *testing.T) {
srv, _ := newAuthTestServer(t)
h := srv.Handler()
for i := 0; i < redeemMaxFails; i++ {
req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"alk_not_a_real_key_at_all"}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("attempt %d: want 401, got %d", i, rec.Code)
}
}
req := httptest.NewRequest("POST", "/api/auth/login-key/redeem", strings.NewReader(`{"key":"alk_other"}`))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusTooManyRequests {
t.Fatalf("want 429 after %d failures, got %d", redeemMaxFails, rec.Code)
}
}
-111
View File
@@ -1,111 +0,0 @@
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
@@ -1,226 +0,0 @@
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
@@ -1,93 +0,0 @@
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
@@ -1,55 +0,0 @@
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
@@ -1,153 +0,0 @@
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")
}
}
-105
View File
@@ -1,105 +0,0 @@
package settings
import (
core "agent-system-core"
"strings"
)
// MaskedProjectMetadata returns a detached credential-safe view without changing
// stored values or dotted service/configuration key shapes. The builtin and
// plugin secret schema plus the existing secret-leaf fallback cover legacy data.
func (s *Service) MaskedProjectMetadata(values map[string]any) map[string]any {
if values == nil {
return map[string]any{}
}
schema := s.Descriptors()
secrets := map[string]bool{}
for _, d := range schema {
if d.Type == core.SettingSecret || d.Secret {
secrets[d.Key] = true
}
}
return maskedProjectValue(values, "", secrets).(map[string]any)
}
func maskedProjectValue(value any, path string, secrets map[string]bool) any {
leaf := path
if i := strings.LastIndexByte(leaf, '.'); i >= 0 {
leaf = leaf[i+1:]
}
if secrets[path] || isSecretKey(leaf) {
if value == nil {
return nil
}
if text, ok := value.(string); ok && text == "" {
return text
}
return MaskPlaceholder
}
switch v := value.(type) {
case map[string]any:
if v == nil {
return v
}
out := make(map[string]any, len(v))
for key, item := range v {
child := key
if path != "" {
child = path + "." + key
}
out[key] = maskedProjectValue(item, child, secrets)
}
return out
case []any:
if v == nil {
return v
}
out := make([]any, len(v))
for i, item := range v {
out[i] = maskedProjectValue(item, path, secrets)
}
return out
default:
return value
}
}
// PrepareProjectMetadataWrite applies the same managed-path and mask-placeholder
// rules as project settings, while preserving the metadata store's key shapes.
// Project metadata lives in projects.settings, not the settings scope table.
func (s *Service) PrepareProjectMetadataWrite(values map[string]any) (map[string]any, error) {
incoming := dropProjectMaskPlaceholders(values)
if err := s.checkManagedWrite(normalizeKeys(incoming)); err != nil {
return nil, err
}
return incoming, nil
}
// dropProjectMaskPlaceholders prunes a parent only when a previously nonempty
// object became empty through mask removal. Explicit empty objects still express
// an overwrite and must reach the managed-path guard unchanged.
func dropProjectMaskPlaceholders(values map[string]any) map[string]any {
out := make(map[string]any, len(values))
for key, value := range values {
if child, ok := value.(map[string]any); ok {
prepared := dropProjectMaskPlaceholders(child)
if len(child) > 0 && len(prepared) == 0 {
continue
}
out[key] = prepared
continue
}
if text, ok := value.(string); ok && text == MaskPlaceholder {
continue
}
out[key] = value
}
return out
}
func (s *Service) checkManagedWrite(incoming map[string]any) error {
if hits := core.TouchesManagedSettings(incoming, s.ManagedSettingPaths()); len(hits) > 0 {
return &ErrManagedPath{Paths: hits}
}
return nil
}
@@ -1,86 +0,0 @@
package settings
import (
core "agent-system-core"
"bytes"
"encoding/json"
"testing"
)
func TestMaskedProjectMetadataCopiesSecretViews(t *testing.T) {
s := NewService(nil)
s.SetExtraSchema([]core.SettingDesc{{Key: "plugins.private.credential", Secret: true}})
raw := map[string]any{"models.chat.apiKey": "fixture-known", "legacy.token": "fixture-flat", "plugins.private": map[string]any{"credential": "fixture-schema"}, "nested": []any{map[string]any{"api_key": "fixture-array"}}, "opaque.secret": map[string]any{"value": "fixture-opaque"}, "custom.token": []any{"fixture-list"}, "custom.caption": "Allowed", "rdm.projectId": "canonical", "rdm.state": "ready"}
before, _ := json.Marshal(raw)
view := s.MaskedProjectMetadata(raw)
after, _ := json.Marshal(raw)
if string(before) != string(after) {
t.Fatal("read-only metadata view mutated its source")
}
encoded, _ := json.Marshal(view)
for _, secret := range []string{"fixture-known", "fixture-flat", "fixture-schema", "fixture-array", "fixture-opaque", "fixture-list"} {
if bytes.Contains(encoded, []byte(secret)) {
t.Fatal("metadata view exposed a fixture secret")
}
}
if view["custom.caption"] != "Allowed" || view["rdm.projectId"] != "canonical" || view["rdm.state"] != "ready" {
t.Fatal("nonsecret metadata shape/value changed")
}
if view["legacy.token"] != MaskPlaceholder || view["opaque.secret"] != MaskPlaceholder || view["custom.token"] != MaskPlaceholder {
t.Fatal("secret fallback did not mask complete dotted values")
}
}
func TestPrepareProjectMetadataWriteUsesManagedGuard(t *testing.T) {
s := NewService(nil)
s.SetExtraSchema([]core.SettingDesc{{Key: "plugins.private.managed", Scope: core.SettingScopeSystem}})
for _, raw := range []map[string]any{{"models.chat.apiKey": "fixture"}, {"models": map[string]any{"chat": map[string]any{"apiKey": "fixture"}}}, {"models": "smash"}, {"plugins.private.managed": "override"}} {
if _, err := s.PrepareProjectMetadataWrite(raw); err == nil {
t.Fatal("managed metadata write accepted")
}
}
raw := map[string]any{"custom.caption": "Allowed", "custom.token": MaskPlaceholder, "models.chat.apiKey": MaskPlaceholder}
view, err := s.PrepareProjectMetadataWrite(raw)
if err != nil {
t.Fatal(err)
}
if len(view) != 1 || view["custom.caption"] != "Allowed" {
t.Fatal("metadata writes must preserve allowed dotted keys and ignore mask placeholders")
}
if raw["custom.token"] != MaskPlaceholder {
t.Fatal("write preparation mutated its input")
}
}
func TestPrepareProjectMetadataWritePrunesOnlyMaskedParents(t *testing.T) {
s := NewService(nil)
for _, raw := range []map[string]any{
{"models": map[string]any{"chat": map[string]any{"apiKey": MaskPlaceholder}}, "display": map[string]any{"caption": "Updated"}},
{"models.chat": map[string]any{"apiKey": MaskPlaceholder}, "display.caption": "Updated"},
{"models": map[string]any{"chat.apiKey": MaskPlaceholder}, "display.caption": "Updated"},
} {
before, _ := json.Marshal(raw)
got, err := s.PrepareProjectMetadataWrite(raw)
if err != nil {
t.Fatalf("unchanged masked credential rejected legal metadata edit: %v", err)
}
if len(got) != 1 {
t.Fatal("mask-only parent remained in prepared metadata")
}
after, _ := json.Marshal(raw)
if !bytes.Equal(before, after) {
t.Fatal("mask preparation changed its input")
}
}
for _, raw := range []map[string]any{
{"models": map[string]any{}},
{"models": map[string]any{"chat": map[string]any{}}},
{"models.chat": map[string]any{}},
{"models": map[string]any{"chat": map[string]any{"apiKey": MaskPlaceholder, "model": "Real overwrite"}}},
{"models.chat": map[string]any{"apiKey": MaskPlaceholder}, "models": map[string]any{"chat": map[string]any{}}},
} {
if _, err := s.PrepareProjectMetadataWrite(raw); err == nil {
t.Fatal("explicit empty or real managed overwrite accepted")
}
}
}
-295
View File
@@ -1,295 +0,0 @@
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
@@ -1,45 +0,0 @@
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")
}
}
+20 -79
View File
@@ -19,77 +19,8 @@ var secretSpecs = []secretSpec{
{"tools.tavily.apiKeys", "tools.tavily"},
}
// 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.
// SnapshotAndMigrateSecrets 先为每个已存 scope 留快照,再把明文密钥迁入独立表并从表单设置里清掉。
// 该服务已经有密钥行时不再迁入,避免重复。掩码占位符不迁。
func (s *Service) SnapshotAndMigrateSecrets() error {
refs, err := s.st.ListSettingsScopes()
if err != nil {
@@ -108,11 +39,6 @@ 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
@@ -126,8 +52,13 @@ func (s *Service) SnapshotAndMigrateSecrets() error {
return err
}
}
// Preserve the source, including disagreements with older table rows.
// LookupSecret makes this explicit system value authoritative.
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
}
}
}
return nil
@@ -149,7 +80,17 @@ func (s *Service) snapshotThrottled(owner, scope string) {
}
func lookupString(m map[string]any, path string) (string, bool) {
cur, _ := valueAtPath(m, path)
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
}
}
s, ok := cur.(string)
if !ok || strings.TrimSpace(s) == "" {
return "", false
+36 -220
View File
@@ -3,33 +3,19 @@
package settings
import (
"reflect"
"sort"
"strings"
"sync"
"agent-system-backend/internal/store"
core "agent-system-core"
)
// Scope 层级键:user 与 project:<id> 落库;system 是管理员全局层(owner 固定
// OwnerSystemRow);conversation 经会话画像选择实现。
// Scope 层级键:user 与 project:<id> 落库;conversation 经会话画像选择实现。
const (
ScopeUser = "user"
ScopeProjectPrefix = "project:"
ScopeSystem = "system"
// OwnerSystemRow 系统层落库行的 owner。空串不是任何账号(账号名非空),
// 租户 API 无法读写该行。
OwnerSystemRow = ""
)
// ErrManagedPath 表示写入负载触碰系统受管设置项;user/project 层拒绝整笔写入。
type ErrManagedPath struct{ Paths []string }
func (e *ErrManagedPath) Error() string {
return "系统受管设置项仅管理员可配置: " + strings.Join(e.Paths, ", ")
}
// SystemDefaults 是系统级默认(代码内置,settings.system.json 语义)。
func SystemDefaults() map[string]any {
return map[string]any{
@@ -90,32 +76,31 @@ func SystemDefaults() map[string]any {
// Schema 返回设置描述清单(设置页渲染依据;插件设置项由 pluginhost 追加)。
func Schema() []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},
{Key: "models.chat.fallbacks", Group: "models", Label: "主模型回退链", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "逗号分隔的模型名。只在尚未输出时,前一个模型返回 5xx 才改用下一个。空表示不回退"},
{Key: "models.chat.maxOutputTokens", Group: "models", Label: "主模型输出上限", Type: core.SettingNumber, Default: 0, Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "单次回复的 max_tokens。0 = 不限制,由模型服务端决定"},
{Key: "models.chat.contextWindow", Group: "models", Label: "主模型上下文窗口(token)", Type: core.SettingNumber, Default: 128000, Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "模型真实窗口的 token 数。运行内清理旧工具结果的阈值由此换算(此前按 32000 字符硬编码,长材料任务第一请求即触发清理且被清内容找不回)"},
{Key: "models.chat.outputReserve", Group: "models", Label: "窗口输出预留(token)", Type: core.SettingNumber, Default: 16384, Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "清理阈值里为模型输出预留的 token 数"},
{Key: "models.chat.contextBuffer", Group: "models", Label: "窗口余量(token)", Type: core.SettingNumber, Default: 4096, Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "避免贴着上限才清理的余量"},
{Key: "models.lightweight.baseUrl", Group: "models", Label: "轻量模型 Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem, Description: "标题生成/澄清等轻任务;空则回落主模型"},
{Key: "models.lightweight.apiKey", Group: "models", Label: "轻量模型 API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot, Scope: core.SettingScopeSystem},
{Key: "models.lightweight.model", Group: "models", Label: "轻量模型名称", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Scope: core.SettingScopeSystem},
{Key: "models.lightweight.maxOutputTokens", Group: "models", Label: "轻量模型输出上限", Type: core.SettingNumber, Default: 0, Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "单次回复的 max_tokens。0 = 不限制,由模型服务端决定"},
{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},
{Key: "runtime.mock", Group: "runtime", Label: "演示模式(mock runner)", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Scope: core.SettingScopeSystem, Description: "不调用真实模型,按脚本回放事件流"},
return []core.SettingDesc{
{Key: "models.chat.baseUrl", Group: "models", Label: "主模型 Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Description: "OpenAI 兼容端点,如 https://api.openai.com/v1"},
{Key: "models.chat.apiKey", Group: "models", Label: "主模型 API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot},
{Key: "models.chat.model", Group: "models", Label: "主模型名称", Type: core.SettingString, Default: "", Effect: core.EffectNewRun},
{Key: "models.chat.fallbacks", Group: "models", Label: "主模型回退链", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "逗号分隔的模型名。只在尚未输出时,前一个模型返回 5xx 才改用下一个。空表示不回退"},
{Key: "models.chat.maxOutputTokens", Group: "models", Label: "主模型输出上限", Type: core.SettingNumber, Default: 0, Effect: core.EffectNewRun, Description: "单次回复的 max_tokens。0 = 不限制,由模型服务端决定"},
{Key: "models.chat.contextWindow", Group: "models", Label: "主模型上下文窗口(token)", Type: core.SettingNumber, Default: 128000, Effect: core.EffectNewRun, Description: "模型真实窗口的 token 数。运行内清理旧工具结果的阈值由此换算(此前按 32000 字符硬编码,长材料任务第一请求即触发清理且被清内容找不回)"},
{Key: "models.chat.outputReserve", Group: "models", Label: "窗口输出预留(token)", Type: core.SettingNumber, Default: 16384, Effect: core.EffectNewRun, Description: "清理阈值里为模型输出预留的 token 数"},
{Key: "models.chat.contextBuffer", Group: "models", Label: "窗口余量(token)", Type: core.SettingNumber, Default: 4096, Effect: core.EffectNewRun, Description: "避免贴着上限才清理的余量"},
{Key: "models.lightweight.baseUrl", Group: "models", Label: "轻量模型 Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Description: "标题生成/澄清等轻任务;空则回落主模型"},
{Key: "models.lightweight.apiKey", Group: "models", Label: "轻量模型 API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot},
{Key: "models.lightweight.model", Group: "models", Label: "轻量模型名称", Type: core.SettingString, Default: "", Effect: core.EffectNewRun},
{Key: "models.lightweight.maxOutputTokens", Group: "models", Label: "轻量模型输出上限", Type: core.SettingNumber, Default: 0, Effect: core.EffectNewRun, Description: "单次回复的 max_tokens。0 = 不限制,由模型服务端决定"},
{Key: "models.embedding.baseUrl", Group: "models", Label: "Embedding Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot, Description: "RAG/记忆检索用(向量管线远期启用)"},
{Key: "models.embedding.apiKey", Group: "models", Label: "Embedding API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot},
{Key: "models.embedding.model", Group: "models", Label: "Embedding 模型", Type: core.SettingString, Default: "", Effect: core.EffectHot},
{Key: "models.rerank.baseUrl", Group: "models", Label: "Reranker Base URL", Type: core.SettingString, Default: "", Effect: core.EffectHot},
{Key: "models.rerank.apiKey", Group: "models", Label: "Reranker API Key", Type: core.SettingSecret, Default: "", Effect: core.EffectHot},
{Key: "models.rerank.model", Group: "models", Label: "Reranker 模型", Type: core.SettingString, Default: "", Effect: core.EffectHot},
{Key: "runtime.mock", Group: "runtime", Label: "演示模式(mock runner)", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "不调用真实模型,按脚本回放事件流"},
{Key: "runtime.squad", Group: "runtime", Label: "小队编排(Agent-Squad)", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "启用 Agent-Squad 引擎编排(#squad 指令与 squad_orchestrate 工具)"},
{Key: "runtime.entry", Group: "runtime", Label: "对话入口", Type: core.SettingEnum, Default: "direct", Options: []string{"direct", "squad"}, Effect: core.EffectNewRun, Description: "direct 走现有对话循环。squad 由小队先分诊:简单任务单步完成,复杂任务由 leader 编排。灰度期默认 direct"},
{Key: "runtime.parallelTools", Group: "runtime", Label: "并行工具执行", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "同一回合并行执行互不依赖的工具调用(检索、读文件、delegate_task 等);卡片流、计划、执行原语等顺序敏感工具仍互斥串行"},
{Key: "runtime.rateLimitPerMin", Group: "runtime", Label: "租户限流(次/分)", Type: core.SettingNumber, Default: 0, Effect: core.EffectHot, Scope: core.SettingScopeSystem, Description: "0 = 不限;多用户建议 300–1200"},
{Key: "runtime.redisAddr", Group: "runtime", Label: "Redis 地址(限流共享)", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "原生 Redis(如 127.0.0.1:6379);空 = 进程内限流(单实例)"},
{Key: "runtime.rateLimitPerMin", Group: "runtime", Label: "租户限流(次/分)", Type: core.SettingNumber, Default: 0, Effect: core.EffectHot, Description: "0 = 不限;多用户建议 300–1200"},
{Key: "runtime.redisAddr", Group: "runtime", Label: "Redis 地址(限流共享)", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Description: "原生 Redis(如 127.0.0.1:6379);空 = 进程内限流(单实例)"},
{Key: "runtime.verify", Group: "runtime", Label: "终轮结果检查", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "终轮确定性检查与独立验证"},
{Key: "runtime.llmRetries", Group: "runtime", Label: "模型重试次数", Type: core.SettingNumber, Default: 2, Effect: core.EffectNewRun, Description: "直连模型在还没有任何输出时,遇到超时、限流或服务端错误的额外重试次数。0 = 不重试。已经开始输出后不再重试"},
{Key: "runtime.codeOrchestration", Group: "runtime", Label: "代码编排", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "允许模型用 run_tool_script 写脚本批量调用工具。默认关闭"},
@@ -127,19 +112,10 @@ 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: "telemetry.otlpEndpoint", Group: "telemetry", Label: "OTLP 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, 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, 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 自动开启"},
@@ -148,30 +124,21 @@ func Schema() []core.SettingDesc {
{Key: "retrieval.topK", Group: "retrieval", Label: "检索命中数", Type: core.SettingNumber, Default: 5, Effect: core.EffectHot},
{Key: "ui.theme", Group: "ui", Label: "主题", Type: core.SettingEnum, Default: "dark", Options: []string{"dark", "light"}, Effect: core.EffectHot},
{Key: "ui.cardflow", Group: "ui", Label: "长结果进右侧卡片", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun, Description: "开启后报告/纪要/方案等产物型长内容经 submit_card 进收件箱,对话只留结论摘要与锚点(默认开,用户 2026-10-11 裁定)"},
{Key: "tools.tavily.apiKeys", Group: "tools", Label: "Tavily API Keys(池)", Type: core.SettingSecret, Default: "", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "逗号分隔;配置后注册 tavily_search/web_search 工具(429/401/403 自动轮换下一把)"},
{Key: "tools.tavily.endpoint", Group: "tools", Label: "Tavily 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "空 = 官方 https://api.tavily.com/search;可指向自建代理"},
{Key: "tools.tavily.apiKeys", Group: "tools", Label: "Tavily API Keys(池)", Type: core.SettingSecret, Default: "", Effect: core.EffectRestart, Description: "逗号分隔;配置后注册 tavily_search/web_search 工具(429/401/403 自动轮换下一把)"},
{Key: "tools.tavily.endpoint", Group: "tools", Label: "Tavily 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, 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 提供有效设置计算与分层写入。
type Service struct {
st *store.Store
system map[string]any // 启动注入的系统层覆盖(如数据目录),位于 defaults 与 user 之间
// extraSchema 是插件设置声明(组合根经 SetExtraSchema 注入);
// 受管路径计算同时覆盖内置 schema 与插件声明。
extraSchema []core.SettingDesc
schemaMu sync.RWMutex
}
func NewService(st *store.Store) *Service { return &Service{st: st} }
@@ -179,197 +146,46 @@ func NewService(st *store.Store) *Service { return &Service{st: st} }
// SetSystemOverride 注入启动期系统层覆盖(组合根调用;不落库)。
func (s *Service) SetSystemOverride(values map[string]any) { s.system = values }
// SetExtraSchema 注入插件设置声明(受管标记由插件按需声明)。
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 {
return append(core.ManagedPaths(s.Descriptors()), "models", "runtime", "tools.tavily", "tools.mcp")
}
// Effective 计算覆盖链结果:defaults → system(启动注入) → system(管理员落库)
// → user(租户) → project。受管项在 user/project 层的存量覆盖被剥除,不再生效。
// Effective 计算覆盖链结果:defaults → system(启动注入) → user(租户) → project。
// owner 是租户身份(认证中间件注入;未启用认证 = store.OwnerDefault)。
func (s *Service) Effective(owner, projectID string) core.EffectiveSettings {
eff := core.MergeSettings(s.declaredDefaults(), map[string]any{})
eff := core.MergeSettings(SystemDefaults(), 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, s.ProjectValues(sysdb, core.SettingScopeSystem))
}
managed := s.ManagedSettingPaths()
if user, err := s.st.GetSettingsScope(owner, ScopeUser); err == nil {
eff = core.MergeSettings(eff, s.ProjectValues(core.StripManagedSettings(normalizeKeys(user), managed), UserSettingScope))
eff = core.MergeSettings(eff, user)
}
if projectID != "" {
if proj, err := s.st.GetSettingsScope(owner, ScopeProjectPrefix+projectID); err == nil {
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)
eff = core.MergeSettings(eff, proj)
}
}
// Trusted runtime policy metadata, never included in editable schema/read projections.
eff["_policy"] = map[string]any{"managedModels": true}
return core.EffectiveSettings(effectiveNumbers(eff))
return eff
}
func (s *Service) UserScope(owner string) (map[string]any, error) {
m, err := s.readScopeStripped(owner, ScopeUser)
return s.ProjectValues(m, UserSettingScope), err
return s.st.GetSettingsScope(owner, ScopeUser)
}
func (s *Service) ProjectScope(owner, projectID string) (map[string]any, error) {
m, err := s.readScopeStripped(owner, ScopeProjectPrefix+projectID)
return s.ProjectValues(m, PersonalProjectScope), err
}
// readScopeStripped 读库并剥除受管路径:受管项对租户层不再生效,也不在设置页
// 回显为"当前值"(存量值保留在库中作为迁移依据,P8 处置)。
func (s *Service) readScopeStripped(owner, scope string) (map[string]any, error) {
m, err := s.st.GetSettingsScope(owner, scope)
if err != nil {
return nil, err
}
return core.StripManagedSettings(normalizeKeys(m), s.ManagedSettingPaths()), nil
}
// SystemScope 返回管理员落库的系统层(未脱敏;HTTP 读面自行 Masked)。
func (s *Service) SystemScope() (map[string]any, error) {
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
return s.st.GetSettingsScope(owner, ScopeProjectPrefix+projectID)
}
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 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 {
incoming := prepareWrite(values)
if scope != ScopeSystem {
if err := s.checkManagedWrite(incoming); err != nil {
return err
}
}
if err := s.st.UpdateSettingsScope(owner, scope, func(cur map[string]any) (map[string]any, error) {
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
return core.MergeSettings(normalizeKeys(cur), incoming), nil
}); err != nil {
return err
}
+10 -25
View File
@@ -1,7 +1,6 @@
package settings
import (
"errors"
"path/filepath"
"reflect"
"strings"
@@ -178,12 +177,11 @@ func TestMergeUserDottedKeyMaskAndNormalizeStored(t *testing.T) {
t.Fatalf("mask path missing: %v", stats.MaskPaths)
}
eff := svc.Effective("local", "p1")
if eff.GetString("runtime.entry", "") != "direct" {
if eff.GetString("runtime.entry", "") != "squad" {
t.Fatalf("entry = %q", eff.GetString("runtime.entry", ""))
}
// P2 受管:tools.tavily.apiKeys 属系统受管项,user 层存量覆盖不再生效。
if got := eff.GetString("tools.tavily.apiKeys", ""); got != "" {
t.Fatalf("managed key leaked from user scope: %q", got)
if eff.GetString("tools.tavily.apiKeys", "") != "nested" {
t.Fatalf("nested key lost: %q", eff.GetString("tools.tavily.apiKeys", ""))
}
if eff.GetNumber("context.maxHistoryMessages", 0) != 30 {
t.Fatalf("history = %v", eff.GetNumber("context.maxHistoryMessages", 0))
@@ -193,10 +191,10 @@ func TestMergeUserDottedKeyMaskAndNormalizeStored(t *testing.T) {
t.Fatalf("second pass = %+v err=%v", again, err)
}
if err := svc.MergeSystem(map[string]any{"runtime.entry": "squad"}); err != nil {
if err := svc.MergeUser("local", map[string]any{"runtime.entry": "direct"}); err != nil {
t.Fatal(err)
}
if svc.Effective("local", "").GetString("runtime.entry", "") != "squad" {
if svc.Effective("local", "").GetString("runtime.entry", "") != "direct" {
t.Fatal("dotted write did not take effect")
}
raw, err := svc.UserScope("local")
@@ -206,27 +204,14 @@ func TestMergeUserDottedKeyMaskAndNormalizeStored(t *testing.T) {
if _, ok := raw["runtime.entry"]; ok {
t.Fatalf("dotted key persisted: %#v", raw)
}
// P2 受管:user 层写受管路径(嵌套与点路径形式)都整笔拒绝。
var mp *ErrManagedPath
if err := svc.MergeUser("local", map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": "sk-real"}}}); !errors.As(err, &mp) {
t.Fatalf("nested managed write not rejected: %v", err)
}
if err := svc.MergeUser("local", map[string]any{"models.chat.apiKey": "sk-real"}); !errors.As(err, &mp) {
t.Fatalf("dotted managed write not rejected: %v", err)
}
if err := svc.MergeUser("local", map[string]any{"models": "smash"}); !errors.As(err, &mp) {
t.Fatalf("ancestor scalar overwrite not rejected: %v", err)
}
// 受管项只能经系统层写入并生效。
if err := svc.MergeSystem(map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": "sk-sys"}}}); err != nil {
if err := svc.MergeUser("local", map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": "sk-real"}}}); err != nil {
t.Fatal(err)
}
if got := svc.Effective("local", "").GetString("models.chat.apiKey", ""); got != "sk-sys" {
t.Fatalf("system-layer key not effective: %q", got)
if err := svc.MergeUser("local", map[string]any{"models.chat.apiKey": MaskPlaceholder}); err != nil {
t.Fatal(err)
}
// 读面剥离:UserScope 不回显受管项的存量值。
if raw2, _ := svc.UserScope("local"); raw2["models"] != nil {
t.Fatalf("managed path exposed in user read scope: %#v", raw2["models"])
if got := svc.Effective("local", "").GetString("models.chat.apiKey", ""); got != "sk-real" {
t.Fatalf("mask writeback overwrote key: %q", got)
}
}
-72
View File
@@ -1,72 +0,0 @@
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
@@ -1,73 +0,0 @@
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
@@ -1,39 +0,0 @@
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
@@ -1,55 +0,0 @@
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")
}
}
-145
View File
@@ -1,145 +0,0 @@
package store
// 账户生命周期并发合同(R5/R11/KTD12,2026-10-13 收尾):
// 并发建号不覆盖、首次引导单胜、兑换退避跨实例共担。
import (
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
)
// 并发建号同名:恰一胜;胜者角色/密码不被败者覆盖(旧实现 UPDATE 步会互相覆盖)。
func TestConcurrentCreateAccountNoOverwrite(t *testing.T) {
dir := t.TempDir()
db := filepath.Join(dir, "shared.db")
st, err := OpenSQLite(db)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
hash, err := HashPassword("winner-pass-1")
if err != nil {
t.Fatal(err)
}
var wins, losses atomic.Int32
var wg sync.WaitGroup
for i := 0; i < 16; i++ {
admin := i%2 == 0 // 一半以 admin 建同名账户
wg.Add(1)
go func(admin bool) {
defer wg.Done()
if err := st.CreateAccount("dupe", "", hash, admin); err == nil {
wins.Add(1)
} else if err == ErrConflict {
losses.Add(1)
}
}(admin)
}
wg.Wait()
if wins.Load() != 1 || losses.Load() != 15 {
t.Fatalf("wins=%d losses=%d, want 1/15", wins.Load(), losses.Load())
}
got, err := st.GetUserAuthState("dupe")
if err != nil {
t.Fatal(err)
}
// 胜者的 hash 原样保留(没有第二个写入者覆盖密码)。
if !got.HasPassword {
t.Fatal("winner password must survive concurrent creates")
}
if _, ok := st.VerifyLogin("dupe", "winner-pass-1"); !ok {
t.Fatal("winner credentials must verify")
}
}
// 首次管理员引导并发:两个并发引导恰一胜;已含用户时拒绝接管。
func TestBootstrapFirstAdminSingleWinner(t *testing.T) {
dir := t.TempDir()
db := filepath.Join(dir, "boot.db")
st, err := OpenSQLite(db)
if err != nil {
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++ {
owner := "admin-a"
if i%2 == 1 {
owner = "admin-b"
}
wg.Add(1)
go func(owner string) {
defer wg.Done()
if _, err := st.BootstrapFirstAdmin(owner, owner, "test"); err == nil {
wins.Add(1)
} else if err == ErrConflict {
conflicts.Add(1)
} else {
t.Errorf("bootstrap contender: %v", err)
}
}(owner)
}
wg.Wait()
if wins.Load() != 1 || conflicts.Load() != 7 {
t.Fatalf("wins=%d conflicts=%d, want 1/7", wins.Load(), conflicts.Load())
}
users, err := st.ListUsersAdmin()
if err != nil || len(users) != 1 {
t.Fatalf("exactly one admin must exist: %d %v", len(users), err)
}
if !users[0].IsAdmin {
t.Fatal("bootstrapped account must be admin")
}
// 已有用户后再次引导:拒绝接管。
if _, err := st.BootstrapFirstAdmin("admin-c", "admin-c", "test"); err != ErrConflict {
t.Fatalf("second bootstrap after users exist: %v", err)
}
}
// 兑换退避跨实例:store A 记录的失败,store B(同库另一连接)同样被限。
func TestRedeemGuardCrossStore(t *testing.T) {
dir := t.TempDir()
db := filepath.Join(dir, "guard.db")
stA, err := OpenSQLite(db)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { stA.Close() })
stB, err := OpenSQLite(db)
if err != nil {
t.Fatal(err)
}
defer stB.Close()
const max = 5
for i := 0; i < max; i++ {
if err := stA.RecordRedeemFailure("10.0.0.9"); err != nil {
t.Fatal(err)
}
}
if blocked, err := stB.RedeemBlocked("10.0.0.9", max, time.Minute); err != nil || !blocked {
t.Fatalf("store B must see the shared limit: blocked=%v err=%v", blocked, err)
}
if blocked, err := stA.RedeemBlocked("10.0.0.8", max, time.Minute); err != nil || blocked {
t.Fatalf("other ip must pass: blocked=%v err=%v", blocked, err)
}
// 窗口外记录在下次判定时清理。
if err := stA.drainRedeemWindow(); err != nil {
t.Fatal(err)
}
if blocked, _ := stA.RedeemBlocked("10.0.0.9", max, time.Minute); blocked {
t.Fatal("limit must clear after the window drains")
}
}
// drainRedeemWindow 测试助手:把窗口内判定用的记录时间整体前移出窗。
func (s *Store) drainRedeemWindow() error {
_, err := s.db.Exec(s.q(`UPDATE redeem_attempts SET at=?`), time.Now().Add(-2*time.Minute).UTC().Format(time.RFC3339Nano))
return err
}
@@ -1,104 +0,0 @@
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")
}
})
}
}
@@ -1,187 +0,0 @@
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")
}
}
@@ -1,167 +0,0 @@
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")
}
}
@@ -1,202 +0,0 @@
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
@@ -1,372 +0,0 @@
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
@@ -1,156 +0,0 @@
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
}
+26 -117
View File
@@ -218,15 +218,18 @@ func VerifyPassword(hash, password string) bool {
return subtle.ConstantTimeCompare(got, want) == 1
}
// CreateAccount 管理员建号:单事务整行 INSERT,owner 唯一冲突返回 ErrConflict。
// passwordHash 非空时 must_change=1;空哈希表示未设密(走激活 Key),must_change=0。
// (原子化:旧实现先查后插再 UPDATE,两个并发 create 同名会在 UPDATE 步互相覆盖
// 角色与密码——R5/KTD12 并发建号不覆盖。)
// CreateAccount 管理员建号:owner 唯一;初始密码 must_change=1。
func (s *Store) CreateAccount(owner, name, passwordHash string, isAdmin bool) error {
_, err := s.CreateActivatedAccount(AccountWrite{
Owner: owner, Name: name, PasswordHash: passwordHash, IsAdmin: isAdmin,
Action: "account.create",
})
if _, err := s.GetUser(owner); err == nil {
return ErrConflict
}
if err := s.EnsureUser(owner, name); err != nil {
return err
}
// 布尔列一律绑 Go bool 参数、不写 1/0 字面量:SQLite 两侧都容忍,
// PostgreSQL 对 boolean 列收到 integer 会 42804(2026-10-05 user create 半失败根因)。
_, err := s.db.Exec(s.q(`UPDATE users SET name=?, password_hash=?, password_set_at=?, must_change_password=?, is_admin=? WHERE owner=?`),
name, passwordHash, nowISO(), true, isAdmin, owner)
return err
}
@@ -277,143 +280,49 @@ func (s *Store) GetUserAuthState(owner string) (UserAuthState, error) {
// VerifyLogin 校验用户名密码。
func (s *Store) VerifyLogin(owner, password string) (UserAuthState, bool) {
st, _, ok := s.VerifyLoginWithVersion(owner, password)
return st, ok
}
// VerifyLoginWithVersion 校验用户名密码并返回验证所依据的认证版本;调用方建会话
// 时必须以 CreateSessionVerified 传回该版本,堵「验证在途 → 恢复/改密完成 →
// 旧请求又建会话」的竞态窗口。
func (s *Store) VerifyLoginWithVersion(owner, password string) (UserAuthState, int, bool) {
var hash string
var ver int
err := s.db.QueryRow(s.q(`SELECT password_hash,auth_version FROM users WHERE owner=?`), owner).Scan(&hash, &ver)
err := s.db.QueryRow(s.q(`SELECT password_hash FROM users WHERE owner=?`), owner).Scan(&hash)
if err != nil || hash == "" || !VerifyPassword(hash, password) {
return UserAuthState{}, 0, false
return UserAuthState{}, false
}
st, err := s.GetUserAuthState(owner)
if err != nil {
return UserAuthState{}, 0, false
return UserAuthState{}, false
}
return st, ver, true
return st, true
}
// ---- 会话 ----
// CreateSession 建会话:明文 token 仅此一次返回;库存 SHA-256 摘要与到期时间;scope=full。
// CreateSession 建会话:明文 token 仅此一次返回;库存 SHA-256 摘要与到期时间。
func (s *Store) CreateSession(owner string) (string, error) {
return s.CreateSessionScoped(owner, SessionScopeFull, sessionTTL)
}
// CreateSessionScoped 按 scope 与 TTL 建会话(尽力绑定账户当前认证版本)。
func (s *Store) CreateSessionScoped(owner, scope string, ttl time.Duration) (string, error) {
if scope == "" {
scope = SessionScopeFull
}
if ttl <= 0 {
ttl = sessionTTL
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
token := "sess_" + hex.EncodeToString(raw)
exp := time.Now().Add(ttl)
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)
if err != nil {
return "", err
}
return token, tx.Commit()
}
// CreateSessionVerified 建会话并绑定期望认证版本:事务内重读账户版本,与密码验证
// 时的一致才插入;版本已前进(并发恢复/改密/重置)返回 ErrConflict,不建会话。
func (s *Store) CreateSessionVerified(owner, scope string, ttl time.Duration, expectVersion int) (string, error) {
if scope == "" {
scope = SessionScopeFull
}
if ttl <= 0 {
ttl = sessionTTL
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", err
}
token := "sess_" + hex.EncodeToString(raw)
exp := time.Now().Add(ttl)
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
err = tx.QueryRow(s.q(`SELECT auth_version FROM users WHERE owner=?`), owner).Scan(&ver)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotFound
}
if err != nil {
return "", err
}
if ver != expectVersion {
return "", ErrConflict
}
if _, 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); err != nil {
return "", err
}
return token, tx.Commit()
exp := time.Now().Add(sessionTTL)
_, err := s.db.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at) VALUES(?,?,?,?,?)`),
hashKey(token), owner, nowISO(), exp.UTC().Format(time.RFC3339Nano), nowISO())
return token, err
}
// SessionOwner 校验会话:命中且未过期返回 owner(尽力刷新 last_seen)。
func (s *Store) SessionOwner(token string) (string, bool) {
owner, _, ok := s.SessionInfo(token)
return owner, ok
}
// SessionInfo 返回 owner 与 scope。
func (s *Store) SessionInfo(token string) (owner, scope string, ok bool) {
if token == "" {
return "", "", false
return "", false
}
var exp string
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)
var owner, exp string
err := s.db.QueryRow(s.q(`SELECT owner,expires_at FROM sessions WHERE token_hash=?`), hashKey(token)).Scan(&owner, &exp)
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
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
return "", false
}
s.db.Exec(s.q(`UPDATE sessions SET last_seen_at=? WHERE token_hash=?`), nowISO(), hashKey(token))
if scope == "" {
scope = SessionScopeFull
}
return owner, scope, true
return owner, true
}
// DeleteSession 注销当前会话。
+6 -84
View File
@@ -388,66 +388,6 @@ func (s *Store) SearchKnowledgeShared(requester string, q core.RetrievalQuery, a
return s.searchKnowledgeSQLiteShared(requester, q, pred)
}
// SearchKnowledgeSharedAuthorized evaluates the trusted document identity before
// returning a shared row. Project-level permissions cannot grant private content.
func (s *Store) SearchKnowledgeSharedAuthorized(requester string, q core.RetrievalQuery, allow func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) {
pred := func(d core.KnowledgeDoc) bool {
return d.Owner != "" && d.Owner != requester && d.ProjectID != "" &&
(q.ProjectID == "" || d.ProjectID == q.ProjectID) &&
(len(q.Sources) == 0 || containsStr(q.Sources, d.Source)) && allow(d)
}
if s.dialect.name() == "postgres" {
return s.searchKnowledgePGSharedCandidates(requester, q, pred, false)
}
out, err := s.searchKnowledgeFTSCandidates(pred, q, false)
if err != nil {
return nil, err
}
if len(out) == 0 && q.Text != "" {
return s.searchKnowledgeLikeSharedCandidates(requester, q, pred, false)
}
return out, nil
}
// DeleteProjectKnowledgeByRef preserves the ordinary legacy replacement scope.
// Registered lifecycle publishers use DeletePluginProjectKnowledgeByRef instead.
func (s *Store) DeleteProjectKnowledgeByRef(ctx context.Context, owner, projectID, ref string) error {
args := []any{owner, ref}
pred := "owner=? AND ref=?"
if projectID != "" {
pred += " AND project_id=?"
args = append(args, projectID)
}
return s.deleteKnowledgeAndVectors(ctx, pred, args...)
}
// DeletePluginProjectKnowledgeByRef replaces only this actor/plugin publication
// scope. Empty project is an exact unscoped domain, never a wildcard. The source
// prefix is compared literally, including any percent/underscore in plugin IDs.
func (s *Store) DeletePluginProjectKnowledgeByRef(ctx context.Context, owner, projectID, ref, pluginID string) error {
if owner == "" || pluginID == "" {
return errors.New("store: publication owner and plugin required")
}
prefix := "plugin:" + pluginID
pred := `owner=? AND project_id=? AND ref=? AND (source=? OR substr(source,1,length(?))=?)`
return s.deleteKnowledgeAndVectors(ctx, pred, owner, projectID, ref, prefix, prefix+":", prefix+":")
}
func (s *Store) deleteKnowledgeAndVectors(ctx context.Context, pred string, args ...any) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, s.q("DELETE FROM knowledge_vectors WHERE doc_id IN (SELECT id FROM knowledge WHERE "+pred+")"), args...); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, s.q("DELETE FROM knowledge WHERE "+pred), args...); err != nil {
return err
}
return tx.Commit()
}
func (s *Store) searchKnowledgeSQLite(owner string, q core.RetrievalQuery) ([]core.RetrievalHit, error) {
out, err := s.searchKnowledgeFTS(ownerVisible(owner, q), q)
if err != nil {
@@ -479,23 +419,14 @@ func (s *Store) searchKnowledgeSQLiteShared(requester string, q core.RetrievalQu
// searchKnowledgeFTS FTS 通道(owner 作用域与共享作用域共用:可见性谓词由调用方注入)。
func (s *Store) searchKnowledgeFTS(pred knowledgePred, q core.RetrievalQuery) ([]core.RetrievalHit, error) {
return s.searchKnowledgeFTSCandidates(pred, q, true)
}
func (s *Store) searchKnowledgeFTSCandidates(pred knowledgePred, q core.RetrievalQuery, bounded bool) ([]core.RetrievalHit, error) {
topK := q.TopK
if topK <= 0 {
topK = 5
}
query := `SELECT k.id,k.owner,k.project_id,k.source,k.title,k.ref,k.chunk,k.created_at, rank
FROM knowledge_fts f JOIN knowledge k ON k.rowid=f.rowid
WHERE knowledge_fts MATCH ? ORDER BY rank`
args := []any{ftsQuery(q.Text)}
if bounded {
query += " LIMIT ?"
args = append(args, topK*2)
}
rows, err := s.db.Query(s.q(query), args...)
rows, err := s.db.Query(s.q(`
SELECT k.id,k.owner,k.project_id,k.source,k.title,k.ref,k.chunk,k.created_at, rank
FROM knowledge_fts f JOIN knowledge k ON k.rowid=f.rowid
WHERE knowledge_fts MATCH ? ORDER BY rank LIMIT ?`), ftsQuery(q.Text), topK*2)
if err != nil {
return nil, err
}
@@ -518,21 +449,12 @@ func (s *Store) searchKnowledgeFTSCandidates(pred knowledgePred, q core.Retrieva
}
func (s *Store) searchKnowledgeLikeShared(requester string, q core.RetrievalQuery, pred knowledgePred) ([]core.RetrievalHit, error) {
return s.searchKnowledgeLikeSharedCandidates(requester, q, pred, true)
}
func (s *Store) searchKnowledgeLikeSharedCandidates(requester string, q core.RetrievalQuery, pred knowledgePred, bounded bool) ([]core.RetrievalHit, error) {
topK := q.TopK
if topK <= 0 {
topK = 5
}
query := `SELECT id,owner,project_id,source,title,ref,chunk,created_at FROM knowledge WHERE owner<>? AND owner<>'' AND (chunk LIKE ? OR title LIKE ?)`
args := []any{requester, "%" + q.Text + "%", "%" + q.Text + "%"}
if bounded {
query += " LIMIT ?"
args = append(args, topK)
}
rows, err := s.db.Query(s.q(query), args...)
rows, err := s.db.Query(s.q(`SELECT id,owner,project_id,source,title,ref,chunk,created_at FROM knowledge WHERE owner<>? AND owner<>'' AND (chunk LIKE ? OR title LIKE ?) LIMIT ?`),
requester, "%"+q.Text+"%", "%"+q.Text+"%", topK)
if err != nil {
return nil, err
}
-17
View File
@@ -17,11 +17,6 @@ type dialect interface {
lockScope(tx *sql.Tx, owner, scope string) error
// lockAdmission 锁定执行许可账本的清点与登记(U3 admission)。
lockAdmission(tx *sql.Tx) error
// 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(嵌入式档)----
@@ -33,8 +28,6 @@ func (sqliteDialect) rebind(q string) string { return q }
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(多用户/多实例档)----
@@ -70,13 +63,3 @@ func (postgresDialect) lockAdmission(tx *sql.Tx) error {
_, err := tx.Exec(`SELECT pg_advisory_xact_lock(hashtext($1))`, "admission:exec-slots")
return err
}
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
}
@@ -1,70 +0,0 @@
package store
import (
core "agent-system-core"
"context"
"testing"
)
func TestAuthorizedKnowledgeFiltersBeforeTopK(t *testing.T) {
st := openTestStore(t)
docs := []core.KnowledgeDoc{}
for i := 0; i < 20; i++ {
docs = append(docs, core.KnowledgeDoc{Owner: "alice", ProjectID: "domain", Source: "private", Chunk: "sharedterm sharedterm sharedterm sharedterm"})
}
docs = append(docs, core.KnowledgeDoc{ID: "published", Owner: "alice", ProjectID: "domain", Source: "plugin:rdm:published", Chunk: "sharedterm plus additional explanatory words for this lower scoring published source"})
if err := st.AddKnowledge(docs...); err != nil {
t.Fatal(err)
}
hits, err := st.SearchKnowledgeSharedAuthorized("bob", core.RetrievalQuery{Text: "sharedterm", ProjectID: "domain", TopK: 1}, func(d core.KnowledgeDoc) bool { return d.ID == "published" })
if err != nil || len(hits) != 1 || hits[0].Doc.ID != "published" {
t.Fatalf("denied rows consumed top-k: %+v %v", hits, err)
}
}
func TestPluginProjectPublicationDeleteUsesExactScope(t *testing.T) {
st := openTestStore(t)
ctx := context.Background()
docs := []core.KnowledgeDoc{{ID: "unscoped", Owner: "alice", Source: "plugin:rdm", Ref: "same", Chunk: "unscoped"}, {ID: "scoped", Owner: "alice", ProjectID: "domain", Source: "plugin:rdm:published", Ref: "same", Chunk: "scoped"}, {ID: "foreign", Owner: "alice", Source: "plugin:other", Ref: "same", Chunk: "foreign"}, {ID: "other_owner", Owner: "bob", Source: "plugin:rdm", Ref: "same", Chunk: "other owner"}}
if err := st.AddKnowledge(docs...); err != nil {
t.Fatal(err)
}
for _, d := range docs {
if err := st.UpsertKnowledgeVector(ctx, d.Owner, d.ID, d.Ref, []float32{1, 0}); err != nil {
t.Fatal(err)
}
}
if err := st.DeletePluginProjectKnowledgeByRef(ctx, "alice", "", "same", "rdm"); err != nil {
t.Fatal(err)
}
for _, table := range []string{"knowledge", "knowledge_vectors"} {
var count int
if err := st.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count); err != nil || count != 3 {
t.Fatalf("exact empty-project namespace %s count=%d error=%v", table, count, err)
}
}
if err := st.DeletePluginProjectKnowledgeByRef(ctx, "alice", "domain", "same", "rdm"); err != nil {
t.Fatal(err)
}
for _, id := range []string{"foreign", "other_owner"} {
var count int
if err := st.db.QueryRow(st.q(`SELECT COUNT(*) FROM knowledge WHERE id=?`), id).Scan(&count); err != nil || count != 1 {
t.Fatalf("protected row %s count=%d error=%v", id, count, err)
}
}
}
func TestPluginPublicationNamespaceIsLiteral(t *testing.T) {
st := openTestStore(t)
docs := []core.KnowledgeDoc{{ID: "owned", Owner: "alice", Source: "plugin:rdm_%", Ref: "same", Chunk: "owned"}, {ID: "owned_suffix", Owner: "alice", Source: "plugin:rdm_%:published", Ref: "same", Chunk: "owned suffix"}, {ID: "protected", Owner: "alice", Source: "plugin:rdmXYZ:published", Ref: "same", Chunk: "protected"}}
if err := st.AddKnowledge(docs...); err != nil {
t.Fatal(err)
}
if err := st.DeletePluginProjectKnowledgeByRef(context.Background(), "alice", "", "same", "rdm_%"); err != nil {
t.Fatal(err)
}
var id string
if err := st.db.QueryRow(`SELECT id FROM knowledge`).Scan(&id); err != nil || id != "protected" {
t.Fatalf("wildcards broadened namespace: id=%s error=%v", id, err)
}
}
-521
View File
@@ -1,521 +0,0 @@
package store
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"errors"
"fmt"
"time"
)
// 一次性登录凭据(与长期 api_keys 分离)。明文前缀 alk_;库只存 SHA-256。
//
// 登录恢复流程(2026-10-13 合同):
// - 每次密码写入(恢复设密/主动改密/管理员与 CLI 重置)同事务 bump
// users.auth_version 并作废该用户全部会话;会话行持有创建时的 auth_version。
// - 密码登录建会话前绑定认证版本,堵「旧密码验证在途 → 恢复完成 → 旧请求
// 又建会话」竞态。
// - 签发/撤销/兑换/改密均与账户事件审计同事务(account_events,v28)。
// ErrBadCredentials 当前密码错误(主动改密路径)。
var ErrBadCredentials = errors.New("store: bad credentials")
const (
LoginKeyPurposeActivate = "activate"
LoginKeyPurposeRecover = "recover"
LoginKeyStatusUnused = "unused"
LoginKeyStatusConsumed = "consumed"
LoginKeyStatusRevoked = "revoked"
LoginKeyStatusExpired = "expired"
SessionScopeFull = "full"
SessionScopeSetPasswordOnly = "set_password_only"
defaultLoginKeyTTL = 30 * time.Minute
minLoginKeyTTL = 5 * time.Minute
maxLoginKeyTTL = 120 * time.Minute
restrictedSessionTTL = 10 * time.Minute
)
// LoginKeyMeta 对外可见元数据(无明文)。
type LoginKeyMeta struct {
ID string `json:"id"`
Owner string `json:"owner"`
Purpose string `json:"purpose"`
Status string `json:"status"`
ExpiresAt time.Time `json:"expiresAt"`
CreatedAt time.Time `json:"createdAt"`
CreatedBy string `json:"createdBy,omitempty"`
}
// AccountEvent 账户生命周期审计行(不含任何凭据材料)。
type AccountEvent struct {
ID string `json:"id"`
Time time.Time `json:"time"`
Actor string `json:"actor,omitempty"`
Entrance string `json:"entrance,omitempty"`
Owner string `json:"owner"`
Action string `json:"action"`
Detail string `json:"detail,omitempty"`
}
// dbExecer 让账户事件写入同时适用于连接与事务(与密码变更同事务提交)。
type dbExecer interface {
Exec(query string, args ...any) (sql.Result, error)
}
func (s *Store) insertAccountEvent(db dbExecer, actor, entrance, owner, action, detail string) error {
idRaw := make([]byte, 8)
if _, err := rand.Read(idRaw); err != nil {
return err
}
_, err := db.Exec(s.q(`INSERT INTO account_events(id,time,actor,entrance,owner,action,detail) VALUES(?,?,?,?,?,?,?)`),
"ae_"+hex.EncodeToString(idRaw), nowISO(), actor, entrance, owner, action, detail)
return err
}
// IssueLoginKey 签发一次性登录 Key。同事务:撤销同 owner+purpose 的 unused 旧 Key、
// 作废该用户既有 set_password_only 会话(补发使旧恢复流程立即失效)、写签发审计。
// 返回明文(仅此一次)与元数据;不改变账户密码、不吊销正常会话。
func (s *Store) IssueLoginKey(owner, purpose, createdBy string, ttl time.Duration) (plain string, meta LoginKeyMeta, err error) {
if purpose != LoginKeyPurposeActivate && purpose != LoginKeyPurposeRecover {
return "", meta, fmt.Errorf("login key: invalid purpose %q", purpose)
}
if _, err := s.GetUser(owner); err != nil {
return "", meta, err
}
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 "", meta, err
}
idRaw := make([]byte, 8)
if _, err := rand.Read(idRaw); err != nil {
return "", meta, err
}
plain = "alk_" + hex.EncodeToString(raw)
id := "lk_" + hex.EncodeToString(idRaw)
now := time.Now().UTC()
exp := now.Add(ttl)
tx, err := s.db.Begin()
if err != nil {
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
}
if err := tx.Commit(); err != nil {
return "", meta, err
}
return plain, meta, nil
}
// insertLoginKeyTx 在既有事务内签发:撤销旧 Key、作废受限会话、插入新 Key、审计。
// (BootstrapFirstAdmin 与建号同事务复用——KTD12:管理员行与激活 Key 共同提交。)
func (s *Store) insertLoginKeyTx(tx *sql.Tx, owner, purpose, createdBy, plain, id string, now, exp time.Time) (LoginKeyMeta, error) {
meta := LoginKeyMeta{}
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND purpose=? AND status=?`),
LoginKeyStatusRevoked, owner, purpose, LoginKeyStatusUnused); err != nil {
return meta, err
}
// 旧 Key 兑换出的受限会话一并作废:补发后只有最新 Key 能进入设密。
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND scope=?`), owner, SessionScopeSetPasswordOnly); err != nil {
return meta, err
}
if _, err := tx.Exec(s.q(`INSERT INTO login_keys(id,owner,purpose,key_hash,status,expires_at,created_at,created_by) VALUES(?,?,?,?,?,?,?,?)`),
id, owner, purpose, hashKey(plain), LoginKeyStatusUnused, exp.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano), createdBy); err != nil {
return meta, err
}
if err := s.insertAccountEvent(tx, createdBy, entranceOf(createdBy), owner, "login_key_issued", purpose+" "+id); err != nil {
return meta, err
}
return LoginKeyMeta{ID: id, Owner: owner, Purpose: purpose, Status: LoginKeyStatusUnused, ExpiresAt: exp, CreatedAt: now, CreatedBy: createdBy}, nil
}
// BootstrapFirstAdmin 首位管理员引导单事务:咨询锁串行(PG)→ 核验库内无用户 →
// 建管理员行 + 激活 Key + 审计共同提交;任何用户已存在返回 ErrConflict(拒绝接管)。
// SQLite 无咨询锁:插入后复查用户数恰为 1,双引导并发时后提交者回滚。
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
// 在窗口内的失败数是否已达上限(只读判定;失败由 RecordRedeemFailure 记录)。
func (s *Store) RedeemBlocked(ip string, max int, window time.Duration) (bool, error) {
cut := time.Now().Add(-window).UTC().Format(time.RFC3339Nano)
tx, err := s.db.Begin()
if err != nil {
return false, err
}
defer tx.Rollback()
if _, err := tx.Exec(s.q(`DELETE FROM redeem_attempts WHERE at < ?`), cut); err != nil {
return false, err
}
var n int
if err := tx.QueryRow(s.q(`SELECT COUNT(1) FROM redeem_attempts WHERE ip=?`), ip).Scan(&n); err != nil {
return false, err
}
return n >= max, tx.Commit()
}
// RecordRedeemFailure 记录一次兑换失败(限流窗口内累计)。
func (s *Store) RecordRedeemFailure(ip string) error {
_, err := s.db.Exec(s.q(`INSERT INTO redeem_attempts(ip, at) VALUES(?,?)`), ip, nowISO())
return err
}
// entranceOf 按签发者标注入口(审计可归因;cli 前缀归 CLI,其余归管理员 HTTP)。
func entranceOf(createdBy string) string {
if createdBy == "" {
return "api"
}
if createdBy == "cli" || createdBy == "cli-bootstrap" || (len(createdBy) > 4 && createdBy[:4] == "cli-") {
return "cli"
}
return "admin-http"
}
// RevokeLoginKey 按凭据编号撤销未使用 Key(CLI 既有语义,不动会话)。
func (s *Store) RevokeLoginKey(id string) error {
res, err := s.db.Exec(s.q(`UPDATE login_keys SET status=? WHERE id=? AND status=?`),
LoginKeyStatusRevoked, id, LoginKeyStatusUnused)
if err != nil {
return err
}
if n, _ := res.RowsAffected(); n == 0 {
return ErrNotFound
}
return nil
}
// 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
}
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
}
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND scope=?`), owner, SessionScopeSetPasswordOnly); err != nil {
return "", err
}
if err := s.insertAccountEvent(tx, actor, "admin-http", owner, "login_key_revoked", id); err != nil {
return "", err
}
return owner, tx.Commit()
}
// ListLoginKeys 属主凭据元数据(时间倒序;无明文)。
func (s *Store) ListLoginKeys(owner string) ([]LoginKeyMeta, error) {
rows, err := s.db.Query(s.q(`SELECT id,owner,purpose,status,expires_at,created_at,COALESCE(created_by,'') FROM login_keys WHERE owner=? ORDER BY created_at DESC LIMIT 50`), owner)
if err != nil {
return nil, err
}
defer rows.Close()
out := []LoginKeyMeta{}
for rows.Next() {
var m LoginKeyMeta
var exp, ca string
if err := rows.Scan(&m.ID, &m.Owner, &m.Purpose, &m.Status, &exp, &ca, &m.CreatedBy); err != nil {
return nil, err
}
m.ExpiresAt, _ = time.Parse(time.RFC3339Nano, exp)
m.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca)
out = append(out, m)
}
return out, rows.Err()
}
// ListAccountEvents 属主账户事件(时间倒序;诊断/审计展示用)。
func (s *Store) ListAccountEvents(owner string, limit int) ([]AccountEvent, error) {
if limit <= 0 || limit > 200 {
limit = 50
}
rows, err := s.db.Query(s.q(`SELECT id,time,COALESCE(actor,''),COALESCE(entrance,''),owner,action,COALESCE(detail,'') FROM account_events WHERE owner=? ORDER BY time DESC LIMIT ?`), owner, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []AccountEvent{}
for rows.Next() {
var e AccountEvent
var ts string
if err := rows.Scan(&e.ID, &ts, &e.Actor, &e.Entrance, &e.Owner, &e.Action, &e.Detail); err != nil {
return nil, err
}
e.Time, _ = time.Parse(time.RFC3339Nano, ts)
out = append(out, e)
}
return out, rows.Err()
}
// RedeemLoginKey 原子消费:unused+未过期 → consumed,并创建 set_password_only 会话
// (绑定账户当前 auth_version)。明文不得走 Bearer;调用方专用 HTTP 入口。失败整体回滚。
func (s *Store) RedeemLoginKey(plain string) (token string, owner string, err error) {
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)
if errors.Is(err, sql.ErrNoRows) {
return "", "", ErrNotFound
}
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)
_ = tx.Commit()
return "", "", ErrNotFound
}
if status != LoginKeyStatusUnused {
return "", "", ErrConflict
}
res, err := tx.Exec(s.q(`UPDATE login_keys SET status=?, consumed_at=? WHERE id=? AND status=?`),
LoginKeyStatusConsumed, nowISO(), id, LoginKeyStatusUnused)
if err != nil {
return "", "", err
}
if n, _ := res.RowsAffected(); n != 1 {
return "", "", ErrConflict
}
var authVersion int
err = tx.QueryRow(s.q(`SELECT auth_version FROM users WHERE owner=?`), o).Scan(&authVersion)
if err != nil {
return "", "", err // 账户已不存在:兑换失败整体回滚
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", "", err
}
token = "sess_" + hex.EncodeToString(raw)
sessExp := time.Now().Add(restrictedSessionTTL)
if _, err := tx.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version) VALUES(?,?,?,?,?,?,?)`),
hashKey(token), o, nowISO(), sessExp.UTC().Format(time.RFC3339Nano), nowISO(), SessionScopeSetPasswordOnly, authVersion); err != nil {
return "", "", err
}
if err := s.insertAccountEvent(tx, o, "login-key", o, "login_key_redeemed", id); err != nil {
return "", "", err
}
if err := tx.Commit(); err != nil {
return "", "", err
}
return token, o, nil
}
// CompleteSetPasswordChecked 设密事务(恢复/强制路径):在最终事务内复验会话
// (存在、未过期、scope 受限或账户仍处强制态、auth_version 未变),然后统一
// 写 hash、清 must_change、bump auth_version、作废未用登录 Key、删除全部会话、
// 写成功审计。会话或版本失效时返回 ErrConflict,不做任何写入。
func (s *Store) CompleteSetPasswordChecked(token, passwordHash string) (owner string, err error) {
// 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
}
return lockOwner, nil
}
// ChangeUserPasswordActive 主动改密单事务:校验当前密码 → 更新(带认证版本乐观
// 检查)→ bump auth_version → 吊销其他会话(保留当前)→ 审计。中途失败无部分成功。
func (s *Store) ChangeUserPasswordActive(owner, current, passwordHash, keepToken string) error {
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
}
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 {
if entrance == "" {
entrance = entranceOf(actor)
}
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 展示)。
func (s *Store) CountUnusedLoginKeys(owner string) (int, error) {
var n int
err := s.db.QueryRow(s.q(`SELECT COUNT(1) FROM login_keys WHERE owner=? AND status=?`), owner, LoginKeyStatusUnused).Scan(&n)
return n, err
}
// RevokeAllAPIKeys 失陷恢复:吊销 owner 全部长期 API Key。
func (s *Store) RevokeAllAPIKeys(owner string) error {
_, err := s.db.Exec(s.q(`UPDATE api_keys SET revoked=1 WHERE owner=? AND revoked=0`), owner)
return err
}
-362
View File
@@ -1,362 +0,0 @@
package store
import (
"path/filepath"
"sync"
"sync/atomic"
"testing"
"time"
)
func TestOpenExistingSQLiteMissing(t *testing.T) {
_, err := OpenExistingSQLite(filepath.Join(t.TempDir(), "nope.db"))
if err == nil {
t.Fatal("expected missing file error")
}
}
func TestLoginKeyRedeemOnceAndSetPassword(t *testing.T) {
st, err := OpenSQLite(filepath.Join(t.TempDir(), "t.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.CreateAccount("alice", "Alice", "", false); err != nil {
t.Fatal(err)
}
plain, meta, err := st.IssueLoginKey("alice", LoginKeyPurposeActivate, "test", 0)
if err != nil || plain == "" || meta.ID == "" {
t.Fatalf("issue: %v meta=%+v", err, meta)
}
tok, owner, err := st.RedeemLoginKey(plain)
if err != nil || owner != "alice" || tok == "" {
t.Fatalf("redeem: %v owner=%s", err, owner)
}
if _, _, err := st.RedeemLoginKey(plain); err == nil {
t.Fatal("replay should fail")
}
o, scope, ok := st.SessionInfo(tok)
if !ok || o != "alice" || scope != SessionScopeSetPasswordOnly {
t.Fatalf("session info %v %q %q", ok, o, scope)
}
hash, err := HashPassword("new-password-9")
if err != nil {
t.Fatal(err)
}
if _, err := st.CompleteSetPasswordChecked(tok, hash); err != nil {
t.Fatal(err)
}
if _, _, ok := st.SessionInfo(tok); ok {
t.Fatal("session should be gone after set password")
}
stAuth, ok := st.VerifyLogin("alice", "new-password-9")
if !ok || stAuth.MustChangePassword || !stAuth.HasPassword {
t.Fatalf("login after set: ok=%v %+v", ok, stAuth)
}
events, err := st.ListAccountEvents("alice", 10)
if err != nil {
t.Fatal(err)
}
actions := map[string]bool{}
for _, e := range events {
actions[e.Action] = true
}
if !actions["login_key_issued"] || !actions["login_key_redeemed"] || !actions["password_set"] {
t.Fatalf("missing account events: %+v", events)
}
}
func TestLoginKeyConcurrentRedeem(t *testing.T) {
st, err := OpenSQLite(filepath.Join(t.TempDir(), "c.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.CreateAccount("bob", "", "", false); err != nil {
t.Fatal(err)
}
plain, _, err := st.IssueLoginKey("bob", LoginKeyPurposeRecover, "test", 0)
if err != nil {
t.Fatal(err)
}
var okCount atomic.Int32
var wg sync.WaitGroup
for i := 0; i < 100; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if _, _, err := st.RedeemLoginKey(plain); err == nil {
okCount.Add(1)
}
}()
}
wg.Wait()
if n := okCount.Load(); n != 1 {
t.Fatalf("expected 1 success, got %d", n)
}
}
// 补发:旧 Key 未用则撤销;已兑换出的受限会话一并作废,最新 Key 才能设密。
func TestIssueLoginKeyRevokesDerivedSessions(t *testing.T) {
st, err := OpenSQLite(filepath.Join(t.TempDir(), "reissue.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.CreateAccount("carol", "", "", false); err != nil {
t.Fatal(err)
}
old1, _, err := st.IssueLoginKey("carol", LoginKeyPurposeRecover, "test", 0)
if err != nil {
t.Fatal(err)
}
oldTok, _, err := st.RedeemLoginKey(old1)
if err != nil {
t.Fatal(err)
}
old2, _, err := st.IssueLoginKey("carol", LoginKeyPurposeRecover, "test", 0)
if err != nil {
t.Fatal(err)
}
// 旧 Key 的受限会话已作废。
if _, _, ok := st.SessionInfo(oldTok); ok {
t.Fatal("restricted session from superseded key should be revoked")
}
// 旧 Key 未用场景同样撤销(old1 已消费,此处验证 old2 补发路径)。
new1, _, err := st.IssueLoginKey("carol", LoginKeyPurposeRecover, "test", 0)
if err != nil {
t.Fatal(err)
}
if _, _, err := st.RedeemLoginKey(old2); err == nil {
t.Fatal("revoked (superseded) key must not redeem")
}
tok, _, err := st.RedeemLoginKey(new1)
if err != nil {
t.Fatal(err)
}
hash, err := HashPassword("carol-final-99")
if err != nil {
t.Fatal(err)
}
if _, err := st.CompleteSetPasswordChecked(tok, hash); err != nil {
t.Fatal(err)
}
}
// 改密事务内复验:会话被作废后不得继续设密;正常受限会话设密后认证版本前进。
func TestCompleteSetPasswordRevalidatesSession(t *testing.T) {
st, err := OpenSQLite(filepath.Join(t.TempDir(), "revalidate.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.CreateAccount("dave", "", "", false); err != nil {
t.Fatal(err)
}
plain, _, err := st.IssueLoginKey("dave", LoginKeyPurposeRecover, "test", 0)
if err != nil {
t.Fatal(err)
}
tok, _, err := st.RedeemLoginKey(plain)
if err != nil {
t.Fatal(err)
}
// 会话被签发补发作废后,设密必须失败。
if _, _, err := st.IssueLoginKey("dave", LoginKeyPurposeRecover, "test", 0); err != nil {
t.Fatal(err)
}
hash, err := HashPassword("dave-final-99")
if err != nil {
t.Fatal(err)
}
if _, err := st.CompleteSetPasswordChecked(tok, hash); err == nil {
t.Fatal("set password with revoked session must fail")
}
stAuth, _ := st.GetUserAuthState("dave")
if stAuth.HasPassword {
t.Fatal("password must be unchanged after rejected set")
}
// 新 Key 兑换出的会话可以设密,且版本前进、旧密码不适用(未设密前无旧密码)。
plain2, _, err := st.IssueLoginKey("dave", LoginKeyPurposeRecover, "test", 0)
if err != nil {
t.Fatal(err)
}
tok2, _, err := st.RedeemLoginKey(plain2)
if err != nil {
t.Fatal(err)
}
if _, err := st.CompleteSetPasswordChecked(tok2, hash); err != nil {
t.Fatal(err)
}
if _, _, ok := st.SessionInfo(tok2); ok {
t.Fatal("all sessions must be destroyed after set")
}
}
// 主动改密:错旧密码拒绝;并发版本竞争单胜;当前会话保留、其他吊销。
func TestChangeUserPasswordActive(t *testing.T) {
st, err := OpenSQLite(filepath.Join(t.TempDir(), "active.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
hash, err := HashPassword("original-pass-1")
if err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("erin", "", hash, false); err != nil {
t.Fatal(err)
}
if err := st.ClearMustChangePassword("erin"); err != nil {
t.Fatal(err)
}
keepTok, err := st.CreateSession("erin")
if err != nil {
t.Fatal(err)
}
otherTok, err := st.CreateSession("erin")
if err != nil {
t.Fatal(err)
}
newHash, err := HashPassword("rotated-pass-2")
if err != nil {
t.Fatal(err)
}
if err := st.ChangeUserPasswordActive("erin", "wrong-old-pass", newHash, keepTok); err != ErrBadCredentials {
t.Fatalf("wrong current: %v", err)
}
if err := st.ChangeUserPasswordActive("erin", "original-pass-1", newHash, keepTok); err != nil {
t.Fatal(err)
}
if _, _, ok := st.SessionInfo(keepTok); !ok {
t.Fatal("current session must survive active change")
}
if _, _, ok := st.SessionInfo(otherTok); ok {
t.Fatal("other sessions must be revoked")
}
stAuth, ok := st.VerifyLogin("erin", "rotated-pass-2")
if !ok || stAuth.MustChangePassword {
t.Fatalf("login with new password: ok=%v %+v", ok, stAuth)
}
if _, ok := st.VerifyLogin("erin", "original-pass-1"); ok {
t.Fatal("old password must fail after change")
}
}
// 登录竞态:密码验证与建会话之间账户被重置(版本前进)时,会话创建必须失败。
func TestLoginRaceBlockedByAuthVersion(t *testing.T) {
st, err := OpenSQLite(filepath.Join(t.TempDir(), "race.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
hash, err := HashPassword("race-old-pass")
if err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("frank", "", hash, false); err != nil {
t.Fatal(err)
}
if err := st.ClearMustChangePassword("frank"); err != nil {
t.Fatal(err)
}
_, ver, ok := st.VerifyLoginWithVersion("frank", "race-old-pass")
if !ok {
t.Fatal("verify should pass before reset")
}
resetHash, err := HashPassword("race-new-pass")
if err != nil {
t.Fatal(err)
}
if err := st.AdminResetUserPassword("frank", resetHash, "admin", "admin-http"); err != nil {
t.Fatal(err)
}
if _, err := st.CreateSessionVerified("frank", SessionScopeFull, time.Minute, ver); err != ErrConflict {
t.Fatalf("stale version session creation: %v", err)
}
// 新密码 + 新版本可建会话。
_, ver2, ok := st.VerifyLoginWithVersion("frank", "race-new-pass")
if !ok {
t.Fatal("verify with new password should pass")
}
if ver2 == ver {
t.Fatal("auth version should have advanced after reset")
}
if _, err := st.CreateSessionVerified("frank", SessionScopeFull, time.Minute, ver2); err != nil {
t.Fatalf("fresh version session creation: %v", err)
}
}
// 管理员重置:版本前进、会话与未用 Key 全部作废、must_change 置位。
func TestAdminResetUserPasswordInvalidates(t *testing.T) {
st, err := OpenSQLite(filepath.Join(t.TempDir(), "adminreset.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
hash, err := HashPassword("admin-old-pass")
if err != nil {
t.Fatal(err)
}
if err := st.CreateAccount("gina", "", hash, false); err != nil {
t.Fatal(err)
}
if err := st.ClearMustChangePassword("gina"); err != nil {
t.Fatal(err)
}
tok, err := st.CreateSession("gina")
if err != nil {
t.Fatal(err)
}
plain, _, err := st.IssueLoginKey("gina", LoginKeyPurposeRecover, "test", 0)
if err != nil {
t.Fatal(err)
}
newHash, err := HashPassword("admin-new-pass")
if err != nil {
t.Fatal(err)
}
if err := st.AdminResetUserPassword("gina", newHash, "admin", "admin-http"); err != nil {
t.Fatal(err)
}
if _, _, ok := st.SessionInfo(tok); ok {
t.Fatal("sessions must be revoked after admin reset")
}
if _, _, err := st.RedeemLoginKey(plain); err == nil {
t.Fatal("unused login key must be revoked after admin reset")
}
stAuth, ok := st.VerifyLogin("gina", "admin-new-pass")
if !ok || !stAuth.MustChangePassword {
t.Fatalf("reset login state: ok=%v %+v", ok, stAuth)
}
}
// RevokeLoginKeyCascade:撤销 Key 并作废该用户受限会话;Key 元数据保留可见。
func TestRevokeLoginKeyCascade(t *testing.T) {
st, err := OpenSQLite(filepath.Join(t.TempDir(), "cascade.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.CreateAccount("hana", "", "", false); err != nil {
t.Fatal(err)
}
plain, meta, err := st.IssueLoginKey("hana", LoginKeyPurposeRecover, "test", 0)
if err != nil {
t.Fatal(err)
}
tok, _, err := st.RedeemLoginKey(plain)
if err != nil {
t.Fatal(err)
}
if _, err := st.RevokeLoginKeyCascade(meta.ID, "admin"); err != nil {
t.Fatal(err)
}
if _, _, ok := st.SessionInfo(tok); ok {
t.Fatal("restricted session must be revoked by cascade")
}
keys, err := st.ListLoginKeys("hana")
if err != nil || len(keys) == 0 || keys[0].Status != LoginKeyStatusConsumed {
t.Fatalf("key list after cascade: %v %+v", err, keys)
}
}
-11
View File
@@ -266,10 +266,6 @@ func (s *Store) ChildRuns(runID string) ([]core.Run, error) {
// 成功后回填 evs[i].Seq。事务内锁定会话序列:PG 用事务级咨询锁(跨实例安全),
// SQLite 单写连接天然串行。空批次为空操作。
func (s *Store) AppendEvents(conversationID string, evs []core.StreamEvent) (int64, error) {
return s.appendEvents(conversationID, evs, "", "")
}
func (s *Store) appendEvents(conversationID string, evs []core.StreamEvent, owner, producer string) (int64, error) {
if len(evs) == 0 {
return 0, nil
}
@@ -302,13 +298,6 @@ func (s *Store) appendEvents(conversationID string, evs []core.StreamEvent, owne
if _, err := tx.Exec(s.q(sb.String()), args...); err != nil {
return 0, err
}
if producer != "" {
for i, e := range evs {
if err := s.saveProjectEventProof(tx, conversationID, base.Int64+int64(i)+1, e, owner, producer); err != nil {
return 0, err
}
}
}
// KTD6:事件提交与 NOTIFY 同事务(outbox 语义的前半——通知不早于事件
// 可见;后半的命令 outbox 走 EnqueueCommandInTx 由批准路径使用)。
if s.dialect.name() == "postgres" {
+1 -137
View File
@@ -712,52 +712,9 @@ func init() {
`ALTER TABLE knowledge_vectors DROP COLUMN content_hash`,
`ALTER TABLE knowledge_vectors DROP COLUMN model`,
}}},
migration{version: 26, name: "login_keys_session_scope",
// 一次性登录 Key(激活/恢复)与会话 scope(full | set_password_only)。
up: map[string][]string{"sqlite": {
`CREATE TABLE IF NOT EXISTS login_keys (
id TEXT PRIMARY KEY,
owner TEXT NOT NULL,
purpose TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
status TEXT NOT NULL,
expires_at TEXT NOT NULL,
consumed_at TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
created_by TEXT NOT NULL DEFAULT '',
instance_id TEXT NOT NULL DEFAULT ''
)`,
`CREATE INDEX IF NOT EXISTS idx_login_keys_owner_purpose ON login_keys(owner, purpose, status)`,
`ALTER TABLE sessions ADD COLUMN scope TEXT NOT NULL DEFAULT 'full'`,
}, "postgres": {
`CREATE TABLE IF NOT EXISTS login_keys (
id TEXT PRIMARY KEY,
owner TEXT NOT NULL,
purpose TEXT NOT NULL,
key_hash TEXT NOT NULL UNIQUE,
status TEXT NOT NULL,
expires_at TEXT NOT NULL,
consumed_at TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
created_by TEXT NOT NULL DEFAULT '',
instance_id TEXT NOT NULL DEFAULT ''
)`,
`CREATE INDEX IF NOT EXISTS idx_login_keys_owner_purpose ON login_keys(owner, purpose, status)`,
`ALTER TABLE sessions ADD COLUMN scope TEXT NOT NULL DEFAULT 'full'`,
}},
down: map[string][]string{"sqlite": {
`ALTER TABLE sessions DROP COLUMN scope`,
`DROP INDEX IF EXISTS idx_login_keys_owner_purpose`,
`DROP TABLE IF EXISTS login_keys`,
}, "postgres": {
`ALTER TABLE sessions DROP COLUMN scope`,
`DROP INDEX IF EXISTS idx_login_keys_owner_purpose`,
`DROP TABLE IF EXISTS login_keys`,
}}},
// U1 I3:服务实例 DB 身份 marker(设计 docs/recovery/u1-identity-manifest.md §5.2)。
// 全表至多一行由 Ensure/Verify 强制;与 runs.instance_id(进程标识)无关。
// v26 已被 login_keys 占用,本表为 v27。
migration{version: 27, name: "agent_system_identity",
migration{version: 26, name: "agent_system_identity",
up: map[string][]string{"sqlite": {
`CREATE TABLE IF NOT EXISTS agent_system_identity (
instance_id TEXT NOT NULL PRIMARY KEY,
@@ -778,99 +735,6 @@ func init() {
}, "postgres": {
`DROP TABLE IF EXISTS agent_system_identity`,
}}},
// 登录恢复流程(2026-10-13):认证版本与会话绑定,堵「旧密码验证在途 →
// 恢复完成 → 旧请求又建会话」竞态;账户事件审计与密码变更同事务落库。
migration{version: 28, name: "auth_version_account_events",
up: map[string][]string{"sqlite": {
`ALTER TABLE users ADD COLUMN auth_version INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE sessions ADD COLUMN auth_version INTEGER NOT NULL DEFAULT 0`,
`CREATE TABLE IF NOT EXISTS account_events (
id TEXT PRIMARY KEY,
time TEXT NOT NULL,
actor TEXT NOT NULL DEFAULT '',
entrance TEXT NOT NULL DEFAULT '',
owner TEXT NOT NULL,
action TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT ''
)`,
`CREATE INDEX IF NOT EXISTS idx_account_events_owner_time ON account_events(owner, time)`,
}, "postgres": {
`ALTER TABLE users ADD COLUMN auth_version INTEGER NOT NULL DEFAULT 0`,
`ALTER TABLE sessions ADD COLUMN auth_version INTEGER NOT NULL DEFAULT 0`,
`CREATE TABLE IF NOT EXISTS account_events (
id TEXT PRIMARY KEY,
time TEXT NOT NULL,
actor TEXT NOT NULL DEFAULT '',
entrance TEXT NOT NULL DEFAULT '',
owner TEXT NOT NULL,
action TEXT NOT NULL,
detail TEXT NOT NULL DEFAULT ''
)`,
`CREATE INDEX IF NOT EXISTS idx_account_events_owner_time ON account_events(owner, time)`,
}},
down: map[string][]string{"sqlite": {
`DROP INDEX IF EXISTS idx_account_events_owner_time`,
`DROP TABLE IF EXISTS account_events`,
`ALTER TABLE sessions DROP COLUMN auth_version`,
`ALTER TABLE users DROP COLUMN auth_version`,
}, "postgres": {
`DROP INDEX IF EXISTS idx_account_events_owner_time`,
`DROP TABLE IF EXISTS account_events`,
`ALTER TABLE sessions DROP COLUMN auth_version`,
`ALTER TABLE users DROP COLUMN auth_version`,
}}},
// 登录恢复收尾(2026-10-13):兑换入口的跨实例失败退避(共享库窗口计数)。
migration{version: 29, name: "redeem_attempts",
up: map[string][]string{"sqlite": {
`CREATE TABLE IF NOT EXISTS redeem_attempts (
ip TEXT NOT NULL,
at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_redeem_attempts_ip_at ON redeem_attempts(ip, at)`,
}, "postgres": {
`CREATE TABLE IF NOT EXISTS redeem_attempts (
ip TEXT NOT NULL,
at TEXT NOT NULL
)`,
`CREATE INDEX IF NOT EXISTS idx_redeem_attempts_ip_at ON redeem_attempts(ip, at)`,
}},
down: map[string][]string{"sqlite": {
`DROP INDEX IF EXISTS idx_redeem_attempts_ip_at`,
`DROP TABLE IF EXISTS redeem_attempts`,
}, "postgres": {
`DROP INDEX IF EXISTS idx_redeem_attempts_ip_at`,
`DROP TABLE IF EXISTS redeem_attempts`,
}}},
migration{version: 30, name: "project_event_publication_proofs",
up: map[string][]string{
"sqlite": {`CREATE TABLE project_event_shares(channel TEXT NOT NULL,seq INTEGER NOT NULL,event_type TEXT NOT NULL,payload_sha256 TEXT NOT NULL,owner TEXT NOT NULL,resource_type TEXT NOT NULL,resource_id TEXT NOT NULL,producer TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(channel,seq),FOREIGN KEY(channel,seq) REFERENCES events(conversation_id,seq) ON DELETE CASCADE)`},
"postgres": {`CREATE TABLE project_event_shares(channel TEXT NOT NULL,seq BIGINT NOT NULL,event_type TEXT NOT NULL,payload_sha256 TEXT NOT NULL,owner TEXT NOT NULL,resource_type TEXT NOT NULL,resource_id TEXT NOT NULL,producer TEXT NOT NULL,created_at TEXT NOT NULL,PRIMARY KEY(channel,seq),FOREIGN KEY(channel,seq) REFERENCES events(conversation_id,seq) ON DELETE CASCADE)`},
},
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`,
}}},
)
}
-53
View File
@@ -1,53 +0,0 @@
package store
// migrationBreaksOldReaders 登记每个迁移版本落地后,schema.target 更低的旧制品
// 还能不能读这个库。新增迁移漏登记时 TestMigrationCompatRegistered 失败。
// true 会抬高 SchemaCompatFloor:候选若要把库迁过该版本,而旧制品的 target
// 低于该下限,切换必须在停旧服务前拒绝,除非走已确认的 forward-only 迁移。
var migrationBreaksOldReaders = map[int]bool{
1: false,
2: false,
3: false,
4: false,
5: false,
6: false,
7: false,
8: false,
9: false,
10: false,
11: false,
12: false,
13: false,
14: false,
15: false,
16: false,
17: false,
18: false,
19: false,
20: false,
21: false,
22: false,
23: false,
24: false,
25: false,
26: false,
27: false,
28: false, // auth_version_account_events:仅加列与新表,旧读者不受影响
29: false, // redeem_attempts:新表,旧读者不受影响
30: false, // project_event_publication_proofs:新表,旧读者不受影响
31: false, // account_audit 给 audit_events 增加带默认值的列,旧制品按列名读取
}
// SchemaTarget 是本二进制会迁移到的 schema 版本。
func SchemaTarget() int { return latestMigrationVersion() }
// SchemaCompatFloor 是旧制品仍能读取的最高破坏性版本。0 表示当前全部迁移都不破坏旧读。
func SchemaCompatFloor() int {
floor := 0
for version, breaks := range migrationBreaksOldReaders {
if breaks && version > floor {
floor = version
}
}
return floor
}
-20
View File
@@ -1,20 +0,0 @@
package store
import "testing"
func TestMigrationCompatRegistered(t *testing.T) {
if len(migrationBreaksOldReaders) != len(migrations) {
t.Fatalf("兼容登记 %d 条,迁移 %d 条", len(migrationBreaksOldReaders), len(migrations))
}
for _, m := range migrations {
if _, ok := migrationBreaksOldReaders[m.version]; !ok {
t.Fatalf("迁移 v%d %s 未登记是否破坏旧制品读取", m.version, m.name)
}
}
if SchemaTarget() != latestMigrationVersion() {
t.Fatalf("target=%d latest=%d", SchemaTarget(), latestMigrationVersion())
}
if SchemaCompatFloor() < 0 || SchemaCompatFloor() > SchemaTarget() {
t.Fatalf("compatFloor=%d target=%d", SchemaCompatFloor(), SchemaTarget())
}
}
-147
View File
@@ -1,147 +0,0 @@
package store
import (
core "agent-system-core"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
)
// ProjectEventAccess is trusted read metadata; payload claims never establish it.
type ProjectEventAccess struct {
Owner string
Shared bool
Producer string
}
type eventResource struct{ owner, project, conversation, kind, id string }
type eventQuery interface{ QueryRow(string, ...any) *sql.Row }
func cardEventID(e core.StreamEvent) (string, error) {
switch e.Type {
case core.EvCardCreated, core.EvCardUpdated, core.EvCardStatus, core.EvCardResolved:
default:
return "", nil
}
var payload struct {
Card struct {
ID string `json:"id"`
} `json:"card"`
CardID string `json:"cardId"`
}
if err := json.Unmarshal(e.Payload, &payload); err != nil {
return "", err
}
switch e.Type {
case core.EvCardCreated, core.EvCardUpdated:
return payload.Card.ID, nil
case core.EvCardStatus, core.EvCardResolved:
return payload.CardID, nil
}
return "", nil
}
func eventPayloadHash(payload []byte) string {
hash := sha256.Sum256(payload)
return hex.EncodeToString(hash[:])
}
func (s *Store) projectEventResource(q eventQuery, e core.StreamEvent) (eventResource, error) {
var resource eventResource
id, err := cardEventID(e)
if err != nil {
return resource, err
}
switch e.Type {
case core.EvCardCreated, core.EvCardUpdated, core.EvCardStatus, core.EvCardResolved:
if id == "" {
return resource, ErrNotFound
}
}
if id != "" {
resource.kind, resource.id = "card", id
err = q.QueryRow(s.q(`SELECT owner,project_id,conversation_id FROM activity_cards WHERE id=?`), id).Scan(&resource.owner, &resource.project, &resource.conversation)
} else if e.Type == core.EvArtifactCreated {
var payload struct {
Artifact struct {
ID string `json:"id"`
} `json:"artifact"`
}
if err := json.Unmarshal(e.Payload, &payload); err != nil {
return resource, err
}
if payload.Artifact.ID == "" {
return resource, ErrNotFound
}
resource.kind, resource.id = "artifact", payload.Artifact.ID
err = q.QueryRow(s.q(`SELECT owner,project_id FROM artifacts WHERE id=?`), resource.id).Scan(&resource.owner, &resource.project)
} else if e.RunID != "" {
resource.kind, resource.id = "run", e.RunID
err = q.QueryRow(s.q(`SELECT owner,project_id,conversation_id FROM runs WHERE id=?`), e.RunID).Scan(&resource.owner, &resource.project, &resource.conversation)
} else {
return resource, ErrNotFound
}
if errors.Is(err, sql.ErrNoRows) {
return resource, ErrNotFound
}
if err != nil {
return resource, err
}
project, ok := core.ProjectFromChannel(e.ConversationID)
if !ok || resource.owner == "" || resource.project != project || (e.ProjectID != "" && e.ProjectID != project) {
return resource, ErrNotFound
}
return resource, nil
}
// ResolveProjectEventAccess resolves immutable resource ownership and verifies an
// exact internal publication record. Old events retain their private default.
func (s *Store) ResolveProjectEventAccess(e core.StreamEvent) (ProjectEventAccess, error) {
resource, err := s.projectEventResource(s.db, e)
if err != nil {
return ProjectEventAccess{}, err
}
access := ProjectEventAccess{Owner: resource.owner}
var owner, kind, id, producer, typ, hash string
err = s.db.QueryRow(s.q(`SELECT owner,resource_type,resource_id,producer,event_type,payload_sha256 FROM project_event_shares WHERE channel=? AND seq=?`), e.ConversationID, e.Seq).Scan(&owner, &kind, &id, &producer, &typ, &hash)
if errors.Is(err, sql.ErrNoRows) {
return access, nil
}
if err != nil {
return ProjectEventAccess{}, err
}
if owner != resource.owner || kind != resource.kind || id != resource.id || strings.TrimSpace(producer) == "" || typ != string(e.Type) || hash != eventPayloadHash(e.Payload) || resource.conversation != "" {
return ProjectEventAccess{}, ErrNotFound
}
access.Shared, access.Producer = true, producer
return access, nil
}
// AppendProjectSharedEvents is an internal publication boundary, not an API or
// model tool. Events and their exact sharing proofs commit together. Caller
// identity must own a project-level card; private conversation cards cannot be
// published through this structural-card path.
func (s *Store) AppendProjectSharedEvents(owner, producer, channel string, events []core.StreamEvent) (int64, error) {
if owner == "" || strings.TrimSpace(producer) == "" {
return 0, fmt.Errorf("store: shared event requires owner and producer")
}
if _, ok := core.ProjectFromChannel(channel); !ok {
return 0, ErrNotFound
}
return s.appendEvents(channel, events, owner, producer)
}
func (s *Store) saveProjectEventProof(tx *sql.Tx, channel string, seq int64, e core.StreamEvent, owner, producer string) error {
e.ConversationID = channel
resource, err := s.projectEventResource(tx, e)
if err != nil {
return err
}
if resource.kind != "card" || resource.owner != owner || resource.conversation != "" {
return ErrNotFound
}
_, err = tx.Exec(s.q(`INSERT INTO project_event_shares(channel,seq,event_type,payload_sha256,owner,resource_type,resource_id,producer,created_at) VALUES(?,?,?,?,?,?,?,?,?)`), channel, seq, string(e.Type), eventPayloadHash(e.Payload), resource.owner, resource.kind, resource.id, producer, nowISO())
return err
}
-140
View File
@@ -1,140 +0,0 @@
package store
import (
core "agent-system-core"
"encoding/json"
"testing"
"time"
)
func storedProjectCardEvent(t *testing.T, st *Store, owner, project, id string) core.StreamEvent {
t.Helper()
card := core.ActivityCard{ID: id, ProjectID: project, Title: "PRIVATE", CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC()}
if err := st.SaveCard(owner, &card, ""); err != nil {
t.Fatal(err)
}
payload, _ := json.Marshal(core.CardPayload{Card: card})
return core.StreamEvent{ConversationID: core.ProjectChannelKey(project), ProjectID: project, Type: core.EvCardCreated, Payload: payload, TS: time.Now().UTC().Format(time.RFC3339Nano), V: 1}
}
func TestProjectEventSharedProofBindsExactEvent(t *testing.T) {
st := openTestStore(t)
p, _ := st.CreateProject("alice", "Project")
e := storedProjectCardEvent(t, st, "alice", p.ID, "card-a")
private := []core.StreamEvent{e}
if _, err := st.AppendEvents(e.ConversationID, private); err != nil {
t.Fatal(err)
}
access, err := st.ResolveProjectEventAccess(private[0])
if err != nil || access.Owner != "alice" || access.Shared {
t.Fatalf("private access=%+v %v", access, err)
}
shared := []core.StreamEvent{e}
if _, err := st.AppendProjectSharedEvents("alice", "plugin:rdm:structure", e.ConversationID, shared); err != nil {
t.Fatal(err)
}
access, err = st.ResolveProjectEventAccess(shared[0])
if err != nil || !access.Shared || access.Owner != "alice" {
t.Fatalf("shared proof=%+v %v", access, err)
}
access, err = st.ResolveProjectEventAccess(private[0])
if err != nil || access.Shared {
t.Fatal("publication retroactively shared private history")
}
for _, mutate := range []func(*core.StreamEvent){func(e *core.StreamEvent) { e.Type = core.EvCardUpdated }, func(e *core.StreamEvent) { e.Payload = append(e.Payload, ' ') }, func(e *core.StreamEvent) { e.Seq += 100 }, func(e *core.StreamEvent) { e.ConversationID = "project:another" }, func(e *core.StreamEvent) { e.ProjectID = "another" }} {
changed := shared[0]
mutate(&changed)
access, err = st.ResolveProjectEventAccess(changed)
if err == nil && access.Shared {
t.Fatal("mutated event retained sharing")
}
}
bad := []core.StreamEvent{e}
before, _ := st.LastEventSeq(e.ConversationID)
if _, err := st.AppendProjectSharedEvents("bob", "plugin:rdm:structure", e.ConversationID, bad); err == nil {
t.Fatal("other owner issued publication")
}
after, _ := st.LastEventSeq(e.ConversationID)
if after != before {
t.Fatal("rejected publication appended event")
}
}
func TestProjectEventMigrationPreservesPrivateHistory(t *testing.T) {
st := openTestStore(t)
if err := st.MigrateDown(29); err != nil {
t.Fatal(err)
}
p, _ := st.CreateProject("alice", "Legacy")
e := storedProjectCardEvent(t, st, "alice", p.ID, "legacy-card")
events := []core.StreamEvent{e}
if _, err := st.AppendEvents(e.ConversationID, events); err != nil {
t.Fatal(err)
}
before, _ := st.EventsAfter(e.ConversationID, 0, 100)
if err := st.migrate(); err != nil {
t.Fatal(err)
}
after, _ := st.EventsAfter(e.ConversationID, 0, 100)
b, _ := json.Marshal(before)
a, _ := json.Marshal(after)
if string(a) != string(b) {
t.Fatal("upgrade changed private event history")
}
access, err := st.ResolveProjectEventAccess(after[0])
if err != nil || access.Owner != "alice" || access.Shared {
t.Fatalf("legacy privacy=%+v %v", access, err)
}
}
func TestProjectSharedEventBatchRollsBackProofAndEvents(t *testing.T) {
st := openTestStore(t)
p, _ := st.CreateProject("alice", "Project")
a := storedProjectCardEvent(t, st, "alice", p.ID, "card-a")
b := storedProjectCardEvent(t, st, "bob", p.ID, "card-b")
if _, err := st.AppendProjectSharedEvents("alice", "plugin:rdm:structure", a.ConversationID, []core.StreamEvent{a, b}); err == nil {
t.Fatal("mixed owner batch published")
}
var count int
for _, table := range []string{"events", "project_event_shares"} {
if err := st.db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&count); err != nil || count != 0 {
t.Fatalf("partial %s publication: count=%d error=%v", table, count, err)
}
}
}
func TestProjectEventInvalidProofAndMissingOwnerFailClosed(t *testing.T) {
st := openTestStore(t)
p, _ := st.CreateProject("alice", "Project")
e := storedProjectCardEvent(t, st, "alice", p.ID, "card-a")
shared := []core.StreamEvent{e}
if _, err := st.AppendProjectSharedEvents("alice", "plugin:rdm:structure", e.ConversationID, shared); err != nil {
t.Fatal(err)
}
if _, err := st.db.Exec(`UPDATE project_event_shares SET producer=''`); err != nil {
t.Fatal(err)
}
if access, err := st.ResolveProjectEventAccess(shared[0]); err == nil || access.Shared {
t.Fatal("corrupted publication proof was accepted")
}
if _, err := st.db.Exec(`UPDATE activity_cards SET owner=''`); err != nil {
t.Fatal(err)
}
if _, err := st.ResolveProjectEventAccess(shared[0]); err == nil {
t.Fatal("missing resource owner was accepted")
}
}
func TestProjectSharingRejectsPrivateConversationCards(t *testing.T) {
st := openTestStore(t)
p, _ := st.CreateProject("alice", "Project")
e := storedProjectCardEvent(t, st, "alice", p.ID, "card-a")
if _, err := st.db.Exec(`UPDATE activity_cards SET conversation_id='private-conversation'`); err != nil {
t.Fatal(err)
}
if _, err := st.AppendProjectSharedEvents("alice", "plugin:rdm:structure", e.ConversationID, []core.StreamEvent{e}); err == nil {
t.Fatal("private conversation card publicly marked")
}
events, err := st.EventsAfter(e.ConversationID, 0, 100)
if err != nil || len(events) != 0 {
t.Fatal("rejected conversation publication appended events")
}
}
-149
View File
@@ -1,149 +0,0 @@
package store
import (
core "agent-system-core"
"database/sql"
"errors"
"strings"
"time"
)
// lockProjectNames serializes projection identity and name admission across all
// owners. SQLite acquires its write reservation before any reads; PostgreSQL
// uses a transaction-scoped advisory lock shared by every projection mutation.
func (s *Store) lockProjectNames(tx *sql.Tx) error {
if s.dialect.name() == "sqlite" {
_, err := tx.Exec(`UPDATE projects SET id=id WHERE 1=0`)
return err
}
return s.dialect.lockScope(tx, "", "host-project-names")
}
// EnsureProject creates a stable projection or verifies an identical retry.
func (s *Store) EnsureProject(owner, id, name string) (core.Project, error) {
name = strings.TrimSpace(name)
if owner == "" || id == "" || name == "" {
return core.Project{}, errors.New("store: project owner, id and name required")
}
tx, err := s.db.Begin()
if err != nil {
return core.Project{}, err
}
defer tx.Rollback()
if err = s.lockProjectNames(tx); err != nil {
return core.Project{}, err
}
p, err := s.projectMetadata(tx, id)
if err == nil {
if p.Owner != owner {
return core.Project{}, ErrNotFound
}
if p.Name != name {
return core.Project{}, ErrConflict
}
return p, tx.Commit()
}
if !errors.Is(err, ErrNotFound) {
return core.Project{}, err
}
available, err := s.projectNameAvailable(tx, name, id)
if err != nil {
return core.Project{}, err
}
if !available {
return core.Project{}, ErrConflict
}
now := time.Now().UTC()
p = core.Project{ID: id, Owner: owner, Name: name, CreatedAt: now, UpdatedAt: now, Settings: map[string]any{}}
_, err = tx.Exec(s.q(`INSERT INTO projects(id,owner,name,settings,created_at,updated_at) VALUES(?,?,?,?,?,?)`), id, owner, name, "{}", now.Format(time.RFC3339Nano), now.Format(time.RFC3339Nano))
if err != nil {
return core.Project{}, err
}
return p, tx.Commit()
}
func (s *Store) RenameOwnedProject(owner, id, name string) (core.Project, error) {
name = strings.TrimSpace(name)
if name == "" {
return core.Project{}, errors.New("store: project name required")
}
tx, err := s.db.Begin()
if err != nil {
return core.Project{}, err
}
defer tx.Rollback()
if err = s.lockProjectNames(tx); err != nil {
return core.Project{}, err
}
p, err := s.projectMetadata(tx, id)
if err != nil {
return core.Project{}, err
}
if p.Owner != owner {
return core.Project{}, ErrNotFound
}
available, err := s.projectNameAvailable(tx, name, id)
if err != nil {
return core.Project{}, err
}
if !available {
return core.Project{}, ErrConflict
}
p.Name, p.UpdatedAt = name, time.Now().UTC()
_, err = tx.Exec(s.q(`UPDATE projects SET name=?,updated_at=? WHERE id=? AND owner=?`), p.Name, p.UpdatedAt.Format(time.RFC3339Nano), id, owner)
if err != nil {
return core.Project{}, err
}
return p, tx.Commit()
}
type projectQuerier interface {
QueryRow(string, ...any) *sql.Row
Query(string, ...any) (*sql.Rows, error)
}
func (s *Store) projectNameAvailable(q projectQuerier, name, exceptID string) (bool, error) {
// Use the same Go whitespace rule as admission, including legacy raw names.
rows, err := q.Query(s.q(`SELECT name FROM projects WHERE id<>?`), exceptID)
if err != nil {
return false, err
}
defer rows.Close()
name = strings.TrimSpace(name)
for rows.Next() {
var candidate string
if err := rows.Scan(&candidate); err != nil {
return false, err
}
if strings.TrimSpace(candidate) == name {
return false, nil
}
}
return true, rows.Err()
}
func (s *Store) ProjectNameAvailable(name, exceptID string) (bool, error) {
return s.projectNameAvailable(s.db, name, exceptID)
}
func (s *Store) projectMetadata(q projectQuerier, id string) (core.Project, error) {
var p core.Project
var settings, ca, ua string
err := q.QueryRow(s.q(`SELECT id,owner,name,settings,created_at,updated_at FROM projects WHERE id=?`), id).Scan(&p.ID, &p.Owner, &p.Name, &settings, &ca, &ua)
if errors.Is(err, sql.ErrNoRows) {
return p, ErrNotFound
}
if err != nil {
return p, err
}
p.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca)
p.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua)
p.Settings = map[string]any{}
if settings != "" && settings != "{}" {
p.Settings, _ = unmarshalJSON[map[string]any](settings)
}
return p, nil
}
// ProjectMetadata is an internal trusted lookup. Capabilities must authorize it
// before exposing metadata to another owner.
func (s *Store) ProjectMetadata(id string) (core.Project, error) { return s.projectMetadata(s.db, id) }
-83
View File
@@ -1,83 +0,0 @@
package store
import (
"errors"
"sync"
"testing"
)
func TestProjectProjectionIdentityAndOwnership(t *testing.T) {
st := openTestStore(t)
p, err := st.EnsureProject("alice", "host-stable", " Global ")
if err != nil || p.ID != "host-stable" || p.Name != "Global" || p.Owner != "alice" {
t.Fatalf("ensure: %+v %v", p, err)
}
retry, err := st.EnsureProject("alice", p.ID, "Global")
if err != nil || retry.ID != p.ID {
t.Fatalf("retry: %+v %v", retry, err)
}
if _, err = st.EnsureProject("bob", p.ID, "Global"); !errors.Is(err, ErrNotFound) {
t.Fatalf("other owner: %v", err)
}
if _, err = st.EnsureProject("alice", p.ID, "Different"); !errors.Is(err, ErrConflict) {
t.Fatalf("different retry name: %v", err)
}
if _, err = st.RenameOwnedProject("bob", p.ID, "Other"); !errors.Is(err, ErrNotFound) {
t.Fatalf("rename owner: %v", err)
}
}
func TestProjectProjectionGlobalNameAdmission(t *testing.T) {
st := openTestStore(t)
if _, err := st.CreateProject("legacy", "Taken"); err != nil {
t.Fatal(err)
}
if _, err := st.EnsureProject("alice", "a", " Taken "); !errors.Is(err, ErrConflict) {
t.Fatalf("legacy collision: %v", err)
}
var wg sync.WaitGroup
results := make(chan error, 2)
for _, owner := range []string{"alice", "bob"} {
wg.Add(1)
go func(owner string) {
defer wg.Done()
_, err := st.EnsureProject(owner, "p-"+owner, "Race")
results <- err
}(owner)
}
wg.Wait()
close(results)
successes, conflicts := 0, 0
for err := range results {
if err == nil {
successes++
} else if errors.Is(err, ErrConflict) {
conflicts++
} else {
t.Fatal(err)
}
}
if successes != 1 || conflicts != 1 {
t.Fatalf("successes=%d conflicts=%d", successes, conflicts)
}
if _, err := st.EnsureProject("alice", "rename-a", "Rename source"); err != nil {
t.Fatal(err)
}
if _, err := st.RenameOwnedProject("alice", "rename-a", "Taken"); !errors.Is(err, ErrConflict) {
t.Fatalf("rename collision: %v", err)
}
available, err := st.ProjectNameAvailable("Taken", "")
if err != nil || available {
t.Fatalf("availability=%v %v", available, err)
}
}
func TestProjectProjectionAdmissionTrimsLegacyWhitespace(t *testing.T) {
st := openTestStore(t)
if _, err := st.CreateProject("legacy", "\tReserved\u00a0"); err != nil {
t.Fatal(err)
}
if _, err := st.EnsureProject("other", "new-id", "Reserved"); !errors.Is(err, ErrConflict) {
t.Fatalf("trimmed legacy admission: %v", err)
}
}
+3 -12
View File
@@ -53,28 +53,19 @@ func (s *Store) searchKnowledgePG(owner string, q core.RetrievalQuery) ([]core.R
// searchKnowledgePGShared EXT-8 切片 2 共享版:他人 owner 的行 + 谓词后置判定
// (allow 回调由上层注入;排序与自检索同构)。
func (s *Store) searchKnowledgePGShared(requester string, q core.RetrievalQuery, pred knowledgePred) ([]core.RetrievalHit, error) {
return s.searchKnowledgePGSharedCandidates(requester, q, pred, true)
}
func (s *Store) searchKnowledgePGSharedCandidates(requester string, q core.RetrievalQuery, pred knowledgePred, bounded bool) ([]core.RetrievalHit, error) {
topK := q.TopK
if topK <= 0 {
topK = 5
}
text := q.Text
query := `
rows, err := s.db.Query(s.q(`
SELECT k.id,k.owner,k.project_id,k.source,k.title,k.ref,k.chunk,k.created_at,
ts_rank(k.tsv, websearch_to_tsquery('simple', ?)) AS r
FROM knowledge k
WHERE (k.owner<>? AND k.owner<>'')
AND (? = '' OR k.tsv @@ websearch_to_tsquery('simple', ?) OR k.chunk LIKE ? OR k.title LIKE ?)
ORDER BY r DESC, k.created_at DESC`
args := []any{text, requester, text, text, "%" + text + "%", "%" + text + "%"}
if bounded {
query += " LIMIT ?"
args = append(args, topK*2)
}
rows, err := s.db.Query(s.q(query), args...)
ORDER BY r DESC, k.created_at DESC
LIMIT ?`), text, requester, text, text, "%"+text+"%", "%"+text+"%", topK*2)
if err != nil {
return nil, err
}
-26
View File
@@ -1,26 +0,0 @@
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
@@ -1,91 +0,0 @@
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()
}
+9 -134
View File
@@ -77,7 +77,6 @@ type Store struct {
migrateMu sync.Mutex
migrateFault error // 非空时 migratePass 在「DDL 已执行、版本未登记」处失败(原子性测试)
accountFault error // 非空时账户事务在提交前失败(半态测试)
vecPGOnce sync.Once // pgvector 列探测(U6-3:一次性缓存)
vecPG bool
@@ -88,103 +87,6 @@ func OpenSQLite(path string) (*Store, error) {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
return openSQLiteFile(path, true, true)
}
// OpenIdentity 打开既有库做身份 ensure/verify:不创建文件或目录,不执行迁移。
// 缺少 agent_system_identity 表时返回 ErrIdentityTableMissing。
func OpenIdentity(storageDSN, dataDir string) (*Store, error) {
if storageDSN != "" {
return openIdentityPostgres(storageDSN)
}
return openIdentitySQLite(filepath.Join(dataDir, "agent-system.db"))
}
func openIdentitySQLite(path string) (*Store, error) {
if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("%w: %s", ErrIdentityDBMissing, path)
}
return nil, err
}
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(30000)&_pragma=foreign_keys(1)&_txlock=immediate", path)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
if err := requireIdentityTable(db, "sqlite"); err != nil {
db.Close()
return nil, err
}
return &Store{db: db, dialect: sqliteDialect{}}, nil
}
func openIdentityPostgres(dsn string) (*Store, error) {
db, err := sql.Open("pgx", dsn)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(2)
if err := db.Ping(); err != nil {
db.Close()
return nil, err
}
if err := requireIdentityTable(db, "postgres"); err != nil {
db.Close()
return nil, err
}
return &Store{db: db, dialect: postgresDialect{}}, nil
}
func requireIdentityTable(db *sql.DB, kind string) error {
var name string
var err error
switch kind {
case "postgres":
err = db.QueryRow(`SELECT COALESCE(to_regclass('public.agent_system_identity')::text, '')`).Scan(&name)
default:
err = db.QueryRow(`SELECT COALESCE((SELECT name FROM sqlite_master WHERE type='table' AND name='agent_system_identity'), '')`).Scan(&name)
}
if err != nil {
return err
}
if name == "" {
return fmt.Errorf("%w: 先执行受控迁移(服务启动或 migrate),身份命令不建库、不迁移", ErrIdentityTableMissing)
}
return nil
}
// SchemaVersion 读取 schema_migrations 的最大版本。表不存在时返回 0。
func (s *Store) SchemaVersion() (int, error) {
var v sql.NullInt64
err := s.db.QueryRow(`SELECT MAX(version) FROM schema_migrations`).Scan(&v)
if err != nil {
msg := strings.ToLower(err.Error())
if strings.Contains(msg, "no such table") || strings.Contains(msg, "does not exist") {
return 0, nil
}
return 0, err
}
if !v.Valid {
return 0, nil
}
return int(v.Int64), nil
}
// OpenExistingSQLite 打开既有嵌入式库:数据文件不存在则失败,不创建空库或父目录,也不迁移。
// schema 与本二进制不一致时拒绝,并提示使用 migrate 或先启动服务。
func OpenExistingSQLite(path string) (*Store, error) {
if _, err := os.Stat(path); err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("open existing: sqlite file missing: %s", path)
}
return nil, err
}
return openSQLiteFile(path, false, false)
}
func openSQLiteFile(path string, createArtifacts, migrateSchema bool) (*Store, error) {
// _txlock=immediate:写事务在 BEGIN 即取库写锁(而非首个写语句升级),
// 跨进程迁移/写路径依赖 busy_timeout 排队而不是中途 SQLITE_BUSY(KTD8)。
// busy_timeout 30s:迁移竞争测试在慢速 CI 盘上 4 个迁移者串行全程可超 5s
@@ -196,7 +98,7 @@ func openSQLiteFile(path string, createArtifacts, migrateSchema bool) (*Store, e
}
// SQLite 单写者:串行化连接,语句级原子 + 版本化迁移事务。
db.SetMaxOpenConns(1)
return finishOpenMode(db, sqliteDialect{}, filepath.Join(filepath.Dir(path), "artifacts"), nil, PoolOptions{}, createArtifacts, migrateSchema)
return finishOpen(db, sqliteDialect{}, filepath.Join(filepath.Dir(path), "artifacts"), nil, PoolOptions{})
}
// OpenPostgres 打开 PostgreSQL 档存储并执行迁移(默认连接预算)。
@@ -213,17 +115,7 @@ func OpenPostgresWithBlobs(dsn, artifactsDir string, blobs blobstore.Store) (*St
// OpenPostgresWithOptions 按 KTD12 连接预算打开 PG 档;QueryTimeoutSec>0 时
// 以 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, true)
}
// OpenExistingPostgres 打开既有 PG 库,不创建 artifacts 目录,也不迁移。
func OpenExistingPostgres(dsn, artifactsDir string) (*Store, error) {
return openPostgresWithOptions(dsn, artifactsDir, PoolOptions{}, nil, false, false)
}
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))
@@ -238,7 +130,7 @@ func openPostgresWithOptions(dsn, artifactsDir string, pool PoolOptions, blobs b
if pool.ConnMaxLifetimeSec > 0 {
db.SetConnMaxLifetime(time.Duration(pool.ConnMaxLifetimeSec) * time.Second)
}
return finishOpenMode(db, postgresDialect{}, artifactsDir, blobs, pool, createArtifacts, migrateSchema)
return finishOpen(db, postgresDialect{}, artifactsDir, blobs, pool)
}
// withPGRuntimeParam 向 DSN 追加运行时参数(libpq 语义:URL 查询串或键值对)。
@@ -264,40 +156,23 @@ 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) {
func finishOpen(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.Store, pool PoolOptions) (*Store, error) {
if blobs == nil {
var err error
if createArtifacts {
blobs, err = blobstore.NewLocal(artifactsDir)
} else {
blobs, err = blobstore.OpenLocalExisting(artifactsDir)
}
blobs, err = blobstore.NewLocal(artifactsDir)
if err != nil {
db.Close()
return nil, err
}
}
s := &Store{db: db, dialect: d, dataDir: artifactsDir, blobs: blobs, pool: pool}
if migrateSchema {
if err := s.migrate(); err != nil {
db.Close()
return nil, err
}
} else if err := s.requireCurrentSchema(); err != nil {
if err := s.migrate(); err != nil {
db.Close()
return nil, err
}
if createArtifacts {
if err := os.MkdirAll(artifactsDir, 0o755); err != nil {
db.Close()
return nil, err
}
if err := os.MkdirAll(artifactsDir, 0o755); err != nil {
db.Close()
return nil, err
}
return s, nil
}
+1 -16
View File
@@ -12,15 +12,6 @@ var ErrSystemIdentityMismatch = errors.New("system identity mismatch")
// ErrSystemIdentityMissing 表中无身份行——须先 Ensure(svc init / 首次建立),禁止静默插入。
var ErrSystemIdentityMissing = errors.New("system identity missing")
// ErrIdentityDBMissing 身份 CLI 拒绝创建数据库文件或父目录。
var ErrIdentityDBMissing = errors.New("identity database missing")
// ErrIdentityTableMissing 库存在但没有冻结的身份表,须走受控迁移而不是身份 CLI。
var ErrIdentityTableMissing = errors.New("identity table missing")
// IdentityColumns 是 agent_system_identity 的冻结列序(v27)。新增列会破坏旧制品读取,必须先改本常量并补兼容测试。
const IdentityColumns = "instance_id,dbref,schema_version,created_at"
// SystemIdentity 是 agent_system_identity 表的一行(一库一服务身份)。
type SystemIdentity struct {
InstanceID string
@@ -52,13 +43,7 @@ func (s *Store) EnsureSystemIdentity(instanceID, dbref string) error {
}
return nil
}
ver, err := s.SchemaVersion()
if err != nil {
return err
}
if ver <= 0 {
ver = 27
}
ver := latestMigrationVersion()
_, err = s.db.Exec(s.q(`INSERT INTO agent_system_identity(instance_id, dbref, schema_version, created_at) VALUES(?,?,?,?)`),
instanceID, dbref, ver, nowISO())
if err != nil {
+2 -90
View File
@@ -1,11 +1,8 @@
package store
import (
"database/sql"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
@@ -58,101 +55,16 @@ func TestSystemIdentityMigrationCreatesTable(t *testing.T) {
}
found := false
for _, v := range versions {
if v == 27 {
if v == 26 {
found = true
break
}
}
if !found {
t.Fatalf("migration v27 not applied: %v", versions)
t.Fatalf("migration v26 not applied: %v", versions)
}
var name string
if err := st.DB().QueryRow(`SELECT name FROM sqlite_master WHERE type='table' AND name='agent_system_identity'`).Scan(&name); err != nil || name != "agent_system_identity" {
t.Fatalf("table missing: name=%q err=%v", name, err)
}
cols := identityColumnNames(t, st.DB())
if cols != IdentityColumns {
t.Fatalf("身份表列已偏离冻结契约:%s", cols)
}
}
func TestOpenIdentityReadsFutureSchemaWithoutMigrating(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "agent-system.db")
raw, err := sql.Open("sqlite", "file:"+path)
if err != nil {
t.Fatal(err)
}
stmts := []string{
`CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL)`,
`INSERT INTO schema_migrations(version, name, applied_at) VALUES (99, 'future', 't')`,
`CREATE TABLE agent_system_identity (
instance_id TEXT NOT NULL PRIMARY KEY,
dbref TEXT NOT NULL,
schema_version INTEGER NOT NULL,
created_at TEXT NOT NULL)`,
`INSERT INTO agent_system_identity(instance_id, dbref, schema_version, created_at) VALUES ('svc-future-aaaa', 'sqlite', 99, 't')`,
}
for _, stmt := range stmts {
if _, err := raw.Exec(stmt); err != nil {
t.Fatal(err)
}
}
raw.Close()
st, err := OpenIdentity("", dir)
if err != nil {
t.Fatal(err)
}
defer st.Close()
if err := st.VerifySystemIdentity("svc-future-aaaa", "sqlite"); err != nil {
t.Fatal(err)
}
ver, err := st.SchemaVersion()
if err != nil || ver != 99 {
t.Fatalf("schema=%d err=%v,身份打开不得改写迁移版本", ver, err)
}
if cols := identityColumnNames(t, st.DB()); cols != IdentityColumns {
t.Fatalf("列=%s", cols)
}
}
func TestOpenIdentityMissingFileOrTable(t *testing.T) {
dir := t.TempDir()
if _, err := OpenIdentity("", filepath.Join(dir, "missing")); !errors.Is(err, ErrIdentityDBMissing) {
t.Fatalf("缺文件:%v", err)
}
if _, err := os.Stat(filepath.Join(dir, "missing")); !os.IsNotExist(err) {
t.Fatal("缺库时创建了目录")
}
path := filepath.Join(dir, "agent-system.db")
raw, err := sql.Open("sqlite", "file:"+path)
if err != nil {
t.Fatal(err)
}
if _, err := raw.Exec(`CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL)`); err != nil {
t.Fatal(err)
}
raw.Close()
parent := filepath.Dir(path)
if _, err := OpenIdentity("", parent); !errors.Is(err, ErrIdentityTableMissing) {
t.Fatalf("缺表:%v", err)
}
}
func identityColumnNames(t *testing.T, db *sql.DB) string {
t.Helper()
rows, err := db.Query(`SELECT name FROM pragma_table_info('agent_system_identity') ORDER BY cid`)
if err != nil {
t.Fatal(err)
}
defer rows.Close()
var names []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
t.Fatal(err)
}
names = append(names, name)
}
return strings.Join(names, ",")
}
-121
View File
@@ -1,121 +0,0 @@
package store
import (
"bytes"
"database/sql"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"agent-system-backend/internal/blobstore"
core "agent-system-core"
)
func TestFinishOpenTypedNilS3UsesLocalArtifacts(t *testing.T) {
dir := t.TempDir()
st := openSQLiteWithBlobs(t, dir, (*blobstore.S3)(nil))
assertLocalArtifactRoundTrip(t, st, filepath.Join(dir, "artifacts"))
}
func TestFinishOpenKeepsConfiguredBlobStore(t *testing.T) {
dir := t.TempDir()
s3, err := blobstore.NewS3("http://127.0.0.1:9/bucket", "us-east-1", "access", "secret", "art/")
if err != nil {
t.Fatal(err)
}
st := openSQLiteWithBlobs(t, dir, s3)
if st.BlobStore() != s3 {
t.Fatalf("configured blob store was replaced: %T", st.BlobStore())
}
}
func TestOpenPostgresTypedNilS3UsesLocalArtifacts(t *testing.T) {
dsn := os.Getenv("AGENT_PG_TEST_DSN")
if dsn == "" {
t.Skip("AGENT_PG_TEST_DSN 未设置:真实 PG 门禁由 CI 的必跑 job 覆盖")
}
schema := "t_" + strings.ToLower(newID("s")) + "_" + strconv.Itoa(os.Getpid())
admin, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatal(err)
}
ensurePGTrgm(t, admin)
if _, err := admin.Exec("CREATE SCHEMA " + schema); err != nil {
admin.Close()
t.Fatalf("create schema: %v", err)
}
t.Cleanup(func() {
_, _ = admin.Exec("DROP SCHEMA IF EXISTS " + schema + " CASCADE")
admin.Close()
})
arts := filepath.Join(t.TempDir(), "artifacts")
st, err := OpenPostgresWithOptions(withTestSearchPath(dsn, schema), arts, PoolOptions{}, (*blobstore.S3)(nil))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
assertLocalArtifactRoundTrip(t, st, arts)
}
func openSQLiteWithBlobs(t *testing.T, dir string, blobs blobstore.Store) *Store {
t.Helper()
dbPath := filepath.Join(dir, "test.db")
dsn := "file:" + dbPath + "?_pragma=busy_timeout(30000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)&_txlock=immediate"
db, err := sql.Open("sqlite", dsn)
if err != nil {
t.Fatal(err)
}
db.SetMaxOpenConns(1)
st, err := finishOpen(db, sqliteDialect{}, filepath.Join(dir, "artifacts"), blobs, PoolOptions{}, true)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
return st
}
func assertLocalArtifactRoundTrip(t *testing.T, st *Store, arts string) {
t.Helper()
if _, ok := st.BlobStore().(*blobstore.Local); !ok {
t.Fatalf("unset blob store must fall back to local, got %T", st.BlobStore())
}
proj, err := st.CreateProject("k4", "k4")
if err != nil {
t.Fatal(err)
}
art := core.Artifact{Owner: "k4", ProjectID: proj.ID, Name: "a.txt", Kind: "data"}
if err := st.CreateArtifact(&art, []byte("hello")); err != nil {
t.Fatal(err)
}
got, err := st.ArtifactContentByID(art.ID)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, []byte("hello")) {
t.Fatalf("read back %q", got)
}
if _, err := os.Stat(filepath.Join(arts, art.ID)); err != nil {
t.Fatalf("artifact file missing under local dir: %v", err)
}
if err := st.UpdateArtifactContent("k4", art.ID, []byte("next")); err != nil {
t.Fatal(err)
}
got, err = st.ArtifactContent("k4", art.ID)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(got, []byte("next")) {
t.Fatalf("overwrite read back %q", got)
}
if err := st.DeleteArtifact("k4", art.ID); err != nil {
t.Fatal(err)
}
if _, err := st.ArtifactContentByID(art.ID); err != ErrNotFound {
t.Fatalf("deleted artifact content: %v", err)
}
if _, err := os.Stat(filepath.Join(arts, art.ID)); !os.IsNotExist(err) {
t.Fatalf("deleted artifact file still present: %v", err)
}
}
+2 -14
View File
@@ -121,16 +121,7 @@ func (s *Store) SearchKnowledgeVectorsSharedModel(ctx context.Context, requester
return hits, nil
}
}
return s.SearchKnowledgeVectorsSharedAuthorized(ctx, requester, model, vec, topK, filter, func(d core.KnowledgeDoc) bool { return allow == nil || allow(d.Owner, d.ProjectID) })
}
// SearchKnowledgeVectorsSharedAuthorized applies per-document authorization to
// shared vector candidates before ranking, including their actual stored ID.
func (s *Store) SearchKnowledgeVectorsSharedAuthorized(ctx context.Context, requester, model string, vec []float32, topK int, filter core.RetrievalQuery, allow func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) {
if topK <= 0 {
topK = 5
}
return s.searchKnowledgeVectorsSharedAuthorizedScan(ctx, requester, model, vec, topK, filter, allow)
return s.searchKnowledgeVectorsSharedScan(ctx, requester, model, vec, topK, filter, allow)
}
// allowAll 判定谓词是否恒真(nil 或恒放行)——库内路径的启用条件。
@@ -196,9 +187,6 @@ func (s *Store) searchKnowledgeVectorsPG(ctx context.Context, owner, model strin
// searchKnowledgeVectorsSharedScan 原进程内共享扫描(降级/谓词路径)。
func (s *Store) searchKnowledgeVectorsSharedScan(ctx context.Context, requester, model string, vec []float32, topK int, filter core.RetrievalQuery, allow func(docOwner, projectID core.ID) bool) ([]core.RetrievalHit, error) {
return s.searchKnowledgeVectorsSharedAuthorizedScan(ctx, requester, model, vec, topK, filter, func(d core.KnowledgeDoc) bool { return allow == nil || allow(d.Owner, d.ProjectID) })
}
func (s *Store) searchKnowledgeVectorsSharedAuthorizedScan(ctx context.Context, requester, model string, vec []float32, topK int, filter core.RetrievalQuery, allow func(core.KnowledgeDoc) bool) ([]core.RetrievalHit, error) {
q := `
SELECT k.id,k.owner,k.project_id,k.source,k.title,k.ref,k.chunk,k.created_at, v.vec
FROM knowledge_vectors v JOIN knowledge k ON k.id = v.doc_id
@@ -227,7 +215,7 @@ func (s *Store) searchKnowledgeVectorsSharedAuthorizedScan(ctx context.Context,
}
d.Owner = own
d.CreatedAt, _ = parseTime(ca)
if d.ProjectID == "" || !allow(d) {
if d.ProjectID == "" || !allow(d.Owner, d.ProjectID) {
continue
}
if filter.ProjectID != "" && d.ProjectID != filter.ProjectID {

Some files were not shown because too many files have changed in this diff Show More