Compare commits
71
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6d2caa43d6 | ||
|
|
8e80e2618e | ||
|
|
a4e33f192f | ||
|
|
76e7cc7c18 | ||
|
|
571e2bd28a | ||
|
|
6dbe025ec1 | ||
|
|
5298103e1f | ||
|
|
f4ac28b18e | ||
|
|
7bf8efdb2f | ||
|
|
f965c5877f | ||
|
|
5d5fc9891c | ||
|
|
d26f3cf2e8 | ||
|
|
d2f96e79bb | ||
|
|
c4435d1415 | ||
|
|
4f72aaf3d8 | ||
|
|
b9bea5bee0 | ||
|
|
7cf8f02399 | ||
|
|
15566f9a33 | ||
|
|
4e285bdd8d | ||
|
|
31a6b326c6 | ||
|
|
baaafe99d3 | ||
|
|
cfb8308895 | ||
|
|
bc1e0a36f7 | ||
|
|
fe977c8e52 | ||
|
|
e3b6cca8bc | ||
|
|
741da4a0b6 | ||
|
|
0af1ebc915 | ||
|
|
349a47de6f | ||
|
|
8b73afeef6 | ||
|
|
76c75ce50c | ||
|
|
496c784213 | ||
|
|
386afcd4a6 | ||
|
|
716fe9541c | ||
|
|
f103540d60 | ||
|
|
467718a519 | ||
|
|
dc736e339d | ||
|
|
46073d30a5 | ||
|
|
9754531cc9 | ||
|
|
c6fb13016a | ||
|
|
0f192a4813 | ||
|
|
545a75b54e | ||
|
|
f13e8b1ab7 | ||
|
|
5c26a86331 | ||
|
|
41e7eea0c3 | ||
|
|
69ea7dcc14 | ||
|
|
58b4c640d5 | ||
|
|
0191be52cd | ||
|
|
bff6820e5f | ||
|
|
f718d36d23 | ||
|
|
acabb85e2a | ||
|
|
5623000d51 | ||
|
|
d1f46c5646 | ||
|
|
830fea3d3b | ||
|
|
19d4e1ef0d | ||
|
|
430caa6296 | ||
|
|
bbe977351d | ||
|
|
0270b40197 | ||
|
|
4d48abaa8c | ||
|
|
5e8ada727c | ||
|
|
c963347e52 | ||
|
|
dcc180dcad | ||
|
|
e7b70c24f8 | ||
|
|
dcd5445d61 | ||
|
|
e046bbd15c | ||
|
|
1d9e8f4a79 | ||
|
|
52d75fd931 | ||
|
|
ed443ed3f6 | ||
|
|
c7b30d2109 | ||
|
|
4352f368cb | ||
|
|
47463a9623 | ||
|
|
1ba7225e83 |
@@ -0,0 +1,89 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
)
|
||||
|
||||
func runCapabilities(args []string) int {
|
||||
fs := flag.NewFlagSet("capabilities", flag.ExitOnError)
|
||||
asJSON := fs.Bool("json", false, "以 JSON 打印能力,不打开数据库")
|
||||
_ = fs.Parse(args)
|
||||
caps := map[string]any{
|
||||
"accountTransaction": true,
|
||||
"accountAudit": true,
|
||||
"accountOpsOpen": true,
|
||||
"passwordStdin": true,
|
||||
"dsnFile": true,
|
||||
"outFile": true,
|
||||
}
|
||||
if !*asJSON {
|
||||
fmt.Fprintln(os.Stderr, "用法:agent-system capabilities --json")
|
||||
return 2
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(caps); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "capabilities:", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func resolveAccountDSN(flagDSN, dsnFile string) (string, error) {
|
||||
if flagDSN != "" {
|
||||
fmt.Fprintln(os.Stderr, "warning: --dsn is deprecated; use AGENT_SYSTEM_DSN or --dsn-file")
|
||||
}
|
||||
if dsnFile != "" {
|
||||
info, err := os.Stat(dsnFile)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if info.Mode().Perm() != 0o600 {
|
||||
return "", fmt.Errorf("dsn file %s must be mode 0600", dsnFile)
|
||||
}
|
||||
raw, err := os.ReadFile(dsnFile)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(trimNewline(raw)), nil
|
||||
}
|
||||
if env := os.Getenv("AGENT_SYSTEM_DSN"); env != "" {
|
||||
return env, nil
|
||||
}
|
||||
return flagDSN, nil
|
||||
}
|
||||
|
||||
func resolveAccountPassword(flagPassword string, fromStdin bool) (string, error) {
|
||||
if flagPassword != "" {
|
||||
fmt.Fprintln(os.Stderr, "warning: --password is deprecated; use --password-stdin")
|
||||
}
|
||||
if !fromStdin {
|
||||
return flagPassword, nil
|
||||
}
|
||||
raw, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(trimNewline(raw)), nil
|
||||
}
|
||||
|
||||
func writeExclusiveSecret(path, body string) error {
|
||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = io.WriteString(f, body+"\n")
|
||||
return err
|
||||
}
|
||||
|
||||
func trimNewline(raw []byte) []byte {
|
||||
for len(raw) > 0 && (raw[len(raw)-1] == '\n' || raw[len(raw)-1] == '\r') {
|
||||
raw = raw[:len(raw)-1]
|
||||
}
|
||||
return raw
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/store"
|
||||
"bytes"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func accountCLI(t *testing.T, args ...string) (int, string) {
|
||||
t.Helper()
|
||||
out, err := os.CreateTemp(t.TempDir(), "capture-")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer out.Close()
|
||||
oldOut, oldErr := os.Stdout, os.Stderr
|
||||
os.Stdout = out
|
||||
os.Stderr = out
|
||||
defer func() { os.Stdout = oldOut; os.Stderr = oldErr }()
|
||||
rc := runUser(args)
|
||||
out.Seek(0, 0)
|
||||
raw, err := io.ReadAll(out)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return rc, string(raw)
|
||||
}
|
||||
func cliStore(t *testing.T) (string, *store.Store) {
|
||||
t.Helper()
|
||||
t.Setenv("AGENT_SYSTEM_DSN", "")
|
||||
dir := t.TempDir()
|
||||
s, e := store.OpenSQLite(filepath.Join(dir, "agent-system.db"))
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
t.Cleanup(func() { s.Close() })
|
||||
if e := s.EnsureSystemIdentity("fixture", "sqlite"); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
return dir, s
|
||||
}
|
||||
func TestOutputFileAppliesToEveryCredentialCommand(t *testing.T) {
|
||||
for _, sub := range []string{"create", "reset-password", "issue-login-key", "bootstrap-admin"} {
|
||||
t.Run(sub, func(t *testing.T) {
|
||||
dir, s := cliStore(t)
|
||||
if sub != "create" && sub != "bootstrap-admin" {
|
||||
if e := s.CreateAccount("ada", "Ada", "", false); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
}
|
||||
out := filepath.Join(t.TempDir(), "secret")
|
||||
rc, _ := accountCLI(t, sub, "--data", dir, "--owner", "ada", "--out-file", out)
|
||||
if rc != 0 {
|
||||
t.Fatalf("credential command failed: %d", rc)
|
||||
}
|
||||
info, e := os.Stat(out)
|
||||
if e != nil {
|
||||
t.Fatal("credential output file missing")
|
||||
}
|
||||
if info.Mode().Perm() != 0600 {
|
||||
t.Fatal("credential output permissions are not private")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestExistingOutputAndConflictingFormatsRefuseBeforeMutation(t *testing.T) {
|
||||
for _, kind := range []string{"existing", "conflicting"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
dir, s := cliStore(t)
|
||||
out := filepath.Join(t.TempDir(), "secret")
|
||||
args := []string{"create", "--data", dir, "--owner", "ada", "--out-file", out}
|
||||
if kind == "existing" {
|
||||
if e := os.WriteFile(out, []byte("existing"), 0600); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
} else {
|
||||
args = append(args, "--out", filepath.Join(t.TempDir(), "json-secret"))
|
||||
}
|
||||
rc, _ := accountCLI(t, args...)
|
||||
if rc == 0 {
|
||||
t.Fatal("unsafe output selection succeeded")
|
||||
}
|
||||
if _, e := s.GetUser("ada"); e == nil {
|
||||
t.Fatal("output refusal happened after account mutation")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPIKeyCreateUsesExclusivePrivateOutput(t *testing.T) {
|
||||
if dir := os.Getenv("U7_APIKEY_CHILD_DIR"); dir != "" {
|
||||
rc := runAPIKey([]string{"create", "--data", dir, "--owner", "ada", "--name", "fixture", "--out-file", os.Getenv("U7_APIKEY_CHILD_OUT")})
|
||||
if rc != 0 {
|
||||
t.Fatal("apikey create rejected its private output destination")
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, existing := range []bool{false, true} {
|
||||
name := "new"
|
||||
if existing {
|
||||
name = "existing"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
dir, st := cliStore(t)
|
||||
if err := st.CreateAccount("ada", "Ada", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := filepath.Join(t.TempDir(), "credential")
|
||||
if existing {
|
||||
if err := os.WriteFile(out, []byte("preserve"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestAPIKeyCreateUsesExclusivePrivateOutput$")
|
||||
cmd.Env = append(os.Environ(), "U7_APIKEY_CHILD_DIR="+dir, "U7_APIKEY_CHILD_OUT="+out)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd.Stdout = &stdout
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
if existing {
|
||||
if err == nil {
|
||||
t.Fatal("existing output was overwritten or mutation succeeded")
|
||||
}
|
||||
raw, readErr := os.ReadFile(out)
|
||||
if readErr != nil || string(raw) != "preserve" {
|
||||
t.Fatal("existing output was modified")
|
||||
}
|
||||
keys, listErr := st.ListAPIKeys("ada")
|
||||
if listErr != nil || len(keys) != 0 {
|
||||
t.Fatal("mutation happened before output preflight")
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal("new private output was rejected")
|
||||
}
|
||||
info, statErr := os.Stat(out)
|
||||
if statErr != nil || info.Mode().Perm() != 0600 {
|
||||
t.Fatal("credential output missing or not private")
|
||||
}
|
||||
raw, readErr := os.ReadFile(out)
|
||||
if readErr != nil || len(raw) == 0 {
|
||||
t.Fatal("credential output empty")
|
||||
}
|
||||
if bytes.Contains(stdout.Bytes(), bytes.TrimSpace(raw)) {
|
||||
t.Fatal("credential was written to stdout")
|
||||
}
|
||||
keys, listErr := st.ListAPIKeys("ada")
|
||||
if listErr != nil || len(keys) != 1 {
|
||||
t.Fatal("successful issue was not committed")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/store"
|
||||
"errors"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func captureIdentityCLI(t *testing.T, run func() int) (int, string) {
|
||||
t.Helper()
|
||||
file, err := os.CreateTemp(t.TempDir(), "identity-output")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer file.Close()
|
||||
stdout, stderr := os.Stdout, os.Stderr
|
||||
os.Stdout, os.Stderr = file, file
|
||||
defer func() { os.Stdout, os.Stderr = stdout, stderr }()
|
||||
code := run()
|
||||
if _, err := file.Seek(0, io.SeekStart); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := io.ReadAll(file)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return code, string(raw)
|
||||
}
|
||||
|
||||
func TestIdentityCommandPGSchemaErrorKeepsRecoveryInstructionWithoutDSN(t *testing.T) {
|
||||
const dsn = "postgres://fixture:dsn-private-fixture@127.0.0.1/db"
|
||||
message := identityCommandError(dsn, errors.New("schema v3 与本二进制 v4 不一致;请运行 agent-system migrate,或先启动一次服务"))
|
||||
if !strings.Contains(message, "agent-system migrate") || strings.Contains(message, "dsn-private-fixture") || strings.Contains(message, "postgres://") {
|
||||
t.Fatalf("schema recovery instruction or secret boundary lost: %q", message)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityCommandsUseEnvironmentDSNWithoutPrintingCredentials(t *testing.T) {
|
||||
const privateFixture = "dsn-private-fixture"
|
||||
t.Setenv("AGENT_SYSTEM_DSN", "postgres://fixture:"+privateFixture+"@%invalid/db")
|
||||
for _, sub := range []string{"user", "apikey"} {
|
||||
data := t.TempDir()
|
||||
local, err := store.OpenSQLite(filepath.Join(data, "agent-system.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := local.EnsureUser("alice", "Alice fixture"); err != nil {
|
||||
local.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
local.Close()
|
||||
code, out := captureIdentityCLI(t, func() int {
|
||||
if sub == "user" {
|
||||
return runUser([]string{"list", "--data", data})
|
||||
}
|
||||
return runAPIKey([]string{"list", "--owner", "alice", "--data", data})
|
||||
})
|
||||
if code != 1 {
|
||||
t.Fatalf("%s ignored configured PG binding and fell back to SQLite: code=%d", sub, code)
|
||||
}
|
||||
if strings.Contains(out, privateFixture) || strings.Contains(out, "postgres://") {
|
||||
t.Fatal("identity diagnostic disclosed DSN credentials")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIdentityCommandDSNSourcePrecedence(t *testing.T) {
|
||||
t.Setenv("AGENT_SYSTEM_DSN", "environment-fixture")
|
||||
if got, err := resolveAccountDSN("deprecated-flag-fixture", ""); err != nil || got != "environment-fixture" {
|
||||
t.Fatalf("configured environment lost to deprecated flag: %q %v", got, err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "dsn")
|
||||
if err := os.WriteFile(path, []byte("protected-file-fixture\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, err := resolveAccountDSN("deprecated-flag-fixture", path); err != nil || got != "protected-file-fixture" {
|
||||
t.Fatalf("protected DSN file lost precedence: %q %v", got, err)
|
||||
}
|
||||
t.Setenv("AGENT_SYSTEM_DSN", "")
|
||||
if got, err := resolveAccountDSN("deprecated-flag-fixture", ""); err != nil || got != "deprecated-flag-fixture" {
|
||||
t.Fatalf("deprecated flag compatibility lost: %q %v", got, err)
|
||||
}
|
||||
if got, err := resolveAccountDSN("", ""); err != nil || got != "" {
|
||||
t.Fatalf("unset sources did not choose embedded storage: %q %v", got, err)
|
||||
}
|
||||
}
|
||||
+287
-64
@@ -9,18 +9,28 @@ 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 {
|
||||
if len(os.Args) > 1 && !strings.HasPrefix(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":
|
||||
@@ -33,6 +43,12 @@ 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)
|
||||
@@ -179,28 +195,49 @@ func runAPIKey(args []string) int {
|
||||
sub := args[0]
|
||||
fs := flag.NewFlagSet("apikey "+sub, flag.ExitOnError)
|
||||
data := fs.String("data", "./data", "数据目录")
|
||||
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式档)")
|
||||
dsn := fs.String("dsn", "", "PostgreSQL DSN(已弃用;优先使用 --dsn-file 或 AGENT_SYSTEM_DSN)")
|
||||
dsnFile := fs.String("dsn-file", "", "0600权限的PostgreSQL DSN文件")
|
||||
owner := fs.String("owner", "", "租户名(owner)")
|
||||
name := fs.String("name", "default", "密钥名称")
|
||||
outFile := fs.String("out-file", "", "把明文密钥独占写入该文件(0600)")
|
||||
fs.Parse(args[1:])
|
||||
if *owner == "" {
|
||||
fmt.Fprintln(os.Stderr, "apikey: --owner 必填")
|
||||
return 2
|
||||
}
|
||||
if sub != "create" && *outFile != "" {
|
||||
fmt.Fprintln(os.Stderr, "apikey: 此子命令不生成密钥")
|
||||
return 2
|
||||
}
|
||||
resolvedDSN, err := resolveAccountDSN(*dsn, *dsnFile)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "apikey:", err)
|
||||
return 1
|
||||
}
|
||||
*dsn = resolvedDSN
|
||||
switch sub {
|
||||
case "create":
|
||||
key, err := app.CreateAPIKey(*dsn, *data, *owner, *name)
|
||||
delivery, err := reserveCredentialOutput(*outFile, "")
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "apikey create:", err)
|
||||
return 1
|
||||
}
|
||||
defer delivery.abort()
|
||||
key, err := app.CreateAPIKey(*dsn, *data, *owner, *name)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "apikey create:", identityCommandError(*dsn, err))
|
||||
return 1
|
||||
}
|
||||
// 明文仅此一次输出;库中只存 SHA-256 摘要。
|
||||
fmt.Println(key)
|
||||
if err := delivery.deliver("api-key", *owner, key, nil); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "apikey create:", err)
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
case "list":
|
||||
keys, err := app.ListAPIKeys(*dsn, *data, *owner)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "apikey list:", err)
|
||||
fmt.Fprintln(os.Stderr, "apikey list:", identityCommandError(*dsn, err))
|
||||
return 1
|
||||
}
|
||||
out, _ := json.MarshalIndent(keys, "", " ")
|
||||
@@ -209,7 +246,7 @@ func runAPIKey(args []string) int {
|
||||
case "revoke":
|
||||
ok, err := app.RevokeAPIKey(*dsn, *data, *owner, *name)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "apikey revoke:", err)
|
||||
fmt.Fprintln(os.Stderr, "apikey revoke:", identityCommandError(*dsn, err))
|
||||
return 1
|
||||
}
|
||||
if !ok {
|
||||
@@ -224,36 +261,208 @@ func runAPIKey(args []string) int {
|
||||
}
|
||||
}
|
||||
|
||||
// runUser:用户账号管理子命令(管理员发放制,R2)——create / reset-password / list。
|
||||
// 密码未显式给定时生成随机值,明文仅此一次输出;创建/重置后首次登录强制改密。
|
||||
// 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 兼容旧随机密码路径。
|
||||
func runUser(args []string) int {
|
||||
if len(args) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "用法:agent-system user <create|reset-password|list> [--owner x --name y --admin --password p]")
|
||||
fmt.Fprintln(os.Stderr, "用法:agent-system user <create|reset-password|list|issue-login-key|revoke-login-key|bootstrap-admin> [flags]")
|
||||
return 2
|
||||
}
|
||||
sub := args[0]
|
||||
fs := flag.NewFlagSet("user "+sub, flag.ExitOnError)
|
||||
data := fs.String("data", "./data", "数据目录")
|
||||
dsn := fs.String("dsn", "", "PostgreSQL 档 DSN(空 = 嵌入式档)")
|
||||
dsn := fs.String("dsn", "", "PostgreSQL DSN(已弃用;优先使用 --dsn-file 或 AGENT_SYSTEM_DSN)")
|
||||
dsnFile := fs.String("dsn-file", "", "0600 权限的 DSN 文件")
|
||||
owner := fs.String("owner", "", "用户名(owner)")
|
||||
name := fs.String("name", "", "显示名")
|
||||
password := fs.String("password", "", "密码(空 = 随机生成并打印一次)")
|
||||
admin := fs.Bool("admin", false, "管理员(可管理用户与密钥)")
|
||||
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 明文;非交互推荐)")
|
||||
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
|
||||
}
|
||||
pw, err := app.CreateUserAccount(*dsn, *data, *owner, *name, *password, *admin)
|
||||
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)
|
||||
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.Println(pw)
|
||||
if *password == "" {
|
||||
fmt.Fprintln(os.Stderr, "初始密码仅此一次输出;该用户首次登录须修改密码。")
|
||||
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, "初始密码仅此一次输出;该用户首次登录须修改密码。")
|
||||
}
|
||||
}
|
||||
return 0
|
||||
case "reset-password":
|
||||
@@ -261,25 +470,79 @@ func runUser(args []string) int {
|
||||
fmt.Fprintln(os.Stderr, "user reset-password: --owner 必填")
|
||||
return 2
|
||||
}
|
||||
pw, err := app.ResetUserPassword(*dsn, *data, *owner, *password)
|
||||
pw, err := app.ResetUserPassword(*dsn, *data, *owner, *password, *openExisting)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user reset-password:", identityCommandError(*dsn, err))
|
||||
return 1
|
||||
}
|
||||
if err := delivery.deliver("password", *owner, pw, nil); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user reset-password:", err)
|
||||
return 1
|
||||
}
|
||||
fmt.Println(pw)
|
||||
if *password == "" {
|
||||
if *out == "" && *outFile == "" && *password == "" {
|
||||
fmt.Fprintln(os.Stderr, "新密码仅此一次输出;该用户下次登录须修改密码(其余会话已吊销)。")
|
||||
}
|
||||
return 0
|
||||
case "list":
|
||||
users, err := app.ListUserAccounts(*dsn, *data)
|
||||
users, err := app.ListUserAccounts(*dsn, *data, *openExisting)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "user list:", err)
|
||||
fmt.Fprintln(os.Stderr, "user list:", identityCommandError(*dsn, err))
|
||||
return 1
|
||||
}
|
||||
out, _ := json.MarshalIndent(users, "", " ")
|
||||
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
|
||||
@@ -287,48 +550,8 @@ func runUser(args []string) int {
|
||||
}
|
||||
|
||||
// runIdentity:U1 I3 服务实例库内身份(manifest.instance_id ↔ agent_system_identity)。
|
||||
// 与 serve --instance(runs 进程标识)无关。
|
||||
// 与 serve --instance(runs 进程标识)无关。实现在 pkg/identity(公共化:下游
|
||||
// 组合仓共用同一 CLI,未知子命令 usage 退出、不落服务路径——2026-10-12 裁定)。
|
||||
func runIdentity(args []string) int {
|
||||
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
|
||||
}
|
||||
return identity.Run(args)
|
||||
}
|
||||
|
||||
@@ -284,13 +284,19 @@ var copyOrder = []string{
|
||||
"projects", "users", "profiles", "settings", "plugins",
|
||||
"conversations", "runs", "messages", "events", "artifacts",
|
||||
"hitl_cards", "plans", "todos", "knowledge", "knowledge_vectors",
|
||||
"activity_cards", "command_audit", "prompt_modules",
|
||||
"activity_cards", "project_event_shares", "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 = `
|
||||
@@ -778,7 +784,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", "command_audit"}
|
||||
ownerTables := []string{"projects", "conversations", "runs", "artifacts", "knowledge", "hitl_cards", "profiles", "activity_cards", "project_event_shares", "command_audit"}
|
||||
for _, t := range ownerTables {
|
||||
if !sqliteHasTable(src, t) {
|
||||
continue
|
||||
|
||||
Vendored
+1
-1
Submodule deps/agent-system-core updated: 89f3906c39...38fe861bca
@@ -0,0 +1,108 @@
|
||||
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])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/store"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAccountMaintenanceDefaultsToNonCreatingOpen(t *testing.T) {
|
||||
t.Setenv("AGENT_SYSTEM_DSN", "")
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
run func(string) error
|
||||
}{
|
||||
{"user", func(dir string) error {
|
||||
_, _, err := CreateUserAccount("", dir, "ada", "Ada", "", false, false, false)
|
||||
return err
|
||||
}},
|
||||
{"apikey", func(dir string) error {
|
||||
_, err := CreateAPIKey("", dir, "ada", "test")
|
||||
return err
|
||||
}},
|
||||
{"identity verify", func(dir string) error {
|
||||
_, err := VerifySystemIdentity("", dir, "fixture", "sqlite")
|
||||
return err
|
||||
}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
dir := filepath.Join(t.TempDir(), "absent")
|
||||
if err := tc.run(dir); err == nil {
|
||||
t.Error("maintenance created a missing database")
|
||||
}
|
||||
if _, err := os.Stat(dir); !os.IsNotExist(err) {
|
||||
t.Error("failed maintenance created a directory")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestAccountMaintenanceCannotImplicitlyMigrate(t *testing.T) {
|
||||
t.Setenv("AGENT_SYSTEM_DSN", "")
|
||||
dir := t.TempDir()
|
||||
st, err := store.OpenSQLite(filepath.Join(dir, "agent-system.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.EnsureSystemIdentity("test", "sqlite"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Roll back the newest additive migration in this private fixture only.
|
||||
if err := st.MigrateDown(store.SchemaTarget() - 1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.Close()
|
||||
if _, _, err := CreateUserAccount("", dir, "ada", "Ada", "", false, false, false); err == nil {
|
||||
t.Error("account command implicitly migrated an older schema")
|
||||
}
|
||||
}
|
||||
+216
-100
@@ -55,6 +55,7 @@ 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"
|
||||
)
|
||||
|
||||
@@ -291,12 +292,7 @@ func Run(opt Options) error {
|
||||
if err := setSvc.SnapshotAndMigrateSecrets(); err != nil {
|
||||
return fmt.Errorf("migrate secrets: %w", err)
|
||||
}
|
||||
runtime.SecretLookup = func(owner, service string) (string, bool) {
|
||||
if owner == "" {
|
||||
owner = store.OwnerDefault
|
||||
}
|
||||
return st.ActiveSecret(owner, service)
|
||||
}
|
||||
runtime.SecretLookup = setSvc.LookupSecret
|
||||
rag.SecretLookup = func(service, fallback string) string {
|
||||
return runtime.LookupSecret(store.OwnerDefault, service, fallback)
|
||||
}
|
||||
@@ -306,7 +302,7 @@ func Run(opt Options) error {
|
||||
if err := tools.RegisterLazySearchTools(reg, func() agenttools.SearchProvider {
|
||||
effNow := setSvc.Effective(store.OwnerDefault, "")
|
||||
tavilyNow := effNow.GetString("tools.tavily.apiKeys", "")
|
||||
if active, ok := st.ActiveSecret(store.OwnerDefault, "tools.tavily"); ok {
|
||||
if active, ok := setSvc.LookupSecret(store.OwnerDefault, "tools.tavily"); ok {
|
||||
tavilyNow = active
|
||||
}
|
||||
keys := splitKeyPool(tavilyNow)
|
||||
@@ -375,7 +371,7 @@ func Run(opt Options) error {
|
||||
},
|
||||
}
|
||||
tavilyNow := effNow.GetString("tools.tavily.apiKeys", "")
|
||||
if active, ok := st.ActiveSecret(store.OwnerDefault, "tools.tavily"); ok {
|
||||
if active, ok := setSvc.LookupSecret(store.OwnerDefault, "tools.tavily"); ok {
|
||||
tavilyNow = active
|
||||
}
|
||||
if keys := splitKeyPool(tavilyNow); len(keys) > 0 {
|
||||
@@ -432,7 +428,10 @@ func Run(opt Options) error {
|
||||
return fmt.Errorf("default profile: %w", err)
|
||||
}
|
||||
if opt.ForceMock {
|
||||
if err := setSvc.MergeUser(store.OwnerDefault, map[string]any{"runtime": map[string]any{"mock": true}}); err != nil {
|
||||
// runtime.* 自 #188 起为系统受管项:启动选项属受信操作员输入,
|
||||
// 播种走系统层(对所有租户生效);用户层写入会被受管拦截拒绝,
|
||||
// 导致 serve --mock 直接启动失败(multi-instance 集成回归)。
|
||||
if err := setSvc.MergeSystem(map[string]any{"runtime": map[string]any{"mock": true}}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@@ -486,6 +485,9 @@ 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)
|
||||
@@ -516,6 +518,7 @@ 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 {
|
||||
@@ -550,7 +553,11 @@ func Run(opt Options) error {
|
||||
if resp.Receipt.State != "" {
|
||||
state = string(resp.Receipt.State)
|
||||
}
|
||||
return surfacesvc.ActionOutcome{State: state, CardID: resp.Card.ID, Receipt: resp.Receipt, Payload: resp.Payload}, nil
|
||||
cardID := ""
|
||||
if resp.Card != nil {
|
||||
cardID = string(resp.Card.ID)
|
||||
}
|
||||
return surfacesvc.ActionOutcome{State: state, CardID: cardID, Receipt: resp.Receipt, Payload: resp.Payload}, nil
|
||||
})
|
||||
// U3:generative-ui 工具注入闸——activation 台账关闭时命令不投影给该 owner
|
||||
// 的模型(禁用即不注入能力;执行端守卫仍在 surfacesvc)。
|
||||
@@ -579,7 +586,8 @@ func Run(opt Options) error {
|
||||
pluginHost.OnCommands = bus.RegisterPluginCommands
|
||||
// hostcaps:hostapi 闭包与进程内插件句柄共用的 owner 作用域能力(EXT-1 批注 1)。
|
||||
authzSlot := &projectAuthz{}
|
||||
caps := hostcaps{st: st, pipe: pipe, set: setSvc, authz: authzSlot, surfaces: surfaceSvc}
|
||||
projectSlot := &projectLifecycleSlot{}
|
||||
caps := hostcaps{projects: projectSlot, 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 {
|
||||
@@ -592,6 +600,16 @@ 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)
|
||||
@@ -616,26 +634,25 @@ func Run(opt Options) error {
|
||||
}
|
||||
// EXT-8 余项:hostapi 检索面同样吃适配器共享(本人 + 放行的他人项目知识)。
|
||||
hostSrv.SharedSearch = func(ctx context.Context, owner, text string) ([]core.RetrievalHit, error) {
|
||||
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
|
||||
return caps.searchDefault(ctx, owner, text)
|
||||
}
|
||||
|
||||
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 在上方已建)。
|
||||
@@ -715,38 +732,98 @@ func Run(opt Options) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateUserAccount CLI 建号(管理员发放制):未给密码则生成随机 12 位并返回明文(仅此一次输出)。
|
||||
func CreateUserAccount(storageDSN, dataDir, owner, name, password string, isAdmin bool) (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 {
|
||||
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 {
|
||||
// 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
|
||||
}
|
||||
st.DeleteUserSessions(owner, "")
|
||||
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) {
|
||||
if password == "" {
|
||||
password = server.RandomPassword()
|
||||
}
|
||||
hash, err := store.HashPassword(password)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
|
||||
return st.AdminResetUserPassword(owner, hash, cliActor(), "cli")
|
||||
}); err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -754,9 +831,9 @@ func ResetUserPassword(storageDSN, dataDir, owner, password string) (string, err
|
||||
}
|
||||
|
||||
// ListUserAccounts CLI 用户列表。
|
||||
func ListUserAccounts(storageDSN, dataDir string) ([]store.AdminUser, error) {
|
||||
func ListUserAccounts(storageDSN, dataDir string, openExisting bool) ([]store.AdminUser, error) {
|
||||
var out []store.AdminUser
|
||||
err := withStore(storageDSN, dataDir, func(st *store.Store) error {
|
||||
err := withStoreMode(storageDSN, dataDir, openExisting, func(st *store.Store) error {
|
||||
var e error
|
||||
out, e = st.ListUsersAdmin()
|
||||
return e
|
||||
@@ -765,13 +842,12 @@ func ListUserAccounts(storageDSN, dataDir string) ([]store.AdminUser, error) {
|
||||
}
|
||||
|
||||
func withStore(storageDSN, dataDir string, fn func(*store.Store) error) error {
|
||||
var st *store.Store
|
||||
var err error
|
||||
if storageDSN != "" {
|
||||
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
|
||||
} else {
|
||||
st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db"))
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -779,69 +855,109 @@ func withStore(storageDSN, dataDir string, fn func(*store.Store) error) error {
|
||||
return fn(st)
|
||||
}
|
||||
|
||||
// CreateAPIKey 是 apikey 子命令的实现:在指定存储档上为 owner 创建密钥(明文仅打印一次)。
|
||||
func CreateAPIKey(storageDSN, dataDir, owner, name string) (string, error) {
|
||||
func withStoreMode(storageDSN, dataDir string, _ bool, fn func(*store.Store) error) error {
|
||||
var st *store.Store
|
||||
var err error
|
||||
if storageDSN != "" {
|
||||
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
|
||||
} else {
|
||||
st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db"))
|
||||
// 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"))
|
||||
} else {
|
||||
st, err = store.OpenExistingSQLite(filepath.Join(dataDir, "agent-system.db"))
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return "", err
|
||||
return err
|
||||
}
|
||||
defer st.Close()
|
||||
return st.CreateAPIKey(owner, name)
|
||||
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
|
||||
}
|
||||
|
||||
// ListAPIKeys 列出租户密钥(摘要视图)。
|
||||
func ListAPIKeys(storageDSN, dataDir, owner string) ([]store.APIKeyInfo, error) {
|
||||
var st *store.Store
|
||||
var err error
|
||||
if storageDSN != "" {
|
||||
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
|
||||
} else {
|
||||
st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db"))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer st.Close()
|
||||
if _, err := st.GetUser(owner); err != nil {
|
||||
return nil, fmt.Errorf("owner %q 不存在(先 apikey create 创建)", owner)
|
||||
}
|
||||
return st.ListAPIKeys(owner)
|
||||
var out []store.APIKeyInfo
|
||||
err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error {
|
||||
if _, err := st.GetUser(owner); err != nil {
|
||||
return fmt.Errorf("owner %q 不存在(先 apikey create 创建)", owner)
|
||||
}
|
||||
var e error
|
||||
out, e = st.ListAPIKeys(owner)
|
||||
return e
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// RevokeAPIKey 撤销指定名称密钥。
|
||||
func RevokeAPIKey(storageDSN, dataDir, owner, name string) (bool, error) {
|
||||
var st *store.Store
|
||||
var err error
|
||||
if storageDSN != "" {
|
||||
st, err = store.OpenPostgres(storageDSN, filepath.Join(dataDir, "artifacts"))
|
||||
} else {
|
||||
st, err = store.OpenSQLite(filepath.Join(dataDir, "agent-system.db"))
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer st.Close()
|
||||
return st.RevokeAPIKeyByName(owner, name)
|
||||
var ok bool
|
||||
err := withStoreMode(storageDSN, dataDir, true, func(st *store.Store) error {
|
||||
var e error
|
||||
ok, e = st.RevokeAPIKeyByName(owner, name)
|
||||
return e
|
||||
})
|
||||
return ok, err
|
||||
}
|
||||
|
||||
// EnsureSystemIdentity CLI:显式建立库内服务身份(svc init / I2→I3 引导)。
|
||||
// 只打开既有库,不建库、不迁移。
|
||||
func EnsureSystemIdentity(storageDSN, dataDir, instanceID, dbref string) error {
|
||||
return withStore(storageDSN, dataDir, func(st *store.Store) error {
|
||||
return withIdentityStore(storageDSN, dataDir, func(st *store.Store) error {
|
||||
return st.EnsureSystemIdentity(instanceID, dbref)
|
||||
})
|
||||
}
|
||||
|
||||
// VerifySystemIdentity CLI:校验库内服务身份(svc start 前;失败不插入)。
|
||||
func VerifySystemIdentity(storageDSN, dataDir, instanceID, dbref string) error {
|
||||
return withStore(storageDSN, dataDir, func(st *store.Store) error {
|
||||
return st.VerifySystemIdentity(instanceID, dbref)
|
||||
// 只打开既有库,不建库、不迁移。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
|
||||
})
|
||||
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 的整型缓存读(限流值热读取免打库)。
|
||||
|
||||
@@ -107,6 +107,11 @@ 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")
|
||||
}
|
||||
@@ -124,6 +129,15 @@ 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
|
||||
|
||||
+158
-11
@@ -4,6 +4,8 @@ import (
|
||||
"agent-system-backend/pkg/extension"
|
||||
"context"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"agent-system-backend/internal/ingest"
|
||||
@@ -19,6 +21,7 @@ import (
|
||||
// 同一实现同时服务 hostapi.Server 的 Search/Complete 闭包与进程内
|
||||
// inprocHost(EXT-1 批注 1:复用现有 owner 作用域函数,不新造句柄)。
|
||||
type hostcaps struct {
|
||||
projects *projectLifecycleSlot
|
||||
st *store.Store
|
||||
pipe *rag.Pipeline
|
||||
set *settings.Service
|
||||
@@ -28,9 +31,11 @@ type hostcaps struct {
|
||||
|
||||
// projectAuthz EXT-8 资源授权适配器槽:进程内扩展注册,组合根装配进 Server。
|
||||
type projectAuthz struct {
|
||||
mu sync.Mutex
|
||||
holder string // 持有者 pluginID(首个注册者生效)
|
||||
fn extension.ResourceAuthorizer
|
||||
mu sync.Mutex
|
||||
holder string // 持有者 pluginID(首个注册者生效)
|
||||
fn extension.ResourceAuthorizer
|
||||
holder2 string // EXT-8b 资源级持有者(首个注册者生效)
|
||||
fn2 extension.ResourceAuthorizerV2
|
||||
}
|
||||
|
||||
func (a *projectAuthz) set(pluginID string, fn extension.ResourceAuthorizer) {
|
||||
@@ -42,7 +47,41 @@ 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
|
||||
@@ -71,6 +110,19 @@ 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 {
|
||||
@@ -141,6 +193,75 @@ 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)
|
||||
}
|
||||
@@ -164,6 +285,20 @@ 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 失败降级纯全文,不阻断写入)。
|
||||
@@ -177,6 +312,9 @@ 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...)
|
||||
@@ -184,31 +322,40 @@ func (h inprocHost) KnowledgeWrite(ctx context.Context, docs []core.KnowledgeDoc
|
||||
return h.caps.pipe.IndexChunks(ctx, core.ID(h.owner), docs)
|
||||
}
|
||||
|
||||
// KnowledgePublish 幂等发布(EXT-9):按 docs 的 ref 整批替换——先清该 owner
|
||||
// 下同 ref 旧行与旧向量(跨来源也清:ref 是发布单元),再走 KnowledgeWrite
|
||||
// 语义(Owner/Source 强制同彼)。重试产生相同结果。
|
||||
// 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.
|
||||
func (h inprocHost) KnowledgePublish(ctx context.Context, docs []core.KnowledgeDoc, embed bool) error {
|
||||
if len(docs) == 0 {
|
||||
return nil
|
||||
}
|
||||
refs := map[string]bool{}
|
||||
type publicationRef struct{ projectID, ref string }
|
||||
refs := map[publicationRef]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[docs[i].Ref] = true
|
||||
refs[publicationRef{docs[i].ProjectID, docs[i].Ref}] = true
|
||||
}
|
||||
}
|
||||
scoped := h.caps.projects != nil && h.caps.projects.active()
|
||||
for ref := range refs {
|
||||
if err := h.caps.st.DeleteKnowledgeByRef(h.owner, ref); err != nil {
|
||||
return err
|
||||
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.DeleteKnowledgeVectors(ctx, h.owner, ref); err != nil {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return h.KnowledgeWrite(ctx, docs, embed)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"agent-system-backend/pkg/extension"
|
||||
"errors"
|
||||
)
|
||||
|
||||
func (r compiledRegistry) RegisterProjectSettingsAuthorizer(extension.ProjectSettingsAuthorizer) error {
|
||||
return errors.New("extension: declarative plugins cannot register project settings authorization")
|
||||
}
|
||||
|
||||
func (r pluginRegistry) RegisterProjectSettingsAuthorizer(fn extension.ProjectSettingsAuthorizer) error {
|
||||
if r.caps.projects == nil || r.pluginID == "" || fn == nil {
|
||||
return errors.New("extension: project settings authorization unavailable")
|
||||
}
|
||||
slot := r.caps.projects
|
||||
slot.mu.Lock()
|
||||
defer slot.mu.Unlock()
|
||||
if slot.settings != nil {
|
||||
return errors.New("extension: project settings authorization already registered")
|
||||
}
|
||||
if slot.provider == nil || slot.holder != r.pluginID {
|
||||
return errors.New("extension: project settings authorizer requires the registered project provider")
|
||||
}
|
||||
slot.settings = fn
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r recordRegistry) RegisterProjectSettingsAuthorizer(fn extension.ProjectSettingsAuthorizer) error {
|
||||
optional, ok := r.Registry.(extension.ProjectSettingsRegistry)
|
||||
if !ok {
|
||||
return errors.New("extension: project settings authorization unavailable")
|
||||
}
|
||||
return optional.RegisterProjectSettingsAuthorizer(fn)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"agent-system-backend/pkg/extension"
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProjectSettingsOptionalRegistrationEnforcesProviderOwnership(t *testing.T) {
|
||||
fn := func(context.Context, string, string, bool) error { return nil }
|
||||
slot := &projectLifecycleSlot{}
|
||||
reg := pluginRegistry{pluginID: "rdm", caps: hostcaps{projects: slot}}
|
||||
if err := reg.RegisterProjectSettingsAuthorizer(fn); err == nil {
|
||||
t.Fatal("settings authorizer accepted without project provider")
|
||||
}
|
||||
if err := reg.RegisterProjectLifecycle(completeLifecycle()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := (pluginRegistry{pluginID: "another", caps: hostcaps{projects: slot}}).RegisterProjectSettingsAuthorizer(fn); err == nil {
|
||||
t.Fatal("another provider took settings authority")
|
||||
}
|
||||
optional, ok := any(recordRegistry{Registry: reg}).(interface {
|
||||
RegisterProjectSettingsAuthorizer(func(context.Context, string, string, bool) error) error
|
||||
})
|
||||
if !ok {
|
||||
t.Fatal("optional callback is not compatible with old-pin anonymous assertion")
|
||||
}
|
||||
if err := optional.RegisterProjectSettingsAuthorizer(fn); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := reg.RegisterProjectSettingsAuthorizer(fn); err == nil {
|
||||
t.Fatal("duplicate settings authorizer accepted")
|
||||
}
|
||||
if err := (compiledRegistry{}).RegisterProjectSettingsAuthorizer(fn); err == nil {
|
||||
t.Fatal("declarative plugin took configuration authority")
|
||||
}
|
||||
var _ extension.ProjectSettingsRegistry = recordRegistry{}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
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)")
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
@@ -19,6 +20,21 @@ 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
|
||||
@@ -32,6 +48,12 @@ 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 {
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -27,8 +27,9 @@ var (
|
||||
const cardAppendWindow = 250 * time.Millisecond
|
||||
|
||||
type Service struct {
|
||||
st *store.Store
|
||||
emit Emitter
|
||||
st *store.Store
|
||||
emit Emitter
|
||||
projectSharedEmit ProjectSharedEmitter
|
||||
// MirrorConvCards EXT-12:会话卡是否镜像进项目公共流(缺省 true=现状全量
|
||||
// 镜像;false 时会话卡只进本人会话流,项目级卡不受影响)。
|
||||
MirrorConvCards bool
|
||||
@@ -41,6 +42,42 @@ 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>")——
|
||||
// 项目收件箱聚合视图(项目级卡 ∪ 各会话未决卡)的实时性依赖它;卡片事件
|
||||
|
||||
@@ -222,7 +222,11 @@ 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) AddIntent(spec json.RawMessage) 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) AddAdminZone(spec json.RawMessage) error { return nil }
|
||||
|
||||
func TestPresentRejectsUnknownActionIDs(t *testing.T) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
package profiles
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
@@ -50,11 +51,20 @@ func (s *Service) EnsureDefault() error {
|
||||
return s.st.SaveProfile(DefaultProfile())
|
||||
}
|
||||
|
||||
func (s *Service) List(owner string) ([]core.AgentProfile, error) { return s.st.ListProfiles(owner) }
|
||||
func (s *Service) List(owner string) ([]core.AgentProfile, error) {
|
||||
profiles, err := s.st.ListProfiles(owner)
|
||||
for i := range profiles {
|
||||
profiles[i].Model = core.ModelRoute{}
|
||||
}
|
||||
return profiles, err
|
||||
}
|
||||
|
||||
func (s *Service) Get(owner, id string) (core.AgentProfile, error) {
|
||||
p, err := s.st.GetProfile(owner, id)
|
||||
if err == nil {
|
||||
// Preserve old rows for migration, but never advertise obsolete personal
|
||||
// model overrides as available configuration or pass them to a real run.
|
||||
p.Model = core.ModelRoute{}
|
||||
return p, nil
|
||||
}
|
||||
// 空或未知画像回落默认(T0 行为基线)。
|
||||
@@ -65,6 +75,9 @@ func (s *Service) Save(owner string, p core.AgentProfile) error {
|
||||
if p.ID == DefaultProfileID {
|
||||
return errReadOnly("profiles: builtin profile is read-only; duplicate and modify")
|
||||
}
|
||||
if p.Model != (core.ModelRoute{}) {
|
||||
return errors.New("模型路由由系统管理员配置;个人画像不能覆盖模型")
|
||||
}
|
||||
if p.Tools.Allow == nil {
|
||||
p.Tools.Allow = []string{"*"}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package profiles
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSaveRejectsPersonalModelRoute(t *testing.T) {
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "profiles.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
s := New(st)
|
||||
for _, route := range []core.ModelRoute{{Chat: "personal"}, {Lightweight: "personal"}, {Embedding: "personal"}, {Rerank: "personal"}} {
|
||||
if err := s.Save("alice", core.AgentProfile{ID: "personal", Model: route}); err == nil {
|
||||
t.Fatal("system managed route accepted in personal profile")
|
||||
}
|
||||
}
|
||||
if err := s.Save("alice", core.AgentProfile{ID: "legal", Name: "Personal assistant"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegacyProfileRoutesRemainStoredButAreNotProjected(t *testing.T) {
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "legacy.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
legacy := core.AgentProfile{ID: "old", Owner: "alice", Name: "Legacy", Model: core.ModelRoute{Chat: "old-model"}}
|
||||
if err := st.SaveProfile(legacy); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := New(st)
|
||||
p, err := s.Get("alice", "old")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Model != (core.ModelRoute{}) {
|
||||
t.Fatal("obsolete model-route projection claims personal override remains available")
|
||||
}
|
||||
list, err := s.List("alice")
|
||||
if err != nil || len(list) != 1 || list[0].Model != (core.ModelRoute{}) {
|
||||
t.Fatal("obsolete route exposed in profile list")
|
||||
}
|
||||
raw, err := st.GetProfile("alice", "old")
|
||||
if err != nil || raw.Model.Chat != "old-model" {
|
||||
t.Fatal("legacy recovery route was deleted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
// 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
|
||||
}
|
||||
+52
-4
@@ -174,19 +174,66 @@ 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 {
|
||||
return p.st.SearchKnowledge(owner, q)
|
||||
hits, err := p.st.SearchKnowledge(owner, q)
|
||||
return authorizedHits(hits, allow), err
|
||||
}
|
||||
route := p.embeddingRoute(ctx)
|
||||
offline, _ := p.embedder.(interface{ Offline() bool })
|
||||
if !route.OK() && (offline == nil || !offline.Offline()) {
|
||||
return p.st.SearchKnowledge(owner, q)
|
||||
hits, err := p.st.SearchKnowledge(owner, q)
|
||||
return authorizedHits(hits, allow), err
|
||||
}
|
||||
qvecs, err := p.embedder.Embed(ctx, []string{q.Text})
|
||||
if err != nil || len(qvecs) == 0 || len(qvecs[0]) == 0 {
|
||||
@@ -194,7 +241,8 @@ 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)
|
||||
}
|
||||
return p.st.SearchKnowledge(owner, q)
|
||||
hits, err := p.st.SearchKnowledge(owner, q)
|
||||
return authorizedHits(hits, allow), err
|
||||
}
|
||||
q.TopK = topK * 2 // 每路多取,融合后再裁剪
|
||||
vhits, err := p.st.SearchKnowledgeVectorsModel(ctx, owner, embedderModel(p.embedder), qvecs[0], topK*2, q)
|
||||
@@ -205,7 +253,7 @@ func (p *Pipeline) Retrieve(ctx context.Context, owner core.ID, q core.Retrieval
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
merged := rrfMerge(vhits, fhits)
|
||||
merged := authorizedHits(rrfMerge(vhits, fhits), allow)
|
||||
if p.reranker != nil && p.rerankRoute(ctx).OK() && len(merged) > 0 {
|
||||
docs := make([]core.KnowledgeDoc, len(merged))
|
||||
for i, h := range merged {
|
||||
|
||||
@@ -162,8 +162,9 @@ func TestRetrieveFallbackAndHybrid(t *testing.T) {
|
||||
}
|
||||
|
||||
// 有 embedder 且路由已配置:查询向量 A 类 → A 文档排前(RRF 向量路主导)。
|
||||
// P2 受管:models.* 是系统受管项,配置写入系统层行(owner=OwnerSystemRow)。
|
||||
pipe2, st2 := newTestPipeline(t, fakeEmbedder{}, nil)
|
||||
if err := st2.MergeSettingsScope(store.OwnerDefault, settings.ScopeUser, map[string]any{
|
||||
if err := st2.MergeSettingsScope(settings.OwnerSystemRow, settings.ScopeSystem, map[string]any{
|
||||
"models": map[string]any{"embedding": map[string]any{"baseUrl": "http://fake.internal", "model": "e-test"}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
|
||||
@@ -120,18 +120,67 @@ func TestBackgroundCardLifecycleOnFailure(t *testing.T) {
|
||||
t.Fatalf("cards = %d, want 1", len(cards))
|
||||
}
|
||||
card := cards[0]
|
||||
if card.Status != core.CardDone || card.Tone != "danger" {
|
||||
t.Fatalf("status = %s tone = %s, want done/danger", card.Status, card.Tone)
|
||||
if card.Status != core.CardFailed || card.Tone != "danger" {
|
||||
t.Fatalf("status = %s tone = %s, want failed/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, "执行失败") {
|
||||
t.Fatalf("failure body = %q", md)
|
||||
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)时后台派发
|
||||
|
||||
@@ -116,6 +116,34 @@ 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()
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -26,6 +28,11 @@ func StageWorkspaceInputs(root, owner, conversation string, files []WorkspaceFil
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Only remove a copy whose protected provenance still matches its content.
|
||||
// Unknown legacy files and user-modified originals are never deleted by name.
|
||||
if err := clearGeneratedTemplate(dir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rels []string
|
||||
used := map[string]bool{}
|
||||
for _, file := range files {
|
||||
@@ -33,6 +40,9 @@ func StageWorkspaceInputs(root, owner, conversation string, files []WorkspaceFil
|
||||
if !ok || len(file.Content) == 0 {
|
||||
continue
|
||||
}
|
||||
if generatedTemplateName(name) {
|
||||
name = "uploaded-" + name
|
||||
}
|
||||
name = uniqueInputName(name, used)
|
||||
if err := os.WriteFile(filepath.Join(dir, name), file.Content, 0o644); err != nil {
|
||||
return rels, err
|
||||
@@ -47,14 +57,64 @@ func StageWorkspaceInputs(root, owner, conversation string, files []WorkspaceFil
|
||||
ext = filepath.Ext(base)
|
||||
}
|
||||
name := "_default-template" + ext
|
||||
existing := map[string]bool{}
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return rels, err
|
||||
}
|
||||
for _, entry := range entries {
|
||||
existing[entry.Name()] = true
|
||||
}
|
||||
name = uniqueInputName(name, existing)
|
||||
if err := os.WriteFile(filepath.Join(dir, name), template, 0o644); err != nil {
|
||||
return rels, err
|
||||
}
|
||||
manifest, _ := json.Marshal(templateProvenance{Name: name, Hash: fmt.Sprintf("%x", sha256.Sum256(template))})
|
||||
if err := os.WriteFile(filepath.Join(dir, templateProvenanceFile), manifest, 0600); err != nil {
|
||||
return rels, err
|
||||
}
|
||||
rels = append(rels, "inputs/"+name)
|
||||
}
|
||||
return rels, nil
|
||||
}
|
||||
|
||||
const templateProvenanceFile = ".default-template-origin.json"
|
||||
|
||||
type templateProvenance struct {
|
||||
Name string `json:"name"`
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
func generatedTemplateName(name string) bool {
|
||||
return name == "_default-template" || strings.HasPrefix(name, "_default-template.") || strings.HasPrefix(name, "_default-template-")
|
||||
}
|
||||
|
||||
func clearGeneratedTemplate(dir string) error {
|
||||
path := filepath.Join(dir, templateProvenanceFile)
|
||||
raw, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var provenance templateProvenance
|
||||
if json.Unmarshal(raw, &provenance) == nil {
|
||||
if name, valid := safeInputName(provenance.Name); valid && name == provenance.Name && generatedTemplateName(name) {
|
||||
body, err := os.ReadFile(filepath.Join(dir, name))
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err == nil && fmt.Sprintf("%x", sha256.Sum256(body)) == provenance.Hash {
|
||||
if err := os.Remove(filepath.Join(dir, name)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return os.Remove(path)
|
||||
}
|
||||
|
||||
// PersistInputTexts 把附件解析出的纯文本写成 inputs/<原名>.md,与原件并列。
|
||||
// 后续轮次的上下文里原件只剩占位时,模型仍能读到可检索的原文文本——
|
||||
// 「根据纪要制定方案」不再只能依赖上一轮产物(实测后续轮 0 次读原件)。
|
||||
@@ -97,10 +157,17 @@ func ListWorkspaceInputs(root, owner, conversation string, staged []string) []st
|
||||
return staged
|
||||
}
|
||||
var out []string
|
||||
current := map[string]bool{}
|
||||
for _, path := range staged {
|
||||
current[path] = true
|
||||
}
|
||||
for _, e := range ents {
|
||||
if e.IsDir() || strings.HasPrefix(e.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
if generatedTemplateName(e.Name()) && !current["inputs/"+e.Name()] {
|
||||
continue
|
||||
}
|
||||
out = append(out, "inputs/"+e.Name())
|
||||
}
|
||||
if len(out) == 0 {
|
||||
@@ -113,8 +180,8 @@ func (m *Manager) stageInputs(conv core.Conversation, eff core.EffectiveSettings
|
||||
var templateName string
|
||||
var template []byte
|
||||
if id := strings.TrimSpace(eff.GetString("deliverables.templateArtifactId", "")); id != "" && m.st != nil {
|
||||
if art, err := m.st.GetArtifact(conv.Owner, id); err == nil {
|
||||
if body, err := m.st.ArtifactContent(conv.Owner, id); err == nil && len(body) > 0 {
|
||||
if art, err := m.st.GetArtifactByID(id); err == nil && art.ProjectID == conv.ProjectID && m.templateAllowed(conv.Owner, art) {
|
||||
if body, err := m.st.ArtifactContentByID(id); err == nil && len(body) > 0 && m.templateAllowed(conv.Owner, art) {
|
||||
templateName, template = art.Name, body
|
||||
}
|
||||
}
|
||||
@@ -122,6 +189,13 @@ func (m *Manager) stageInputs(conv core.Conversation, eff core.EffectiveSettings
|
||||
return StageWorkspaceInputs(m.workspaceRoot, conv.Owner, conv.ID, files, templateName, template)
|
||||
}
|
||||
|
||||
func (m *Manager) templateAllowed(owner string, artifact core.Artifact) bool {
|
||||
if m.artifactResourceAllow != nil {
|
||||
return m.artifactResourceAllow(owner, artifact)
|
||||
}
|
||||
return artifact.Owner == owner
|
||||
}
|
||||
|
||||
func safeInputName(name string) (string, bool) {
|
||||
base := filepath.Base(strings.TrimSpace(name))
|
||||
if base == "" || base == "." || base == ".." || strings.HasPrefix(base, ".") {
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -79,7 +79,23 @@ func TestMaterialIntakeAttachmentOnlyLifecycle(t *testing.T) {
|
||||
}
|
||||
|
||||
close(hold.release)
|
||||
waitUntil(t, func() bool { return len(systemTexts(t, c)) >= 1 })
|
||||
// 等完成去重门禁本身(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
|
||||
})
|
||||
|
||||
// 完成后同批再发:已完成说明,同样不重跑;卡片回投保留材料键。
|
||||
c3 := secondRunCtx(c, materialDocs(), "")
|
||||
@@ -259,7 +275,7 @@ func TestMaterialQueuedWhenFull(t *testing.T) {
|
||||
return false
|
||||
}
|
||||
for _, card := range cards {
|
||||
if card.Title == materialCardTitle && card.SourceRunID != "" && strings.Contains(cardBodyText(card), "执行中") {
|
||||
if card.Title == materialCardTitle && card.SourceRunID != "" && strings.Contains(cardBodyText(card), core.TaskSummaryStatusRunning) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
+152
-20
@@ -57,7 +57,11 @@ type Manager struct {
|
||||
retriever core.Retriever // 可选混合检索(nil = store 全文)
|
||||
// sharedKnowledgeAllow EXT-13:回合内检索的共享授权谓词(请求 owner × 知识所属
|
||||
// projectID);nil = 未注册(现状:仅本人两路,不触碰共享通道)。
|
||||
sharedKnowledgeAllow func(owner string, projectID core.ID) bool
|
||||
sharedKnowledgeAllow func(owner string, projectID core.ID) bool
|
||||
knowledgeResourceAllow func(owner string, doc core.KnowledgeDoc) bool
|
||||
artifactResourceAllow func(owner string, artifact core.Artifact) bool
|
||||
knowledgeProjectScope func(context.Context, string, core.ID) (core.ID, error)
|
||||
projectWorkAuthorization func(context.Context, string, core.ID) (bool, error)
|
||||
// commands EXT-14:命令即工具的投影与分发源(nil = 未接线,命令不进工具清单)。
|
||||
commands CommandSource
|
||||
indexer core.DocumentIndexer // 可选索引入口(nil = store 整文档单块)
|
||||
@@ -176,6 +180,12 @@ 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 }
|
||||
|
||||
@@ -197,6 +207,43 @@ 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 }
|
||||
|
||||
@@ -596,7 +643,7 @@ func (c *runCtx) SetAssistantParts(parts []core.Part) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *runCtx) AskHITL(card core.HITLCard) (core.HITLResponse, error) {
|
||||
func (c *runCtx) AskHITL(card core.HITLCard) (answer core.HITLResponse, err error) {
|
||||
card.ID = store.NewID("hitl")
|
||||
card.RunID = c.run.ID
|
||||
card.ConversationID = c.run.ConversationID
|
||||
@@ -638,8 +685,13 @@ func (c *runCtx) AskHITL(card core.HITLCard) (core.HITLResponse, error) {
|
||||
// 排队,取消语境下重取失败由取消路径收口)。
|
||||
c.mgr.adm.release(c.mgr.st, string(c.run.ID))
|
||||
defer func() {
|
||||
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)
|
||||
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)
|
||||
}
|
||||
}()
|
||||
return waitHITL(c.ctx, c.h.hitlCh, timeout)
|
||||
@@ -842,6 +894,9 @@ 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
|
||||
@@ -902,6 +957,40 @@ 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 {
|
||||
@@ -917,6 +1006,9 @@ 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)}
|
||||
@@ -937,12 +1029,18 @@ 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 {
|
||||
@@ -1141,6 +1239,9 @@ 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
|
||||
}
|
||||
@@ -1150,6 +1251,9 @@ 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
|
||||
}
|
||||
@@ -1259,6 +1363,10 @@ 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{
|
||||
@@ -1276,7 +1384,16 @@ func (m *Manager) startClaimedRun(conv core.Conversation, userMessage core.Messa
|
||||
m.mu.Unlock()
|
||||
|
||||
runner := selectConversationRunner(m, eff, launch)
|
||||
inputPaths, _ := m.stageInputs(conv, eff, inputs)
|
||||
inputPaths, stageErr := m.stageInputs(conv, eff, inputs)
|
||||
if stageErr != nil {
|
||||
cancel()
|
||||
m.mu.Lock()
|
||||
delete(m.active, run.ID)
|
||||
m.mu.Unlock()
|
||||
m.releaseConversation(conv.ID)
|
||||
_ = m.st.UpdateRun(&core.Run{ID: run.ID, Status: core.RunFailed, Error: &core.ErrorPart{Code: "run.input_staging_failed", Message: "运行输入准备失败"}})
|
||||
return run, fmt.Errorf("stage run inputs: %w", stageErr)
|
||||
}
|
||||
PersistInputTexts(m.workspaceRoot, conv.Owner, conv.ID, attachments)
|
||||
inputPaths = ListWorkspaceInputs(m.workspaceRoot, conv.Owner, conv.ID, inputPaths)
|
||||
rc := &runCtx{mgr: m, run: run, prof: prof, eff: eff, ctx: ctx, h: h, userMessage: userMessage, attachments: attachments, inputPaths: inputPaths, launch: launch, files: filestate.New(), permissionMode: conversationMode(m, run.Owner, run.ConversationID)}
|
||||
@@ -1581,6 +1698,12 @@ 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 永远等不到答案,
|
||||
@@ -1625,6 +1748,9 @@ 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})
|
||||
@@ -1635,6 +1761,9 @@ 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
|
||||
@@ -1947,13 +2076,14 @@ func (m *Manager) createBackgroundCard(parent core.Run, childID core.ID, name, t
|
||||
if title == "" {
|
||||
title = "后台任务"
|
||||
}
|
||||
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})},
|
||||
_ = task // 派发说明保留入参兼容;用户可见正文用 L1 骨架,不回填原始指令。
|
||||
statusLabel := core.TaskSummaryStatusRunning
|
||||
if status != "" {
|
||||
statusLabel = 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,
|
||||
@@ -1976,9 +2106,9 @@ func (m *Manager) promoteQueuedCard(owner string, card core.ActivityCard, childI
|
||||
if node.Type != core.UIKV {
|
||||
continue
|
||||
}
|
||||
for j, kv := range node.KV {
|
||||
if kv[0] == "状态" {
|
||||
card.Body[i].KV[j][1] = "执行中,完成后自动回投结果"
|
||||
for j, pair := range node.KV {
|
||||
if pair[0] == core.TaskSummaryKVStatus || pair[0] == "状态" {
|
||||
card.Body[i].KV[j][1] = core.TaskSummaryStatusRunning
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1988,15 +2118,19 @@ func (m *Manager) promoteQueuedCard(owner string, card core.ActivityCard, childI
|
||||
}
|
||||
|
||||
// finishBackgroundCard 把后台子代理终态写回进度卡:成功=结果摘要(含工件行),
|
||||
// 失败=错误说明;状态统一置 done,tone 区分成败。kv 原样保留(材料键等持久凭据)。
|
||||
// 失败=错误说明并置 CardFailed(U1:失败不得显示完成灯)。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()
|
||||
}
|
||||
@@ -2005,14 +2139,12 @@ func (m *Manager) finishBackgroundCard(owner string, card core.ActivityCard, pay
|
||||
if summary == "" {
|
||||
summary = "(无结果摘要)"
|
||||
}
|
||||
card.Body = []core.UINode{
|
||||
{Type: core.UIMarkdown, Markdown: summary},
|
||||
{Type: core.UIKV, KV: append(append([][2]string(nil), kv...), [2]string{"通道", "慢通道 · 后台执行"}, [2]string{"状态", "已完成"})},
|
||||
}
|
||||
extra := append(append([][2]string(nil), kv...), [2]string{"通道", "慢通道 · 后台执行"})
|
||||
card.Body = (core.TaskSummaryView{OutcomeLine: summary}).FinishedBody(statusLabel, extra)
|
||||
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, core.CardDone, tone, ""); serr != nil {
|
||||
if _, serr := m.cards.SetStatus(owner, card.ID, status, tone, ""); serr != nil {
|
||||
log.Printf("[runmgr] background card status: %v", serr)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package runmgr
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProjectTemplateCannotReadAnotherProjectsArtifact(t *testing.T) {
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "templates.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
p, _ := st.CreateProject("alice", "Current")
|
||||
other, _ := st.CreateProject("alice", "Other")
|
||||
artifact := core.Artifact{Owner: "alice", ProjectID: other.ID, Name: "private-template.docx"}
|
||||
if err := st.CreateArtifact(&artifact, []byte("other project private template")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := &Manager{st: st, workspaceRoot: t.TempDir()}
|
||||
paths, err := m.stageInputs(core.Conversation{ID: "conversation", Owner: "alice", ProjectID: p.ID}, core.EffectiveSettings{"deliverables": map[string]any{"templateArtifactId": artifact.ID}}, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(paths) != 0 {
|
||||
t.Fatal("template settings read another project's artifact")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedTemplateUsesLiveResourceAuthorityAndClearsRevokedCopy(t *testing.T) {
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "templates.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
p, _ := st.CreateProject("alice", "Shared")
|
||||
artifact := core.Artifact{Owner: "alice", ProjectID: p.ID, Name: "template.docx"}
|
||||
if err := st.CreateArtifact(&artifact, []byte("shared template")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m := &Manager{st: st, workspaceRoot: t.TempDir()}
|
||||
allowed := true
|
||||
m.SetArtifactResourceAllow(func(actor string, a core.Artifact) bool { return allowed && actor == "bob" && a.ID == artifact.ID })
|
||||
conv := core.Conversation{ID: "bob-private", Owner: "bob", ProjectID: p.ID}
|
||||
eff := core.EffectiveSettings{"deliverables": map[string]any{"templateArtifactId": artifact.ID}}
|
||||
paths, err := m.stageInputs(conv, eff, []WorkspaceFile{{Name: "original.txt", Content: []byte("private original")}})
|
||||
if err != nil || len(paths) != 2 {
|
||||
t.Fatalf("authorized template: %v %v", paths, err)
|
||||
}
|
||||
allowed = false
|
||||
paths, err = m.stageInputs(conv, eff, nil)
|
||||
if err != nil || len(paths) != 0 {
|
||||
t.Fatalf("revoked template: %v %v", paths, err)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(m.workspaceRoot, "bob", conv.ID, "inputs", "_default-template.docx")); !os.IsNotExist(err) {
|
||||
t.Fatal("revoked cached template still available")
|
||||
}
|
||||
if body, err := os.ReadFile(filepath.Join(m.workspaceRoot, "bob", conv.ID, "inputs", "original.txt")); err != nil || string(body) != "private original" {
|
||||
t.Fatal("revocation removed private uploaded original")
|
||||
}
|
||||
// Matching ownership also goes through the registered resource policy.
|
||||
conv.Owner = "alice"
|
||||
if paths, err := m.stageInputs(conv, eff, nil); err != nil || len(paths) != 0 {
|
||||
t.Fatal("owner bypassed live template resource policy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemplateStagingPreservesUntrackedLegacyOriginal(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
dir := filepath.Join(root, "alice", "conversation", "inputs")
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
legacy := filepath.Join(dir, "_default-template.docx")
|
||||
if err := os.WriteFile(legacy, []byte("private original with legacy reserved name"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
paths, err := StageWorkspaceInputs(root, "alice", "conversation", nil, "new-template.docx", []byte("authorized generated template"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body, err := os.ReadFile(legacy); err != nil || string(body) != "private original with legacy reserved name" {
|
||||
t.Fatal("unknown legacy file deleted or overwritten")
|
||||
}
|
||||
if len(paths) != 1 || paths[0] == "inputs/_default-template.docx" {
|
||||
t.Fatal("new generated file reused unknown original path")
|
||||
}
|
||||
if _, err := StageWorkspaceInputs(root, "alice", "conversation", nil, "", nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if body, err := os.ReadFile(legacy); err != nil || string(body) != "private original with legacy reserved name" {
|
||||
t.Fatal("revocation removed unknown original")
|
||||
}
|
||||
}
|
||||
@@ -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 失败链测试)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package runtime
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
func TestManagedModelsIgnorePersistedProfileRoutes(t *testing.T) {
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "models.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
svc := settings.NewService(st)
|
||||
if err := svc.MergeSystem(map[string]any{"models": map[string]any{
|
||||
"chat": map[string]any{"model": "system-chat", "baseUrl": "https://system.example", "apiKey": "system-key"},
|
||||
"lightweight": map[string]any{"model": "system-lite", "baseUrl": "https://lite.example", "apiKey": "lite-key"},
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
prof := core.AgentProfile{Owner: "alice", Model: core.ModelRoute{Chat: "old-personal-chat", Lightweight: "old-personal-lite"}}
|
||||
eff := svc.Effective("alice", "project")
|
||||
if got := ResolveChatModel(prof, eff); got.Model != "system-chat" || got.Key != "system-key" {
|
||||
t.Fatalf("personal chat route still effective: %+v", got)
|
||||
}
|
||||
if got := ResolveLightweightModel(prof, eff); got.Model != "system-lite" || got.Key != "lite-key" {
|
||||
t.Fatalf("personal lightweight route still effective: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,8 @@ import (
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// SecretLookup 由组合根注入。返回该服务当前生效的明文;没有独立密钥时 ok 为 false,调用方用设置里的旧字段。
|
||||
// SecretLookup is wired by the composition root. Protected services resolve only
|
||||
// system authority; ok=true with an empty value means explicit revocation.
|
||||
var SecretLookup func(owner, service string) (string, bool)
|
||||
|
||||
// LookupSecret 给组合根以外的调用方使用。owner 为空时用 local。
|
||||
@@ -21,15 +22,15 @@ func overlayKey(owner, service, fallback string) string {
|
||||
if owner == "" {
|
||||
owner = "local"
|
||||
}
|
||||
if v, ok := SecretLookup(owner, service); ok && strings.TrimSpace(v) != "" {
|
||||
if v, ok := SecretLookup(owner, service); ok {
|
||||
return v
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// 模型路由解析(4.7 / 4.9 覆盖链):设置层 defaults → system → user → project,
|
||||
// 画像 ModelRoute 为会话内最高层覆盖(kind 为空回落设置层主模型)。
|
||||
// 端点与密钥按 kind 独立配置,轻量档空则回落主档。
|
||||
// Real Service.Effective snapshots carry the trusted managed-model policy and
|
||||
// ignore legacy profile routes. Unmanaged standalone runtime snapshots retain
|
||||
// their own profile semantics. Lightweight model/endpoint falls back to chat.
|
||||
|
||||
// ContextInjection 是一条条件注入,由 runmgr 从存储读出,组装时再转换成 ctxasm 的形状。
|
||||
type ContextInjection struct {
|
||||
@@ -50,10 +51,10 @@ type ResolvedModel struct {
|
||||
Key string
|
||||
}
|
||||
|
||||
// ResolveChatModel 解析主对话模型(画像 route > 设置层)。
|
||||
// ResolveChatModel resolves system-managed chat settings in actual runs.
|
||||
func ResolveChatModel(prof core.AgentProfile, eff core.EffectiveSettings) ResolvedModel {
|
||||
m := eff.GetString("models.chat.model", "")
|
||||
if prof.Model.Chat != "" {
|
||||
if !eff.GetBool("_policy.managedModels", false) && prof.Model.Chat != "" {
|
||||
m = prof.Model.Chat
|
||||
}
|
||||
return ResolvedModel{Kind: "chat", Model: m,
|
||||
@@ -80,12 +81,12 @@ func ChatFallbacks(eff core.EffectiveSettings) []string {
|
||||
return out
|
||||
}
|
||||
|
||||
// ResolveLightweightModel 解析轻量模型:画像 route > 设置层轻量档 > 回落主档。
|
||||
// ResolveLightweightModel resolves the managed lightweight route, then chat.
|
||||
func ResolveLightweightModel(prof core.AgentProfile, eff core.EffectiveSettings) ResolvedModel {
|
||||
m := eff.GetString("models.lightweight.model", "")
|
||||
base := eff.GetString("models.lightweight.baseUrl", "")
|
||||
key := overlayKey(prof.Owner, "models.lightweight", eff.GetString("models.lightweight.apiKey", ""))
|
||||
if prof.Model.Lightweight != "" {
|
||||
if !eff.GetBool("_policy.managedModels", false) && prof.Model.Lightweight != "" {
|
||||
m = prof.Model.Lightweight
|
||||
}
|
||||
if m == "" || base == "" {
|
||||
|
||||
+177
-29
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
@@ -60,20 +61,73 @@ func loginClear(owner string) {
|
||||
delete(loginGuard.fails, owner)
|
||||
}
|
||||
|
||||
func setSessionCookie(w http.ResponseWriter, token string, maxAge int) {
|
||||
// 兑换入口退避:共享库窗口计数(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) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: SessionCookie, Value: token, Path: "/", HttpOnly: true,
|
||||
SameSite: http.SameSiteLaxMode, MaxAge: maxAge,
|
||||
SameSite: http.SameSiteLaxMode, MaxAge: maxAge, Secure: requestSecure(r),
|
||||
})
|
||||
}
|
||||
|
||||
func authStateJSON(st store.UserAuthState) map[string]any {
|
||||
return map[string]any{
|
||||
"owner": st.Owner, "name": st.Name, "isAdmin": st.IsAdmin,
|
||||
"mustChangePassword": st.MustChangePassword, "auth": true,
|
||||
"mustChangePassword": st.MustChangePassword, "passwordSet": st.HasPassword,
|
||||
"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) {
|
||||
@@ -98,20 +152,34 @@ func (s *Server) login(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "too many failed attempts; retry later", http.StatusTooManyRequests)
|
||||
return
|
||||
}
|
||||
st, ok := s.st.VerifyLogin(owner, req.Password)
|
||||
st, ver, ok := s.st.VerifyLoginWithVersion(owner, req.Password)
|
||||
if !ok {
|
||||
loginFail(owner)
|
||||
http.Error(w, "invalid owner or password", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
loginClear(owner)
|
||||
token, err := s.st.CreateSession(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)
|
||||
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, token, 7*24*3600)
|
||||
writeJSON(w, http.StatusOK, authStateJSON(st))
|
||||
setSessionCookie(w, r, token, maxAge)
|
||||
out := authStateJSON(st)
|
||||
out["sessionScope"] = scope
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// logout:注销当前会话。
|
||||
@@ -119,19 +187,21 @@ func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
|
||||
if tok := sessionToken(r); tok != "" {
|
||||
s.st.DeleteSession(tok)
|
||||
}
|
||||
setSessionCookie(w, "", -1)
|
||||
setSessionCookie(w, r, "", -1)
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
|
||||
// changePassword:会话登录者改密。校验当前密码 → 新哈希落库 → 清 must_change →
|
||||
// 吊销其余会话(保留当前)。重置后的首登改密走同一路径。
|
||||
// changePassword:会话登录者改密。
|
||||
// - 受限会话或 must_change/未设密:不要求 current;事务内复验会话与认证版本,
|
||||
// 完成后销毁全部会话,要求重新密码登录(reloginRequired=true)。
|
||||
// - 普通主动改密:要求 current;单事务完成校验、更新、吊销其他会话(保留当前)。
|
||||
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, ok := s.st.SessionOwner(tok)
|
||||
owner, scope, ok := s.st.SessionInfo(tok)
|
||||
if !ok {
|
||||
http.Error(w, "session expired", http.StatusUnauthorized)
|
||||
return
|
||||
@@ -144,10 +214,6 @@ 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
|
||||
@@ -157,18 +223,36 @@ func (s *Server) changePassword(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.st.SetUserPassword(owner, hash); err != nil {
|
||||
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
|
||||
}
|
||||
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)
|
||||
writeJSON(w, http.StatusOK, authStateJSON(st))
|
||||
out := authStateJSON(st)
|
||||
out["reloginRequired"] = false
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
func validPassword(pw string) error {
|
||||
@@ -202,7 +286,8 @@ func (s *Server) listAdminUsers(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireAdmin(w, r); !ok {
|
||||
actor, ok := s.requireAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
@@ -229,7 +314,7 @@ func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.st.CreateAccount(req.Owner, strings.TrimSpace(req.Name), hash, req.IsAdmin); err != nil {
|
||||
if _, err := s.st.CreateActivatedAccount(store.AccountWrite{Owner: req.Owner, Name: strings.TrimSpace(req.Name), PasswordHash: hash, IsAdmin: req.IsAdmin, Actor: actor, Action: "account.create"}); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
@@ -240,9 +325,12 @@ func (s *Server) createAdminUser(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// resetAdminUserPassword:管理员重置密码 → must_change=1,吊销该用户全部会话。
|
||||
// resetAdminUserPassword:管理员直接重置密码(兼容保留的既有入口;常规恢复
|
||||
// 走签发一次性登录 Key)。单事务:must_change=1、bump auth_version、作废未用
|
||||
// 登录 Key、吊销全部会话、审计。
|
||||
func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireAdmin(w, r); !ok {
|
||||
actor, ok := s.requireAdmin(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
owner := r.PathValue("owner")
|
||||
@@ -262,17 +350,77 @@ func (s *Server) resetAdminUserPassword(w http.ResponseWriter, r *http.Request)
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
if err := s.st.SetUserPassword(owner, hash); err != nil {
|
||||
if err := s.st.AdminResetUserPassword(owner, hash, actor, "admin-http"); 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)
|
||||
|
||||
+82
-18
@@ -14,13 +14,16 @@ 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:user 即租户)。
|
||||
// Authenticator 是 API key 认证(阶段 1:owner 即租户)。
|
||||
// Enabled=false(嵌入式档默认,回环本地单用户)时全部请求回落默认租户。
|
||||
// SSE 的 EventSource 无法带 Authorization 头,允许 ?key= 查询参数兜底。
|
||||
type Authenticator struct {
|
||||
@@ -41,41 +44,94 @@ func (a *Authenticator) Middleware(next http.Handler) http.Handler {
|
||||
next.ServeHTTP(w, r.WithContext(withOwner(r, store.OwnerDefault)))
|
||||
return
|
||||
}
|
||||
// 豁免:静态前端(登录页须可加载)与登录入口本身。
|
||||
p := r.URL.Path
|
||||
if !strings.HasPrefix(p, "/api/") || p == "/api/auth/login" {
|
||||
// 豁免:静态前端、登录、一次性 Key 兑换(body 专用,不走 Bearer)。
|
||||
if !strings.HasPrefix(p, "/api/") || p == "/api/auth/login" || p == "/api/auth/login-key/redeem" {
|
||||
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 owner, ok := a.st.AuthOwner(key); ok {
|
||||
next.ServeHTTP(w, r.WithContext(withAuthVia(withOwner(r, owner), AuthViaKey)))
|
||||
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)
|
||||
return
|
||||
}
|
||||
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)
|
||||
} 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)
|
||||
return
|
||||
}
|
||||
if owner, ok := cookieOwner(r, a.st); ok {
|
||||
next.ServeHTTP(w, r.WithContext(withAuthVia(withOwner(r, owner), AuthViaSession)))
|
||||
ctx := withAuthVia(withOwner(r, owner), via)
|
||||
ctx = context.WithValue(ctx, sessionScopeCtxKey{}, scope)
|
||||
if err := a.enforcePasswordGate(w, r, owner, via, scope, p); err != nil {
|
||||
return
|
||||
}
|
||||
http.Error(w, "unauthorized: login required", http.StatusUnauthorized)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
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 != "" {
|
||||
@@ -108,6 +164,14 @@ 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 ") {
|
||||
|
||||
@@ -321,10 +321,29 @@ func TestAdminAccountFlow(t *testing.T) {
|
||||
return rec
|
||||
}
|
||||
|
||||
// 会话可访问 API;/api/me 携带身份与管理员位。
|
||||
// /api/me 允许 must_change;业务 API 拒绝(U4 设密门)。
|
||||
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 {
|
||||
@@ -335,7 +354,7 @@ func TestAdminAccountFlow(t *testing.T) {
|
||||
t.Fatalf("duplicate owner must 409: %d", rec.Code)
|
||||
}
|
||||
|
||||
// alice 首登:mustChangePassword=true;改密后清除。
|
||||
// alice 首登:mustChangePassword=true;业务 API 被设密门拒绝。
|
||||
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()
|
||||
@@ -358,16 +377,19 @@ 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("non-admin create must 403: %d", rec.Code)
|
||||
t.Fatalf("must_change non-admin create must 403: %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)
|
||||
// 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)
|
||||
}
|
||||
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())
|
||||
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)
|
||||
}
|
||||
// 旧密码不能再登录;新密码可以且无需改密。
|
||||
req = httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"owner":"alice","password":"alice-init-01"}`))
|
||||
@@ -377,6 +399,18 @@ 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 {
|
||||
@@ -493,3 +527,79 @@ 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", ""))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
|
||||
agentcommand "agent-command"
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
@@ -15,7 +16,13 @@ var userCommandSlug = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
||||
|
||||
func (s *Server) listSecrets(w http.ResponseWriter, r *http.Request) {
|
||||
list, err := s.st.ListSecrets(s.owner(r))
|
||||
writeJSONOr(w, list, err)
|
||||
filtered := make([]store.Secret, 0, len(list))
|
||||
for _, secret := range list {
|
||||
if !settings.ProtectedSecretService(secret.Service) {
|
||||
filtered = append(filtered, secret)
|
||||
}
|
||||
}
|
||||
writeJSONOr(w, filtered, err)
|
||||
}
|
||||
|
||||
func (s *Server) createSecret(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -28,6 +35,10 @@ func (s *Server) createSecret(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if settings.ProtectedSecretService(strings.TrimSpace(req.Service)) {
|
||||
http.Error(w, "模型和搜索服务密钥只能在系统设置中配置", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
sec, err := s.st.AddSecret(s.owner(r), req.Service, req.Label, req.Value)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
@@ -37,10 +48,16 @@ func (s *Server) createSecret(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) activateSecret(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.mutablePersonalSecret(w, r) {
|
||||
return
|
||||
}
|
||||
writeOKOr(w, s.st.ActivateSecret(s.owner(r), r.PathValue("id")))
|
||||
}
|
||||
|
||||
func (s *Server) renameSecret(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.mutablePersonalSecret(w, r) {
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Label string `json:"label"`
|
||||
}
|
||||
@@ -52,9 +69,27 @@ func (s *Server) renameSecret(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) deleteSecret(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.mutablePersonalSecret(w, r) {
|
||||
return
|
||||
}
|
||||
writeOKOr(w, s.st.DeleteSecret(s.owner(r), r.PathValue("id")))
|
||||
}
|
||||
|
||||
func (s *Server) mutablePersonalSecret(w http.ResponseWriter, r *http.Request) bool {
|
||||
list, err := s.st.ListSecrets(s.owner(r))
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return false
|
||||
}
|
||||
for _, secret := range list {
|
||||
if secret.ID == r.PathValue("id") && settings.ProtectedSecretService(secret.Service) {
|
||||
http.Error(w, "旧模型和搜索服务密钥已退出生效链;请在系统设置中配置", http.StatusBadRequest)
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) listContextEntries(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
list, err := s.st.ListContextEntries(s.owner(r), q.Get("projectId"), q.Get("conversationId"))
|
||||
@@ -164,6 +199,10 @@ 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"`
|
||||
}
|
||||
|
||||
+116
-51
@@ -48,12 +48,14 @@ 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{})
|
||||
@@ -130,6 +132,17 @@ func stubTargets(prompt string) []string {
|
||||
|
||||
func doJSON(t *testing.T, h http.Handler, method, path string, body any) (int, map[string]any) {
|
||||
t.Helper()
|
||||
// Existing fixture callers provide desired values; public writes carry the
|
||||
// freshly read revision. Tests of stale/legacy requests use raw HTTP directly.
|
||||
if method == "PUT" && path == "/api/settings/system" {
|
||||
if values, ok := body.(map[string]any); ok && values["values"] == nil {
|
||||
code, current := doJSON(t, h, "GET", path, nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("read system revision: %d", code)
|
||||
}
|
||||
body = map[string]any{"values": values, "expectedRevision": current["systemRevision"]}
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if body != nil {
|
||||
json.NewEncoder(&buf).Encode(body)
|
||||
@@ -1010,50 +1023,71 @@ func TestSquadOrchestration(t *testing.T) {
|
||||
t.Fatal("squad deliverable artifact not found")
|
||||
}
|
||||
|
||||
// TestModelRoutingOverrideChain:设置覆盖链集成(defaults → user → project)
|
||||
// 与画像层最终覆盖——经由真实 run 的失败信息携带解析出的模型名取证。
|
||||
// TestModelRoutingOverrideChain:P2 受管语义下的模型配置链路——models.* 是系统
|
||||
// 受管项,user/project 层整笔拒绝(含嵌套与点路径),仅系统层可写;画像层模型名
|
||||
// 存量画像层也不能覆盖。经由真实 run 的失败信息携带解析出的模型名取证。
|
||||
func TestModelRoutingOverrideChain(t *testing.T) {
|
||||
srv, st := newTestServer(t)
|
||||
h := srv.Handler()
|
||||
projectID, convID := setupConversation(t, h)
|
||||
|
||||
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)
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 系统层(无认证测试档 = 本机管理员)写入生效。
|
||||
code, out := doJSON(t, h, "PUT", "/api/settings/system", map[string]any{
|
||||
"models": map[string]any{"chat": map[string]any{"model": "model-SYS"}},
|
||||
})
|
||||
if code != 200 {
|
||||
t.Fatal("get settings failed")
|
||||
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")
|
||||
}
|
||||
_ = out
|
||||
|
||||
// 画像层: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{
|
||||
// 新画像路由被拒,存量画像路由保留原数据但退出生效链。
|
||||
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{
|
||||
"id": "prof_route_c", "name": "路由C", "systemPrompt": "x",
|
||||
"model": map[string]any{"chat": "model-C"},
|
||||
"tools": map[string]any{"allow": []string{"*"}},
|
||||
"context": map[string]any{"maxHistoryMessages": 10},
|
||||
"hitl": map[string]any{"askOnAmbiguity": true}, "budget": map[string]any{},
|
||||
})
|
||||
if prof["id"] != "prof_route_c" {
|
||||
t.Fatalf("profile save: %v", prof)
|
||||
if code != http.StatusBadRequest {
|
||||
t.Fatalf("personal profile model accepted: %d", code)
|
||||
}
|
||||
if err := st.SaveProfile(core.AgentProfile{ID: "prof_route_c", Owner: store.OwnerDefault, Name: "Legacy route", Model: core.ModelRoute{Chat: "model-C"}, Tools: core.ToolPolicy{Allow: []string{"*"}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mustCode(t, h, "PATCH", "/api/conversations/"+convID, map[string]any{"profileId": "prof_route_c"})
|
||||
runID := postMessage(t, h, convID, "触发一次真实模型调用(预期失败并携带解析出的模型名)")
|
||||
@@ -1062,8 +1096,8 @@ func TestModelRoutingOverrideChain(t *testing.T) {
|
||||
t.Fatalf("expected failed run (no baseUrl), got %v", terminal["status"])
|
||||
}
|
||||
errMsg := terminal["error"].(map[string]any)["message"].(string)
|
||||
if !strings.Contains(errMsg, `model-C`) {
|
||||
t.Fatalf("run error should carry profile-routed model name: %q", errMsg)
|
||||
if !strings.Contains(errMsg, `model-SYS`) || strings.Contains(errMsg, `model-C`) {
|
||||
t.Fatalf("run error should carry system-routed model name: %q", errMsg)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1205,20 +1239,30 @@ func TestProjectInbox(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsDottedKeyAndSecretMask:点路径写入生效、嵌套胜出、掩码不回写、GET 不回明文。
|
||||
// TestSettingsDottedKeyAndSecretMask:点路径写入生效、受管密钥归系统层、
|
||||
// 掩码不回写、user/system 读面均不回明文、user 面不回显受管项(P2)。
|
||||
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: %d %v", code, out)
|
||||
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)
|
||||
}
|
||||
}
|
||||
svc := settings.NewService(st)
|
||||
|
||||
set(map[string]any{"runtime.entry": "squad"})
|
||||
setSys(map[string]any{"runtime.entry": "squad"})
|
||||
set(map[string]any{"ui.theme": "light"})
|
||||
if got := svc.Effective(store.OwnerDefault, "").GetString("runtime.entry", ""); got != "squad" {
|
||||
t.Fatalf("dotted write did not take effect: %q", got)
|
||||
}
|
||||
@@ -1232,21 +1276,25 @@ func TestSettingsDottedKeyAndSecretMask(t *testing.T) {
|
||||
|
||||
const pool = "tvly-should-not-leak"
|
||||
const modelKey = "sk-should-not-leak"
|
||||
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",
|
||||
})
|
||||
// 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}}})
|
||||
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 != "nested-wins" {
|
||||
t.Fatalf("nested form should win, got %q", got)
|
||||
if got := eff.GetString("tools.tavily.apiKeys", ""); got != pool {
|
||||
t.Fatalf("system pool not effective: %q", got)
|
||||
}
|
||||
|
||||
// 读面:user 面不回显受管项;系统面返回掩码;两个面都不泄漏明文。
|
||||
code, out := doJSON(t, h, "GET", "/api/settings", nil)
|
||||
if code != 200 {
|
||||
t.Fatalf("GET settings: %d", code)
|
||||
@@ -1255,19 +1303,36 @@ func TestSettingsDottedKeyAndSecretMask(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, secret := range []string{pool, modelKey, "nested-wins", "flat-loses"} {
|
||||
for _, secret := range []string{pool, modelKey} {
|
||||
if strings.Contains(string(blob), secret) {
|
||||
t.Fatalf("GET leaked %q", secret)
|
||||
t.Fatalf("GET /api/settings leaked %q", secret)
|
||||
}
|
||||
}
|
||||
user := out["user"].(map[string]any)
|
||||
tavily := user["tools"].(map[string]any)["tavily"].(map[string]any)
|
||||
if tavily["apiKeys"] != settings.MaskPlaceholder {
|
||||
t.Fatalf("apiKeys mask = %v", tavily["apiKeys"])
|
||||
if user["models"] != nil || user["tools"] != nil {
|
||||
t.Fatalf("managed subtrees exposed in user scope: %#v", user)
|
||||
}
|
||||
chat := user["models"].(map[string]any)["chat"].(map[string]any)
|
||||
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)
|
||||
if tavily["apiKeys"] != settings.MaskPlaceholder {
|
||||
t.Fatalf("system apiKeys mask = %v", tavily["apiKeys"])
|
||||
}
|
||||
chat := sysValues["models"].(map[string]any)["chat"].(map[string]any)
|
||||
if chat["apiKey"] != settings.MaskPlaceholder {
|
||||
t.Fatalf("apiKey mask = %v", chat["apiKey"])
|
||||
t.Fatalf("system apiKey mask = %v", chat["apiKey"])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,311 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -273,6 +273,20 @@ 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 {
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,504 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/internal/store"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
func (s *Server) SetProjectSettingsAuthorizer(fn extension.ProjectSettingsAuthorizer) {
|
||||
s.projectSettingsAuthz = fn
|
||||
}
|
||||
|
||||
func (s *Server) settingsProject(ctx context.Context, actor, id string, write bool) (core.Project, error) {
|
||||
p, err := s.st.ProjectMetadata(id)
|
||||
if err != nil {
|
||||
return core.Project{}, err
|
||||
}
|
||||
if s.projectSettingsAuthz != nil {
|
||||
if err := s.projectSettingsAuthz(ctx, actor, p.ID, write); err != nil {
|
||||
return core.Project{}, err
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
// A domain lifecycle can revoke even the project creator. Missing settings
|
||||
// capability fails closed; read/work authorization cannot grant configuration.
|
||||
if s.projectLifecycle != nil {
|
||||
return core.Project{}, store.ErrNotFound
|
||||
}
|
||||
if p.Owner != actor {
|
||||
return core.Project{}, store.ErrNotFound
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
func decodeSettingsObject(r *http.Request) (map[string]any, error) {
|
||||
var values map[string]any
|
||||
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
||||
if err := decoder.Decode(&values); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if values == nil {
|
||||
return nil, errors.New("需要设置对象")
|
||||
}
|
||||
if err := decoder.Decode(new(any)); err != io.EOF {
|
||||
return nil, errors.New("设置请求必须只包含一个对象")
|
||||
}
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func decodeStrictSettingsRequest(r *http.Request, target any) error {
|
||||
decoder := json.NewDecoder(io.LimitReader(r.Body, 1<<20))
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(target); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := decoder.Decode(new(any)); err != io.EOF {
|
||||
return errors.New("请求只能包含一个对象")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) previewSettingsMigration(w http.ResponseWriter, r *http.Request) {
|
||||
actor := s.owner(r)
|
||||
project, err := s.settingsProject(r.Context(), actor, r.PathValue("id"), true)
|
||||
if err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Choices []settings.MigrationChoice `json:"choices"`
|
||||
ExpectedRevision *int64 `json:"expectedRevision"`
|
||||
}
|
||||
if err := decodeStrictSettingsRequest(r, &req); err != nil || req.ExpectedRevision == nil || *req.ExpectedRevision < 0 {
|
||||
http.Error(w, "需要 choices 和 expectedRevision", 400)
|
||||
return
|
||||
}
|
||||
for _, choice := range req.Choices {
|
||||
if _, err := s.st.GetUser(choice.SourceOwner); err != nil && !(s.Auth == nil && choice.SourceOwner == actor) {
|
||||
writeStoreErr(w, store.ErrNotFound)
|
||||
return
|
||||
}
|
||||
if _, err := s.settingsProject(r.Context(), choice.SourceOwner, project.ID, false); err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
s.set.SetExtraSchema(s.plugins.SettingsSchema())
|
||||
result, err := s.set.PreviewProjectMigration(actor, project.ID, req.Choices, *req.ExpectedRevision)
|
||||
if err != nil {
|
||||
s.writeMigrationError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, result)
|
||||
}
|
||||
|
||||
func (s *Server) applySettingsMigration(w http.ResponseWriter, r *http.Request) {
|
||||
actor := s.owner(r)
|
||||
project, err := s.settingsProject(r.Context(), actor, r.PathValue("id"), true)
|
||||
if err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Observation string `json:"observation"`
|
||||
ExpectedRevision *int64 `json:"expectedRevision"`
|
||||
}
|
||||
if err := decodeStrictSettingsRequest(r, &req); err != nil || req.ExpectedRevision == nil || *req.ExpectedRevision < 0 {
|
||||
http.Error(w, "需要 observation 和 expectedRevision", 400)
|
||||
return
|
||||
}
|
||||
s.set.SetExtraSchema(s.plugins.SettingsSchema())
|
||||
selected, choices, err := s.set.MigrationSelection(actor, project.ID, req.Observation, *req.ExpectedRevision)
|
||||
if err != nil {
|
||||
s.writeMigrationError(w, err)
|
||||
return
|
||||
}
|
||||
for _, choice := range choices {
|
||||
if _, err := s.st.GetUser(choice.SourceOwner); err != nil && !(s.Auth == nil && choice.SourceOwner == actor) {
|
||||
writeStoreErr(w, store.ErrNotFound)
|
||||
return
|
||||
}
|
||||
if _, err := s.settingsProject(r.Context(), choice.SourceOwner, project.ID, false); err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := s.validateProjectTemplate(actor, project, selected); err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
if _, err := s.settingsProject(r.Context(), actor, project.ID, true); err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
result, err := s.set.ApplyProjectMigration(actor, project.ID, req.Observation, *req.ExpectedRevision, selected)
|
||||
if err != nil {
|
||||
s.writeMigrationError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, result)
|
||||
}
|
||||
|
||||
func (s *Server) writeMigrationError(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, settings.ErrSettingsConflict) {
|
||||
http.Error(w, err.Error(), 409)
|
||||
return
|
||||
}
|
||||
var invalid *settings.ErrInvalidSetting
|
||||
if errors.As(err, &invalid) {
|
||||
http.Error(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
writeProjectErr(w, err)
|
||||
}
|
||||
|
||||
func (s *Server) putSettingsPersonalProject(w http.ResponseWriter, r *http.Request) {
|
||||
owner := s.owner(r)
|
||||
p, err := s.settingsProject(r.Context(), owner, r.PathValue("id"), false)
|
||||
if err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
values, err := decodeSettingsObject(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
if err := s.set.ValidateWrite(values, settings.PersonalProjectScope); err != nil {
|
||||
http.Error(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
s.writeSettingsMerge(w, func() error { return s.set.MergePersonalProject(owner, p.ID, values) })
|
||||
}
|
||||
|
||||
func (s *Server) validateProjectTemplate(actor string, p core.Project, values map[string]any) error {
|
||||
projected := s.set.ProjectValues(values, settings.ProjectSettingScope)
|
||||
id := core.EffectiveSettings(projected).GetString("deliverables.templateArtifactId", "")
|
||||
if id == "" {
|
||||
return nil
|
||||
}
|
||||
a, err := s.st.GetArtifactByID(id)
|
||||
if err != nil || a.ProjectID != p.ID {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
if s.ProjectAuthzV2 != nil {
|
||||
if !s.authorizeSharedResource(actor, "model-context", "artifact", a.ID, a.Owner, a.ProjectID) {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
} else if a.Owner != actor {
|
||||
return store.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) getSettingsMigration(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := s.requireSystemAdmin(w, r); !ok {
|
||||
return
|
||||
}
|
||||
if r.URL.Query().Get("dryRun") != "true" {
|
||||
http.Error(w, "迁移清单仅支持 dryRun=true;不执行自动迁移", 400)
|
||||
return
|
||||
}
|
||||
s.set.SetExtraSchema(s.plugins.SettingsSchema())
|
||||
view, err := s.set.MigrationInventory()
|
||||
writeJSONOr(w, view, err)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
+603
-88
@@ -26,6 +26,7 @@ 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"
|
||||
@@ -41,24 +42,27 @@ import (
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
st *store.Store
|
||||
Auth *Authenticator // API key 认证(nil/未启用 = 默认租户;组合根注入)
|
||||
Retriever core.Retriever // 混合检索(nil = store 全文;组合根注入)
|
||||
Indexer core.DocumentIndexer // 检索索引入口(nil = 整文档单块;组合根注入)
|
||||
Limiter ratelimit.Limiter // 租户限流(nil = 不限;组合根注入)
|
||||
LimitLookup func() int // 当前限流值(次/分;<=0 不限;组合根缓存注入)
|
||||
mgr *runmgr.Manager
|
||||
Cards *cardsvc.Service // 收件箱任务卡(nil = 不启用;组合根注入)
|
||||
Surfaces *surfacesvc.Service // Generative UI Surface(nil = 不启用;EXT-17)
|
||||
Bus *cmdbus.Bus // 命令总线(nil = 不启用;组合根注入;四入口同源 D27)
|
||||
Gate NLIntentGate // 意图闸门(chat 入口同源判定;nil = 不接)
|
||||
Workflows *workflow.Registry // 已装载工作流(nil = 不接)
|
||||
Prompts *prompts.Service // 提示词库(nil = 不启用 API 与技能命令;执行器另有回退)
|
||||
reg *tools.Registry
|
||||
prof *profiles.Service
|
||||
set *settings.Service
|
||||
plugins *plugins.Host
|
||||
static fs.FS
|
||||
projectLifecycle *extension.ProjectLifecycle
|
||||
projectSettingsAuthz extension.ProjectSettingsAuthorizer
|
||||
projectHost func(string) extension.Host
|
||||
st *store.Store
|
||||
Auth *Authenticator // API key 认证(nil/未启用 = 默认租户;组合根注入)
|
||||
Retriever core.Retriever // 混合检索(nil = store 全文;组合根注入)
|
||||
Indexer core.DocumentIndexer // 检索索引入口(nil = 整文档单块;组合根注入)
|
||||
Limiter ratelimit.Limiter // 租户限流(nil = 不限;组合根注入)
|
||||
LimitLookup func() int // 当前限流值(次/分;<=0 不限;组合根缓存注入)
|
||||
mgr *runmgr.Manager
|
||||
Cards *cardsvc.Service // 收件箱任务卡(nil = 不启用;组合根注入)
|
||||
Surfaces *surfacesvc.Service // Generative UI Surface(nil = 不启用;EXT-17)
|
||||
Bus *cmdbus.Bus // 命令总线(nil = 不启用;组合根注入;四入口同源 D27)
|
||||
Gate NLIntentGate // 意图闸门(chat 入口同源判定;nil = 不接)
|
||||
Workflows *workflow.Registry // 已装载工作流(nil = 不接)
|
||||
Prompts *prompts.Service // 提示词库(nil = 不启用 API 与技能命令;执行器另有回退)
|
||||
reg *tools.Registry
|
||||
prof *profiles.Service
|
||||
set *settings.Service
|
||||
plugins *plugins.Host
|
||||
static fs.FS
|
||||
// Subscribe 注入 bus 订阅(事件在线转发;重放走 store 游标)。
|
||||
Subscribe func(conversationID string) (<-chan core.StreamEvent, func())
|
||||
// DisableUploadIndexing EXT-9:true 时上传端点不再自动写检索索引(原件与
|
||||
@@ -67,6 +71,9 @@ 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(兼容),其余动作一律
|
||||
// 默认拒绝——旧只读适配器不得自动获得写入能力。公开契约的版本化扩展
|
||||
@@ -94,6 +101,11 @@ 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)) {
|
||||
@@ -110,13 +122,32 @@ func (s *Server) authorizeProjectAction(owner string, projectID core.ID, action
|
||||
return err == nil && ok
|
||||
}
|
||||
if action == ProjectRead {
|
||||
return s.authorizeSharedProject(owner, projectID)
|
||||
return s.authorizeSharedResource(owner, "read", "project", projectID, "", projectID)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// authorizeSharedProject 读取路径的共享判定:适配器未注册或拒绝/出错一律 false
|
||||
// (fail-closed;错误不区分,避免泄露存在性)。
|
||||
// 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。
|
||||
func (s *Server) authorizeSharedProject(owner string, projectID core.ID) bool {
|
||||
if s.ProjectAuthz == nil {
|
||||
return false
|
||||
@@ -179,7 +210,7 @@ func (s *Server) WireBuiltinCommands() {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c, err := s.st.CreateConversation(conv.Owner, conv.ProjectID, "新会话", conv.ProfileID)
|
||||
c, err := s.createPrivateConversation(ctx, conv.Owner, conv.ProjectID, "新会话", conv.ProfileID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -251,11 +282,15 @@ 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)
|
||||
@@ -321,6 +356,13 @@ 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)
|
||||
@@ -387,9 +429,15 @@ 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" {
|
||||
@@ -399,14 +447,15 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if _, err := s.st.GetProject(s.owner(r), pid); err != nil {
|
||||
// EXT-8 切片 2:owner 自有未命中时咨询适配器(放行才可订阅项目流)。
|
||||
if !s.authorizeSharedProject(s.owner(r), core.ID(pid)) {
|
||||
// EXT-8b:资源级咨询(stream×project);V2 缺席回落 V1。
|
||||
if !s.authorizeSharedResource(s.owner(r), "stream", "project", core.ID(pid), "", 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
|
||||
@@ -438,45 +487,96 @@ 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
|
||||
}
|
||||
|
||||
lastSent := since
|
||||
// Seen progresses even when a private frame is filtered. Visible only tracks
|
||||
// frames delivered to this actor, preserving authoritative seq and resumption.
|
||||
lastSeen := since
|
||||
for {
|
||||
replay, err := s.st.EventsAfter(conversationID, lastSent, 1000)
|
||||
replay, err := s.st.EventsAfter(conversationID, lastSeen, 1000)
|
||||
if err != nil || len(replay) == 0 {
|
||||
break
|
||||
}
|
||||
for _, e := range replay {
|
||||
if e.Seq <= lastSent {
|
||||
if e.Seq <= lastSeen {
|
||||
continue
|
||||
}
|
||||
if !send(e) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
lastSent = e.Seq
|
||||
lastSeen = 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()
|
||||
@@ -485,14 +585,19 @@ func (s *Server) events(w http.ResponseWriter, r *http.Request) {
|
||||
stop()
|
||||
return
|
||||
}
|
||||
if e.ConversationID != conversationID || e.Seq <= lastSent {
|
||||
if e.ConversationID != conversationID || e.Seq <= lastSeen {
|
||||
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
|
||||
}
|
||||
lastSent = e.Seq
|
||||
lastSeen = e.Seq
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -538,6 +643,23 @@ 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)
|
||||
@@ -574,6 +696,15 @@ 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)
|
||||
}
|
||||
@@ -588,6 +719,58 @@ 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)
|
||||
@@ -605,12 +788,29 @@ 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 _, err := s.st.GetProject(owner, r.PathValue("id")); err != nil {
|
||||
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 {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
@@ -634,9 +834,13 @@ 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)
|
||||
}
|
||||
@@ -650,13 +854,8 @@ func (s *Server) createConversation(w http.ResponseWriter, r *http.Request) {
|
||||
if req.Title == "" {
|
||||
req.Title = "新会话"
|
||||
}
|
||||
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)
|
||||
c, err := s.createPrivateConversation(r.Context(), s.owner(r), r.PathValue("id"), req.Title, req.ProfileID)
|
||||
writeProjectJSON(w, c, err)
|
||||
}
|
||||
|
||||
// conversationDetail 终态对账权威:消息树 + runs + 活动 HITL 卡片。
|
||||
@@ -735,6 +934,10 @@ 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"`
|
||||
@@ -890,7 +1093,7 @@ func (s *Server) postMessage(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
if startErr != nil {
|
||||
s.mgr.ReleaseConversation(convID)
|
||||
writeStoreErr(w, startErr)
|
||||
writeProjectErr(w, startErr)
|
||||
return
|
||||
}
|
||||
_ = s.st.SetMessageRunID(msg.ID, run.ID)
|
||||
@@ -995,10 +1198,21 @@ func (s *Server) execCommand(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
var hostProject string
|
||||
if body.ConversationID != "" {
|
||||
if conv, err := s.st.GetConversation(s.owner(r), body.ConversationID); err == nil {
|
||||
conv, err := s.st.GetConversation(s.owner(r), body.ConversationID)
|
||||
if err != nil {
|
||||
if s.projectLifecycle != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
} else {
|
||||
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,
|
||||
@@ -1077,6 +1291,22 @@ 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):本地无执行句柄——写持久命令,执行实例
|
||||
@@ -1143,6 +1373,28 @@ 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"`
|
||||
}
|
||||
@@ -1189,6 +1441,17 @@ 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 {
|
||||
@@ -1201,10 +1464,18 @@ func (s *Server) upload(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
owner := s.owner(r)
|
||||
if _, err := s.st.GetProject(owner, projectID); err != nil {
|
||||
writeStoreErr(w, err)
|
||||
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
|
||||
}
|
||||
}
|
||||
mime := header.Header.Get("Content-Type")
|
||||
art := core.Artifact{ProjectID: projectID, Owner: owner, Name: header.Filename, Kind: kindOf(header.Filename), MIME: mime}
|
||||
@@ -1219,11 +1490,14 @@ 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}}},
|
||||
}
|
||||
if s.Cards != nil {
|
||||
// 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 {
|
||||
ingestCard, _ = s.Cards.Create(owner, ingestCard, "")
|
||||
}
|
||||
doc := ingest.Parse(art.Name, mime, content)
|
||||
if s.Cards != nil {
|
||||
if showIngestCard {
|
||||
ingestCard.Status = core.CardDone
|
||||
ingestCard.Tone = "ok"
|
||||
body := []core.UINode{
|
||||
@@ -1267,20 +1541,32 @@ func kindOf(filename string) string {
|
||||
}
|
||||
|
||||
func (s *Server) listArtifacts(w http.ResponseWriter, r *http.Request) {
|
||||
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)
|
||||
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)
|
||||
return
|
||||
}
|
||||
as, err := s.st.ListProjectArtifactsAll(pid)
|
||||
writeJSONOr(w, as, err)
|
||||
as, err = s.st.ListProjectArtifactsAll(pid)
|
||||
} else {
|
||||
as, err = s.st.ListArtifacts(owner, pid)
|
||||
}
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
as, err := s.st.ListArtifacts(owner, pid)
|
||||
writeJSONOr(w, as, err)
|
||||
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)
|
||||
}
|
||||
|
||||
// sharedArtifactGet EXT-8 工件读取回退:owner 自有未命中 → 取元数据问适配器,
|
||||
@@ -1290,7 +1576,8 @@ func (s *Server) sharedArtifactGet(owner, id string) (core.Artifact, bool) {
|
||||
if err != nil {
|
||||
return core.Artifact{}, false
|
||||
}
|
||||
if !s.authorizeSharedProject(owner, a.ProjectID) {
|
||||
// EXT-8b:工件级六要素(Owner=工件属主,受信元数据解析)。
|
||||
if !s.authorizeSharedResource(owner, "download", "artifact", a.ID, a.Owner, a.ProjectID) {
|
||||
return core.Artifact{}, false
|
||||
}
|
||||
return a, true
|
||||
@@ -1375,47 +1662,190 @@ func (s *Server) deleteProfile(w http.ResponseWriter, r *http.Request) {
|
||||
func (s *Server) getSettings(w http.ResponseWriter, r *http.Request) {
|
||||
owner := s.owner(r)
|
||||
projectID := r.URL.Query().Get("project")
|
||||
schema := append(settings.Schema(), s.plugins.SettingsSchema()...)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"schema": schema,
|
||||
"defaults": settings.Masked(settings.SystemDefaults(), schema),
|
||||
"user": settings.Masked(mustUser(s.set, owner), schema),
|
||||
"project": settings.Masked(mustScope(s.set, owner, projectID), schema),
|
||||
s.set.SetExtraSchema(s.plugins.SettingsSchema())
|
||||
if projectID == "" {
|
||||
schema := s.set.SchemaFor(settings.UserSettingScope)
|
||||
user, err := s.set.UserScope(owner)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"schema": schema, "defaults": settings.Masked(s.set.DefaultsFor(settings.UserSettingScope), schema), "user": settings.Masked(user, schema), "project": map[string]any{}})
|
||||
return
|
||||
}
|
||||
p, err := s.settingsProject(r.Context(), owner, projectID, false)
|
||||
if err != nil {
|
||||
writeProjectErr(w, err)
|
||||
return
|
||||
}
|
||||
shared, revision, err := s.set.SharedProjectScope(p.ID)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
schema := s.set.SchemaFor(settings.ProjectSettingScope)
|
||||
personalSchema := s.set.SchemaFor(settings.PersonalProjectScope)
|
||||
personalProject, err := s.set.ProjectScope(owner, p.ID)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
personalUser, err := s.set.UserScope(owner)
|
||||
if err != nil {
|
||||
writeStoreErr(w, err)
|
||||
return
|
||||
}
|
||||
_, configureErr := s.settingsProject(r.Context(), owner, p.ID, true)
|
||||
writeJSON(w, 200, map[string]any{
|
||||
"schema": schema, "defaults": settings.Masked(s.set.DefaultsFor(settings.ProjectSettingScope), schema), "project": settings.Masked(shared, schema),
|
||||
"user": map[string]any{}, "projectRevision": revision, "projectCanConfigure": configureErr == nil,
|
||||
"personalSchema": personalSchema, "personalDefaults": settings.Masked(s.set.DefaultsFor(settings.PersonalProjectScope), personalSchema),
|
||||
"personalProject": settings.Masked(personalProject, personalSchema), "personalUser": settings.Masked(personalUser, personalSchema),
|
||||
})
|
||||
}
|
||||
|
||||
func mustUser(s *settings.Service, owner string) map[string]any {
|
||||
m, _ := s.UserScope(owner)
|
||||
if m == nil {
|
||||
return map[string]any{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func mustScope(s *settings.Service, owner, projectID string) map[string]any {
|
||||
if projectID == "" {
|
||||
return map[string]any{}
|
||||
}
|
||||
m, _ := s.ProjectScope(owner, projectID)
|
||||
return m
|
||||
}
|
||||
|
||||
func (s *Server) putSettingsUser(w http.ResponseWriter, r *http.Request) {
|
||||
var values map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&values); err != nil {
|
||||
values, err := decodeSettingsObject(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeOKOr(w, s.set.MergeUser(s.owner(r), values))
|
||||
s.set.SetExtraSchema(s.plugins.SettingsSchema())
|
||||
if err := s.set.ValidateWrite(values, settings.UserSettingScope); err != nil {
|
||||
http.Error(w, err.Error(), 400)
|
||||
return
|
||||
}
|
||||
s.writeSettingsMerge(w, func() error { return s.set.MergeUser(s.owner(r), values) })
|
||||
}
|
||||
|
||||
func (s *Server) putSettingsProject(w http.ResponseWriter, r *http.Request) {
|
||||
var values map[string]any
|
||||
if err := json.NewDecoder(r.Body).Decode(&values); err != nil {
|
||||
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) {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
writeOKOr(w, s.set.MergeProject(s.owner(r), r.PathValue("id"), values))
|
||||
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})
|
||||
}
|
||||
|
||||
// ---- 工具(用户声明式 HTTP 工具注册)----
|
||||
@@ -1450,7 +1880,25 @@ func (s *Server) createUserTool(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "v1 用户工具需为声明式 HTTP 工具(提供 http 段)", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
lookup := func(key string) string { return s.set.Effective(s.owner(r), "").GetString(key, "") }
|
||||
s.set.SetExtraSchema(s.plugins.SettingsSchema())
|
||||
if !s.set.PersonalCredentialReferenceAllowed(desc.HTTP.AuthRef) {
|
||||
http.Error(w, "个人 HTTP 工具只能引用个人凭据,不能导出系统模型、搜索或环境密钥", 400)
|
||||
return
|
||||
}
|
||||
owner := s.owner(r)
|
||||
lookup := func(key string) string {
|
||||
if !s.set.PersonalCredentialReferenceAllowed(key) {
|
||||
return ""
|
||||
}
|
||||
if value, ok := s.st.ActiveSecret(owner, key); ok {
|
||||
return value
|
||||
}
|
||||
personal, err := s.set.UserScope(owner)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return core.EffectiveSettings(personal).GetString(key, "")
|
||||
}
|
||||
if err := s.reg.Register(desc, tools.HTTPExecutor(*desc.HTTP, lookup)); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
@@ -1538,7 +1986,27 @@ func (s *Server) reloadPlugins(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
func (s *Server) pluginSettingsSchema(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, s.plugins.SettingsSchema())
|
||||
scope := core.SettingScope(r.URL.Query().Get("scope"))
|
||||
if scope == "" {
|
||||
scope = settings.UserSettingScope
|
||||
}
|
||||
if scope == core.SettingScopeSystem {
|
||||
if _, ok := s.requireSystemAdmin(w, r); !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
if scope != core.SettingScopeSystem && scope != settings.UserSettingScope && scope != settings.ProjectSettingScope && scope != settings.PersonalProjectScope {
|
||||
http.Error(w, "设置范围无效", 400)
|
||||
return
|
||||
}
|
||||
s.set.SetExtraSchema(s.plugins.SettingsSchema())
|
||||
out := []core.SettingDesc{}
|
||||
for _, d := range s.set.SchemaFor(scope) {
|
||||
if strings.HasPrefix(d.Group, "plugin:") {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// listAdminZoneSections 管理区声明分区清单(WP3-I3):展平全部编译插件的
|
||||
@@ -1709,10 +2177,53 @@ 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.authorizeSharedProject(owner, projectID)
|
||||
return s.authorizeSharedResource(owner, "search", "project", projectID, docOwner, 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)
|
||||
@@ -1777,6 +2288,10 @@ 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
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/settings"
|
||||
"agent-system-backend/pkg/extension"
|
||||
core "agent-system-core"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSharedProjectSettingsRealRequestsAndRevocation(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
alice, _ := st.CreateAPIKey("alice", "test")
|
||||
bob, _ := st.CreateAPIKey("bob", "test")
|
||||
mallory, _ := st.CreateAPIKey("mallory", "test")
|
||||
p, _ := st.CreateProject("alice", "Shared business")
|
||||
other, _ := st.CreateProject("alice", "Other")
|
||||
revoked := false
|
||||
s.SetProjectSettingsAuthorizer(func(_ context.Context, actor, id string, write bool) error {
|
||||
if id != p.ID || revoked || actor == "mallory" {
|
||||
return lifecycleStatusError(404)
|
||||
}
|
||||
if write && actor != "alice" {
|
||||
return lifecycleStatusError(403)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
write := func(key, body string) int {
|
||||
return lifecycleRequest(t, s, key, "PUT", "/api/settings/project/"+p.ID, body).Code
|
||||
}
|
||||
if code := write(alice, `{"values":{"deliverables.style":"formal"},"expectedRevision":0}`); code != 200 {
|
||||
t.Fatalf("save=%d", code)
|
||||
}
|
||||
w := lifecycleRequest(t, s, bob, "GET", "/api/settings?project="+p.ID, "")
|
||||
var view map[string]any
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &view)
|
||||
if w.Code != 200 || view["projectRevision"] != float64(1) || view["projectCanConfigure"] != false || !strings.Contains(w.Body.String(), "formal") {
|
||||
t.Fatalf("member shared read=%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if code := write(bob, `{"values":{"deliverables.style":"minimal"},"expectedRevision":1}`); code != 403 {
|
||||
t.Fatalf("work/member treated as configuration authority=%d", code)
|
||||
}
|
||||
if code := write(alice, `{"values":{"deliverables.style":"minimal"},"expectedRevision":0}`); code != 409 {
|
||||
t.Fatalf("stale save=%d", code)
|
||||
}
|
||||
for _, id := range []string{other.ID, "missing"} {
|
||||
if w := lifecycleRequest(t, s, bob, "GET", "/api/settings?project="+id, ""); w.Code != 404 {
|
||||
t.Fatalf("cross project read=%d", w.Code)
|
||||
}
|
||||
}
|
||||
if w := lifecycleRequest(t, s, mallory, "GET", "/api/settings?project="+p.ID, ""); w.Code != 404 {
|
||||
t.Fatalf("outsider read=%d", w.Code)
|
||||
}
|
||||
if w := lifecycleRequest(t, s, alice, "PUT", "/api/settings/project/"+p.ID+"/personal", `{"crossSession.read":true,"ui.theme":"light","context.userInstructions":"Alice only"}`); w.Code != 200 {
|
||||
t.Fatalf("personal save=%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
w = lifecycleRequest(t, s, bob, "GET", "/api/settings?project="+p.ID, "")
|
||||
if strings.Contains(w.Body.String(), "Alice only") || s.set.Effective("bob", p.ID).GetBool("crossSession.read", false) {
|
||||
t.Fatal("personal state shared with another member")
|
||||
}
|
||||
revoked = true
|
||||
if w := lifecycleRequest(t, s, alice, "GET", "/api/settings?project="+p.ID, ""); w.Code != 404 {
|
||||
t.Fatalf("creator bypassed revocation=%d", w.Code)
|
||||
}
|
||||
if code := write(alice, `{"values":{"deliverables.style":"minimal"},"expectedRevision":1}`); code != 404 {
|
||||
t.Fatalf("revoked write=%d", code)
|
||||
}
|
||||
if got := s.set.Effective("alice", p.ID).GetString("deliverables.style", ""); got != "formal" {
|
||||
t.Fatalf("denied save changed data: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedProjectSettingsConcurrentRevision(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
p, _ := st.CreateProject("alice", "Concurrent")
|
||||
var wg sync.WaitGroup
|
||||
codes := make(chan int, 2)
|
||||
for _, style := range []string{"formal", "minimal"} {
|
||||
wg.Add(1)
|
||||
go func(style string) {
|
||||
defer wg.Done()
|
||||
body := fmt.Sprintf(`{"values":{"deliverables.style":%q},"expectedRevision":0}`, style)
|
||||
codes <- lifecycleRequest(t, s, key, "PUT", "/api/settings/project/"+p.ID, body).Code
|
||||
}(style)
|
||||
}
|
||||
wg.Wait()
|
||||
close(codes)
|
||||
counts := map[int]int{}
|
||||
for code := range codes {
|
||||
counts[code]++
|
||||
}
|
||||
if counts[200] != 1 || counts[409] != 1 {
|
||||
t.Fatalf("concurrent save outcomes=%v", counts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectWorkCapabilityCannotConfigureSettings(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
p, _ := st.CreateProject("alice", "Capability")
|
||||
s.SetProjectLifecycle(extension.ProjectLifecycle{Work: func(context.Context, string, core.ID) (bool, error) { return true, nil }}, nil)
|
||||
if w := lifecycleRequest(t, s, key, "PUT", "/api/settings/project/"+p.ID, `{"values":{"deliverables.style":"formal"},"expectedRevision":0}`); w.Code != 404 {
|
||||
t.Fatalf("missing configure capability allowed write: %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationInventoryProtectedAndNonDestructive(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
if w := lifecycleRequest(t, s, key, "GET", "/api/settings/migration?dryRun=true", ""); w.Code != 403 {
|
||||
t.Fatalf("nonadmin inventory=%d", w.Code)
|
||||
}
|
||||
s.Auth = nil
|
||||
_ = st.MergeSettingsScope("alice", "project:p", map[string]any{"deliverables.style": "formal", "models.chat.apiKey": "sensitive-source-fixture"})
|
||||
_ = st.MergeSettingsScope("bob", "project:p", map[string]any{"deliverables.style": "minimal"})
|
||||
_, _ = st.AddSecret("alice", "models.chat", "Old", "sensitive-secret-fixture")
|
||||
w := lifecycleRequest(t, s, "", "GET", "/api/settings/migration?dryRun=true", "")
|
||||
if w.Code != 200 || !strings.Contains(w.Body.String(), `"conflict"`) || strings.Contains(w.Body.String(), "sensitive-") {
|
||||
t.Fatalf("inventory invalid=%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
raw, _ := st.GetSettingsScope("alice", "project:p")
|
||||
encoded, _ := json.Marshal(raw)
|
||||
if !strings.Contains(string(encoded), "sensitive-source-fixture") {
|
||||
t.Fatal("dry run removed recovery value")
|
||||
}
|
||||
if s.set.Effective("bob", "p").GetString("deliverables.style", "") != "modern" {
|
||||
t.Fatal("conflicting legacy values automatically promoted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalSecretsRejectSystemManagedServices(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, err := st.CreateAPIKey("alice", "test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, service := range []string{"models.chat", "models.lightweight", "models.embedding", "models.rerank", "tools.tavily"} {
|
||||
body, _ := json.Marshal(map[string]string{"service": service, "value": "fixture-key"})
|
||||
w := lifecycleRequest(t, s, key, "POST", "/api/secrets", string(body))
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("personal %s accepted: %d %s", service, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
old, err := st.AddSecret("alice", "models.chat", "old", "old-fixture-key")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, action := range []struct{ method, path, body string }{{"POST", "/api/secrets/" + old.ID + "/activate", ""}, {"PATCH", "/api/secrets/" + old.ID, `{"label":"new"}`}, {"DELETE", "/api/secrets/" + old.ID, ""}} {
|
||||
if w := lifecycleRequest(t, s, key, action.method, action.path, action.body); w.Code != 400 {
|
||||
t.Fatalf("old protected key mutable: %s %d", action.method, w.Code)
|
||||
}
|
||||
}
|
||||
if w := lifecycleRequest(t, s, key, "GET", "/api/secrets", ""); w.Code != 200 || strings.Contains(w.Body.String(), old.ID) {
|
||||
t.Fatal("retired protected key displayed as personal configuration")
|
||||
}
|
||||
if w := lifecycleRequest(t, s, key, "POST", "/api/secrets", `{"service":"custom.personal","value":"fixture"}`); w.Code != 200 {
|
||||
t.Fatal("unrelated personal credentials blocked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsReadProjectsOnlyRequestedScopes(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
p, _ := st.CreateProject("alice", "Settings project")
|
||||
w := lifecycleRequest(t, s, key, "GET", "/api/settings?project="+p.ID, "")
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("read: %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "models.chat") || strings.Contains(w.Body.String(), "主模型") {
|
||||
t.Fatal("system schema exposed in project settings")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSettingsRejectUnknownAndMalformedPaths(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
for _, body := range []string{`{"unknown":{"key":true}}`, `{"unknown":"••••••"}`, `{"ui":{"theme":{"nested":"light"}}}`, `{"ui":{}}`, `{"ui..theme":"light"}`, `{"ui":{"theme":"invalid"}}`, `{"ui.theme":"invalid","ui":{"theme":"light"}}`, `null`} {
|
||||
w := lifecycleRequest(t, s, key, "PUT", "/api/settings/user", body)
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("malformed accepted %s: %d", body, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectSettingsExplicitMigrationSourceAndRetry(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
p, _ := st.CreateProject("alice", "Migration")
|
||||
if err := st.MergeSettingsScope("alice", "project:"+p.ID, map[string]any{"deliverables.style": "formal", "ui.theme": "light", "context.userInstructions": "private-original"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.MergeSettingsScope("bob", "project:"+p.ID, map[string]any{"deliverables.style": "minimal"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
preview := func() string {
|
||||
w := lifecycleRequest(t, s, key, "POST", "/api/settings/project/"+p.ID+"/migration/preview", `{"choices":[{"path":"deliverables.style","sourceOwner":"alice"}],"expectedRevision":0}`)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("preview=%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if strings.Contains(w.Body.String(), "private-original") || strings.Contains(w.Body.String(), "formal") {
|
||||
t.Fatal("preview disclosed source values")
|
||||
}
|
||||
var response struct {
|
||||
Observation string `json:"observation"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
return response.Observation
|
||||
}
|
||||
apply := func(observation string) *httptest.ResponseRecorder {
|
||||
body, _ := json.Marshal(map[string]any{"observation": observation, "expectedRevision": 0})
|
||||
return lifecycleRequest(t, s, key, "POST", "/api/settings/project/"+p.ID+"/migration/apply", string(body))
|
||||
}
|
||||
old := preview()
|
||||
_ = st.MergeSettingsScope("alice", "project:"+p.ID, map[string]any{"deliverables.style": "modern"})
|
||||
if w := apply(old); w.Code != 409 {
|
||||
t.Fatalf("changed source accepted=%d", w.Code)
|
||||
}
|
||||
if got := s.set.Effective("alice", p.ID).GetString("deliverables.style", ""); got != "modern" {
|
||||
t.Fatalf("stale apply changed target=%s", got)
|
||||
}
|
||||
_ = st.MergeSettingsScope("alice", "project:"+p.ID, map[string]any{"deliverables.style": "formal"})
|
||||
observation := preview()
|
||||
if w := apply(observation); w.Code != 200 {
|
||||
t.Fatalf("apply=%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if w := apply(observation); w.Code != 200 {
|
||||
t.Fatalf("retry=%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if got := s.set.Effective("bob", p.ID).GetString("deliverables.style", ""); got != "formal" {
|
||||
t.Fatalf("explicit selected source not applied=%s", got)
|
||||
}
|
||||
raw, _ := st.GetSettingsScope("alice", "project:"+p.ID)
|
||||
encoded, _ := json.Marshal(raw)
|
||||
if !strings.Contains(string(encoded), "private-original") || !strings.Contains(string(encoded), "formal") {
|
||||
t.Fatal("migration changed original/private source")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitSystemMigrationCASAndCredentialRevocation(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
_, err := st.AddSecret(settings.OwnerSystemRow, "models.chat", "old", "old-system-fixture")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
code, view := doJSON(t, s.Handler(), "GET", "/api/settings/system", nil)
|
||||
if code != 200 {
|
||||
t.Fatal(code)
|
||||
}
|
||||
revision := int64(view["systemRevision"].(float64))
|
||||
save := func(path, value string, expected int64) *httptest.ResponseRecorder {
|
||||
body, _ := json.Marshal(map[string]any{"values": map[string]any{"models.chat.apiKey": value}, "expectedRevision": expected})
|
||||
return lifecycleRequest(t, s, "", "POST", path, string(body))
|
||||
}
|
||||
if w := save("/api/settings/migration/system", "new-system-fixture", revision); w.Code != 200 {
|
||||
t.Fatalf("explicit provided system values=%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if got, _ := s.set.LookupSecret("alice", "models.chat"); got != "new-system-fixture" {
|
||||
t.Fatal("old active table credential shadowed explicit new value")
|
||||
}
|
||||
if w := save("/api/settings/migration/system", "wrong-stale-fixture", revision); w.Code != 409 {
|
||||
t.Fatalf("stale system migration=%d", w.Code)
|
||||
}
|
||||
if w := save("/api/settings/migration/system", "", revision+1); w.Code != 200 {
|
||||
t.Fatalf("revoke=%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
if got, _ := s.set.LookupSecret("alice", "models.chat"); got != "" {
|
||||
t.Fatal("revocation fell back to old credential")
|
||||
}
|
||||
if w := lifecycleRequest(t, s, "", "PUT", "/api/settings/system", `{"models.chat.model":"legacy-format"}`); w.Code != 400 {
|
||||
t.Fatal("legacy unversioned write silently accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationRejectsUntrustedSourcesTokensAndRevokedActor(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
bob, _ := st.CreateAPIKey("bob", "test")
|
||||
p, _ := st.CreateProject("alice", "Trusted")
|
||||
other, _ := st.CreateProject("alice", "Other")
|
||||
_ = st.MergeSettingsScope("alice", "project:"+p.ID, map[string]any{"deliverables.style": "formal"})
|
||||
_ = st.MergeSettingsScope("bob", "project:"+p.ID, map[string]any{"deliverables.style": "minimal"})
|
||||
preview := func(source string) *httptest.ResponseRecorder {
|
||||
body, _ := json.Marshal(map[string]any{"choices": []map[string]string{{"path": "deliverables.style", "sourceOwner": source}}, "expectedRevision": 0})
|
||||
return lifecycleRequest(t, s, key, "POST", "/api/settings/project/"+p.ID+"/migration/preview", string(body))
|
||||
}
|
||||
if w := preview("bob"); w.Code != 404 {
|
||||
t.Fatalf("forged scope granted source authority=%d", w.Code)
|
||||
}
|
||||
w := preview("alice")
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
var response struct {
|
||||
Observation string `json:"observation"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &response)
|
||||
body, _ := json.Marshal(map[string]any{"observation": response.Observation, "expectedRevision": 0})
|
||||
if w := lifecycleRequest(t, s, key, "POST", "/api/settings/project/"+other.ID+"/migration/apply", string(body)); w.Code != 404 {
|
||||
t.Fatal("token reused for another project")
|
||||
}
|
||||
if w := lifecycleRequest(t, s, bob, "POST", "/api/settings/project/"+p.ID+"/migration/apply", string(body)); w.Code != 404 {
|
||||
t.Fatal("token reused by another actor")
|
||||
}
|
||||
s.SetProjectSettingsAuthorizer(func(context.Context, string, string, bool) error { return lifecycleStatusError(403) })
|
||||
if w := lifecycleRequest(t, s, key, "POST", "/api/settings/project/"+p.ID+"/migration/apply", string(body)); w.Code != 403 {
|
||||
t.Fatal("revoked actor migration applied")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPersonalHTTPToolsCannotExportSystemCredential(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
for _, ref := range []string{"models.chat.apiKey", "tools.tavily.apiKeys", "env:MODEL_API_KEY"} {
|
||||
body, _ := json.Marshal(map[string]any{"name": "private.export", "http": map[string]any{"method": "GET", "url": "https://user-controlled.invalid", "authRef": ref}})
|
||||
if w := lifecycleRequest(t, s, key, "POST", "/api/tools", string(body)); w.Code != 400 {
|
||||
t.Fatalf("personal tool can refer to protected credential namespace %s: %d", ref, w.Code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicProjectAndSystemUnsetEnvelope(t *testing.T) {
|
||||
s, st := newTestServer(t)
|
||||
p, _ := st.CreateProject("local", "Defaults")
|
||||
w := lifecycleRequest(t, s, "", "PUT", "/api/settings/project/"+p.ID, `{"values":{"deliverables.style":"formal"},"expectedRevision":0}`)
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
w = lifecycleRequest(t, s, "", "PUT", "/api/settings/project/"+p.ID, `{"values":{},"unset":["deliverables.style"],"expectedRevision":1}`)
|
||||
if w.Code != 200 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
if s.set.Effective("local", p.ID).GetString("deliverables.style", "") != "modern" {
|
||||
t.Fatal("project unset did not restore inherited default")
|
||||
}
|
||||
if w := lifecycleRequest(t, s, "", "PUT", "/api/settings/project/"+p.ID, `{"values":{"deliverables.style":"formal"},"unset":["deliverables.style"],"expectedRevision":2}`); w.Code != 400 {
|
||||
t.Fatal("overlap request accepted")
|
||||
}
|
||||
revision, _ := s.set.SystemRevision()
|
||||
body, _ := json.Marshal(map[string]any{"values": map[string]any{}, "unset": []string{"runtime.mock"}, "expectedRevision": revision})
|
||||
if w := lifecycleRequest(t, s, "", "PUT", "/api/settings/system", string(body)); w.Code != 200 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
if s.set.Effective("local", "").GetBool("runtime.mock", true) {
|
||||
t.Fatal("system unset did not restore default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicPersonalMergePatchDeletesOnlyKnownNonsecretLeaves(t *testing.T) {
|
||||
s, st := newAuthTestServer(t)
|
||||
key, _ := st.CreateAPIKey("alice", "test")
|
||||
p, _ := st.CreateProject("alice", "Personal reset")
|
||||
if w := lifecycleRequest(t, s, key, "PUT", "/api/settings/user", `{"ui.theme":"light","ui.cardflow":false,"retrieval.topK":12}`); w.Code != 200 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
if w := lifecycleRequest(t, s, key, "PUT", "/api/settings/user", `{"ui.theme":null,"ui.cardflow":null,"retrieval.topK":null}`); w.Code != 200 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
w := lifecycleRequest(t, s, key, "GET", "/api/settings", "")
|
||||
var view struct {
|
||||
User map[string]any `json:"user"`
|
||||
}
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &view)
|
||||
if w.Code != 200 || view.User["ui"] != nil || view.User["retrieval"] != nil {
|
||||
t.Fatal("deleted preferences still read back as overrides")
|
||||
}
|
||||
if w := lifecycleRequest(t, s, key, "PUT", "/api/settings/project/"+p.ID+"/personal", `{"crossSession.read":true}`); w.Code != 200 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
if w := lifecycleRequest(t, s, key, "PUT", "/api/settings/project/"+p.ID+"/personal", `{"crossSession.read":null}`); w.Code != 200 {
|
||||
t.Fatal(w.Code, w.Body.String())
|
||||
}
|
||||
if s.set.Effective("alice", p.ID).GetBool("crossSession.read", true) {
|
||||
t.Fatal("personal project reset stayed active")
|
||||
}
|
||||
for _, body := range []string{`{"ui":null}`, `{"unknown":null}`, `{"models.chat.apiKey":null}`, `{"deliverables.style":null}`, `{"_policy.managedModels":null}`} {
|
||||
if w := lifecycleRequest(t, s, key, "PUT", "/api/settings/user", body); w.Code != 400 {
|
||||
t.Fatal("invalid personal reset accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
core "agent-system-core"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// MigrationEntry deliberately includes no values, masks, or value digests. Raw
|
||||
// recovery data stays in the existing protected DB rows/snapshots.
|
||||
type MigrationEntry struct {
|
||||
Owner string `json:"owner"`
|
||||
Scope string `json:"scope"`
|
||||
Path string `json:"path"`
|
||||
Status string `json:"status"`
|
||||
Reason string `json:"reason"`
|
||||
LastSnapshotAt string `json:"lastSnapshotAt,omitempty"`
|
||||
}
|
||||
|
||||
type MigrationInventory struct {
|
||||
DryRun bool `json:"dryRun"`
|
||||
Entries []MigrationEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// MigrationInventory is read-only. Settings rows have no trustworthy per-key
|
||||
// modification timestamps, so snapshot time is only a recovery reference. It
|
||||
// never guesses the newest owner or promotes a personal credential/configuration.
|
||||
func (s *Service) MigrationInventory() (MigrationInventory, error) {
|
||||
out := MigrationInventory{DryRun: true, Entries: []MigrationEntry{}}
|
||||
refs, err := s.st.ListSettingsScopes()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
type candidate struct {
|
||||
index int
|
||||
value any
|
||||
}
|
||||
groups := map[string][]candidate{}
|
||||
for _, ref := range refs {
|
||||
if ref.Owner == OwnerSystemRow || (ref.Scope != ScopeUser && !strings.HasPrefix(ref.Scope, ScopeProjectPrefix)) {
|
||||
continue
|
||||
}
|
||||
values, err := s.st.GetSettingsScope(ref.Owner, ref.Scope)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
values = normalizeKeys(values)
|
||||
stamp := ""
|
||||
if at, ok := s.st.LatestSnapshotAt(ref.Owner, ref.Scope); ok {
|
||||
stamp = at.UTC().Format("2006-01-02T15:04:05.999999999Z07:00")
|
||||
}
|
||||
for _, d := range s.Descriptors() {
|
||||
value, present := valueAtPath(values, d.Key)
|
||||
if !present {
|
||||
continue
|
||||
}
|
||||
entry := MigrationEntry{Owner: ref.Owner, Scope: ref.Scope, Path: d.Key, LastSnapshotAt: stamp}
|
||||
if d.Scope == core.SettingScopeSystem {
|
||||
entry.Status = "quarantined"
|
||||
entry.Reason = "系统受管旧覆盖已退出生效链,原值保留;禁止自动提升为系统配置"
|
||||
} else if d.Scope == ProjectSettingScope && strings.HasPrefix(ref.Scope, ScopeProjectPrefix) {
|
||||
entry.Status = "requires-decision"
|
||||
entry.Reason = "旧项目业务副本保留;按键选择权威来源后才能迁移,快照时间不等于修改时间"
|
||||
group := ref.Scope + ":" + d.Key
|
||||
groups[group] = append(groups[group], candidate{len(out.Entries), value})
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
out.Entries = append(out.Entries, entry)
|
||||
}
|
||||
known := map[string]bool{}
|
||||
for _, d := range s.Descriptors() {
|
||||
known[d.Key] = true
|
||||
}
|
||||
for _, path := range core.TouchesManagedSettings(values, s.ManagedSettingPaths()) {
|
||||
if !known[path] {
|
||||
out.Entries = append(out.Entries, MigrationEntry{Owner: ref.Owner, Scope: ref.Scope, Path: path, Status: "quarantined", Reason: "系统受管命名空间的旧未知项已退出生效链,原值保留", LastSnapshotAt: stamp})
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, group := range groups {
|
||||
if len(group) < 2 {
|
||||
continue
|
||||
}
|
||||
for _, item := range group[1:] {
|
||||
if !reflect.DeepEqual(group[0].value, item.value) {
|
||||
for _, candidate := range group {
|
||||
out.Entries[candidate.index].Status = "conflict"
|
||||
out.Entries[candidate.index].Reason = "同项目同键的多 owner 副本值不同;必须由项目负责人逐键裁定,禁止自动合并"
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
secrets, err := s.st.SecretInventory()
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
for _, entry := range secrets {
|
||||
if entry.Owner != "" && ProtectedSecretService(entry.Service) {
|
||||
out.Entries = append(out.Entries, MigrationEntry{Owner: entry.Owner, Scope: "secret_keys", Path: entry.Service, Status: "quarantined", Reason: fmt.Sprintf("%d 条个人密钥已退出生效链,原记录保留;系统凭据须单独配置", entry.Count)})
|
||||
}
|
||||
}
|
||||
sort.Slice(out.Entries, func(i, j int) bool {
|
||||
a, b := out.Entries[i], out.Entries[j]
|
||||
return a.Scope+"\x00"+a.Path+"\x00"+a.Owner < b.Scope+"\x00"+b.Path+"\x00"+b.Owner
|
||||
})
|
||||
return out, nil
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
const observationPrefix = "migration-observation:"
|
||||
|
||||
type MigrationChoice struct {
|
||||
Path string `json:"path"`
|
||||
SourceOwner string `json:"sourceOwner"`
|
||||
}
|
||||
type MigrationPreview struct {
|
||||
Observation string `json:"observation"`
|
||||
ProjectRevision int64 `json:"projectRevision"`
|
||||
Conflicts []string `json:"conflicts"`
|
||||
}
|
||||
type MigrationApplyResult struct {
|
||||
ProjectRevision int64 `json:"projectRevision"`
|
||||
Replayed bool `json:"replayed"`
|
||||
}
|
||||
|
||||
func (s *Service) migrationChoices(choices []MigrationChoice) error {
|
||||
if len(choices) == 0 || len(choices) > 100 {
|
||||
return &ErrInvalidSetting{Path: "choices", Reason: "请选择 1 到 100 个明确来源"}
|
||||
}
|
||||
descriptors := map[string]core.SettingDesc{}
|
||||
for _, d := range s.SchemaFor(ProjectSettingScope) {
|
||||
descriptors[d.Key] = d
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, choice := range choices {
|
||||
d, ok := descriptors[choice.Path]
|
||||
leaf := choice.Path[strings.LastIndex(choice.Path, ".")+1:]
|
||||
if !ok || choice.SourceOwner == "" || seen[choice.Path] {
|
||||
return &ErrInvalidSetting{Path: choice.Path, Reason: "未知、重复或缺少来源的项目业务项"}
|
||||
}
|
||||
if d.Secret || d.Type == core.SettingSecret || isSecretKey(leaf) {
|
||||
return &ErrInvalidSetting{Path: choice.Path, Reason: "不迁移个人密钥;请明确配置新的共享凭据"}
|
||||
}
|
||||
seen[choice.Path] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// migrationSources projects business values only; an observation never copies
|
||||
// another actor's personal preferences or credentials into its recovery record.
|
||||
func (s *Service) migrationSources(rows map[store.SettingsScopeRef]map[string]any, projectID string) map[string]any {
|
||||
sources := map[string]any{}
|
||||
for ref, row := range rows {
|
||||
if ref.Owner != "" && ref.Scope == ScopeProjectPrefix+projectID {
|
||||
values := s.ProjectValues(row, ProjectSettingScope)
|
||||
for _, d := range s.SchemaFor(ProjectSettingScope) {
|
||||
if d.Secret || d.Type == core.SettingSecret || isSecretKey(d.Key[strings.LastIndex(d.Key, ".")+1:]) {
|
||||
deletePath(values, d.Key)
|
||||
}
|
||||
}
|
||||
sources[ref.Owner] = values
|
||||
}
|
||||
}
|
||||
return sources
|
||||
}
|
||||
|
||||
func (s *Service) chosenMigrationValues(sources map[string]any, choices []MigrationChoice) (map[string]any, error) {
|
||||
values := map[string]any{}
|
||||
for _, choice := range choices {
|
||||
row, _ := sources[choice.SourceOwner].(map[string]any)
|
||||
value, ok := valueAtPath(row, choice.Path)
|
||||
if !ok {
|
||||
return nil, &ErrInvalidSetting{Path: choice.Path, Reason: "观察的来源中缺少有效值"}
|
||||
}
|
||||
values = core.MergeSettings(values, expandDotted(choice.Path, value))
|
||||
}
|
||||
return values, s.ValidateWrite(values, ProjectSettingScope)
|
||||
}
|
||||
|
||||
func migrationConflicts(sources map[string]any, choices []MigrationChoice) []string {
|
||||
out := []string{}
|
||||
for _, choice := range choices {
|
||||
var first any
|
||||
seen := false
|
||||
for _, row := range sources {
|
||||
values, _ := row.(map[string]any)
|
||||
value, ok := valueAtPath(values, choice.Path)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if !seen {
|
||||
first = value
|
||||
seen = true
|
||||
} else if !reflect.DeepEqual(first, value) {
|
||||
out = append(out, choice.Path)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Service) PreviewProjectMigration(actor, projectID string, choices []MigrationChoice, expectedRevision int64) (MigrationPreview, error) {
|
||||
result := MigrationPreview{Conflicts: []string{}}
|
||||
if actor == "" || projectID == "" {
|
||||
return result, &ErrInvalidSetting{Path: "project", Reason: "需要受信项目和身份"}
|
||||
}
|
||||
if err := s.migrationChoices(choices); err != nil {
|
||||
return result, err
|
||||
}
|
||||
target := store.SettingsScopeRef{Owner: OwnerSystemRow, Scope: ScopeSharedProjectPrefix + projectID}
|
||||
observation := store.SettingsScopeRef{Owner: OwnerSystemRow, Scope: observationPrefix + store.NewID("obs")}
|
||||
err := s.st.UpdateSettingsScopes([]store.SettingsScopeRef{target, observation}, ScopeProjectPrefix+projectID, func(rows map[store.SettingsScopeRef]map[string]any) (map[store.SettingsScopeRef]map[string]any, error) {
|
||||
revision := sharedRevision(rows[target])
|
||||
if revision != expectedRevision {
|
||||
return nil, ErrSettingsConflict
|
||||
}
|
||||
sources := s.migrationSources(rows, projectID)
|
||||
if _, err := s.chosenMigrationValues(sources, choices); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result.Observation = strings.TrimPrefix(observation.Scope, observationPrefix)
|
||||
result.ProjectRevision = revision
|
||||
result.Conflicts = migrationConflicts(sources, choices)
|
||||
return map[store.SettingsScopeRef]map[string]any{observation: {"actor": actor, "projectId": projectID, "expectedRevision": revision, "choices": choices, "sources": sources, "createdAt": time.Now().UTC().Format(time.RFC3339Nano)}}, nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (s *Service) MigrationSelection(actor, projectID, token string, expectedRevision int64) (map[string]any, []MigrationChoice, error) {
|
||||
if !validObservationToken(token) {
|
||||
return nil, nil, store.ErrNotFound
|
||||
}
|
||||
row, err := s.st.GetSettingsScope(OwnerSystemRow, observationPrefix+token)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if row["actor"] != actor || row["projectId"] != projectID {
|
||||
return nil, nil, store.ErrNotFound
|
||||
}
|
||||
number, _ := row["expectedRevision"].(float64)
|
||||
if int64(number) != expectedRevision {
|
||||
return nil, nil, ErrSettingsConflict
|
||||
}
|
||||
var choices []MigrationChoice
|
||||
encoded, _ := json.Marshal(row["choices"])
|
||||
if err := json.Unmarshal(encoded, &choices); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if err := s.migrationChoices(choices); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
sources, _ := row["sources"].(map[string]any)
|
||||
values, err := s.chosenMigrationValues(sources, choices)
|
||||
return values, choices, err
|
||||
}
|
||||
|
||||
func validObservationToken(token string) bool {
|
||||
return strings.HasPrefix(token, "obs_") && len(token) <= 100 && !strings.ContainsAny(token, "/\\\x00")
|
||||
}
|
||||
|
||||
func (s *Service) ApplyProjectMigration(actor, projectID, token string, expectedRevision int64, prevalidated map[string]any) (MigrationApplyResult, error) {
|
||||
result := MigrationApplyResult{}
|
||||
if actor == "" || projectID == "" || !validObservationToken(token) {
|
||||
return result, store.ErrNotFound
|
||||
}
|
||||
target := store.SettingsScopeRef{Owner: OwnerSystemRow, Scope: ScopeSharedProjectPrefix + projectID}
|
||||
observation := store.SettingsScopeRef{Owner: OwnerSystemRow, Scope: observationPrefix + token}
|
||||
err := s.st.UpdateSettingsScopes([]store.SettingsScopeRef{target, observation}, ScopeProjectPrefix+projectID, func(rows map[store.SettingsScopeRef]map[string]any) (map[store.SettingsScopeRef]map[string]any, error) {
|
||||
observed := rows[observation]
|
||||
if observed["actor"] != actor || observed["projectId"] != projectID {
|
||||
return nil, store.ErrNotFound
|
||||
}
|
||||
observedRevision := int64(0)
|
||||
if number, ok := observed["expectedRevision"].(float64); ok {
|
||||
observedRevision = int64(number)
|
||||
}
|
||||
if observedRevision != expectedRevision {
|
||||
return nil, ErrSettingsConflict
|
||||
}
|
||||
if number, applied := observed["appliedRevision"].(float64); applied {
|
||||
result.ProjectRevision = int64(number)
|
||||
result.Replayed = true
|
||||
return nil, nil
|
||||
}
|
||||
if sharedRevision(rows[target]) != expectedRevision {
|
||||
return nil, ErrSettingsConflict
|
||||
}
|
||||
previous, _ := observed["sources"].(map[string]any)
|
||||
current := s.migrationSources(rows, projectID)
|
||||
if !reflect.DeepEqual(previous, current) {
|
||||
return nil, ErrSettingsConflict
|
||||
}
|
||||
var choices []MigrationChoice
|
||||
encoded, _ := json.Marshal(observed["choices"])
|
||||
if err := json.Unmarshal(encoded, &choices); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.migrationChoices(choices); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
selected, err := s.chosenMigrationValues(current, choices)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if prevalidated == nil || !reflect.DeepEqual(selected, prevalidated) {
|
||||
return nil, ErrSettingsConflict
|
||||
}
|
||||
row := rows[target]
|
||||
existing, _ := row["values"].(map[string]any)
|
||||
next := core.MergeSettings(s.ProjectValues(existing, ProjectSettingScope), selected)
|
||||
revision := sharedRevision(row)
|
||||
if !reflect.DeepEqual(existing, next) {
|
||||
revision++
|
||||
}
|
||||
result.ProjectRevision = revision
|
||||
completed := core.MergeSettings(observed, map[string]any{"appliedRevision": revision, "appliedAt": time.Now().UTC().Format(time.RFC3339Nano)})
|
||||
return map[store.SettingsScopeRef]map[string]any{target: {"revision": revision, "values": next, "updatedAt": time.Now().UTC().Format(time.RFC3339Nano)}, observation: completed}, nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
func TestMigrationApplyRollbackAndIdempotentRetry(t *testing.T) {
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "migration.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
s := NewService(st)
|
||||
if err := st.MergeSettingsScope("alice", ScopeProjectPrefix+"p1", map[string]any{"deliverables.style": "formal", "ui.theme": "light"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
preview, err := s.PreviewProjectMigration("alice", "p1", []MigrationChoice{{Path: "deliverables.style", SourceOwner: "alice"}}, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
selected, _, err := s.MigrationSelection("alice", "p1", preview.Observation, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Observation is sorted before target, so failure of this later write must
|
||||
// roll back the previously attempted observation completion as well.
|
||||
if _, err := st.DB().Exec(`CREATE TRIGGER reject_migration BEFORE INSERT ON settings WHEN NEW.scope='shared-project:p1' BEGIN SELECT RAISE(ABORT,'fixture migration target failure'); END`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.ApplyProjectMigration("alice", "p1", preview.Observation, 0, selected); err == nil {
|
||||
t.Fatal("injected target failure succeeded")
|
||||
}
|
||||
row, _ := st.GetSettingsScope(OwnerSystemRow, observationPrefix+preview.Observation)
|
||||
if _, exists := row["appliedRevision"]; exists {
|
||||
t.Fatal("failed migration left completed observation")
|
||||
}
|
||||
_, revision, err := s.SharedProjectScope("p1")
|
||||
if err != nil || revision != 0 {
|
||||
t.Fatal("failed migration changed target")
|
||||
}
|
||||
if _, err := st.DB().Exec(`DROP TRIGGER reject_migration`); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := s.ApplyProjectMigration("alice", "p1", preview.Observation, 0, selected)
|
||||
if err != nil || result.ProjectRevision != 1 || result.Replayed {
|
||||
t.Fatalf("retry=%+v %v", result, err)
|
||||
}
|
||||
result, err = s.ApplyProjectMigration("alice", "p1", preview.Observation, 0, selected)
|
||||
if err != nil || result.ProjectRevision != 1 || !result.Replayed {
|
||||
t.Fatalf("idempotent replay=%+v %v", result, err)
|
||||
}
|
||||
original, _ := st.GetSettingsScope("alice", ScopeProjectPrefix+"p1")
|
||||
if original["ui.theme"] != "light" || original["deliverables.style"] != "formal" {
|
||||
t.Fatal("migration rewrote source/personal rows")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationRejectsChangedSourceSetTargetAndPrivateCredentials(t *testing.T) {
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "migration.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
s := NewService(st)
|
||||
_ = st.MergeSettingsScope("alice", ScopeProjectPrefix+"p1", map[string]any{"deliverables.style": "formal"})
|
||||
choices := []MigrationChoice{{Path: "deliverables.style", SourceOwner: "alice"}}
|
||||
preview, err := s.PreviewProjectMigration("alice", "p1", choices, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
selected, _, _ := s.MigrationSelection("alice", "p1", preview.Observation, 0)
|
||||
_ = st.MergeSettingsScope("new-owner", ScopeProjectPrefix+"p1", map[string]any{"deliverables.style": "minimal"})
|
||||
if _, err := s.ApplyProjectMigration("alice", "p1", preview.Observation, 0, selected); err != ErrSettingsConflict {
|
||||
t.Fatalf("changed conflict/source set=%v", err)
|
||||
}
|
||||
preview, err = s.PreviewProjectMigration("alice", "p1", choices, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
selected, _, _ = s.MigrationSelection("alice", "p1", preview.Observation, 0)
|
||||
if _, err := s.MergeSharedProject("p1", map[string]any{"deliverables.style": "modern"}, 0); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.ApplyProjectMigration("alice", "p1", preview.Observation, 0, selected); err != ErrSettingsConflict {
|
||||
t.Fatalf("changed target=%v", err)
|
||||
}
|
||||
if _, err := s.PreviewProjectMigration("alice", "p1", []MigrationChoice{{Path: "models.chat.apiKey", SourceOwner: "other"}}, 1); err == nil {
|
||||
t.Fatal("private credential source accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPersonalMergePatchNullDeletesNonsecretLeafAndFollowsDefaults(t *testing.T) {
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "personal.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
s := NewService(st)
|
||||
s.SetExtraSchema([]core.SettingDesc{{Key: "plugins.example.personalNumber", Type: core.SettingNumber, Default: 3}})
|
||||
if err := s.MergeUser("alice", map[string]any{"ui.theme": "light", "ui.cardflow": false, "retrieval.topK": 12, "plugins.example.personalNumber": 9}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.MergeUser("alice", map[string]any{"ui.theme": nil, "ui.cardflow": nil, "retrieval.topK": nil, "plugins.example.personalNumber": nil}); err != nil {
|
||||
t.Fatalf("valid personal reset rejected: %v", err)
|
||||
}
|
||||
raw, err := st.GetSettingsScope("alice", ScopeUser)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{"ui.theme", "ui.cardflow", "retrieval.topK", "plugins.example.personalNumber"} {
|
||||
if _, exists := valueAtPath(raw, path); exists {
|
||||
t.Fatalf("null persisted instead of deleting %s", path)
|
||||
}
|
||||
}
|
||||
eff := s.Effective("alice", "")
|
||||
if eff.GetString("ui.theme", "") != "dark" || !eff.GetBool("ui.cardflow", false) || eff.GetNumber("retrieval.topK", 0) != 5 {
|
||||
t.Fatalf("personal defaults not restored: theme=%q cardflow=%v topK=%v", eff.GetString("ui.theme", ""), eff.GetBool("ui.cardflow", false), eff.GetNumber("retrieval.topK", 0))
|
||||
}
|
||||
s.SetExtraSchema([]core.SettingDesc{{Key: "plugins.example.personalNumber", Type: core.SettingNumber, Default: 7}})
|
||||
if got := s.Effective("alice", "").GetNumber("plugins.example.personalNumber", 0); got != 7 {
|
||||
t.Fatal("null reset copied old default instead of inheriting future default")
|
||||
}
|
||||
if err := s.MergePersonalProject("alice", "p", map[string]any{"crossSession.read": true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.MergePersonalProject("alice", "p", map[string]any{"crossSession.read": nil}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Effective("alice", "p").GetBool("crossSession.read", true) {
|
||||
t.Fatal("personal project boolean reset not effective")
|
||||
}
|
||||
for _, values := range []map[string]any{{"ui": nil}, {"unknown": nil}, {"models.chat.apiKey": nil}, {"_policy.managedModels": nil}, {"deliverables.style": nil}} {
|
||||
if err := s.MergeUser("alice", values); err == nil {
|
||||
t.Fatal("unknown/ancestor/managed/private/wrong scope reset accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
)
|
||||
|
||||
// These checks require a disposable database. They never select a deployment DSN.
|
||||
func isolatedSettingsPostgres(t *testing.T) *store.Store {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("AGENT_SETTINGS_PG_TEST_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("NOT_RUN: AGENT_SETTINGS_PG_TEST_DSN requires an isolated PostgreSQL fixture")
|
||||
}
|
||||
if os.Getenv("AGENT_SETTINGS_PG_ISOLATED") != "1" {
|
||||
t.Fatal("refusing PostgreSQL settings tests without an isolated fixture marker")
|
||||
}
|
||||
probe, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatal("cannot open isolated PostgreSQL fixture")
|
||||
}
|
||||
var database string
|
||||
err = probe.QueryRow("SELECT current_database()").Scan(&database)
|
||||
probe.Close()
|
||||
if err != nil || !strings.HasPrefix(database, "settings_") {
|
||||
t.Fatal("PostgreSQL settings fixture database must have the settings_ prefix")
|
||||
}
|
||||
st, err := store.OpenPostgres(dsn, t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal("cannot initialize isolated PostgreSQL fixture")
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
return st
|
||||
}
|
||||
|
||||
func TestPostgresSharedSettingsConcurrentCASAndPersonalBoundary(t *testing.T) {
|
||||
first, second := isolatedSettingsPostgres(t), isolatedSettingsPostgres(t)
|
||||
a, b := NewService(first), NewService(second)
|
||||
project := fmt.Sprintf("pg-cas-%d", time.Now().UnixNano())
|
||||
start := make(chan struct{})
|
||||
results := make(chan error, 2)
|
||||
var writers sync.WaitGroup
|
||||
for i, service := range []*Service{a, b} {
|
||||
writers.Add(1)
|
||||
go func(service *Service, style string) {
|
||||
defer writers.Done()
|
||||
<-start
|
||||
_, err := service.MergeSharedProject(project, map[string]any{"deliverables.style": style}, 0)
|
||||
results <- err
|
||||
}(service, []string{"formal", "minimal"}[i])
|
||||
}
|
||||
close(start)
|
||||
writers.Wait()
|
||||
close(results)
|
||||
passed, conflicts := 0, 0
|
||||
for err := range results {
|
||||
switch {
|
||||
case err == nil:
|
||||
passed++
|
||||
case errors.Is(err, ErrSettingsConflict):
|
||||
conflicts++
|
||||
default:
|
||||
t.Fatalf("unexpected concurrent write result: %v", err)
|
||||
}
|
||||
}
|
||||
if passed != 1 || conflicts != 1 {
|
||||
t.Fatalf("CAS admitted %d writers with %d conflicts", passed, conflicts)
|
||||
}
|
||||
values, revision, err := b.SharedProjectScope(project)
|
||||
if err != nil || revision != 1 {
|
||||
t.Fatalf("second connection sees revision %d: %v", revision, err)
|
||||
}
|
||||
if err := first.MergeSettingsScope("alice", ScopeProjectPrefix+project, map[string]any{"context.userInstructions": "private", "models.chat.model": "legacy"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b.Effective("bob", project).GetString("context.userInstructions", "") != "" || b.Effective("alice", project).GetString("models.chat.model", "") != "" {
|
||||
t.Fatal("legacy model override or another member's preferences became effective")
|
||||
}
|
||||
if got := b.Effective("bob", project).GetString("deliverables.style", ""); got != a.Effective("alice", project).GetString("deliverables.style", "") || len(values) == 0 {
|
||||
t.Fatal("shared project configuration diverged across actors/connections")
|
||||
}
|
||||
if _, err := a.MergeSharedProject(project, map[string]any{}, revision, []string{"deliverables.style"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := b.Effective("bob", project).GetString("deliverables.style", ""); got != "modern" {
|
||||
t.Fatalf("removing the override did not restore the default: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresProjectMigrationRollbackAndReplay(t *testing.T) {
|
||||
st := isolatedSettingsPostgres(t)
|
||||
s := NewService(st)
|
||||
project := fmt.Sprintf("pg-migration-%d", time.Now().UnixNano())
|
||||
if err := st.MergeSettingsScope("alice", ScopeProjectPrefix+project, map[string]any{"deliverables.style": "formal", "context.userInstructions": "private"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
preview, err := s.PreviewProjectMigration("alice", project, []MigrationChoice{{Path: "deliverables.style", SourceOwner: "alice"}}, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
selected, _, err := s.MigrationSelection("alice", project, preview.Observation, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Abort after the observation has been updated, proving that both rows roll back.
|
||||
function := fmt.Sprintf("settings_reject_%d", time.Now().UnixNano())
|
||||
trigger := function + "_trigger"
|
||||
ddl := fmt.Sprintf(`CREATE FUNCTION %s() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF NEW.scope='%s' THEN RAISE EXCEPTION 'fixture migration target failure'; END IF; RETURN NEW; END $$`, function, ScopeSharedProjectPrefix+project)
|
||||
if _, err := st.DB().Exec(ddl); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
st.DB().Exec("DROP TRIGGER IF EXISTS " + trigger + " ON settings")
|
||||
st.DB().Exec("DROP FUNCTION IF EXISTS " + function + "()")
|
||||
})
|
||||
if _, err := st.DB().Exec(fmt.Sprintf("CREATE TRIGGER %s BEFORE INSERT OR UPDATE ON settings FOR EACH ROW EXECUTE FUNCTION %s()", trigger, function)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.ApplyProjectMigration("alice", project, preview.Observation, 0, selected); err == nil {
|
||||
t.Fatal("injected PostgreSQL target failure succeeded")
|
||||
}
|
||||
observation, err := st.GetSettingsScope(OwnerSystemRow, observationPrefix+preview.Observation)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, done := observation["appliedRevision"]; done {
|
||||
t.Fatal("rollback retained a completed observation")
|
||||
}
|
||||
if _, revision, err := s.SharedProjectScope(project); err != nil || revision != 0 {
|
||||
t.Fatal("failed migration changed the project target")
|
||||
}
|
||||
if _, err := st.DB().Exec("DROP TRIGGER " + trigger + " ON settings"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
out, err := s.ApplyProjectMigration("alice", project, preview.Observation, 0, selected)
|
||||
if err != nil || out.ProjectRevision != 1 || out.Replayed != (attempt == 1) {
|
||||
t.Fatalf("apply/replay attempt %d: %+v, %v", attempt, out, err)
|
||||
}
|
||||
}
|
||||
old, err := st.GetSettingsScope("alice", ScopeProjectPrefix+project)
|
||||
if err != nil || old["context.userInstructions"] != "private" || old["deliverables.style"] != "formal" {
|
||||
t.Fatal("migration changed a legacy or private source row")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
const (
|
||||
ScopeSharedProjectPrefix = "shared-project:"
|
||||
PersonalProjectScope core.SettingScope = core.SettingScopeProjectPersonal
|
||||
UserSettingScope core.SettingScope = core.SettingScopeUser
|
||||
ProjectSettingScope core.SettingScope = core.SettingScopeProject
|
||||
)
|
||||
|
||||
func builtinScope(d core.SettingDesc) core.SettingScope {
|
||||
if d.Scope == core.SettingScopeSystem || strings.HasPrefix(d.Key, "runtime.") || d.Key == "plugins.dir" || d.Key == "tools.mcp.overrides" {
|
||||
return core.SettingScopeSystem
|
||||
}
|
||||
if strings.HasPrefix(d.Key, "deliverables.") {
|
||||
return ProjectSettingScope
|
||||
}
|
||||
if d.Key == "crossSession.read" {
|
||||
return PersonalProjectScope
|
||||
}
|
||||
return UserSettingScope
|
||||
}
|
||||
|
||||
// Descriptors protects builtin declarations from plugin collisions. Legacy blank
|
||||
// plugin scope means a personal user/project preference, never shared authority.
|
||||
func (s *Service) Descriptors() []core.SettingDesc {
|
||||
out := Schema()
|
||||
known := map[string]bool{}
|
||||
for _, d := range out {
|
||||
known[d.Key] = true
|
||||
}
|
||||
if s != nil {
|
||||
s.schemaMu.RLock()
|
||||
defer s.schemaMu.RUnlock()
|
||||
for _, d := range s.extraSchema {
|
||||
// A plugin leaf must not replace a builtin namespace/object.
|
||||
overlaps := false
|
||||
for key := range known {
|
||||
if strings.HasPrefix(key, d.Key+".") || strings.HasPrefix(d.Key, key+".") {
|
||||
overlaps = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !known[d.Key] && !overlaps && validSettingPath(d.Key) {
|
||||
if strings.HasPrefix(d.Key, "models.") || strings.HasPrefix(d.Key, "runtime.") || strings.HasPrefix(d.Key, "tools.tavily.") || strings.HasPrefix(d.Key, "tools.mcp.") {
|
||||
d.Scope = core.SettingScopeSystem
|
||||
}
|
||||
out = append(out, d)
|
||||
known[d.Key] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Service) declaredDefaults() map[string]any {
|
||||
out := map[string]any{}
|
||||
for _, d := range s.Descriptors() {
|
||||
if d.Default != nil && validateSettingType(d, d.Default) == nil {
|
||||
out = core.MergeSettings(out, expandDotted(d.Key, d.Default))
|
||||
}
|
||||
}
|
||||
return core.MergeSettings(out, SystemDefaults())
|
||||
}
|
||||
|
||||
// EffectiveSettings getters use the JSON numeric representation. Code/plugin
|
||||
// defaults must have the same float64 form as values read back from the DB.
|
||||
func effectiveNumbers(values map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(values))
|
||||
for key, value := range values {
|
||||
switch v := value.(type) {
|
||||
case map[string]any:
|
||||
out[key] = effectiveNumbers(v)
|
||||
case int:
|
||||
out[key] = float64(v)
|
||||
case int64:
|
||||
out[key] = float64(v)
|
||||
default:
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func scopeAllows(d core.SettingDesc, scope core.SettingScope) bool {
|
||||
if d.Scope == "" {
|
||||
return scope == UserSettingScope || scope == PersonalProjectScope
|
||||
}
|
||||
return d.Scope == scope || (scope == PersonalProjectScope && d.Scope == UserSettingScope)
|
||||
}
|
||||
|
||||
func (s *Service) SchemaFor(scope core.SettingScope) []core.SettingDesc {
|
||||
out := []core.SettingDesc{}
|
||||
for _, d := range s.Descriptors() {
|
||||
if scopeAllows(d, scope) {
|
||||
out = append(out, d)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// ProjectValues contains only known leaves for the given authority scope. Old
|
||||
// user/project copies of shared or managed configuration stay in storage but
|
||||
// cannot re-enter the effective settings or HTTP responses.
|
||||
func (s *Service) ProjectValues(values map[string]any, scope core.SettingScope) map[string]any {
|
||||
values = normalizeKeys(values)
|
||||
out := map[string]any{}
|
||||
for _, d := range s.SchemaFor(scope) {
|
||||
if v, ok := valueAtPath(values, d.Key); ok && validateSettingType(d, v) == nil {
|
||||
out = core.MergeSettings(out, expandDotted(d.Key, normalizeValue(v)))
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Service) DefaultsFor(scope core.SettingScope) map[string]any {
|
||||
out := map[string]any{}
|
||||
for _, d := range s.SchemaFor(scope) {
|
||||
out = core.MergeSettings(out, expandDotted(d.Key, d.Default))
|
||||
}
|
||||
return core.MergeSettings(out, s.ProjectValues(SystemDefaults(), scope))
|
||||
}
|
||||
|
||||
type ErrInvalidSetting struct {
|
||||
Path string
|
||||
Reason string
|
||||
}
|
||||
|
||||
func (e *ErrInvalidSetting) Error() string { return fmt.Sprintf("设置 %s: %s", e.Path, e.Reason) }
|
||||
|
||||
func validSettingPath(path string) bool {
|
||||
if path == "" {
|
||||
return false
|
||||
}
|
||||
root := strings.SplitN(path, ".", 2)[0]
|
||||
if root == "_policy" || root == "_revision" {
|
||||
return false
|
||||
}
|
||||
for _, segment := range strings.Split(path, ".") {
|
||||
if segment == "" || strings.TrimSpace(segment) != segment || strings.ContainsAny(segment, "/\\\x00") {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// ValidateWrite validates raw structure before normalization, then known leaves
|
||||
// and ownership. This prevents dotted/nested ambiguity and empty/scalar parents
|
||||
// from silently erasing managed subtrees.
|
||||
func (s *Service) ValidateWrite(values map[string]any, scope core.SettingScope) error {
|
||||
if values == nil {
|
||||
return &ErrInvalidSetting{Reason: "需要设置对象"}
|
||||
}
|
||||
if err := checkRawSettingPaths(values, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := checkAmbiguousPaths(values, "", map[string]bool{}); err != nil {
|
||||
return err
|
||||
}
|
||||
incoming := prepareWrite(values)
|
||||
if scope != core.SettingScopeSystem {
|
||||
if err := s.checkManagedWrite(incoming); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
desc := map[string]core.SettingDesc{}
|
||||
for _, d := range s.Descriptors() {
|
||||
desc[d.Key] = d
|
||||
}
|
||||
var walk func(map[string]any, string) error
|
||||
walk = func(m map[string]any, prefix string) error {
|
||||
keys := make([]string, 0, len(m))
|
||||
for key := range m {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
for _, key := range keys {
|
||||
path := key
|
||||
if prefix != "" {
|
||||
path = prefix + "." + key
|
||||
}
|
||||
value := m[key]
|
||||
if d, known := desc[path]; known {
|
||||
if !scopeAllows(d, scope) {
|
||||
return &ErrInvalidSetting{Path: path, Reason: "不属于当前设置范围"}
|
||||
}
|
||||
if value == nil && (scope == UserSettingScope || scope == PersonalProjectScope) {
|
||||
leaf := path[strings.LastIndex(path, ".")+1:]
|
||||
if d.Secret || d.Type == core.SettingSecret || isSecretKey(leaf) {
|
||||
return &ErrInvalidSetting{Path: path, Reason: "撤销密钥请明确保存空值,不能删除后回退旧凭据"}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if err := validateSettingType(d, value); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
child, object := value.(map[string]any)
|
||||
parent := false
|
||||
for candidate := range desc {
|
||||
if strings.HasPrefix(candidate, path+".") {
|
||||
parent = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !object || !parent || len(child) == 0 {
|
||||
return &ErrInvalidSetting{Path: path, Reason: "未知设置项或无效对象"}
|
||||
}
|
||||
if err := walk(child, path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return walk(normalizeKeys(values), "")
|
||||
}
|
||||
|
||||
func checkAmbiguousPaths(values map[string]any, prefix string, seen map[string]bool) error {
|
||||
for key, value := range values {
|
||||
path := key
|
||||
if prefix != "" {
|
||||
path = prefix + "." + key
|
||||
}
|
||||
if child, ok := value.(map[string]any); ok && len(child) > 0 {
|
||||
if err := checkAmbiguousPaths(child, path, seen); err != nil {
|
||||
return err
|
||||
}
|
||||
continue
|
||||
}
|
||||
for previous := range seen {
|
||||
if previous == path || strings.HasPrefix(previous, path+".") || strings.HasPrefix(path, previous+".") {
|
||||
return &ErrInvalidSetting{Path: path, Reason: "嵌套和点路径配置重复或冲突"}
|
||||
}
|
||||
}
|
||||
seen[path] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkRawSettingPaths(values map[string]any, prefix string) error {
|
||||
for key, value := range values {
|
||||
path := key
|
||||
if prefix != "" {
|
||||
path = prefix + "." + key
|
||||
}
|
||||
if !validSettingPath(key) {
|
||||
return &ErrInvalidSetting{Path: path, Reason: "路径格式无效"}
|
||||
}
|
||||
if object, ok := value.(map[string]any); ok {
|
||||
if err := checkRawSettingPaths(object, path); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSettingType(d core.SettingDesc, value any) error {
|
||||
valid := false
|
||||
switch d.Type {
|
||||
case core.SettingBool:
|
||||
_, valid = value.(bool)
|
||||
case core.SettingString, core.SettingSecret:
|
||||
_, valid = value.(string)
|
||||
case core.SettingEnum:
|
||||
if text, ok := value.(string); ok {
|
||||
for _, option := range d.Options {
|
||||
if text == option {
|
||||
valid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
case core.SettingNumber:
|
||||
switch number := value.(type) {
|
||||
case float64:
|
||||
valid = !math.IsNaN(number) && !math.IsInf(number, 0) && number >= 0 && number <= 1e9 && number == math.Trunc(number)
|
||||
case int:
|
||||
valid = number >= 0 && number <= 1e9
|
||||
}
|
||||
}
|
||||
if !valid {
|
||||
return &ErrInvalidSetting{Path: d.Key, Reason: "值类型或选项无效"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
core "agent-system-core"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPluginSettingScopesAndReservedModelNamespace(t *testing.T) {
|
||||
s := NewService(nil)
|
||||
s.SetExtraSchema([]core.SettingDesc{
|
||||
{Key: "plugins.example.shared", Type: core.SettingString, Scope: ProjectSettingScope},
|
||||
{Key: "plugins.example.personal", Type: core.SettingString},
|
||||
{Key: "plugins.example.system", Type: core.SettingString, Scope: core.SettingScopeSystem},
|
||||
{Key: "models.custom.apiKey", Type: core.SettingSecret},
|
||||
{Key: "models.chat.model", Type: core.SettingString},
|
||||
})
|
||||
if err := s.ValidateWrite(map[string]any{"plugins.example.shared": "team"}, ProjectSettingScope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, scope := range []core.SettingScope{UserSettingScope, PersonalProjectScope} {
|
||||
if err := s.ValidateWrite(map[string]any{"plugins.example.shared": "team"}, scope); err == nil {
|
||||
t.Fatal("shared plugin accepted personal override")
|
||||
}
|
||||
if err := s.ValidateWrite(map[string]any{"plugins.example.personal": "mine"}, scope); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.ValidateWrite(map[string]any{"models.custom.apiKey": "personal"}, scope); err == nil {
|
||||
t.Fatal("plugin opened the reserved managed model namespace")
|
||||
}
|
||||
if err := s.ValidateWrite(map[string]any{"models.chat.model": "personal"}, scope); err == nil {
|
||||
t.Fatal("plugin replaced builtin authority")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginCannotPublishPrivateRuntimePolicySetting(t *testing.T) {
|
||||
s := NewService(nil)
|
||||
s.SetExtraSchema([]core.SettingDesc{{Key: "_policy.managedModels", Type: core.SettingBool, Scope: core.SettingScopeSystem}})
|
||||
if err := s.ValidateWrite(map[string]any{"_policy.managedModels": false}, core.SettingScopeSystem); err == nil {
|
||||
t.Fatal("private runtime policy became editable through plugin descriptor")
|
||||
}
|
||||
if err := s.validateUnset(map[string]any{}, []string{"_policy.managedModels"}, core.SettingScopeSystem); err == nil {
|
||||
t.Fatal("private runtime policy accepted unset")
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,77 @@ var secretSpecs = []secretSpec{
|
||||
{"tools.tavily.apiKeys", "tools.tavily"},
|
||||
}
|
||||
|
||||
// SnapshotAndMigrateSecrets 先为每个已存 scope 留快照,再把明文密钥迁入独立表并从表单设置里清掉。
|
||||
// 该服务已经有密钥行时不再迁入,避免重复。掩码占位符不迁。
|
||||
// ProtectedSecretService is the system-owned credential namespace. Unknown
|
||||
// model kinds are protected too; callers cannot evade policy by using a new key.
|
||||
func ProtectedSecretService(service string) bool {
|
||||
service = strings.TrimSpace(service)
|
||||
return service == "models" || strings.HasPrefix(service, "models.") || service == "tools.tavily" || strings.HasPrefix(service, "tools.tavily.")
|
||||
}
|
||||
|
||||
// PersonalCredentialReferenceAllowed protects global credentials from arbitrary
|
||||
// user-declared HTTP destinations. Environment references are process authority,
|
||||
// while unrelated personal service secrets may still be used by that user.
|
||||
func (s *Service) PersonalCredentialReferenceAllowed(ref string) bool {
|
||||
ref = strings.TrimSpace(ref)
|
||||
if strings.HasPrefix(ref, "env:") || ProtectedSecretService(ref) {
|
||||
return false
|
||||
}
|
||||
for _, d := range s.Descriptors() {
|
||||
if d.Scope == "system" && (d.Secret || d.Type == "secret") && (ref == d.Key || strings.HasPrefix(ref, d.Key+".")) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// LookupSecret resolves protected services only from system authority. A newly
|
||||
// saved system form value (including explicit empty/revoked) supersedes legacy
|
||||
// secret-table rows. Personal credentials remain usable for unrelated services.
|
||||
func (s *Service) LookupSecret(owner, service string) (string, bool) {
|
||||
if ProtectedSecretService(service) {
|
||||
for _, spec := range secretSpecs {
|
||||
if spec.service != service {
|
||||
continue
|
||||
}
|
||||
system, err := s.SystemScope()
|
||||
if err != nil {
|
||||
return "", true
|
||||
}
|
||||
if value, exists := valueAtPath(normalizeKeys(system), spec.path); exists {
|
||||
text, _ := value.(string)
|
||||
if text == MaskPlaceholder {
|
||||
return "", true
|
||||
}
|
||||
return text, true
|
||||
}
|
||||
}
|
||||
return s.st.ActiveSecret(OwnerSystemRow, service)
|
||||
}
|
||||
if owner == "" {
|
||||
owner = "local"
|
||||
}
|
||||
return s.st.ActiveSecret(owner, service)
|
||||
}
|
||||
|
||||
func valueAtPath(values map[string]any, path string) (any, bool) {
|
||||
var current any = values
|
||||
for _, key := range strings.Split(path, ".") {
|
||||
object, ok := current.(map[string]any)
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
current, ok = object[key]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return current, true
|
||||
}
|
||||
|
||||
// SnapshotAndMigrateSecrets preserves protected snapshots and imports only an
|
||||
// explicitly system-owned credential when its independent row is absent. It
|
||||
// retains source values and disagreements for review; personal keys are not
|
||||
// promoted. Mask placeholders are never usable credentials.
|
||||
func (s *Service) SnapshotAndMigrateSecrets() error {
|
||||
refs, err := s.st.ListSettingsScopes()
|
||||
if err != nil {
|
||||
@@ -39,6 +108,11 @@ func (s *Service) SnapshotAndMigrateSecrets() error {
|
||||
return err
|
||||
}
|
||||
for _, spec := range secretSpecs {
|
||||
// Never promote a personal/project credential to shared authority.
|
||||
// Keep those rows intact for the protected migration inventory.
|
||||
if ref.Owner != OwnerSystemRow || ref.Scope != ScopeSystem {
|
||||
continue
|
||||
}
|
||||
val, ok := lookupString(cur, spec.path)
|
||||
if !ok || val == MaskPlaceholder {
|
||||
continue
|
||||
@@ -52,13 +126,8 @@ func (s *Service) SnapshotAndMigrateSecrets() error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
path := spec.path
|
||||
if err := s.st.UpdateSettingsScope(ref.Owner, ref.Scope, func(cur map[string]any) (map[string]any, error) {
|
||||
deletePath(cur, path)
|
||||
return cur, nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
// Preserve the source, including disagreements with older table rows.
|
||||
// LookupSecret makes this explicit system value authoritative.
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -80,17 +149,7 @@ func (s *Service) snapshotThrottled(owner, scope string) {
|
||||
}
|
||||
|
||||
func lookupString(m map[string]any, path string) (string, bool) {
|
||||
var cur any = m
|
||||
for _, p := range strings.Split(path, ".") {
|
||||
obj, ok := cur.(map[string]any)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
cur, ok = obj[p]
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
cur, _ := valueAtPath(m, path)
|
||||
s, ok := cur.(string)
|
||||
if !ok || strings.TrimSpace(s) == "" {
|
||||
return "", false
|
||||
|
||||
+220
-36
@@ -3,19 +3,33 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
// Scope 层级键:user 与 project:<id> 落库;conversation 经会话画像选择实现。
|
||||
// Scope 层级键:user 与 project:<id> 落库;system 是管理员全局层(owner 固定
|
||||
// OwnerSystemRow);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{
|
||||
@@ -76,31 +90,32 @@ func SystemDefaults() map[string]any {
|
||||
|
||||
// Schema 返回设置描述清单(设置页渲染依据;插件设置项由 pluginhost 追加)。
|
||||
func Schema() []core.SettingDesc {
|
||||
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: "不调用真实模型,按脚本回放事件流"},
|
||||
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: "不调用真实模型,按脚本回放事件流"},
|
||||
{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, 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.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.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 写脚本批量调用工具。默认关闭"},
|
||||
@@ -112,10 +127,19 @@ func Schema() []core.SettingDesc {
|
||||
{Key: "runtime.composeFinal", Group: "runtime", Label: "终轮整理回复", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun, Description: "用过工具的回合结束后,再用主模型重写一段最终回复。默认关闭(裁定 4:转录权威后工作模型已看到完整证据,重写只见过 160 字摘要、实测会虚构章节与承诺未做之事);异常收尾时另有兜底"},
|
||||
{Key: "runtime.intentSemantic", Group: "runtime", Label: "意图语义裁决(慢通道)", Type: core.SettingBool, Default: false, Effect: core.EffectRestart, Description: "规则歧义时用 embedding 语义相似度裁决。需已配置 embedding 端点。默认关闭(行为与 I1 一致)"},
|
||||
{Key: "runtime.intentGenerator", Group: "runtime", Label: "意图命令生成(慢通道)", Type: core.SettingBool, Default: false, Effect: core.EffectRestart, Description: "规则与语义都未命中时用轻量模型生成意图命令(幻觉 fail-closed 回退)。默认关闭(行为与 I1 一致)"},
|
||||
{Key: "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: "runtime.exec", Group: "runtime", Label: "执行命令工具", Type: core.SettingBool, Default: true, Effect: core.EffectNewRun},
|
||||
{Key: "runtime.fsTools", Group: "runtime", Label: "文件工具", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun},
|
||||
{Key: "runtime.workflowScripts", Group: "runtime", Label: "工作流脚本执行", Type: core.SettingBool, Default: false, Effect: core.EffectNewRun},
|
||||
{Key: "runtime.execBudgetGlobal", Group: "runtime", Label: "全局同时执行上限", Type: core.SettingNumber, Default: 0, Effect: core.EffectHot},
|
||||
{Key: "runtime.execBudgetPerUser", Group: "runtime", Label: "每用户同时执行上限", Type: core.SettingNumber, Default: 0, Effect: core.EffectHot},
|
||||
{Key: "runtime.execQueueMax", Group: "runtime", Label: "执行等待队列上限", Type: core.SettingNumber, Default: 200, Effect: core.EffectRestart},
|
||||
{Key: "runtime.toolSelection", Group: "runtime", Label: "工具选择方式", Type: core.SettingString, Default: "", Effect: core.EffectNewRun},
|
||||
{Key: "telemetry.otlpEndpoint", Group: "telemetry", Label: "OTLP 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "OTel HTTP JSON 上报端点(如 http://localhost:4318)。空 = 不导出(默认);环境变量 OTEL_EXPORTER_OTLP_ENDPOINT 作为后备"},
|
||||
{Key: "telemetry.serviceName", Group: "telemetry", Label: "遥测 service.name", Type: core.SettingString, Default: "agent-system-backend", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "OTLP resource 的 service.name"},
|
||||
{Key: "context.maxHistoryMessages", Group: "context", Label: "历史窗口条数", Type: core.SettingNumber, Default: 40, Effect: core.EffectNewRun},
|
||||
{Key: "context.maxToolResultChars", Group: "context", Label: "工具结果上限(字符)", Type: core.SettingNumber, Default: 20000, Effect: core.EffectNewRun},
|
||||
{Key: "context.maxAttachmentChars", Group: "context", Label: "附件上下文上限(字符)", Type: core.SettingNumber, Default: 0, Effect: core.EffectNewRun},
|
||||
{Key: "context.maxCardChars", Group: "context", Label: "卡片上下文上限(字符)", Type: core.SettingNumber, Default: 0, Effect: core.EffectNewRun},
|
||||
{Key: "context.userInstructions", Group: "context", Label: "个人指令", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "每次对话附在上下文末尾,不改系统前缀"},
|
||||
{Key: "context.contextWindow", Group: "context", Label: "上下文窗口(估算 token)", Type: core.SettingNumber, Default: 32000, Effect: core.EffectNewRun, Description: "超出时只丢掉最早的历史。估算为 UTF-8 字节 / 3.35,并用上一请求的真实用量校准"},
|
||||
{Key: "context.thinkReplay", Group: "context", Label: "思考内容回传", Type: core.SettingEnum, Default: "auto", Options: []string{"auto", "on", "off"}, Effect: core.EffectNewRun, Description: "交错思考模型(如 MiniMax)要求历史 assistant 消息原样带 <think> 块,丢弃会掉性能。auto=按模型名识别 MiniMax 自动开启"},
|
||||
@@ -124,21 +148,30 @@ 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, 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.tavily.apiKeys", Group: "tools", Label: "Tavily API Keys(池)", Type: core.SettingSecret, Default: "", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "逗号分隔;配置后注册 tavily_search/web_search 工具(429/401/403 自动轮换下一把)"},
|
||||
{Key: "tools.tavily.endpoint", Group: "tools", Label: "Tavily 端点", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Scope: core.SettingScopeSystem, Description: "空 = 官方 https://api.tavily.com/search;可指向自建代理"},
|
||||
{Key: "tools.mcp.overrides", Group: "tools", Label: "MCP 项目覆盖", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "JSON 对象。键是服务器名,值可含 enabled 与 exposure(direct 或 deferred)"},
|
||||
{Key: "tools.mcp.servers", Group: "tools", Label: "MCP 服务配置", Type: core.SettingString, Default: "", Effect: core.EffectRestart, Scope: core.SettingScopeSystem},
|
||||
{Key: "deliverables.style", Group: "deliverables", Label: "交付物默认样式", Type: core.SettingEnum, Default: "modern", Options: []string{"modern", "formal", "minimal"}, Effect: core.EffectNewRun, Description: "modern 现代专业 / formal 公文 / minimal 极简。本轮指令、上传模板或参考文档优先于这项"},
|
||||
{Key: "deliverables.accentColor", Group: "deliverables", Label: "交付物强调色", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "6 位十六进制,如 1B3A4B。空 = 使用样式自带主色"},
|
||||
{Key: "deliverables.templateArtifactId", Group: "deliverables", Label: "默认模板工件 ID", Type: core.SettingString, Default: "", Effect: core.EffectNewRun, Description: "填写已上传模板的工件 ID。每次运行会复制到会话 inputs/_default-template"},
|
||||
{Key: "plugins.dir", Group: "plugins", Label: "插件目录", Type: core.SettingString, Default: "./plugins", Effect: core.EffectRestart},
|
||||
{Key: "plugins.generative-ui.autoPresent", Group: "plugins", Label: "Generative UI 自动建议页面", Type: core.SettingBool, Default: false, Effect: core.EffectHot, Description: "开启后在任务阶段变化或材料/工件完成时建议调用 generative-ui.present(默认关)。须同时启用该插件的 Surface activation;有预算与冷却,不会因页面修订自激"},
|
||||
}
|
||||
for i := range out {
|
||||
out[i].Scope = builtinScope(out[i])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Service 提供有效设置计算与分层写入。
|
||||
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} }
|
||||
@@ -146,46 +179,197 @@ func NewService(st *store.Store) *Service { return &Service{st: st} }
|
||||
// SetSystemOverride 注入启动期系统层覆盖(组合根调用;不落库)。
|
||||
func (s *Service) SetSystemOverride(values map[string]any) { s.system = values }
|
||||
|
||||
// Effective 计算覆盖链结果:defaults → system(启动注入) → user(租户) → project。
|
||||
// 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 层的存量覆盖被剥除,不再生效。
|
||||
// owner 是租户身份(认证中间件注入;未启用认证 = store.OwnerDefault)。
|
||||
func (s *Service) Effective(owner, projectID string) core.EffectiveSettings {
|
||||
eff := core.MergeSettings(SystemDefaults(), map[string]any{})
|
||||
eff := core.MergeSettings(s.declaredDefaults(), map[string]any{})
|
||||
if s.system != nil {
|
||||
eff = core.MergeSettings(eff, s.system)
|
||||
}
|
||||
if sysdb, err := s.st.GetSettingsScope(OwnerSystemRow, ScopeSystem); err == nil {
|
||||
eff = core.MergeSettings(eff, s.ProjectValues(sysdb, core.SettingScopeSystem))
|
||||
}
|
||||
managed := s.ManagedSettingPaths()
|
||||
if user, err := s.st.GetSettingsScope(owner, ScopeUser); err == nil {
|
||||
eff = core.MergeSettings(eff, user)
|
||||
eff = core.MergeSettings(eff, s.ProjectValues(core.StripManagedSettings(normalizeKeys(user), managed), UserSettingScope))
|
||||
}
|
||||
if projectID != "" {
|
||||
if proj, err := s.st.GetSettingsScope(owner, ScopeProjectPrefix+projectID); err == nil {
|
||||
eff = core.MergeSettings(eff, proj)
|
||||
eff = core.MergeSettings(eff, s.ProjectValues(core.StripManagedSettings(normalizeKeys(proj), managed), PersonalProjectScope))
|
||||
}
|
||||
if shared, _, err := s.SharedProjectScope(projectID); err == nil {
|
||||
eff = core.MergeSettings(eff, shared)
|
||||
}
|
||||
}
|
||||
return eff
|
||||
// Trusted runtime policy metadata, never included in editable schema/read projections.
|
||||
eff["_policy"] = map[string]any{"managedModels": true}
|
||||
return core.EffectiveSettings(effectiveNumbers(eff))
|
||||
}
|
||||
|
||||
func (s *Service) UserScope(owner string) (map[string]any, error) {
|
||||
return s.st.GetSettingsScope(owner, ScopeUser)
|
||||
m, err := s.readScopeStripped(owner, ScopeUser)
|
||||
return s.ProjectValues(m, UserSettingScope), err
|
||||
}
|
||||
|
||||
func (s *Service) ProjectScope(owner, projectID string) (map[string]any, error) {
|
||||
return s.st.GetSettingsScope(owner, ScopeProjectPrefix+projectID)
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
return core.MergeSettings(normalizeKeys(cur), incoming), nil
|
||||
next := core.MergeSettings(normalizeKeys(cur), incoming)
|
||||
if scope != ScopeSystem {
|
||||
for _, path := range core.FlattenSettingLeafPaths(incoming) {
|
||||
if value, present := valueAtPath(incoming, path); present && value == nil {
|
||||
deletePath(next, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
if scope == ScopeSystem && !reflect.DeepEqual(normalizeKeys(cur), next) {
|
||||
next["_revision"] = systemRevision(cur) + 1
|
||||
}
|
||||
return next, nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
@@ -177,11 +178,12 @@ func TestMergeUserDottedKeyMaskAndNormalizeStored(t *testing.T) {
|
||||
t.Fatalf("mask path missing: %v", stats.MaskPaths)
|
||||
}
|
||||
eff := svc.Effective("local", "p1")
|
||||
if eff.GetString("runtime.entry", "") != "squad" {
|
||||
if eff.GetString("runtime.entry", "") != "direct" {
|
||||
t.Fatalf("entry = %q", eff.GetString("runtime.entry", ""))
|
||||
}
|
||||
if eff.GetString("tools.tavily.apiKeys", "") != "nested" {
|
||||
t.Fatalf("nested key lost: %q", eff.GetString("tools.tavily.apiKeys", ""))
|
||||
// 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.GetNumber("context.maxHistoryMessages", 0) != 30 {
|
||||
t.Fatalf("history = %v", eff.GetNumber("context.maxHistoryMessages", 0))
|
||||
@@ -191,10 +193,10 @@ func TestMergeUserDottedKeyMaskAndNormalizeStored(t *testing.T) {
|
||||
t.Fatalf("second pass = %+v err=%v", again, err)
|
||||
}
|
||||
|
||||
if err := svc.MergeUser("local", map[string]any{"runtime.entry": "direct"}); err != nil {
|
||||
if err := svc.MergeSystem(map[string]any{"runtime.entry": "squad"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if svc.Effective("local", "").GetString("runtime.entry", "") != "direct" {
|
||||
if svc.Effective("local", "").GetString("runtime.entry", "") != "squad" {
|
||||
t.Fatal("dotted write did not take effect")
|
||||
}
|
||||
raw, err := svc.UserScope("local")
|
||||
@@ -204,14 +206,27 @@ func TestMergeUserDottedKeyMaskAndNormalizeStored(t *testing.T) {
|
||||
if _, ok := raw["runtime.entry"]; ok {
|
||||
t.Fatalf("dotted key persisted: %#v", raw)
|
||||
}
|
||||
if err := svc.MergeUser("local", map[string]any{"models": map[string]any{"chat": map[string]any{"apiKey": "sk-real"}}}); err != nil {
|
||||
// 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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := svc.MergeUser("local", map[string]any{"models.chat.apiKey": MaskPlaceholder}); 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 got := svc.Effective("local", "").GetString("models.chat.apiKey", ""); got != "sk-real" {
|
||||
t.Fatalf("mask writeback overwrote key: %q", got)
|
||||
// 读面剥离:UserScope 不回显受管项的存量值。
|
||||
if raw2, _ := svc.UserScope("local"); raw2["models"] != nil {
|
||||
t.Fatalf("managed path exposed in user read scope: %#v", raw2["models"])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
core "agent-system-core"
|
||||
)
|
||||
|
||||
var ErrSettingsConflict = errors.New("项目设置已被其他成员修改,请重新读取后保存")
|
||||
|
||||
// SharedProjectScope reads only the project-owned namespace. Old per-owner
|
||||
// business copies are never silently promoted; migration inventory tracks them.
|
||||
func (s *Service) SharedProjectScope(projectID string) (map[string]any, int64, error) {
|
||||
row, err := s.st.GetSettingsScope(OwnerSystemRow, ScopeSharedProjectPrefix+projectID)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
values, _ := row["values"].(map[string]any)
|
||||
return s.ProjectValues(values, ProjectSettingScope), sharedRevision(row), nil
|
||||
}
|
||||
|
||||
func sharedRevision(row map[string]any) int64 {
|
||||
switch value := row["revision"].(type) {
|
||||
case float64:
|
||||
return int64(value)
|
||||
case int64:
|
||||
return value
|
||||
case int:
|
||||
return int64(value)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (s *Service) MergeSharedProject(projectID string, values map[string]any, expectedRevision int64, unset ...[]string) (int64, error) {
|
||||
if err := s.ValidateWrite(values, ProjectSettingScope); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
paths := optionalUnset(unset)
|
||||
if err := s.validateUnset(values, paths, ProjectSettingScope); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return s.mergeSharedProject(projectID, prepareWrite(values), &expectedRevision, paths)
|
||||
}
|
||||
|
||||
func (s *Service) mergeSharedProject(projectID string, values map[string]any, expectedRevision *int64, unset ...[]string) (int64, error) {
|
||||
if projectID == "" {
|
||||
return 0, &ErrInvalidSetting{Path: "project", Reason: "需要项目标识"}
|
||||
}
|
||||
var revision int64
|
||||
err := s.st.UpdateSettingsScope(OwnerSystemRow, ScopeSharedProjectPrefix+projectID, func(row map[string]any) (map[string]any, error) {
|
||||
revision = sharedRevision(row)
|
||||
if expectedRevision != nil && revision != *expectedRevision {
|
||||
return nil, ErrSettingsConflict
|
||||
}
|
||||
current, _ := row["values"].(map[string]any)
|
||||
if current == nil {
|
||||
current = map[string]any{}
|
||||
}
|
||||
next := core.MergeSettings(s.ProjectValues(current, ProjectSettingScope), values)
|
||||
for _, path := range optionalUnset(unset) {
|
||||
deletePath(next, path)
|
||||
}
|
||||
if reflect.DeepEqual(current, next) {
|
||||
return row, nil
|
||||
}
|
||||
revision++
|
||||
return map[string]any{"revision": revision, "values": next, "updatedAt": time.Now().UTC().Format(time.RFC3339Nano)}, nil
|
||||
})
|
||||
return revision, err
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/store"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProjectBusinessSettingsSharedButPersonalPreferencesPrivate(t *testing.T) {
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "shared.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
s := NewService(st)
|
||||
if err := s.MergeProject("alice", "p1", map[string]any{"deliverables.style": "formal"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := s.Effective("bob", "p1").GetString("deliverables.style", ""); got != "formal" {
|
||||
t.Fatalf("same project's second member got %q", got)
|
||||
}
|
||||
if got := s.Effective("alice", "p2").GetString("deliverables.style", ""); got != "modern" {
|
||||
t.Fatalf("settings crossed project: %q", got)
|
||||
}
|
||||
// Personal project preferences and old managed settings stay isolated.
|
||||
if err := st.MergeSettingsScope("alice", ScopeProjectPrefix+"p1", map[string]any{"crossSession.read": true, "ui.theme": "light", "context.userInstructions": "private", "models.chat.model": "legacy"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Effective("bob", "p1").GetBool("crossSession.read", false) || s.Effective("bob", "p1").GetString("context.userInstructions", "") != "" {
|
||||
t.Fatal("personal project preferences leaked across actors")
|
||||
}
|
||||
if s.Effective("alice", "p1").GetString("models.chat.model", "") != "" {
|
||||
t.Fatal("legacy managed model override remains effective")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProtectedCredentialsIgnoreOldPersonalRows(t *testing.T) {
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "secrets.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
s := NewService(st)
|
||||
for _, spec := range secretSpecs {
|
||||
if _, err := st.AddSecret("alice", spec.service, "legacy", "personal-fixture"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.AddSecret(OwnerSystemRow, spec.service, "system", "system-fixture"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, ok := s.LookupSecret("alice", spec.service); !ok || got != "system-fixture" {
|
||||
t.Fatalf("personal credential won for %s", spec.service)
|
||||
}
|
||||
}
|
||||
if err := s.MergeSystem(map[string]any{"models.chat.apiKey": "rotated-fixture"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := s.LookupSecret("alice", "models.chat"); got != "rotated-fixture" {
|
||||
t.Fatal("new system form lost to old secret table row")
|
||||
}
|
||||
if err := s.SnapshotAndMigrateSecrets(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := s.LookupSecret("alice", "models.chat"); got != "rotated-fixture" {
|
||||
t.Fatal("restart migration revived old credential")
|
||||
}
|
||||
if err := s.MergeSystem(map[string]any{"models.chat.apiKey": ""}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := s.LookupSecret("alice", "models.chat"); got != "" {
|
||||
t.Fatal("revoked credential revived")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
core "agent-system-core"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func (s *Service) validateUnset(values map[string]any, paths []string, scope core.SettingScope) error {
|
||||
descriptors := map[string]core.SettingDesc{}
|
||||
for _, d := range s.Descriptors() {
|
||||
descriptors[d.Key] = d
|
||||
}
|
||||
touched := core.FlattenSettingLeafPaths(normalizeKeys(values))
|
||||
seen := map[string]bool{}
|
||||
for _, path := range paths {
|
||||
d, known := descriptors[path]
|
||||
if !known || !validSettingPath(path) || !scopeAllows(d, scope) || seen[path] {
|
||||
return &ErrInvalidSetting{Path: path, Reason: "只能恢复当前范围内明确、唯一的设置叶子默认值"}
|
||||
}
|
||||
leaf := path[strings.LastIndex(path, ".")+1:]
|
||||
if d.Secret || d.Type == core.SettingSecret || isSecretKey(leaf) {
|
||||
return &ErrInvalidSetting{Path: path, Reason: "撤销密钥请明确保存空值,不能通过恢复默认回退旧凭据"}
|
||||
}
|
||||
for _, written := range touched {
|
||||
if written == path || strings.HasPrefix(written, path+".") || strings.HasPrefix(path, written+".") {
|
||||
return &ErrInvalidSetting{Path: path, Reason: "同一项不能同时保存值与恢复默认"}
|
||||
}
|
||||
}
|
||||
seen[path] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func optionalUnset(paths [][]string) []string {
|
||||
if len(paths) == 0 {
|
||||
return nil
|
||||
}
|
||||
return paths[0]
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"agent-system-backend/internal/store"
|
||||
core "agent-system-core"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVersionedUnsetRestoresInheritanceWithoutRevivingSecret(t *testing.T) {
|
||||
st, err := store.OpenSQLite(filepath.Join(t.TempDir(), "unset.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
s := NewService(st)
|
||||
s.SetExtraSchema([]core.SettingDesc{{Key: "plugins.example.projectPreference", Type: core.SettingString, Scope: ProjectSettingScope, Default: "first-default"}})
|
||||
revision, err := s.MergeSharedProject("p", map[string]any{"plugins.example.projectPreference": "override"}, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := s.Effective("bob", "p").GetString("plugins.example.projectPreference", ""); got != "override" {
|
||||
t.Fatal(got)
|
||||
}
|
||||
revision, err = s.MergeSharedProject("p", map[string]any{}, revision, []string{"plugins.example.projectPreference"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.SetExtraSchema([]core.SettingDesc{{Key: "plugins.example.projectPreference", Type: core.SettingString, Scope: ProjectSettingScope, Default: "changed-default"}})
|
||||
if got := s.Effective("bob", "p").GetString("plugins.example.projectPreference", ""); got != "changed-default" {
|
||||
t.Fatalf("unset stored a copy of old default: %s", got)
|
||||
}
|
||||
for _, paths := range [][]string{{"plugins.example"}, {"_policy.managedModels"}, {"unknown"}, {"ui.theme"}} {
|
||||
if _, err := s.MergeSharedProject("p", map[string]any{}, revision, paths); err == nil {
|
||||
t.Fatalf("invalid unset accepted: %v", paths)
|
||||
}
|
||||
}
|
||||
if _, err := s.MergeSharedProject("p", map[string]any{"plugins.example.projectPreference": "other"}, revision, []string{"plugins.example.projectPreference"}); err == nil {
|
||||
t.Fatal("value and unset overlap accepted")
|
||||
}
|
||||
if _, err := s.MergeSharedProject("p", map[string]any{}, 0, []string{"plugins.example.projectPreference"}); err != ErrSettingsConflict {
|
||||
t.Fatal("stale unset accepted")
|
||||
}
|
||||
_, _ = st.AddSecret(OwnerSystemRow, "models.chat", "Old", "old-secret-fixture")
|
||||
systemRevision, _ := s.SystemRevision()
|
||||
if _, err := s.MergeSystemAtRevision(map[string]any{}, systemRevision, []string{"models.chat.apiKey"}); err == nil {
|
||||
t.Fatal("secret unset would revive deployment credential")
|
||||
}
|
||||
if _, err := s.MergeSystemAtRevision(map[string]any{"models.chat.apiKey": ""}, systemRevision); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if value, _ := s.LookupSecret("alice", "models.chat"); value != "" {
|
||||
t.Fatal("explicit revoke revived old credential")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAccountPasswordSurvivesProcessTermination(t *testing.T) {
|
||||
if mode := os.Getenv("U7_CRASH_CHILD_MODE"); mode != "" {
|
||||
path := os.Getenv("U7_CRASH_CHILD_DB")
|
||||
st, err := OpenExistingSQLite(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := HashPassword("private-fixture-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = st.CreateActivatedAccount(AccountWrite{Owner: "survivor", Name: "Survivor", PasswordHash: h, Action: "account.create", Actor: "fixture"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Keep the database connection and process alive at the signal point;
|
||||
// a graceful close would only test reopen, not abrupt termination.
|
||||
fmt.Println("U7_COMMITTED")
|
||||
select {}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
signal syscall.Signal
|
||||
}{{"SIGTERM", syscall.SIGTERM}, {"SIGKILL", syscall.SIGKILL}} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "accounts.db")
|
||||
created, err := OpenSQLite(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := created.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
child := exec.Command(os.Args[0], "-test.run=^TestAccountPasswordSurvivesProcessTermination$")
|
||||
child.Env = append(os.Environ(), "U7_CRASH_CHILD_MODE="+tc.name, "U7_CRASH_CHILD_DB="+path)
|
||||
stdout, err := child.StdoutPipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
child.Stderr = os.Stderr
|
||||
if err := child.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ready := make(chan error, 1)
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
for scanner.Scan() {
|
||||
if strings.Contains(scanner.Text(), "U7_COMMITTED") {
|
||||
ready <- nil
|
||||
return
|
||||
}
|
||||
}
|
||||
ready <- fmt.Errorf("child exited without committed receipt: %v", scanner.Err())
|
||||
}()
|
||||
select {
|
||||
case err := <-ready:
|
||||
if err != nil {
|
||||
_ = child.Process.Kill()
|
||||
_ = child.Wait()
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
_ = child.Process.Kill()
|
||||
_ = child.Wait()
|
||||
t.Fatal("child did not commit in time")
|
||||
}
|
||||
if err := child.Process.Signal(tc.signal); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
waitErr := child.Wait()
|
||||
var exitErr *exec.ExitError
|
||||
if !errors.As(waitErr, &exitErr) {
|
||||
t.Fatalf("child was not terminated by %s: %v", tc.name, waitErr)
|
||||
}
|
||||
status, ok := exitErr.Sys().(syscall.WaitStatus)
|
||||
if !ok || !status.Signaled() || status.Signal() != tc.signal {
|
||||
t.Fatalf("child ended without requested %s: %v", tc.name, waitErr)
|
||||
}
|
||||
reopened, err := OpenExistingSQLite(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reopened.Close()
|
||||
if _, ok := reopened.VerifyLogin("survivor", "private-fixture-password"); !ok {
|
||||
t.Fatal("committed password missing after real process termination")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func activeAccount(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
s := openTestStore(t)
|
||||
h, e := HashPassword("old-password")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e := s.CreateAccount("ada", "Ada", h, false); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
if e := s.ClearMustChangePassword("ada"); e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
return s
|
||||
}
|
||||
func TestAccountMaintenanceInvalidatesVerifiedLogin(t *testing.T) {
|
||||
for _, kind := range []string{"password", "revoke-all"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
s := activeAccount(t)
|
||||
_, ver, ok := s.VerifyLoginWithVersion("ada", "old-password")
|
||||
if !ok {
|
||||
t.Fatal("healthy login failed")
|
||||
}
|
||||
var err error
|
||||
if kind == "password" {
|
||||
h, e := HashPassword("new-password")
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
_, err = s.ApplyPasswordChange(AccountWrite{Owner: "ada", PasswordHash: h})
|
||||
} else {
|
||||
_, err = s.RevokeAllAndIssue(AccountWrite{Owner: "ada", Purpose: LoginKeyPurposeRecover, CreatedBy: "admin"})
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.CreateSessionVerified("ada", SessionScopeFull, time.Hour, ver); err == nil {
|
||||
t.Fatal("stale in-flight login could create a session after credential replacement")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestActivatedAccountRetainsLoginKeySuccessEvent(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
if _, err := s.CreateActivatedAccount(AccountWrite{Owner: "ada", Purpose: LoginKeyPurposeActivate, CreatedBy: "admin", Actor: "admin"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var n int
|
||||
if err := s.db.QueryRow(s.q(`SELECT COUNT(*) FROM account_events WHERE owner=? AND action='login_key_issued'`), "ada").Scan(&n); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatal("atomic issuance dropped main account event")
|
||||
}
|
||||
}
|
||||
func TestCheckedPasswordMutationRollsBackAuditFailure(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
if err := s.CreateAccount("ada", "Ada", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, _, err := s.IssueLoginKey("ada", LoginKeyPurposeActivate, "admin", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, _, err := s.RedeemLoginKey(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := HashPassword("new-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.SetAccountFault(ErrConflict)
|
||||
if _, err := s.CompleteSetPasswordChecked(token, h); err == nil {
|
||||
t.Fatal("checked password path bypassed the shared audited commit")
|
||||
}
|
||||
if a, err := s.GetUserAuthState("ada"); err != nil || a.HasPassword {
|
||||
t.Fatal("failed checked change left a password behind")
|
||||
}
|
||||
if _, _, ok := s.SessionInfo(token); !ok {
|
||||
t.Fatal("failed checked change consumed its session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRevokeAllRefusesMissingOwnerWithoutCredential(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
_, err := s.RevokeAllAndIssue(AccountWrite{Owner: "missing", Purpose: LoginKeyPurposeRecover, CreatedBy: "admin", Actor: "admin"})
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("missing owner must be rejected before issuing any key: %v", err)
|
||||
}
|
||||
var n int
|
||||
if err := s.db.QueryRow(s.q(`SELECT COUNT(*) FROM login_keys WHERE owner=?`), "missing").Scan(&n); err != nil || n != 0 {
|
||||
t.Fatalf("orphan credential rows=%d err=%v", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivePasswordChangeRevokesUnusedRecoveryKey(t *testing.T) {
|
||||
s := activeAccount(t)
|
||||
key, _, err := s.IssueLoginKey("ada", LoginKeyPurposeRecover, "admin", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, err := s.CreateSession("ada")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := HashPassword("replacement-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.ChangeUserPasswordActive("ada", "old-password", hash, session); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := s.RedeemLoginKey(key); err == nil {
|
||||
t.Fatal("unused recovery key issued before active password change still redeems")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRejectedActiveChangeIsAudited(t *testing.T) {
|
||||
s := activeAccount(t)
|
||||
h, err := HashPassword("next-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err = s.ChangeUserPasswordActive("ada", "incorrect-current-password", h, "")
|
||||
if !errors.Is(err, ErrBadCredentials) {
|
||||
t.Fatalf("expected bad credentials, got %v", err)
|
||||
}
|
||||
count, err := s.CountAudit("ada", "rejected")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("rejected checked account mutation has %d rejected audit rows, want1", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDelayedOldVersionSessionIsRejectedAtUse(t *testing.T) {
|
||||
s := activeAccount(t)
|
||||
_, ver, ok := s.VerifyLoginWithVersion("ada", "old-password")
|
||||
if !ok {
|
||||
t.Fatal("healthy verification missing")
|
||||
}
|
||||
h, err := HashPassword("new-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := s.ApplyPasswordChange(AccountWrite{Owner: "ada", PasswordHash: h}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Represents an insert racing after the replacement's earlier DELETE.
|
||||
token := "sess_private_stale_fixture"
|
||||
_, err = s.db.Exec(s.q(`INSERT INTO sessions(token_hash,owner,created_at,expires_at,last_seen_at,scope,auth_version) VALUES(?,?,?,?,?,?,?)`),
|
||||
hashKey(token), "ada", nowISO(), time.Now().Add(time.Hour).UTC().Format(time.RFC3339Nano), nowISO(), SessionScopeFull, ver)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, ok := s.SessionInfo(token); ok {
|
||||
t.Fatal("session carrying the retired auth_version became authorized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestActivePasswordChangePreservesCurrentVersionedSession(t *testing.T) {
|
||||
s := activeAccount(t)
|
||||
token, err := s.CreateSession("ada")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
h, err := HashPassword("new-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.ChangeUserPasswordActive("ada", "old-password", h, token); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if owner, scope, ok := s.SessionInfo(token); !ok || owner != "ada" || scope != SessionScopeFull {
|
||||
t.Fatal("active change did not retain its current full session")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The trigger holds a transaction after its authorization read. A second
|
||||
// connection must wait for that owner's lock before replacing credentials.
|
||||
func holdPGAccountWrite(t *testing.T, s *Store, table, event, suffix string) func() {
|
||||
t.Helper()
|
||||
lock := "u7_account_" + suffix
|
||||
_, err := s.db.Exec(`CREATE FUNCTION u7_pause_account() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN PERFORM pg_advisory_xact_lock(hashtext(current_schema() || ':` + lock + `')); RETURN NEW; END $$`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = s.db.Exec(`CREATE TRIGGER u7_pause_account BEFORE ` + event + ` ON ` + table + ` FOR EACH ROW EXECUTE FUNCTION u7_pause_account()`)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conn, err := s.db.Conn(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := conn.ExecContext(context.Background(), `SELECT pg_advisory_lock(hashtext(current_schema() || ':`+lock+`'))`); err != nil {
|
||||
conn.Close()
|
||||
t.Fatal(err)
|
||||
}
|
||||
released := false
|
||||
release := func() {
|
||||
if !released {
|
||||
released = true
|
||||
_, _ = conn.ExecContext(context.Background(), `SELECT pg_advisory_unlock(hashtext(current_schema() || ':`+lock+`'))`)
|
||||
_ = conn.Close()
|
||||
}
|
||||
}
|
||||
t.Cleanup(release)
|
||||
return release
|
||||
}
|
||||
|
||||
func waitForPGAccountWrite(t *testing.T, s *Store, suffix string) {
|
||||
t.Helper()
|
||||
lock := "u7_account_" + suffix
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
var n int
|
||||
err := s.db.QueryRow(`SELECT count(*) FROM pg_locks WHERE locktype='advisory' AND NOT granted
|
||||
AND objid=(hashtext(current_schema() || ':` + lock + `')::bigint & 4294967295)::oid`).Scan(&n)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n > 0 {
|
||||
return
|
||||
}
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
t.Fatal("account write did not reach the deterministic PostgreSQL pause")
|
||||
}
|
||||
|
||||
func awaitPGAccountResult[T any](t *testing.T, done <-chan T) T {
|
||||
t.Helper()
|
||||
select {
|
||||
case result := <-done:
|
||||
return result
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("serialized account transaction did not finish")
|
||||
var zero T
|
||||
return zero
|
||||
}
|
||||
}
|
||||
|
||||
func TestPGVerifiedSessionAndPasswordChangeSerialize(t *testing.T) {
|
||||
s := activeAccount(t)
|
||||
if s.Dialect() != "postgres" {
|
||||
t.Skip("real PostgreSQL required")
|
||||
}
|
||||
_, version, ok := s.VerifyLoginWithVersion("ada", "old-password")
|
||||
if !ok {
|
||||
t.Fatal("initial login failed")
|
||||
}
|
||||
release := holdPGAccountWrite(t, s, "sessions", "INSERT", "session")
|
||||
type sessionResult struct {
|
||||
token string
|
||||
err error
|
||||
}
|
||||
sessionDone := make(chan sessionResult, 1)
|
||||
go func() {
|
||||
token, err := s.CreateSessionVerified("ada", SessionScopeFull, time.Hour, version)
|
||||
sessionDone <- sessionResult{token, err}
|
||||
}()
|
||||
waitForPGAccountWrite(t, s, "session")
|
||||
hash, err := HashPassword("replacement-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
changeDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := s.ApplyPasswordChange(AccountWrite{Owner: "ada", PasswordHash: hash, Actor: "admin"})
|
||||
changeDone <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-changeDone:
|
||||
t.Fatalf("replacement committed ahead of the in-flight verified session: %v", err)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
release()
|
||||
session := awaitPGAccountResult(t, sessionDone)
|
||||
if session.err != nil {
|
||||
t.Fatal(session.err)
|
||||
}
|
||||
if err := awaitPGAccountResult(t, changeDone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, ok := s.SessionInfo(session.token); ok {
|
||||
t.Fatal("old verified session survived later password replacement")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPGRestrictedPasswordSetAndKeyReissueSerialize(t *testing.T) {
|
||||
s := openTestStore(t)
|
||||
if s.Dialect() != "postgres" {
|
||||
t.Skip("real PostgreSQL required")
|
||||
}
|
||||
if err := s.CreateAccount("ada", "Ada", "", false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key, _, err := s.IssueLoginKey("ada", LoginKeyPurposeRecover, "admin", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token, _, err := s.RedeemLoginKey(key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := HashPassword("replacement-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
release := holdPGAccountWrite(t, s, "users", "UPDATE", "password")
|
||||
setDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := s.CompleteSetPasswordChecked(token, hash)
|
||||
setDone <- err
|
||||
}()
|
||||
waitForPGAccountWrite(t, s, "password")
|
||||
issueDone := make(chan error, 1)
|
||||
go func() {
|
||||
_, _, err := s.IssueLoginKey("ada", LoginKeyPurposeRecover, "admin", 0)
|
||||
issueDone <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-issueDone:
|
||||
t.Fatalf("key replacement committed ahead of the in-flight password set: %v", err)
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
}
|
||||
release()
|
||||
if err := awaitPGAccountResult(t, setDone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := awaitPGAccountResult(t, issueDone); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, ok := s.SessionInfo(token); ok {
|
||||
t.Fatal("old restricted session survived password set and key reissue")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Two independent process opens share one isolated PostgreSQL schema. The
|
||||
// barrier makes their account transactions genuinely concurrent.
|
||||
func TestAccountTwoProcessPostgresAdmission(t *testing.T) {
|
||||
if mode := os.Getenv("U7_ACCOUNT_CHILD_MODE"); mode != "" {
|
||||
childDSN := os.Getenv("U7_ACCOUNT_CHILD_DSN")
|
||||
barrier := os.Getenv("U7_ACCOUNT_CHILD_BARRIER")
|
||||
st, err := OpenExistingPostgres(childDSN, os.Getenv("U7_ACCOUNT_CHILD_ARTIFACTS"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
if err := os.WriteFile(os.Getenv("U7_ACCOUNT_CHILD_READY"), []byte("ready"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < 500; i++ {
|
||||
if _, err := os.Stat(barrier); err == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
if _, err := os.Stat(barrier); err != nil {
|
||||
t.Fatal("two-process barrier missing")
|
||||
}
|
||||
var outcome AccountOutcome
|
||||
switch mode {
|
||||
case "create":
|
||||
outcome, err = st.CreateActivatedAccount(AccountWrite{Owner: "same", Name: "Same", Purpose: LoginKeyPurposeActivate, CreatedBy: "fixture", Actor: "fixture", Action: "account.create"})
|
||||
case "bootstrap":
|
||||
outcome, err = st.BootstrapFirstAdminWrite(AccountWrite{Owner: os.Getenv("U7_ACCOUNT_CHILD_OWNER"), Name: "Bootstrap", CreatedBy: "fixture", Actor: "fixture"})
|
||||
case "bootstrap-versus-create":
|
||||
if os.Getenv("U7_ACCOUNT_CHILD_OWNER") == "bootstrap" {
|
||||
outcome, err = st.BootstrapFirstAdminWrite(AccountWrite{Owner: "admin", Name: "Admin", CreatedBy: "fixture", Actor: "fixture"})
|
||||
} else {
|
||||
outcome, err = st.CreateActivatedAccount(AccountWrite{Owner: "ordinary", Name: "Ordinary", Purpose: LoginKeyPurposeActivate, CreatedBy: "fixture", Actor: "fixture", Action: "account.create"})
|
||||
}
|
||||
default:
|
||||
t.Fatal("unknown child mode")
|
||||
}
|
||||
if err == nil {
|
||||
if outcome.OperationID == "" || outcome.Plain == "" {
|
||||
t.Fatal("committed account did not produce a complete receipt")
|
||||
}
|
||||
fmt.Println("U7_RESULT=success")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, ErrConflict) {
|
||||
fmt.Println("U7_RESULT=conflict")
|
||||
return
|
||||
}
|
||||
t.Fatalf("unexpected admission error: %v", err)
|
||||
return
|
||||
}
|
||||
baseDSN := os.Getenv("AGENT_PG_TEST_DSN")
|
||||
if baseDSN == "" {
|
||||
t.Skip("AGENT_PG_TEST_DSN absent; Dev PG gate runs this against a real isolated schema")
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
mode string
|
||||
owners [2]string
|
||||
}{
|
||||
{"create", [2]string{"one", "two"}},
|
||||
{"bootstrap", [2]string{"admin-one", "admin-two"}},
|
||||
{"bootstrap-versus-create", [2]string{"bootstrap", "ordinary"}},
|
||||
} {
|
||||
t.Run(tc.mode, func(t *testing.T) {
|
||||
schema := fmt.Sprintf("u7_audit_%d", time.Now().UnixNano())
|
||||
admin, err := sql.Open("pgx", baseDSN)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer admin.Close()
|
||||
ensurePGTrgm(t, admin)
|
||||
if _, err := admin.Exec(`CREATE SCHEMA ` + schema); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer admin.Exec(`DROP SCHEMA IF EXISTS ` + schema + ` CASCADE`)
|
||||
dsn := withTestSearchPath(baseDSN, schema)
|
||||
artifacts := filepath.Join(t.TempDir(), "artifacts")
|
||||
st, err := OpenPostgres(dsn, artifacts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.EnsureSystemIdentity("pg-two-process", "pg"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.Close()
|
||||
// Hold the shared admission point so both independent processes
|
||||
// are demonstrably contending before either may commit.
|
||||
lockKey := "bootstrap:first-admin"
|
||||
if tc.mode == "create" {
|
||||
lockKey = "account:same"
|
||||
}
|
||||
lockConn, err := admin.Conn(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer lockConn.Close()
|
||||
if _, err := lockConn.ExecContext(context.Background(), `SELECT pg_advisory_lock(hashtext($1))`, lockKey); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
released := false
|
||||
release := func() {
|
||||
if !released {
|
||||
released = true
|
||||
_, _ = lockConn.ExecContext(context.Background(), `SELECT pg_advisory_unlock(hashtext($1))`, lockKey)
|
||||
}
|
||||
}
|
||||
defer release()
|
||||
barrier := filepath.Join(t.TempDir(), "go")
|
||||
readyDir := t.TempDir()
|
||||
var children [2]*exec.Cmd
|
||||
var logs [2]bytes.Buffer
|
||||
for i := range children {
|
||||
readyPath := filepath.Join(readyDir, fmt.Sprintf("ready-%d", i))
|
||||
cmd := exec.Command(os.Args[0], "-test.run=^TestAccountTwoProcessPostgresAdmission$", "-test.v")
|
||||
cmd.Env = append(os.Environ(), "U7_ACCOUNT_CHILD_MODE="+tc.mode, "U7_ACCOUNT_CHILD_DSN="+dsn,
|
||||
"U7_ACCOUNT_CHILD_OWNER="+tc.owners[i], "U7_ACCOUNT_CHILD_ARTIFACTS="+artifacts,
|
||||
"U7_ACCOUNT_CHILD_BARRIER="+barrier, "U7_ACCOUNT_CHILD_READY="+readyPath)
|
||||
cmd.Stdout = &logs[i]
|
||||
cmd.Stderr = &logs[i]
|
||||
if err := cmd.Start(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
children[i] = cmd
|
||||
}
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for i := range children {
|
||||
readyPath := filepath.Join(readyDir, fmt.Sprintf("ready-%d", i))
|
||||
for {
|
||||
if _, err := os.Stat(readyPath); err == nil {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("child %d never opened the shared database: %s", i, logs[i].String())
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(barrier, []byte("go"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for {
|
||||
var waiters int
|
||||
err := admin.QueryRow(`SELECT count(*) FROM pg_locks WHERE locktype='advisory' AND NOT granted
|
||||
AND objid=(hashtext($1)::bigint & 4294967295)::oid`, lockKey).Scan(&waiters)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if waiters >= 2 {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("only %d of 2 child processes reached the shared admission lock: %s | %s", waiters, logs[0].String(), logs[1].String())
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
release()
|
||||
wins, losses := 0, 0
|
||||
for i, cmd := range children {
|
||||
if err := cmd.Wait(); err != nil {
|
||||
t.Fatalf("child %d failed: %v %s", i, err, logs[i].String())
|
||||
}
|
||||
out := logs[i].String()
|
||||
if strings.Contains(out, "U7_RESULT=success") {
|
||||
wins++
|
||||
} else if strings.Contains(out, "U7_RESULT=conflict") {
|
||||
losses++
|
||||
} else {
|
||||
t.Fatalf("child %d produced no outcome: %s", i, out)
|
||||
}
|
||||
}
|
||||
if wins != 1 || losses != 1 {
|
||||
t.Fatalf("admission wins=%d conflicts=%d, want 1/1", wins, losses)
|
||||
}
|
||||
reopened, err := OpenExistingPostgres(dsn, artifacts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer reopened.Close()
|
||||
users, err := reopened.ListUsersAdmin()
|
||||
if err != nil || len(users) != 1 {
|
||||
t.Fatalf("actual committed users=%d, err=%v", len(users), err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AccountWrite 是一次账户变更的调用参数。成功审计与变更同事务。
|
||||
type AccountWrite struct {
|
||||
Owner string
|
||||
Name string
|
||||
PasswordHash string
|
||||
IsAdmin bool
|
||||
MustChange bool
|
||||
ExceptToken string
|
||||
Purpose string
|
||||
CreatedBy string
|
||||
TTL time.Duration
|
||||
RevokeAllKeys bool
|
||||
Actor string
|
||||
InstanceID string
|
||||
Action string
|
||||
}
|
||||
|
||||
// AccountOutcome 是成功提交后可以对外展示的标识。不含密码、哈希或明文。
|
||||
type AccountOutcome struct {
|
||||
Plain string
|
||||
CredentialID string
|
||||
OperationID string
|
||||
}
|
||||
|
||||
// CreateActivatedAccount 严格插入账户。passwordHash 为空且 Purpose 非空时,同一事务签发登录 Key。
|
||||
func (s *Store) CreateActivatedAccount(w AccountWrite) (AccountOutcome, error) {
|
||||
if w.Action == "" {
|
||||
w.Action = "account.create"
|
||||
}
|
||||
var out AccountOutcome
|
||||
err := s.accountTx(w, func(tx *sql.Tx, opID string) error {
|
||||
if s.dialect.name() == "postgres" {
|
||||
// A first ordinary account and first-admin bootstrap use the same
|
||||
// admission lock. If bootstrap commits while this call waits, the
|
||||
// earlier empty-database observation cannot authorize a second
|
||||
// competing first account.
|
||||
var before, after int
|
||||
if err := tx.QueryRow(s.q(`SELECT COUNT(*) FROM users`)).Scan(&before); err != nil {
|
||||
return err
|
||||
}
|
||||
if before == 0 {
|
||||
if err := s.dialect.lockBootstrap(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.QueryRow(s.q(`SELECT COUNT(*) FROM users`)).Scan(&after); err != nil {
|
||||
return err
|
||||
}
|
||||
if after != 0 {
|
||||
return ErrConflict
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := insertAccount(s, tx, w); err != nil {
|
||||
return err
|
||||
}
|
||||
if w.PasswordHash == "" && w.Purpose != "" {
|
||||
plain, id, err := insertLoginKey(s, tx, w)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Plain, out.CredentialID = plain, id
|
||||
}
|
||||
out.OperationID = opID
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// ApplyPasswordChange 在同一事务里写哈希、更新强制改密标志、作废未使用登录 Key,并处理会话。
|
||||
func (s *Store) ApplyPasswordChange(w AccountWrite) (AccountOutcome, error) {
|
||||
if w.Action == "" {
|
||||
w.Action = "account.password"
|
||||
}
|
||||
var out AccountOutcome
|
||||
err := s.accountTx(w, func(tx *sql.Tx, opID string) error {
|
||||
res, err := tx.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=?, auth_version=auth_version+1 WHERE owner=?`),
|
||||
w.PasswordHash, nowISO(), w.MustChange, w.Owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`),
|
||||
LoginKeyStatusRevoked, w.Owner, LoginKeyStatusUnused); err != nil {
|
||||
return err
|
||||
}
|
||||
if w.ExceptToken != "" {
|
||||
_, err = tx.Exec(s.q(`DELETE FROM sessions WHERE owner=? AND token_hash<>?`), w.Owner, hashKey(w.ExceptToken))
|
||||
} else {
|
||||
_, err = tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), w.Owner)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if w.ExceptToken != "" {
|
||||
if _, err := tx.Exec(s.q(`UPDATE sessions SET auth_version=(SELECT auth_version FROM users WHERE owner=?) WHERE owner=? AND token_hash=?`),
|
||||
w.Owner, w.Owner, hashKey(w.ExceptToken)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
out.OperationID = opID
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// BootstrapFirstAdmin 在立即事务(SQLite)或身份行锁(PostgreSQL)内复核空库后建首位管理员。
|
||||
func (s *Store) BootstrapFirstAdminWrite(w AccountWrite) (AccountOutcome, error) {
|
||||
w.IsAdmin = true
|
||||
w.PasswordHash = ""
|
||||
w.Purpose = LoginKeyPurposeActivate
|
||||
if w.CreatedBy == "" {
|
||||
w.CreatedBy = "cli-bootstrap"
|
||||
}
|
||||
if w.Action == "" {
|
||||
w.Action = "account.bootstrap"
|
||||
}
|
||||
var out AccountOutcome
|
||||
err := s.accountTx(w, func(tx *sql.Tx, opID string) error {
|
||||
if err := s.dialect.lockBootstrap(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := lockIdentity(s, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := requireEmptyBusiness(s, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := insertAccount(s, tx, w); err != nil {
|
||||
return err
|
||||
}
|
||||
var users int
|
||||
if err := tx.QueryRow(`SELECT COUNT(*) FROM users`).Scan(&users); err != nil {
|
||||
return err
|
||||
}
|
||||
if users != 1 {
|
||||
return ErrConflict
|
||||
}
|
||||
if err := s.insertAccountEvent(tx, w.CreatedBy, entranceOf(w.CreatedBy), w.Owner, "bootstrap_admin", w.Owner); err != nil {
|
||||
return err
|
||||
}
|
||||
plain, id, err := insertLoginKey(s, tx, w)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Plain, out.CredentialID, out.OperationID = plain, id, opID
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
// RevokeAllAndIssue 把吊销长期密钥、删除会话、作废未使用登录 Key 和签发新 Key 放进一次提交。
|
||||
func (s *Store) RevokeAllAndIssue(w AccountWrite) (AccountOutcome, error) {
|
||||
if w.Action == "" {
|
||||
w.Action = "account.revoke_credentials"
|
||||
}
|
||||
var out AccountOutcome
|
||||
err := s.accountTx(w, func(tx *sql.Tx, opID string) error {
|
||||
res, err := tx.Exec(s.q(`UPDATE users SET auth_version=auth_version+1 WHERE owner=?`), w.Owner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, err := res.RowsAffected(); err != nil {
|
||||
return err
|
||||
} else if n != 1 {
|
||||
return ErrNotFound
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(s.q(`UPDATE api_keys SET revoked=1 WHERE owner=? AND revoked=0`), w.Owner); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`DELETE FROM sessions WHERE owner=?`), w.Owner); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`UPDATE login_keys SET status=? WHERE owner=? AND status=?`),
|
||||
LoginKeyStatusRevoked, w.Owner, LoginKeyStatusUnused); err != nil {
|
||||
return err
|
||||
}
|
||||
plain, id, err := insertLoginKey(s, tx, w)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out.Plain, out.CredentialID, out.OperationID = plain, id, opID
|
||||
return nil
|
||||
})
|
||||
return out, err
|
||||
}
|
||||
|
||||
func (s *Store) accountTx(w AccountWrite, fn func(*sql.Tx, string) error) error {
|
||||
opID := newID("op")
|
||||
tx, err := s.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback()
|
||||
}
|
||||
}()
|
||||
if w.Owner != "" {
|
||||
if err := s.dialect.lockAccount(tx, w.Owner); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if w.InstanceID == "" {
|
||||
if err := tx.QueryRow(`SELECT instance_id FROM agent_system_identity`).Scan(&w.InstanceID); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := fn(tx, opID); err != nil {
|
||||
_ = tx.Rollback()
|
||||
committed = true
|
||||
s.recordRejected(w, opID)
|
||||
return err
|
||||
}
|
||||
if s.accountFault != nil {
|
||||
err = s.accountFault
|
||||
_ = tx.Rollback()
|
||||
committed = true
|
||||
s.recordRejected(w, opID)
|
||||
return err
|
||||
}
|
||||
if err := insertAudit(s, tx, w, opID, "success"); err != nil {
|
||||
_ = tx.Rollback()
|
||||
committed = true
|
||||
s.recordRejected(w, opID)
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
// Commit may have reached the database even if the client lost its
|
||||
// response. Recording a rejection here would assert an unknown outcome.
|
||||
return err
|
||||
}
|
||||
committed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) recordRejected(w AccountWrite, opID string) {
|
||||
_, _ = s.db.Exec(s.q(`INSERT INTO audit_events(time,owner,action,actor,target,entry,instance_id,operation_id,outcome) VALUES(?,?,?,?,?,?,?,?,?)`),
|
||||
nowISO(), w.Owner, w.Action, w.Actor, w.Owner, "account", w.InstanceID, opID, "rejected")
|
||||
}
|
||||
|
||||
func insertAudit(s *Store, tx *sql.Tx, w AccountWrite, opID, outcome string) error {
|
||||
_, err := tx.Exec(s.q(`INSERT INTO audit_events(time,owner,action,actor,target,entry,instance_id,operation_id,outcome) VALUES(?,?,?,?,?,?,?,?,?)`),
|
||||
nowISO(), w.Owner, w.Action, w.Actor, w.Owner, "account", w.InstanceID, opID, outcome)
|
||||
return err
|
||||
}
|
||||
|
||||
func insertAccount(s *Store, tx *sql.Tx, w AccountWrite) error {
|
||||
mustChange := w.PasswordHash != "" || w.MustChange
|
||||
setAt := ""
|
||||
if w.PasswordHash != "" {
|
||||
setAt = nowISO()
|
||||
}
|
||||
_, err := tx.Exec(s.q(`INSERT INTO users(id,owner,name,created_at,password_hash,password_set_at,must_change_password,is_admin) VALUES(?,?,?,?,?,?,?,?)`),
|
||||
newID("usr"), w.Owner, w.Name, nowISO(), w.PasswordHash, setAt, mustChange, w.IsAdmin)
|
||||
if isUniqueViolation(err) {
|
||||
return ErrConflict
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func insertLoginKey(s *Store, tx *sql.Tx, w AccountWrite) (string, string, error) {
|
||||
purpose := w.Purpose
|
||||
if purpose != LoginKeyPurposeActivate && purpose != LoginKeyPurposeRecover {
|
||||
return "", "", fmt.Errorf("login key: invalid purpose %q", purpose)
|
||||
}
|
||||
ttl := w.TTL
|
||||
if ttl <= 0 {
|
||||
ttl = defaultLoginKeyTTL
|
||||
}
|
||||
if ttl < minLoginKeyTTL {
|
||||
ttl = minLoginKeyTTL
|
||||
}
|
||||
if ttl > maxLoginKeyTTL {
|
||||
ttl = maxLoginKeyTTL
|
||||
}
|
||||
raw := make([]byte, 32)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
idRaw := make([]byte, 8)
|
||||
if _, err := rand.Read(idRaw); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
plain := "alk_" + hex.EncodeToString(raw)
|
||||
id := "lk_" + hex.EncodeToString(idRaw)
|
||||
now := time.Now().UTC()
|
||||
if _, err := s.insertLoginKeyTx(tx, w.Owner, purpose, w.CreatedBy, plain, id, now, now.Add(ttl)); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
_, err := tx.Exec(s.q(`UPDATE login_keys SET instance_id=? WHERE id=?`), w.InstanceID, id)
|
||||
return plain, id, err
|
||||
}
|
||||
|
||||
func lockIdentity(s *Store, tx *sql.Tx) error {
|
||||
q := `SELECT instance_id FROM agent_system_identity`
|
||||
if s.dialect.name() == "postgres" {
|
||||
q += ` FOR UPDATE`
|
||||
}
|
||||
rows, err := tx.Query(q)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer rows.Close()
|
||||
n := 0
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return err
|
||||
}
|
||||
n++
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return fmt.Errorf("%w: agent_system_identity 无行", ErrSystemIdentityMissing)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func requireEmptyBusiness(s *Store, tx *sql.Tx) error {
|
||||
for _, table := range []string{"users", "projects", "conversations", "messages", "runs", "api_keys", "sessions", "login_keys"} {
|
||||
var n int
|
||||
if err := tx.QueryRow(`SELECT COUNT(1) FROM ` + table).Scan(&n); err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return ErrConflict
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) requireCurrentSchema() error {
|
||||
applied, err := s.MigrationStatus()
|
||||
if err != nil || len(applied) == 0 {
|
||||
return fmt.Errorf("schema 版本不可读;请运行 agent-system migrate,或先启动一次服务")
|
||||
}
|
||||
latest := migrations[len(migrations)-1].version
|
||||
got := applied[len(applied)-1]
|
||||
if got != latest {
|
||||
return fmt.Errorf("schema v%d 与本二进制 v%d 不一致;请运行 agent-system migrate,或先启动一次服务", got, latest)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetAccountFault 让下一次账户事务在提交前失败,用于半态测试。
|
||||
func (s *Store) SetAccountFault(err error) {
|
||||
s.accountFault = err
|
||||
}
|
||||
|
||||
// CountAudit 返回指定 outcome 的账户审计条数。
|
||||
func (s *Store) CountAudit(owner, outcome string) (int, error) {
|
||||
var n int
|
||||
err := s.db.QueryRow(s.q(`SELECT COUNT(1) FROM audit_events WHERE owner=? AND entry=? AND outcome=?`), owner, "account", outcome).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConcurrentCreateOneWinner(t *testing.T) {
|
||||
st := openLife(t)
|
||||
const n = 8
|
||||
var wg sync.WaitGroup
|
||||
wins := make(chan error, n)
|
||||
for i := 0; i < n; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, err := st.CreateActivatedAccount(AccountWrite{
|
||||
Owner: "same", Name: "Same", Purpose: LoginKeyPurposeActivate, CreatedBy: "test",
|
||||
Action: "account.create",
|
||||
})
|
||||
wins <- err
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(wins)
|
||||
okN, conflict := 0, 0
|
||||
for err := range wins {
|
||||
switch {
|
||||
case err == nil:
|
||||
okN++
|
||||
case errors.Is(err, ErrConflict):
|
||||
conflict++
|
||||
default:
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if okN != 1 || conflict != n-1 {
|
||||
t.Fatalf("wins=%d conflicts=%d", okN, conflict)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordChangeIsAtomic(t *testing.T) {
|
||||
st := openLife(t)
|
||||
hash, err := HashPassword("old-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.CreateActivatedAccount(AccountWrite{Owner: "ada", Name: "Ada", PasswordHash: hash, Action: "account.create"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
plain, _, err := st.IssueLoginKey("ada", LoginKeyPurposeActivate, "test", 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st.SetAccountFault(fmt.Errorf("injected"))
|
||||
next, err := HashPassword("new-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.ApplyPasswordChange(AccountWrite{Owner: "ada", PasswordHash: next, Action: "account.password"}); err == nil {
|
||||
t.Fatal("faulted change returned success")
|
||||
}
|
||||
st.SetAccountFault(nil)
|
||||
if _, ok := st.VerifyLogin("ada", "old-password"); !ok {
|
||||
t.Fatal("old password did not survive the failed change")
|
||||
}
|
||||
if _, err := st.ApplyPasswordChange(AccountWrite{Owner: "ada", PasswordHash: next, Action: "account.password"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := st.RedeemLoginKey(plain); err == nil {
|
||||
t.Fatal("old key redeemed after password change")
|
||||
}
|
||||
if _, ok := st.VerifyLogin("ada", "new-password"); !ok {
|
||||
t.Fatal("new password missing")
|
||||
}
|
||||
n, err := st.CountAudit("ada", "rejected")
|
||||
if err != nil || n == 0 {
|
||||
t.Fatalf("rejected audit n=%d err=%v", n, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenExistingDoesNotMigrateOrCreate(t *testing.T) {
|
||||
missing := filepath.Join(t.TempDir(), "absent", "agent-system.db")
|
||||
if _, err := OpenExistingSQLite(missing); err == nil {
|
||||
t.Fatal("missing file opened")
|
||||
}
|
||||
if _, err := os.Stat(filepath.Dir(missing)); !os.IsNotExist(err) {
|
||||
t.Fatalf("parent created: %v", err)
|
||||
}
|
||||
path := filepath.Join(t.TempDir(), "agent-system.db")
|
||||
st, err := OpenSQLite(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := OpenExistingSQLite(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again.Close()
|
||||
}
|
||||
|
||||
func TestBootstrapRefusesExistingBusiness(t *testing.T) {
|
||||
st := openLife(t)
|
||||
if err := st.EnsureSystemIdentity("inst", "sqlite"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.CreateProject("owner", "p"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.BootstrapFirstAdminWrite(AccountWrite{Owner: "root", Name: "root"}); err == nil {
|
||||
t.Fatal("bootstrap accepted a database that already has a project")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPasswordSurvivesReopen(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "agent-system.db")
|
||||
st, err := OpenSQLite(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
hash, err := HashPassword("keep-password")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := st.CreateActivatedAccount(AccountWrite{Owner: "ada", Name: "Ada", PasswordHash: hash}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := st.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err = OpenSQLite(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer st.Close()
|
||||
if _, ok := st.VerifyLogin("ada", "keep-password"); !ok {
|
||||
t.Fatal("password did not survive reopen")
|
||||
}
|
||||
}
|
||||
|
||||
func openLife(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
st, err := OpenSQLite(filepath.Join(t.TempDir(), "agent-system.db"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { st.Close() })
|
||||
return st
|
||||
}
|
||||
+117
-26
@@ -218,18 +218,15 @@ func VerifyPassword(hash, password string) bool {
|
||||
return subtle.ConstantTimeCompare(got, want) == 1
|
||||
}
|
||||
|
||||
// CreateAccount 管理员建号:owner 唯一;初始密码 must_change=1。
|
||||
// CreateAccount 管理员建号:单事务整行 INSERT,owner 唯一冲突返回 ErrConflict。
|
||||
// passwordHash 非空时 must_change=1;空哈希表示未设密(走激活 Key),must_change=0。
|
||||
// (原子化:旧实现先查后插再 UPDATE,两个并发 create 同名会在 UPDATE 步互相覆盖
|
||||
// 角色与密码——R5/KTD12 并发建号不覆盖。)
|
||||
func (s *Store) CreateAccount(owner, name, passwordHash string, isAdmin bool) error {
|
||||
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)
|
||||
_, err := s.CreateActivatedAccount(AccountWrite{
|
||||
Owner: owner, Name: name, PasswordHash: passwordHash, IsAdmin: isAdmin,
|
||||
Action: "account.create",
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -280,49 +277,143 @@ 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
|
||||
err := s.db.QueryRow(s.q(`SELECT password_hash FROM users WHERE owner=?`), owner).Scan(&hash)
|
||||
var ver int
|
||||
err := s.db.QueryRow(s.q(`SELECT password_hash,auth_version FROM users WHERE owner=?`), owner).Scan(&hash, &ver)
|
||||
if err != nil || hash == "" || !VerifyPassword(hash, password) {
|
||||
return UserAuthState{}, false
|
||||
return UserAuthState{}, 0, false
|
||||
}
|
||||
st, err := s.GetUserAuthState(owner)
|
||||
if err != nil {
|
||||
return UserAuthState{}, false
|
||||
return UserAuthState{}, 0, false
|
||||
}
|
||||
return st, true
|
||||
return st, ver, true
|
||||
}
|
||||
|
||||
// ---- 会话 ----
|
||||
|
||||
// CreateSession 建会话:明文 token 仅此一次返回;库存 SHA-256 摘要与到期时间。
|
||||
// CreateSession 建会话:明文 token 仅此一次返回;库存 SHA-256 摘要与到期时间;scope=full。
|
||||
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(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
|
||||
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()
|
||||
}
|
||||
|
||||
// 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 owner, exp string
|
||||
err := s.db.QueryRow(s.q(`SELECT owner,expires_at FROM sessions WHERE token_hash=?`), hashKey(token)).Scan(&owner, &exp)
|
||||
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)
|
||||
if err != nil {
|
||||
return "", false
|
||||
return "", "", false
|
||||
}
|
||||
if sessionVersion != accountVersion {
|
||||
_, _ = s.db.Exec(s.q(`DELETE FROM sessions WHERE token_hash=? AND auth_version=?`), hashKey(token), sessionVersion)
|
||||
return "", "", false
|
||||
}
|
||||
if expT, err := time.Parse(time.RFC3339Nano, exp); err != nil || time.Now().After(expT) {
|
||||
s.db.Exec(s.q(`DELETE FROM sessions WHERE token_hash=?`), hashKey(token))
|
||||
return "", false
|
||||
return "", "", false
|
||||
}
|
||||
s.db.Exec(s.q(`UPDATE sessions SET last_seen_at=? WHERE token_hash=?`), nowISO(), hashKey(token))
|
||||
return owner, true
|
||||
if scope == "" {
|
||||
scope = SessionScopeFull
|
||||
}
|
||||
return owner, scope, true
|
||||
}
|
||||
|
||||
// DeleteSession 注销当前会话。
|
||||
|
||||
@@ -388,6 +388,66 @@ 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 {
|
||||
@@ -419,14 +479,23 @@ 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
|
||||
}
|
||||
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)
|
||||
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...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -449,12 +518,21 @@ func (s *Store) searchKnowledgeFTS(pred knowledgePred, q core.RetrievalQuery) ([
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
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)
|
||||
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...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -17,6 +17,11 @@ 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(嵌入式档)----
|
||||
@@ -28,6 +33,8 @@ 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(多用户/多实例档)----
|
||||
|
||||
@@ -63,3 +70,13 @@ 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -266,6 +266,10 @@ 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
|
||||
}
|
||||
@@ -298,6 +302,13 @@ func (s *Store) AppendEvents(conversationID string, evs []core.StreamEvent) (int
|
||||
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" {
|
||||
|
||||
+137
-1
@@ -712,9 +712,52 @@ 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(进程标识)无关。
|
||||
migration{version: 26, name: "agent_system_identity",
|
||||
// v26 已被 login_keys 占用,本表为 v27。
|
||||
migration{version: 27, name: "agent_system_identity",
|
||||
up: map[string][]string{"sqlite": {
|
||||
`CREATE TABLE IF NOT EXISTS agent_system_identity (
|
||||
instance_id TEXT NOT NULL PRIMARY KEY,
|
||||
@@ -735,6 +778,99 @@ 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`,
|
||||
}}},
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
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())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
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) }
|
||||
@@ -0,0 +1,83 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -53,19 +53,28 @@ 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
|
||||
rows, err := s.db.Query(s.q(`
|
||||
query := `
|
||||
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
|
||||
LIMIT ?`), text, requester, text, text, "%"+text+"%", "%"+text+"%", topK*2)
|
||||
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...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package store
|
||||
|
||||
type SecretInventoryEntry struct {
|
||||
Owner string
|
||||
Service string
|
||||
Count int
|
||||
}
|
||||
|
||||
// SecretInventory is a metadata-only administration/migration read, not a
|
||||
// credential export. It never selects value, suffix, or a value-derived digest.
|
||||
func (s *Store) SecretInventory() ([]SecretInventoryEntry, error) {
|
||||
rows, err := s.db.Query(`SELECT owner,service,COUNT(*) FROM secret_keys GROUP BY owner,service ORDER BY owner,service`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []SecretInventoryEntry{}
|
||||
for rows.Next() {
|
||||
var entry SecretInventoryEntry
|
||||
if err := rows.Scan(&entry.Owner, &entry.Service, &entry.Count); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, entry)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// UpdateSettingsScopes applies a migration's observation/result and target in
|
||||
// one transaction. additionalScope captures the complete legacy source set;
|
||||
// serializable isolation protects its predicate against concurrent legacy writes.
|
||||
// Unreturned rows remain untouched, so sources can be checked without rewriting.
|
||||
func (s *Store) UpdateSettingsScopes(refs []SettingsScopeRef, additionalScope string, update func(map[SettingsScopeRef]map[string]any) (map[SettingsScopeRef]map[string]any, error)) error {
|
||||
tx, err := s.db.BeginTx(context.Background(), &sql.TxOptions{Isolation: sql.LevelSerializable})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
unique := map[SettingsScopeRef]bool{}
|
||||
for _, ref := range refs {
|
||||
unique[ref] = true
|
||||
}
|
||||
if additionalScope != "" {
|
||||
rows, err := tx.Query(s.q(`SELECT owner,scope FROM settings WHERE scope=? AND owner<>? ORDER BY owner`), additionalScope, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
var ref SettingsScopeRef
|
||||
if err := rows.Scan(&ref.Owner, &ref.Scope); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
unique[ref] = true
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
rows.Close()
|
||||
}
|
||||
ordered := make([]SettingsScopeRef, 0, len(unique))
|
||||
for ref := range unique {
|
||||
ordered = append(ordered, ref)
|
||||
}
|
||||
sort.Slice(ordered, func(i, j int) bool {
|
||||
return ordered[i].Owner+"\x00"+ordered[i].Scope < ordered[j].Owner+"\x00"+ordered[j].Scope
|
||||
})
|
||||
current := map[SettingsScopeRef]map[string]any{}
|
||||
for _, ref := range ordered {
|
||||
if err := s.dialect.lockScope(tx, ref.Owner, ref.Scope); err != nil {
|
||||
return err
|
||||
}
|
||||
var payload string
|
||||
err := tx.QueryRow(s.q(`SELECT "values" FROM settings WHERE owner=? AND scope=?`), ref.Owner, ref.Scope).Scan(&payload)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
value := map[string]any{}
|
||||
if payload != "" {
|
||||
value, err = unmarshalJSON[map[string]any](payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if value == nil {
|
||||
value = map[string]any{}
|
||||
}
|
||||
}
|
||||
current[ref] = value
|
||||
}
|
||||
next, err := update(current)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for ref := range next {
|
||||
if !unique[ref] {
|
||||
return errors.New("settings transaction cannot write an unlocked scope")
|
||||
}
|
||||
}
|
||||
for _, ref := range ordered {
|
||||
value, write := next[ref]
|
||||
if !write || marshalJSON(value) == marshalJSON(current[ref]) {
|
||||
continue
|
||||
}
|
||||
if _, err := tx.Exec(s.q(`INSERT INTO settings(owner,scope,"values") VALUES(?,?,?) ON CONFLICT(owner,scope) DO UPDATE SET "values"=excluded."values"`), ref.Owner, ref.Scope, marshalJSON(value)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
+134
-9
@@ -77,6 +77,7 @@ type Store struct {
|
||||
|
||||
migrateMu sync.Mutex
|
||||
migrateFault error // 非空时 migratePass 在「DDL 已执行、版本未登记」处失败(原子性测试)
|
||||
accountFault error // 非空时账户事务在提交前失败(半态测试)
|
||||
|
||||
vecPGOnce sync.Once // pgvector 列探测(U6-3:一次性缓存)
|
||||
vecPG bool
|
||||
@@ -87,6 +88,103 @@ 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
|
||||
@@ -98,7 +196,7 @@ func OpenSQLite(path string) (*Store, error) {
|
||||
}
|
||||
// SQLite 单写者:串行化连接,语句级原子 + 版本化迁移事务。
|
||||
db.SetMaxOpenConns(1)
|
||||
return finishOpen(db, sqliteDialect{}, filepath.Join(filepath.Dir(path), "artifacts"), nil, PoolOptions{})
|
||||
return finishOpenMode(db, sqliteDialect{}, filepath.Join(filepath.Dir(path), "artifacts"), nil, PoolOptions{}, createArtifacts, migrateSchema)
|
||||
}
|
||||
|
||||
// OpenPostgres 打开 PostgreSQL 档存储并执行迁移(默认连接预算)。
|
||||
@@ -115,7 +213,17 @@ 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))
|
||||
@@ -130,7 +238,7 @@ func OpenPostgresWithOptions(dsn, artifactsDir string, pool PoolOptions, blobs b
|
||||
if pool.ConnMaxLifetimeSec > 0 {
|
||||
db.SetConnMaxLifetime(time.Duration(pool.ConnMaxLifetimeSec) * time.Second)
|
||||
}
|
||||
return finishOpen(db, postgresDialect{}, artifactsDir, blobs, pool)
|
||||
return finishOpenMode(db, postgresDialect{}, artifactsDir, blobs, pool, createArtifacts, migrateSchema)
|
||||
}
|
||||
|
||||
// withPGRuntimeParam 向 DSN 追加运行时参数(libpq 语义:URL 查询串或键值对)。
|
||||
@@ -156,23 +264,40 @@ func AppendPGRuntimeParam(dsn, key, value string) string {
|
||||
return dsn + sep + kv
|
||||
}
|
||||
|
||||
func finishOpen(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.Store, pool PoolOptions) (*Store, error) {
|
||||
if blobs == nil {
|
||||
// Preserve the existing initialization helper contract; maintenance opens select
|
||||
// nonmigrating mode explicitly through finishOpenMode.
|
||||
func finishOpen(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.Store, pool PoolOptions, createArtifacts bool) (*Store, error) {
|
||||
return finishOpenMode(db, d, artifactsDir, blobs, pool, createArtifacts, true)
|
||||
}
|
||||
|
||||
func finishOpenMode(db *sql.DB, d dialect, artifactsDir string, blobs blobstore.Store, pool PoolOptions, createArtifacts, migrateSchema bool) (*Store, error) {
|
||||
if blobstore.IsNil(blobs) {
|
||||
var err error
|
||||
blobs, err = blobstore.NewLocal(artifactsDir)
|
||||
if createArtifacts {
|
||||
blobs, err = blobstore.NewLocal(artifactsDir)
|
||||
} else {
|
||||
blobs, err = blobstore.OpenLocalExisting(artifactsDir)
|
||||
}
|
||||
if err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
s := &Store{db: db, dialect: d, dataDir: artifactsDir, blobs: blobs, pool: pool}
|
||||
if err := s.migrate(); err != nil {
|
||||
if migrateSchema {
|
||||
if err := s.migrate(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
} else if err := s.requireCurrentSchema(); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(artifactsDir, 0o755); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
if createArtifacts {
|
||||
if err := os.MkdirAll(artifactsDir, 0o755); err != nil {
|
||||
db.Close()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
@@ -12,6 +12,15 @@ 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
|
||||
@@ -43,7 +52,13 @@ func (s *Store) EnsureSystemIdentity(instanceID, dbref string) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
ver := latestMigrationVersion()
|
||||
ver, err := s.SchemaVersion()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if ver <= 0 {
|
||||
ver = 27
|
||||
}
|
||||
_, 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 {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
@@ -55,16 +58,101 @@ func TestSystemIdentityMigrationCreatesTable(t *testing.T) {
|
||||
}
|
||||
found := false
|
||||
for _, v := range versions {
|
||||
if v == 26 {
|
||||
if v == 27 {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("migration v26 not applied: %v", versions)
|
||||
t.Fatalf("migration v27 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, ",")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -121,7 +121,16 @@ func (s *Store) SearchKnowledgeVectorsSharedModel(ctx context.Context, requester
|
||||
return hits, nil
|
||||
}
|
||||
}
|
||||
return s.searchKnowledgeVectorsSharedScan(ctx, requester, model, vec, topK, filter, allow)
|
||||
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)
|
||||
}
|
||||
|
||||
// allowAll 判定谓词是否恒真(nil 或恒放行)——库内路径的启用条件。
|
||||
@@ -187,6 +196,9 @@ 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
|
||||
@@ -215,7 +227,7 @@ func (s *Store) searchKnowledgeVectorsSharedScan(ctx context.Context, requester,
|
||||
}
|
||||
d.Owner = own
|
||||
d.CreatedAt, _ = parseTime(ca)
|
||||
if d.ProjectID == "" || !allow(d.Owner, d.ProjectID) {
|
||||
if d.ProjectID == "" || !allow(d) {
|
||||
continue
|
||||
}
|
||||
if filter.ProjectID != "" && d.ProjectID != filter.ProjectID {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user